OpenTerminal v0.2.1: terminal suite with SSH, split panes, themes and keyword highlighting
- Local terminal (node-pty) + SSH sessions (ssh2) with unified data plane - dockview split panes, per-group '+' tab bar button, layout templates - Session replay buffer (late-subscriber catch-up), host key pinning, credential encryption via safeStorage - 12 builtin themes + custom theme editor, system font enumeration - Keyword highlighting: regex rules with priority, ANSI truecolor injection, chunk-boundary-safe stream (carry-over of partial escape sequences) - Dark antd theme app-wide, settings dialog redesign, error boundary - Tests: ssh loopback, session e2e, highlight split-chunk regression
This commit is contained in:
commit
551723c2d4
58 files changed
+11929
No files matched your search
@@ -0,0 +1,8 @@
|
||||
import { BrowserWindow } from 'electron'
|
||||
|
||||
/** Send `payload` to every live BrowserWindow. */
|
||||
export function broadcast(channel: string, ...args: unknown[]): void {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) win.webContents.send(channel, ...args)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* SSH connection bookmarks. Persists to <userData>/connections.json as an array.
|
||||
*
|
||||
* Secret fields (password / keyContent / passphrase) are persisted only
|
||||
* encrypted via Electron safeStorage (base64 blob in `*_enc` fields). The
|
||||
* renderer contracts (`SshConnection`) never contain a secret: `*_enc` fields
|
||||
* are stripped from the returned objects and replaced with `savedAuth` flags.
|
||||
*
|
||||
* When safeStorage is unavailable (e.g. during headless dev) the plaintext
|
||||
* secret is base64'd and prefixed with `plain:` so the record stays
|
||||
* serialisable — a dev-only fallback surfaced via console.warn.
|
||||
*/
|
||||
|
||||
import { safeStorage } from 'electron'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { dirname } from 'path'
|
||||
import type { SshAuthMethod, SshConnection, SshConnectionInput } from '../shared/connections'
|
||||
|
||||
const ENC_SUFFIX = '_enc'
|
||||
const PLAIN_PREFIX = 'plain:'
|
||||
const SECRET_KEYS = ['password', 'keyContent', 'passphrase'] as const
|
||||
|
||||
/** Internal persisted record: public fields + encrypted secret fields. */
|
||||
interface StoredConnection {
|
||||
id: string
|
||||
name: string
|
||||
group?: string
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
auth: SshAuthMethod
|
||||
askPasswordAtConnect: boolean
|
||||
askPassphraseAtConnect: boolean
|
||||
keyPath?: string
|
||||
keepaliveIntervalSec: number
|
||||
createdAt: number
|
||||
lastConnectedAt?: number
|
||||
password_enc?: string
|
||||
keyContent_enc?: string
|
||||
passphrase_enc?: string
|
||||
}
|
||||
|
||||
/** Public fields of SshConnectionInput reflected onto a StoredConnection. */
|
||||
const PUBLIC_KEYS = [
|
||||
'name',
|
||||
'group',
|
||||
'host',
|
||||
'port',
|
||||
'username',
|
||||
'auth',
|
||||
'askPasswordAtConnect',
|
||||
'askPassphraseAtConnect',
|
||||
'keyPath',
|
||||
'keepaliveIntervalSec'
|
||||
] as const satisfies readonly (keyof Omit<StoredConnection, 'password_enc' | 'keyContent_enc' | 'passphrase_enc'>)[]
|
||||
|
||||
function encrypt(plain: string): string {
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
return safeStorage.encryptString(plain).toString('base64')
|
||||
}
|
||||
// Dev fallback: no OS keychain, store a reversible base64 marker so the
|
||||
// field still round-trips through JSON.
|
||||
console.warn(
|
||||
'[connections] safeStorage unavailable - storing plain: prefixed base64 secret (dev only)'
|
||||
)
|
||||
return PLAIN_PREFIX + Buffer.from(plain, 'utf8').toString('base64')
|
||||
}
|
||||
|
||||
function decrypt(stored: string | undefined): string | undefined {
|
||||
if (typeof stored !== 'string' || stored.length === 0) return undefined
|
||||
if (stored.startsWith(PLAIN_PREFIX)) {
|
||||
return Buffer.from(stored.slice(PLAIN_PREFIX.length), 'base64').toString('utf8')
|
||||
}
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
try {
|
||||
return safeStorage.decryptString(Buffer.from(stored, 'base64'))
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Strip secret fields from an internal record into the renderer contract. */
|
||||
function toPublic(stored: StoredConnection): SshConnection {
|
||||
return {
|
||||
id: stored.id,
|
||||
name: stored.name,
|
||||
group: stored.group,
|
||||
host: stored.host,
|
||||
port: stored.port,
|
||||
username: stored.username,
|
||||
auth: stored.auth,
|
||||
askPasswordAtConnect: stored.askPasswordAtConnect,
|
||||
askPassphraseAtConnect: stored.askPassphraseAtConnect,
|
||||
keyPath: stored.keyPath,
|
||||
keepaliveIntervalSec: stored.keepaliveIntervalSec,
|
||||
createdAt: stored.createdAt,
|
||||
lastConnectedAt: stored.lastConnectedAt,
|
||||
savedAuth: {
|
||||
hasPassword: stored.password_enc !== undefined && stored.password_enc.length > 0,
|
||||
hasKeyContent: stored.keyContent_enc !== undefined && stored.keyContent_enc.length > 0,
|
||||
hasPassphrase: stored.passphrase_enc !== undefined && stored.passphrase_enc.length > 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ConnectionsStore {
|
||||
constructor(private readonly filePath: string) {}
|
||||
|
||||
private load(): StoredConnection[] {
|
||||
try {
|
||||
const raw: unknown = JSON.parse(readFileSync(this.filePath, 'utf8'))
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.filter(
|
||||
(x): x is StoredConnection =>
|
||||
x !== null &&
|
||||
typeof x === 'object' &&
|
||||
typeof (x as StoredConnection).id === 'string' &&
|
||||
typeof (x as StoredConnection).host === 'string'
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// missing / corrupted file -> start fresh
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
private save(list: StoredConnection[]): void {
|
||||
mkdirSync(dirname(this.filePath), { recursive: true })
|
||||
writeFileSync(this.filePath, JSON.stringify(list, null, 2), 'utf8')
|
||||
}
|
||||
|
||||
listConnections(): SshConnection[] {
|
||||
return this.load().map(toPublic)
|
||||
}
|
||||
|
||||
/** Decrypt a stored secret for a connection (undefined when absent). */
|
||||
|
||||
/** Decrypt a stored secret for a connection (undefined when absent). */
|
||||
getSecret(
|
||||
conn: SshConnection,
|
||||
field: 'password' | 'keyContent' | 'passphrase'
|
||||
): string | undefined {
|
||||
const encField = `${field}${ENC_SUFFIX}` as keyof StoredConnection
|
||||
const stored = this.load().find((c) => c.id === conn.id)
|
||||
return stored ? decrypt(stored[encField] as string | undefined) : undefined
|
||||
}
|
||||
|
||||
saveConnection(input: SshConnectionInput): SshConnection {
|
||||
const list = this.load()
|
||||
let stored: StoredConnection | undefined
|
||||
|
||||
if (typeof input.id === 'string' && input.id.length > 0) {
|
||||
stored = list.find((c) => c.id === input.id)
|
||||
}
|
||||
|
||||
if (stored) {
|
||||
// Update in place; omitted secrets keep their previously stored value.
|
||||
const target = stored as unknown as Record<string, unknown>
|
||||
const source = input as unknown as Record<string, unknown>
|
||||
for (const key of PUBLIC_KEYS) {
|
||||
target[key] = source[key]
|
||||
}
|
||||
} else {
|
||||
stored = {
|
||||
id: input.id && input.id.length > 0 ? input.id : randomUUID(),
|
||||
name: input.name,
|
||||
group: input.group,
|
||||
host: input.host,
|
||||
port: input.port,
|
||||
username: input.username,
|
||||
auth: input.auth,
|
||||
askPasswordAtConnect: input.askPasswordAtConnect,
|
||||
askPassphraseAtConnect: input.askPassphraseAtConnect,
|
||||
keyPath: input.keyPath,
|
||||
keepaliveIntervalSec: input.keepaliveIntervalSec,
|
||||
createdAt: Date.now()
|
||||
}
|
||||
list.push(stored)
|
||||
}
|
||||
|
||||
// Encrypt new secret values; absent secrets leave the previous value intact.
|
||||
const target = stored as unknown as Record<string, unknown>
|
||||
for (const key of SECRET_KEYS) {
|
||||
const value = input[key]
|
||||
if (value === undefined) continue
|
||||
if (value === '') {
|
||||
delete target[`${key}${ENC_SUFFIX}`]
|
||||
} else {
|
||||
target[`${key}${ENC_SUFFIX}`] = encrypt(value)
|
||||
}
|
||||
}
|
||||
|
||||
this.save(list)
|
||||
return toPublic(stored)
|
||||
}
|
||||
|
||||
deleteConnection(id: string): void {
|
||||
const list = this.load()
|
||||
const next = list.filter((c) => c.id !== id)
|
||||
if (next.length < list.length) this.save(next)
|
||||
}
|
||||
|
||||
/** Mark a connection as recently used. */
|
||||
touch(id: string, at = Date.now()): void {
|
||||
const list = this.load()
|
||||
const conn = list.find((c) => c.id === id)
|
||||
if (!conn) return
|
||||
conn.lastConnectedAt = at
|
||||
this.save(list)
|
||||
}
|
||||
}
|
||||
|
||||
/** Default location: <userData>/connections.json */
|
||||
export function defaultConnectionsPath(userDataPath: string): string {
|
||||
return `${userDataPath}/connections.json`
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { app, BrowserWindow, shell } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { registerIpc } from './ipc'
|
||||
import { killAllPtys } from './pty'
|
||||
|
||||
function createWindow(): void {
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
minWidth: 720,
|
||||
minHeight: 480,
|
||||
show: false,
|
||||
backgroundColor: '#1e1e1e',
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false
|
||||
}
|
||||
})
|
||||
|
||||
win.on('ready-to-show', () => win.show())
|
||||
|
||||
// Surface renderer console errors in the dev terminal for diagnosis.
|
||||
win.webContents.on('console-message', (event) => {
|
||||
if (event.level === 'error') {
|
||||
console.error(`[renderer] ${event.message}`)
|
||||
}
|
||||
})
|
||||
|
||||
win.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
const devUrl = process.env['ELECTRON_RENDERER_URL']
|
||||
if (devUrl) {
|
||||
win.loadURL(devUrl)
|
||||
} else {
|
||||
win.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
registerIpc()
|
||||
createWindow()
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
})
|
||||
|
||||
app.on('before-quit', () => {
|
||||
killAllPtys()
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit()
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { app, ipcMain } from 'electron'
|
||||
import fontList from 'font-list'
|
||||
import { homedir } from 'os'
|
||||
import { Ipc, type AppInfo, type LayoutMeta, type PtyCreateOptions } from '../shared/ipc'
|
||||
import type { HostKeyAction, SessionOpenOptions, SshConnection, SshConnectionInput } from '../shared/connections'
|
||||
import { getLayout, listLayouts, saveLayout, deleteLayout } from './layouts'
|
||||
import { createPty, killPty, resizePty, writePty, openSession, configureSessionRuntime, getSessionReplay } from './pty'
|
||||
import { registerSettingsIpc } from './settingsStore'
|
||||
import { ConnectionsStore, defaultConnectionsPath } from './connectionsStore'
|
||||
import { KnownHostsStore, defaultKnownHostsPath } from './knownHosts'
|
||||
import { resolveHostKey } from './ssh'
|
||||
import { broadcast } from './broadcast'
|
||||
|
||||
export function registerIpc(): void {
|
||||
const connectionsStore = new ConnectionsStore(defaultConnectionsPath(app.getPath('userData')))
|
||||
const knownHostsStore = new KnownHostsStore(defaultKnownHostsPath(app.getPath('userData')))
|
||||
|
||||
// Runtime deps for the session layer (pty.ts routes into ssh.ts, which stays
|
||||
// Electron-free).
|
||||
configureSessionRuntime({
|
||||
broadcast: (channel, ...args) => broadcast(channel, ...args),
|
||||
getConnection: (connectionId) => {
|
||||
const found = connectionsStore.listConnections().find((c) => c.id === connectionId)
|
||||
if (!found) throw new Error(`连接书签不存在 (${connectionId})`)
|
||||
return found
|
||||
},
|
||||
getSecret: (conn, field) => connectionsStore.getSecret(conn, field),
|
||||
touch: (id) => connectionsStore.touch(id),
|
||||
knownHosts: {
|
||||
check: (host, port, key) => knownHostsStore.check(host, port, key),
|
||||
accept: (host, port, key, fingerprint) => knownHostsStore.accept(host, port, key, fingerprint)
|
||||
},
|
||||
promptHostKey: (prompt) => broadcast(Ipc.HOSTKEY_PROMPT, prompt)
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
Ipc.APP_INFO,
|
||||
(): AppInfo => ({ platform: process.platform, appVersion: app.getVersion(), homeDir: homedir() })
|
||||
)
|
||||
|
||||
ipcMain.handle(Ipc.PTY_CREATE, (_event, opts?: PtyCreateOptions) => createPty(opts))
|
||||
ipcMain.handle(Ipc.SESSION_OPEN, (_event, opts: SessionOpenOptions) => openSession(opts))
|
||||
ipcMain.handle(Ipc.SESSION_REPLAY, (_event, id: string) => getSessionReplay(id))
|
||||
ipcMain.on(Ipc.PTY_WRITE, (_event, id: string, data: string) => writePty(id, data))
|
||||
ipcMain.on(Ipc.PTY_RESIZE, (_event, id: string, cols: number, rows: number) =>
|
||||
resizePty(id, cols, rows)
|
||||
)
|
||||
ipcMain.on(Ipc.PTY_KILL, (_event, id: string) => killPty(id))
|
||||
|
||||
// ---- ssh connections (bookmarks) ----
|
||||
ipcMain.handle(Ipc.CONNECTIONS_LIST, (): SshConnection[] => connectionsStore.listConnections())
|
||||
ipcMain.handle(Ipc.CONNECTIONS_SAVE, (_event, input: SshConnectionInput): SshConnection =>
|
||||
connectionsStore.saveConnection(input)
|
||||
)
|
||||
ipcMain.handle(Ipc.CONNECTIONS_DELETE, (_event, id: string) => {
|
||||
connectionsStore.deleteConnection(id)
|
||||
})
|
||||
|
||||
// HOSTKEY_PROMPT is broadcast; the renderer answers here (send, not handle).
|
||||
ipcMain.on(Ipc.HOSTKEY_RESPOND, (_event, promptId: string, action: HostKeyAction) => {
|
||||
resolveHostKey(promptId, action)
|
||||
})
|
||||
|
||||
ipcMain.handle(Ipc.FONTS_LIST, async () => {
|
||||
try {
|
||||
return await fontList.getFonts({ disableQuoting: true })
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(Ipc.LAYOUTS_LIST, (): LayoutMeta[] => listLayouts())
|
||||
ipcMain.handle(Ipc.LAYOUTS_GET, (_event, id: string) => getLayout(id))
|
||||
ipcMain.handle(Ipc.LAYOUTS_SAVE, (_event, meta: LayoutMeta, json: string) =>
|
||||
saveLayout(meta, json)
|
||||
)
|
||||
ipcMain.handle(Ipc.LAYOUTS_DELETE, (_event, id: string) => deleteLayout(id))
|
||||
|
||||
registerSettingsIpc()
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Host key pinning store. Persists to <userData>/ssh_known_hosts.json.
|
||||
* No Electron imports here: the file location is injected so the module can be
|
||||
* reused by the loopback test harness (and any future main-process unit tests).
|
||||
*/
|
||||
|
||||
import { createHash, randomUUID } from 'crypto'
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { dirname } from 'path'
|
||||
|
||||
export interface KnownHostEntry {
|
||||
/** uuid; addedAt is split out so fingerprint clash updates can be precise */
|
||||
id: string
|
||||
host: string
|
||||
port: number
|
||||
/** raw ssh wire-format host key (base64, no padding) */
|
||||
keyBase64: string
|
||||
/** 'SHA256:' + base64(sha256(key)) without padding, OpenSSH style */
|
||||
fingerprint: string
|
||||
addedAt: number
|
||||
}
|
||||
|
||||
export interface KnownHostsStoreShape {
|
||||
version: 1
|
||||
entries: KnownHostEntry[]
|
||||
}
|
||||
|
||||
export type HostKeyCheckResult =
|
||||
| { status: 'match'; entry: KnownHostEntry }
|
||||
| { status: 'new' }
|
||||
| { status: 'changed'; stored: KnownHostEntry }
|
||||
|
||||
/** sha256 fingerprint in ssh "SHA256:..." style (no padding) */
|
||||
export function fingerprintOf(key: Buffer): string {
|
||||
return 'SHA256:' + createHash('sha256').update(key).digest('base64').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
export class KnownHostsStore {
|
||||
constructor(private readonly filePath: string) {}
|
||||
|
||||
private load(): KnownHostsStoreShape {
|
||||
try {
|
||||
const raw: unknown = JSON.parse(readFileSync(this.filePath, 'utf8'))
|
||||
if (raw !== null && typeof raw === 'object') {
|
||||
const shape = raw as Partial<KnownHostsStoreShape>
|
||||
if (Array.isArray(shape.entries)) {
|
||||
return {
|
||||
version: 1,
|
||||
entries: shape.entries.filter(
|
||||
(e): e is KnownHostEntry =>
|
||||
e !== null &&
|
||||
typeof e === 'object' &&
|
||||
typeof (e as KnownHostEntry).host === 'string' &&
|
||||
typeof (e as KnownHostEntry).keyBase64 === 'string'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// missing / corrupted file -> start fresh
|
||||
}
|
||||
return { version: 1, entries: [] }
|
||||
}
|
||||
|
||||
private save(shape: KnownHostsStoreShape): void {
|
||||
mkdirSync(dirname(this.filePath), { recursive: true })
|
||||
writeFileSync(this.filePath, JSON.stringify(shape, null, 2), 'utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare the live host key against the stored entry for (host, port).
|
||||
*/
|
||||
check(host: string, port: number, key: Buffer): HostKeyCheckResult {
|
||||
const entries = this.load().entries.filter((e) => e.host === host && e.port === port)
|
||||
if (entries.length === 0) return { status: 'new' }
|
||||
|
||||
const fingerprint = fingerprintOf(key)
|
||||
const keyBase64 = key.toString('base64')
|
||||
const stored = entries[0]
|
||||
if (stored.keyBase64 === keyBase64 || stored.fingerprint === fingerprint) {
|
||||
return { status: 'match', entry: stored }
|
||||
}
|
||||
return { status: 'changed', stored }
|
||||
}
|
||||
|
||||
/** Record a new host key (accept of a 'new' or 'changed' prompt). */
|
||||
accept(host: string, port: number, key: Buffer, fingerprint: string): KnownHostEntry {
|
||||
const shape = this.load()
|
||||
const entry: KnownHostEntry = {
|
||||
id: randomUUID(),
|
||||
host,
|
||||
port,
|
||||
keyBase64: key.toString('base64'),
|
||||
fingerprint,
|
||||
addedAt: Date.now()
|
||||
}
|
||||
shape.entries = shape.entries.filter((e) => !(e.host === host && e.port === port))
|
||||
shape.entries.push(entry)
|
||||
this.save(shape)
|
||||
return entry
|
||||
}
|
||||
|
||||
list(): KnownHostEntry[] {
|
||||
return [...this.load().entries]
|
||||
}
|
||||
}
|
||||
|
||||
/** Default location: <userData>/ssh_known_hosts.json */
|
||||
export function defaultKnownHostsPath(userDataPath: string): string {
|
||||
return `${userDataPath}/ssh_known_hosts.json`
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { app } from 'electron'
|
||||
import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import type { LayoutMeta } from '../shared/ipc'
|
||||
|
||||
const layoutsDir = (): string => {
|
||||
const dir = join(app.getPath('userData'), 'layouts')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
return dir
|
||||
}
|
||||
|
||||
function layoutPath(id: string): string {
|
||||
return join(layoutsDir(), `${id}.json`)
|
||||
}
|
||||
|
||||
interface LayoutFile {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: number
|
||||
json: string
|
||||
}
|
||||
|
||||
export function listLayouts(): LayoutMeta[] {
|
||||
const dir = layoutsDir()
|
||||
const metas: LayoutMeta[] = []
|
||||
for (const entry of readdirSync(dir)) {
|
||||
if (!entry.endsWith('.json')) continue
|
||||
try {
|
||||
const file = JSON.parse(readFileSync(join(dir, entry), 'utf8')) as Partial<LayoutFile>
|
||||
const id = typeof file.id === 'string' ? file.id : entry.slice(0, -'.json'.length)
|
||||
const name = typeof file.name === 'string' ? file.name : id
|
||||
const createdAt = typeof file.createdAt === 'number' ? file.createdAt : 0
|
||||
metas.push({ id, name, createdAt })
|
||||
} catch {
|
||||
// skip corrupted file
|
||||
}
|
||||
}
|
||||
return metas.sort((a, b) => b.createdAt - a.createdAt)
|
||||
}
|
||||
|
||||
export function getLayout(id: string): string | null {
|
||||
try {
|
||||
const file = JSON.parse(readFileSync(layoutPath(id), 'utf8')) as Partial<LayoutFile>
|
||||
return typeof file.json === 'string' ? file.json : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function saveLayout(meta: LayoutMeta, json: string): void {
|
||||
const file: LayoutFile = { id: meta.id, name: meta.name, createdAt: meta.createdAt, json }
|
||||
writeFileSync(layoutPath(meta.id), JSON.stringify(file, null, 2), 'utf8')
|
||||
}
|
||||
|
||||
export function deleteLayout(id: string): void {
|
||||
try {
|
||||
rmSync(layoutPath(id))
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
|
||||
}
|
||||
}
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
import { spawn, type IPty } from '@lydell/node-pty'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { homedir } from 'os'
|
||||
import { Ipc, type PtyCreateOptions, type PtyCreateResult } from '../shared/ipc'
|
||||
import type { SessionOpenOptions, HostKeyPromptEvent, SshConnection } from '../shared/connections'
|
||||
import { broadcast } from './broadcast'
|
||||
import { connectSsh, type SshSessionHandle } from './ssh'
|
||||
import type { HostKeyCheckResult } from './knownHosts'
|
||||
|
||||
/**
|
||||
* Session routing table. A session is either a local pty or an established ssh
|
||||
* shell; the generic PTY_* (data plane) channels address both. PTY_DATA /
|
||||
* PTY_EXIT broadcasts are identical for both kinds.
|
||||
*/
|
||||
type Session = { kind: 'local'; pty: IPty } | { kind: 'ssh'; ssh: SshSessionHandle }
|
||||
|
||||
const sessions = new Map<string, Session>()
|
||||
|
||||
/**
|
||||
* Per-session replay of recent output (capped). Late subscribers — a terminal
|
||||
* view mounting after the shell already printed its banner, or a panel rebound
|
||||
* by template apply — read this instead of losing the head of the stream.
|
||||
*/
|
||||
const REPLAY_CAP = 64 * 1024
|
||||
const replayBuffers = new Map<string, string>()
|
||||
|
||||
function appendReplay(id: string, data: string): void {
|
||||
const prev = replayBuffers.get(id) ?? ''
|
||||
const next = prev.length + data.length > REPLAY_CAP
|
||||
? (prev + data).slice(prev.length + data.length - REPLAY_CAP)
|
||||
: prev + data
|
||||
replayBuffers.set(id, next)
|
||||
}
|
||||
|
||||
/** Recent output of a session ('' when unknown). */
|
||||
export function getSessionReplay(id: string): string {
|
||||
return replayBuffers.get(id) ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Dependencies injected once by ipc.ts (configureSessionRuntime) so pty.ts
|
||||
* stays free of store / known-hosts imports and the ssh service can remain
|
||||
* decoupled from Electron.
|
||||
*/
|
||||
export interface SessionRuntimeDeps {
|
||||
/** resolve a bookmark by id (throws a Chinese message when missing) */
|
||||
getConnection(connectionId: string): SshConnection
|
||||
/** decrypt a stored secret for a connection */
|
||||
getSecret(conn: SshConnection, field: 'password' | 'keyContent' | 'passphrase'): string | undefined
|
||||
/** mark a bookmark as recently connected */
|
||||
touch(connectionId: string): void
|
||||
/** host key pinning: check against the store, record an accepted key */
|
||||
knownHosts: {
|
||||
check(host: string, port: number, key: Buffer): HostKeyCheckResult
|
||||
accept(host: string, port: number, key: Buffer, fingerprint: string): void
|
||||
}
|
||||
/** ask the renderer to decide an unknown / changed host key */
|
||||
promptHostKey(prompt: HostKeyPromptEvent): void
|
||||
broadcast(channel: string, ...args: unknown[]): void
|
||||
}
|
||||
|
||||
let runtimeDeps: SessionRuntimeDeps | undefined
|
||||
|
||||
/** Wire the real stores + renderer prompt. Called once during app startup. */
|
||||
export function configureSessionRuntime(deps: SessionRuntimeDeps): void {
|
||||
runtimeDeps = deps
|
||||
}
|
||||
|
||||
function defaultShell(): string {
|
||||
switch (process.platform) {
|
||||
case 'win32':
|
||||
return 'powershell.exe'
|
||||
case 'darwin':
|
||||
return process.env.SHELL || '/bin/zsh'
|
||||
default:
|
||||
return process.env.SHELL || '/bin/bash'
|
||||
}
|
||||
}
|
||||
|
||||
export function createPty(opts: PtyCreateOptions = {}): PtyCreateResult {
|
||||
const id = randomUUID()
|
||||
const shell = opts.shell ?? defaultShell()
|
||||
const cwd = opts.cwd ?? homedir()
|
||||
const env = { ...process.env, ...opts.env } as Record<string, string>
|
||||
|
||||
const pty = spawn(shell, [], { name: 'xterm-256color', cols: 80, rows: 24, cwd, env })
|
||||
sessions.set(id, { kind: 'local', pty })
|
||||
replayBuffers.set(id, '')
|
||||
|
||||
pty.onData((data) => {
|
||||
try {
|
||||
appendReplay(id, data)
|
||||
broadcast(Ipc.PTY_DATA, { id, data })
|
||||
} catch {
|
||||
// never crash the event loop
|
||||
}
|
||||
})
|
||||
|
||||
pty.onExit(({ exitCode }) => {
|
||||
try {
|
||||
sessions.delete(id)
|
||||
broadcast(Ipc.PTY_EXIT, { id, exitCode })
|
||||
} catch {
|
||||
// never crash the event loop
|
||||
}
|
||||
})
|
||||
|
||||
return { id, shell, cwd }
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a session. `local` reuses createPty; `ssh` goes through the ssh service
|
||||
* and resolves only once the shell stream is ready to stream data.
|
||||
*/
|
||||
export async function openSession(opts: SessionOpenOptions): Promise<{ id: string }> {
|
||||
if (opts.kind === 'local') {
|
||||
return { id: createPty().id }
|
||||
}
|
||||
|
||||
const deps = runtimeDeps
|
||||
if (!deps) {
|
||||
throw new Error('SSH 会话服务尚未初始化')
|
||||
}
|
||||
if (!opts.connectionId) {
|
||||
throw new Error('SSH 会话缺少 connectionId')
|
||||
}
|
||||
|
||||
const conn = deps.getConnection(opts.connectionId)
|
||||
const handle = await connectSsh(conn, opts.secretOverride, {
|
||||
connections: {
|
||||
getSecret: (c, field) => deps.getSecret(c, field),
|
||||
touch: (id: string) => {
|
||||
// Successful connect: record lastConnectedAt on the bookmark.
|
||||
try {
|
||||
deps.touch(id)
|
||||
} catch {
|
||||
// store write failure must not break the session
|
||||
}
|
||||
}
|
||||
},
|
||||
knownHosts: deps.knownHosts,
|
||||
broadcast: deps.broadcast,
|
||||
promptHostKey: deps.promptHostKey
|
||||
})
|
||||
sessions.set(handle.id, { kind: 'ssh', ssh: handle })
|
||||
|
||||
handle.stream.on('data', (data: Buffer) => {
|
||||
try {
|
||||
const text = data.toString('utf8')
|
||||
appendReplay(handle.id, text)
|
||||
deps.broadcast(Ipc.PTY_DATA, { id: handle.id, data: text })
|
||||
} catch {
|
||||
// never crash the event loop
|
||||
}
|
||||
})
|
||||
|
||||
handle.stream.on('close', () => {
|
||||
try {
|
||||
sessions.delete(handle.id)
|
||||
deps.broadcast(Ipc.PTY_EXIT, { id: handle.id, exitCode: 0 })
|
||||
} catch {
|
||||
// never crash the event loop
|
||||
}
|
||||
})
|
||||
|
||||
return { id: handle.id }
|
||||
}
|
||||
|
||||
export function writePty(id: string, data: string): void {
|
||||
const session = sessions.get(id)
|
||||
if (!session) return
|
||||
try {
|
||||
if (session.kind === 'local') session.pty.write(data)
|
||||
else session.ssh.stream.write(data)
|
||||
} catch {
|
||||
// session may already be dead
|
||||
}
|
||||
}
|
||||
|
||||
export function resizePty(id: string, cols: number, rows: number): void {
|
||||
const session = sessions.get(id)
|
||||
if (!session) return
|
||||
try {
|
||||
if (session.kind === 'local') session.pty.resize(cols, rows)
|
||||
// ssh2 Channel.setWindow(rows, cols, height, width)
|
||||
else session.ssh.stream.setWindow(rows, cols, 0, 0)
|
||||
} catch {
|
||||
// session may already be dead
|
||||
}
|
||||
}
|
||||
|
||||
export function killPty(id: string): void {
|
||||
replayBuffers.delete(id)
|
||||
const session = sessions.get(id)
|
||||
if (!session) return
|
||||
if (session.kind === 'ssh') {
|
||||
try {
|
||||
session.ssh.stream.close()
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
try {
|
||||
session.ssh.client.end()
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
session.pty.kill()
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
sessions.delete(id)
|
||||
}
|
||||
|
||||
export function killAllPtys(): void {
|
||||
for (const session of sessions.values()) {
|
||||
if (session.kind === 'ssh') {
|
||||
try {
|
||||
session.ssh.stream.close()
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
try {
|
||||
session.ssh.client.end()
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
session.pty.kill()
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
}
|
||||
sessions.clear()
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { app, ipcMain } from 'electron'
|
||||
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { Ipc } from '../shared/ipc'
|
||||
import {
|
||||
DEFAULT_HIGHLIGHT_RULES,
|
||||
DEFAULT_SETTINGS,
|
||||
type AppSettings,
|
||||
type HighlightRule,
|
||||
type TerminalSettings
|
||||
} from '../shared/settings'
|
||||
import type { TerminalTheme } from '../shared/theme'
|
||||
import { broadcast } from './broadcast'
|
||||
|
||||
const settingsPath = (): string => join(app.getPath('userData'), 'settings.json')
|
||||
|
||||
const TERMINAL_KEYS = new Set(Object.keys(DEFAULT_SETTINGS.terminal) as (keyof TerminalSettings)[])
|
||||
|
||||
/** Light structural check for a persisted highlight rule. */
|
||||
function isHighlightRule(value: unknown): value is HighlightRule {
|
||||
if (value === null || typeof value !== 'object') return false
|
||||
const rule = value as Record<string, unknown>
|
||||
return (
|
||||
typeof rule.id === 'string' &&
|
||||
typeof rule.pattern === 'string' &&
|
||||
typeof rule.enabled === 'boolean' &&
|
||||
typeof rule.priority === 'number' &&
|
||||
rule.color !== null &&
|
||||
typeof rule.color === 'object' &&
|
||||
typeof (rule.color as { fg?: unknown }).fg === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function sanitizeRules(value: unknown): HighlightRule[] {
|
||||
if (!Array.isArray(value)) return DEFAULT_HIGHLIGHT_RULES
|
||||
const rules = value.filter(isHighlightRule)
|
||||
return rules.length > 0 ? rules : DEFAULT_HIGHLIGHT_RULES
|
||||
}
|
||||
|
||||
function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
|
||||
const errors: string[] = []
|
||||
let terminal: TerminalSettings = { ...DEFAULT_SETTINGS.terminal }
|
||||
let customThemes: unknown = DEFAULT_SETTINGS.customThemes
|
||||
let highlightRules: unknown = DEFAULT_HIGHLIGHT_RULES
|
||||
|
||||
if (raw !== null && typeof raw === 'object') {
|
||||
const packageSettings = raw as { terminal?: unknown; customThemes?: unknown; highlightRules?: unknown }
|
||||
if (packageSettings.terminal !== null && typeof packageSettings.terminal === 'object') {
|
||||
const candidate = packageSettings.terminal as Record<string, unknown>
|
||||
const merged: Record<string, unknown> = { ...terminal }
|
||||
for (const key of TERMINAL_KEYS) {
|
||||
const keyType = typeof DEFAULT_SETTINGS.terminal[key]
|
||||
if (candidate[key] !== undefined && typeof candidate[key] === keyType) {
|
||||
merged[key] = candidate[key]
|
||||
} else if (candidate[key] !== undefined) {
|
||||
errors.push(`terminal.${key}`)
|
||||
}
|
||||
}
|
||||
terminal = merged as unknown as TerminalSettings
|
||||
}
|
||||
if (Array.isArray(packageSettings.customThemes)) {
|
||||
customThemes = packageSettings.customThemes
|
||||
}
|
||||
if (packageSettings.highlightRules !== undefined) {
|
||||
highlightRules = packageSettings.highlightRules
|
||||
}
|
||||
}
|
||||
|
||||
const themes: TerminalTheme[] = Array.isArray(customThemes) ? (customThemes as TerminalTheme[]) : []
|
||||
|
||||
return { settings: { terminal, customThemes: themes, highlightRules: sanitizeRules(highlightRules) }, errors }
|
||||
}
|
||||
|
||||
export function loadSettings(): AppSettings {
|
||||
try {
|
||||
const raw: unknown = JSON.parse(readFileSync(settingsPath(), 'utf8'))
|
||||
const { settings } = deepMerge(raw)
|
||||
return settings
|
||||
} catch {
|
||||
return {
|
||||
terminal: { ...DEFAULT_SETTINGS.terminal },
|
||||
customThemes: [...DEFAULT_SETTINGS.customThemes],
|
||||
highlightRules: DEFAULT_HIGHLIGHT_RULES.map((rule) => ({ ...rule }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSettings(next: AppSettings): AppSettings {
|
||||
const merged = deepMerge({
|
||||
terminal: next.terminal,
|
||||
customThemes: next.customThemes,
|
||||
highlightRules: next.highlightRules
|
||||
}).settings
|
||||
const pretty = JSON.stringify(merged, null, 2)
|
||||
|
||||
mkdirSync(app.getPath('userData'), { recursive: true })
|
||||
const path = settingsPath()
|
||||
const tmp = `${path}.tmp`
|
||||
writeFileSync(tmp, pretty, 'utf8')
|
||||
renameSync(tmp, path)
|
||||
|
||||
broadcast(Ipc.SETTINGS_CHANGED, merged)
|
||||
return merged
|
||||
}
|
||||
|
||||
export function registerSettingsIpc(): void {
|
||||
ipcMain.handle(Ipc.SETTINGS_GET, () => loadSettings())
|
||||
ipcMain.handle(Ipc.SETTINGS_SET, (_event, next: AppSettings) => saveSettings(next))
|
||||
}
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* SSH session service (M2).
|
||||
*
|
||||
* Deliberately decoupled from Electron: broadcast and host-key store are
|
||||
* injected (`SshServiceDeps`), so the connect / verify / shell flow can be
|
||||
* exercised by the pure-node loopback harness (tests/ssh-loopback.mjs) with a
|
||||
* plain ssh2 client and no Chromium runtime.
|
||||
*
|
||||
* Host-key verification pauses the handshake inside the `hostVerifier`
|
||||
* callback: ssh2's kex suspends until `verify()` is called, which is exactly
|
||||
* what we need for store-check + renderer prompt + accept-record. The 15s
|
||||
* connect timeout is paused while a host-key prompt is outstanding so the
|
||||
* prompt's own 30s budget governs that wait.
|
||||
*/
|
||||
|
||||
import type { SshConnection, SshSecretOverride } from '../shared/connections'
|
||||
import { Ipc } from '../shared/ipc'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { readFileSync } from 'fs'
|
||||
import { fingerprintOf, type HostKeyCheckResult } from './knownHosts'
|
||||
import type { ConnectionsStore } from './connectionsStore'
|
||||
import type { Client, ClientChannel, ConnectConfig } from 'ssh2'
|
||||
|
||||
export interface SshSessionHandle {
|
||||
id: string
|
||||
/** established ssh connection; powers the session routing in pty.ts */
|
||||
client: Client
|
||||
/** open interactive shell channel (ClientChannel, a Duplex) */
|
||||
stream: ClientChannel
|
||||
}
|
||||
|
||||
export interface SshServiceDeps {
|
||||
/** resolves saved secrets (service never writes them) */
|
||||
connections: Pick<ConnectionsStore, 'getSecret' | 'touch'>
|
||||
/** host key pinning store access */
|
||||
knownHosts: {
|
||||
check(host: string, port: number, key: Buffer): HostKeyCheckResult
|
||||
accept(host: string, port: number, key: Buffer, fingerprint: string): void
|
||||
}
|
||||
/** electron-free broadcast; matches main/broadcast.ts */
|
||||
broadcast(channel: string, ...args: unknown[]): void
|
||||
/**
|
||||
* Ask the renderer to decide on an unknown / changed host key. The prompt
|
||||
* resolves via the module-level `resolveHostKey(promptId, action)`.
|
||||
*/
|
||||
promptHostKey(prompt: {
|
||||
promptId: string
|
||||
host: string
|
||||
port: number
|
||||
fingerprint: string
|
||||
reason: 'new' | 'changed'
|
||||
}): void
|
||||
timeoutMs?: { prompt: number; connect: number }
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUTS = { prompt: 30_000, connect: 15_000 }
|
||||
|
||||
/**
|
||||
* Open an ssh session (connect + auth + open shell) for `conn`.
|
||||
*
|
||||
* Resolves with `{ id, client, stream }` once the shell stream is ready to
|
||||
* stream data. Rejects with a human-readable Chinese Error when anything goes
|
||||
* wrong before the shell is ready.
|
||||
*/
|
||||
export async function connectSsh(
|
||||
conn: SshConnection,
|
||||
secretOverride: SshSecretOverride | undefined,
|
||||
deps: SshServiceDeps
|
||||
): Promise<SshSessionHandle> {
|
||||
const mod = await import('ssh2')
|
||||
const Client = mod.Client
|
||||
const timeouts = { ...DEFAULT_TIMEOUTS, ...deps.timeoutMs }
|
||||
|
||||
const sessionId = randomUUID()
|
||||
const handshake = new Client()
|
||||
|
||||
let settled = false
|
||||
let connectTimer: NodeJS.Timeout | undefined
|
||||
let verifierErr: string | undefined
|
||||
let resolvePromise!: (handle: SshSessionHandle) => void
|
||||
let rejectPromise!: (err: Error) => void
|
||||
|
||||
const armConnectTimer = (): void => {
|
||||
if (connectTimer) clearTimeout(connectTimer)
|
||||
connectTimer = setTimeout(() => {
|
||||
fail(new Error(`连接超时 (${conn.host}:${conn.port})`))
|
||||
}, timeouts.connect)
|
||||
}
|
||||
|
||||
const fail = (err: Error): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
if (connectTimer) clearTimeout(connectTimer)
|
||||
rejectPromise(err)
|
||||
try {
|
||||
handshake.destroy()
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
// --- host key verification ------------------------------------------------
|
||||
// Called by ssh2 during kex; return undefined => async verdict via verify().
|
||||
const hostVerifier = (hostKey: Buffer, verify: (permitted: boolean) => void): void => {
|
||||
let fingerprint: string
|
||||
let status: 'new' | 'changed' | 'match'
|
||||
try {
|
||||
fingerprint = fingerprintOf(hostKey)
|
||||
status = deps.knownHosts.check(conn.host, conn.port, hostKey).status
|
||||
} catch {
|
||||
fingerprint = fingerprintOf(hostKey)
|
||||
status = 'new'
|
||||
}
|
||||
|
||||
if (status === 'match') {
|
||||
verify(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Pause the connect timeout; the user's decision owns this wait.
|
||||
if (connectTimer) clearTimeout(connectTimer)
|
||||
|
||||
promptUser(conn.host, conn.port, fingerprint, status, timeouts.prompt, deps)
|
||||
.then((accepted) => {
|
||||
if (accepted) {
|
||||
try {
|
||||
deps.knownHosts.accept(conn.host, conn.port, hostKey, fingerprint)
|
||||
} catch (err) {
|
||||
verifierErr = `保存主机指纹失败: ${(err as Error).message}`
|
||||
verify(false)
|
||||
return
|
||||
}
|
||||
verify(true)
|
||||
} else {
|
||||
verifierErr = '用户拒绝了主机指纹'
|
||||
verify(false)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
// Resume the overall connect timer once the decision lands.
|
||||
if (!settled) armConnectTimer()
|
||||
})
|
||||
}
|
||||
|
||||
return new Promise<SshSessionHandle>((resolve, reject) => {
|
||||
resolvePromise = resolve
|
||||
rejectPromise = reject
|
||||
|
||||
// failure paths before resolution: `fail` and the connect timer
|
||||
armConnectTimer()
|
||||
|
||||
handshake.on('error', (err: Error) => {
|
||||
const message = verifierErr ?? err.message
|
||||
if (!settled) {
|
||||
fail(new Error(`连接失败 ${conn.host}:${conn.port}: ${message}`))
|
||||
return
|
||||
}
|
||||
// Session already established: surface as a session exit and clean up.
|
||||
try {
|
||||
deps.broadcast(Ipc.PTY_EXIT, { id: sessionId, exitCode: 1 })
|
||||
} catch {
|
||||
// never crash the event loop
|
||||
}
|
||||
try {
|
||||
handshake.destroy()
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
})
|
||||
|
||||
handshake.on('ready', () => {
|
||||
handshake.shell(
|
||||
{ term: 'xterm-256color', cols: 80, rows: 24 },
|
||||
(err: Error | undefined, shell: ClientChannel) => {
|
||||
if (err) {
|
||||
fail(new Error(`无法打开 SSH shell (${conn.host}:${conn.port}): ${err.message}`))
|
||||
return
|
||||
}
|
||||
if (settled) {
|
||||
try {
|
||||
shell.end()
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
if (connectTimer) clearTimeout(connectTimer)
|
||||
// Successful connect: record lastConnectedAt on the bookmark.
|
||||
try {
|
||||
deps.connections.touch(conn.id)
|
||||
} catch {
|
||||
// store write failure must not break the session
|
||||
}
|
||||
resolvePromise({ id: sessionId, client: handshake, stream: shell })
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const cfg: ConnectConfig = {
|
||||
host: conn.host,
|
||||
port: conn.port,
|
||||
username: conn.username,
|
||||
// SshConnection keeps it in seconds; ssh2 expects milliseconds.
|
||||
keepaliveInterval: Math.round(conn.keepaliveIntervalSec * 1000),
|
||||
hostVerifier
|
||||
}
|
||||
|
||||
// Password auth
|
||||
const password = secretOverride?.password ?? deps.connections.getSecret(conn, 'password')
|
||||
if (password !== undefined) cfg.password = password
|
||||
|
||||
// Private key auth (keyPath takes precedence over stored keyContent)
|
||||
if (conn.auth === 'privateKey') {
|
||||
const keyContent = deps.connections.getSecret(conn, 'keyContent')
|
||||
const privateKey: string | undefined =
|
||||
conn.keyPath !== undefined && conn.keyPath.length > 0
|
||||
? readKeyFile(conn.keyPath)
|
||||
: keyContent !== undefined && keyContent.length > 0
|
||||
? keyContent
|
||||
: undefined
|
||||
if (privateKey !== undefined) {
|
||||
cfg.privateKey = privateKey
|
||||
const passphrase = secretOverride?.passphrase ?? deps.connections.getSecret(conn, 'passphrase')
|
||||
if (passphrase !== undefined) cfg.passphrase = passphrase
|
||||
}
|
||||
}
|
||||
|
||||
// Agent auth (defaults supplied only when an agent socket is reachable)
|
||||
if (conn.auth === 'agent') {
|
||||
const agent = process.env.SSH_AUTH_SOCK ?? (process.platform === 'win32' ? 'pageant' : undefined)
|
||||
if (agent !== undefined) cfg.agent = agent
|
||||
}
|
||||
|
||||
try {
|
||||
handshake.connect(cfg)
|
||||
} catch (err) {
|
||||
// e.g. unparseable privateKey is thrown synchronously by ssh2
|
||||
fail(new Error(`SSH 连接初始化失败 (${conn.host}:${conn.port}): ${(err as Error).message}`))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- host-key prompt routing -------------------------------------------------
|
||||
|
||||
interface PendingPrompt {
|
||||
resolve: (accepted: boolean) => void
|
||||
timer: NodeJS.Timeout
|
||||
}
|
||||
|
||||
const pendingPrompts = new Map<string, PendingPrompt>()
|
||||
|
||||
/**
|
||||
* Ask the renderer to approve / reject a host key. Resolves `true`/`false`.
|
||||
* Times out (default 30s) -> treated as reject.
|
||||
*/
|
||||
function promptUser(
|
||||
host: string,
|
||||
port: number,
|
||||
fingerprint: string,
|
||||
reason: 'new' | 'changed',
|
||||
timeoutMs: number,
|
||||
deps: SshServiceDeps
|
||||
): Promise<boolean> {
|
||||
const promptId = randomUUID()
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
pendingPrompts.delete(promptId)
|
||||
resolve(false)
|
||||
}, timeoutMs)
|
||||
|
||||
pendingPrompts.set(promptId, { resolve, timer })
|
||||
try {
|
||||
deps.promptHostKey({ promptId, host, port, fingerprint, reason })
|
||||
} catch {
|
||||
// broadcast failure must not hang the attempt forever
|
||||
pendingPrompts.delete(promptId)
|
||||
clearTimeout(timer)
|
||||
resolve(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a renderer host-key decision to the matching pending prompt.
|
||||
* Wired by the main process: ipcMain.on(Ipc.HOSTKEY_RESPOND, (…, promptId, action)).
|
||||
*/
|
||||
export function resolveHostKey(promptId: string, action: 'accept' | 'reject'): void {
|
||||
const pending = pendingPrompts.get(promptId)
|
||||
if (!pending) return
|
||||
clearTimeout(pending.timer)
|
||||
pendingPrompts.delete(promptId)
|
||||
pending.resolve(action === 'accept')
|
||||
}
|
||||
|
||||
/** Read a private key file; surfaces a descriptive error on failure. */
|
||||
function readKeyFile(keyPath: string): string {
|
||||
try {
|
||||
return readFileSync(keyPath, 'utf8')
|
||||
} catch (err) {
|
||||
throw new Error(`无法读取私钥文件 ${keyPath}: ${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
Vendored
+100
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Minimal ambient typings for the `ssh2` package (v1.17.0).
|
||||
*
|
||||
* ssh2 ships no type declarations and `@types/ssh2` is not installed; npm
|
||||
* install is out of scope here, so this file declares exactly the surface this
|
||||
* project consumes (verified against node_modules/ssh2/lib/client.js,
|
||||
* Channel.js, kex.js, agent.js, server.js). Lives under src/main/, which is
|
||||
* owned by this milestone.
|
||||
*/
|
||||
|
||||
declare module 'ssh2' {
|
||||
import { Duplex } from 'stream'
|
||||
import { Socket } from 'net'
|
||||
|
||||
/** Duplex wrapper around an SSH channel (shell/exec). */
|
||||
export interface ClientChannel extends Duplex {
|
||||
setWindow(rows: number, cols: number, height: number, width: number): void
|
||||
signal(signalName: string): void
|
||||
exit(statusOrSignal: number | string, coreDumped?: boolean, msg?: string): void
|
||||
close(): void
|
||||
}
|
||||
|
||||
export interface PseudoTtyOptions {
|
||||
rows?: number
|
||||
cols?: number
|
||||
width?: number
|
||||
height?: number
|
||||
term?: string
|
||||
}
|
||||
|
||||
export interface ShellOptions {
|
||||
rows?: number
|
||||
cols?: number
|
||||
width?: number
|
||||
height?: number
|
||||
term?: string
|
||||
/** environment (name => value) requested over the session */
|
||||
env?: Record<string, string>
|
||||
x11?: boolean | number | Record<string, unknown>
|
||||
/** forward a local ssh-agent to the remote session */
|
||||
agentForward?: boolean
|
||||
}
|
||||
|
||||
export interface ConnectConfig {
|
||||
host?: string
|
||||
port?: number
|
||||
username: string
|
||||
password?: string
|
||||
privateKey?: Buffer | string
|
||||
passphrase?: string
|
||||
/** ssh-agent socket path or a BaseAgent instance */
|
||||
agent?: string
|
||||
/** forward the local agent to the remote (requires `agent`) */
|
||||
agentForward?: boolean
|
||||
/** keepalive interval in milliseconds (0 disables) */
|
||||
keepaliveInterval?: number
|
||||
keepaliveCountMax?: number
|
||||
/** ms to wait for handshake before erroring (0 disables) */
|
||||
readyTimeout?: number
|
||||
/** socket connect timeout in ms (0 disables) */
|
||||
timeout?: number
|
||||
hostHash?: string
|
||||
/**
|
||||
* Optional host key verification. When it returns a boolean, ssh2 uses it
|
||||
* synchronously; a promise-using implementer must instead call the `verify`
|
||||
* callback (returning `undefined`), which defers the handshake.
|
||||
*/
|
||||
hostVerifier?: (key: Buffer, verify: (permitted: boolean) => void) => boolean | void
|
||||
debug?: (...args: unknown[]) => void
|
||||
algorithms?: Record<string, unknown>
|
||||
ident?: string | Buffer
|
||||
sock?: Socket
|
||||
strictVendor?: boolean
|
||||
localAddress?: string
|
||||
localHostname?: string
|
||||
localUsername?: string
|
||||
/** try keyboard-interactive auth */
|
||||
tryKeyboard?: boolean
|
||||
authHandler?: unknown
|
||||
forceIPv4?: boolean
|
||||
forceIPv6?: boolean
|
||||
}
|
||||
|
||||
export class Client {
|
||||
connect(cfg: ConnectConfig): this
|
||||
end(): this
|
||||
destroy(): this
|
||||
shell(cb: (err: Error | undefined, stream: ClientChannel) => void): this
|
||||
shell(
|
||||
opts: PseudoTtyOptions | ShellOptions | false,
|
||||
cb: (err: Error | undefined, stream: ClientChannel) => void
|
||||
): this
|
||||
exec(cmd: string, cb: (err: Error | undefined, stream: ClientChannel) => void): this
|
||||
on(event: 'ready' | 'close', listener: () => void): this
|
||||
on(event: 'error', listener: (err: Error) => void): this
|
||||
on(event: string, listener: (...args: never[]) => void): this
|
||||
}
|
||||
|
||||
export function createAgent(path: string): unknown
|
||||
}
|
||||
Reference in new issue
Block a user