Files
Xen-Orchestra-xen-orchestra…/@vates/node-vsphere-soap/lib/client.mjs
Florent BEAUCHAMP 3bf50c38ee fix(vmware-explorer): fixes omnibus (#10363)
This branch is a reliability overhaul of the ESXi/vSphere source side of V2V, plus a scope reduction of the package. The SOAP plumbing is rewritten:

    VimClient wraps @vates/node-vsphere-soap in a promise API with one emitter per call (no more cross-talk between concurrent calls, no listener leak) and a pTimeout;
    vim25 faults are now parsed (_parseFault.mjs) so callers get a real fault type instead of task execution failed;
    the library no longer throws from async callbacks (which killed xo-server) and its beforeExit logout hook is per-client instead of removeAllListeners.
    Request specs are built in explicit schema order (soap/specs.mjs) with xsi:type, and property values are normalized (soap/normalize.mjs) so search() and the new #retrieveProperty share one shape.
    search() now paginates, cancels the retrieval and destroys the container view.
    Datastore HTTP downloads get session-cookie reuse, retry with jittered backoff, a headers-only timeout, and range-honouring validation.
    Task waiting polls with backoff and per-operation timeouts (6 h for snapshot consolidation).
    nbdkit servers are memoized by a key that includes the export options, the readiness wait races the process death instead of sleeping 2 s
    the password file is 0600 and unlinked
    the thumbprint is read over TLS in-process instead of two openssl spawns.
    The old VHD-over-datastore readers (VhdEsxi*, Datastore*, openDeltaVmdkAsVhd) are deleted, hence the majo, rleaving the vddk/NBD path with a COWD grain-directory parser as data-map fallback.
    ~1.7k lines of unit tests are added.
2026-09-11 17:36:38 +02:00

333 lines
9.9 KiB
JavaScript

/*
node-vsphere-soap
client.js
This file creates the Client class
- when the class is instantiated, a connection will be made to the ESXi/vCenter server to verify that the creds are good
- upon a bad login, the connection will be terminated
*/
import { EventEmitter } from 'events'
import axios from 'axios'
import https from 'node:https'
import util from 'util'
import soap from 'soap'
import Cookie from 'soap-cookie' // required for session persistence
import { createLogger } from '@xen-orchestra/log'
import { parseFault } from './_parseFault.mjs'
// exposed for the consumers talking to the host outside of the WSDL, e.g. `Fetch`
export { parseFault } from './_parseFault.mjs'
const { warn } = createLogger('xo:node-vsphere-soap:client')
class VmwareError extends Error {
constructor(rawError) {
const { code, faultcode, faultstring, localizedMessage, body } = parseFault(rawError)
super(localizedMessage ?? faultstring ?? rawError?.message ?? 'unknown SOAP error')
this.name = 'VmwareError'
// the vim25 fault type, the only part of a fault a caller can branch on
this.code = code
this.faultcode = faultcode
this.faultstring = faultstring
this.localizedMessage = localizedMessage
this.body = body
if (rawError?.stack !== undefined) {
this.stack = rawError.stack
}
// legacy constraint: not putting the cause since the error failed to be stringified correctly
// down the road, the fault is exposed through own properties instead
warn('SOAP call failed', { code, faultcode, faultstring, localizedMessage })
}
}
// Client class
// inherits from EventEmitter
// possible events: connect, error, ready
export function Client(vCenterHostname, username, password, sslVerify) {
this.status = 'disconnected'
this.reconnectCount = 0
this._exitHook = undefined
sslVerify = typeof sslVerify !== 'undefined' ? sslVerify : false
EventEmitter.call(this)
// sslVerify argument handling
// don't use any proxy to connect to the esxi
if (sslVerify) {
this.clientopts = {
request: axios.create({
proxy: false,
}),
}
} else {
this.clientopts = {
request: axios.create({
httpsAgent: new https.Agent({
rejectUnauthorized: false,
}),
proxy: false,
}),
}
}
this.connectionInfo = {
host: vCenterHostname,
user: username,
password,
sslVerify,
}
this._loginArgs = {
userName: this.connectionInfo.user,
password: this.connectionInfo.password,
}
this._vcUrl = 'https://' + this.connectionInfo.host + '/sdk/vimService.wsdl'
// connect to the vCenter / ESXi host
this.on('connect', this._connect)
this.emit('connect')
// close session
this.on('close', this._close)
return this
}
util.inherits(Client, EventEmitter)
Client.prototype.runCommand = function (command, args) {
const self = this
const cmdargs = args ?? {}
const emitter = new EventEmitter()
const onResponse = function (err, result, raw, soapHeader) {
if (err) {
// an error and a result are mutually exclusive: emitting both makes every caller listening
// for 'result' believe the call succeeded, with `result` undefined
_soapErrorHandler(self, emitter, command, cmdargs, err)
return
}
if (command === 'Logout') {
self.status = 'disconnected'
self._unregisterExitHook()
}
emitter.emit('result', result, raw, soapHeader)
}
const send = function () {
self.client.VimService.VimPort[command](cmdargs, onResponse)
}
// check if client has successfully connected
if (self.status === 'ready' || self.status === 'connecting') {
send()
} else {
// if connection not ready or connecting, reconnect to instance
if (self.status === 'disconnected') {
self.emit('connect')
}
// a failed connection would otherwise leave this listener registered for ever, and the next
// successful one would replay every command whose caller has long given up
const onConnectionError = error => {
self.off('ready', onReady)
emitter.emit('error', error)
}
const onReady = () => {
self.off('error', onConnectionError)
send()
}
self.once('ready', onReady)
self.once('error', onConnectionError)
}
return emitter
}
Client.prototype.close = function () {
const self = this
self.emit('close')
}
Client.prototype._connect = function () {
const self = this
if (self.status !== 'disconnected') {
return
}
self.status = 'connecting'
soap.createClient(
self._vcUrl,
self.clientopts,
function (err, client) {
if (err) {
self.status = 'disconnected'
// throwing here would be an uncaught exception: we are in an async callback, not in the
// stack of the caller
self.emit('error', new VmwareError(err))
return
}
self.client = client // save client for later use
self
.runCommand('RetrieveServiceContent', { _this: 'ServiceInstance' })
.once('result', function (result, raw, soapHeader) {
if (!result.returnval) {
// this is a domain/server error return it as is
self.status = 'disconnected'
self.emit('error', raw)
return
}
self.serviceContent = result.returnval
self.sessionManager = result.returnval.sessionManager
const loginArgs = { _this: self.sessionManager, ...self._loginArgs }
self
.runCommand('Login', loginArgs)
.once('result', function (result, raw, soapHeader) {
self.authCookie = new Cookie(client.lastResponseHeaders)
self.client.setSecurity(self.authCookie) // needed since vSphere SOAP WS uses cookies
self.userName = result.returnval.userName
self.fullName = result.returnval.fullName
self.reconnectCount = 0
self.status = 'ready'
self.emit('ready')
self._registerExitHook()
})
.once('error', function (err) {
self.status = 'disconnected'
self.emit('error', err)
})
})
.once('error', function (err) {
self.status = 'disconnected'
self.emit('error', err)
})
},
self._vcUrl
)
}
// Logs out on process exit, so that a session is not left open on the server until it expires.
//
// The hook must be stored: passing `self._close` directly would call it with `this === process`,
// and removing it with `process.removeAllListeners('beforeExit')` would drop the hooks of every
// other module of the process.
Client.prototype._registerExitHook = function () {
const self = this
if (self._exitHook === undefined) {
self._exitHook = function () {
self._close()
}
process.once('beforeExit', self._exitHook)
}
}
Client.prototype._unregisterExitHook = function () {
const self = this
if (self._exitHook !== undefined) {
process.off('beforeExit', self._exitHook)
self._exitHook = undefined
}
}
Client.prototype._close = function () {
const self = this
self._unregisterExitHook()
if (self.status === 'ready') {
self
.runCommand('Logout', { _this: self.sessionManager })
.once('result', function () {
self.status = 'disconnected'
})
.once('error', function () {
/* don't care of error during disconnection */
self.status = 'disconnected'
})
} else {
self.status = 'disconnected'
}
}
// Errors are only emitted on `emitter`, the emitter of the failed call: throwing from here would
// be an uncaught exception since we are always in an async callback, and emitting on the client
// would reject unrelated concurrent calls.
//
// Exported for the unit tests: building a `Client` connects to a host.
export function _soapErrorHandler(self, emitter, command, args, err) {
err = err || { body: 'general error' }
const vErr = new VmwareError(err)
// `NotAuthenticated` is raised when the session expired: log in again and replay the call.
//
// The fault type is used instead of the previous `err.body?.match(...)`: node-soap only fills
// `body` for streamed responses, so the string test never matched and the session was never
// renewed
const isSessionExpired =
vErr.code === 'NotAuthenticated' || /session is not authenticated/i.test(vErr.faultstring ?? vErr.message)
if (!isSessionExpired || self.reconnectCount >= 10) {
emitter.emit('error', vErr)
return
}
// The call is replayed only once a new session is ready. Handing it straight to `runCommand`
// would send it right away while the status is 'connecting' — i.e. on the very session which
// just expired — and the failure of that replay would land back here.
const onConnectionError = function (error) {
self.off('ready', onReady)
emitter.emit('error', error)
}
const onReady = function () {
self.off('error', onConnectionError)
self
.runCommand(command, args)
.once('result', function (result, raw, soapHeader) {
emitter.emit('result', result, raw, soapHeader)
})
.once('error', function (retryError) {
emitter.emit('error', retryError)
})
}
self.once('ready', onReady)
self.once('error', onConnectionError)
// A whole wave of calls can fail on the same expired session, e.g. during an import. Only the
// first one to notice drops it and pays for a retry: the others ride the reconnect it started,
// instead of each burning one of the 10 and of resetting the status under a `_connect` already
// in flight — which used to start a second `soap.createClient` and replace `self.client` under
// the first one.
if (self.status === 'ready') {
self.status = 'disconnected'
self._unregisterExitHook()
self.reconnectCount += 1
}
if (self.status === 'disconnected') {
// no reconnect in flight: either this call is the first to notice, or the previous attempt
// already failed
self.emit('connect')
}
}
// end