Phase 1c: OPKSSH cert auth, tmux session monitor/reattach, session logging
- terminal.ts: connectWithCertificate() shells out to system ssh via node-pty for OpenSSH certificate auth (ssh2 has no native support); list_tmux WS message + tmuxSession connect param for tmux attach/create with shell-injection-safe name validation; sessionLogging config field appends terminal output to disk. - Settings.tsx: certificate secret field and sessionLogging checkbox for SSH host integrations. - Terminal.tsx: tmux session picker in each pane's header. - Verified end-to-end against a real test SSH server running real bash/tmux processes (plain shell, tmux create+list, session log written to disk). Cert auth path type-checks but is unverified in this sandbox (no ssh CLI available) - documented as a gap in TERMIX_MIGRATION.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
This commit is contained in:
parent
94b174c72e
commit
27abbc8ce1
6 changed files with 272 additions and 45 deletions
|
|
@ -52,7 +52,11 @@ Rationale for splitting: 1a alone is a real, useful terminal (matches what `/ter
|
||||||
- **Tabs + up to 4 split panes**: `src/pages/Terminal.tsx` rewritten around a `TerminalPane` component (one xterm + WebSocket connection each, reusable). Each tab holds 1/2/4 panes (single / split-2 / 2x2 grid); each pane connects independently to whichever SSH host is clicked while it's focused.
|
- **Tabs + up to 4 split panes**: `src/pages/Terminal.tsx` rewritten around a `TerminalPane` component (one xterm + WebSocket connection each, reusable). Each tab holds 1/2/4 panes (single / split-2 / 2x2 grid); each pane connects independently to whichever SSH host is clicked while it's focused.
|
||||||
- **Terminal theme/font customization**: a preferences bar (theme preset, font size, font family) persisted to `localStorage` (`archnest-terminal-prefs`), applied per-pane on connect.
|
- **Terminal theme/font customization**: a preferences bar (theme preset, font size, font family) persisted to `localStorage` (`archnest-terminal-prefs`), applied per-pane on connect.
|
||||||
- Verified via a clean production build (`tsc -b && vite build`) — no real browser available in this environment to click through tabs/panes, so this is build/type verification only, not an interactive UI test.
|
- Verified via a clean production build (`tsc -b && vite build`) — no real browser available in this environment to click through tabs/panes, so this is build/type verification only, not an interactive UI test.
|
||||||
- ⬜ Phase 1c — not started.
|
- ✅ **Phase 1c — done, with one documented verification gap.**
|
||||||
|
- **OPKSSH / certificate auth**: `ssh2` (the npm library) has no support for OpenSSH certificates — confirmed by inspecting its type definitions and README, no certificate-related auth flow exists. Implemented `connectWithCertificate()` in `backend/src/routes/terminal.ts`: writes the stored private key + certificate to a temp dir (mode `0600`) and shells out to the system `ssh` binary (which natively understands `-o CertificateFile=`) under a real `node-pty` pty. Used automatically when an SSH integration has a `certificate` secret configured (new field added to Settings' SSH host form). Does **not** support jump-host chaining (documented limitation, not silently dropped — Termix's own OPKSSH path doesn't generally chain through jump hosts either). **Verification gap**: this sandbox has no `ssh` CLI installed (`apt-get install openssh-client` failed — mirror 404), so this path type-checks and is logically sound but has not been exercised end-to-end. Needs a real test against a cert-auth-enabled host before being considered fully verified; `openssh-client` is near-universal on real deployment targets, so this is a sandbox limitation, not an expected production gap.
|
||||||
|
- **tmux session monitor/reattach**: new WebSocket message `list_tmux` execs `tmux list-sessions` on the target host and returns session names; `connect` accepts an optional `tmuxSession` (validated against `^[A-Za-z0-9_-]{1,64}$` before being interpolated into a shell command, to prevent injection) which attaches to that tmux session or creates it if missing, via `exec('tmux attach -t <name> || tmux new-session -s <name>', { pty: ... })` instead of a plain `client.shell()`. `src/pages/Terminal.tsx`'s pane header gained a tmux session picker (plain shell / new session / attach to an existing one). **Verified end-to-end** against a real test SSH server running real `bash`/`tmux` processes (via `node-pty`): listed zero sessions, created a `testsess` tmux session through the WS protocol, confirmed a follow-up `list_tmux` call returned `['testsess']`.
|
||||||
|
- **Session recording/logging to disk**: new SSH integration config field `sessionLogging` (checkbox in Settings' SSH host form). When set, all outbound terminal output (both the `ssh2` path and the cert-auth pty path) is appended to `<ARCHNEST_SESSION_LOG_DIR ?? './data/session-logs'>/<integrationId>_<timestamp>.log`. No log browsing/download UI yet (not built — out of scope for this pass, not silently dropped). **Verified end-to-end**: a real shell session's output was confirmed present in its log file on disk.
|
||||||
|
- No real `ssh` CLI / no real OPKSSH certificate available in this sandbox to test against, see verification gap above. Everything else in this phase was tested against live processes, not mocked.
|
||||||
|
|
||||||
### Phase 2 — SSH Tunnels (NOT STARTED)
|
### Phase 2 — SSH Tunnels (NOT STARTED)
|
||||||
|
|
||||||
|
|
|
||||||
17
backend/package-lock.json
generated
17
backend/package-lock.json
generated
|
|
@ -18,6 +18,7 @@
|
||||||
"better-sqlite3": "^11.8.1",
|
"better-sqlite3": "^11.8.1",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
"fastify": "^5.2.1",
|
"fastify": "^5.2.1",
|
||||||
|
"node-pty": "^1.1.0",
|
||||||
"ssh2": "^1.17.0",
|
"ssh2": "^1.17.0",
|
||||||
"undici": "^8.5.0",
|
"undici": "^8.5.0",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
|
|
@ -2038,6 +2039,22 @@
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-addon-api": {
|
||||||
|
"version": "7.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||||
|
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/node-pty": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"node-addon-api": "^7.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/obliterator": {
|
"node_modules/obliterator": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
"better-sqlite3": "^11.8.1",
|
"better-sqlite3": "^11.8.1",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
"fastify": "^5.2.1",
|
"fastify": "^5.2.1",
|
||||||
|
"node-pty": "^1.1.0",
|
||||||
"ssh2": "^1.17.0",
|
"ssh2": "^1.17.0",
|
||||||
"undici": "^8.5.0",
|
"undici": "^8.5.0",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import type { FastifyInstance } from 'fastify'
|
import type { FastifyInstance } from 'fastify'
|
||||||
import { Client, type ClientChannel, type ConnectConfig } from 'ssh2'
|
import { Client, type ClientChannel, type ConnectConfig } from 'ssh2'
|
||||||
|
import { spawn as spawnPty, type IPty } from 'node-pty'
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, appendFileSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
import { db } from '../db/index.js'
|
import { db } from '../db/index.js'
|
||||||
import { loadSecrets } from '../db/secrets.js'
|
import { loadSecrets } from '../db/secrets.js'
|
||||||
|
|
||||||
|
|
@ -10,13 +14,18 @@ interface IntegrationRow {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClientMessage {
|
interface ClientMessage {
|
||||||
type: 'connect' | 'input' | 'resize' | 'disconnect'
|
type: 'connect' | 'input' | 'resize' | 'disconnect' | 'list_tmux'
|
||||||
integrationId?: number
|
integrationId?: number
|
||||||
cols?: number
|
cols?: number
|
||||||
rows?: number
|
rows?: number
|
||||||
data?: string
|
data?: string
|
||||||
|
tmuxSession?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TMUX_NAME_RE = /^[A-Za-z0-9_-]{1,64}$/
|
||||||
|
|
||||||
|
const SESSION_LOG_DIR = process.env.ARCHNEST_SESSION_LOG_DIR ?? './data/session-logs'
|
||||||
|
|
||||||
function send(socket: { send: (data: string) => void }, payload: Record<string, unknown>) {
|
function send(socket: { send: (data: string) => void }, payload: Record<string, unknown>) {
|
||||||
socket.send(JSON.stringify(payload))
|
socket.send(JSON.stringify(payload))
|
||||||
}
|
}
|
||||||
|
|
@ -31,6 +40,8 @@ function loadSshHost(integrationId: number) {
|
||||||
return { id: row.id, config, secrets }
|
return { id: row.id, config, secrets }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SshHost = NonNullable<ReturnType<typeof loadSshHost>>
|
||||||
|
|
||||||
function makeHostVerifier(integrationId: number): ConnectConfig['hostVerifier'] {
|
function makeHostVerifier(integrationId: number): ConnectConfig['hostVerifier'] {
|
||||||
return (keyHash: string): boolean => {
|
return (keyHash: string): boolean => {
|
||||||
const row = db
|
const row = db
|
||||||
|
|
@ -44,7 +55,7 @@ function makeHostVerifier(integrationId: number): ConnectConfig['hostVerifier']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function baseConnectConfig(host: ReturnType<typeof loadSshHost> extends infer T ? NonNullable<T> : never): ConnectConfig {
|
function baseConnectConfig(host: SshHost): ConnectConfig {
|
||||||
return {
|
return {
|
||||||
host: host.config.host,
|
host: host.config.host,
|
||||||
port: Number(host.config.port) || 22,
|
port: Number(host.config.port) || 22,
|
||||||
|
|
@ -58,19 +69,119 @@ function baseConnectConfig(host: ReturnType<typeof loadSshHost> extends infer T
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Connects to `target`, transparently chaining through its jump host (if configured) via forwardOut(). */
|
||||||
|
function connectTarget(
|
||||||
|
target: SshHost,
|
||||||
|
onReady: (client: Client) => void,
|
||||||
|
onError: (message: string) => void,
|
||||||
|
): { conn: Client; jumpConn: Client | null } {
|
||||||
|
const jumpHostId = target.config.jumpHostIntegrationId ? Number(target.config.jumpHostIntegrationId) : null
|
||||||
|
|
||||||
|
if (jumpHostId) {
|
||||||
|
const jumpHost = loadSshHost(jumpHostId)
|
||||||
|
if (!jumpHost) {
|
||||||
|
onError('Jump host integration not found')
|
||||||
|
return { conn: new Client(), jumpConn: null }
|
||||||
|
}
|
||||||
|
const jumpConn = new Client()
|
||||||
|
jumpConn.on('ready', () => {
|
||||||
|
jumpConn.forwardOut('127.0.0.1', 0, target.config.host, Number(target.config.port) || 22, (err, sock) => {
|
||||||
|
if (err) {
|
||||||
|
onError(`Jump host forward failed: ${err.message}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client.connect({ ...baseConnectConfig(target), sock })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
jumpConn.on('error', (err) => onError(`Jump host error: ${err.message}`))
|
||||||
|
const client = new Client()
|
||||||
|
client.on('ready', () => onReady(client))
|
||||||
|
client.on('error', (err) => onError(err.message))
|
||||||
|
jumpConn.connect(baseConnectConfig(jumpHost))
|
||||||
|
return { conn: client, jumpConn }
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new Client()
|
||||||
|
client.on('ready', () => onReady(client))
|
||||||
|
client.on('error', (err) => onError(err.message))
|
||||||
|
client.connect(baseConnectConfig(target))
|
||||||
|
return { conn: client, jumpConn: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OPKSSH / certificate auth has no support in the ssh2 library, so this path shells out to the
|
||||||
|
* system `ssh` binary (which natively understands OpenSSH certificates via CertificateFile) under
|
||||||
|
* a real pty. Jump-host chaining is not supported on this path. */
|
||||||
|
function connectWithCertificate(
|
||||||
|
target: SshHost,
|
||||||
|
cols: number,
|
||||||
|
rows: number,
|
||||||
|
onReady: (pty: IPty, keyDir: string) => void,
|
||||||
|
onError: (message: string) => void,
|
||||||
|
) {
|
||||||
|
const keyDir = mkdtempSync(join(tmpdir(), 'archnest-ssh-'))
|
||||||
|
const keyFile = join(keyDir, 'id_key')
|
||||||
|
const certFile = join(keyDir, 'id_key-cert.pub')
|
||||||
|
try {
|
||||||
|
writeFileSync(keyFile, target.secrets.privateKey ?? '', { mode: 0o600 })
|
||||||
|
writeFileSync(certFile, target.secrets.certificate ?? '', { mode: 0o600 })
|
||||||
|
} catch (err) {
|
||||||
|
rmSync(keyDir, { recursive: true, force: true })
|
||||||
|
onError(err instanceof Error ? err.message : 'Failed to write certificate files')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
'-tt',
|
||||||
|
'-p', String(Number(target.config.port) || 22),
|
||||||
|
'-i', keyFile,
|
||||||
|
'-o', `CertificateFile=${certFile}`,
|
||||||
|
'-o', 'StrictHostKeyChecking=accept-new',
|
||||||
|
'-o', `UserKnownHostsFile=${join(keyDir, 'known_hosts')}`,
|
||||||
|
`${target.config.username}@${target.config.host}`,
|
||||||
|
]
|
||||||
|
|
||||||
|
let pty: IPty
|
||||||
|
try {
|
||||||
|
pty = spawnPty('ssh', args, { name: 'xterm-256color', cols, rows })
|
||||||
|
} catch (err) {
|
||||||
|
rmSync(keyDir, { recursive: true, force: true })
|
||||||
|
onError(err instanceof Error ? `Failed to spawn ssh: ${err.message}` : 'Failed to spawn ssh')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onReady(pty, keyDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionLogPath(integrationId: number) {
|
||||||
|
mkdirSync(SESSION_LOG_DIR, { recursive: true })
|
||||||
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||||
|
return join(SESSION_LOG_DIR, `${integrationId}_${stamp}.log`)
|
||||||
|
}
|
||||||
|
|
||||||
export async function terminalRoutes(app: FastifyInstance) {
|
export async function terminalRoutes(app: FastifyInstance) {
|
||||||
app.get('/api/terminal', { websocket: true }, (socket, req) => {
|
app.get('/api/terminal', { websocket: true }, (socket, req) => {
|
||||||
let conn: Client | null = null
|
let conn: Client | null = null
|
||||||
let jumpConn: Client | null = null
|
let jumpConn: Client | null = null
|
||||||
let stream: ClientChannel | null = null
|
let stream: ClientChannel | null = null
|
||||||
|
let pty: IPty | null = null
|
||||||
|
let ptyKeyDir: string | null = null
|
||||||
|
let logPath: string | null = null
|
||||||
|
|
||||||
|
const logData = (data: string) => {
|
||||||
|
if (logPath) appendFileSync(logPath, data)
|
||||||
|
}
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
stream?.end()
|
stream?.end()
|
||||||
conn?.end()
|
conn?.end()
|
||||||
jumpConn?.end()
|
jumpConn?.end()
|
||||||
|
pty?.kill()
|
||||||
|
if (ptyKeyDir) rmSync(ptyKeyDir, { recursive: true, force: true })
|
||||||
stream = null
|
stream = null
|
||||||
conn = null
|
conn = null
|
||||||
jumpConn = null
|
jumpConn = null
|
||||||
|
pty = null
|
||||||
|
ptyKeyDir = null
|
||||||
|
logPath = null
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.on('close', cleanup)
|
socket.on('close', cleanup)
|
||||||
|
|
@ -84,7 +195,7 @@ export async function terminalRoutes(app: FastifyInstance) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.type === 'connect') {
|
if (msg.type === 'connect' || msg.type === 'list_tmux') {
|
||||||
const query = req.query as { token?: string }
|
const query = req.query as { token?: string }
|
||||||
try {
|
try {
|
||||||
await app.jwt.verify(query.token ?? '')
|
await app.jwt.verify(query.token ?? '')
|
||||||
|
|
@ -100,68 +211,106 @@ export async function terminalRoutes(app: FastifyInstance) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const startShell = (client: Client) => {
|
if (msg.type === 'list_tmux') {
|
||||||
|
const { conn: ephemeralConn, jumpConn: ephemeralJump } = connectTarget(
|
||||||
|
target,
|
||||||
|
(client) => {
|
||||||
|
client.exec("command -v tmux >/dev/null && tmux list-sessions -F '#S' 2>/dev/null", (err, ch) => {
|
||||||
|
if (err) {
|
||||||
|
send(socket, { type: 'tmux_sessions', sessions: [] })
|
||||||
|
client.end()
|
||||||
|
ephemeralJump?.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let out = ''
|
||||||
|
ch.on('data', (chunk: Buffer) => (out += chunk.toString('utf8')))
|
||||||
|
ch.on('close', () => {
|
||||||
|
const sessions = out.split('\n').map((s) => s.trim()).filter(Boolean)
|
||||||
|
send(socket, { type: 'tmux_sessions', sessions })
|
||||||
|
client.end()
|
||||||
|
ephemeralJump?.end()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
(message) => send(socket, { type: 'tmux_sessions', sessions: [], error: message }),
|
||||||
|
)
|
||||||
|
void ephemeralConn
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const cols = msg.cols ?? 80
|
||||||
|
const rows = msg.rows ?? 24
|
||||||
|
|
||||||
|
if (target.secrets.certificate) {
|
||||||
|
connectWithCertificate(
|
||||||
|
target,
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
(p, keyDir) => {
|
||||||
|
pty = p
|
||||||
|
ptyKeyDir = keyDir
|
||||||
|
if (target.config.sessionLogging === 'true') logPath = sessionLogPath(target.id)
|
||||||
|
send(socket, { type: 'connected' })
|
||||||
|
p.onData((data) => {
|
||||||
|
logData(data)
|
||||||
|
send(socket, { type: 'data', data })
|
||||||
|
})
|
||||||
|
p.onExit(() => {
|
||||||
|
send(socket, { type: 'closed' })
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
(message) => send(socket, { type: 'error', message }),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const startSession = (client: Client) => {
|
||||||
conn = client
|
conn = client
|
||||||
client.shell({ cols: msg.cols ?? 80, rows: msg.rows ?? 24, term: 'xterm-256color' }, (err, ch) => {
|
const tmuxSession = msg.tmuxSession && TMUX_NAME_RE.test(msg.tmuxSession) ? msg.tmuxSession : null
|
||||||
|
const onChannel = (err: Error | undefined, ch: ClientChannel) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
send(socket, { type: 'error', message: err.message })
|
send(socket, { type: 'error', message: err.message })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
stream = ch
|
stream = ch
|
||||||
|
if (target.config.sessionLogging === 'true') logPath = sessionLogPath(target.id)
|
||||||
send(socket, { type: 'connected' })
|
send(socket, { type: 'connected' })
|
||||||
ch.on('data', (chunk: Buffer) => send(socket, { type: 'data', data: chunk.toString('utf8') }))
|
ch.on('data', (chunk: Buffer) => {
|
||||||
|
const text = chunk.toString('utf8')
|
||||||
|
logData(text)
|
||||||
|
send(socket, { type: 'data', data: text })
|
||||||
|
})
|
||||||
ch.stderr.on('data', (chunk: Buffer) => send(socket, { type: 'data', data: chunk.toString('utf8') }))
|
ch.stderr.on('data', (chunk: Buffer) => send(socket, { type: 'data', data: chunk.toString('utf8') }))
|
||||||
ch.on('close', () => {
|
ch.on('close', () => {
|
||||||
send(socket, { type: 'closed' })
|
send(socket, { type: 'closed' })
|
||||||
cleanup()
|
cleanup()
|
||||||
})
|
})
|
||||||
})
|
}
|
||||||
|
if (tmuxSession) {
|
||||||
|
client.exec(`tmux attach -t ${tmuxSession} || tmux new-session -s ${tmuxSession}`, {
|
||||||
|
pty: { cols, rows, term: 'xterm-256color' },
|
||||||
|
}, onChannel)
|
||||||
|
} else {
|
||||||
|
client.shell({ cols, rows, term: 'xterm-256color' }, onChannel)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const jumpHostId = target.config.jumpHostIntegrationId ? Number(target.config.jumpHostIntegrationId) : null
|
const result = connectTarget(target, startSession, (message) => send(socket, { type: 'error', message }))
|
||||||
|
conn = result.conn
|
||||||
if (jumpHostId) {
|
jumpConn = result.jumpConn
|
||||||
const jumpHost = loadSshHost(jumpHostId)
|
|
||||||
if (!jumpHost) {
|
|
||||||
send(socket, { type: 'error', message: 'Jump host integration not found' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jumpConn = new Client()
|
|
||||||
jumpConn.on('ready', () => {
|
|
||||||
jumpConn!.forwardOut('127.0.0.1', 0, target.config.host, Number(target.config.port) || 22, (err, sock) => {
|
|
||||||
if (err) {
|
|
||||||
send(socket, { type: 'error', message: `Jump host forward failed: ${err.message}` })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const client = new Client()
|
|
||||||
client.on('ready', () => startShell(client))
|
|
||||||
client.on('error', (err2) => send(socket, { type: 'error', message: err2.message }))
|
|
||||||
client.connect({ ...baseConnectConfig(target), sock })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
jumpConn.on('error', (err) => {
|
|
||||||
send(socket, { type: 'error', message: `Jump host error: ${err.message}` })
|
|
||||||
})
|
|
||||||
jumpConn.connect(baseConnectConfig(jumpHost))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = new Client()
|
|
||||||
client.on('ready', () => startShell(client))
|
|
||||||
client.on('error', (err) => {
|
|
||||||
send(socket, { type: 'error', message: err.message })
|
|
||||||
})
|
|
||||||
client.connect(baseConnectConfig(target))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.type === 'input') {
|
if (msg.type === 'input') {
|
||||||
stream?.write(msg.data ?? '')
|
if (pty) pty.write(msg.data ?? '')
|
||||||
|
else stream?.write(msg.data ?? '')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.type === 'resize') {
|
if (msg.type === 'resize') {
|
||||||
stream?.setWindow(msg.rows ?? 24, msg.cols ?? 80, 0, 0)
|
if (pty) pty.resize(msg.cols ?? 80, msg.rows ?? 24)
|
||||||
|
else stream?.setWindow(msg.rows ?? 24, msg.cols ?? 80, 0, 0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ const sshFields: FieldDef[] = [
|
||||||
{ key: 'password', label: 'Password', secret: true },
|
{ key: 'password', label: 'Password', secret: true },
|
||||||
{ key: 'privateKey', label: 'Private Key (PEM)', secret: true },
|
{ key: 'privateKey', label: 'Private Key (PEM)', secret: true },
|
||||||
{ key: 'passphrase', label: 'Key Passphrase', secret: true },
|
{ key: 'passphrase', label: 'Key Passphrase', secret: true },
|
||||||
|
{ key: 'certificate', label: 'OPKSSH Certificate (id_key-cert.pub)', secret: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
const cardBase: React.CSSProperties = {
|
const cardBase: React.CSSProperties = {
|
||||||
|
|
@ -401,6 +402,7 @@ function SshHostsSection() {
|
||||||
const fieldsWithJumpHost = (): FieldDef[] => [
|
const fieldsWithJumpHost = (): FieldDef[] => [
|
||||||
...sshFields,
|
...sshFields,
|
||||||
{ key: 'jumpHostIntegrationId', label: 'Jump Host (optional)' },
|
{ key: 'jumpHostIntegrationId', label: 'Jump Host (optional)' },
|
||||||
|
{ key: 'sessionLogging', label: 'Record session to disk' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function buildPayload(fields: FieldDef[], values: Record<string, string>) {
|
function buildPayload(fields: FieldDef[], values: Record<string, string>) {
|
||||||
|
|
@ -498,6 +500,18 @@ function SshHostsSection() {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (f.key === 'sessionLogging') {
|
||||||
|
const savedValue = existing?.config[f.key] === 'true'
|
||||||
|
const value = values[f.key] !== undefined ? values[f.key] === 'true' : savedValue
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex items-end pb-1.5">
|
||||||
|
<label className="flex items-center gap-2 text-xs" style={{ color: '#E8E6E0' }}>
|
||||||
|
<input type="checkbox" checked={value} onChange={(e) => onChange(f.key, e.target.checked ? 'true' : 'false')} />
|
||||||
|
{f.label}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
const isRevealed = revealed.has(key)
|
const isRevealed = revealed.has(key)
|
||||||
const savedValue = f.secret ? '' : existing?.config[f.key] ?? ''
|
const savedValue = f.secret ? '' : existing?.config[f.key] ?? ''
|
||||||
const value = values[f.key] ?? savedValue
|
const value = values[f.key] ?? savedValue
|
||||||
|
|
|
||||||
|
|
@ -291,6 +291,8 @@ function TerminalPane({
|
||||||
onFocus: () => void
|
onFocus: () => void
|
||||||
}) {
|
}) {
|
||||||
const [connected, setConnected] = useState(false)
|
const [connected, setConnected] = useState(false)
|
||||||
|
const [tmuxSessions, setTmuxSessions] = useState<string[]>([])
|
||||||
|
const [selectedTmux, setSelectedTmux] = useState('')
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const termRef = useRef<XTerm | null>(null)
|
const termRef = useRef<XTerm | null>(null)
|
||||||
const fitRef = useRef<FitAddon | null>(null)
|
const fitRef = useRef<FitAddon | null>(null)
|
||||||
|
|
@ -330,11 +332,28 @@ function TerminalPane({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hostId === null || hostId === lastHostIdRef.current) return
|
if (hostId === null || hostId === lastHostIdRef.current) return
|
||||||
lastHostIdRef.current = hostId
|
lastHostIdRef.current = hostId
|
||||||
|
setSelectedTmux('')
|
||||||
|
fetchTmuxSessions(hostId)
|
||||||
connect(hostId)
|
connect(hostId)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [hostId])
|
}, [hostId])
|
||||||
|
|
||||||
function connect(id: number) {
|
function fetchTmuxSessions(id: number) {
|
||||||
|
setTmuxSessions([])
|
||||||
|
const token = getToken()
|
||||||
|
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||||
|
const ws = new WebSocket(`${proto}://${window.location.host}/api/terminal?token=${encodeURIComponent(token ?? '')}`)
|
||||||
|
ws.onopen = () => ws.send(JSON.stringify({ type: 'list_tmux', integrationId: id }))
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const msg = JSON.parse(event.data)
|
||||||
|
if (msg.type === 'tmux_sessions') {
|
||||||
|
setTmuxSessions(msg.sessions ?? [])
|
||||||
|
ws.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect(id: number, tmuxSession?: string) {
|
||||||
wsRef.current?.close()
|
wsRef.current?.close()
|
||||||
setConnected(false)
|
setConnected(false)
|
||||||
const term = termRef.current
|
const term = termRef.current
|
||||||
|
|
@ -348,7 +367,7 @@ function TerminalPane({
|
||||||
wsRef.current = ws
|
wsRef.current = ws
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
ws.send(JSON.stringify({ type: 'connect', integrationId: id, cols: term.cols, rows: term.rows }))
|
ws.send(JSON.stringify({ type: 'connect', integrationId: id, cols: term.cols, rows: term.rows, tmuxSession }))
|
||||||
}
|
}
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
const msg = JSON.parse(event.data)
|
const msg = JSON.parse(event.data)
|
||||||
|
|
@ -389,6 +408,29 @@ function TerminalPane({
|
||||||
<div className="mb-2 flex items-center gap-2 px-1 text-xs" style={{ color: TEXT_SECONDARY }}>
|
<div className="mb-2 flex items-center gap-2 px-1 text-xs" style={{ color: TEXT_SECONDARY }}>
|
||||||
<span className="inline-block h-2 w-2 rounded-full" style={{ background: connected ? '#2ECC71' : '#7A7D85' }} />
|
<span className="inline-block h-2 w-2 rounded-full" style={{ background: connected ? '#2ECC71' : '#7A7D85' }} />
|
||||||
{host ? (connected ? `Connected — ${host.name}` : `Disconnected — ${host.name}`) : 'Select a host to connect'}
|
{host ? (connected ? `Connected — ${host.name}` : `Disconnected — ${host.name}`) : 'Select a host to connect'}
|
||||||
|
{host && (
|
||||||
|
<select
|
||||||
|
value={selectedTmux}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onChange={(e) => {
|
||||||
|
const raw = e.target.value
|
||||||
|
const value = raw === '__new__' ? `archnest-${Date.now().toString(36)}` : raw
|
||||||
|
setSelectedTmux(raw)
|
||||||
|
connect(host.id, value || undefined)
|
||||||
|
}}
|
||||||
|
className="ml-auto rounded-md border border-white/10 bg-transparent px-1.5 py-0.5"
|
||||||
|
style={{ color: TEXT_SECONDARY, fontSize: '11px' }}
|
||||||
|
title="Attach to a tmux session on this host"
|
||||||
|
>
|
||||||
|
<option value="">Plain shell</option>
|
||||||
|
<option value="__new__">New tmux session</option>
|
||||||
|
{tmuxSessions.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
tmux: {s}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div ref={containerRef} className="min-h-0 flex-1" />
|
<div ref={containerRef} className="min-h-0 flex-1" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue