sshExecutor.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
  1. import {Client,type SFTPWrapper,type ServerHostKeyAlgorithm} from 'ssh2'
  2. import {createHash,timingSafeEqual} from 'node:crypto'
  3. import {StringDecoder} from 'node:string_decoder'
  4. import type {IsolationGate,CredentialExecutionReceipt} from './executor.js'
  5. import {parse,sshCommand,sshTimeout,type VaultEntry,type LeaseInput} from './schema.js'
  6. import {HttpError} from '../errors.js'
  7. 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)))
  8. const serverHostKey:ServerHostKeyAlgorithm[]=['ssh-ed25519','rsa-sha2-512','rsa-sha2-256','ecdsa-sha2-nistp256']
  9. /** First-use discovery stops at key exchange. It never supplies credentials or authenticates. */
  10. export async function probeSshHostKey(host:Extract<LeaseInput['target'],{kind:'ssh'}>,gate:IsolationGate,externalSignal:AbortSignal):Promise<string>{
  11. gate.assert();externalSignal.throwIfAborted()
  12. const conn=new Client(),signal=AbortSignal.any([externalSignal,AbortSignal.timeout(5000)])
  13. let abort!:()=>void
  14. try{
  15. return await new Promise<string>((resolve,reject)=>{
  16. let settled=false
  17. const fail=()=>{if(!settled){settled=true;reject(new HttpError(503,'无法连接 SSH 服务器,密码已保存,请检查地址和端口','vault_host_probe_failed'))}}
  18. abort=()=>{fail();conn.destroy()};signal.addEventListener('abort',abort,{once:true})
  19. conn.on('error',fail);conn.on('close',fail)
  20. conn.connect({host:host.host.replace(/^\[|\]$/g,''),port:host.port,username:'host-key-probe',authHandler:[],tryKeyboard:false,readyTimeout:5000,
  21. algorithms:{serverHostKey},hostVerifier:(key:Buffer)=>{
  22. try{signal.throwIfAborted();gate.assert();if(!settled){settled=true;resolve('SHA256:'+createHash('sha256').update(key).digest('base64').replace(/=+$/,''))}}
  23. catch{fail()}
  24. return false // Reject before authentication even when discovery succeeded.
  25. }})
  26. })
  27. }finally{signal.removeEventListener('abort',abort);conn.destroy()}
  28. }
  29. /** Pinned SSH authentication. Autonomous commands are enabled only by trusted
  30. * Bot approval; credentials, forwarding and a local shell are never exposed. */
  31. export class SshSftpExecutor {
  32. constructor(private gate:IsolationGate){}
  33. executeAutonomous(entry:Readonly<VaultEntry>,input:LeaseInput,signal:AbortSignal){
  34. if(input.operation!=='ssh.exec'||entry.target.kind!=='ssh')throw new HttpError(403,'此凭据不支持自主 SSH 运维','vault_usage_forbidden')
  35. const command=parse(sshCommand,input.command??(entry.usage?.kind==='ssh.exec'?entry.usage.command:undefined)),timeout=parse(sshTimeout,input.timeoutSeconds??60)
  36. return this.run({...entry,usage:{kind:'ssh.exec',command}},{...input,operation:'ssh.exec'},signal,{timeout})
  37. }
  38. execute(entry:Readonly<VaultEntry>,input:LeaseInput,externalSignal:AbortSignal){
  39. if(input.command!==undefined||input.timeoutSeconds!==undefined)throw new HttpError(403,'请先将 SSH 凭据授权给指定 Bot','vault_usage_forbidden')
  40. return this.run(entry,input,externalSignal)
  41. }
  42. private async run(entry:Readonly<VaultEntry>,input:LeaseInput,externalSignal:AbortSignal,autonomous?:{timeout:number}):Promise<CredentialExecutionReceipt>{
  43. this.gate.assert();externalSignal.throwIfAborted()
  44. const plan=entry.usage
  45. 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)
  46. return {status:'manual_takeover_required',reason:'usage_policy_required',submitted:false}
  47. const host=entry.target
  48. if(!host.hostKey)throw new HttpError(403,'首次使用前需要确认 SSH 服务器','vault_host_key_required')
  49. const expected=Buffer.from(host.hostKey.slice(7),'base64')
  50. if(expected.length!==32)throw new HttpError(403,'SSH host key 指纹无效','vault_host_key_rejected')
  51. const signal=AbortSignal.any([externalSignal,AbortSignal.timeout(autonomous?autonomous.timeout*1000:15000)]),conn=new Client()
  52. let hostRejected=false,finished=false,fail!:(error:unknown)=>void,ready!:()=>void
  53. const failure=new Promise<never>((_r,reject)=>{fail=reject}),connected=new Promise<void>(resolve=>{ready=resolve})
  54. // connect() can synchronously reject a malformed key before work is created.
  55. // Keep close/error rejection observed on that path as well.
  56. void failure.catch(()=>{})
  57. const invoke=<T>(start:(done:(error:Error|undefined|null,value:T)=>void)=>void)=>Promise.race([call(start),failure])
  58. const abort=()=>{fail(new Error('operation cancelled'));conn.destroy()}
  59. signal.addEventListener('abort',abort,{once:true})
  60. conn.on('error',()=>fail(hostRejected?new HttpError(403,'SSH 服务器指纹已改变,已停止连接','vault_host_key_rejected'):new Error('SSH connection unavailable')))
  61. conn.on('close',()=>{if(!finished)fail(new Error('SSH receipt unavailable'))})
  62. conn.once('ready',ready)
  63. const check=()=>{signal.throwIfAborted();this.gate.assert()}
  64. try{
  65. conn.connect({host:host.host.replace(/^\[|\]$/g,''),port:host.port,username:entry.username,
  66. ...(host.auth === 'password' ? {password:entry.secret} : {privateKey:entry.secret,...(entry.passphrase?{passphrase:entry.passphrase}:{})}),
  67. authHandler:[host.auth === 'password' ? 'password' : 'publickey'],tryKeyboard:false,agentForward:false,readyTimeout:5000,keepaliveInterval:0,
  68. algorithms:{serverHostKey},
  69. hostVerifier:(key:Buffer)=>{const digest=createHash('sha256').update(key).digest();const match=timingSafeEqual(digest,expected);hostRejected=!match;return match}})
  70. const work=(async():Promise<CredentialExecutionReceipt>=>{
  71. await Promise.race([connected,failure]);check()
  72. if(plan.kind==='ssh.exec'){
  73. // Autonomous commands run on the approved remote SSH account, never
  74. // through a local shell. Restricted approval still uses its fixed plan.
  75. const stream=await invoke<import('ssh2').ClientChannel>(done=>conn.exec(plan.command,{pty:false},done))
  76. check();let output=0,truncated=false
  77. const stdout:Buffer[]=[],stderr:Buffer[]=[],limit=65536
  78. const privateValues=[entry.secret,entry.passphrase].filter((s):s is string=>!!s).map(s=>s.replace(/\r\n/g,'\n'))
  79. if(entry.target.kind==='ssh'&&entry.target.auth!=='password')privateValues.push(...entry.secret.split(/\r?\n/).filter(s=>s.length>=8))
  80. const retainedLimit=limit+Math.max(...privateValues.map(s=>Buffer.byteLength(s)))
  81. let outBytes=0,errBytes=0
  82. const capture=(chunks:Buffer[],data:Buffer,bytes:number)=>{const available=Math.max(0,retainedLimit-bytes);if(available<data.length)truncated=true;if(available)chunks.push(Buffer.from(data.subarray(0,available)));return bytes+Math.min(data.length,available)}
  83. const discard=(data:Buffer)=>{output+=data.length;if(output>limit)abort()}
  84. stream.on('data',(data:Buffer)=>{if(autonomous)outBytes=capture(stdout,data,outBytes);else discard(data)})
  85. stream.stderr.on('data',(data:Buffer)=>{if(autonomous)errBytes=capture(stderr,data,errBytes);else discard(data)})
  86. stream.on('error',()=>fail(new Error('SSH command interrupted')))
  87. 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()})
  88. check()
  89. if(autonomous){
  90. const sanitize=(chunks:Buffer[])=>{
  91. let text=Buffer.concat(chunks).toString('utf8').replace(/\r\n/g,'\n')
  92. for(const value of privateValues){
  93. // The retained buffer may end partway through a credential.
  94. for(let n=truncated?Math.min(value.length-1,text.length):0;n>0;n--)if(text.endsWith(value.slice(0,n))){text=text.slice(0,-n)+'[REDACTED]';break}
  95. text=text.split(value).join('[REDACTED]')
  96. }
  97. return text.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g,'').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g,'')
  98. }
  99. try{
  100. const out=Buffer.from(sanitize(stdout)),err=Buffer.from(sanitize(stderr)),outSize=Math.min(out.length,limit),errSize=Math.min(err.length,limit-outSize)
  101. truncated ||=outSize<out.length||errSize<err.length
  102. return {status:'complete',operation:'ssh.exec',exitCode:code,stdout:new StringDecoder('utf8').write(out.subarray(0,outSize)),stderr:new StringDecoder('utf8').write(err.subarray(0,errSize)),truncated}
  103. }finally{for(const b of [...stdout,...stderr])b.fill(0)}
  104. }
  105. return {status:'complete',operation:'ssh.exec',exitCode:code}
  106. }
  107. const sftp=await invoke<SFTPWrapper>(done=>conn.sftp(done));sftp.on('error',()=>fail(new Error('SFTP interrupted')))
  108. check()
  109. if(plan.kind==='sftp.read'){
  110. const canonical=await invoke<string>(done=>sftp.realpath(plan.remotePath,done));check()
  111. if(canonical!==plan.remotePath)throw new HttpError(403,'SFTP 读取目标不是批准的准确路径','vault_usage_forbidden')
  112. const handle=await invoke<Buffer>(done=>sftp.open(plan.remotePath,'r',done));check()
  113. try{
  114. const stat=await invoke<import('ssh2').Stats>(done=>sftp.fstat(handle,done));check()
  115. if(!stat.isFile()||stat.size>plan.maxBytes)throw new HttpError(403,'SFTP 读取文件超出授权类型或大小','vault_usage_forbidden')
  116. const digest=createHash('sha256'),buffer=Buffer.alloc(16384);let bytes=0
  117. try{
  118. 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))}
  119. return {status:'complete',operation:'sftp.read',bytes,sha256:digest.digest('hex')} as CredentialExecutionReceipt
  120. }finally{buffer.fill(0)}
  121. }finally{await invoke<void>(done=>sftp.close(handle,error=>done(error,undefined))).catch(()=>{})}
  122. }
  123. const slash=plan.remotePath.lastIndexOf('/'),parent=plan.remotePath.slice(0,slash)||'/'
  124. const canonical=await invoke<string>(done=>sftp.realpath(parent,done));check()
  125. if(canonical!==parent)throw new HttpError(403,'SFTP 写入目录不是批准的准确路径','vault_usage_forbidden')
  126. if(Buffer.byteLength(plan.contents,'utf8')>65536)throw new HttpError(403,'SFTP 写入超过授权大小','vault_usage_forbidden')
  127. const data=Buffer.from(plan.contents,'utf8');let handle:Buffer|undefined
  128. try{
  129. // Exclusive creation: never overwrite or follow an existing final link.
  130. handle=await invoke<Buffer>(done=>sftp.open(plan.remotePath,'wx',{mode:0o600},done));check()
  131. 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()}
  132. await invoke<void>(done=>sftp.close(handle!,error=>done(error,undefined)));check()
  133. handle=undefined
  134. return {status:'complete',operation:'sftp.write',bytes:data.length,sha256:createHash('sha256').update(data).digest('hex')} as CredentialExecutionReceipt
  135. }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.
  136. })()
  137. const result=await Promise.race([work,failure]);finished=true;return result
  138. }finally{
  139. signal.removeEventListener('abort',abort);conn.destroy()
  140. }
  141. }
  142. }