Files
OpenTerminal/src/main/connectionsStore.ts
T
Bill 172cce1962 fix(stores): 配置文件形状不符先备份再回退;日志尾部同步落盘
- commands/connections/settings 三个 store 原先只对 JSON.parse 抛错做
  .bak 备份,合法 JSON 但形状不符(如 [1,2,3])会静默回退空/默认,下次
  写盘把命令库/书签凭据/自定义主题整体销毁。现统一为形状不符也先备份
  (与 knownHosts 的 fail-closed 策略对齐),三个 store 测试补对应用例
- sanitizeRules 回退分支返回预设副本而非模块级共享数组引用,并在回退时
  记录 warning(原先连警告都没有)
- 会话日志尾部:logStop 触发的最后一轮 flush 改同步落盘(在途异步写未
  落地时由 drain 循环做保序的最终同步轮),before-quit 不再丢日志尾巴
2026-10-09 11:31:06 +08:00

297 lines
11 KiB
TypeScript

/**
* 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 { copyFileSync, existsSync, readFileSync } from 'fs'
import type { SshAuthMethod, SshConnection, SshConnectionInput } from '../shared/connections'
import { writeJson } from './store'
const ENC_SUFFIX = '_enc'
const PLAIN_PREFIX = 'plain:'
const SECRET_KEYS = ['password', 'keyContent', 'passphrase'] as const
const AUTH_METHODS: ReadonlySet<string> = new Set(['password', 'privateKey', 'agent'])
/**
* Runtime coercion for renderer-supplied connection fields. saveConnection
* copies input values verbatim, and they flow straight into ssh2's
* ConnectConfig (port, keepalive) — a malformed save or a renderer regression
* must not persist `port: "22"` or a NaN-bound keepalive. Repair over drop,
* like every other store: strings stay strings, numbers are range-checked,
* unknown auth falls back to password, optional fields collapse to undefined.
*/
function coerceConnectionInput(input: SshConnectionInput): SshConnectionInput {
const out = { ...input } as Record<string, unknown>
out.name = typeof input.name === 'string' ? input.name : ''
out.group = typeof input.group === 'string' && input.group !== '' ? input.group : undefined
out.host = typeof input.host === 'string' ? input.host.trim() : ''
out.username = typeof input.username === 'string' ? input.username : ''
const port = Number(input.port)
out.port = Number.isInteger(port) && port >= 1 && port <= 65535 ? port : 22
out.auth = AUTH_METHODS.has(input.auth as string) ? input.auth : 'password'
out.askPasswordAtConnect = input.askPasswordAtConnect === true
out.askPassphraseAtConnect = input.askPassphraseAtConnect === true
out.keyPath =
typeof input.keyPath === 'string' && input.keyPath !== '' ? input.keyPath : undefined
const keepalive = Number(input.keepaliveIntervalSec)
out.keepaliveIntervalSec =
Number.isFinite(keepalive) && keepalive >= 0 ? Math.round(keepalive) : 0
out.highlightProfileId =
typeof input.highlightProfileId === 'string' && input.highlightProfileId !== ''
? input.highlightProfileId
: undefined
for (const key of SECRET_KEYS) {
if (out[key] !== undefined && typeof out[key] !== 'string') out[key] = undefined
}
return out as unknown as SshConnectionInput
}
/** 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
highlightProfileId?: string
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',
'highlightProfileId'
] 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,
highlightProfileId: stored.highlightProfileId,
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
}
}
}
/**
* A connections.json that exists but cannot be read or parsed must never be
* silently destroyed: load() falls back to an empty list, and the next write
* (any bookmark edit, or just a `touch()` after connecting) would rewrite the
* file from that empty list — taking every saved host and its `*_enc`
* credentials with it. Keep a one-time copy the user can recover from before
* that can happen.
*/
function backupUnparseableConnections(file: string, err: unknown): void {
try {
// ENOENT (first run) is the normal empty case: nothing to lose, stay quiet.
if (!existsSync(file)) return
const bak = `${file}.bak`
if (existsSync(bak)) return // keep the first backup; later loads must not clobber it
copyFileSync(file, bak)
console.warn(
`[connections] ${file} unparseable (${err instanceof Error ? err.message : String(err)}) — original kept at ${bak}`
)
} catch {
// best effort: the backup must never break loading
}
}
/**
* The single exit for an unusable connections.json — parse failure and wrong
* shape both end here, so neither can quietly skip the backup.
*/
function discardConnectionsFile(file: string, err: unknown): StoredConnection[] {
backupUnparseableConnections(file, err)
return []
}
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'
)
}
// Valid JSON of the wrong shape (`{}`) is not "no bookmarks": it is a file
// we cannot read, and the next write would rewrite it from an empty list.
// Same exit as the parse failure below.
return discardConnectionsFile(
this.filePath,
new Error('connections.json shape mismatch: expected an array')
)
} catch (err) {
// missing -> start fresh; unreadable / corrupt -> keep the original first
return discardConnectionsFile(this.filePath, err)
}
}
private save(list: StoredConnection[]): void {
writeJson(this.filePath, list)
}
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(rawInput: SshConnectionInput): SshConnection {
const input = coerceConnectionInput(rawInput)
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,
highlightProfileId: input.highlightProfileId,
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`
}