sshExecutor.ts 6.9 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
  1. import {Client,type SFTPWrapper} from 'ssh2'
  2. import {createHash,timingSafeEqual} from 'node:crypto'
  3. import type {IsolationGate,CredentialExecutionReceipt} from './executor.js'
  4. import type {VaultEntry,LeaseInput} from './schema.js'
  5. import {HttpError} from '../errors.js'
  6. const call=<T>(start:(done:(error:Error|undefined|null,value:T)=>void)=>void)=>new Promise<T>((resolve,reject)=>start((error,value)=>error?reject(error):resolve(value)))
  7. /** Key-only, pinned-host SSH. No shell session, command interpolation, PTY,
  8. * environment, agent forwarding, ProxyCommand, jump host or TCP forwarding. */
  9. export class SshSftpExecutor {
  10. constructor(private gate:IsolationGate){}
  11. async execute(entry:Readonly<VaultEntry>,input:LeaseInput,externalSignal:AbortSignal):Promise<CredentialExecutionReceipt>{
  12. this.gate.assert();externalSignal.throwIfAborted()
  13. const plan=entry.usage
  14. if(entry.target.kind!=='ssh'||input.target.kind!=='ssh'||JSON.stringify(entry.target)!==JSON.stringify(input.target)||!plan||plan.kind==='website.form'||plan.kind!==input.operation)
  15. return {status:'manual_takeover_required',reason:'usage_policy_required',submitted:false}
  16. const host=entry.target,expected=Buffer.from(host.hostKey.slice(7),'base64')
  17. if(expected.length!==32)throw new HttpError(403,'SSH host key 指纹无效','vault_host_key_rejected')
  18. const signal=AbortSignal.any([externalSignal,AbortSignal.timeout(15000)]),conn=new Client()
  19. let hostRejected=false,finished=false,fail!:(error:unknown)=>void,ready!:()=>void
  20. const failure=new Promise<never>((_r,reject)=>{fail=reject}),connected=new Promise<void>(resolve=>{ready=resolve})
  21. // connect() can synchronously reject a malformed key before work is created.
  22. // Keep close/error rejection observed on that path as well.
  23. void failure.catch(()=>{})
  24. const invoke=<T>(start:(done:(error:Error|undefined|null,value:T)=>void)=>void)=>Promise.race([call(start),failure])
  25. const abort=()=>{fail(new Error('operation cancelled'));conn.destroy()}
  26. signal.addEventListener('abort',abort,{once:true})
  27. conn.on('error',()=>fail(hostRejected?new HttpError(403,'SSH host key 与人工批准指纹不符','vault_host_key_rejected'):new Error('SSH connection unavailable')))
  28. conn.on('close',()=>{if(!finished)fail(new Error('SSH receipt unavailable'))})
  29. conn.once('ready',ready)
  30. const check=()=>{signal.throwIfAborted();this.gate.assert()}
  31. try{
  32. conn.connect({host:host.host.replace(/^\[|\]$/g,''),port:host.port,username:entry.username,privateKey:entry.secret,...(entry.passphrase?{passphrase:entry.passphrase}:{}),
  33. authHandler:['publickey'],tryKeyboard:false,agentForward:false,readyTimeout:5000,keepaliveInterval:0,
  34. algorithms:{serverHostKey:['ssh-ed25519','rsa-sha2-512','rsa-sha2-256','ecdsa-sha2-nistp256']},
  35. hostVerifier:(key:Buffer)=>{const digest=createHash('sha256').update(key).digest();const match=timingSafeEqual(digest,expected);hostRejected=!match;return match}})
  36. const work=(async()=>{
  37. await Promise.race([connected,failure]);check()
  38. if(plan.kind==='ssh.exec'){
  39. // Exactly the user-approved absolute executable; no arguments or
  40. // strings assembled from model input are ever sent to the server.
  41. const stream=await invoke<import('ssh2').ClientChannel>(done=>conn.exec(plan.command,{pty:false},done))
  42. check();let output=0
  43. const discard=(data:Buffer)=>{output+=data.length;if(output>65536)abort()}
  44. stream.on('data',discard);stream.stderr.on('data',discard);stream.on('error',()=>fail(new Error('SSH command interrupted')))
  45. const code=await new Promise<number>((resolve,reject)=>{stream.once('close',(value:number|undefined)=>Number.isInteger(value)?resolve(value!):reject(new Error('SSH exit receipt unavailable')));stream.end()})
  46. check();return {status:'complete',operation:'ssh.exec',exitCode:code} as CredentialExecutionReceipt
  47. }
  48. const sftp=await invoke<SFTPWrapper>(done=>conn.sftp(done));sftp.on('error',()=>fail(new Error('SFTP interrupted')))
  49. check()
  50. if(plan.kind==='sftp.read'){
  51. const canonical=await invoke<string>(done=>sftp.realpath(plan.remotePath,done));check()
  52. if(canonical!==plan.remotePath)throw new HttpError(403,'SFTP 读取目标不是批准的准确路径','vault_usage_forbidden')
  53. const handle=await invoke<Buffer>(done=>sftp.open(plan.remotePath,'r',done));check()
  54. try{
  55. const stat=await invoke<import('ssh2').Stats>(done=>sftp.fstat(handle,done));check()
  56. if(!stat.isFile()||stat.size>plan.maxBytes)throw new HttpError(403,'SFTP 读取文件超出授权类型或大小','vault_usage_forbidden')
  57. const digest=createHash('sha256'),buffer=Buffer.alloc(16384);let bytes=0
  58. try{
  59. while(true){check();const count=await invoke<number>(done=>sftp.read(handle,buffer,0,Math.min(buffer.length,plan.maxBytes+1-bytes),bytes,(error,count)=>done(error,count)));check();if(!count)break;bytes+=count;if(bytes>plan.maxBytes)throw new Error('SFTP size limit');digest.update(buffer.subarray(0,count))}
  60. return {status:'complete',operation:'sftp.read',bytes,sha256:digest.digest('hex')} as CredentialExecutionReceipt
  61. }finally{buffer.fill(0)}
  62. }finally{await invoke<void>(done=>sftp.close(handle,error=>done(error,undefined))).catch(()=>{})}
  63. }
  64. const slash=plan.remotePath.lastIndexOf('/'),parent=plan.remotePath.slice(0,slash)||'/'
  65. const canonical=await invoke<string>(done=>sftp.realpath(parent,done));check()
  66. if(canonical!==parent)throw new HttpError(403,'SFTP 写入目录不是批准的准确路径','vault_usage_forbidden')
  67. if(Buffer.byteLength(plan.contents,'utf8')>65536)throw new HttpError(403,'SFTP 写入超过授权大小','vault_usage_forbidden')
  68. const data=Buffer.from(plan.contents,'utf8');let handle:Buffer|undefined
  69. try{
  70. // Exclusive creation: never overwrite or follow an existing final link.
  71. handle=await invoke<Buffer>(done=>sftp.open(plan.remotePath,'wx',{mode:0o600},done));check()
  72. for(let offset=0;offset<data.length;offset+=16384){check();await invoke<void>(done=>sftp.write(handle!,data,offset,Math.min(16384,data.length-offset),offset,error=>done(error,undefined)));check()}
  73. await invoke<void>(done=>sftp.close(handle!,error=>done(error,undefined)));check()
  74. handle=undefined
  75. return {status:'complete',operation:'sftp.write',bytes:data.length,sha256:createHash('sha256').update(data).digest('hex')} as CredentialExecutionReceipt
  76. }finally{data.fill(0);if(handle)await invoke<void>(done=>sftp.close(handle!,error=>done(error,undefined))).catch(()=>{})} // Interrupted writes may leave a partial file: never retry or overwrite automatically.
  77. })()
  78. const result=await Promise.race([work,failure]);finished=true;return result
  79. }finally{
  80. signal.removeEventListener('abort',abort);conn.destroy()
  81. }
  82. }
  83. }