mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-10-11 22:50:01 +00:00
feat(v2v): remove vddk dependency (#10421)
Co-authored-by: Mathieu <[email protected]>
This commit is contained in:
co-authored by
Mathieu
parent
3fea79d899
commit
dc57333f58
@@ -0,0 +1,57 @@
|
||||
name: Vectura
|
||||
|
||||
# only when vectura changes: the rest of the repository does not build it
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '@xen-orchestra/vmware-explorer/vectura/**'
|
||||
- .github/workflows/vectura.yml
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: '@xen-orchestra/vmware-explorer/vectura'
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# https://github.com/actions/checkout
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# rustup, on the runner image, installs the toolchain rust-toolchain.toml
|
||||
# names on the first cargo command
|
||||
- run: cargo fmt --all --check
|
||||
- run: cargo clippy --all-targets --locked -- -D warnings
|
||||
- run: cargo test --locked
|
||||
|
||||
# https://github.com/taiki-e/install-action
|
||||
- uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: [email protected],[email protected]
|
||||
|
||||
- run: cargo deny --locked check
|
||||
- run: cargo llvm-cov --locked --json --summary-only --output-path target/coverage.json
|
||||
- name: Hold the coverage floor
|
||||
run: cargo test --locked --test coverage -- --ignored
|
||||
|
||||
# builds the binary as a release does, then checks it and the one in git,
|
||||
# which xo-server runs
|
||||
binary:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# https://github.com/actions/checkout
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# https://github.com/mlugg/setup-zig
|
||||
- uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: 0.16.0
|
||||
|
||||
# https://github.com/taiki-e/install-action
|
||||
- uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: [email protected]
|
||||
|
||||
- run: scripts/build.sh "$RUNNER_TEMP/build"
|
||||
- run: scripts/check-binary.sh "$RUNNER_TEMP/build/vectura"
|
||||
- run: scripts/check-binary.sh vectura
|
||||
@@ -1,5 +1,5 @@
|
||||
Reads the inventory, the metadata and the disks of an ESXi host or a vCenter, over vim25 SOAP and
|
||||
the `/folder` HTTP endpoint, and exports the content of a disk through an `nbdkit` server.
|
||||
the `/folder` HTTP endpoint, and exports the content of a disk through a `vectura` server.
|
||||
|
||||
### Errors
|
||||
|
||||
@@ -25,8 +25,7 @@ which failed without the host naming a fault type reports `TASK_FAILED` instead.
|
||||
| `DATACENTER_NOT_FOUND` | No datacenter holds the named datastore, and a file cannot be downloaded without one. Carries `dataStore` and the `dataStores` which are known. |
|
||||
| `DATASTORE_NOT_FOUND` | An absolute disk reference of a vmx or a vmsd is on no known datastore. Carries `filePath` and `dataStoreUrls`. |
|
||||
| `ESXI_SESSION_EXPIRED` | The session of the HTTP endpoint was refused (401 or 403), which happens on an import lasting hours. Retried after authenticating again. |
|
||||
| `NBDKIT_EXITED` | The `nbdkit` server exited before it started listening, e.g. on a wrong thumbprint or a missing vddk library. Its logs are named in the message. |
|
||||
| `NBDKIT_NOT_LISTENING` | The `nbdkit` server is running, but never started listening on its port. |
|
||||
| `NBD_SERVER_EXITED` | The `vectura` server exited, e.g. on a wrong thumbprint or a disk the host refuses to open. Raised by `NbdStdioClient`, which carries the tail of its stderr on the error. |
|
||||
| `NO_DATA_MAP` | The allocated ranges of a disk could not be read: an unreadable vmdk descriptor, or a delta in a format which is not supported. The disk has to be transferred in full instead of as a delta. |
|
||||
| `NO_PROPERTY` | The host returned no value for the property which was read, e.g. because the object is gone. |
|
||||
| `NO_TASK` | A `*_Task` method answered without a task to wait for. |
|
||||
|
||||
@@ -17,7 +17,7 @@ npm install --save @xen-orchestra/vmware-explorer
|
||||
## Usage
|
||||
|
||||
Reads the inventory, the metadata and the disks of an ESXi host or a vCenter, over vim25 SOAP and
|
||||
the `/folder` HTTP endpoint, and exports the content of a disk through an `nbdkit` server.
|
||||
the `/folder` HTTP endpoint, and exports the content of a disk through a `vectura` server.
|
||||
|
||||
### Errors
|
||||
|
||||
@@ -43,15 +43,14 @@ which failed without the host naming a fault type reports `TASK_FAILED` instead.
|
||||
| `DATACENTER_NOT_FOUND` | No datacenter holds the named datastore, and a file cannot be downloaded without one. Carries `dataStore` and the `dataStores` which are known. |
|
||||
| `DATASTORE_NOT_FOUND` | An absolute disk reference of a vmx or a vmsd is on no known datastore. Carries `filePath` and `dataStoreUrls`. |
|
||||
| `ESXI_SESSION_EXPIRED` | The session of the HTTP endpoint was refused (401 or 403), which happens on an import lasting hours. Retried after authenticating again. |
|
||||
| `NBDKIT_EXITED` | The `nbdkit` server exited before it started listening, e.g. on a wrong thumbprint or a missing vddk library. Its logs are named in the message. |
|
||||
| `NBDKIT_NOT_LISTENING` | The `nbdkit` server is running, but never started listening on its port. |
|
||||
| `NBD_SERVER_EXITED` | The `vectura` server exited, e.g. on a wrong thumbprint or a disk the host refuses to open. Raised by `NbdStdioClient`, which carries the tail of its stderr on the error. |
|
||||
| `NO_DATA_MAP` | The allocated ranges of a disk could not be read: an unreadable vmdk descriptor, or a delta in a format which is not supported. The disk has to be transferred in full instead of as a delta. |
|
||||
| `NO_PROPERTY` | The host returned no value for the property which was read, e.g. because the object is gone. |
|
||||
| `NO_TASK` | A `*_Task` method answered without a task to wait for. |
|
||||
| `RANGE_IGNORED` | The host answered something else than the byte range which was requested. Reading it would use the wrong offset, or as much memory as the file is big. |
|
||||
| `TASK_FAILED` | A task ended in error, and the host named no fault type for it. |
|
||||
| `TASK_TIMEOUT` | A task did not complete within its deadline. It keeps running on the host, so it is **not** retried: starting a second one would not make the first go away. |
|
||||
| `NO_CHANGE_ID` | Asked for changed block, but change block was not tracked when snapshotted |
|
||||
| `NO_CHANGE_ID` | Asked for changed block, but change block was not tracked when snapshotted |
|
||||
|
||||
`ESXI_SESSION_EXPIRED` and the transport failures are transient, and are retried inside the package
|
||||
— while downloading from a datastore, and while polling a task. Every other code is reported to the
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createServer } from 'node:net'
|
||||
import { describe, it } from 'node:test'
|
||||
|
||||
import { findFreePort, waitForPort } from './_nbdkit.mjs'
|
||||
|
||||
// these bind a real port
|
||||
describe('findFreePort', function () {
|
||||
it('returns a port which can be bound', async function () {
|
||||
const port = await findFreePort()
|
||||
|
||||
assert.equal(typeof port, 'number')
|
||||
const server = createServer()
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(port, '127.0.0.1', resolve)
|
||||
})
|
||||
server.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('waitForPort', function () {
|
||||
it('resolves as soon as the port accepts a connection', async function () {
|
||||
const port = await findFreePort()
|
||||
const server = createServer()
|
||||
// start listening later, as nbdkit does once its plugin is configured
|
||||
const timer = setTimeout(() => server.listen(port, '127.0.0.1'), 30)
|
||||
|
||||
try {
|
||||
await waitForPort(port, { probeDelay: 5, timeout: 2e3 })
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
server.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('gives up with the reason of the last attempt', async function () {
|
||||
const port = await findFreePort()
|
||||
|
||||
await assert.rejects(waitForPort(port, { probeDelay: 1, timeout: 20 }), error => {
|
||||
assert.match(error.message, /nothing is listening on 127\.0\.0\.1:/)
|
||||
assert.equal(error.code, 'NBDKIT_NOT_LISTENING')
|
||||
assert.equal(error.cause.code, 'ECONNREFUSED')
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
it('stops probing when aborted', async function () {
|
||||
const port = await findFreePort()
|
||||
|
||||
await assert.rejects(waitForPort(port, { probeDelay: 1, signal: AbortSignal.abort(), timeout: 1e3 }), {
|
||||
name: 'AbortError',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,109 +0,0 @@
|
||||
import { connect, createServer } from 'node:net'
|
||||
import { setTimeout as delay } from 'node:timers/promises'
|
||||
|
||||
/**
|
||||
* Command line of an nbdkit server exporting one disk of a VM through the vddk plugin.
|
||||
*
|
||||
* @param {object} params
|
||||
* @returns {Array<string>}
|
||||
*/
|
||||
export function formatNbdkitArgs({
|
||||
compression,
|
||||
diskPath,
|
||||
host,
|
||||
libdir,
|
||||
passFile,
|
||||
port,
|
||||
singleLink,
|
||||
threads,
|
||||
thumbprint,
|
||||
user,
|
||||
vmId,
|
||||
}) {
|
||||
return [
|
||||
'-r', // readonly
|
||||
'-v',
|
||||
'-f',
|
||||
'--exit-with-parent', // implies -f , ensure we don't leave orphans
|
||||
`--threads=${threads}`,
|
||||
`--port=${port}`,
|
||||
'vddk', // the vddk plugin
|
||||
`compression=${compression}`,
|
||||
`thumbprint=${thumbprint}`,
|
||||
`server=${host}`,
|
||||
`user=${user}`,
|
||||
`password=+${passFile}`,
|
||||
`libdir=${libdir}`,
|
||||
`vm=moref=${vmId}`,
|
||||
// `single-link` restricts the export to the top delta. An empty argument used to be passed
|
||||
// when it is disabled: nbdkit tolerates it, but it sits in the slot of the magic positional
|
||||
// parameter of the plugin, right before the disk to export
|
||||
...(singleLink ? ['single-link=true'] : []),
|
||||
diskPath,
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserves a port by letting the system pick a free one.
|
||||
*
|
||||
* The port is released before being handed out, so the caller must be able to cope with a
|
||||
* concurrent process taking it in the meantime.
|
||||
*
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
export function findFreePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer()
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const { port } = server.address()
|
||||
server.close(error => (error != null ? reject(error) : resolve(port)))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const tryConnect = (port, host) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const socket = connect({ port, host })
|
||||
socket.once('connect', () => {
|
||||
socket.destroy()
|
||||
resolve()
|
||||
})
|
||||
socket.once('error', error => {
|
||||
socket.destroy()
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Waits until something listens on a port.
|
||||
*
|
||||
* nbdkit binds its port once its plugin is configured, so this is what tells that the server is
|
||||
* usable — the previous implementation waited a fixed two seconds, which was both too long for a
|
||||
* fast host and too short for a slow one.
|
||||
*
|
||||
* @param {number} port
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.host]
|
||||
* @param {number} [options.probeDelay] - in ms, between two attempts
|
||||
* @param {AbortSignal} [options.signal]
|
||||
* @param {number} [options.timeout] - in ms
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function waitForPort(port, { host = '127.0.0.1', probeDelay = 50, signal, timeout = 30e3 } = {}) {
|
||||
const start = Date.now()
|
||||
for (;;) {
|
||||
signal?.throwIfAborted()
|
||||
try {
|
||||
return await tryConnect(port, host)
|
||||
} catch (error) {
|
||||
if (Date.now() - start >= timeout) {
|
||||
const wrapped = new Error(`nothing is listening on ${host}:${port} after ${timeout}ms`)
|
||||
wrapped.code = 'NBDKIT_NOT_LISTENING'
|
||||
wrapped.cause = error
|
||||
throw wrapped
|
||||
}
|
||||
}
|
||||
await delay(probeDelay, undefined, { signal })
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
|
||||
import { formatNbdkitArgs } from './_nbdkit.mjs'
|
||||
|
||||
const PARAMS = {
|
||||
compression: 'fastlz',
|
||||
diskPath: '[ds main] a.vm/a.vm.vmdk',
|
||||
host: 'esxi.test',
|
||||
libdir: '/usr/local/lib/vddk/vmware-vix-disklib-distrib',
|
||||
passFile: '/tmp/xo-server-42/params',
|
||||
port: 11000,
|
||||
singleLink: false,
|
||||
threads: 1,
|
||||
thumbprint: 'AA:BB:CC',
|
||||
user: 'root',
|
||||
vmId: 'vm-42',
|
||||
}
|
||||
|
||||
describe('formatNbdkitArgs', function () {
|
||||
it('never passes an empty argument', function () {
|
||||
// an empty argument sits in the slot of the magic positional parameter of the plugin
|
||||
for (const singleLink of [false, true]) {
|
||||
const args = formatNbdkitArgs({ ...PARAMS, singleLink })
|
||||
assert.equal(
|
||||
args.every(argument => argument.length > 0),
|
||||
true,
|
||||
JSON.stringify(args)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('exports read only, exits with its parent, and reads the password from a file', function () {
|
||||
const args = formatNbdkitArgs(PARAMS)
|
||||
|
||||
assert.ok(args.includes('-r'))
|
||||
assert.ok(args.includes('--exit-with-parent'))
|
||||
assert.ok(args.includes(`password=+${PARAMS.passFile}`))
|
||||
// the password itself never appears in the command line, which is world readable
|
||||
assert.equal(
|
||||
args.some(argument => argument.includes('password=') && !argument.startsWith('password=+')),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('restricts the export to the top delta only when asked', function () {
|
||||
assert.equal(formatNbdkitArgs(PARAMS).includes('single-link=true'), false)
|
||||
assert.equal(formatNbdkitArgs({ ...PARAMS, singleLink: true }).includes('single-link=true'), true)
|
||||
})
|
||||
|
||||
it('ends with the disk to export', function () {
|
||||
const args = formatNbdkitArgs({ ...PARAMS, singleLink: true })
|
||||
|
||||
assert.equal(args[args.length - 1], PARAMS.diskPath)
|
||||
})
|
||||
})
|
||||
@@ -2,11 +2,29 @@ import assert from 'node:assert/strict'
|
||||
import { createServer } from 'node:net'
|
||||
import { describe, it } from 'node:test'
|
||||
|
||||
import { findFreePort } from './_nbdkit.mjs'
|
||||
import { getCertificateThumbprint } from './_thumbprint.mjs'
|
||||
|
||||
const noop = () => {}
|
||||
|
||||
/**
|
||||
* Reserves a port by letting the system pick a free one.
|
||||
*
|
||||
* The port is released before being handed out, so a concurrent process could take it in the
|
||||
* meantime — which is fine here, every test using it wants a port nothing answers on.
|
||||
*
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
function findFreePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer()
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const { port } = server.address()
|
||||
server.close(error => (error != null ? reject(error) : resolve(port)))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// the successful path needs a certificate, so it is only exercised against a real host: what is
|
||||
// covered here are the two failures which used to freeze or terminate the process
|
||||
describe('getCertificateThumbprint', function () {
|
||||
|
||||
@@ -2,13 +2,13 @@ import { connect } from 'node:tls'
|
||||
import { isIP } from 'node:net'
|
||||
|
||||
/**
|
||||
* SHA-1 fingerprint of the certificate presented by a TLS server, in the `AA:BB:...` form the
|
||||
* vddk library expects.
|
||||
* SHA-256 fingerprint of the certificate presented by a TLS server, in the `sha256:AA:BB:...` form
|
||||
* vectura expects for `--thumbprint`.
|
||||
*
|
||||
* This used to shell out to `openssl s_client` and `openssl x509`, which left a temporary
|
||||
* directory and a file descriptor behind on every call, had no timeout at all — a black holed port
|
||||
* froze the whole spawn of the nbdkit server — and threw from inside a callback instead of
|
||||
* rejecting, terminating the process.
|
||||
* froze the whole start of an import — and threw from inside a callback instead of rejecting,
|
||||
* terminating the process.
|
||||
*
|
||||
* @param {string} host
|
||||
* @param {object} [options]
|
||||
@@ -36,13 +36,14 @@ export function getCertificateThumbprint(host, { port = 443, timeout = 10e3 } =
|
||||
}
|
||||
|
||||
socket.once('secureConnect', () => {
|
||||
const { fingerprint } = socket.getPeerCertificate()
|
||||
if (typeof fingerprint !== 'string' || fingerprint.length === 0) {
|
||||
// `fingerprint` is the SHA-1 one, which is not what is pinned here
|
||||
const { fingerprint256 } = socket.getPeerCertificate()
|
||||
if (typeof fingerprint256 !== 'string' || fingerprint256.length === 0) {
|
||||
// nothing to wait for on this path, drop the socket instead of half closing it
|
||||
return fail(new Error(`the host ${host}:${port} did not present any certificate`))
|
||||
}
|
||||
socket.end()
|
||||
resolve(fingerprint)
|
||||
resolve(`sha256:${fingerprint256}`)
|
||||
})
|
||||
socket.once('timeout', () => {
|
||||
fail(new Error(`no TLS handshake with ${host}:${port} after ${timeout}ms`))
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
// the vddk library is not redistributable: it has to be downloaded from Broadcom and extracted
|
||||
// there by `esxi.installVddkLib`
|
||||
export const VDDK_LIB_DIR = '/usr/local/lib/vddk'
|
||||
export const VDDK_LIB_PATH = `${VDDK_LIB_DIR}/vmware-vix-disklib-distrib`
|
||||
export const VDDK_LIB_FILE = `${VDDK_LIB_PATH}/lib64/libvixDiskLib.so`
|
||||
@@ -0,0 +1,40 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
// the vectura binary is small and redistributable: it ships with this package, next to its sources
|
||||
// and its build process, so the binary xo-server runs is always the one it was tested against
|
||||
export const VECTURA_BIN = fileURLToPath(new URL('vectura/vectura', import.meta.url))
|
||||
|
||||
/**
|
||||
* Command line of a vectura server exporting one disk of a VM as NBD on its own standard streams.
|
||||
*
|
||||
* The password is not here on purpose: vectura reads it from `VECTURA_PASSWORD` only, which keeps it
|
||||
* out of the command line of a process anyone can list.
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {string} [params.compression] - `skipz`, `zlib` or `none`, vectura defaults to `skipz`
|
||||
* @param {number} [params.depth] - host reads kept in flight, 1 to 32, vectura defaults to 16
|
||||
* @param {string} params.diskPath - `[datastore] dir/disk.vmdk`
|
||||
* @param {string} params.host
|
||||
* @param {string} params.thumbprint - `sha256:` and the digest of the management port certificate
|
||||
* @param {string} params.user
|
||||
* @param {string} params.vmId - managed object reference, such as `3` or `vm-42`
|
||||
* @returns {Array<string>}
|
||||
*/
|
||||
export function formatVecturaArgs({ compression, depth, diskPath, host, thumbprint, user, vmId }) {
|
||||
return [
|
||||
'serve',
|
||||
'--host',
|
||||
host,
|
||||
'--user',
|
||||
user,
|
||||
'--vm-id',
|
||||
vmId,
|
||||
'--disk',
|
||||
diskPath,
|
||||
'--thumbprint',
|
||||
thumbprint,
|
||||
// left out when undefined: vectura's own defaults are the ones we want
|
||||
...(compression !== undefined ? ['--compression', compression] : []),
|
||||
...(depth !== undefined ? ['--depth', String(depth)] : []),
|
||||
]
|
||||
}
|
||||
@@ -1,116 +1,32 @@
|
||||
import { exec } from 'node:child_process'
|
||||
import semver from 'semver'
|
||||
import fs from 'node:fs/promises'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
|
||||
import { VDDK_LIB_DIR, VDDK_LIB_FILE } from './_vddk.mjs'
|
||||
import { VECTURA_BIN } from './_vectura.mjs'
|
||||
|
||||
/**
|
||||
* Whether the vectura binary shipped with this package runs on this machine.
|
||||
*
|
||||
* There is nothing to install: this only fails when the binary lost its executable bit or when the
|
||||
* system is too old for it (glibc before 2.34).
|
||||
*
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
/* async */ function nbdInfo() {
|
||||
return new Promise(function (resolve, reject) {
|
||||
const expectedVersion = '1.23.4'
|
||||
exec('nbdinfo --version', (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
return resolve({
|
||||
error: `exit code ${error.code}`,
|
||||
status: 'error',
|
||||
})
|
||||
}
|
||||
const matches = stdout.match(/nbdinfo ([0-9.]+)/)
|
||||
const version = matches?.[1] ?? ''
|
||||
resolve({
|
||||
installed: true,
|
||||
version,
|
||||
status: semver.satisfies(version, `>=${expectedVersion}`) ? 'success' : 'alarm',
|
||||
expectedVersion,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function getNbdKitVersion() {
|
||||
return new Promise(function (resolve) {
|
||||
exec('nbdkit --version', (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
return resolve({
|
||||
error: `exit code ${error.code}`,
|
||||
status: 'error',
|
||||
})
|
||||
}
|
||||
const matches = stdout.match(/nbdkit ([0-9.]+)/)
|
||||
const version = matches?.[1] ?? ''
|
||||
resolve(version)
|
||||
})
|
||||
})
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function nbdKit() {
|
||||
const expectedVersion = '1.42.5'
|
||||
async function vectura() {
|
||||
try {
|
||||
const version = await getNbdKitVersion()
|
||||
return {
|
||||
installed: true,
|
||||
version,
|
||||
status: semver.satisfies(version, `>=${expectedVersion}`) ? 'success' : 'alarm',
|
||||
expectedVersion,
|
||||
}
|
||||
await promisify(execFile)(VECTURA_BIN, ['--version'])
|
||||
return { status: 'success' }
|
||||
} catch (error) {
|
||||
return {
|
||||
error: `exit code ${error.code}`,
|
||||
status: 'error',
|
||||
}
|
||||
return { error: `vectura is not runnable: ${error.message}`, status: 'error' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the import of a VM from an ESXi host needs on this machine.
|
||||
*
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
/* async */ function nbdKitVddk() {
|
||||
return new Promise(function (resolve) {
|
||||
exec('nbdkit --dump-plugin vddk', (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
return resolve({
|
||||
error: stderr,
|
||||
status: 'error',
|
||||
})
|
||||
}
|
||||
resolve({
|
||||
status: 'success',
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function vddk() {
|
||||
try {
|
||||
await fs.stat(VDDK_LIB_FILE)
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'error',
|
||||
error: `Vddk library is not present or accessible in ${VDDK_LIB_DIR}/ it can be downloaded from https://developer.broadcom.com/sdks/vmware-virtual-disk-development-kit-vddk/latest`,
|
||||
}
|
||||
}
|
||||
return { status: 'success' }
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function checkVddkDependencies() {
|
||||
export async function checkDependencies() {
|
||||
return {
|
||||
nbdinfo: await nbdInfo(),
|
||||
nbdkit: await nbdKit(),
|
||||
nbdkitPluginVddk: await nbdKitVddk(),
|
||||
vddk: await vddk(),
|
||||
vectura: await vectura(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createServer } from 'node:net'
|
||||
import { describe, it } from 'node:test'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { PassThrough } from 'node:stream'
|
||||
|
||||
import { connectedEsxi } from './esxi.fixtures.mjs'
|
||||
|
||||
// these bind a real port and write real temporary files, unlike the rest of the suite
|
||||
describe('nbdkit servers', function () {
|
||||
/**
|
||||
* Stands in for nbdkit: it listens on the port it is given, so that the readiness probe of the
|
||||
* server under test is exercised for real.
|
||||
*/
|
||||
const fakeNbdkit = ({ exitWith, failWith } = {}) => {
|
||||
const spawned = []
|
||||
const spawn = (command, args) => {
|
||||
const child = new EventEmitter()
|
||||
child.exitCode = null
|
||||
child.signalCode = null
|
||||
child.stdout = new PassThrough()
|
||||
child.stderr = new PassThrough()
|
||||
child.kill = signal => {
|
||||
child.signalCode = signal ?? 'SIGTERM'
|
||||
child.exitCode = 0
|
||||
entry.server?.close()
|
||||
setImmediate(() => child.emit('exit', 0, child.signalCode))
|
||||
return true
|
||||
}
|
||||
|
||||
const port = Number(args.find(argument => argument.startsWith('--port=')).slice('--port='.length))
|
||||
const entry = { args, child, command, port }
|
||||
spawned.push(entry)
|
||||
|
||||
if (failWith !== undefined) {
|
||||
setImmediate(() => child.emit('error', failWith))
|
||||
} else if (exitWith !== undefined) {
|
||||
// nbdkit exits on a bad thumbprint or a missing vddk library, without ever listening
|
||||
setImmediate(() => {
|
||||
child.exitCode = exitWith
|
||||
child.emit('exit', exitWith, null)
|
||||
})
|
||||
} else {
|
||||
entry.server = createServer()
|
||||
entry.server.listen(port, '127.0.0.1')
|
||||
}
|
||||
return child
|
||||
}
|
||||
return { spawn, spawned }
|
||||
}
|
||||
|
||||
const nbdkitEsxi = async options => {
|
||||
const { spawn, spawned } = fakeNbdkit(options)
|
||||
const { esxi } = await connectedEsxi({ spawn })
|
||||
// the thumbprint is the only step of a spawn which needs the real host
|
||||
esxi.getServerThumbprint = async () => 'AA:BB:CC'
|
||||
return { esxi, spawned }
|
||||
}
|
||||
|
||||
it('spawns one server per disk, and shares it', async function () {
|
||||
const { esxi, spawned } = await nbdkitEsxi()
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
esxi.getNbdServer('vm-1', '[ds] vm/vm.vmdk'),
|
||||
esxi.getNbdServer('vm-1', '[ds] vm/vm.vmdk'),
|
||||
])
|
||||
|
||||
// the promise is memoized: two concurrent calls used to spawn two servers, orphaning one
|
||||
assert.equal(spawned.length, 1)
|
||||
assert.equal(first.value, second.value)
|
||||
const server = first.value
|
||||
assert.equal(server.nbdInfos.exportname, '[ds] vm/vm.vmdk')
|
||||
assert.equal(server.nbdInfos.port, spawned[0].port)
|
||||
|
||||
// the server belongs to both of them: the first to let go must not stop it
|
||||
await first.dispose()
|
||||
assert.equal(server.process.exitCode, null)
|
||||
|
||||
await second.dispose()
|
||||
assert.equal(server.process.exitCode, 0)
|
||||
|
||||
await esxi.close()
|
||||
})
|
||||
|
||||
it('spawns a new server after the previous one was killed', async function () {
|
||||
const { esxi, spawned } = await nbdkitEsxi()
|
||||
|
||||
const first = await esxi.getNbdServer('vm-1', '[ds] vm/vm.vmdk')
|
||||
const firstServer = first.value
|
||||
await first.dispose()
|
||||
const second = await esxi.getNbdServer('vm-1', '[ds] vm/vm.vmdk')
|
||||
|
||||
// the entry used to be left in the map, so this handed out the dead process of a closed port
|
||||
assert.equal(spawned.length, 2)
|
||||
assert.notEqual(firstServer.nbdInfos.port, second.value.nbdInfos.port)
|
||||
assert.equal(firstServer.process.exitCode, 0)
|
||||
|
||||
await esxi.close()
|
||||
})
|
||||
|
||||
it('forgets a server which died on its own', async function () {
|
||||
const { esxi, spawned } = await nbdkitEsxi()
|
||||
|
||||
const first = await esxi.getNbdServer('vm-1', '[ds] vm/vm.vmdk')
|
||||
spawned[0].server.close()
|
||||
first.value.process.exitCode = 1
|
||||
first.value.process.emit('exit', 1, null)
|
||||
await first.value.died
|
||||
|
||||
await esxi.getNbdServer('vm-1', '[ds] vm/vm.vmdk')
|
||||
|
||||
assert.equal(spawned.length, 2)
|
||||
|
||||
await esxi.close()
|
||||
})
|
||||
|
||||
it('reports a missing nbdkit instead of terminating the process', async function () {
|
||||
const error = new Error('spawn nbdkit ENOENT')
|
||||
error.code = 'ENOENT'
|
||||
const { esxi, spawned } = await nbdkitEsxi({ failWith: error })
|
||||
|
||||
// without an 'error' listener on the child process, this used to be an uncaught event
|
||||
await assert.rejects(esxi.getNbdServer('vm-1', '[ds] vm/vm.vmdk'), { code: 'ENOENT' })
|
||||
|
||||
// the failure is not memoized either
|
||||
await assert.rejects(esxi.getNbdServer('vm-1', '[ds] vm/vm.vmdk'), { code: 'ENOENT' })
|
||||
assert.equal(spawned.length, 2)
|
||||
})
|
||||
|
||||
it('reports an nbdkit which exited before being ready', async function () {
|
||||
const { esxi, spawned } = await nbdkitEsxi({ exitWith: 1 })
|
||||
|
||||
// waiting for the port would otherwise burn the whole readiness timeout
|
||||
await assert.rejects(esxi.getNbdServer('vm-1', '[ds] vm/vm.vmdk'), {
|
||||
code: 'NBDKIT_EXITED',
|
||||
message: /^nbdkit exited with code 1 before being ready/,
|
||||
})
|
||||
assert.equal(spawned.length, 1)
|
||||
|
||||
await esxi.close()
|
||||
})
|
||||
|
||||
it('never passes the password on the command line', async function () {
|
||||
const { esxi, spawned } = await nbdkitEsxi()
|
||||
|
||||
await esxi.getNbdServer('vm-1', '[ds] vm/vm.vmdk')
|
||||
|
||||
assert.equal(
|
||||
spawned[0].args.some(argument => argument.includes('password')),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
spawned[0].args.some(argument => argument.includes('password=password')),
|
||||
false
|
||||
)
|
||||
|
||||
await esxi.close()
|
||||
})
|
||||
|
||||
it('kills the remaining servers when closing', async function () {
|
||||
const { esxi, spawned } = await nbdkitEsxi()
|
||||
|
||||
// never disposed: closing is the safety net for a caller which leaked its disposable
|
||||
const disposable = await esxi.getNbdServer('vm-1', '[ds] vm/vm.vmdk')
|
||||
const server = disposable.value
|
||||
await esxi.close()
|
||||
|
||||
assert.equal(server.process.exitCode, 0)
|
||||
assert.equal(spawned.length, 1)
|
||||
|
||||
// disposing after the close has nothing left to do, and must not throw
|
||||
await disposable.dispose()
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,13 @@
|
||||
import { asyncEach } from '@vates/async-each'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { dirname } from 'node:path'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { Disposable, pTimeout } from 'promise-toolbox'
|
||||
import { setTimeout as delay } from 'node:timers/promises'
|
||||
import { Agent } from 'undici'
|
||||
|
||||
import { findFreePort, formatNbdkitArgs, waitForPort } from './_nbdkit.mjs'
|
||||
import { resolveDiskLocation } from './_paths.mjs'
|
||||
import { getCertificateThumbprint } from './_thumbprint.mjs'
|
||||
import { VDDK_LIB_PATH } from './_vddk.mjs'
|
||||
import { formatVecturaArgs, VECTURA_BIN } from './_vectura.mjs'
|
||||
import { COWD_HEADER_LENGTH, grainDirectoryToDataMap, parseCowdHeader } from './parsers/cowd.mjs'
|
||||
import parseVmdk from './parsers/vmdk.mjs'
|
||||
import parseVmsd from './parsers/vmsd.mjs'
|
||||
@@ -25,16 +23,9 @@ import {
|
||||
traversalSpec,
|
||||
} from './soap/specs.mjs'
|
||||
import { VimClient } from './soap/VimClient.mjs'
|
||||
import { spawn } from 'node:child_process'
|
||||
import NbdClient from '@vates/nbd-client'
|
||||
|
||||
import { tmpdir } from 'node:os'
|
||||
import fs from 'node:fs/promises'
|
||||
|
||||
const { info, warn } = createLogger('xo:vmware-explorer:esxi')
|
||||
|
||||
export { VDDK_LIB_DIR, VDDK_LIB_PATH } from './_vddk.mjs'
|
||||
|
||||
const DEFAULT_DOWNLOAD_RETRIES = 4
|
||||
// a vmdk descriptor is a text file of a few hundred bytes, but a long parentFileNameHint pushes
|
||||
// its extent line further
|
||||
@@ -52,9 +43,6 @@ const DEFAULT_TASK_TIMEOUT = 60e3
|
||||
const MAX_CBT_QUERIES = 1024
|
||||
const MAX_RETRY_DELAY = 30e3
|
||||
const MAX_TASK_POLL_DELAY = 5e3
|
||||
const NBDKIT_KILL_TIMEOUT = 10e3
|
||||
// connecting the vddk library to the host can be slow
|
||||
const NBDKIT_READY_TIMEOUT = 60e3
|
||||
|
||||
// a failure which will not fix itself must not be retried: a missing file, a rejected
|
||||
// authentication or a programming error only delay the report of the real problem
|
||||
@@ -89,12 +77,6 @@ function isRetryableError(error) {
|
||||
return error?.name === 'TimeoutError'
|
||||
}
|
||||
|
||||
const noop = () => {}
|
||||
|
||||
// the options change what the server exports, so they are part of its identity
|
||||
const nbdServerKey = (vmId, diskPath, { compression, singleLink, threads }) =>
|
||||
JSON.stringify([vmId, diskPath, singleLink, threads, compression])
|
||||
|
||||
/**
|
||||
* Everything the change tracking of a VM is addressed by, as {@link Esxi#getChangeTracking} reads
|
||||
* it. Opaque to its caller, which reads it once and hands it back to {@link Esxi#getDataMap}.
|
||||
@@ -148,10 +130,8 @@ export default class Esxi extends EventEmitter {
|
||||
#httpsAgent
|
||||
#user
|
||||
#password
|
||||
#spawn
|
||||
#thumbprint
|
||||
#vimClient
|
||||
#nbdServers = new Map()
|
||||
|
||||
/**
|
||||
* @param {string} host
|
||||
@@ -160,19 +140,11 @@ export default class Esxi extends EventEmitter {
|
||||
* @param {boolean} sslVerify
|
||||
* @param {object} [options]
|
||||
* @param {typeof globalThis.fetch} [options.fetch] - injectable fetch implementation, for tests
|
||||
* @param {typeof spawn} [options.spawn] - injectable process spawner, for tests
|
||||
* @param {object} [options.vimClient] - injectable SOAP client, for tests
|
||||
*/
|
||||
constructor(
|
||||
host,
|
||||
user,
|
||||
password,
|
||||
sslVerify,
|
||||
{ fetch: fetchImplementation, spawn: spawnImplementation, vimClient } = {}
|
||||
) {
|
||||
constructor(host, user, password, sslVerify, { fetch: fetchImplementation, vimClient } = {}) {
|
||||
super()
|
||||
this.#fetchImpl = fetchImplementation ?? globalThis.fetch
|
||||
this.#spawn = spawnImplementation ?? spawn
|
||||
this.#host = host.trim()
|
||||
this.#user = user
|
||||
this.#password = password
|
||||
@@ -217,20 +189,10 @@ export default class Esxi extends EventEmitter {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async close() {
|
||||
try {
|
||||
// the servers would only be reaped by `--exit-with-parent`, i.e. when this process ends
|
||||
await asyncEach([...this.#nbdServers.keys()], key => this.#killNbdServerByKey(key), {
|
||||
concurrency: 4,
|
||||
// every server must be tried: `asyncEach` stops on the first error by default, which would
|
||||
// leave the other ones running
|
||||
stopOnError: false,
|
||||
})
|
||||
} finally {
|
||||
// a server refusing to die must not leave the session open on the host, nor the sockets of
|
||||
// the agent
|
||||
await this.#vimClient.close()
|
||||
await this.#httpsAgent?.close()
|
||||
}
|
||||
// the vectura processes are owned by the `NbdStdioClient` reading through them, and stopped
|
||||
// when it disconnects: there is nothing to reap here
|
||||
await this.#vimClient.close()
|
||||
await this.#httpsAgent?.close()
|
||||
}
|
||||
|
||||
async #computeDatacenters() {
|
||||
@@ -956,10 +918,10 @@ export default class Esxi extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* SHA-1 fingerprint of the certificate of the host, as the vddk library expects it.
|
||||
* SHA-256 fingerprint of the certificate of the host, as vectura's `--thumbprint` expects it.
|
||||
*
|
||||
* Memoized: it used to be computed again for every nbdkit server, with two openssl processes
|
||||
* every time.
|
||||
* Memoized: it used to be computed again for every disk export, with two openssl processes every
|
||||
* time.
|
||||
*
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
@@ -978,188 +940,40 @@ export default class Esxi extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts an nbdkit server exporting a disk of a VM, or shares the one already serving it.
|
||||
* Settings of an `NbdStdioClient` reading one disk of a VM through vectura.
|
||||
*
|
||||
* The server is stopped when the disposable is disposed, and a server shared by several callers
|
||||
* survives until the last of them disposes: the caller no longer has to name it again, with the
|
||||
* exact same options, to stop it.
|
||||
* Nothing is started here: `vectura serve` speaks NBD on its own standard streams, so one process
|
||||
* serves exactly one client, and the client is what spawns it, kills it on `disconnect()` and
|
||||
* restarts it when a read is retried. The caller passes these settings, and `NbdStdioClient` as
|
||||
* the `ClientClass`, to `NbdDisk`.
|
||||
*
|
||||
* @param {string} vmId
|
||||
* @param {string} diskPath - `[datastore] dir/disk.vmdk`
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.compression]
|
||||
* @param {boolean} [options.singleLink] - export the top delta only
|
||||
* @param {number} [options.threads]
|
||||
* @returns {Promise<Disposable<{ died: Promise<object>, nbdInfos: object, process: object }>>}
|
||||
* @param {string} [options.compression] - `skipz`, `zlib` or `none`, vectura defaults to `skipz`
|
||||
* @param {number} [options.depth] - host reads in flight, 1 to 32, vectura defaults to 16
|
||||
* @returns {Promise<{ args: Array<string>, command: string, env: object, exportname: string }>}
|
||||
*/
|
||||
async getNbdServer(vmId, diskPath, { compression = 'fastlz', singleLink = false, threads = 1 } = {}) {
|
||||
const key = nbdServerKey(vmId, diskPath, { compression, singleLink, threads })
|
||||
|
||||
let entry = this.#nbdServers.get(key)
|
||||
if (entry === undefined) {
|
||||
// the promise is memoized, not its result: the previous implementation had six await points
|
||||
// between the check and the registration, so two concurrent calls spawned two servers and
|
||||
// orphaned the first one
|
||||
entry = { pending: this.#spawnNbdKitProcess(vmId, diskPath, { compression, singleLink, threads }), users: 0 }
|
||||
this.#nbdServers.set(key, entry)
|
||||
|
||||
// neither a failed spawn nor a dead server must be handed out to the next caller
|
||||
const forget = () => {
|
||||
if (this.#nbdServers.get(key) === entry) {
|
||||
this.#nbdServers.delete(key)
|
||||
}
|
||||
}
|
||||
entry.pending.then(server => server.died.then(forget), forget)
|
||||
}
|
||||
|
||||
entry.users += 1
|
||||
|
||||
let server
|
||||
try {
|
||||
server = await entry.pending
|
||||
} catch (error) {
|
||||
entry.users -= 1
|
||||
throw error
|
||||
}
|
||||
|
||||
// `Disposable` refuses a second disposal itself, so this runs exactly once per caller
|
||||
const dispose = async () => {
|
||||
entry.users -= 1
|
||||
// `close()` stops every server left, a disposal after it has nothing to do
|
||||
if (entry.users === 0 && this.#nbdServers.get(key) === entry) {
|
||||
await this.#killNbdServerByKey(key)
|
||||
}
|
||||
}
|
||||
|
||||
return new Disposable(dispose, server)
|
||||
}
|
||||
|
||||
async #spawnNbdKitProcess(vmId, diskPath, { compression, singleLink, threads }) {
|
||||
const thumbprint = await this.getServerThumbprint()
|
||||
const port = await findFreePort()
|
||||
const tmpDir = await fs.mkdtemp(join(tmpdir(), 'xo-server'))
|
||||
const passFile = join(tmpDir, 'params')
|
||||
const outFd = await fs.open(join(tmpDir, 'stdout'), 'a')
|
||||
const errFd = await fs.open(join(tmpDir, 'stderr'), 'a')
|
||||
// the file holds a password, and only the directory was protecting it
|
||||
await fs.writeFile(passFile, this.#password, { mode: 0o600 })
|
||||
|
||||
const args = formatNbdkitArgs({
|
||||
compression,
|
||||
diskPath,
|
||||
host: this.#host,
|
||||
libdir: VDDK_LIB_PATH,
|
||||
passFile,
|
||||
port,
|
||||
singleLink,
|
||||
threads,
|
||||
thumbprint,
|
||||
user: this.#user,
|
||||
vmId,
|
||||
})
|
||||
|
||||
const nbdKitProcess = this.#spawn('nbdkit', args, {
|
||||
cwd: tmpDir,
|
||||
env: {
|
||||
...process.env,
|
||||
LD_LIBRARY_PATH: `${VDDK_LIB_PATH}/lib64`,
|
||||
},
|
||||
})
|
||||
nbdKitProcess.stdout.pipe(outFd.createWriteStream())
|
||||
nbdKitProcess.stderr.pipe(errFd.createWriteStream())
|
||||
info(`nbdkit logs of ${diskPath} are in ${tmpDir}`)
|
||||
|
||||
// `error` is emitted when the binary is missing: without a listener, it is an uncaught event
|
||||
// which terminates the whole process
|
||||
const died = new Promise(resolve => {
|
||||
nbdKitProcess.once('error', error => resolve({ error }))
|
||||
nbdKitProcess.once('exit', (code, signal) => resolve({ code, signal }))
|
||||
})
|
||||
|
||||
died.then(async ({ code, error, signal }) => {
|
||||
await Promise.all([outFd.close().catch(noop), errFd.close().catch(noop)])
|
||||
if (error !== undefined) {
|
||||
warn('nbdkit could not be started', { args, error, tmpDir })
|
||||
} else if (code !== 0) {
|
||||
warn(`nbdkit server process exited with code ${code} ,detailed logs are in ${tmpDir}/stderr `, { signal })
|
||||
} else {
|
||||
// nothing to look at, the logs would pile up in the temporary directory
|
||||
await fs.rm(tmpDir, { force: true, recursive: true }).catch(noop)
|
||||
}
|
||||
})
|
||||
|
||||
// the readiness of the server and its death are racing: nbdkit exits on a bad thumbprint or a
|
||||
// missing library, and waiting for the port would then burn the whole timeout
|
||||
const failed = died.then(({ code, error }) => {
|
||||
if (error !== undefined) {
|
||||
// the spawn itself failed, and its own code already says why, e.g. `ENOENT` when nbdkit is
|
||||
// not installed
|
||||
throw error
|
||||
}
|
||||
const exited = new Error(
|
||||
`nbdkit exited with code ${code} before being ready, detailed logs are in ${tmpDir}/stderr`
|
||||
)
|
||||
exited.code = 'NBDKIT_EXITED'
|
||||
throw exited
|
||||
})
|
||||
failed.catch(noop) // the race is usually won by the readiness of the server
|
||||
|
||||
const readiness = new AbortController()
|
||||
try {
|
||||
await Promise.race([waitForPort(port, { signal: readiness.signal, timeout: NBDKIT_READY_TIMEOUT }), failed])
|
||||
} catch (error) {
|
||||
await this.#killNbdServer({ died, process: nbdKitProcess }, diskPath).catch(noop)
|
||||
throw error
|
||||
} finally {
|
||||
// losing the race must not leave a probe running until its own timeout
|
||||
readiness.abort()
|
||||
// nbdkit reads the password once, while configuring its plugin, which is done by the time it
|
||||
// listens
|
||||
await fs.unlink(passFile).catch(error => warn('failed to remove the password file', { error, passFile }))
|
||||
}
|
||||
|
||||
async getVecturaSpawnSettings(vmId, diskPath, { compression, depth } = {}) {
|
||||
return {
|
||||
died,
|
||||
nbdInfos: { address: '127.0.0.1', port, exportname: diskPath },
|
||||
process: nbdKitProcess,
|
||||
command: VECTURA_BIN,
|
||||
args: formatVecturaArgs({
|
||||
compression,
|
||||
depth,
|
||||
diskPath,
|
||||
host: this.#host,
|
||||
thumbprint: await this.getServerThumbprint(),
|
||||
user: this.#user,
|
||||
vmId,
|
||||
}),
|
||||
// vectura reads the password from there and nowhere else: never a flag, never a file, so it
|
||||
// cannot be read from the command line of the process
|
||||
env: { ...process.env, VECTURA_PASSWORD: this.#password },
|
||||
// vectura serves a single unnamed export, the disk is already named by `--disk`
|
||||
exportname: '',
|
||||
}
|
||||
}
|
||||
|
||||
async #killNbdServer(server, label) {
|
||||
const { died, process: nbdKitProcess } = server
|
||||
if (nbdKitProcess.exitCode !== null || nbdKitProcess.signalCode !== null) {
|
||||
return
|
||||
}
|
||||
nbdKitProcess.kill()
|
||||
try {
|
||||
await pTimeout.call(died, NBDKIT_KILL_TIMEOUT)
|
||||
} catch (error) {
|
||||
warn('nbdkit did not exit, killing it', { error, label, pid: nbdKitProcess.pid })
|
||||
nbdKitProcess.kill('SIGKILL')
|
||||
await died
|
||||
}
|
||||
}
|
||||
|
||||
async #killNbdServerByKey(key) {
|
||||
const entry = this.#nbdServers.get(key)
|
||||
if (entry === undefined) {
|
||||
warn(`nbdkit server ${key} was already killed`)
|
||||
return
|
||||
}
|
||||
// the entry used to be left in place, so the next spawn handed out a dead process listening on
|
||||
// nothing
|
||||
this.#nbdServers.delete(key)
|
||||
|
||||
let server
|
||||
try {
|
||||
server = await entry.pending
|
||||
} catch {
|
||||
// the spawn failed, there is nothing left to kill
|
||||
return
|
||||
}
|
||||
await this.#killNbdServer(server, key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one `QueryChangedDiskAreas`.
|
||||
*
|
||||
@@ -1326,8 +1140,8 @@ export default class Esxi extends EventEmitter {
|
||||
/**
|
||||
* Blocks of a disk to read, from the change tracking of the host.
|
||||
*
|
||||
* Needs no nbdkit server, no vddk library and no nbdinfo: one call answers from the `-ctk.vmdk`
|
||||
* the host maintains. Two questions, the same call:
|
||||
* Needs nothing installed and no disk export: one call answers from the `-ctk.vmdk` the host
|
||||
* maintains. Two questions, the same call:
|
||||
* - without `baseDiskPath`, every block the disk has ever used, across the whole chain
|
||||
* - with it, only the blocks written since the disk a previous import already read
|
||||
*
|
||||
@@ -1432,37 +1246,6 @@ export default class Esxi extends EventEmitter {
|
||||
return areas
|
||||
}
|
||||
|
||||
async #getDataMapFromVddk(vmId, datastoreName, diskPath, signal) {
|
||||
return Disposable.use(
|
||||
this.getNbdServer(vmId, `[${datastoreName}] ${diskPath}`, { singleLink: true }),
|
||||
async ({ nbdInfos }) => {
|
||||
const start = Date.now()
|
||||
info('nbd server for data map spawned')
|
||||
signal?.throwIfAborted()
|
||||
|
||||
// the client is built here, and not before the server: a spawn which failed used to leave
|
||||
// it undefined, and the `TypeError` of `nbdClient.disconnect()` replaced the real error,
|
||||
// feeding the fallback with a misleading cause
|
||||
const nbdClient = new NbdClient(nbdInfos)
|
||||
try {
|
||||
await nbdClient.connect()
|
||||
|
||||
info('nbd client for data map connected')
|
||||
|
||||
const dataMap = await nbdClient.getMap(signal)
|
||||
|
||||
info(
|
||||
`got the data map of the single disk in ${Math.round((Date.now() - start) / 1000)} seconds ,${dataMap.length} blocks`
|
||||
)
|
||||
|
||||
return dataMap
|
||||
} finally {
|
||||
await nbdClient.disconnect().catch(error => warn('error while disconnecting the nbd client', { error }))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async #readRange(datastoreName, path, start, length, signal) {
|
||||
// an HTTP range is inclusive: asking for `0-512` reads 513 bytes
|
||||
const res = await this.download(datastoreName, path, { range: `${start}-${start + length - 1}`, signal })
|
||||
@@ -1502,8 +1285,9 @@ export default class Esxi extends EventEmitter {
|
||||
* Blocks of a disk which hold data, used to read only what the disk uses.
|
||||
*
|
||||
* The change tracking of the host answers both questions and is asked first: it is a documented
|
||||
* call every backup product has leaned on for years, where the map read through the vddk needs
|
||||
* an nbdkit server, the vddk library and nbdinfo, all version-coupled.
|
||||
* call every backup product has leaned on for years. When it refuses, the metadata of the delta
|
||||
* is read straight from the datastore, which is the only other source left — vectura serves the
|
||||
* disk and nothing else, there is no block map to ask it for.
|
||||
*
|
||||
* @param {string} vmId
|
||||
* @param {string} datastoreName
|
||||
@@ -1530,19 +1314,12 @@ export default class Esxi extends EventEmitter {
|
||||
}
|
||||
|
||||
if (baseDiskPath === undefined) {
|
||||
// the other two sources describe a single link of the chain, which is not the disk being
|
||||
// read: handing one of them over as the map of a whole disk would drop everything the
|
||||
// parents hold. Reading the disk is the only other answer
|
||||
// the metadata of a delta describes a single link of the chain, which is not the disk being
|
||||
// read: handing it over as the map of a whole disk would drop everything the parents hold.
|
||||
// Reading the disk is the only other answer
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
// We await the result of getDataMapFromVddk so we can catch errors and fallback to the direct metadata reading.
|
||||
return await this.#getDataMapFromVddk(vmId, datastoreName, diskPath, signal)
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted()
|
||||
warn('error while getting datamap from vddk, fall back to a direct metadata reading', { error })
|
||||
return this.#getDataMapFromCowd(datastoreName, diskPath, signal)
|
||||
}
|
||||
return this.#getDataMapFromCowd(datastoreName, diskPath, signal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { once } from 'node:events'
|
||||
|
||||
import { connectedEsxi, DATACENTERS, FakeVimClient, moRef, page, response } from './esxi.fixtures.mjs'
|
||||
import Esxi from './esxi.mjs'
|
||||
import { VECTURA_BIN } from './_vectura.mjs'
|
||||
|
||||
describe('search', function () {
|
||||
it('destroys the container view it created', async function () {
|
||||
@@ -779,12 +780,6 @@ describe('keepAlive', function () {
|
||||
describe('getDataMap', function () {
|
||||
const BLOCK_LENGTH = 4096 * 512
|
||||
|
||||
// the thumbprint is the first step of a spawn: failing it sends the code down the fallback
|
||||
// without binding a port nor writing a temporary file
|
||||
const failingThumbprint = async () => {
|
||||
throw new Error('no vddk on this machine')
|
||||
}
|
||||
|
||||
const DELTA_DESCRIPTOR = `# Disk DescriptorFile
|
||||
version=1
|
||||
CID=d7980f7a
|
||||
@@ -827,7 +822,6 @@ RW 16384 VMFSSPARSE "vm-000001-delta.vmdk"
|
||||
return response({ status: 206, body: DELTA_DESCRIPTOR })
|
||||
},
|
||||
})
|
||||
esxi.getServerThumbprint = failingThumbprint
|
||||
|
||||
const dataMap = await esxi.getDataMap('vm-1', 'ds main', 'a.vm/vm-000001.vmdk', {
|
||||
baseDiskPath: 'a.vm/vm.vmdk',
|
||||
@@ -854,14 +848,13 @@ RW 16384 VMFSSPARSE "vm-000001-delta.vmdk"
|
||||
fetch: async url =>
|
||||
response({ status: 206, body: url.pathname.endsWith('-delta.vmdk') ? header : DELTA_DESCRIPTOR }),
|
||||
})
|
||||
esxi.getServerThumbprint = failingThumbprint
|
||||
|
||||
await assert.rejects(esxi.getDataMap('vm-1', 'ds main', 'a.vm/vm-000001.vmdk', { baseDiskPath: 'a.vm/vm.vmdk' }), {
|
||||
code: 'NO_DATA_MAP',
|
||||
})
|
||||
})
|
||||
|
||||
// the change tracking of the host answers a delta without an nbdkit server nor the vddk
|
||||
// the change tracking of the host answers a delta without exporting the disk at all
|
||||
describe('from the change tracking', function () {
|
||||
const CAPACITY = 16 * 1024 * 1024 * 1024
|
||||
const ACTIVE = '[ds main] a.vm/vm-000001.vmdk'
|
||||
@@ -938,8 +931,7 @@ RW 16384 VMFSSPARSE "vm-000001-delta.vmdk"
|
||||
// fallback which reaches the network would answer with a DNS failure a few seconds later
|
||||
fetch: async () => response({ status: 206, body: 'not a vmdk descriptor' }),
|
||||
})
|
||||
// the vddk and the metadata reading must never be reached by a successful CBT query
|
||||
esxi.getServerThumbprint = failingThumbprint
|
||||
// the metadata reading must never be reached by a successful CBT query
|
||||
return { esxi, vimClient }
|
||||
}
|
||||
|
||||
@@ -1032,7 +1024,7 @@ RW 16384 VMFSSPARSE "vm-000001-delta.vmdk"
|
||||
it('falls back when no snapshot recorded a changeId for the disk of the previous import', async function () {
|
||||
const { esxi, vimClient } = await cbtEsxi({ changeId: null })
|
||||
|
||||
// the vddk then the metadata reading, which has nothing to read from the fake responses
|
||||
// the metadata reading, which has nothing to read from the fake responses
|
||||
await assert.rejects(
|
||||
esxi.getDataMap('vm-1', 'ds main', 'a.vm/vm-000001.vmdk', { baseDiskPath: 'a.vm/vm.vmdk' }),
|
||||
{ code: 'NO_DATA_MAP' }
|
||||
@@ -1072,9 +1064,8 @@ RW 16384 VMFSSPARSE "vm-000001-delta.vmdk"
|
||||
return response({ status: 206, body: 'not a vmdk descriptor' })
|
||||
},
|
||||
})
|
||||
esxi.getServerThumbprint = failingThumbprint
|
||||
|
||||
// the vddk and the metadata reading both describe a single link of the chain: handing one
|
||||
// the metadata reading describes a single link of the chain: handing one
|
||||
// over as the map of a whole disk would drop everything its parents hold
|
||||
assert.equal(await esxi.getDataMap('vm-1', 'ds main', 'a.vm/vm-000001.vmdk'), undefined)
|
||||
assert.deepEqual(fetched, [])
|
||||
@@ -1137,3 +1128,59 @@ RW 16384 VMFSSPARSE "vm-000001-delta.vmdk"
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getVecturaSpawnSettings', function () {
|
||||
const vecturaEsxi = async () => {
|
||||
const { esxi } = await connectedEsxi()
|
||||
// the only step needing the real host
|
||||
esxi.getServerThumbprint = async () => 'sha256:AA:BB:CC'
|
||||
return esxi
|
||||
}
|
||||
|
||||
it('builds the vectura command line of one disk', async function () {
|
||||
const esxi = await vecturaEsxi()
|
||||
|
||||
const { args, command, exportname } = await esxi.getVecturaSpawnSettings('1', '[ds main] vm/vm.vmdk')
|
||||
|
||||
assert.equal(command, VECTURA_BIN)
|
||||
assert.deepEqual(args, [
|
||||
'serve',
|
||||
'--host',
|
||||
'esxi.test',
|
||||
'--user',
|
||||
'user',
|
||||
'--vm-id',
|
||||
'1',
|
||||
'--disk',
|
||||
'[ds main] vm/vm.vmdk',
|
||||
'--thumbprint',
|
||||
'sha256:AA:BB:CC',
|
||||
])
|
||||
// vectura serves a single unnamed export
|
||||
assert.equal(exportname, '')
|
||||
})
|
||||
|
||||
it('passes the password through the environment and never on the command line', async function () {
|
||||
const esxi = await vecturaEsxi()
|
||||
|
||||
const { args, env } = await esxi.getVecturaSpawnSettings('1', '[ds main] vm/vm.vmdk')
|
||||
|
||||
assert.equal(env.VECTURA_PASSWORD, 'password')
|
||||
assert.equal(
|
||||
args.some(argument => argument.includes('password')),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves compression and depth out unless they are asked for', async function () {
|
||||
const esxi = await vecturaEsxi()
|
||||
|
||||
const { args } = await esxi.getVecturaSpawnSettings('1', '[ds main] vm/vm.vmdk', { compression: 'none', depth: 4 })
|
||||
|
||||
assert.deepEqual(args.slice(-4), ['--compression', 'none', '--depth', '4'])
|
||||
|
||||
const { args: bare } = await esxi.getVecturaSpawnSettings('1', '[ds main] vm/vm.vmdk')
|
||||
assert.equal(bare.includes('--compression'), false)
|
||||
assert.equal(bare.includes('--depth'), false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,11 +6,9 @@
|
||||
"description": "Reads the VMs and the disks of an ESXi host or a vCenter",
|
||||
"dependencies": {
|
||||
"@vates/async-each": "^1.0.3",
|
||||
"@vates/nbd-client": "^3.5.1",
|
||||
"@vates/node-vsphere-soap": "^2.1.3",
|
||||
"@xen-orchestra/log": "^0.7.2",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"semver": "^7.7.2",
|
||||
"undici": "^7.8.0"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Cargo
|
||||
/target/
|
||||
**/*.rs.bk
|
||||
|
||||
# Editors and operating systems
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
.idea/
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
|
||||
# Local environment files are never committed. VECTURA_PASSWORD lives in the
|
||||
# shell that runs the command, not in a file next to the source.
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
+952
@@ -0,0 +1,952 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
|
||||
dependencies = [
|
||||
"asn1-rs-derive",
|
||||
"asn1-rs-impl",
|
||||
"displaydoc",
|
||||
"nom",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
"thiserror",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs-derive"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs-impl"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"untrusted 0.7.1",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||
|
||||
[[package]]
|
||||
name = "der-parser"
|
||||
version = "10.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"displaydoc",
|
||||
"nom",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oid-registry"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.42.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rcgen"
|
||||
version = "0.14.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"rustls-pki-types",
|
||||
"time",
|
||||
"x509-parser",
|
||||
"yasna",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted 0.9.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusticata-macros"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
|
||||
dependencies = [
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted 0.9.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "vectura"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"clap",
|
||||
"flate2",
|
||||
"quick-xml",
|
||||
"rcgen",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-pki-types",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "x509-parser"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"aws-lc-rs",
|
||||
"data-encoding",
|
||||
"der-parser",
|
||||
"lazy_static",
|
||||
"nom",
|
||||
"oid-registry",
|
||||
"rusticata-macros",
|
||||
"thiserror",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yasna"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
|
||||
dependencies = [
|
||||
"bit-vec",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,55 @@
|
||||
[package]
|
||||
name = "vectura"
|
||||
version = "0.1.1"
|
||||
edition = "2024"
|
||||
description = "Standalone client for VMware's NFC disk-read protocol"
|
||||
# The license of the Xen Orchestra application components, see LICENSE.md at
|
||||
# the root of the repository. The crate stays unpublished, so deny.toml keeps
|
||||
# skipping it: its allow list says what a dependency may bring into the
|
||||
# binary, and AGPL is deliberately not on it.
|
||||
license = "AGPL-3.0-or-later"
|
||||
publish = false
|
||||
# No `rust-version`: the toolchain pin lives in rust-toolchain.toml alone.
|
||||
|
||||
[dependencies]
|
||||
aws-lc-rs = "1.18.1"
|
||||
clap = { version = "4.6.6", features = ["derive"] }
|
||||
flate2 = { version = "1.1.10", default-features = false, features = ["rust_backend"] }
|
||||
quick-xml = "0.42.0"
|
||||
rustls = "0.23"
|
||||
rustls-native-certs = "0.8.4"
|
||||
rustls-pki-types = "1.15.1"
|
||||
thiserror = "2.0.20"
|
||||
|
||||
[dev-dependencies]
|
||||
rcgen = { version = "0.14.10", default-features = false, features = ["aws_lc_rs"] }
|
||||
serde_json = "1.0.151"
|
||||
|
||||
[profile.release]
|
||||
# The binary is committed and shipped in the npm package: no symbols.
|
||||
strip = true
|
||||
|
||||
[lints.rust]
|
||||
# The whole crate is safe Rust. `forbid` cannot be overridden by an attribute
|
||||
# lower down, unlike `deny`, which is the point.
|
||||
unsafe_code = "forbid"
|
||||
missing_docs = "warn"
|
||||
missing_debug_implementations = "warn"
|
||||
redundant_imports = "warn"
|
||||
redundant_lifetimes = "warn"
|
||||
trivial_numeric_casts = "warn"
|
||||
unused_lifetimes = "warn"
|
||||
|
||||
[lints.clippy]
|
||||
# Every warning is an error in CI through
|
||||
# `cargo clippy -- -D warnings`; the groups stay at `warn` here so that a
|
||||
# local `cargo build` still compiles while a fix is in progress.
|
||||
all = { level = "warn", priority = -1 }
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
# Individual lints from the `restriction` group; each one names a construct
|
||||
# the project does not use.
|
||||
allow_attributes_without_reason = "warn"
|
||||
clone_on_ref_ptr = "warn"
|
||||
map_err_ignore = "warn"
|
||||
undocumented_unsafe_blocks = "warn"
|
||||
unused_result_ok = "warn"
|
||||
@@ -0,0 +1,109 @@
|
||||
# Vectura
|
||||
|
||||
Vectura reads VMware virtual disks over the NFC protocol. It is one Linux
|
||||
binary with two commands:
|
||||
|
||||
- `vectura serve` logs in to an ESXi host, opens one virtual disk and serves
|
||||
it read-only as an NBD export on its own standard input and output. The
|
||||
program that spawned it reads any range of the disk, in any order, and
|
||||
ends the session when it is done.
|
||||
- `vectura fingerprint` prints the certificate thumbprints a host presents
|
||||
on its management and data ports, so an operator can pin them before any
|
||||
credential is sent.
|
||||
|
||||
Vectura targets ESXi 7.x and 8.x hosts, reached directly or through a
|
||||
vCenter; with a vCenter, the disk is read from the ESXi host its ticket
|
||||
names. An ESXi 6.x host is refused at the data port's greeting. Certificate
|
||||
verification can be pinned or pointed at a CA bundle but never skipped, and
|
||||
the password is read from `VECTURA_PASSWORD` only.
|
||||
|
||||
## Install
|
||||
|
||||
The binary, `vectura`, ships in this directory with
|
||||
`@xen-orchestra/vmware-explorer`, and xo-server runs it from there. It runs
|
||||
on Debian 12, Ubuntu 22.04 or newer (amd64) and needs nothing but glibc. To
|
||||
use it by hand, copy it to a directory in `PATH`:
|
||||
|
||||
```sh
|
||||
sudo install vectura /usr/local/bin/
|
||||
```
|
||||
|
||||
To build from source, install the
|
||||
`build-essential` package (the TLS library compiles C) and let `rustup`
|
||||
pick the pinned toolchain from `rust-toolchain.toml`:
|
||||
|
||||
```sh
|
||||
cargo build --release --locked
|
||||
```
|
||||
|
||||
## Try it
|
||||
|
||||
```sh
|
||||
vectura fingerprint --host esxi.example
|
||||
read -rs VECTURA_PASSWORD && export VECTURA_PASSWORD
|
||||
vectura serve --host esxi.example --user root --vm-id 3 \
|
||||
--disk '[datastore1] vm/vm.vmdk' \
|
||||
--thumbprint sha256:<the 443 sha256 line printed above>
|
||||
```
|
||||
|
||||
After the last command, standard output is an NBD stream and standard error
|
||||
is the only place a message for a person appears. An NBD client that can
|
||||
talk to a child process over its pipes, such as libnbd's `connect_command`,
|
||||
reads the disk from there. The tutorial below walks through it.
|
||||
|
||||
## Documentation
|
||||
|
||||
Tutorial, for the first run:
|
||||
|
||||
- [Read your first disk](docs/tutorials/read-your-first-disk.md)
|
||||
|
||||
How-to guides, one task each:
|
||||
|
||||
- [Pin a host certificate](docs/how-to/pin-a-host-certificate.md)
|
||||
- [Copy a disk to a raw image](docs/how-to/copy-a-disk-to-a-raw-image.md)
|
||||
- [Drive Vectura from a program](docs/how-to/drive-vectura-from-a-program.md)
|
||||
- [Tune depth and compression](docs/how-to/tune-depth-and-compression.md)
|
||||
- [Troubleshoot a session](docs/how-to/troubleshoot-a-session.md)
|
||||
|
||||
Reference, the facts:
|
||||
|
||||
- [Command line](docs/reference/command-line.md)
|
||||
- [The NBD export](docs/reference/nbd-export.md)
|
||||
- [The transcript](docs/reference/transcript.md)
|
||||
- [Host requirements and limits](docs/reference/host-requirements-and-limits.md)
|
||||
|
||||
Explanation, the reasoning:
|
||||
|
||||
- [How a disk is read](docs/explanation/how-a-disk-is-read.md)
|
||||
- [The security model](docs/explanation/security-model.md)
|
||||
- [Scope and design choices](docs/explanation/scope-and-design-choices.md)
|
||||
|
||||
## Development and release
|
||||
|
||||
`.github/workflows/vectura.yml` runs `cargo fmt`, `cargo clippy`, the tests,
|
||||
`cargo deny`, the coverage floor and the binary build on every push that
|
||||
touches this directory. The pre-commit hook runs `cargo fmt --check` when a
|
||||
`.rs` file is staged.
|
||||
|
||||
A change to vectura needs its own line in the packages list of
|
||||
`CHANGELOG.unreleased.md`:
|
||||
|
||||
```md
|
||||
- vectura patch
|
||||
```
|
||||
|
||||
`scripts/gen-deps-list.js` then lists `./scripts/bump-pkg vectura <type>`,
|
||||
followed by a release of `@xen-orchestra/vmware-explorer`, which bundles the
|
||||
binary. `bump-pkg` runs `scripts/release.sh`: it bumps the version in
|
||||
`Cargo.toml` and `Cargo.lock`, rebuilds the binary in place of the previous
|
||||
one and commits both as `feat(vectura): <version>`.
|
||||
`tests/bundled_binary.rs` fails when the binary and `Cargo.toml` disagree, or
|
||||
when the binary lost its executable bit.
|
||||
|
||||
The build links against glibc 2.34 through zig, whatever the glibc of the
|
||||
machine, so the binary runs on Debian 12 and Ubuntu 22.04. It needs
|
||||
[zig](https://ziglang.org/download/) (0.16.0 tested) and:
|
||||
|
||||
```sh
|
||||
cargo install --locked [email protected]
|
||||
```
|
||||
@@ -0,0 +1,4 @@
|
||||
# Clippy configuration. `doc_markdown` (pedantic) asks for backticks around
|
||||
# any CamelCase word in a doc comment; these are the product names the docs
|
||||
# use as prose, not identifiers. `..` keeps Clippy's own default list.
|
||||
doc-valid-idents = ["VMware", "ESXi", "vCenter", "vSphere", "OpenSSL", ".."]
|
||||
@@ -0,0 +1,79 @@
|
||||
# cargo-deny policy. `cargo deny --locked check` runs in CI (Vectura workflow)
|
||||
# and locally after `cargo install cargo-deny`.
|
||||
# Every key states why it holds its value, so a change here is a reviewed
|
||||
# decision rather than a knob turned to make a red run green.
|
||||
|
||||
[graph]
|
||||
# Resolve the graph for the platforms Vectura is built for, not for whichever
|
||||
# host runs the check, so a platform-only dependency is still audited.
|
||||
targets = [
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"aarch64-unknown-linux-gnu",
|
||||
"x86_64-apple-darwin",
|
||||
"aarch64-apple-darwin",
|
||||
"x86_64-pc-windows-msvc",
|
||||
]
|
||||
# Audit optional dependencies too: a feature nobody enables today is enabled
|
||||
# by someone tomorrow.
|
||||
all-features = true
|
||||
|
||||
[advisories]
|
||||
# Empty on purpose. An advisory is waived here only with its RUSTSEC id, the
|
||||
# reason and a date, and the pull request says why.
|
||||
ignore = []
|
||||
# A yanked crate was pulled by its author for a reason; do not build on it.
|
||||
yanked = "deny"
|
||||
# Unmaintained or unsound dependencies are reported for the crates this
|
||||
# workspace depends on directly or transitively, which is all of them here.
|
||||
unmaintained = "workspace"
|
||||
unsound = "workspace"
|
||||
|
||||
[licenses]
|
||||
# What may enter the binary. OpenSSL is on the list for aws-lc-sys, which the
|
||||
# default rustls crypto provider builds on; nothing else needs it.
|
||||
allow = [
|
||||
"0BSD",
|
||||
"Apache-2.0",
|
||||
"Apache-2.0 WITH LLVM-exception",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"CC0-1.0",
|
||||
"ISC",
|
||||
"MIT",
|
||||
"MIT-0",
|
||||
"MPL-2.0",
|
||||
"OpenSSL",
|
||||
"Unicode-3.0",
|
||||
"Unlicense",
|
||||
"Zlib",
|
||||
]
|
||||
# How sure the license-text classifier must be before a detected license is
|
||||
# accepted; below this the crate is reported instead of guessed.
|
||||
confidence-threshold = 0.8
|
||||
# Per-crate exceptions to the allow list; empty, and each one added must say
|
||||
# why in a comment.
|
||||
exceptions = []
|
||||
# Development dependencies ship in nobody's binary, but they run on every
|
||||
# contributor machine and in CI, so their licenses are checked too.
|
||||
include-dev = true
|
||||
|
||||
[licenses.private]
|
||||
# Vectura is AGPL-3.0-or-later and `publish = false`. Skip the crate itself:
|
||||
# the allow list above says what a dependency may bring into the binary, and
|
||||
# AGPL stays off it on purpose.
|
||||
ignore = true
|
||||
|
||||
[bans]
|
||||
# Two versions of one crate double the audit surface; reported, not fatal,
|
||||
# because a dependency bump often carries a duplicate for one release.
|
||||
multiple-versions = "warn"
|
||||
# A `*` requirement accepts whatever the registry serves next; refuse it.
|
||||
wildcards = "deny"
|
||||
|
||||
[sources]
|
||||
# Only crates.io. A git or alternative-registry dependency bypasses the audit
|
||||
# and advisory data everything above relies on.
|
||||
unknown-registry = "deny"
|
||||
unknown-git = "deny"
|
||||
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
|
||||
allow-git = []
|
||||
@@ -0,0 +1,119 @@
|
||||
# How a disk is read
|
||||
|
||||
This page follows one `vectura serve` run from the first packet to the
|
||||
last, and says why each step is there. It is background; the reference
|
||||
pages hold the exact values.
|
||||
|
||||
## Two planes
|
||||
|
||||
An ESXi host exposes a virtual disk through two ports with two jobs.
|
||||
|
||||
Port 443 is the management plane. It speaks SOAP over HTTPS, the same
|
||||
API vSphere clients use. Vectura makes four calls on it, hand-rolled
|
||||
HTTP/1.1 with a small XML body each:
|
||||
|
||||
1. `RetrieveServiceContent`, to find the session manager. An ESXi host
|
||||
and a vCenter answer alike.
|
||||
2. `Login`, with the user name and the password from `VECTURA_PASSWORD`.
|
||||
The host sets a session cookie.
|
||||
3. `RetrieveInternalContent`, to reach the NFC service.
|
||||
4. `NfcGetVmFiles`, with the virtual machine's managed object reference.
|
||||
The host answers with a ticket: an opaque string, the port to use, and
|
||||
the SHA-1 thumbprint of the certificate that port presents. A vCenter
|
||||
also names the ESXi host that holds the virtual machine, and the data
|
||||
port is opened on that host; an ESXi host leaves the name out, and the
|
||||
data port is its own.
|
||||
|
||||
At the end, `Logout` closes the session. Between the two, the management
|
||||
session is idle; the disk goes through the other port.
|
||||
|
||||
Port 902 is the data plane. The host greets in clear with a `220` line that lists its capabilities,
|
||||
among them `NFCSSL supported` and `SHA256 supported`. Vectura reads the
|
||||
list and refuses a host that lacks what it needs, before it sends
|
||||
anything. Then it starts TLS.
|
||||
|
||||
## Ticket, thumbprint, and a second TLS session
|
||||
|
||||
Inside that first TLS session, a short line dialogue turns the ticket
|
||||
into an open channel. The client names its session with the ticket, asks
|
||||
for the banner, and asks the host to confirm the SHA-256 of the
|
||||
certificate it is presenting. The host's answer must match the
|
||||
certificate the TLS layer saw, and the SHA-1 from the ticket must match
|
||||
it too. Then the client asks for `nfcssl`, and the host answers that the
|
||||
channel is ready.
|
||||
|
||||
`nfcssl` means a second TLS handshake, on the same TCP connection, inside
|
||||
the first session. It is how the hosts expect the disk bytes to be
|
||||
carried. Vectura checks that the second
|
||||
session presents the same certificate as the first. With
|
||||
`--transport nfc`, the second handshake is skipped and the records below
|
||||
travel in clear after the dialogue; Vectura warns before doing so.
|
||||
|
||||
## Handshake and records
|
||||
|
||||
The disk session opens with six fixed-size messages of 264 bytes: the
|
||||
client sends a tag, an end-of-tag and a version, the host acknowledges
|
||||
and answers with its version, then the client sends its name, its mode
|
||||
and a ready message, and the host answers ready. Vectura calls itself
|
||||
`vectura` and asks for the mode in which the host serves arbitrary
|
||||
ranges of a disk on demand.
|
||||
|
||||
From then on, both sides exchange records: a 16-byte header with a magic
|
||||
number, an opcode and a length, followed by a payload. The client says
|
||||
hello, states its protocol version and capabilities, and opens the disk
|
||||
by its datastore path. The host's open reply gives the disk's virtual
|
||||
capacity and sector size; that capacity becomes the NBD export size. A
|
||||
close and an end record close the session at the other end of the run.
|
||||
|
||||
## Reads, chunks, and the window
|
||||
|
||||
An NBD client asks for any range up to 32 MiB. The host, however, answers
|
||||
a read of at most 1 MiB, and it answers it as a series of chunks of
|
||||
64 KiB, each carrying its offset within the read and the compression the
|
||||
host chose for it: as stored, deflated with zlib, or with its runs of
|
||||
zeros left out, which Vectura calls `skipz` and asks for by default. The
|
||||
host is free to answer with a stored chunk whatever was asked, and it
|
||||
does when compressing would not shrink the chunk. Every chunk is checked
|
||||
before it is placed: its offset must be inside the read, it must not
|
||||
overlap a chunk already received, and once decoded it must be exactly
|
||||
64 KiB or the tail of the read. A chunk that fails is a broken read, and
|
||||
a broken read ends the run rather than serve wrong bytes.
|
||||
|
||||
So `serve` splits each NBD command into host reads of 1 MiB, numbers
|
||||
them, and keeps a window of them in flight, 16 by default and 32 at most.
|
||||
The window is what hides the round trip. When
|
||||
a read's last chunk lands, it is placed at the right offset of the NBD
|
||||
command's buffer, and when a command's last read lands, the command is
|
||||
answered. Commands can complete out of order, which is what the NBD
|
||||
cookie is for.
|
||||
|
||||
## Two threads, one channel
|
||||
|
||||
`serve` runs on two threads. One reads NBD requests from standard input
|
||||
and pushes them into a bounded channel. The other owns the host
|
||||
connection and standard output: it pulls commands, issues host reads,
|
||||
receives records, places chunks, and writes replies. Nothing else touches
|
||||
standard output, which is why nothing else may print: a stray line there
|
||||
would corrupt the NBD stream.
|
||||
|
||||
The bounded channel is the back pressure. When the host side is busy, the
|
||||
input side waits with its next command in hand, and the client sees its
|
||||
replies slow down instead of its requests vanishing into a queue.
|
||||
|
||||
## Ending
|
||||
|
||||
A client that sends `NBD_CMD_DISC` gets a clean end: the disk is closed,
|
||||
the end record sent, the management session logged out, exit code 0. A
|
||||
client that closes its side without disconnecting gets the same cleanup
|
||||
and an exit code of 1, because something went wrong on its side and the
|
||||
process that spawned it should know. A failure on the host's side is
|
||||
reported as `NBD_EIO` to every command still in flight, then the same
|
||||
cleanup, then exit code 1. There is no reconnect: a run is one session,
|
||||
and a session that breaks is a new run.
|
||||
|
||||
## Related
|
||||
|
||||
- [The NBD export](../reference/nbd-export.md) and
|
||||
[the transcript](../reference/transcript.md) give the exact values.
|
||||
- [The security model](security-model.md) says what is verified at each
|
||||
step.
|
||||
@@ -0,0 +1,108 @@
|
||||
# Scope and design choices
|
||||
|
||||
Vectura is small on purpose. This page lists what it does not do, why,
|
||||
and which of the choices are principles rather than gaps that a later
|
||||
version fills.
|
||||
|
||||
## Why a client written from public sources
|
||||
|
||||
Vectura depends on no proprietary library. A Linux package carries the
|
||||
whole of it, at a version the packager chose, with nothing for the
|
||||
operator to download by hand.
|
||||
|
||||
The client side of the protocol is written from public sources only: the
|
||||
vSphere API reference, Broadcom knowledge base articles, and
|
||||
measurements taken on ESXi 7 and 8 hosts. No proprietary binary or
|
||||
header is opened, and no third-party reimplementation is read. It is
|
||||
slower to write; it is the only way the result can be shipped.
|
||||
|
||||
## Why ESXi 7 and 8, and nothing else
|
||||
|
||||
- **vCenter as a relay.** The disk's files live on the ESXi host that
|
||||
holds the virtual machine. A vCenter answers the management calls and
|
||||
hands out a ticket that names that host; Vectura then opens the data
|
||||
port on the named host, not on the vCenter. The management plane is the
|
||||
same either way, so a vCenter needs no second kind of session.
|
||||
- **No ESXi 6.x.** Those hosts do not announce `SHA256 supported`, so the
|
||||
data port could not be confirmed by anything better than SHA-1, and
|
||||
their default TLS configuration is what a 2026 client should not
|
||||
negotiate. Both lines of ESXi 6 are past their end of general support.
|
||||
- **Capabilities, not versions.** What a host can do is read from the
|
||||
greeting line on port 902, not from a version string. A host that
|
||||
announces the needed capabilities is served; one that does not is
|
||||
refused with the list of what it announced.
|
||||
|
||||
## Why standard input and output
|
||||
|
||||
The consumer Vectura is built for is a program that spawns it: one
|
||||
process per disk, for as long as the disk is being read. A TCP listener
|
||||
would need a port, a bind address, a way to hand the socket over and a
|
||||
way to make sure nobody else connects to it. Two pipes need none of that,
|
||||
they are closed when the parent dies, and libnbd already knows how to
|
||||
negotiate over them. Shell users who want a socket put `socat` in front,
|
||||
as the copy guide shows.
|
||||
|
||||
The same reasoning gives one connection per process. The export does not
|
||||
advertise multiple connections, because the process has exactly one
|
||||
host session and one client, and a second client would need a second
|
||||
ticket anyway.
|
||||
|
||||
## Why read-only
|
||||
|
||||
The use is backup, replication and inspection, which read. Writing to a
|
||||
virtual disk that a hypervisor owns is a different product with a
|
||||
different failure mode, and leaving the write path out means it cannot
|
||||
be reached by mistake, by flag or by bug.
|
||||
|
||||
## Why no reconnect
|
||||
|
||||
A session that breaks, on the host or on the network, ends the run with
|
||||
`NBD_EIO` to what was in flight and exit code 1. Reconnecting inside the
|
||||
process would mean a second login, a second ticket and a decision about
|
||||
which of the reads in flight to trust, all hidden from the caller. The
|
||||
caller has the exit code and can start a new process, with the reads it
|
||||
knows it still needs. What has been measured so far, an open disk that
|
||||
survives half an hour idle and a management session that lasts at least
|
||||
thirty minutes, makes the ordinary run not need one.
|
||||
|
||||
## Why rustls, synchronous I/O and two threads
|
||||
|
||||
- **rustls with aws-lc-rs, no OpenSSL.** One TLS stack, in the binary,
|
||||
with no system library to match at run time and no shell out. ESXi 7
|
||||
and 8 hosts negotiate TLS 1.2 with ECDHE and AES-GCM on both ports,
|
||||
which rustls speaks.
|
||||
- **Synchronous `std` I/O, no async runtime.** A run is two sockets and
|
||||
two pipes. Two threads joined by a bounded channel cover that with
|
||||
back pressure for free and nothing to schedule. An async runtime would
|
||||
add a dependency, a build-time and a class of bugs for a concurrency
|
||||
the problem does not have.
|
||||
- **A depth window instead of parallel connections.** The host answers a
|
||||
read of at most 1 MiB, so throughput on a long link is a matter of
|
||||
keeping enough of them in flight. A window of 16, adjustable up to 32,
|
||||
does that on one connection and keeps the host's per-host budget
|
||||
shared fairly between processes.
|
||||
|
||||
## Why `skipz` by default
|
||||
|
||||
Three ways to pack a chunk were measured on the same disk and link.
|
||||
Leaving out runs of zeros costs the host almost nothing and cut the bytes
|
||||
on the wire to a sixth; zlib halved that again and was slower end to end,
|
||||
because the host deflates slower than the link carries. `skipz` is the
|
||||
default; `zlib` is there for a link slow enough to make it pay, and
|
||||
`none` for diagnosis.
|
||||
|
||||
## Why the password comes only from the environment
|
||||
|
||||
A flag shows in the process table for the life of the process. A file is
|
||||
left behind and has permissions to get wrong. A prompt cannot be answered
|
||||
by the program that spawns Vectura. The environment is inherited from the
|
||||
parent, visible to the parent's owner only, and gone with the process.
|
||||
Nothing else is accepted, so nothing else can be misused.
|
||||
|
||||
## What may change
|
||||
|
||||
Reconnection within a run, a wider host set once a public source covers
|
||||
it, and LAN throughput figures are all open. What will not change without
|
||||
a written decision is the list above: no way to skip certificate
|
||||
verification, no password outside the environment, no write path, no
|
||||
proprietary dependency.
|
||||
@@ -0,0 +1,113 @@
|
||||
# The security model
|
||||
|
||||
Vectura carries a password, a ticket and a whole disk across a network.
|
||||
This page says what each of them is protected by, what an attacker in a
|
||||
given position could do, and which choices were made on purpose so that
|
||||
an operator cannot weaken them by accident.
|
||||
|
||||
## What is at stake
|
||||
|
||||
- **The password.** It logs in on the host as `--user`, which is often
|
||||
`root`. Whoever gets it owns the host.
|
||||
- **The ticket.** It opens the files of one virtual machine, once, for a
|
||||
short time. Less than the password, still a way to read a disk.
|
||||
- **The disk.** Everything on it.
|
||||
- **The bytes served.** A disk that was altered on the way is worse than
|
||||
no disk: a backup restored from it looks fine and is not.
|
||||
|
||||
## The management port is verified before the password leaves
|
||||
|
||||
`Login` is the first message that carries the password, and it goes
|
||||
through a TLS session that was verified first. Three ways are offered and
|
||||
one of them has to be taken:
|
||||
|
||||
- **A pinned thumbprint.** `--thumbprint sha256:<hash>` accepts exactly
|
||||
one certificate. Nothing else about it is checked, and nothing else
|
||||
needs to be: a name, an expiry date or a chain adds nothing when the
|
||||
certificate itself is known. It is the right choice for the default
|
||||
self-signed certificate an ESXi host installs, which no CA can vouch
|
||||
for and which names `localhost.localdomain` rather than the host.
|
||||
- **A CA bundle.** `--ca-file` verifies the chain against the bundle and
|
||||
the name against `--host`. For hosts whose certificates are issued by
|
||||
the organization's own CA.
|
||||
- **The system roots.** With neither flag, the same verification against
|
||||
the operating system's root store. For hosts with publicly issued
|
||||
certificates.
|
||||
|
||||
There is no fourth way. No `--insecure`, no `--no-verify`, no trust on
|
||||
first use, no environment variable that skips the check. A tool that has
|
||||
such a flag ends up run with it, in a script, forever. Vectura would
|
||||
rather fail with `certificate thumbprint mismatch` and make the operator
|
||||
look at why the certificate changed.
|
||||
|
||||
The `fingerprint` command exists so that the thumbprint can be obtained
|
||||
without trusting anything: it accepts any certificate, sends nothing
|
||||
after the handshake, and prints the hashes for the operator to compare
|
||||
out of band.
|
||||
|
||||
## The data port is verified by the ticket
|
||||
|
||||
The connection to port 902 does not need a flag, because the ticket
|
||||
already carries the SHA-1 thumbprint of the certificate that port
|
||||
presents. The ticket came through the verified management session, so
|
||||
its thumbprint is trusted the way the password's destination was. Vectura
|
||||
then asks the host, inside the first TLS session, to confirm the SHA-256
|
||||
of its certificate, and compares that with what the TLS layer saw as
|
||||
well. This is why a host that does not announce `SHA256 supported` is
|
||||
refused: without the confirmation, the data port would rest on SHA-1
|
||||
alone. Finally, the second TLS session that carries the disk must present
|
||||
the same certificate as the first.
|
||||
|
||||
An attacker who controls the network between Vectura and the host sees
|
||||
TLS on 443 and TLS on 902, and can complete neither handshake without the
|
||||
host's private key. What they can do is drop the connection, which ends
|
||||
the run with exit code 1. `--transport nfc` removes the second TLS session
|
||||
and carries the records in clear after the ticket dialogue, which is why
|
||||
it prints a warning and why the default is `nfcssl`; the password and the
|
||||
ticket are still protected under `nfc`, the disk is not.
|
||||
|
||||
## Secrets stay in memory
|
||||
|
||||
- The password is read from `VECTURA_PASSWORD` and nowhere else. Not a
|
||||
flag, which the process table would show; not a file, which would be
|
||||
left behind; not a prompt, which a spawning program cannot answer.
|
||||
- Neither the password, the ticket nor the session cookie is ever written
|
||||
to standard error, put in an error message, rendered in a debug
|
||||
dump or kept in a test fixture. The types that hold them print a
|
||||
placeholder, and a test checks that they do. The transcript shows the
|
||||
ticket as `<ticket>`.
|
||||
- The binary carries no credential and no configuration.
|
||||
|
||||
A transcript or a bug report can therefore be shared as it is.
|
||||
|
||||
## What comes from the host is data
|
||||
|
||||
Every reply from the host is parsed with a limit: a dialogue line of at
|
||||
most 1 KiB, an HTTP body of at most 1 MiB, a record of at most 1 MiB, an
|
||||
NBD option of at most 4 KiB. A length field that says more is an error,
|
||||
not an allocation. Each chunk of a read is checked against the read it
|
||||
answers before it is placed. The code has no `unsafe` block and, outside
|
||||
tests, no `unwrap` or `expect`: a broken invariant is an error that says
|
||||
what was violated, not a panic that drops the stream half written.
|
||||
|
||||
## The export is read-only, structurally
|
||||
|
||||
Vectura never sends the host a write. The NBD export advertises
|
||||
`NBD_FLAG_READ_ONLY`, and a write, trim or write-zeroes command is
|
||||
answered `NBD_EPERM` and discarded. There is no flag to change that,
|
||||
because there is no code path behind it.
|
||||
|
||||
## What is not covered
|
||||
|
||||
- **The host's own security.** Vectura trusts the certificate it was
|
||||
told to trust and the bytes the host serves under it. A compromised
|
||||
host serves what it likes.
|
||||
- **The spawning program.** Whatever reads the NBD stream from standard
|
||||
output sees the disk. It also holds the password, since it puts it in
|
||||
the child's environment.
|
||||
- **The link's availability.** A connection can be cut. The run ends,
|
||||
nothing is retried, and the caller decides.
|
||||
|
||||
Vulnerabilities, which is anything that touches certificate
|
||||
verification, credentials, or how data from the host is parsed, are
|
||||
reported to the maintainers in private, not in a public issue.
|
||||
@@ -0,0 +1,130 @@
|
||||
# Copy a disk to a raw image
|
||||
|
||||
This guide copies a whole virtual disk to a raw image on the local machine
|
||||
with `nbdcopy`, and checks the copy against an image built from the
|
||||
datastore's own files.
|
||||
|
||||
## What you need
|
||||
|
||||
- A virtual machine that is powered off, or a disk that the host will let
|
||||
you open: a base link under a snapshot of a running virtual machine is
|
||||
fine, the link a running virtual machine writes to is not.
|
||||
- `libnbd-bin`, for `nbdinfo` and `nbdcopy`.
|
||||
- `socat`, to give the NBD tools a socket to connect to.
|
||||
- The host pinned as in [Pin a host certificate](pin-a-host-certificate.md),
|
||||
and `VECTURA_PASSWORD` exported in the shell.
|
||||
|
||||
## Why a bridge
|
||||
|
||||
`vectura serve` speaks NBD on its own standard input and output, the way a
|
||||
spawning program expects. `nbdcopy` and `nbdinfo` can start a server
|
||||
themselves with their `[ CMD ]` form, but that form hands the child a
|
||||
listening socket through systemd socket activation, not its pipes, so
|
||||
`serve` would wait on standard input while the client waits on the socket.
|
||||
A Unix socket with `socat` in front of it connects the two: each
|
||||
connection accepted on the socket becomes the standard input and output of
|
||||
one fresh `serve` process.
|
||||
|
||||
## 1. Write the server command in a script
|
||||
|
||||
`socat` splits the command it runs at spaces, and a datastore path has
|
||||
one, so put the command in a script:
|
||||
|
||||
```sh
|
||||
cat > ~/bin/serve-vm <<'END'
|
||||
#!/bin/sh
|
||||
exec vectura serve \
|
||||
--host esxi.example \
|
||||
--user root \
|
||||
--vm-id 3 \
|
||||
--disk '[datastore1] vm/vm.vmdk' \
|
||||
--thumbprint sha256:BA:78:16:...:AD \
|
||||
--verbose "$@"
|
||||
END
|
||||
chmod +x ~/bin/serve-vm
|
||||
```
|
||||
|
||||
`--verbose` is optional; it makes each process report a summary on
|
||||
standard error when it ends.
|
||||
|
||||
## 2. Start the bridge
|
||||
|
||||
```sh
|
||||
socket="$XDG_RUNTIME_DIR/vectura.sock"
|
||||
socat UNIX-LISTEN:"$socket",fork EXEC:"$HOME/bin/serve-vm" &
|
||||
```
|
||||
|
||||
`fork` keeps `socat` listening after the first connection. Every
|
||||
connection starts one `serve`, which logs in, obtains its own ticket,
|
||||
opens the disk and ends when the client disconnects. The password reaches
|
||||
the script through the environment `socat` inherited; nothing about it is
|
||||
on a command line.
|
||||
|
||||
## 3. Look at the export
|
||||
|
||||
```sh
|
||||
nbdinfo "nbd+unix:///?socket=$socket"
|
||||
```
|
||||
|
||||
`nbdinfo` reports the export size, that it is read-only, and the block
|
||||
sizes (512 bytes minimum, 1 MiB preferred, 32 MiB maximum). It opens one
|
||||
connection, so one `serve` ran and ended.
|
||||
|
||||
## 4. Copy
|
||||
|
||||
```sh
|
||||
nbdcopy -p "nbd+unix:///?socket=$socket" disk.raw
|
||||
```
|
||||
|
||||
The export does not advertise multiple connections, so `nbdcopy` uses one,
|
||||
and one `serve` process serves the whole disk. Its `summary` line on
|
||||
standard error, when `--verbose` is set, gives the bytes served, the bytes
|
||||
that crossed the wire and the elapsed time.
|
||||
|
||||
`qemu-img` reads the same socket if you want another format directly:
|
||||
|
||||
```sh
|
||||
qemu-img convert -p -O qcow2 "nbd+unix:///?socket=$socket" disk.qcow2
|
||||
```
|
||||
|
||||
## 5. Stop the bridge
|
||||
|
||||
```sh
|
||||
kill %1
|
||||
rm -f "$socket"
|
||||
```
|
||||
|
||||
## Check the copy
|
||||
|
||||
The reference is the raw image of the disk built from the files the
|
||||
datastore holds. Download the descriptor and every extent of the disk
|
||||
through the datastore browser while the virtual machine is off, into one
|
||||
directory so that a snapshot chain's parent references resolve, then
|
||||
convert the top descriptor:
|
||||
|
||||
```sh
|
||||
qemu-img convert -O raw vm-000002.vmdk reference.raw
|
||||
cmp disk.raw reference.raw && echo identical
|
||||
```
|
||||
|
||||
Build the reference before the copy or after it, not during it: the host
|
||||
locks the files while the datastore browser serves them, and an NFC open
|
||||
during the download fails with `Failed to lock the file`; the reverse also
|
||||
holds, and the datastore browser answers `500` while `serve` has the disk
|
||||
open.
|
||||
|
||||
## Long copies
|
||||
|
||||
A management session on the host expires after somewhere between 30 and
|
||||
52 minutes without a management call, and reads on the disk do not count
|
||||
as one. A copy that takes longer ends with the disk fully served, exit
|
||||
code 0, and one line on standard error saying the final `Logout` was
|
||||
refused because the session was no longer authenticated. It is
|
||||
informational; the copy is complete.
|
||||
|
||||
## Related
|
||||
|
||||
- [Tune depth and compression](tune-depth-and-compression.md) for a faster
|
||||
copy on a long link.
|
||||
- [Host requirements and limits](../reference/host-requirements-and-limits.md)
|
||||
for what the host lets you open.
|
||||
@@ -0,0 +1,155 @@
|
||||
# Drive Vectura from a program
|
||||
|
||||
`vectura serve` is built to be spawned: the program that starts it talks
|
||||
NBD to it over the child's standard input and output and reads any range
|
||||
of the disk in any order. This guide states the contract and shows it from
|
||||
Python with libnbd, from C, and from Node.js.
|
||||
|
||||
## The contract
|
||||
|
||||
- **One process, one disk, one connection.** Spawn one `serve` per disk
|
||||
you read. The export does not advertise multiple connections.
|
||||
- **The password is in the environment, only.** Put `VECTURA_PASSWORD` in
|
||||
the child's environment. Never on the command line: it would be visible
|
||||
in the process table.
|
||||
- **Standard input** carries your NBD client flags, options and requests.
|
||||
**Standard output** carries the server's greeting, option replies and
|
||||
simple replies, and nothing else, ever.
|
||||
- **Standard error** carries diagnostics with a `vectura:` prefix and, with
|
||||
`--verbose`, the transcript. Read it or discard it; if you pipe it,
|
||||
drain the pipe so the child never blocks on it. No line on it ever
|
||||
contains the password, the ticket or the session cookie.
|
||||
- **Negotiate fixed newstyle.** Send `NBD_FLAG_C_FIXED_NEWSTYLE`, then
|
||||
`NBD_OPT_GO` for the export size, the read-only flag and the block
|
||||
sizes. Any export name is accepted, the empty one included.
|
||||
- **Read within the limits.** `NBD_CMD_READ` at any offset and length
|
||||
inside the export, at most 32 MiB per command, 1 MiB preferred. A
|
||||
command past the export or larger than 32 MiB is answered `NBD_EINVAL`.
|
||||
- **Match replies by cookie.** Commands are answered as soon as their data
|
||||
is complete, which can be out of order.
|
||||
- **End with `NBD_CMD_DISC`.** The child closes the disk, logs out and
|
||||
exits 0. Closing its standard input instead makes it do the same cleanup
|
||||
and exit 1 with `the nbd client left without disconnecting` on standard
|
||||
error.
|
||||
- **Exit codes.** 0 on a clean end, 1 when the host or the client failed,
|
||||
2 for a usage error such as a missing password or a bad flag; usage
|
||||
errors are reported before any connection is made.
|
||||
|
||||
## Python, with libnbd
|
||||
|
||||
```python
|
||||
import os
|
||||
import nbd
|
||||
|
||||
env = dict(os.environ, VECTURA_PASSWORD=password) # never a command-line argument
|
||||
h = nbd.NBD()
|
||||
h.connect_command([
|
||||
"vectura", "serve",
|
||||
"--host", "esxi.example",
|
||||
"--user", "root",
|
||||
"--vm-id", "3",
|
||||
"--disk", "[datastore1] vm/vm.vmdk",
|
||||
"--thumbprint", "sha256:BA:78:16:...:AD",
|
||||
])
|
||||
size = h.get_size()
|
||||
first = h.pread(1 << 20, 0)
|
||||
last = h.pread(1 << 20, size - (1 << 20))
|
||||
h.shutdown() # sends NBD_CMD_DISC; the child exits 0
|
||||
```
|
||||
|
||||
`connect_command` spawns the child, pipes its standard input and output
|
||||
and negotiates with `NBD_OPT_GO`. Set `VECTURA_PASSWORD` in the parent's
|
||||
environment before the call; libnbd passes the environment through. For
|
||||
concurrent reads, libnbd's asynchronous `aio_pread` keeps several commands
|
||||
in flight; see the next section for how many.
|
||||
|
||||
## C, with libnbd
|
||||
|
||||
```c
|
||||
struct nbd_handle *h = nbd_create();
|
||||
char *argv[] = {
|
||||
"vectura", "serve",
|
||||
"--host", "esxi.example", "--user", "root", "--vm-id", "3",
|
||||
"--disk", "[datastore1] vm/vm.vmdk",
|
||||
"--thumbprint", "sha256:BA:78:16:...:AD",
|
||||
NULL,
|
||||
};
|
||||
if (nbd_connect_command(h, argv) == -1) { /* nbd_get_error() */ }
|
||||
int64_t size = nbd_get_size(h);
|
||||
nbd_pread(h, buf, sizeof buf, 0, 0);
|
||||
nbd_shutdown(h, 0);
|
||||
nbd_close(h);
|
||||
```
|
||||
|
||||
## Node.js
|
||||
|
||||
Node has no NBD client in its standard library, but any client that works
|
||||
on a duplex stream can be given the child's pipes:
|
||||
|
||||
```js
|
||||
const { spawn } = require('node:child_process')
|
||||
const { Duplex } = require('node:stream')
|
||||
|
||||
const child = spawn(
|
||||
'vectura',
|
||||
[
|
||||
'serve',
|
||||
'--host',
|
||||
'esxi.example',
|
||||
'--user',
|
||||
'root',
|
||||
'--vm-id',
|
||||
'3',
|
||||
'--disk',
|
||||
'[datastore1] vm/vm.vmdk',
|
||||
'--thumbprint',
|
||||
'sha256:BA:78:16:...:AD',
|
||||
],
|
||||
{
|
||||
env: { ...process.env, VECTURA_PASSWORD: password },
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
}
|
||||
)
|
||||
|
||||
// One stream that reads the server's replies and writes your requests.
|
||||
const nbd = Duplex.from({ readable: child.stdout, writable: child.stdin })
|
||||
|
||||
child.on('exit', code => {
|
||||
// 0 after your NBD_CMD_DISC; 1 if the host failed or you closed stdin first.
|
||||
})
|
||||
```
|
||||
|
||||
A client that only accepts a TCP socket needs a small bridge: a local
|
||||
`net.createServer` whose accepted socket is piped both ways into the
|
||||
child's pipes, one child per accepted connection.
|
||||
|
||||
## Keep the host busy
|
||||
|
||||
`serve` fetches the disk from the host in reads of at most 1 MiB and keeps
|
||||
up to `--depth` of them in flight, 16 by default. A 16 MiB command alone
|
||||
fills a depth of 16; sixteen 1 MiB commands do the same. Keep at least
|
||||
that much outstanding from your side, or the window sits idle between
|
||||
your commands. The depth is a per-host budget: when several of your
|
||||
processes read from the same host at once, lower `--depth` so that the
|
||||
total stays at or under 32.
|
||||
|
||||
## Lifetimes
|
||||
|
||||
- An idle export costs nothing for at least 30 minutes: a client that
|
||||
pauses between reads does not need to reconnect within that time.
|
||||
- The management session expires after 30 to 52 minutes without a
|
||||
management call. A longer run still serves every byte and exits 0; its
|
||||
final `Logout` is refused and reported on standard error.
|
||||
- Killing the child with a signal skips the logout. The host expires the
|
||||
abandoned session by itself after about half an hour, but prefer
|
||||
`NBD_CMD_DISC`.
|
||||
- Connections to the host time out after 10 seconds; a read the host does
|
||||
not answer for 60 seconds ends the run with `NBD_EIO` to every command
|
||||
in flight and exit code 1.
|
||||
|
||||
## Related
|
||||
|
||||
- [The NBD export](../reference/nbd-export.md) lists every option and
|
||||
command and how it is answered.
|
||||
- [Command line](../reference/command-line.md) lists the flags and exit
|
||||
codes.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Pin a host certificate
|
||||
|
||||
`vectura serve` verifies the management port's certificate before it sends
|
||||
the password, and there is no flag to skip that. This guide shows the
|
||||
three ways to make the check pass and how to keep it passing when the
|
||||
certificate changes.
|
||||
|
||||
## Choose how the port is trusted
|
||||
|
||||
| Situation | Flag | What is checked |
|
||||
| -------------------------------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| The host uses its default, self-signed certificate | `--thumbprint sha256:<hash>` | The SHA-256 hash of the certificate the host presents equals `<hash>`. Nothing else: no name, no expiry, no chain. |
|
||||
| The host's certificate is issued by your own CA | `--ca-file <bundle.pem>` | The certificate chains to a certificate in the bundle and names the host given to `--host`. |
|
||||
| The host's certificate is issued by a public CA | nothing | Same check, against the system's root store. |
|
||||
|
||||
`--thumbprint` and `--ca-file` exclude each other; giving both is a usage
|
||||
error.
|
||||
|
||||
The default ESXi certificate names `localhost.localdomain`, not the host,
|
||||
so `--ca-file` cannot accept it even with the right issuer. Pin it.
|
||||
|
||||
## Get the thumbprint
|
||||
|
||||
```sh
|
||||
vectura fingerprint --host esxi.example
|
||||
```
|
||||
|
||||
```
|
||||
443 sha256 BA:78:16:...:AD
|
||||
443 sha1 3B:...:7F
|
||||
902 sha256 BA:78:16:...:AD
|
||||
902 sha1 3B:...:7F
|
||||
```
|
||||
|
||||
`fingerprint` accepts whatever certificate the host presents, sends
|
||||
nothing after the handshake, and drops the connection. That is the point:
|
||||
it lets you see the certificate before any credential crosses the wire,
|
||||
but it cannot tell a genuine host from an interceptor. Check the hash out
|
||||
of band before you pin it. On the host itself:
|
||||
|
||||
```sh
|
||||
openssl x509 -in /etc/vmware/ssl/rui.crt -noout -fingerprint -sha256
|
||||
```
|
||||
|
||||
Or compare with what the host's administrator gives you.
|
||||
|
||||
## Pass the pin
|
||||
|
||||
The `443 sha256` value is taken as printed, colons and upper case
|
||||
included; lower case and a bare hex string are accepted too:
|
||||
|
||||
```sh
|
||||
vectura serve --host esxi.example --thumbprint sha256:BA:78:16:...:AD ...
|
||||
```
|
||||
|
||||
The data port needs no pin from you. The ticket Vectura obtains inside the
|
||||
verified management session carries the SHA-1 thumbprint of the data
|
||||
port's certificate, the host confirms the SHA-256 in the data-port
|
||||
dialogue, and both must match the certificate presented there. On the
|
||||
hosts measured so far, both ports present the same certificate, which is
|
||||
why the `443` and `902` lines above are equal.
|
||||
|
||||
## Use a CA bundle
|
||||
|
||||
```sh
|
||||
vectura serve --host esxi.example --ca-file /etc/ssl/esxi-ca.pem ...
|
||||
```
|
||||
|
||||
The bundle is a PEM file holding one or more CA certificates. Give
|
||||
`--host` the name the certificate carries, since the name is part of the
|
||||
check; an address works only if the certificate lists it.
|
||||
|
||||
## After the certificate changes
|
||||
|
||||
A renewed or replaced certificate has a new hash, and the next `serve`
|
||||
stops with:
|
||||
|
||||
```
|
||||
vectura: management plane: RetrieveServiceContent: certificate thumbprint mismatch: expected sha256 BA:78:..., the host presented sha256 24:8D:...
|
||||
```
|
||||
|
||||
Run `fingerprint` again, check the new hash out of band, and update the
|
||||
pin. A mismatch you did not expect is the check doing its job: do not
|
||||
update the pin until you know why the certificate changed.
|
||||
|
||||
## Related
|
||||
|
||||
- [The security model](../explanation/security-model.md) says what each
|
||||
verification protects.
|
||||
- [Command line](../reference/command-line.md) lists the flags.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Troubleshoot a session
|
||||
|
||||
Everything Vectura has to say goes to standard error with a `vectura:`
|
||||
prefix; standard output is data. Start there, and add `--verbose` to see
|
||||
where in the session the run stopped. This guide lists the messages you
|
||||
are most likely to meet, what each means and what to do.
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
| ---- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 0 | The client disconnected with `NBD_CMD_DISC` and the session ended, or `fingerprint` printed its lines. |
|
||||
| 1 | The host, the network or the client failed after the command started. The reason is on standard error. |
|
||||
| 2 | Usage error: a bad or missing flag, `VECTURA_PASSWORD` unset or empty, an unreadable `--ca-file`. Reported before any connection. |
|
||||
|
||||
## Before the login
|
||||
|
||||
| Message | Meaning | Do |
|
||||
| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `VECTURA_PASSWORD is not set` | The variable is missing or empty in the environment of the process. | Export it in the shell that runs `serve`, or set it in the child's environment. |
|
||||
| `could not connect: ... timed out` | Nothing answered on port 443 within 10 seconds. | Check the address, the port and the path to the host. |
|
||||
| `certificate thumbprint mismatch: expected ..., the host presented ...` | The management port's certificate is not the pinned one. | Run `fingerprint`, check the new hash out of band, update the pin. See [Pin a host certificate](pin-a-host-certificate.md). |
|
||||
| `tls handshake failed: ...` | With `--ca-file` or the system roots: the certificate does not chain or does not name the host. | Pin by thumbprint, or give `--host` the name the certificate carries. |
|
||||
| `Login: the host answered: ...` | The host refused the credentials; the text is the host's. | Check the user and the password. |
|
||||
|
||||
## Opening the disk
|
||||
|
||||
| Message | Meaning | Do |
|
||||
| ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `greeting: the host does not announce "SHA256 supported"; announced: [...]` | The data port belongs to a host older than ESXi 7, which Vectura does not support. | Use an ESXi 7.x or 8.x host. |
|
||||
| `greeting: the host does not announce "NFCSSL supported"; announced: [...]` | The host cannot carry the disk inside a second TLS session. | Not seen on a supported host. `--transport nfc` would carry the disk in clear text; understand what that means before using it. |
|
||||
| `open "[ds] vm/x.vmdk": the host reported error 11: NfcAioProcessOpenFileMsg: permission check failed ...` | The ticket covers the files of the virtual machine `--vm-id` names, and this path is not one of them, or the account may not read it. | Check `--vm-id` against `--disk`. |
|
||||
| `... Thin/TBZ/Sparse disks cannot be opened in multiwriter mode` | The disk is the link a running virtual machine writes to. | Power the virtual machine off, or take a snapshot and read a base link. |
|
||||
| `... DiskLib error 16392: Failed to lock the file` | Another reader holds the file, typically a datastore browser download. | Wait for it to finish. |
|
||||
| `the host closed the connection during the ...` | The data port dropped the session before the disk was open. | Retry once; if it repeats, the host's log says why. |
|
||||
|
||||
## While serving
|
||||
|
||||
| Symptom | Meaning | Do |
|
||||
| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| A read is answered `NBD_EIO`, then the process exits 1 | A host read failed, timed out after 60 seconds, or answered with a chunk that does not fit its read. Every command in flight got `NBD_EIO`, the disk was closed and the session logged out. | Read the message on standard error. A run is not resumed; start a new one. |
|
||||
| A read is answered `NBD_EINVAL` | The request is past the end of the export, longer than 32 MiB, carries a command flag, or is `NBD_CMD_FLUSH`. | Fix the client's request. |
|
||||
| A write is answered `NBD_EPERM` | The export is read-only. | Nothing to do; this is the answer. |
|
||||
| `the nbd client left without disconnecting`, exit 1 | The client closed standard input before `NBD_CMD_DISC`. The disk was closed and the session logged out. | Send `NBD_CMD_DISC` to end cleanly. |
|
||||
| `logout: Logout: the host answered: The session is not authenticated.` after a long run, exit 0 | The management session expired during the run, which was longer than 30 to 52 minutes. The disk was served in full. | Nothing. It is informational. |
|
||||
| `nbdcopy [ vectura serve ... ]` or `nbdinfo [ ... ]` hangs, then exits 1 | Those forms start the child with a listening socket, not its pipes. | Put a Unix socket in front; see [Copy a disk to a raw image](copy-a-disk-to-a-raw-image.md). |
|
||||
|
||||
## Reading a transcript
|
||||
|
||||
With `--verbose`, the last line before the error says how far the session
|
||||
got: the management calls with their HTTP status, the greeting, the TLS
|
||||
lines, the dialogue, the handshake message types, the `open` line, the
|
||||
NBD options. [The transcript](../reference/transcript.md) explains each
|
||||
line.
|
||||
|
||||
## Reporting
|
||||
|
||||
A bug report should carry the command line without the password, the
|
||||
`--verbose` transcript, the host's version and the transport. The
|
||||
transcript never contains the password, the ticket or the session cookie.
|
||||
Anything touching certificate verification, credentials or how data from
|
||||
the host is parsed is a vulnerability; report it to the maintainers in
|
||||
private, not in a public issue.
|
||||
@@ -0,0 +1,76 @@
|
||||
# Tune depth and compression
|
||||
|
||||
Two flags decide how fast a disk is served: `--depth`, the number of host
|
||||
reads in flight, and `--compression`, how the host packs each chunk. The
|
||||
defaults, 16 and `skipz`, are right for most runs. This guide says when
|
||||
to move them and how to see whether it helped.
|
||||
|
||||
## Depth
|
||||
|
||||
`serve` splits every client read into host reads of at most 1 MiB and
|
||||
keeps up to `--depth` of them in flight across all the NBD commands it
|
||||
holds. A reply lands, the next read goes out, and each NBD command is
|
||||
answered when its last chunk arrives.
|
||||
|
||||
- **Raise it on a long link.** The window is what covers the round trip.
|
||||
On a link with a 61 ms round trip, going from 16 to 32 finished a
|
||||
16 GiB disk 1.40 times sooner with exactly the same bytes on the wire.
|
||||
- **Lower it when processes share a host.** The host's NFC buffers are a
|
||||
per-host budget, and 32 is the most one process may ask for. With four
|
||||
processes on one host, `--depth 8` each keeps the total where one
|
||||
process alone would be.
|
||||
- **The client has to keep up.** Depth counts host reads, not client
|
||||
commands. One 16 MiB command occupies sixteen slots; a client that sends
|
||||
one 512-byte command at a time and waits leaves the window empty
|
||||
whatever `--depth` says.
|
||||
|
||||
The range is 1 to 32; anything else is a usage error.
|
||||
|
||||
## Compression
|
||||
|
||||
| Mode | What the host sends | When |
|
||||
| ----------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `skipz` (default) | Each 64 KiB chunk with its runs of zeros left out | Almost always. Cheapest for the host, and the wire cost tracks how sparse the disk is. |
|
||||
| `zlib` | Each chunk deflated | Rarely. Fewest bytes on the wire, but the host deflates slower than most links carry: in the run below it halved the wire bytes of `skipz` and still took longer. |
|
||||
| `none` | Each chunk as stored | Diagnosis, or a fast local link with a slow host. Six to seven times the bytes of `skipz` on a typical disk. |
|
||||
|
||||
Whatever is asked, a chunk the host cannot reduce comes back stored, and
|
||||
the three modes serve identical bytes.
|
||||
|
||||
Measured on an ESXi 7.0.3 host over a link whose 5.81 MB/s ceiling
|
||||
bounds every row, so read the rows against each other and not as what a
|
||||
LAN would give:
|
||||
|
||||
| Mode | Depth | Elapsed | Served | On the wire |
|
||||
| ------- | ----- | ------- | ---------- | ----------- |
|
||||
| `skipz` | 16 | 630 s | 26.0 MiB/s | 4.14 MB/s |
|
||||
| `zlib` | 16 | 705 s | 23.2 MiB/s | 2.01 MB/s |
|
||||
| `none` | 16 | 3110 s | 5.3 MiB/s | 5.52 MB/s |
|
||||
| `skipz` | 32 | 449 s | 36.5 MiB/s | 5.81 MB/s |
|
||||
|
||||
## See what a run does
|
||||
|
||||
Add `--verbose`. Every GiB served, a line reports the ratio of wire bytes
|
||||
to disk bytes so far and the depth in use:
|
||||
|
||||
```
|
||||
progress: served 4 GiB, compression ratio 0.15, depth 16
|
||||
```
|
||||
|
||||
At the end, the summary gives the totals:
|
||||
|
||||
```
|
||||
summary: bytes served 17179869184, wire bytes 2607992664, host reads 16384, elapsed 628.6 s
|
||||
```
|
||||
|
||||
A ratio near 1.00 under `skipz` means the disk is dense and `zlib` might
|
||||
cut wire bytes, at the host's expense. An elapsed time that does not drop
|
||||
when `--depth` rises means the link or the client, not the window, is the
|
||||
limit.
|
||||
|
||||
## Related
|
||||
|
||||
- [The transcript](../reference/transcript.md) for every line `--verbose`
|
||||
writes.
|
||||
- [How a disk is read](../explanation/how-a-disk-is-read.md) for what a
|
||||
host read and a chunk are.
|
||||
@@ -0,0 +1,89 @@
|
||||
# Command line
|
||||
|
||||
```
|
||||
vectura <command> [flags]
|
||||
vectura --version
|
||||
vectura --help
|
||||
```
|
||||
|
||||
Two commands: `fingerprint` and `serve`. Every message for a person goes
|
||||
to standard error, prefixed `vectura: `. `serve` writes the NBD stream to
|
||||
standard output and nothing else.
|
||||
|
||||
## `vectura fingerprint`
|
||||
|
||||
Prints the hashes of the certificates a host presents on its management
|
||||
and data ports, then exits. Any certificate is accepted; nothing is sent
|
||||
after the TLS handshake.
|
||||
|
||||
| Flag | Value | Default | Meaning |
|
||||
| ------------ | --------------------------- | ------------------ | -------------------------------------------------------------------------------------- |
|
||||
| `--host` | `<name>` or `<name>:<port>` | required, port 443 | The host, with its management port. A bare IPv6 address keeps the default port. |
|
||||
| `--nfc-port` | port number | 902 | The data port, where the host greets before TLS starts. |
|
||||
| `--verbose` | | off | Also write the negotiated TLS version and cipher suite of each port to standard error. |
|
||||
|
||||
Output, one line per port and hash, hex in upper case with colons:
|
||||
|
||||
```
|
||||
443 sha256 <hash>
|
||||
443 sha1 <hash>
|
||||
902 sha256 <hash>
|
||||
902 sha1 <hash>
|
||||
```
|
||||
|
||||
Exit code 0 when the four lines were printed, 1 when a port could not be
|
||||
reached or did not complete a handshake, 2 for a usage error.
|
||||
|
||||
## `vectura serve`
|
||||
|
||||
Exports one virtual disk, read-only, as a fixed-newstyle NBD server on
|
||||
standard input and output. The password is read from `VECTURA_PASSWORD`.
|
||||
|
||||
| Flag | Value | Default | Meaning |
|
||||
| --------------- | --------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `--host` | `<name>` or `<name>:<port>` | required, port 443 | The ESXi host and its management port. With `--ca-file` or the system roots, the name is checked against the certificate. |
|
||||
| `--user` | user name | required | The account to log in with. |
|
||||
| `--vm-id` | managed object reference | required | The virtual machine that owns the disk, such as `3` or `vm-42`. |
|
||||
| `--disk` | datastore path | required | The disk to export, such as `[datastore1] vm/vm.vmdk`. Quote it: the path holds a space. |
|
||||
| `--thumbprint` | `sha256:<64 hex digits>` | none | Pin the management port's certificate to this hash. Colons between pairs and either case are accepted. Excludes `--ca-file`. |
|
||||
| `--ca-file` | path to a PEM bundle | none | Verify the management port's certificate against this bundle instead of the system roots. Excludes `--thumbprint`. |
|
||||
| `--depth` | 1 to 32 | 16 | Host reads kept in flight at once. The limit is per host: lower it when several processes read from the same host. |
|
||||
| `--compression` | `skipz`, `zlib`, `none` | `skipz` | How the host packs each chunk: runs of zeros left out, deflated, or as stored. |
|
||||
| `--transport` | `nfcssl`, `nfc` | `nfcssl` | Whether the disk session runs inside a second TLS session on the data connection, or in clear. `nfc` prints a warning. |
|
||||
| `--verbose` | | off | Write the session transcript to standard error. |
|
||||
|
||||
Without `--thumbprint` or `--ca-file`, the management port's certificate
|
||||
is verified against the system's root store. There is no flag that skips
|
||||
verification. The data port is verified against the thumbprint carried by
|
||||
the ticket and confirmed in the ticket dialogue, with no flag involved.
|
||||
|
||||
### Environment
|
||||
|
||||
| Variable | Meaning |
|
||||
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `VECTURA_PASSWORD` | The password of `--user`. Required and non-empty; checked before any connection. Never read from a flag, a file or a prompt. |
|
||||
|
||||
### Exit codes
|
||||
|
||||
| Code | When |
|
||||
| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 0 | The NBD client sent `NBD_CMD_DISC`; the disk was closed and the session logged out. |
|
||||
| 1 | The host, the network or the client failed after the command started: a connection refused or timed out, a certificate that did not match, a host error, a malformed reply, a client that closed standard input without disconnecting. The reason is on standard error. |
|
||||
| 2 | Usage error: unknown or conflicting flags, a value out of range, `VECTURA_PASSWORD` unset or empty, a `--ca-file` that does not parse. Reported before any connection. |
|
||||
|
||||
### Warnings
|
||||
|
||||
`--transport nfc` prints, before the session starts:
|
||||
|
||||
```
|
||||
vectura: warning: --transport nfc carries the disk in clear text after the ticket dialogue
|
||||
```
|
||||
|
||||
### Timeouts
|
||||
|
||||
| Timeout | Value |
|
||||
| ------------------------------------- | ---------- |
|
||||
| Connecting to a port | 10 seconds |
|
||||
| A read or write on a connected socket | 60 seconds |
|
||||
|
||||
Either one, expired, ends the run with exit code 1.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Host requirements and limits
|
||||
|
||||
What the ESXi host has to be and do for `vectura serve` to read a disk,
|
||||
and the limits it imposes. Measurements were taken on ESXi 7.0.3 and
|
||||
8.0.1 hosts; VMware's documentation is cited where it states the limit.
|
||||
|
||||
## The host
|
||||
|
||||
| Requirement | Detail |
|
||||
| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ESXi 7.x or 8.x, directly or through a vCenter | With a vCenter, `--host` names the vCenter and the ticket names the ESXi host that holds the virtual machine; the data port is opened on that host. |
|
||||
| `SHA256 supported` in the data port's greeting | The ticket dialogue confirms the data port's certificate by its SHA-256. ESXi 6.x does not announce it and is refused: `greeting: the host does not announce "SHA256 supported"`. |
|
||||
| `NFCSSL supported` in the greeting, for `--transport nfcssl` | Without it, only `--transport nfc` can run, in clear text. Every 7.x and 8.x host measured announces it. |
|
||||
| Port 443 and port 902 reachable | 443 for the management calls, on the vCenter when there is one; 902 for the greeting, the ticket dialogue and the disk, always on the ESXi host. |
|
||||
| An account that may read the virtual machine's files | The account logs in on port 443 and obtains the ticket; the ticket covers the files of the virtual machine `--vm-id` names. |
|
||||
|
||||
The greeting lines and their capability words are documented in Broadcom
|
||||
knowledge base articles 343952, 338286, 341384 and 417531.
|
||||
|
||||
## The disk
|
||||
|
||||
| Disk | Can it be opened |
|
||||
| --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Any link of a powered-off virtual machine | Yes. |
|
||||
| A base link under a snapshot of a running virtual machine | Yes, and the bytes are frozen for the duration. |
|
||||
| The link a running virtual machine writes to | No: `Thin/TBZ/Sparse disks cannot be opened in multiwriter mode`. |
|
||||
| A file the datastore browser is serving at the same time | No: `DiskLib error 16392: Failed to lock the file`. The reverse holds too: the datastore browser answers `500` while the disk is open over NFC. |
|
||||
| A file of another virtual machine than `--vm-id` | No: error 11, `NfcAioProcessOpenFileMsg: permission check failed`. |
|
||||
|
||||
A snapshot chain is read through its top link; the host resolves the
|
||||
parents. The export size is the disk's virtual capacity, whatever the
|
||||
size of the files.
|
||||
|
||||
## Sizes
|
||||
|
||||
| Item | Value | Source |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------- |
|
||||
| Host read | At most 1 MiB per read | VMware's documented NFC session limits: 1 MB per stream |
|
||||
| Chunk | 64 KiB, the host's unit of compression and placement | Measured on ESXi 7 and 8 |
|
||||
| Sector | 512 bytes | The open reply |
|
||||
| Chunk compression codes | `none` 0, `zlib` 1, `skipz` 3; a chunk the host cannot reduce comes back as 0 whatever was asked | Measured on ESXi 7 and 8 |
|
||||
|
||||
## Concurrency
|
||||
|
||||
| Item | Value | Source |
|
||||
| ------------------------------- | ----------------------------------------------------------- | -------------------------------------- |
|
||||
| Reads in flight per process | 1 to 32, `--depth`, default 16 | Vectura |
|
||||
| NFC buffer per host | 48 MB, 1 MB per stream, about 48 concurrent streams | VMware's documented NFC session limits |
|
||||
| Connections per `serve` process | One management session, one data connection, one NBD client | Vectura |
|
||||
|
||||
Several processes on one host share the host's buffer: keep the sum of
|
||||
their depths at or under 32.
|
||||
|
||||
## Time
|
||||
|
||||
| Item | Value | Source |
|
||||
| ----------------------------------------------------- | ------------------------------------------------------------------------- | -------- |
|
||||
| Management session lifetime without a management call | Expires between 30 and 52 minutes; reads on the data port do not renew it | Measured |
|
||||
| Open disk, idle | Stays open for at least 30 minutes without a read | Measured |
|
||||
| Abandoned session after a signal | Expired by the host on its own, after about half an hour | Measured |
|
||||
| Connect timeout | 10 seconds | Vectura |
|
||||
| Socket read or write timeout | 60 seconds | Vectura |
|
||||
|
||||
A run longer than the session lifetime serves the whole disk and exits
|
||||
0; its `Logout` is refused and reported on standard error.
|
||||
|
||||
## TLS
|
||||
|
||||
| Item | Value | Source |
|
||||
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| Versions the hosts negotiate | TLS 1.2 on both ports, `TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384` or the AES-128 variant | Measured; the vSphere 7 and 8 documentation on the default cipher suites |
|
||||
| Certificate on 902 | The same certificate as on 443 on every host measured | Measured |
|
||||
| Data port verification | The SHA-1 thumbprint in the ticket, the SHA-256 confirmed in the dialogue, and the second TLS session must present the certificate of the first | Vectura |
|
||||
|
||||
## Throughput, measured
|
||||
|
||||
One 16 GiB disk on an ESXi 7.0.3 host, over a link with a 61 ms round
|
||||
trip and a 5.81 MB/s ceiling, which bounds every row. Not a LAN figure.
|
||||
|
||||
| `--compression` | `--depth` | Elapsed | Served | Wire |
|
||||
| --------------- | --------- | ------- | ---------- | --------- |
|
||||
| `skipz` | 16 | 630 s | 26.0 MiB/s | 4.14 MB/s |
|
||||
| `zlib` | 16 | 705 s | 23.2 MiB/s | 2.01 MB/s |
|
||||
| `none` | 16 | 3110 s | 5.3 MiB/s | 5.52 MB/s |
|
||||
| `skipz` | 32 | 449 s | 36.5 MiB/s | 5.81 MB/s |
|
||||
@@ -0,0 +1,86 @@
|
||||
# The NBD export
|
||||
|
||||
`vectura serve` implements the server side of the NBD protocol, as the
|
||||
protocol document describes it
|
||||
(<https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md>),
|
||||
over its standard input (requests) and standard output (replies). This
|
||||
page lists what the server sends, what it accepts and how it answers
|
||||
everything else.
|
||||
|
||||
## Handshake
|
||||
|
||||
The server sends the fixed-newstyle greeting:
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ---------------------------------------------------------- |
|
||||
| `NBDMAGIC` | `0x4e42444d41474943` |
|
||||
| `IHAVEOPT` | `0x49484156454F5054` |
|
||||
| Handshake flags | `NBD_FLAG_FIXED_NEWSTYLE` (1) and `NBD_FLAG_NO_ZEROES` (2) |
|
||||
|
||||
The client flags must carry `NBD_FLAG_C_FIXED_NEWSTYLE`; otherwise the
|
||||
run ends with `nbd: the client flags 0x... lack NBD_FLAG_C_FIXED_NEWSTYLE`
|
||||
and exit code 1. `NBD_FLAG_C_NO_ZEROES` is honored: with it, the 124
|
||||
zero bytes after `NBD_OPT_EXPORT_NAME`'s reply are omitted.
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Reply |
|
||||
| ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| `NBD_OPT_EXPORT_NAME` (1) | The export size and flags, then transmission starts. |
|
||||
| `NBD_OPT_ABORT` (2) | `NBD_REP_ACK`, then the server exits 0. |
|
||||
| `NBD_OPT_INFO` (6) | `NBD_REP_INFO` for `NBD_INFO_EXPORT` and `NBD_INFO_BLOCK_SIZE`, then `NBD_REP_ACK`; negotiation continues. |
|
||||
| `NBD_OPT_GO` (7) | The same replies as `NBD_OPT_INFO`, then transmission starts. |
|
||||
| Anything else (`NBD_OPT_LIST`, `NBD_OPT_STARTTLS`, `NBD_OPT_STRUCTURED_REPLY`, extended headers, metadata contexts) | `NBD_REP_ERR_UNSUP`; negotiation continues. |
|
||||
|
||||
The export name given to `NBD_OPT_EXPORT_NAME`, `NBD_OPT_INFO` or
|
||||
`NBD_OPT_GO` is ignored: there is one disk, whatever the client calls it.
|
||||
An `NBD_OPT_INFO` or `NBD_OPT_GO` whose data does not parse as an export
|
||||
name followed by a count of information requests is answered
|
||||
`NBD_REP_ERR_INVALID`. Option data longer than 4096 bytes is a protocol
|
||||
failure: the run ends with exit code 1.
|
||||
|
||||
### What the export reports
|
||||
|
||||
| Item | Value |
|
||||
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Export size | The virtual capacity of the disk, in bytes, as the host reports it at open. For a snapshot chain, the capacity of the disk, not the size of the top link's file. |
|
||||
| Transmission flags | `NBD_FLAG_HAS_FLAGS` (1) and `NBD_FLAG_READ_ONLY` (2), nothing else. |
|
||||
| Minimum block size | 512 bytes |
|
||||
| Preferred block size | 1 MiB |
|
||||
| Maximum block size | 32 MiB |
|
||||
|
||||
Not advertised, so not to be assumed: multiple connections
|
||||
(`NBD_FLAG_CAN_MULTI_CONN`), flush, trim, write zeroes, fast zero,
|
||||
structured replies, TLS on the NBD side.
|
||||
|
||||
## Transmission
|
||||
|
||||
Requests carry `NBD_REQUEST_MAGIC`; any other magic ends the run with
|
||||
exit code 1. Replies are simple replies, `NBD_SIMPLE_REPLY_MAGIC`, with
|
||||
the request's cookie. Replies can arrive in a different order from the
|
||||
requests.
|
||||
|
||||
| Command | Condition | Reply |
|
||||
| ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `NBD_CMD_READ` (0) | Offset plus length within the export, length at most 32 MiB, no command flags | Error 0 and the data. |
|
||||
| `NBD_CMD_READ` | Past the export, longer than 32 MiB, or with any command flag set | `NBD_EINVAL` (22), no data. |
|
||||
| `NBD_CMD_READ` | A host read failed, timed out, or answered with a chunk that does not fit | `NBD_EIO` (5) to this command and every other in flight, then the disk is closed, the session logged out, and the server exits 1. |
|
||||
| `NBD_CMD_WRITE` (1), `NBD_CMD_TRIM` (4), `NBD_CMD_WRITE_ZEROES` (6) | | `NBD_EPERM` (1). The data of a write is consumed and discarded. |
|
||||
| `NBD_CMD_FLUSH` (3), `NBD_CMD_CACHE` (5), anything unknown, any command with flags | | `NBD_EINVAL` (22). |
|
||||
| `NBD_CMD_DISC` (2) | | No reply. The disk is closed, the session logged out, the server exits 0. |
|
||||
|
||||
A length of zero on a read is within the export whenever the offset is,
|
||||
and is answered with error 0 and no data.
|
||||
|
||||
## Ending
|
||||
|
||||
| Event | Result |
|
||||
| ------------------------------------------- | ------------------------------------------------------------------------------------- |
|
||||
| `NBD_CMD_DISC` | Close, logout, exit 0. |
|
||||
| `NBD_OPT_ABORT` during negotiation | Logout, exit 0. |
|
||||
| Standard input closed before `NBD_CMD_DISC` | Close, logout, exit 1, `the nbd client left without disconnecting` on standard error. |
|
||||
| A malformed request, option or flag word | Close, logout, exit 1, the reason on standard error. |
|
||||
| A host or network failure | `NBD_EIO` to what is in flight, close if possible, logout, exit 1. |
|
||||
|
||||
Once the server has stopped, it does not reconnect and cannot be reused:
|
||||
a new read means a new process.
|
||||
@@ -0,0 +1,77 @@
|
||||
# The transcript
|
||||
|
||||
With `--verbose`, `vectura serve` writes one line per event of the
|
||||
session to standard error, in the order the events happen. `vectura
|
||||
fingerprint --verbose` writes only the TLS lines. Without `--verbose`,
|
||||
standard error carries only errors and warnings, all prefixed `vectura: `.
|
||||
|
||||
Transcript lines carry no prefix. None ever contains the password, the
|
||||
NFC ticket or the management session cookie: the ticket is rendered as
|
||||
`<ticket>`. A line standard error cannot take is dropped; the NBD stream
|
||||
is never delayed by the transcript.
|
||||
|
||||
## Lines, in session order
|
||||
|
||||
| Line | When | Fields |
|
||||
| -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `<call>: <status> in <n> ms` | After each management call on port 443: `RetrieveServiceContent`, `Login`, `RetrieveInternalContent`, `NfcGetVmFiles`, and `Logout` at the end. | The HTTP status the host answered, and the time from request to full response. |
|
||||
| `greeting: <capabilities>` | After the data port's greeting line. | The capabilities the host announced, comma separated, as the host wrote them, such as `NFCSSL supported, SHA256 supported`. |
|
||||
| `first session tls <version> <suite>` | After the first TLS handshake on the data port. | The protocol version and cipher suite, in rustls's names, such as `TLSv1_2 TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384`. |
|
||||
| `> <line>` | Each line sent in the ticket dialogue. | `SESSION <ticket>`, `BANNER `, `THUMBPRINT_SHA2 <tag>`, `PROXY nfcssl` or `PROXY nfc`. |
|
||||
| `< <line>` | Each line the host answered. | The host's reply, such as `220 ...` or `200 <thumbprint>`. |
|
||||
| `second session tls <version> <suite>` | After the second TLS handshake, `--transport nfcssl` only. | As above. |
|
||||
| `> handshake type <n>` | Each handshake message sent. | The message type: 43, 33 and 51 are sent, then 54, 55 and 52 after the host's replies. |
|
||||
| `< handshake type <n>` | Each handshake message received. | The message type: 36 and 51 after the first three, 52 after the last three. |
|
||||
| `open: <capacity> bytes, sector size <n>` | After the host's open reply. | The disk's virtual capacity and its sector size, as reported. |
|
||||
| `nbd option <n>: <outcome>` | After each NBD option. | The option number and how it was answered: `export`, `info`, `acknowledged`, `invalid` or `unsupported`. |
|
||||
| `nbd export: <size> bytes` | When transmission starts. | The export size sent to the client. |
|
||||
| `progress: served <n> GiB, compression ratio <r>, depth <d>` | Each time the bytes served cross a whole gibibyte. | Whole gibibytes served so far, wire bytes per served byte with two decimals, and the depth in use. |
|
||||
| `summary: bytes served <n>, wire bytes <n>, host reads <n>, elapsed <s> s` | When the session ends, before `Logout`. | Bytes served to the client, bytes received from the host for them, host reads issued, and the seconds since negotiation began, with one decimal. |
|
||||
|
||||
`fingerprint --verbose` writes `<port> tls <version> <suite>` per port,
|
||||
with `443` or the value of `--nfc-port` as the port.
|
||||
|
||||
## Ratios
|
||||
|
||||
`compression ratio` is wire bytes divided by served bytes. `1.00` means
|
||||
the host sent every byte as stored; `0.15` means fifteen bytes on the
|
||||
wire per hundred bytes of disk. Before any byte is served it reads
|
||||
`0.00`.
|
||||
|
||||
## Example
|
||||
|
||||
```
|
||||
RetrieveServiceContent: 200 in 41 ms
|
||||
Login: 200 in 63 ms
|
||||
RetrieveInternalContent: 200 in 38 ms
|
||||
NfcGetVmFiles: 200 in 52 ms
|
||||
greeting: SSL Required, ServerDaemonProtocol:SOAP, MKSDisplayProtocol:VNC , VMXARGS supported, NFCSSL supported, SHA256 supported
|
||||
first session tls TLSv1_2 TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
|
||||
> SESSION <ticket>
|
||||
> BANNER
|
||||
< 220 NFC server ready
|
||||
> THUMBPRINT_SHA2 <tag>
|
||||
< 200 BA:78:16:...:AD
|
||||
> PROXY nfcssl
|
||||
< 200 ...
|
||||
second session tls TLSv1_2 TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
|
||||
> handshake type 43
|
||||
> handshake type 33
|
||||
> handshake type 51
|
||||
< handshake type 36
|
||||
< handshake type 51
|
||||
> handshake type 54
|
||||
> handshake type 55
|
||||
> handshake type 52
|
||||
< handshake type 52
|
||||
open: 17179869184 bytes, sector size 512
|
||||
nbd option 7: info
|
||||
nbd export: 17179869184 bytes
|
||||
progress: served 1 GiB, compression ratio 0.16, depth 16
|
||||
...
|
||||
summary: bytes served 17179869184, wire bytes 2607992664, host reads 16384, elapsed 628.6 s
|
||||
Logout: 200 in 35 ms
|
||||
```
|
||||
|
||||
The `220` and `200` lines are illustrative: their text after the code is
|
||||
the host's and varies by version.
|
||||
@@ -0,0 +1,181 @@
|
||||
# Read your first disk
|
||||
|
||||
This tutorial takes you from a fresh Debian or Ubuntu machine to reading
|
||||
the first sector of a virtual machine's disk on an ESXi host. It takes
|
||||
about fifteen minutes. At the end you will have pinned a host, run
|
||||
`vectura serve` under an NBD client, read bytes from the disk and read the
|
||||
transcript of the session.
|
||||
|
||||
## What you need
|
||||
|
||||
- A Debian 12 or Ubuntu 22.04 machine, or newer, on amd64, that reaches the
|
||||
ESXi host on ports 443 and 902.
|
||||
- An ESXi 7.x or 8.x host, reached directly by its own address. A vCenter
|
||||
works too, as long as this machine also reaches the ESXi host on port 902.
|
||||
- An account on that host allowed to read the virtual machine's files.
|
||||
`root` works; a narrower account is fine as long as the host lets it
|
||||
obtain an NFC ticket for the virtual machine.
|
||||
- A virtual machine that is powered off. The disk a running virtual
|
||||
machine writes to cannot be opened; the section "Pick a disk" says why.
|
||||
- The Vectura binary, `vectura`, from
|
||||
`@xen-orchestra/vmware-explorer/vectura/` in Xen Orchestra.
|
||||
- `libnbd-bin`, which provides `nbdsh`, an NBD client that can start a
|
||||
server as a child process and talk to it over its pipes.
|
||||
|
||||
## 1. Install the tools
|
||||
|
||||
```sh
|
||||
sudo install vectura /usr/local/bin/
|
||||
sudo apt install libnbd-bin
|
||||
vectura --version
|
||||
```
|
||||
|
||||
The last command prints `vectura` and the version. If it prints a usage
|
||||
message instead, the binary did not install.
|
||||
|
||||
## 2. Pin the host
|
||||
|
||||
Vectura never skips certificate verification. ESXi hosts ship a
|
||||
self-signed certificate, so the usual way to trust one is to pin it by its
|
||||
SHA-256 thumbprint. Print the thumbprints first:
|
||||
|
||||
```sh
|
||||
vectura fingerprint --host esxi.example
|
||||
```
|
||||
|
||||
The output has four lines, two per port:
|
||||
|
||||
```
|
||||
443 sha256 BA:78:16:...:AD
|
||||
443 sha1 3B:...:7F
|
||||
902 sha256 BA:78:16:...:AD
|
||||
902 sha1 3B:...:7F
|
||||
```
|
||||
|
||||
Before you trust the `443 sha256` line, check it against the host itself.
|
||||
On the host, over SSH, the certificate is at `/etc/vmware/ssl/rui.crt`:
|
||||
|
||||
```sh
|
||||
openssl x509 -in /etc/vmware/ssl/rui.crt -noout -fingerprint -sha256
|
||||
```
|
||||
|
||||
When the two hashes match, keep the `443 sha256` value: it is what
|
||||
`--thumbprint` takes, colons included. The data port needs no pin from
|
||||
you; the host tells Vectura which certificate to expect there, inside the
|
||||
verified management session.
|
||||
|
||||
## 3. Put the password in the environment
|
||||
|
||||
Vectura reads the password from `VECTURA_PASSWORD` and from nowhere else:
|
||||
not a flag, not a file, not a prompt. Read it into the variable without
|
||||
echoing it and without leaving it in the shell history:
|
||||
|
||||
```sh
|
||||
read -rs VECTURA_PASSWORD && export VECTURA_PASSWORD
|
||||
```
|
||||
|
||||
Type the password and press Enter. The variable is now set for this shell
|
||||
and for every process it starts.
|
||||
|
||||
## 4. Pick a disk
|
||||
|
||||
`serve` needs two things from the virtual machine: its managed object
|
||||
reference and the datastore path of the disk.
|
||||
|
||||
- The managed object reference is the number in the virtual machine's URL
|
||||
in the ESXi host client, `.../host/vms/3` for `3`. Vectura takes it as
|
||||
`--vm-id 3`.
|
||||
- The datastore path is the "Disk file" of the hard disk in the virtual
|
||||
machine's settings, in the form `[datastore1] vm/vm.vmdk`. Vectura takes
|
||||
it as `--disk '[datastore1] vm/vm.vmdk'`, quoted because of the space.
|
||||
|
||||
Power the virtual machine off before you go on. The host refuses to open
|
||||
the link a running virtual machine writes to, and a disk read while it is
|
||||
being written to would not be a consistent image anyway.
|
||||
|
||||
## 5. Read the first sector
|
||||
|
||||
`nbdsh` starts `vectura serve` as a child process, negotiates the NBD
|
||||
export over the child's standard input and output, and gives you a handle
|
||||
`h` on it. Fill in your host, thumbprint, reference and path:
|
||||
|
||||
```sh
|
||||
nbdsh -c '
|
||||
h.connect_command(["vectura", "serve",
|
||||
"--host", "esxi.example",
|
||||
"--user", "root",
|
||||
"--vm-id", "3",
|
||||
"--disk", "[datastore1] vm/vm.vmdk",
|
||||
"--thumbprint", "sha256:BA:78:16:...:AD"])
|
||||
print("capacity:", h.get_size(), "bytes")
|
||||
sector = h.pread(512, 0)
|
||||
print("boot signature:", sector[510:512].hex())
|
||||
h.shutdown()
|
||||
'
|
||||
```
|
||||
|
||||
You should see the capacity of the disk in bytes and, for a disk with a
|
||||
partition table, the boot signature `55aa`. Behind the scenes, Vectura
|
||||
logged in to the host, obtained a ticket for the virtual machine's files,
|
||||
opened the disk on the data port, answered the client's negotiation with
|
||||
the capacity the host reported, served one 512-byte read, then closed the
|
||||
disk and logged out when `h.shutdown()` sent the disconnect.
|
||||
|
||||
If the command fails instead, the reason is on standard error with a
|
||||
`vectura:` prefix. The most common ones at this point:
|
||||
|
||||
- `VECTURA_PASSWORD is not set`: step 3 was run in another shell.
|
||||
- `certificate thumbprint mismatch`: the `--thumbprint` value is not the
|
||||
`443 sha256` line of step 2.
|
||||
- `the host reported error 11: ... permission check failed`: the path
|
||||
does not belong to the virtual machine `--vm-id` names, or the account
|
||||
may not read it.
|
||||
|
||||
## 6. Watch the session
|
||||
|
||||
Run the same command with `--verbose` added to the argument list. Vectura
|
||||
then narrates the session on standard error, which `nbdsh` leaves on your
|
||||
terminal:
|
||||
|
||||
```
|
||||
RetrieveServiceContent: 200 in 41 ms
|
||||
Login: 200 in 63 ms
|
||||
RetrieveInternalContent: 200 in 38 ms
|
||||
NfcGetVmFiles: 200 in 52 ms
|
||||
greeting: SSL Required, ServerDaemonProtocol:SOAP, MKSDisplayProtocol:VNC , VMXARGS supported, NFCSSL supported, SHA256 supported
|
||||
first session tls TLSv1_2 TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
|
||||
> SESSION <ticket>
|
||||
> BANNER
|
||||
< 220 ...
|
||||
> THUMBPRINT_SHA2 ...
|
||||
< 200 BA:78:16:...
|
||||
> PROXY nfcssl
|
||||
< 200 ...
|
||||
second session tls TLSv1_2 TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
|
||||
> handshake type 43
|
||||
...
|
||||
open: 17179869184 bytes, sector size 512
|
||||
nbd option 7: info
|
||||
nbd export: 17179869184 bytes
|
||||
summary: bytes served 512, wire bytes 528, host reads 1, elapsed 0.3 s
|
||||
Logout: 200 in 35 ms
|
||||
```
|
||||
|
||||
The ticket is shown as `<ticket>`: the transcript never carries the
|
||||
password, the ticket or the session cookie, so it is safe to paste into a
|
||||
bug report. The `summary` line says the one sector cost one read on the
|
||||
host; the wire bytes count the chunk's framing as well as its data. On a
|
||||
whole disk the wire bytes fall far below the bytes served, because the
|
||||
default compression leaves out runs of zeros.
|
||||
|
||||
## Where to go next
|
||||
|
||||
- To copy a whole disk into a raw image and check it against the
|
||||
datastore's own copy, see
|
||||
[Copy a disk to a raw image](../how-to/copy-a-disk-to-a-raw-image.md).
|
||||
- To spawn Vectura from your own program, see
|
||||
[Drive Vectura from a program](../how-to/drive-vectura-from-a-program.md).
|
||||
- To understand what the transcript lines mean, see
|
||||
[The transcript](../reference/transcript.md), and for what happened
|
||||
between the login and the first byte,
|
||||
[How a disk is read](../explanation/how-a-disk-is-read.md).
|
||||
@@ -0,0 +1,7 @@
|
||||
# The toolchain CI, scripts/build.sh and a fresh clone use, through
|
||||
# rustup. An exact version rather than "stable" because `cargo clippy -- -D
|
||||
# warnings` would turn every lint a newer Clippy ships into a red gate on a
|
||||
# pull request that touched nothing.
|
||||
[toolchain]
|
||||
channel = "1.98.0"
|
||||
components = ["rustfmt", "clippy", "llvm-tools-preview"]
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
# Builds the vectura binary into the directory given as the only argument.
|
||||
#
|
||||
# zig links the binary against glibc 2.34 whatever the glibc of the build
|
||||
# machine, so it runs on Debian 12 and Ubuntu 22.04 wherever it is built.
|
||||
# Needs zig and cargo-zigbuild, see the README. The Vectura workflow and
|
||||
# release.sh run it.
|
||||
set -eu
|
||||
|
||||
out=$(mkdir -p "$1" && cd "$1" && pwd)
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
cargo zigbuild --locked --release --target x86_64-unknown-linux-gnu.2.34
|
||||
# the glibc suffix is for zig only: the binary lands under the plain triple
|
||||
install -m 755 target/x86_64-unknown-linux-gnu/release/vectura "$out"
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
# Checks the vectura binary given as the only argument. It links against
|
||||
# glibc only and uses no glibc symbol newer than 2.34, the version build.sh
|
||||
# links against, below the glibc 2.36 of Debian 12, the oldest system it
|
||||
# supports: another library or a newer glibc fails here instead of on a
|
||||
# user's machine. Then it runs the binary: without a subcommand vectura exits
|
||||
# 2 with a `vectura: ` line on standard error and nothing on standard output,
|
||||
# which proves it runs and links. The Vectura workflow runs it.
|
||||
set -euo pipefail
|
||||
export LC_ALL=C
|
||||
# a path with a slash, or running it would look it up in PATH
|
||||
binary=$(realpath "$1")
|
||||
|
||||
test -x "$binary"
|
||||
|
||||
readelf --dynamic "$binary" | sed -n 's/.*(NEEDED).*\[\(.*\)\]$/\1/p' | while read -r library; do
|
||||
case $library in
|
||||
libc.so.6 | libm.so.6 | ld-linux-x86-64.so.2) ;;
|
||||
*)
|
||||
echo "$binary needs $library" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
newest=$(objdump -T "$binary" | grep -o 'GLIBC_[0-9.]*' | sort -u -V | tail -n 1)
|
||||
if [ "$(printf '%s\n' "$newest" GLIBC_2.34 | sort -V | tail -n 1)" != GLIBC_2.34 ]; then
|
||||
echo "$binary needs $newest" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
code=0
|
||||
"$binary" > "$tmp/out" 2> "$tmp/err" || code=$?
|
||||
test "$code" -eq 2
|
||||
test ! -s "$tmp/out"
|
||||
grep --quiet '^vectura: ' "$tmp/err"
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/bin/sh
|
||||
# Releases vectura: `scripts/bump-pkg vectura <patch|minor|major>`, at the
|
||||
# root of the repository, runs it for the `- vectura <type>` line of
|
||||
# CHANGELOG.unreleased.md. Bumps the version of the crate, rebuilds its
|
||||
# binary in place of the previous one and commits both. See build.sh for the
|
||||
# tools it needs.
|
||||
set -eu
|
||||
|
||||
case ${1-} in
|
||||
patch | minor | major) type=$1 ;;
|
||||
*)
|
||||
echo "Usage: $0 patch|minor|major" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# as bump-pkg does with package.json: after a failed run, bumping again would
|
||||
# skip a version
|
||||
if ! git diff-index --quiet HEAD Cargo.toml; then
|
||||
echo "Changes detected in Cargo.toml, revert them first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
old=$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -n 1)
|
||||
new=$(echo "$old" | awk -F. -v type="$type" '{
|
||||
if (type == "major") print $1 + 1 ".0.0"
|
||||
else if (type == "minor") print $1 "." $2 + 1 ".0"
|
||||
else print $1 "." $2 "." $3 + 1
|
||||
}')
|
||||
echo "vectura: $old -> $new ($type)"
|
||||
|
||||
# The first `version` line of Cargo.toml is the one of [package], and the
|
||||
# line after `name = "vectura"` in Cargo.lock is its entry there.
|
||||
awk -v new="$new" '!done && /^version = / { $0 = "version = \"" new "\""; done = 1 } 1' Cargo.toml > Cargo.toml.tmp
|
||||
mv Cargo.toml.tmp Cargo.toml
|
||||
awk -v new="$new" 'prev == "name = \"vectura\"" && /^version = / { $0 = "version = \"" new "\"" } { print; prev = $0 }' Cargo.lock > Cargo.lock.tmp
|
||||
mv Cargo.lock.tmp Cargo.lock
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
scripts/build.sh "$tmp"
|
||||
|
||||
# mv keeps the mode build.sh set, which git records: the binary stays
|
||||
# executable in the npm package
|
||||
mv "$tmp/vectura" vectura
|
||||
rmdir "$tmp"
|
||||
git add -- Cargo.toml Cargo.lock vectura
|
||||
git commit --message "feat(vectura): $new"
|
||||
@@ -0,0 +1,581 @@
|
||||
//! The command line: argument parsing, exit codes, and which stream gets what.
|
||||
//!
|
||||
//! Exit codes: 0 on success, 1 when talking to the host or writing the result
|
||||
//! failed, 2 for a usage error. Explanations go to standard error with a
|
||||
//! `vectura:` prefix; standard output carries results only: the thumbprint
|
||||
//! lines of `fingerprint`, the NBD stream of `serve`.
|
||||
|
||||
use std::env::VarError;
|
||||
use std::ffi::OsString;
|
||||
use std::io::{self, Write};
|
||||
use std::num::{NonZeroU8, NonZeroUsize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::ExitCode;
|
||||
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
use rustls_pki_types::CertificateDer;
|
||||
use rustls_pki_types::pem::PemObject;
|
||||
|
||||
use crate::transcript::Transcript;
|
||||
use crate::{disk, nfc, serve, soap, tls};
|
||||
|
||||
/// Exit code when the host or the output could not be dealt with.
|
||||
const EXIT_FAILURE: u8 = 1;
|
||||
|
||||
/// Exit code for a usage error: bad flags, missing values, unknown commands.
|
||||
const EXIT_USAGE: u8 = 2;
|
||||
|
||||
/// What clap puts before every usage message; `vectura:` replaces it so the
|
||||
/// line names the program rather than a generic word.
|
||||
const CLAP_ERROR_PREFIX: &str = "error: ";
|
||||
|
||||
/// The management port, where the vSphere API listens over TLS.
|
||||
const MANAGEMENT_PORT: u16 = 443;
|
||||
|
||||
/// The data port, where the host greets in clear before TLS starts.
|
||||
const DATA_PORT: u16 = 902;
|
||||
|
||||
/// The only place the password is read from: never a flag, a file or a prompt.
|
||||
const PASSWORD_VARIABLE: &str = "VECTURA_PASSWORD";
|
||||
|
||||
/// What a `--thumbprint` value starts with; the rest is the SHA-256 hex.
|
||||
const THUMBPRINT_PREFIX: &str = "sha256:";
|
||||
|
||||
/// How many host reads `serve` keeps in flight unless `--depth` says otherwise.
|
||||
///
|
||||
/// Half of [`serve::MAX_DEPTH`]: enough to keep a host busy, and two
|
||||
/// processes reading from one host stay under its limit without being told to.
|
||||
const DEFAULT_DEPTH: &str = "16";
|
||||
|
||||
/// Reads VMware virtual disks over NFC.
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "vectura", version)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum Command {
|
||||
/// Print the certificate thumbprints a host presents on its management and data ports.
|
||||
Fingerprint(Fingerprint),
|
||||
/// Export one virtual disk, read-only, as an NBD server on standard input and output.
|
||||
Serve(Serve),
|
||||
}
|
||||
|
||||
/// Arguments of `vectura fingerprint`.
|
||||
#[derive(Debug, Args)]
|
||||
struct Fingerprint {
|
||||
/// Host name or address, with an optional management port (default 443).
|
||||
#[arg(long, value_parser = parse_host)]
|
||||
host: HostPort,
|
||||
/// Data port where the host greets before TLS starts.
|
||||
#[arg(long, default_value_t = DATA_PORT)]
|
||||
nfc_port: u16,
|
||||
/// Also report the negotiated TLS version and cipher suite on standard error.
|
||||
#[arg(long)]
|
||||
verbose: bool,
|
||||
}
|
||||
|
||||
/// Arguments of `vectura serve`; the password comes from `VECTURA_PASSWORD`.
|
||||
#[derive(Debug, Args)]
|
||||
struct Serve {
|
||||
/// ESXi host or vCenter name or address, with an optional management port (default 443).
|
||||
#[arg(long, value_parser = parse_host)]
|
||||
host: HostPort,
|
||||
/// User name to log in with.
|
||||
#[arg(long)]
|
||||
user: String,
|
||||
/// Managed object reference of the virtual machine, such as `3` or `vm-42`.
|
||||
#[arg(long)]
|
||||
vm_id: String,
|
||||
/// Datastore path of the disk, such as `[datastore1] vm/vm.vmdk`.
|
||||
#[arg(long)]
|
||||
disk: String,
|
||||
/// Pin the management port's certificate: `sha256:` and 64 hex digits.
|
||||
#[arg(long, value_parser = parse_thumbprint, conflicts_with = "ca_file")]
|
||||
thumbprint: Option<tls::Pin>,
|
||||
/// Verify the management port's certificate against this PEM bundle instead of the system roots.
|
||||
#[arg(long)]
|
||||
ca_file: Option<PathBuf>,
|
||||
/// Host reads kept in flight at once, 1 to 32.
|
||||
///
|
||||
/// The limit is per host: lower it when several processes read from the same host.
|
||||
#[arg(long, default_value = DEFAULT_DEPTH, value_parser = parse_depth)]
|
||||
depth: NonZeroU8,
|
||||
|
||||
/// How the host sends each chunk: skipz, zlib or none.
|
||||
///
|
||||
/// skipz leaves out the runs of zeros, zlib deflates every chunk, none
|
||||
/// sends the bytes as stored.
|
||||
#[arg(long, default_value_t = disk::Compression::Skipz)]
|
||||
compression: disk::Compression,
|
||||
/// How the disk session runs after the ticket dialogue.
|
||||
///
|
||||
/// `nfcssl` opens a second TLS session on the same connection, `nfc` stays
|
||||
/// in clear.
|
||||
#[arg(long, default_value_t = nfc::Transport::Nfcssl)]
|
||||
transport: nfc::Transport,
|
||||
/// Write a transcript of the session to standard error.
|
||||
#[arg(long)]
|
||||
verbose: bool,
|
||||
}
|
||||
|
||||
/// A host name or address and the management port to reach it on.
|
||||
#[derive(Debug, Clone)]
|
||||
struct HostPort {
|
||||
host: String,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
/// Parses `host` or `host:port`; a bare IPv6 address keeps the default port.
|
||||
fn parse_host(text: &str) -> Result<HostPort, String> {
|
||||
let (host, port) = match text.rsplit_once(':') {
|
||||
Some((host, port)) if !host.contains(':') => {
|
||||
let port = port
|
||||
.parse()
|
||||
.map_err(|error| format!("invalid port {port:?}: {error}"))?;
|
||||
(host, port)
|
||||
}
|
||||
_ => (text, MANAGEMENT_PORT),
|
||||
};
|
||||
rustls_pki_types::ServerName::try_from(host)
|
||||
.map_err(|error| format!("invalid host {host:?}: {error}"))?;
|
||||
Ok(HostPort {
|
||||
host: host.to_owned(),
|
||||
port,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parses `sha256:<hex>`; colons between pairs and either case are accepted.
|
||||
fn parse_thumbprint(text: &str) -> Result<tls::Pin, String> {
|
||||
let hex = text
|
||||
.strip_prefix(THUMBPRINT_PREFIX)
|
||||
.ok_or_else(|| format!("{text:?} does not start with {THUMBPRINT_PREFIX}"))?;
|
||||
tls::Pin::sha256(hex).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Parses a depth of 1 to [`serve::MAX_DEPTH`], naming the range when it is not.
|
||||
fn parse_depth(text: &str) -> Result<NonZeroU8, String> {
|
||||
text.parse::<NonZeroU8>()
|
||||
.ok()
|
||||
.filter(|depth| usize::from(depth.get()) <= serve::MAX_DEPTH)
|
||||
.ok_or_else(|| format!("{text} is not in 1..={}", serve::MAX_DEPTH))
|
||||
}
|
||||
|
||||
/// Failures of `vectura fingerprint`, each naming what it happened on.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
enum Failure {
|
||||
/// The management port could not be reached or its TLS handshake failed.
|
||||
#[error("{address}: {source}")]
|
||||
Management {
|
||||
address: String,
|
||||
#[source]
|
||||
source: tls::Error,
|
||||
},
|
||||
/// The data port could not be reached, greeted wrongly, or its TLS handshake failed.
|
||||
#[error("{address}: {source}")]
|
||||
Data {
|
||||
address: String,
|
||||
#[source]
|
||||
source: nfc::Error,
|
||||
},
|
||||
/// The result could not be written.
|
||||
#[error("writing to standard output: {0}")]
|
||||
Stdout(#[source] io::Error),
|
||||
}
|
||||
|
||||
/// Runs the command line `args` and returns the exit code.
|
||||
pub fn run<I, T>(args: I) -> ExitCode
|
||||
where
|
||||
I: IntoIterator<Item = T>,
|
||||
T: Into<OsString> + Clone,
|
||||
{
|
||||
let cli = match Cli::try_parse_from(args) {
|
||||
Ok(cli) => cli,
|
||||
Err(error) => return report_parse(&error),
|
||||
};
|
||||
match cli.command {
|
||||
Command::Fingerprint(arguments) => match fingerprint(&arguments) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(failure) => {
|
||||
eprintln!("vectura: {failure}");
|
||||
ExitCode::from(EXIT_FAILURE)
|
||||
}
|
||||
},
|
||||
Command::Serve(arguments) => serve_command(&arguments, std::env::var(PASSWORD_VARIABLE)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `serve` with the password as the environment yielded it.
|
||||
///
|
||||
/// A missing or empty password and an unreadable CA file are usage errors,
|
||||
/// reported before any connection is made.
|
||||
fn serve_command(arguments: &Serve, password: Result<String, VarError>) -> ExitCode {
|
||||
let password = match password {
|
||||
Ok(text) if !text.is_empty() => soap::Password::new(text),
|
||||
_ => {
|
||||
eprintln!("vectura: {PASSWORD_VARIABLE} is not set");
|
||||
return ExitCode::from(EXIT_USAGE);
|
||||
}
|
||||
};
|
||||
let trust = match trust(arguments) {
|
||||
Ok(trust) => trust,
|
||||
Err(text) => {
|
||||
eprintln!("vectura: {text}");
|
||||
return ExitCode::from(EXIT_USAGE);
|
||||
}
|
||||
};
|
||||
if arguments.transport == nfc::Transport::Nfc {
|
||||
// A warning standard error cannot take is dropped, as a transcript
|
||||
// line is: the disk stream must not stop because its reader left.
|
||||
let _ = writeln!(
|
||||
io::stderr().lock(),
|
||||
"vectura: warning: --transport nfc carries the disk in clear text after the ticket dialogue"
|
||||
);
|
||||
}
|
||||
let target = serve::Target {
|
||||
host: arguments.host.host.clone(),
|
||||
port: arguments.host.port,
|
||||
trust,
|
||||
user: arguments.user.clone(),
|
||||
vm_id: arguments.vm_id.clone(),
|
||||
disk: arguments.disk.clone(),
|
||||
depth: NonZeroUsize::from(arguments.depth),
|
||||
compression: arguments.compression,
|
||||
transport: arguments.transport,
|
||||
transcript: Transcript::new(arguments.verbose),
|
||||
};
|
||||
let mut stdout = io::stdout().lock();
|
||||
match serve::run(&target, &password, io::stdin(), &mut stdout) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
eprintln!("vectura: {error}");
|
||||
ExitCode::from(EXIT_FAILURE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How `serve` judges the management port's certificate: pin, bundle, or system roots.
|
||||
fn trust(arguments: &Serve) -> Result<tls::Trust, String> {
|
||||
if let Some(pin) = &arguments.thumbprint {
|
||||
return Ok(tls::Trust::Pin(pin.clone()));
|
||||
}
|
||||
match &arguments.ca_file {
|
||||
Some(path) => read_anchors(path).map(tls::Trust::Anchors),
|
||||
None => Ok(tls::Trust::System),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads every certificate of the PEM bundle at `path`.
|
||||
fn read_anchors(path: &Path) -> Result<Vec<CertificateDer<'static>>, String> {
|
||||
let bytes =
|
||||
std::fs::read(path).map_err(|error| format!("cannot read {}: {error}", path.display()))?;
|
||||
CertificateDer::pem_slice_iter(&bytes)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|error| format!("cannot parse {}: {error}", path.display()))
|
||||
}
|
||||
|
||||
/// Prints a parse outcome where it belongs and returns its exit code.
|
||||
///
|
||||
/// Usage errors go to standard error with exit 2; help and version go to
|
||||
/// standard output with exit 0.
|
||||
fn report_parse(error: &clap::Error) -> ExitCode {
|
||||
if error.use_stderr() {
|
||||
let text = error.to_string();
|
||||
let text = text.strip_prefix(CLAP_ERROR_PREFIX).unwrap_or(&text);
|
||||
eprintln!("vectura: {text}");
|
||||
return ExitCode::from(EXIT_USAGE);
|
||||
}
|
||||
if error.print().is_ok() {
|
||||
ExitCode::SUCCESS
|
||||
} else {
|
||||
ExitCode::from(EXIT_FAILURE)
|
||||
}
|
||||
}
|
||||
|
||||
/// Probes both ports, then prints their thumbprints, management port first.
|
||||
fn fingerprint(arguments: &Fingerprint) -> Result<(), Failure> {
|
||||
let HostPort { host, port } = &arguments.host;
|
||||
let management = tls::connect(host, *port)
|
||||
.and_then(|mut stream| tls::capture(&mut stream, host))
|
||||
.map_err(|source| Failure::Management {
|
||||
address: format!("{host}:{port}"),
|
||||
source,
|
||||
})?;
|
||||
let data = nfc::capture(host, arguments.nfc_port).map_err(|source| Failure::Data {
|
||||
address: format!("{host}:{}", arguments.nfc_port),
|
||||
source,
|
||||
})?;
|
||||
let presented = [(*port, management), (arguments.nfc_port, data)];
|
||||
let transcript = Transcript::new(arguments.verbose);
|
||||
for (port, presented) in &presented {
|
||||
transcript.tls(&port.to_string(), presented.version, presented.suite);
|
||||
}
|
||||
let mut stdout = io::stdout().lock();
|
||||
for (port, presented) in &presented {
|
||||
writeln!(
|
||||
stdout,
|
||||
"{port} sha256 {}",
|
||||
tls::sha256(&presented.certificate)
|
||||
)
|
||||
.map_err(Failure::Stdout)?;
|
||||
writeln!(stdout, "{port} sha1 {}", tls::sha1(&presented.certificate))
|
||||
.map_err(Failure::Stdout)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn serve_takes_the_nfc_transport_and_verbose_from_the_flags() {
|
||||
let cli = Cli::try_parse_from([
|
||||
"vectura",
|
||||
"serve",
|
||||
"--host",
|
||||
"esxi.example",
|
||||
"--user",
|
||||
"root",
|
||||
"--vm-id",
|
||||
"3",
|
||||
"--disk",
|
||||
"d",
|
||||
"--transport",
|
||||
"nfc",
|
||||
"--verbose",
|
||||
])
|
||||
.unwrap();
|
||||
let Command::Serve(serve) = cli.command else {
|
||||
panic!("serve was parsed");
|
||||
};
|
||||
assert_eq!(serve.transport, nfc::Transport::Nfc);
|
||||
assert!(serve.verbose);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_data_port_defaults_to_902_and_verbose_is_off() {
|
||||
let cli =
|
||||
Cli::try_parse_from(["vectura", "fingerprint", "--host", "esxi.example"]).unwrap();
|
||||
let Command::Fingerprint(fingerprint) = cli.command else {
|
||||
panic!("fingerprint was parsed");
|
||||
};
|
||||
assert_eq!((fingerprint.host.port, fingerprint.nfc_port), (443, 902));
|
||||
assert!(!fingerprint.verbose);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_takes_its_target_from_the_flags_and_pins_with_a_prefixed_thumbprint() {
|
||||
let cli = Cli::try_parse_from([
|
||||
"vectura",
|
||||
"serve",
|
||||
"--host",
|
||||
"esxi.example:8443",
|
||||
"--user",
|
||||
"root",
|
||||
"--vm-id",
|
||||
"3",
|
||||
"--disk",
|
||||
"[datastore1] vm/vm.vmdk",
|
||||
"--thumbprint",
|
||||
&format!("sha256:{}", "ab".repeat(32)),
|
||||
])
|
||||
.unwrap();
|
||||
let Command::Serve(serve) = cli.command else {
|
||||
panic!("serve was parsed");
|
||||
};
|
||||
assert_eq!(
|
||||
(serve.host.host.as_str(), serve.host.port),
|
||||
("esxi.example", 8443)
|
||||
);
|
||||
assert_eq!((serve.user.as_str(), serve.vm_id.as_str()), ("root", "3"));
|
||||
assert_eq!(serve.disk, "[datastore1] vm/vm.vmdk");
|
||||
assert!(serve.thumbprint.is_some());
|
||||
assert!(matches!(trust(&serve), Ok(tls::Trust::Pin(_))));
|
||||
assert_eq!(serve.transport, nfc::Transport::Nfcssl);
|
||||
assert!(!serve.verbose);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thumbprint_without_the_sha256_prefix_or_with_bad_hex_is_refused() {
|
||||
let error = parse_thumbprint(&"ab".repeat(32)).unwrap_err();
|
||||
assert!(error.ends_with("does not start with sha256:"), "{error}");
|
||||
let error = parse_thumbprint("sha256:zz").unwrap_err();
|
||||
assert!(
|
||||
error.ends_with("is not a sha256 thumbprint of 64 hex digits"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumbprint_and_ca_file_together_are_a_usage_error() {
|
||||
let error = Cli::try_parse_from([
|
||||
"vectura",
|
||||
"serve",
|
||||
"--host",
|
||||
"h",
|
||||
"--user",
|
||||
"u",
|
||||
"--vm-id",
|
||||
"1",
|
||||
"--disk",
|
||||
"d",
|
||||
"--thumbprint",
|
||||
&format!("sha256:{}", "ab".repeat(32)),
|
||||
"--ca-file",
|
||||
"roots.pem",
|
||||
])
|
||||
.unwrap_err();
|
||||
assert!(error.use_stderr());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_compression_defaults_to_skipz_and_takes_zlib_or_none() {
|
||||
let base = [
|
||||
"vectura", "serve", "--host", "h", "--user", "u", "--vm-id", "1", "--disk", "d",
|
||||
];
|
||||
let Command::Serve(serve) = Cli::try_parse_from(base).unwrap().command else {
|
||||
panic!("serve was parsed");
|
||||
};
|
||||
assert_eq!(serve.compression, disk::Compression::Skipz);
|
||||
for (name, expected) in [
|
||||
("skipz", Some(disk::Compression::Skipz)),
|
||||
("zlib", Some(disk::Compression::Zlib)),
|
||||
("none", Some(disk::Compression::None)),
|
||||
("gzip", None),
|
||||
] {
|
||||
let mut args = base.to_vec();
|
||||
args.extend(["--compression", name]);
|
||||
let parsed = Cli::try_parse_from(args)
|
||||
.ok()
|
||||
.and_then(|cli| match cli.command {
|
||||
Command::Serve(serve) => Some(serve.compression),
|
||||
Command::Fingerprint(_) => None,
|
||||
});
|
||||
assert_eq!(parsed, expected, "--compression {name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_depth_defaults_to_sixteen_and_takes_one_to_thirty_two() {
|
||||
let base = [
|
||||
"vectura", "serve", "--host", "h", "--user", "u", "--vm-id", "1", "--disk", "d",
|
||||
];
|
||||
let Command::Serve(serve) = Cli::try_parse_from(base).unwrap().command else {
|
||||
panic!("serve was parsed");
|
||||
};
|
||||
assert_eq!(serve.depth.get(), 16);
|
||||
for (depth, accepted) in [("1", true), ("32", true), ("0", false), ("33", false)] {
|
||||
let mut args = base.to_vec();
|
||||
args.extend(["--depth", depth]);
|
||||
let parsed = Cli::try_parse_from(args);
|
||||
assert_eq!(parsed.is_ok(), accepted, "--depth {depth}");
|
||||
if let Ok(Cli {
|
||||
command: Command::Serve(serve),
|
||||
}) = parsed
|
||||
{
|
||||
assert_eq!(serve.depth.to_string(), depth);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn serve_with(ca_file: Option<PathBuf>) -> Serve {
|
||||
Serve {
|
||||
host: parse_host("esxi.example").unwrap(),
|
||||
user: "root".to_owned(),
|
||||
vm_id: "3".to_owned(),
|
||||
disk: "[ds] vm.vmdk".to_owned(),
|
||||
thumbprint: None,
|
||||
ca_file,
|
||||
depth: parse_depth(DEFAULT_DEPTH).unwrap(),
|
||||
compression: disk::Compression::Skipz,
|
||||
transport: nfc::Transport::Nfcssl,
|
||||
verbose: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_password_is_a_usage_error_like_a_missing_one() {
|
||||
let usage = ExitCode::from(EXIT_USAGE);
|
||||
assert_eq!(serve_command(&serve_with(None), Ok(String::new())), usage);
|
||||
assert_eq!(
|
||||
serve_command(&serve_with(None), Err(VarError::NotPresent)),
|
||||
usage
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_pin_or_a_bundle_the_system_roots_apply() {
|
||||
assert!(matches!(trust(&serve_with(None)), Ok(tls::Trust::System)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_ca_file_is_refused_naming_it() {
|
||||
let error = trust(&serve_with(Some(PathBuf::from("/nonexistent/roots.pem")))).unwrap_err();
|
||||
assert!(
|
||||
error.starts_with("cannot read /nonexistent/roots.pem: "),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pem_bundle_yields_its_certificates_and_a_broken_one_is_refused() {
|
||||
let directory = std::env::temp_dir().join(format!("vectura-cli-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
// The PEM reader does not check the DER inside, so two tiny blocks do.
|
||||
let block = "-----BEGIN CERTIFICATE-----\nMAA=\n-----END CERTIFICATE-----\n";
|
||||
let bundle = directory.join("roots.pem");
|
||||
std::fs::write(&bundle, [block, block].concat()).unwrap();
|
||||
let Ok(tls::Trust::Anchors(anchors)) = trust(&serve_with(Some(bundle))) else {
|
||||
panic!("the bundle was read");
|
||||
};
|
||||
assert_eq!(anchors.len(), 2);
|
||||
|
||||
let broken = directory.join("broken.pem");
|
||||
std::fs::write(
|
||||
&broken,
|
||||
"-----BEGIN CERTIFICATE-----\nnot base64\n-----END CERTIFICATE-----\n",
|
||||
)
|
||||
.unwrap();
|
||||
let error = trust(&serve_with(Some(broken))).unwrap_err();
|
||||
assert!(error.contains("cannot parse"), "{error}");
|
||||
std::fs::remove_dir_all(&directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_host_without_a_port_gets_the_management_port() {
|
||||
let parsed = parse_host("esxi.example").unwrap();
|
||||
assert_eq!((parsed.host.as_str(), parsed.port), ("esxi.example", 443));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_host_with_a_port_keeps_it() {
|
||||
let parsed = parse_host("esxi.example:8443").unwrap();
|
||||
assert_eq!((parsed.host.as_str(), parsed.port), ("esxi.example", 8443));
|
||||
let parsed = parse_host("192.0.2.10:443").unwrap();
|
||||
assert_eq!((parsed.host.as_str(), parsed.port), ("192.0.2.10", 443));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_ipv6_address_is_a_host_with_the_default_port() {
|
||||
let parsed = parse_host("2001:db8::10").unwrap();
|
||||
assert_eq!((parsed.host.as_str(), parsed.port), ("2001:db8::10", 443));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unparsable_port_or_host_is_refused_with_the_offending_text() {
|
||||
let error = parse_host("esxi.example:notaport").unwrap_err();
|
||||
assert!(error.starts_with("invalid port \"notaport\": "), "{error}");
|
||||
let error = parse_host("esxi.example:70000").unwrap_err();
|
||||
assert!(error.starts_with("invalid port \"70000\": "), "{error}");
|
||||
let error = parse_host("not a host:443").unwrap_err();
|
||||
assert!(
|
||||
error.starts_with("invalid host \"not a host\": "),
|
||||
"{error}"
|
||||
);
|
||||
let error = parse_host("").unwrap_err();
|
||||
assert!(error.starts_with("invalid host \"\": "), "{error}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,840 @@
|
||||
//! Splitting a client read into host reads and decoding the chunks that answer them.
|
||||
//!
|
||||
//! Everything here is a pure function over numbers and byte slices: no
|
||||
//! socket, no clock, so it is tested without a host.
|
||||
|
||||
use std::fmt;
|
||||
use std::io::Read as _;
|
||||
use std::str::FromStr;
|
||||
|
||||
/// One mebibyte, the most a host read asks for.
|
||||
///
|
||||
/// Cutting on the disk's mebibyte boundaries is a choice made here, not a
|
||||
/// rule of the host: a mebibyte is the read size the host was measured
|
||||
/// with, at most sixteen chunks, and the block size the export will advertise
|
||||
/// as preferred, so a client reading that way sends one host read per command.
|
||||
pub const MIB: u64 = 1 << 20;
|
||||
/// The 512-byte sector every host read is widened to.
|
||||
///
|
||||
/// Asking whole sectors is a choice made here, not a rule of the host: it
|
||||
/// gives every host read the same shape whatever a client asks, and the
|
||||
/// padding around the client's range is dropped when the chunks are placed.
|
||||
pub const SECTOR: u64 = 512;
|
||||
|
||||
/// One host read: sector aligned, within one mebibyte, and the chunks received for it.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Read {
|
||||
/// The disk offset the read starts at.
|
||||
pub offset: u64,
|
||||
/// How many bytes it asks for.
|
||||
pub length: u32,
|
||||
/// The ranges within the read that arrived, as `(offset, end)` pairs.
|
||||
received: Vec<(u32, u32)>,
|
||||
}
|
||||
|
||||
/// The code of a chunk sent as stored: the wire holds its bytes.
|
||||
///
|
||||
/// A read request carries the code at offset 12 and every reply echoes it.
|
||||
const NONE: u32 = 0;
|
||||
/// The code of a chunk sent as one zlib stream.
|
||||
const ZLIB: u32 = 1;
|
||||
/// The code of a chunk sent as skipz, its runs of zeros left out.
|
||||
///
|
||||
/// The code between, 2, is fastlz, which is neither asked for nor decoded.
|
||||
const SKIPZ: u32 = 3;
|
||||
/// Every field of a skipz stream is a little-endian word this wide.
|
||||
const WORD: usize = 4;
|
||||
/// A skipz stream starts with the chunk length and a reserved word.
|
||||
const SKIPZ_HEADER_LEN: usize = 2 * WORD;
|
||||
/// A skipz segment starts with its offset within the chunk and its length.
|
||||
const SEGMENT_HEADER_LEN: usize = 2 * WORD;
|
||||
|
||||
/// How the host is asked to send each chunk.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Compression {
|
||||
/// As stored.
|
||||
None,
|
||||
/// As one zlib stream per chunk.
|
||||
Zlib,
|
||||
/// As the runs worth sending, the zeros between them left out.
|
||||
Skipz,
|
||||
}
|
||||
|
||||
impl Compression {
|
||||
/// The code the wire carries for this compression.
|
||||
#[must_use]
|
||||
pub const fn code(self) -> u32 {
|
||||
match self {
|
||||
Compression::None => NONE,
|
||||
Compression::Zlib => ZLIB,
|
||||
Compression::Skipz => SKIPZ,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Compression {
|
||||
type Err = String;
|
||||
|
||||
/// Parses `none`, `zlib` or `skipz`, naming the three on any other text.
|
||||
fn from_str(text: &str) -> Result<Compression, String> {
|
||||
match text {
|
||||
"none" => Ok(Compression::None),
|
||||
"zlib" => Ok(Compression::Zlib),
|
||||
"skipz" => Ok(Compression::Skipz),
|
||||
other => Err(format!("{other} is not none, zlib or skipz")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Compression {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(match self {
|
||||
Compression::None => "none",
|
||||
Compression::Zlib => "zlib",
|
||||
Compression::Skipz => "skipz",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// What one reply says about a chunk, in disk terms.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Chunk {
|
||||
/// The disk offset the host says the read started at.
|
||||
pub base_offset: u64,
|
||||
/// The length the host says the read asked for.
|
||||
pub total_length: u32,
|
||||
/// Where the chunk starts within the read.
|
||||
pub offset: u32,
|
||||
/// How many disk bytes the chunk holds.
|
||||
pub length: u32,
|
||||
/// The code of the compression the reply declares, echoed from the request.
|
||||
pub compression: u32,
|
||||
}
|
||||
|
||||
/// A chunk that does not fit its read or does not decode, with raw values.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum Error {
|
||||
/// The chunk names another base offset than the read asked.
|
||||
#[error("chunk: base offset {received} answers a read at offset {asked}")]
|
||||
BaseOffset {
|
||||
/// The offset the read asked.
|
||||
asked: u64,
|
||||
/// The base offset the chunk carried.
|
||||
received: u64,
|
||||
},
|
||||
/// The chunk names another total length than the read asked.
|
||||
#[error("chunk: total length {received} answers a read of {asked} bytes")]
|
||||
TotalLength {
|
||||
/// The length the read asked.
|
||||
asked: u32,
|
||||
/// The total length the chunk carried.
|
||||
received: u32,
|
||||
},
|
||||
/// The chunk ends past the read.
|
||||
#[error("chunk: {length} bytes at offset {offset} end past the {asked} bytes read")]
|
||||
OutOfRange {
|
||||
/// The chunk's offset within the read.
|
||||
offset: u32,
|
||||
/// The chunk's length.
|
||||
length: u32,
|
||||
/// The length the read asked.
|
||||
asked: u32,
|
||||
},
|
||||
/// The chunk covers bytes another chunk already delivered.
|
||||
#[error("chunk: {length} bytes at offset {offset} overlap a chunk already received")]
|
||||
Overlap {
|
||||
/// The chunk's offset within the read.
|
||||
offset: u32,
|
||||
/// The chunk's length.
|
||||
length: u32,
|
||||
},
|
||||
/// The chunk came with a compression that is not decoded.
|
||||
#[error(
|
||||
"chunk: {length} bytes at offset {offset} came with compression {compression}, which is not decoded"
|
||||
)]
|
||||
Compression {
|
||||
/// The chunk's offset within the read.
|
||||
offset: u32,
|
||||
/// The chunk's length.
|
||||
length: u32,
|
||||
/// The code the reply declares.
|
||||
compression: u32,
|
||||
},
|
||||
/// The chunk came as stored, with another number of bytes than it holds.
|
||||
#[error("chunk: {length} bytes at offset {offset} came as {wire} bytes on the wire")]
|
||||
WireLength {
|
||||
/// The chunk's offset within the read.
|
||||
offset: u32,
|
||||
/// The chunk's length.
|
||||
length: u32,
|
||||
/// The bytes it carried on the wire.
|
||||
wire: usize,
|
||||
},
|
||||
/// The chunk came as a zlib stream that does not inflate.
|
||||
#[error("chunk: {length} bytes at offset {offset}: the zlib stream does not inflate: {detail}")]
|
||||
Zlib {
|
||||
/// The chunk's offset within the read.
|
||||
offset: u32,
|
||||
/// The chunk's length.
|
||||
length: u32,
|
||||
/// What the inflater reported.
|
||||
detail: String,
|
||||
},
|
||||
/// The chunk's zlib stream inflates to another number of bytes than it holds.
|
||||
#[error("chunk: {length} bytes at offset {offset} inflate to {inflated} bytes")]
|
||||
Inflated {
|
||||
/// The chunk's offset within the read.
|
||||
offset: u32,
|
||||
/// The chunk's length.
|
||||
length: u32,
|
||||
/// The bytes the stream inflates to, counted up to one past the length.
|
||||
inflated: usize,
|
||||
},
|
||||
/// The chunk came as a skipz stream that ends inside a header or a segment.
|
||||
#[error(
|
||||
"chunk: {length} bytes at offset {offset}: the skipz stream of {wire} bytes ends inside a header or a segment"
|
||||
)]
|
||||
SkipzShort {
|
||||
/// The chunk's offset within the read.
|
||||
offset: u32,
|
||||
/// The chunk's length.
|
||||
length: u32,
|
||||
/// The bytes the stream holds.
|
||||
wire: usize,
|
||||
},
|
||||
/// The chunk came as a skipz stream with a segment ending past the chunk.
|
||||
#[error(
|
||||
"chunk: {length} bytes at offset {offset}: a skipz segment of {segment_length} bytes at offset {segment_offset} ends past the chunk"
|
||||
)]
|
||||
SkipzSegment {
|
||||
/// The chunk's offset within the read.
|
||||
offset: u32,
|
||||
/// The chunk's length.
|
||||
length: u32,
|
||||
/// The segment's offset within the chunk.
|
||||
segment_offset: u32,
|
||||
/// The segment's length.
|
||||
segment_length: u32,
|
||||
},
|
||||
}
|
||||
|
||||
impl Chunk {
|
||||
/// Decodes the chunk's `wire` bytes, as the reply declares them, into its bytes.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the compression is one that is not decoded, when the bytes do not
|
||||
/// decode, or when they decode to another number of bytes than the chunk holds.
|
||||
pub fn decode(&self, wire: &[u8]) -> Result<Vec<u8>, Error> {
|
||||
match self.compression {
|
||||
NONE => self.raw(wire),
|
||||
ZLIB => self.inflate(wire),
|
||||
SKIPZ => self.unskip(wire),
|
||||
compression => Err(Error::Compression {
|
||||
offset: self.offset,
|
||||
length: self.length,
|
||||
compression,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// The chunk sent as stored: the wire is its bytes.
|
||||
fn raw(&self, wire: &[u8]) -> Result<Vec<u8>, Error> {
|
||||
if wire.len() != index(self.length) {
|
||||
return Err(Error::WireLength {
|
||||
offset: self.offset,
|
||||
length: self.length,
|
||||
wire: wire.len(),
|
||||
});
|
||||
}
|
||||
Ok(wire.to_vec())
|
||||
}
|
||||
|
||||
/// The chunk sent as one zlib stream.
|
||||
fn inflate(&self, wire: &[u8]) -> Result<Vec<u8>, Error> {
|
||||
let mut bytes = Vec::new();
|
||||
// One byte past the chunk: a stream running longer shows without
|
||||
// inflating all of it.
|
||||
flate2::read::ZlibDecoder::new(wire)
|
||||
.take(u64::from(self.length) + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|error| Error::Zlib {
|
||||
offset: self.offset,
|
||||
length: self.length,
|
||||
detail: error.to_string(),
|
||||
})?;
|
||||
if bytes.len() != index(self.length) {
|
||||
return Err(Error::Inflated {
|
||||
offset: self.offset,
|
||||
length: self.length,
|
||||
inflated: bytes.len(),
|
||||
});
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// The chunk sent as skipz, or as stored when the host had nothing to skip.
|
||||
///
|
||||
/// A skipz stream starts with the chunk length and a reserved word, then
|
||||
/// segments, each its offset within the chunk, its length and its bytes,
|
||||
/// all little-endian. What no segment covers is zero. A stream whose
|
||||
/// first word is not the chunk length is the chunk as stored.
|
||||
fn unskip(&self, wire: &[u8]) -> Result<Vec<u8>, Error> {
|
||||
if word(wire, 0) != Some(self.length) {
|
||||
return self.raw(wire);
|
||||
}
|
||||
let short = || Error::SkipzShort {
|
||||
offset: self.offset,
|
||||
length: self.length,
|
||||
wire: wire.len(),
|
||||
};
|
||||
let mut bytes = vec![0_u8; index(self.length)];
|
||||
let mut rest = wire.get(SKIPZ_HEADER_LEN..).ok_or_else(short)?;
|
||||
while !rest.is_empty() {
|
||||
let (Some(at), Some(count)) = (word(rest, 0), word(rest, WORD)) else {
|
||||
return Err(short());
|
||||
};
|
||||
let end = index(at)
|
||||
.checked_add(index(count))
|
||||
.filter(|end| *end <= bytes.len())
|
||||
.ok_or(Error::SkipzSegment {
|
||||
offset: self.offset,
|
||||
length: self.length,
|
||||
segment_offset: at,
|
||||
segment_length: count,
|
||||
})?;
|
||||
let data_end = SEGMENT_HEADER_LEN + index(count);
|
||||
let data = rest.get(SEGMENT_HEADER_LEN..data_end).ok_or_else(short)?;
|
||||
bytes[index(at)..end].copy_from_slice(data);
|
||||
rest = &rest[data_end..];
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// The little-endian word at `at` of `bytes`, if all its bytes are there.
|
||||
fn word(bytes: &[u8], at: usize) -> Option<u32> {
|
||||
let bytes = bytes.get(at..at + WORD)?;
|
||||
<[u8; WORD]>::try_from(bytes).ok().map(u32::from_le_bytes)
|
||||
}
|
||||
|
||||
/// A wire length or offset as an index.
|
||||
///
|
||||
/// Every supported target has an index at least as wide as a word, so the
|
||||
/// fallback never runs; it keeps the conversion free of a panic.
|
||||
fn index(value: u32) -> usize {
|
||||
usize::try_from(value).unwrap_or(usize::MAX)
|
||||
}
|
||||
|
||||
/// Cuts the range of `length` bytes at `offset`, clipped to `capacity`, into host reads.
|
||||
///
|
||||
/// Each read stays within one [`MIB`] of the disk and is widened to
|
||||
/// [`SECTOR`] boundaries, never past `capacity`. The reads come in disk
|
||||
/// order and do not overlap.
|
||||
#[must_use]
|
||||
pub fn reads(offset: u64, length: u32, capacity: u64) -> Vec<Read> {
|
||||
let end = offset.saturating_add(u64::from(length)).min(capacity);
|
||||
if offset >= end {
|
||||
return Vec::new();
|
||||
}
|
||||
(offset / MIB..=(end - 1) / MIB)
|
||||
.map(|mebibyte| {
|
||||
let start = offset.max(mebibyte * MIB);
|
||||
let stop = end.min(mebibyte.saturating_add(1).saturating_mul(MIB));
|
||||
let aligned_start = start / SECTOR * SECTOR;
|
||||
let aligned_stop = stop.div_ceil(SECTOR).saturating_mul(SECTOR).min(capacity);
|
||||
Read::new(
|
||||
aligned_start,
|
||||
u32::try_from(aligned_stop - aligned_start).unwrap_or(u32::MAX),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Read {
|
||||
/// A read of `length` bytes at `offset` with no chunk received yet.
|
||||
#[must_use]
|
||||
pub fn new(offset: u64, length: u32) -> Read {
|
||||
Read {
|
||||
offset,
|
||||
length,
|
||||
received: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks `chunk` against the read and the chunks before it, then counts it.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the chunk answers another read, ends past this one, or overlaps
|
||||
/// a chunk already accepted.
|
||||
pub fn accept(&mut self, chunk: &Chunk) -> Result<(), Error> {
|
||||
if chunk.base_offset != self.offset {
|
||||
return Err(Error::BaseOffset {
|
||||
asked: self.offset,
|
||||
received: chunk.base_offset,
|
||||
});
|
||||
}
|
||||
if chunk.total_length != self.length {
|
||||
return Err(Error::TotalLength {
|
||||
asked: self.length,
|
||||
received: chunk.total_length,
|
||||
});
|
||||
}
|
||||
let end = chunk
|
||||
.offset
|
||||
.checked_add(chunk.length)
|
||||
.filter(|end| *end <= self.length)
|
||||
.ok_or(Error::OutOfRange {
|
||||
offset: chunk.offset,
|
||||
length: chunk.length,
|
||||
asked: self.length,
|
||||
})?;
|
||||
if self
|
||||
.received
|
||||
.iter()
|
||||
.any(|(start, stop)| chunk.offset < *stop && *start < end)
|
||||
{
|
||||
return Err(Error::Overlap {
|
||||
offset: chunk.offset,
|
||||
length: chunk.length,
|
||||
});
|
||||
}
|
||||
self.received.push((chunk.offset, end));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the accepted chunks cover the whole read.
|
||||
#[must_use]
|
||||
pub fn is_complete(&self) -> bool {
|
||||
self.received
|
||||
.iter()
|
||||
.map(|(start, stop)| stop - start)
|
||||
.sum::<u32>()
|
||||
== self.length
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A mebibyte as a read length.
|
||||
const MIB_LEN: u32 = 1 << 20;
|
||||
|
||||
fn read(offset: u64, length: u32) -> Read {
|
||||
Read::new(offset, length)
|
||||
}
|
||||
|
||||
fn chunk(offset: u32, length: u32) -> Chunk {
|
||||
Chunk {
|
||||
base_offset: 4096,
|
||||
total_length: 2048,
|
||||
offset,
|
||||
length,
|
||||
compression: NONE,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_inside_one_sector_becomes_that_whole_sector() {
|
||||
assert_eq!(reads(700, 100, MIB), vec![read(512, 512)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_is_widened_to_the_sectors_it_touches() {
|
||||
assert_eq!(reads(511, 2, MIB), vec![read(0, 1024)]);
|
||||
assert_eq!(reads(512, 512, MIB), vec![read(512, 512)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_over_three_mebibytes_is_cut_on_the_boundaries() {
|
||||
assert_eq!(
|
||||
reads(MIB - 100, 2 * MIB_LEN + 200, 10 * MIB),
|
||||
vec![
|
||||
read(MIB - 512, 512),
|
||||
read(MIB, MIB_LEN),
|
||||
read(2 * MIB, MIB_LEN),
|
||||
read(3 * MIB, 512),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_ending_on_a_boundary_stops_there() {
|
||||
assert_eq!(reads(0, MIB_LEN, 10 * MIB), vec![read(0, MIB_LEN)]);
|
||||
assert_eq!(reads(MIB - 1, 1, 10 * MIB), vec![read(MIB - 512, 512)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_is_clipped_to_the_capacity_and_never_widened_past_it() {
|
||||
assert_eq!(
|
||||
reads(MIB - 512, 4096, MIB + 100),
|
||||
vec![read(MIB - 512, 512), read(MIB, 100)]
|
||||
);
|
||||
assert_eq!(reads(MIB, 1, MIB), vec![]);
|
||||
assert_eq!(reads(u64::MAX, 1, u64::MAX), vec![]);
|
||||
assert_eq!(
|
||||
reads(u64::MAX - 1, 1, u64::MAX),
|
||||
vec![read(u64::MAX - 511, 511)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_range_yields_no_read() {
|
||||
assert_eq!(reads(4096, 0, MIB), vec![]);
|
||||
assert_eq!(reads(700, 0, MIB), vec![]);
|
||||
assert_eq!(reads(0, 0, 0), vec![]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunks_in_any_order_complete_the_read_once_they_cover_it() {
|
||||
let mut read = read(4096, 2048);
|
||||
assert!(!read.is_complete());
|
||||
read.accept(&chunk(1500, 548)).unwrap();
|
||||
read.accept(&chunk(0, 700)).unwrap();
|
||||
assert!(!read.is_complete());
|
||||
read.accept(&chunk(700, 800)).unwrap();
|
||||
assert!(read.is_complete());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chunk_answering_another_read_is_refused_naming_both() {
|
||||
let mut read = read(4096, 2048);
|
||||
let error = read
|
||||
.accept(&Chunk {
|
||||
base_offset: 8192,
|
||||
..chunk(0, 512)
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
error,
|
||||
Error::BaseOffset {
|
||||
asked: 4096,
|
||||
received: 8192
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"chunk: base offset 8192 answers a read at offset 4096"
|
||||
);
|
||||
let error = read
|
||||
.accept(&Chunk {
|
||||
total_length: 1024,
|
||||
..chunk(0, 512)
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"chunk: total length 1024 answers a read of 2048 bytes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chunk_ending_past_the_read_is_refused() {
|
||||
let mut read = read(4096, 2048);
|
||||
read.accept(&chunk(1536, 512)).unwrap();
|
||||
assert_eq!(
|
||||
read.accept(&chunk(1024, 1025)).unwrap_err().to_string(),
|
||||
"chunk: 1025 bytes at offset 1024 end past the 2048 bytes read"
|
||||
);
|
||||
assert_eq!(
|
||||
read.accept(&chunk(u32::MAX, 1)).unwrap_err(),
|
||||
Error::OutOfRange {
|
||||
offset: u32::MAX,
|
||||
length: 1,
|
||||
asked: 2048
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chunk_overlapping_one_already_received_is_refused() {
|
||||
let mut read = read(4096, 2048);
|
||||
read.accept(&chunk(512, 512)).unwrap();
|
||||
assert_eq!(
|
||||
read.accept(&chunk(1023, 1)).unwrap_err(),
|
||||
Error::Overlap {
|
||||
offset: 1023,
|
||||
length: 1
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
read.accept(&chunk(0, 513)).unwrap_err().to_string(),
|
||||
"chunk: 513 bytes at offset 0 overlap a chunk already received"
|
||||
);
|
||||
read.accept(&chunk(1024, 1024)).unwrap();
|
||||
read.accept(&chunk(0, 512)).unwrap();
|
||||
assert!(read.is_complete());
|
||||
}
|
||||
|
||||
/// A chunk of `length` bytes at offset 0, sent with `compression`.
|
||||
fn sent(length: u32, compression: u32) -> Chunk {
|
||||
Chunk {
|
||||
compression,
|
||||
..chunk(0, length)
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes 0 to `length - 1`.
|
||||
fn counting(length: u8) -> Vec<u8> {
|
||||
(0..length).collect()
|
||||
}
|
||||
|
||||
/// The bytes 0 to 15 as one zlib stream, the output of Python's `zlib.compress`.
|
||||
const ZLIB_16: [u8; 24] = [
|
||||
0x78, 0x9C, 0x63, 0x60, 0x64, 0x62, 0x66, 0x61, 0x65, 0x63, 0xE7, 0xE0, 0xE4, 0xE2, 0xE6,
|
||||
0xE1, 0xE5, 0xE3, 0x07, 0x00, 0x02, 0xB8, 0x00, 0x79,
|
||||
];
|
||||
/// The bytes 0 to 14 as one zlib stream.
|
||||
const ZLIB_15: [u8; 23] = [
|
||||
0x78, 0x9C, 0x63, 0x60, 0x64, 0x62, 0x66, 0x61, 0x65, 0x63, 0xE7, 0xE0, 0xE4, 0xE2, 0xE6,
|
||||
0xE1, 0xE5, 0x03, 0x00, 0x02, 0x3F, 0x00, 0x6A,
|
||||
];
|
||||
/// The bytes 0 to 16 as one zlib stream.
|
||||
const ZLIB_17: [u8; 25] = [
|
||||
0x78, 0x9C, 0x63, 0x60, 0x64, 0x62, 0x66, 0x61, 0x65, 0x63, 0xE7, 0xE0, 0xE4, 0xE2, 0xE6,
|
||||
0xE1, 0xE5, 0xE3, 0x17, 0x00, 0x00, 0x03, 0x41, 0x00, 0x89,
|
||||
];
|
||||
|
||||
/// A skipz stream for a `length`-byte chunk holding `segments`, an offset and bytes each.
|
||||
fn skipz(length: u32, segments: &[(u32, &[u8])]) -> Vec<u8> {
|
||||
let mut wire = length.to_le_bytes().to_vec();
|
||||
wire.extend_from_slice(&0_u32.to_le_bytes());
|
||||
for (offset, data) in segments {
|
||||
wire.extend_from_slice(&offset.to_le_bytes());
|
||||
wire.extend_from_slice(&u32::try_from(data.len()).unwrap().to_le_bytes());
|
||||
wire.extend_from_slice(data);
|
||||
}
|
||||
wire
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_compression_codes_are_none_zero_zlib_one_and_skipz_three() {
|
||||
assert_eq!(Compression::None.code(), 0);
|
||||
assert_eq!(Compression::Zlib.code(), 1);
|
||||
assert_eq!(Compression::Skipz.code(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_compression_is_parsed_from_its_name_and_prints_as_it() {
|
||||
for (name, compression) in [
|
||||
("none", Compression::None),
|
||||
("zlib", Compression::Zlib),
|
||||
("skipz", Compression::Skipz),
|
||||
] {
|
||||
assert_eq!(name.parse::<Compression>(), Ok(compression));
|
||||
assert_eq!(compression.to_string(), name);
|
||||
}
|
||||
assert_eq!(
|
||||
"gzip".parse::<Compression>(),
|
||||
Err("gzip is not none, zlib or skipz".to_owned())
|
||||
);
|
||||
assert!("Skipz".parse::<Compression>().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chunk_sent_as_stored_is_its_wire_bytes() {
|
||||
assert_eq!(sent(3, NONE).decode(b"abc"), Ok(b"abc".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chunk_sent_as_stored_with_another_number_of_bytes_is_refused_naming_both() {
|
||||
let error = sent(512, NONE).decode(&[0; 511]).unwrap_err();
|
||||
assert_eq!(
|
||||
error,
|
||||
Error::WireLength {
|
||||
offset: 0,
|
||||
length: 512,
|
||||
wire: 511
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"chunk: 512 bytes at offset 0 came as 511 bytes on the wire"
|
||||
);
|
||||
assert_eq!(
|
||||
sent(512, NONE).decode(&[0; 513]),
|
||||
Err(Error::WireLength {
|
||||
offset: 0,
|
||||
length: 512,
|
||||
wire: 513
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zlib_chunk_is_inflated() {
|
||||
assert_eq!(sent(16, ZLIB).decode(&ZLIB_16), Ok(counting(16)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zlib_stream_that_does_not_inflate_is_refused_with_the_reason() {
|
||||
let mut bad_header = ZLIB_16;
|
||||
bad_header[0] = 0x00;
|
||||
let error = sent(16, ZLIB).decode(&bad_header).unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
error,
|
||||
Error::Zlib {
|
||||
offset: 0,
|
||||
length: 16,
|
||||
..
|
||||
}
|
||||
),
|
||||
"{error:?}"
|
||||
);
|
||||
let message = error.to_string();
|
||||
assert!(
|
||||
message.starts_with("chunk: 16 bytes at offset 0: the zlib stream does not inflate: "),
|
||||
"{message}"
|
||||
);
|
||||
let mut bad_checksum = ZLIB_16;
|
||||
bad_checksum[ZLIB_16.len() - 1] ^= 0xFF;
|
||||
assert!(matches!(
|
||||
sent(16, ZLIB).decode(&bad_checksum),
|
||||
Err(Error::Zlib { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zlib_stream_inflating_to_another_number_of_bytes_is_refused_naming_both() {
|
||||
let error = sent(16, ZLIB).decode(&ZLIB_15).unwrap_err();
|
||||
assert_eq!(
|
||||
error,
|
||||
Error::Inflated {
|
||||
offset: 0,
|
||||
length: 16,
|
||||
inflated: 15
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"chunk: 16 bytes at offset 0 inflate to 15 bytes"
|
||||
);
|
||||
assert_eq!(
|
||||
sent(16, ZLIB).decode(&ZLIB_17),
|
||||
Err(Error::Inflated {
|
||||
offset: 0,
|
||||
length: 16,
|
||||
inflated: 17
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_skipz_chunk_without_segments_is_all_zeros() {
|
||||
assert_eq!(
|
||||
sent(2048, SKIPZ).decode(&skipz(2048, &[])),
|
||||
Ok(vec![0; 2048])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_skipz_segment_covering_the_chunk_is_the_whole_chunk() {
|
||||
let data = counting(16);
|
||||
assert_eq!(sent(16, SKIPZ).decode(&skipz(16, &[(0, &data)])), Ok(data));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_reserved_word_of_a_skipz_stream_is_ignored() {
|
||||
let data = counting(16);
|
||||
let mut wire = skipz(16, &[(0, &data)]);
|
||||
wire[WORD..SKIPZ_HEADER_LEN].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert_eq!(sent(16, SKIPZ).decode(&wire), Ok(data));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skipz_segments_at_both_ends_are_placed_with_zeros_between() {
|
||||
let wire = skipz(16, &[(0, b"ab"), (13, b"xyz")]);
|
||||
let mut expected = vec![0; 16];
|
||||
expected[..2].copy_from_slice(b"ab");
|
||||
expected[13..].copy_from_slice(b"xyz");
|
||||
assert_eq!(sent(16, SKIPZ).decode(&wire), Ok(expected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_skipz_chunk_whose_first_word_is_not_its_length_is_taken_as_stored() {
|
||||
let data = counting(16);
|
||||
assert_eq!(sent(16, SKIPZ).decode(&data), Ok(data.clone()));
|
||||
assert_eq!(
|
||||
sent(16, SKIPZ).decode(&data[..15]),
|
||||
Err(Error::WireLength {
|
||||
offset: 0,
|
||||
length: 16,
|
||||
wire: 15
|
||||
})
|
||||
);
|
||||
assert_eq!(sent(3, SKIPZ).decode(b"abc"), Ok(b"abc".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_skipz_stream_cut_inside_its_header_or_a_segment_is_refused() {
|
||||
let short = |wire: &[u8]| Error::SkipzShort {
|
||||
offset: 0,
|
||||
length: 16,
|
||||
wire: wire.len(),
|
||||
};
|
||||
let header = skipz(16, &[]);
|
||||
assert_eq!(
|
||||
sent(16, SKIPZ).decode(&header[..6]),
|
||||
Err(short(&header[..6]))
|
||||
);
|
||||
let segment = skipz(16, &[(4, b"abcd")]);
|
||||
assert_eq!(
|
||||
sent(16, SKIPZ).decode(&segment[..12]),
|
||||
Err(short(&segment[..12]))
|
||||
);
|
||||
assert_eq!(
|
||||
sent(16, SKIPZ).decode(&segment[..18]),
|
||||
Err(short(&segment[..18]))
|
||||
);
|
||||
assert_eq!(
|
||||
short(&header[..6]).to_string(),
|
||||
"chunk: 16 bytes at offset 0: the skipz stream of 6 bytes ends inside a header or a segment"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_skipz_segment_ending_past_the_chunk_is_refused_naming_it() {
|
||||
let error = sent(16, SKIPZ)
|
||||
.decode(&skipz(16, &[(13, b"abcd")]))
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
error,
|
||||
Error::SkipzSegment {
|
||||
offset: 0,
|
||||
length: 16,
|
||||
segment_offset: 13,
|
||||
segment_length: 4
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"chunk: 16 bytes at offset 0: a skipz segment of 4 bytes at offset 13 ends past the chunk"
|
||||
);
|
||||
let mut wrapping = skipz(16, &[]);
|
||||
wrapping.extend_from_slice(&u32::MAX.to_le_bytes());
|
||||
wrapping.extend_from_slice(&2_u32.to_le_bytes());
|
||||
wrapping.extend_from_slice(b"ab");
|
||||
assert!(matches!(
|
||||
sent(16, SKIPZ).decode(&wrapping),
|
||||
Err(Error::SkipzSegment { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chunk_with_a_compression_that_is_not_decoded_is_refused_naming_it() {
|
||||
let error = sent(16, 2).decode(&[0; 16]).unwrap_err();
|
||||
assert_eq!(
|
||||
error,
|
||||
Error::Compression {
|
||||
offset: 0,
|
||||
length: 16,
|
||||
compression: 2
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"chunk: 16 bytes at offset 0 came with compression 2, which is not decoded"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Vectura reads VMware virtual disks over the NFC protocol.
|
||||
//!
|
||||
//! `vectura serve` exports one virtual disk, read-only, as an NBD server on
|
||||
//! its own standard input and output; `vectura fingerprint` prints the
|
||||
//! certificate thumbprints a host presents on its management and data ports,
|
||||
//! so an operator can pin them.
|
||||
|
||||
pub mod cli;
|
||||
pub mod disk;
|
||||
pub mod nbd;
|
||||
pub mod nfc;
|
||||
pub mod serve;
|
||||
pub mod soap;
|
||||
pub mod tls;
|
||||
pub mod transcript;
|
||||
@@ -0,0 +1,7 @@
|
||||
//! The `vectura` command-line entry point.
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
vectura::cli::run(std::env::args_os())
|
||||
}
|
||||
@@ -0,0 +1,867 @@
|
||||
//! A fixed-newstyle NBD server over two streams: negotiation and requests.
|
||||
//!
|
||||
//! Layouts and constants follow the NBD protocol document
|
||||
//! (<https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md>);
|
||||
//! every multi-byte field is big-endian. `NBD_OPT_EXPORT_NAME`,
|
||||
//! `NBD_OPT_GO`, `NBD_OPT_INFO` and `NBD_OPT_ABORT` are served; every other
|
||||
//! option is answered `NBD_REP_ERR_UNSUP`. The export is read-only: reads
|
||||
//! are served, every other request is refused with the error it deserves.
|
||||
|
||||
use std::io::{self, Read, Write};
|
||||
|
||||
use crate::transcript::Transcript;
|
||||
|
||||
/// The initial magic, the ASCII of `NBDMAGIC`.
|
||||
pub const NBDMAGIC: u64 = 0x4E42_444D_4147_4943;
|
||||
/// The option magic, the ASCII of `IHAVEOPT`, opening the greeting and every option.
|
||||
pub const IHAVEOPT: u64 = 0x4948_4156_454F_5054;
|
||||
/// The magic opening every option reply.
|
||||
pub const REPLY_MAGIC: u64 = 0x0003_e889_0455_65a9;
|
||||
/// Handshake flag: the server speaks fixed newstyle.
|
||||
pub const FLAG_FIXED_NEWSTYLE: u16 = 1;
|
||||
/// Handshake flag: the server offers to skip the 124 zero bytes after the export size.
|
||||
pub const FLAG_NO_ZEROES: u16 = 2;
|
||||
/// Client flag: the client speaks fixed newstyle; required.
|
||||
pub const FLAG_C_FIXED_NEWSTYLE: u32 = 1;
|
||||
/// Client flag: the client agrees to skip the 124 zero bytes.
|
||||
pub const FLAG_C_NO_ZEROES: u32 = 2;
|
||||
/// The option that names the export and enters transmission at once.
|
||||
pub const OPT_EXPORT_NAME: u32 = 1;
|
||||
/// The option that ends the negotiation without an export.
|
||||
pub const OPT_ABORT: u32 = 2;
|
||||
/// The option asking about an export while staying in negotiation.
|
||||
pub const OPT_INFO: u32 = 6;
|
||||
/// The option asking about an export and entering transmission on success.
|
||||
pub const OPT_GO: u32 = 7;
|
||||
/// The reply accepting an option, or closing a run of info replies.
|
||||
pub const REP_ACK: u32 = 1;
|
||||
/// The reply carrying one piece of information about the export.
|
||||
pub const REP_INFO: u32 = 3;
|
||||
/// The reply refusing an option the server does not serve.
|
||||
pub const REP_ERR_UNSUP: u32 = 0x8000_0001;
|
||||
/// The reply refusing an option whose data is malformed.
|
||||
pub const REP_ERR_INVALID: u32 = 0x8000_0003;
|
||||
/// The info carrying the export size and the transmission flags.
|
||||
pub const INFO_EXPORT: u16 = 0;
|
||||
/// The info naming the minimum, preferred and maximum request sizes.
|
||||
pub const INFO_BLOCK_SIZE: u16 = 3;
|
||||
/// The smallest request advertised: one sector, the unit the host reads in.
|
||||
///
|
||||
/// Advertised only; a request off a sector is still served, since the host
|
||||
/// reads are widened to sectors anyway.
|
||||
pub const MIN_BLOCK_SIZE: u32 = 512;
|
||||
/// The request size advertised as efficient: one host read, one mebibyte.
|
||||
pub const PREFERRED_BLOCK_SIZE: u32 = 1 << 20;
|
||||
/// The longest read served, 32 MiB: the protocol's baseline maximum.
|
||||
///
|
||||
/// A reply is held whole in memory, so a longer read is refused before its
|
||||
/// buffer is allocated.
|
||||
pub const MAX_PAYLOAD: u32 = 1 << 25;
|
||||
/// Transmission flag: the flags field is meaningful; always set.
|
||||
pub const FLAG_HAS_FLAGS: u16 = 1;
|
||||
/// Transmission flag: the export cannot be written.
|
||||
pub const FLAG_READ_ONLY: u16 = 2;
|
||||
/// What the greeting offers: [`FLAG_FIXED_NEWSTYLE`] and [`FLAG_NO_ZEROES`].
|
||||
const HANDSHAKE_FLAGS: u16 = 0b11;
|
||||
/// What every export reports: [`FLAG_HAS_FLAGS`] and [`FLAG_READ_ONLY`].
|
||||
const EXPORT_FLAGS: u16 = 0b11;
|
||||
/// The magic opening every request.
|
||||
pub const REQUEST_MAGIC: u32 = 0x2560_9513;
|
||||
/// The magic opening every simple reply.
|
||||
pub const SIMPLE_REPLY_MAGIC: u32 = 0x6744_6698;
|
||||
/// The request for `length` bytes at `offset`; its reply carries them after the header.
|
||||
pub const CMD_READ: u16 = 0;
|
||||
/// The request to write the `length` bytes that follow it; refused, the bytes dropped.
|
||||
pub const CMD_WRITE: u16 = 1;
|
||||
/// The request that ends the session; it gets no reply.
|
||||
pub const CMD_DISC: u16 = 2;
|
||||
/// The request to discard a range; refused.
|
||||
pub const CMD_TRIM: u16 = 4;
|
||||
/// The request to zero a range; refused.
|
||||
pub const CMD_WRITE_ZEROES: u16 = 6;
|
||||
/// The error a write of any kind is answered with: the export is read-only.
|
||||
pub const EPERM: u32 = 1;
|
||||
/// The error a read the server could not complete is answered with.
|
||||
pub const EIO: u32 = 5;
|
||||
/// The error a request the server cannot serve is answered with.
|
||||
pub const EINVAL: u32 = 22;
|
||||
/// The most option data accepted: an export name and a short info list.
|
||||
///
|
||||
/// Anything longer is a protocol failure rather than an allocation.
|
||||
pub const MAX_OPTION_LEN: u32 = 4096;
|
||||
/// Length of the greeting: two magics and the handshake flags.
|
||||
pub const GREETING_LEN: usize = 18;
|
||||
/// Length of an option header: magic, option and data length.
|
||||
pub const OPTION_LEN: usize = 16;
|
||||
/// Length of an option reply without data.
|
||||
pub const OPTION_REPLY_LEN: usize = 20;
|
||||
/// Zero bytes after the export size and flags, unless `NO_ZEROES` was agreed.
|
||||
pub const ZEROES_LEN: usize = 124;
|
||||
/// Length of a request: magic, flags, type, cookie, offset, length.
|
||||
pub const REQUEST_LEN: usize = 28;
|
||||
/// Length of a simple reply header: magic, error, cookie.
|
||||
pub const SIMPLE_REPLY_LEN: usize = 16;
|
||||
/// Length of the export info: type, size, transmission flags.
|
||||
pub const INFO_EXPORT_LEN: usize = 12;
|
||||
/// Length of the block-size info: type and the three sizes.
|
||||
pub const INFO_BLOCK_SIZE_LEN: usize = 14;
|
||||
|
||||
/// Errors of the NBD side, each a protocol violation or a lost client.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
/// A read from or write to the client failed.
|
||||
#[error("nbd: {0}")]
|
||||
Io(#[source] io::Error),
|
||||
/// The client closed its end before sending `NBD_CMD_DISC`.
|
||||
#[error("the nbd client left without disconnecting")]
|
||||
Left,
|
||||
/// The client does not speak fixed newstyle.
|
||||
#[error("nbd: the client flags {0:#x} lack NBD_FLAG_C_FIXED_NEWSTYLE")]
|
||||
NotFixedNewstyle(u32),
|
||||
/// An option did not start with the option magic.
|
||||
#[error("nbd: option magic {0:#018x} is not IHAVEOPT")]
|
||||
OptionMagic(u64),
|
||||
/// An option carries more data than [`MAX_OPTION_LEN`].
|
||||
#[error("nbd: option {option} carries {length} bytes, more than {MAX_OPTION_LEN}")]
|
||||
OptionTooLong {
|
||||
/// The option.
|
||||
option: u32,
|
||||
/// Its announced data length.
|
||||
length: u32,
|
||||
},
|
||||
/// A request did not start with the request magic.
|
||||
#[error("nbd: request magic {0:#010x} is not NBD_REQUEST_MAGIC")]
|
||||
RequestMagic(u32),
|
||||
}
|
||||
|
||||
/// One request read from the client.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Request {
|
||||
/// The command flags.
|
||||
pub flags: u16,
|
||||
/// The command type, such as [`CMD_DISC`].
|
||||
pub kind: u16,
|
||||
/// The cookie a reply echoes.
|
||||
pub cookie: u64,
|
||||
/// The offset in the export.
|
||||
pub offset: u64,
|
||||
/// The length of the range.
|
||||
pub length: u32,
|
||||
}
|
||||
|
||||
/// The header of an option the client sent.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct OptionHeader {
|
||||
/// The option.
|
||||
pub option: u32,
|
||||
/// The length of the data that follows.
|
||||
pub length: u32,
|
||||
}
|
||||
|
||||
/// How a negotiation ended.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Negotiated {
|
||||
/// The client took the export; transmission follows.
|
||||
Export,
|
||||
/// The client aborted; nothing follows.
|
||||
Abort,
|
||||
}
|
||||
|
||||
/// The greeting: both magics and the handshake flags.
|
||||
#[must_use]
|
||||
pub fn greeting() -> [u8; GREETING_LEN] {
|
||||
let mut bytes = [0_u8; GREETING_LEN];
|
||||
bytes[..8].copy_from_slice(&NBDMAGIC.to_be_bytes());
|
||||
bytes[8..16].copy_from_slice(&IHAVEOPT.to_be_bytes());
|
||||
bytes[16..].copy_from_slice(&HANDSHAKE_FLAGS.to_be_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Reads the client flags and requires fixed newstyle.
|
||||
///
|
||||
/// # Errors
|
||||
/// When [`FLAG_C_FIXED_NEWSTYLE`] is not set.
|
||||
pub fn client_flags(bytes: &[u8; 4]) -> Result<u32, Error> {
|
||||
let flags = u32::from_be_bytes(*bytes);
|
||||
if flags & FLAG_C_FIXED_NEWSTYLE == 0 {
|
||||
return Err(Error::NotFixedNewstyle(flags));
|
||||
}
|
||||
Ok(flags)
|
||||
}
|
||||
|
||||
/// Decodes an option header.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the magic is not [`IHAVEOPT`] or the data is longer than [`MAX_OPTION_LEN`].
|
||||
pub fn option_header(bytes: &[u8; OPTION_LEN]) -> Result<OptionHeader, Error> {
|
||||
let magic = u64::from_be_bytes([
|
||||
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
|
||||
]);
|
||||
if magic != IHAVEOPT {
|
||||
return Err(Error::OptionMagic(magic));
|
||||
}
|
||||
let header = OptionHeader {
|
||||
option: u32::from_be_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]),
|
||||
length: u32::from_be_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]),
|
||||
};
|
||||
if header.length > MAX_OPTION_LEN {
|
||||
return Err(Error::OptionTooLong {
|
||||
option: header.option,
|
||||
length: header.length,
|
||||
});
|
||||
}
|
||||
Ok(header)
|
||||
}
|
||||
|
||||
/// An option reply of type `reply` to `option`, carrying `data`.
|
||||
#[must_use]
|
||||
pub fn option_reply(option: u32, reply: u32, data: &[u8]) -> Vec<u8> {
|
||||
let mut bytes = Vec::with_capacity(OPTION_REPLY_LEN + data.len());
|
||||
bytes.extend_from_slice(&REPLY_MAGIC.to_be_bytes());
|
||||
bytes.extend_from_slice(&option.to_be_bytes());
|
||||
bytes.extend_from_slice(&reply.to_be_bytes());
|
||||
let length = u32::try_from(data.len()).unwrap_or(u32::MAX);
|
||||
bytes.extend_from_slice(&length.to_be_bytes());
|
||||
bytes.extend_from_slice(data);
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Whether `NBD_OPT_INFO` or `NBD_OPT_GO` data holds a name and an info list exactly.
|
||||
///
|
||||
/// Neither is read: there is one disk, and both infos are sent whatever the
|
||||
/// list asks.
|
||||
fn is_info_request(data: &[u8]) -> bool {
|
||||
let Some((length, rest)) = data.split_first_chunk::<4>() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(length) = usize::try_from(u32::from_be_bytes(*length)) else {
|
||||
return false;
|
||||
};
|
||||
let Some((_, rest)) = rest.split_at_checked(length) else {
|
||||
return false;
|
||||
};
|
||||
let Some((count, rest)) = rest.split_first_chunk::<2>() else {
|
||||
return false;
|
||||
};
|
||||
rest.len() == usize::from(u16::from_be_bytes(*count)) * 2
|
||||
}
|
||||
|
||||
/// The export info: size and the read-only transmission flags.
|
||||
#[must_use]
|
||||
pub fn export_info(size: u64) -> [u8; INFO_EXPORT_LEN] {
|
||||
let mut bytes = [0_u8; INFO_EXPORT_LEN];
|
||||
bytes[..2].copy_from_slice(&INFO_EXPORT.to_be_bytes());
|
||||
bytes[2..10].copy_from_slice(&size.to_be_bytes());
|
||||
bytes[10..].copy_from_slice(&EXPORT_FLAGS.to_be_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
/// The block-size info: minimum, preferred and maximum request sizes.
|
||||
#[must_use]
|
||||
pub fn block_size_info() -> [u8; INFO_BLOCK_SIZE_LEN] {
|
||||
let mut bytes = [0_u8; INFO_BLOCK_SIZE_LEN];
|
||||
bytes[..2].copy_from_slice(&INFO_BLOCK_SIZE.to_be_bytes());
|
||||
bytes[2..6].copy_from_slice(&MIN_BLOCK_SIZE.to_be_bytes());
|
||||
bytes[6..10].copy_from_slice(&PREFERRED_BLOCK_SIZE.to_be_bytes());
|
||||
bytes[10..].copy_from_slice(&MAX_PAYLOAD.to_be_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Both infos then the ack, answering a well-formed `NBD_OPT_INFO` or `NBD_OPT_GO`.
|
||||
///
|
||||
/// The client's info list is not consulted: the export info is mandatory,
|
||||
/// the block-size info is sent whether or not it was asked, since the
|
||||
/// limits hold either way, and any other type is ignored.
|
||||
fn info_replies(option: u32, size: u64) -> Vec<u8> {
|
||||
let mut bytes = option_reply(option, REP_INFO, &export_info(size));
|
||||
bytes.extend(option_reply(option, REP_INFO, &block_size_info()));
|
||||
bytes.extend(option_reply(option, REP_ACK, &[]));
|
||||
bytes
|
||||
}
|
||||
|
||||
/// The reply to one option, its outcome if it is final, and a transcript word.
|
||||
///
|
||||
/// The export name is ignored: there is one disk, whatever the client calls it.
|
||||
fn answer(
|
||||
option: u32,
|
||||
data: &[u8],
|
||||
size: u64,
|
||||
no_zeroes: bool,
|
||||
) -> (Vec<u8>, Option<Negotiated>, &'static str) {
|
||||
match option {
|
||||
OPT_EXPORT_NAME => (
|
||||
export_reply(size, no_zeroes),
|
||||
Some(Negotiated::Export),
|
||||
"export",
|
||||
),
|
||||
OPT_ABORT => (
|
||||
option_reply(option, REP_ACK, &[]),
|
||||
Some(Negotiated::Abort),
|
||||
"acknowledged",
|
||||
),
|
||||
OPT_INFO | OPT_GO if is_info_request(data) => (
|
||||
info_replies(option, size),
|
||||
(option == OPT_GO).then_some(Negotiated::Export),
|
||||
"info",
|
||||
),
|
||||
OPT_INFO | OPT_GO => (option_reply(option, REP_ERR_INVALID, &[]), None, "invalid"),
|
||||
_ => (
|
||||
option_reply(option, REP_ERR_UNSUP, &[]),
|
||||
None,
|
||||
"unsupported",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The reply to `NBD_OPT_EXPORT_NAME`: size, read-only flags, then the zeros unless skipped.
|
||||
#[must_use]
|
||||
pub fn export_reply(size: u64, no_zeroes: bool) -> Vec<u8> {
|
||||
let mut bytes = size.to_be_bytes().to_vec();
|
||||
bytes.extend_from_slice(&EXPORT_FLAGS.to_be_bytes());
|
||||
if !no_zeroes {
|
||||
bytes.resize(bytes.len() + ZEROES_LEN, 0);
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Decodes a request.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the magic is not [`REQUEST_MAGIC`].
|
||||
pub fn request(bytes: &[u8; REQUEST_LEN]) -> Result<Request, Error> {
|
||||
let magic = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
|
||||
if magic != REQUEST_MAGIC {
|
||||
return Err(Error::RequestMagic(magic));
|
||||
}
|
||||
let long = |from: usize| {
|
||||
bytes[from..from + 8]
|
||||
.try_into()
|
||||
.map(u64::from_be_bytes)
|
||||
.unwrap_or_default()
|
||||
};
|
||||
Ok(Request {
|
||||
flags: u16::from_be_bytes([bytes[4], bytes[5]]),
|
||||
kind: u16::from_be_bytes([bytes[6], bytes[7]]),
|
||||
cookie: long(8),
|
||||
offset: long(16),
|
||||
length: u32::from_be_bytes([bytes[24], bytes[25], bytes[26], bytes[27]]),
|
||||
})
|
||||
}
|
||||
|
||||
/// A simple reply header echoing `cookie`, with `error` zero on success.
|
||||
#[must_use]
|
||||
pub fn simple_reply(cookie: u64, error: u32) -> [u8; SIMPLE_REPLY_LEN] {
|
||||
let mut bytes = [0_u8; SIMPLE_REPLY_LEN];
|
||||
bytes[..4].copy_from_slice(&SIMPLE_REPLY_MAGIC.to_be_bytes());
|
||||
bytes[4..8].copy_from_slice(&error.to_be_bytes());
|
||||
bytes[8..].copy_from_slice(&cookie.to_be_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
/// The error `request` is answered with, or `None` for a read to serve.
|
||||
///
|
||||
/// A command flag, a type that is neither a read nor a write of some kind,
|
||||
/// or a read longer than [`MAX_PAYLOAD`] or reaching past `size` is
|
||||
/// [`EINVAL`]; a write, a write of zeroes or a trim is [`EPERM`], the export
|
||||
/// being read-only. `NBD_CMD_DISC` gets no reply and must be handled first.
|
||||
#[must_use]
|
||||
pub fn refusal(request: &Request, size: u64) -> Option<u32> {
|
||||
if request.flags != 0 {
|
||||
return Some(EINVAL);
|
||||
}
|
||||
let end = request.offset.checked_add(u64::from(request.length));
|
||||
match request.kind {
|
||||
CMD_WRITE | CMD_WRITE_ZEROES | CMD_TRIM => Some(EPERM),
|
||||
CMD_READ if request.length <= MAX_PAYLOAD && end.is_some_and(|end| end <= size) => None,
|
||||
_ => Some(EINVAL),
|
||||
}
|
||||
}
|
||||
|
||||
/// Negotiates with the client until it takes the export or aborts.
|
||||
///
|
||||
/// The export is `size` bytes and read-only, whatever name the client gives.
|
||||
///
|
||||
/// # Errors
|
||||
/// When a stream fails, the client leaves, or it breaks the protocol.
|
||||
pub fn negotiate<R: Read, W: Write>(
|
||||
input: &mut R,
|
||||
output: &mut W,
|
||||
size: u64,
|
||||
transcript: Transcript,
|
||||
) -> Result<Negotiated, Error> {
|
||||
send(output, &greeting())?;
|
||||
let mut flags = [0_u8; 4];
|
||||
receive(input, &mut flags)?;
|
||||
let no_zeroes = client_flags(&flags)? & FLAG_C_NO_ZEROES != 0;
|
||||
loop {
|
||||
let mut header = [0_u8; OPTION_LEN];
|
||||
receive(input, &mut header)?;
|
||||
let header = option_header(&header)?;
|
||||
let mut data = vec![0_u8; usize::try_from(header.length).unwrap_or_default()];
|
||||
receive(input, &mut data)?;
|
||||
let (reply, outcome, word) = answer(header.option, &data, size, no_zeroes);
|
||||
transcript.option(header.option, word);
|
||||
send(output, &reply)?;
|
||||
if let Some(outcome) = outcome {
|
||||
if outcome == Negotiated::Export {
|
||||
transcript.export(size);
|
||||
}
|
||||
return Ok(outcome);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the next request; the payload of a write is drained and dropped.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the stream fails, the client leaves, or the magic is wrong.
|
||||
pub fn next_request<R: Read>(input: &mut R) -> Result<Request, Error> {
|
||||
let mut bytes = [0_u8; REQUEST_LEN];
|
||||
receive(input, &mut bytes)?;
|
||||
let request = request(&bytes)?;
|
||||
if request.kind == CMD_WRITE {
|
||||
let length = u64::from(request.length);
|
||||
let drained =
|
||||
io::copy(&mut input.by_ref().take(length), &mut io::sink()).map_err(Error::Io)?;
|
||||
if drained < length {
|
||||
return Err(Error::Left);
|
||||
}
|
||||
}
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
/// Writes `bytes` to the client and flushes.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the write fails; a closed pipe counts as the client leaving.
|
||||
pub fn send<W: Write>(output: &mut W, bytes: &[u8]) -> Result<(), Error> {
|
||||
output
|
||||
.write_all(bytes)
|
||||
.and_then(|()| output.flush())
|
||||
.map_err(|error| match error.kind() {
|
||||
io::ErrorKind::BrokenPipe => Error::Left,
|
||||
_ => Error::Io(error),
|
||||
})
|
||||
}
|
||||
|
||||
fn receive<R: Read>(input: &mut R, buffer: &mut [u8]) -> Result<(), Error> {
|
||||
input
|
||||
.read_exact(buffer)
|
||||
.map_err(|error| match error.kind() {
|
||||
io::ErrorKind::UnexpectedEof => Error::Left,
|
||||
_ => Error::Io(error),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::Cursor;
|
||||
|
||||
use super::*;
|
||||
|
||||
const QUIET: Transcript = Transcript::new(false);
|
||||
|
||||
#[test]
|
||||
fn the_greeting_is_the_two_magics_then_the_handshake_flags_big_endian() {
|
||||
assert_eq!(greeting(), *b"NBDMAGICIHAVEOPT\x00\x03");
|
||||
assert_eq!(HANDSHAKE_FLAGS, FLAG_FIXED_NEWSTYLE | FLAG_NO_ZEROES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_flags_require_fixed_newstyle() {
|
||||
assert_eq!(client_flags(&[0, 0, 0, 3]).unwrap(), 3);
|
||||
let error = client_flags(&[0, 0, 0, 2]).unwrap_err();
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"nbd: the client flags 0x2 lack NBD_FLAG_C_FIXED_NEWSTYLE"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_option_header_round_trips_and_a_wrong_magic_is_refused() {
|
||||
let mut bytes = *b"IHAVEOPT\x00\x00\x00\x01\x00\x00\x00\x05";
|
||||
assert_eq!(
|
||||
option_header(&bytes).unwrap(),
|
||||
OptionHeader {
|
||||
option: 1,
|
||||
length: 5
|
||||
}
|
||||
);
|
||||
bytes[0] = b'X';
|
||||
let error = option_header(&bytes).unwrap_err();
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"nbd: option magic 0x58484156454f5054 is not IHAVEOPT"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_option_at_the_limit_passes_and_a_longer_one_is_refused_naming_it() {
|
||||
let mut bytes = *b"IHAVEOPT\x00\x00\x00\x07\x00\x00\x00\x00";
|
||||
bytes[12..].copy_from_slice(&MAX_OPTION_LEN.to_be_bytes());
|
||||
assert_eq!(option_header(&bytes).unwrap().length, MAX_OPTION_LEN);
|
||||
bytes[12..].copy_from_slice(&(MAX_OPTION_LEN + 1).to_be_bytes());
|
||||
let error = option_header(&bytes).unwrap_err();
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"nbd: option 7 carries 4097 bytes, more than 4096"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_option_reply_carries_the_reply_magic_the_option_the_type_and_its_data() {
|
||||
let reply = option_reply(7, REP_ERR_UNSUP, &[]);
|
||||
assert_eq!(&reply[..8], &[0, 3, 0xe8, 0x89, 0x04, 0x55, 0x65, 0xa9]);
|
||||
assert_eq!(&reply[8..12], &[0, 0, 0, 7]);
|
||||
assert_eq!(&reply[12..16], &[0x80, 0, 0, 1]);
|
||||
assert_eq!(&reply[16..], &[0, 0, 0, 0]);
|
||||
|
||||
let reply = option_reply(OPT_GO, REP_INFO, b"abc");
|
||||
assert_eq!(reply.len(), OPTION_REPLY_LEN + 3);
|
||||
assert_eq!(&reply[12..16], &[0, 0, 0, 3]);
|
||||
assert_eq!(&reply[16..20], &[0, 0, 0, 3]);
|
||||
assert_eq!(&reply[20..], b"abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_info_request_is_well_formed_when_its_name_and_its_list_fill_the_data() {
|
||||
assert!(is_info_request(
|
||||
b"\x00\x00\x00\x04disk\x00\x02\x00\x03\x00\x00"
|
||||
));
|
||||
assert!(is_info_request(b"\x00\x00\x00\x00\x00\x00"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_info_request_whose_name_or_list_does_not_fit_the_data_is_malformed() {
|
||||
for data in [
|
||||
&b""[..],
|
||||
b"\x00\x00\x00\x00",
|
||||
b"\x00\x00\x00\x05disk\x00\x00",
|
||||
b"\x00\x00\x00\x04disk\x00\x01",
|
||||
b"\x00\x00\x00\x04disk\x00\x00\x00",
|
||||
b"\x00\x00\x00\x04disk\x00\x00\x00\x03",
|
||||
] {
|
||||
assert!(!is_info_request(data), "{data:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_export_info_holds_the_type_the_size_and_the_read_only_flags() {
|
||||
let info = export_info(0x0001_0000_0000);
|
||||
assert_eq!(&info[..2], &[0, 0]);
|
||||
assert_eq!(&info[2..10], &[0, 0, 0, 1, 0, 0, 0, 0]);
|
||||
assert_eq!(&info[10..], &[0, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_block_size_info_advertises_a_sector_a_mebibyte_and_thirty_two_mebibytes() {
|
||||
let info = block_size_info();
|
||||
assert_eq!(&info[..2], &[0, 3]);
|
||||
assert_eq!(&info[2..6], &[0, 0, 2, 0]);
|
||||
assert_eq!(&info[6..10], &[0, 0x10, 0, 0]);
|
||||
assert_eq!(&info[10..], &[2, 0, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_export_reply_holds_the_size_the_read_only_flags_and_the_zeros() {
|
||||
let reply = export_reply(0x0001_0000_0000, false);
|
||||
assert_eq!(reply.len(), 8 + 2 + ZEROES_LEN);
|
||||
assert_eq!(&reply[..8], &[0, 0, 0, 1, 0, 0, 0, 0]);
|
||||
assert_eq!(&reply[8..10], &[0, 3]);
|
||||
assert_eq!(EXPORT_FLAGS, FLAG_HAS_FLAGS | FLAG_READ_ONLY);
|
||||
assert!(reply[10..].iter().all(|byte| *byte == 0));
|
||||
assert_eq!(export_reply(16, true).len(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_round_trips_and_a_wrong_magic_is_refused() {
|
||||
let mut bytes = [0_u8; REQUEST_LEN];
|
||||
bytes[..4].copy_from_slice(&REQUEST_MAGIC.to_be_bytes());
|
||||
bytes[4..6].copy_from_slice(&1_u16.to_be_bytes());
|
||||
bytes[6..8].copy_from_slice(&CMD_DISC.to_be_bytes());
|
||||
bytes[8..16].copy_from_slice(&0x1122_3344_5566_7788_u64.to_be_bytes());
|
||||
bytes[16..24].copy_from_slice(&4096_u64.to_be_bytes());
|
||||
bytes[24..].copy_from_slice(&512_u32.to_be_bytes());
|
||||
assert_eq!(
|
||||
request(&bytes).unwrap(),
|
||||
Request {
|
||||
flags: 1,
|
||||
kind: CMD_DISC,
|
||||
cookie: 0x1122_3344_5566_7788,
|
||||
offset: 4096,
|
||||
length: 512
|
||||
}
|
||||
);
|
||||
bytes[0] = 0;
|
||||
let error = request(&bytes).unwrap_err();
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"nbd: request magic 0x00609513 is not NBD_REQUEST_MAGIC"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_simple_reply_holds_the_magic_the_error_and_the_cookie() {
|
||||
let reply = simple_reply(0x0102_0304_0506_0708, EINVAL);
|
||||
assert_eq!(&reply[..4], &[0x67, 0x44, 0x66, 0x98]);
|
||||
assert_eq!(&reply[4..8], &[0, 0, 0, 22]);
|
||||
assert_eq!(&reply[8..], &[1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
}
|
||||
|
||||
fn client_bytes(flags: u32, options: &[(u32, &[u8])]) -> Vec<u8> {
|
||||
let mut bytes = flags.to_be_bytes().to_vec();
|
||||
for (option, data) in options {
|
||||
bytes.extend_from_slice(&IHAVEOPT.to_be_bytes());
|
||||
bytes.extend_from_slice(&option.to_be_bytes());
|
||||
bytes.extend_from_slice(&u32::try_from(data.len()).unwrap().to_be_bytes());
|
||||
bytes.extend_from_slice(data);
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
/// `NBD_OPT_LIST`, `NBD_OPT_STARTTLS` and `NBD_OPT_STRUCTURED_REPLY`, none served.
|
||||
const OPT_LIST: u32 = 3;
|
||||
const OPT_STARTTLS: u32 = 5;
|
||||
const OPT_STRUCTURED_REPLY: u32 = 8;
|
||||
|
||||
#[test]
|
||||
fn negotiation_refuses_unserved_options_then_answers_export_name_with_the_size() {
|
||||
let mut input = Cursor::new(client_bytes(
|
||||
FLAG_C_FIXED_NEWSTYLE | FLAG_C_NO_ZEROES,
|
||||
&[
|
||||
(OPT_LIST, b""),
|
||||
(OPT_STARTTLS, b""),
|
||||
(OPT_STRUCTURED_REPLY, b""),
|
||||
(0x4242, b"\x00\x00\x00\x00\x00\x00\x00\x00"),
|
||||
(OPT_EXPORT_NAME, b"disk"),
|
||||
],
|
||||
));
|
||||
let mut output = Vec::new();
|
||||
|
||||
let outcome = negotiate(&mut input, &mut output, 4096, QUIET).unwrap();
|
||||
|
||||
assert_eq!(outcome, Negotiated::Export);
|
||||
let mut expected = greeting().to_vec();
|
||||
for option in [OPT_LIST, OPT_STARTTLS, OPT_STRUCTURED_REPLY, 0x4242] {
|
||||
expected.extend(option_reply(option, REP_ERR_UNSUP, &[]));
|
||||
}
|
||||
expected.extend(export_reply(4096, true));
|
||||
assert_eq!(output, expected);
|
||||
}
|
||||
|
||||
/// The bytes answering a well-formed `NBD_OPT_INFO` or `NBD_OPT_GO`.
|
||||
fn info_bytes(option: u32, size: u64) -> Vec<u8> {
|
||||
let mut bytes = option_reply(option, REP_INFO, &export_info(size));
|
||||
bytes.extend(option_reply(option, REP_INFO, &block_size_info()));
|
||||
bytes.extend(option_reply(option, REP_ACK, &[]));
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negotiation_answers_go_with_both_infos_and_an_ack_then_enters_transmission() {
|
||||
let mut input = Cursor::new(client_bytes(
|
||||
FLAG_C_FIXED_NEWSTYLE,
|
||||
&[(OPT_GO, b"\x00\x00\x00\x01x\x00\x01\x00\x03")],
|
||||
));
|
||||
let mut output = Vec::new();
|
||||
|
||||
let outcome = negotiate(&mut input, &mut output, 4096, QUIET).unwrap();
|
||||
|
||||
assert_eq!(outcome, Negotiated::Export);
|
||||
let mut expected = greeting().to_vec();
|
||||
expected.extend(info_bytes(OPT_GO, 4096));
|
||||
assert_eq!(output, expected, "no zero padding follows the ack");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negotiation_answers_info_the_same_way_and_stays_in_negotiation() {
|
||||
let mut input = Cursor::new(client_bytes(
|
||||
FLAG_C_FIXED_NEWSTYLE | FLAG_C_NO_ZEROES,
|
||||
&[
|
||||
(OPT_INFO, b"\x00\x00\x00\x00\x00\x02\x00\x00\x00\x09"),
|
||||
(OPT_EXPORT_NAME, b""),
|
||||
],
|
||||
));
|
||||
let mut output = Vec::new();
|
||||
|
||||
let outcome = negotiate(&mut input, &mut output, 4096, QUIET).unwrap();
|
||||
|
||||
assert_eq!(outcome, Negotiated::Export);
|
||||
let mut expected = greeting().to_vec();
|
||||
expected.extend(info_bytes(OPT_INFO, 4096));
|
||||
expected.extend(export_reply(4096, true));
|
||||
assert_eq!(output, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negotiation_refuses_a_malformed_go_as_invalid_and_continues() {
|
||||
let mut input = Cursor::new(client_bytes(
|
||||
FLAG_C_FIXED_NEWSTYLE | FLAG_C_NO_ZEROES,
|
||||
&[(OPT_GO, b"\x00\x00\x00\x09"), (OPT_EXPORT_NAME, b"")],
|
||||
));
|
||||
let mut output = Vec::new();
|
||||
|
||||
negotiate(&mut input, &mut output, 4096, QUIET).unwrap();
|
||||
|
||||
let mut expected = greeting().to_vec();
|
||||
expected.extend(option_reply(OPT_GO, REP_ERR_INVALID, &[]));
|
||||
expected.extend(export_reply(4096, true));
|
||||
assert_eq!(output, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negotiation_acknowledges_abort_and_ends_without_an_export() {
|
||||
let mut input = Cursor::new(client_bytes(
|
||||
FLAG_C_FIXED_NEWSTYLE,
|
||||
&[(OPT_ABORT, b"\x00\x00"), (OPT_EXPORT_NAME, b"")],
|
||||
));
|
||||
let mut output = Vec::new();
|
||||
|
||||
let outcome = negotiate(&mut input, &mut output, 4096, QUIET).unwrap();
|
||||
|
||||
assert_eq!(outcome, Negotiated::Abort);
|
||||
let mut expected = greeting().to_vec();
|
||||
expected.extend(option_reply(OPT_ABORT, REP_ACK, &[]));
|
||||
assert_eq!(output, expected, "the option after the abort is never read");
|
||||
}
|
||||
|
||||
fn a_request(flags: u16, kind: u16, offset: u64, length: u32) -> Request {
|
||||
Request {
|
||||
flags,
|
||||
kind,
|
||||
cookie: 7,
|
||||
offset,
|
||||
length,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_read_within_the_export_and_the_payload_limit_is_not_refused() {
|
||||
assert_eq!(refusal(&a_request(0, CMD_READ, 0, 512), 512), None);
|
||||
assert_eq!(refusal(&a_request(0, CMD_READ, 512, 0), 512), None);
|
||||
let whole = u64::from(MAX_PAYLOAD);
|
||||
assert_eq!(
|
||||
refusal(&a_request(0, CMD_READ, 0, MAX_PAYLOAD), whole),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_write_a_write_of_zeroes_and_a_trim_are_refused_as_read_only() {
|
||||
for kind in [CMD_WRITE, CMD_WRITE_ZEROES, CMD_TRIM] {
|
||||
let request = a_request(0, kind, 0, 512);
|
||||
assert_eq!(refusal(&request, 512), Some(EPERM), "{request:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `NBD_CMD_FLUSH` and `NBD_CMD_CACHE`, neither served.
|
||||
const CMD_FLUSH: u16 = 3;
|
||||
const CMD_CACHE: u16 = 5;
|
||||
|
||||
#[test]
|
||||
fn a_flag_an_unserved_type_and_a_read_out_of_bounds_are_invalid() {
|
||||
for (request, size) in [
|
||||
(a_request(1, CMD_READ, 0, 512), 512),
|
||||
(a_request(1, CMD_WRITE, 0, 512), 512),
|
||||
(a_request(0, CMD_FLUSH, 0, 0), 512),
|
||||
(a_request(0, CMD_CACHE, 0, 512), 512),
|
||||
(a_request(0, 99, 0, 0), 512),
|
||||
(a_request(0, CMD_READ, 511, 2), 512),
|
||||
(a_request(0, CMD_READ, u64::MAX, 1), 512),
|
||||
(a_request(0, CMD_READ, 0, MAX_PAYLOAD + 1), u64::MAX),
|
||||
] {
|
||||
assert_eq!(refusal(&request, size), Some(EINVAL), "{request:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn request_bytes(kind: u16, length: u32) -> Vec<u8> {
|
||||
let mut bytes = REQUEST_MAGIC.to_be_bytes().to_vec();
|
||||
bytes.extend_from_slice(&[0, 0]);
|
||||
bytes.extend_from_slice(&kind.to_be_bytes());
|
||||
bytes.extend_from_slice(&[0; 16]);
|
||||
bytes.extend_from_slice(&length.to_be_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_next_request_drains_a_write_payload_before_the_request_after_it() {
|
||||
let mut bytes = request_bytes(CMD_WRITE, 5);
|
||||
bytes.extend_from_slice(b"hello");
|
||||
bytes.extend(request_bytes(CMD_DISC, 0));
|
||||
let mut input = Cursor::new(bytes);
|
||||
|
||||
assert_eq!(next_request(&mut input).unwrap().kind, CMD_WRITE);
|
||||
assert_eq!(next_request(&mut input).unwrap().kind, CMD_DISC);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_write_whose_payload_is_cut_short_counts_as_the_client_leaving() {
|
||||
let mut bytes = request_bytes(CMD_WRITE, 5);
|
||||
bytes.extend_from_slice(b"hel");
|
||||
|
||||
let error = next_request(&mut Cursor::new(bytes)).unwrap_err();
|
||||
|
||||
assert!(matches!(error, Error::Left), "{error:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negotiation_sends_the_zeros_when_the_client_did_not_agree_to_skip_them() {
|
||||
let mut input = Cursor::new(client_bytes(
|
||||
FLAG_C_FIXED_NEWSTYLE,
|
||||
&[(OPT_EXPORT_NAME, b"")],
|
||||
));
|
||||
let mut output = Vec::new();
|
||||
|
||||
negotiate(&mut input, &mut output, 1, QUIET).unwrap();
|
||||
|
||||
assert_eq!(output.len(), GREETING_LEN + 8 + 2 + ZEROES_LEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_client_that_leaves_during_negotiation_or_before_a_request_is_reported() {
|
||||
let mut output = Vec::new();
|
||||
let error = negotiate(
|
||||
&mut Cursor::new(b"\x00\x00\x00\x01IHAVE"),
|
||||
&mut output,
|
||||
1,
|
||||
QUIET,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"the nbd client left without disconnecting"
|
||||
);
|
||||
|
||||
let error = next_request(&mut Cursor::new(b"")).unwrap_err();
|
||||
assert!(matches!(error, Error::Left), "{error:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_pipe_on_send_counts_as_the_client_leaving() {
|
||||
struct Broken;
|
||||
impl Write for Broken {
|
||||
fn write(&mut self, _: &[u8]) -> io::Result<usize> {
|
||||
Err(io::Error::from(io::ErrorKind::BrokenPipe))
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
let error = send(&mut Broken, b"x").unwrap_err();
|
||||
|
||||
assert!(matches!(error, Error::Left), "{error:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_next_request_is_decoded_from_its_twenty_eight_bytes() {
|
||||
let mut bytes = REQUEST_MAGIC.to_be_bytes().to_vec();
|
||||
bytes.extend_from_slice(&[0, 0, 0, 2]);
|
||||
bytes.extend_from_slice(&[0; 20]);
|
||||
|
||||
let request = next_request(&mut Cursor::new(bytes)).unwrap();
|
||||
|
||||
assert_eq!(request.kind, CMD_DISC);
|
||||
assert_eq!(request.cookie, 0);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,197 @@
|
||||
//! A host's data-port greeting and the capability gate.
|
||||
//!
|
||||
//! The line is ASCII, ends in CRLF and reads
|
||||
//! `220 <version text>: <capability>, <capability>, ...`. An item may carry
|
||||
//! a `/` suffix, which is not part of its name. Whether the client goes on is
|
||||
//! decided on the capability names alone, never on the version text.
|
||||
|
||||
/// The capability a host must announce before TLS starts on its data port.
|
||||
///
|
||||
/// A host without it identifies its data-port certificate by SHA-1 only,
|
||||
/// which this client refuses to pin.
|
||||
pub const SHA256_SUPPORTED: &str = "SHA256 supported";
|
||||
|
||||
/// The capability of a host that carries the disk session over TLS.
|
||||
///
|
||||
/// A host without it carries the disk session in clear after the ticket
|
||||
/// dialogue, which the client does only when asked for the `nfc` transport.
|
||||
pub const NFCSSL_SUPPORTED: &str = "NFCSSL supported";
|
||||
|
||||
/// The reply code opening every greeting, as Broadcom KB 343952 shows.
|
||||
///
|
||||
/// A line without it is not a greeting.
|
||||
const REPLY_CODE: &str = "220 ";
|
||||
|
||||
/// A parsed greeting: the capability names a host announces.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Greeting {
|
||||
/// The capability names in the order announced, `/` suffixes dropped.
|
||||
pub capabilities: Vec<String>,
|
||||
}
|
||||
|
||||
/// Errors from a greeting line.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum Error {
|
||||
/// The line does not start with the greeting reply code.
|
||||
#[error("not a greeting line: {0:?}")]
|
||||
NotAGreeting(String),
|
||||
/// The host does not announce a capability this client needs.
|
||||
#[error("the host does not announce {capability:?}; announced: [{}]", announced.join(", "))]
|
||||
Missing {
|
||||
/// The capability that is required.
|
||||
capability: &'static str,
|
||||
/// The capabilities the host announced instead.
|
||||
announced: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Greeting {
|
||||
/// Fails, naming `capability` and the announced list, unless the host announces it.
|
||||
///
|
||||
/// # Errors
|
||||
/// When `capability` is not among the announced names.
|
||||
pub fn require(&self, capability: &'static str) -> Result<(), Error> {
|
||||
if self.capabilities.iter().any(|name| name == capability) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::Missing {
|
||||
capability,
|
||||
announced: self.capabilities.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses one greeting line, with or without its trailing CRLF.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the line does not start with the greeting reply code.
|
||||
pub fn parse(line: &[u8]) -> Result<Greeting, Error> {
|
||||
let text = String::from_utf8_lossy(line);
|
||||
let text = text.trim_end_matches(['\r', '\n']);
|
||||
let body = text
|
||||
.strip_prefix(REPLY_CODE)
|
||||
.ok_or_else(|| Error::NotAGreeting(text.to_owned()))?;
|
||||
let list = body.split_once(": ").map_or("", |(_, list)| list);
|
||||
let capabilities = list
|
||||
.split(',')
|
||||
.map(|item| item.split_once('/').map_or(item, |(name, _)| name).trim())
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
Ok(Greeting { capabilities })
|
||||
}
|
||||
|
||||
/// An ESXi 8 greeting, as quoted in Broadcom KB 341384 (ESXi 6.x to 8.x) and KB 417531 (8.0.3).
|
||||
#[cfg(test)]
|
||||
pub(crate) const ESXI_8: &[u8] = b"220 VMware Authentication Daemon Version 1.10: SSL Required, ServerDaemonProtocol:SOAP, MKSDisplayProtocol:VNC , VMXARGS supported, NFCSSL supported/t, SHA256 supported\r\n";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// As quoted in Broadcom KB 338286 (ESXi 6.7 and 7.0).
|
||||
const ESXI_6: &[u8] = b"220 VMware Authentication Daemon Version 1.10: SSL Required, ServerDaemonProtocol:SOAP, MKSDisplayProtocol:VNC , VMXARGS supported, NFCSSL supported/t\r\n";
|
||||
|
||||
#[test]
|
||||
fn an_esxi_8_greeting_lists_its_capabilities_without_suffixes() {
|
||||
let greeting = parse(ESXI_8).unwrap();
|
||||
assert_eq!(
|
||||
greeting.capabilities,
|
||||
[
|
||||
"SSL Required",
|
||||
"ServerDaemonProtocol:SOAP",
|
||||
"MKSDisplayProtocol:VNC",
|
||||
"VMXARGS supported",
|
||||
"NFCSSL supported",
|
||||
"SHA256 supported",
|
||||
]
|
||||
);
|
||||
assert_eq!(greeting.require(SHA256_SUPPORTED), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_esxi_6_greeting_is_refused_with_its_capability_list_quoted() {
|
||||
let greeting = parse(ESXI_6).unwrap();
|
||||
|
||||
let error = greeting.require(SHA256_SUPPORTED).unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"the host does not announce \"SHA256 supported\"; announced: [SSL Required, \
|
||||
ServerDaemonProtocol:SOAP, MKSDisplayProtocol:VNC, VMXARGS supported, \
|
||||
NFCSSL supported]"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_without_the_reply_code_is_not_a_greeting() {
|
||||
for line in [
|
||||
&b"VMware Authentication Daemon Version 1.10: SSL Required\r\n"[..],
|
||||
b"500 VMware Authentication Daemon Version 1.10: SSL Required\r\n",
|
||||
b"220VMware\r\n",
|
||||
b"",
|
||||
] {
|
||||
let error = parse(line).unwrap_err();
|
||||
assert!(matches!(error, Error::NotAGreeting(_)), "{line:?}: {error}");
|
||||
}
|
||||
assert_eq!(
|
||||
parse(b"500 nope\r\n").unwrap_err().to_string(),
|
||||
"not a greeting line: \"500 nope\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nfcssl_support_is_required_by_its_name_and_refused_with_the_list_quoted() {
|
||||
assert_eq!(parse(ESXI_8).unwrap().require(NFCSSL_SUPPORTED), Ok(()));
|
||||
let without =
|
||||
b"220 VMware Authentication Daemon Version 1.10: SSL Required, SHA256 supported\r\n";
|
||||
|
||||
let error = parse(without)
|
||||
.unwrap()
|
||||
.require(NFCSSL_SUPPORTED)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"the host does not announce \"NFCSSL supported\"; announced: [SSL Required, SHA256 supported]"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_greeting_with_an_empty_capability_list_announces_nothing() {
|
||||
for line in [
|
||||
&b"220 VMware Authentication Daemon Version 1.10\r\n"[..],
|
||||
b"220 VMware Authentication Daemon Version 1.10: \r\n",
|
||||
b"220 VMware Authentication Daemon Version 1.10: ,\r\n",
|
||||
] {
|
||||
let greeting = parse(line).unwrap();
|
||||
assert!(greeting.capabilities.is_empty(), "{line:?}");
|
||||
assert_eq!(
|
||||
greeting.require(SHA256_SUPPORTED).unwrap_err().to_string(),
|
||||
"the host does not announce \"SHA256 supported\"; announced: []"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_gate_reads_capability_names_and_not_the_version_text() {
|
||||
let greeting = parse(b"220 SHA256 supported daemon: SSL Required\r\n").unwrap();
|
||||
assert_eq!(greeting.capabilities, ["SSL Required"]);
|
||||
assert!(greeting.require(SHA256_SUPPORTED).is_err());
|
||||
assert_eq!(greeting.require("SSL Required"), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_capability_name_must_match_whole_and_case_sensitive() {
|
||||
let greeting = parse(b"220 d: SHA256 supported/t, sha256 supportedx\r\n").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
greeting.capabilities,
|
||||
["SHA256 supported", "sha256 supportedx"]
|
||||
);
|
||||
assert_eq!(greeting.require(SHA256_SUPPORTED), Ok(()));
|
||||
assert!(greeting.require("sha256 supported").is_err());
|
||||
assert!(greeting.require("SHA256").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! The fixed-size messages exchanged on the data port once TLS is up, before any record.
|
||||
//!
|
||||
//! Every message is [`LEN`] bytes: a little-endian `u32` type at offset 0,
|
||||
//! two little-endian `u32` arguments at offsets 4 and 8, and zero padding.
|
||||
//! A message of type [`TAG`] carries a 16-byte session tag at offset 4
|
||||
//! instead of arguments, and one of type [`NAME`] is followed by a trailer
|
||||
//! holding the client name and the operation name back to back, with their
|
||||
//! lengths in the two arguments. The host reads one message per TLS
|
||||
//! record, so each message is written on its own. The client sends [`TAG`],
|
||||
//! [`TAG_END`] and [`VERSION`] and only then waits: the host acknowledges
|
||||
//! the tag with [`TAG_ACK`] once the version message has arrived, and echoes
|
||||
//! [`VERSION`] right after. The client then sends [`NAME`], [`MODE`] and
|
||||
//! [`READY`] and waits for the [`READY`] echo. A reply of type [`ERROR`], or
|
||||
//! of any type other than the one expected, ends the session.
|
||||
|
||||
/// Length of every message in bytes.
|
||||
pub const LEN: usize = 264;
|
||||
|
||||
/// Length of the session tag a [`TAG`] message carries, in bytes.
|
||||
pub const TAG_LEN: usize = 16;
|
||||
|
||||
/// The client's first message, carrying the session tag at offset 4.
|
||||
pub const TAG: u32 = 43;
|
||||
/// The client's second message: no arguments; the host acknowledges both after [`VERSION`].
|
||||
pub const TAG_END: u32 = 33;
|
||||
/// The host's acknowledgment of [`TAG`] and [`TAG_END`]; its arguments are not read.
|
||||
pub const TAG_ACK: u32 = 36;
|
||||
/// The client's protocol proposal, with [`VERSION_ARGS`]; the host answers with type 51 too.
|
||||
pub const VERSION: u32 = 51;
|
||||
/// The client name and operation name message, with their lengths as arguments.
|
||||
pub const NAME: u32 = 54;
|
||||
/// The client's mode message, with [`MODE_ARGS`].
|
||||
pub const MODE: u32 = 55;
|
||||
/// The client's last message, with zero arguments; the host answers with type 52 too.
|
||||
pub const READY: u32 = 52;
|
||||
/// A host's refusal, in place of the message it would otherwise echo.
|
||||
pub const ERROR: u32 = 4;
|
||||
|
||||
/// Arguments of the [`VERSION`] message.
|
||||
pub const VERSION_ARGS: (u32, u32) = (12, 1);
|
||||
/// Arguments of the [`MODE`] message.
|
||||
pub const MODE_ARGS: (u32, u32) = (3, 0);
|
||||
|
||||
/// The client name sent in the [`NAME`] trailer.
|
||||
pub const CLIENT_NAME: &[u8] = b"vectura";
|
||||
/// The operation name sent in the [`NAME`] trailer: a disk read session.
|
||||
pub const OPERATION: &[u8] = b"nbdmode";
|
||||
|
||||
/// Builds a message of `kind` with two little-endian arguments.
|
||||
#[must_use]
|
||||
pub fn message(kind: u32, arguments: (u32, u32)) -> [u8; LEN] {
|
||||
let mut bytes = [0_u8; LEN];
|
||||
bytes[..4].copy_from_slice(&kind.to_le_bytes());
|
||||
bytes[4..8].copy_from_slice(&arguments.0.to_le_bytes());
|
||||
bytes[8..12].copy_from_slice(&arguments.1.to_le_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Builds the [`TAG`] message carrying `tag` at offset 4.
|
||||
#[must_use]
|
||||
pub fn tag(tag: &[u8; TAG_LEN]) -> [u8; LEN] {
|
||||
let mut bytes = message(TAG, (0, 0));
|
||||
bytes[4..4 + TAG_LEN].copy_from_slice(tag);
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Builds the [`NAME`] message and its trailer, client name then operation name.
|
||||
#[must_use]
|
||||
pub fn name() -> Vec<u8> {
|
||||
let lengths = (
|
||||
u32::try_from(CLIENT_NAME.len()).unwrap_or(u32::MAX),
|
||||
u32::try_from(OPERATION.len()).unwrap_or(u32::MAX),
|
||||
);
|
||||
let mut bytes = message(NAME, lengths).to_vec();
|
||||
bytes.extend_from_slice(CLIENT_NAME);
|
||||
bytes.extend_from_slice(OPERATION);
|
||||
bytes
|
||||
}
|
||||
|
||||
/// The type at offset 0 of a message.
|
||||
#[must_use]
|
||||
pub fn kind(bytes: &[u8; LEN]) -> u32 {
|
||||
u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_message_holds_its_type_and_arguments_little_endian_then_zeros() {
|
||||
let bytes = message(VERSION, VERSION_ARGS);
|
||||
assert_eq!(bytes.len(), 264);
|
||||
assert_eq!(&bytes[..12], &[51, 0, 0, 0, 12, 0, 0, 0, 1, 0, 0, 0]);
|
||||
assert!(bytes[12..].iter().all(|byte| *byte == 0));
|
||||
assert_eq!(kind(&bytes), VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tag_message_carries_the_sixteen_byte_tag_at_offset_four() {
|
||||
let bytes = tag(b"ABCDEFGHIJKLMNOP");
|
||||
assert_eq!(&bytes[..4], &[43, 0, 0, 0]);
|
||||
assert_eq!(&bytes[4..20], b"ABCDEFGHIJKLMNOP");
|
||||
assert!(bytes[20..].iter().all(|byte| *byte == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_name_message_announces_both_lengths_and_trails_both_names() {
|
||||
let bytes = name();
|
||||
assert_eq!(bytes.len(), 264 + 14);
|
||||
assert_eq!(&bytes[..12], &[54, 0, 0, 0, 7, 0, 0, 0, 7, 0, 0, 0]);
|
||||
assert!(bytes[12..264].iter().all(|byte| *byte == 0));
|
||||
assert_eq!(&bytes[264..], b"vecturanbdmode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_end_mode_and_ready_messages_carry_their_fixed_arguments() {
|
||||
assert_eq!(
|
||||
&message(TAG_END, (0, 0))[..12],
|
||||
&[33, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
|
||||
);
|
||||
assert_eq!(
|
||||
&message(MODE, MODE_ARGS)[..12],
|
||||
&[55, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0]
|
||||
);
|
||||
assert_eq!(
|
||||
&message(READY, (0, 0))[..12],
|
||||
&[52, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
//! The record protocol carried by the data session once the handshake is done.
|
||||
//!
|
||||
//! A record is a 16-byte header, a payload of the announced length and, for
|
||||
//! an open request or a read reply, a trailer the length does not count. The header holds
|
||||
//! four little-endian `u32`s: [`MAGIC`], the opcode, the payload length and a
|
||||
//! sequence number the client increments per record sent. The host answers
|
||||
//! each record with a header echoing the opcode and sequence number. A
|
||||
//! record it refuses gets an [`ERROR`] record first, carrying a code and a
|
||||
//! text, and then the reply itself.
|
||||
|
||||
/// Value at offset 0 of every header; on the wire it reads `7a da 00 a1`.
|
||||
pub const MAGIC: u32 = 0xA100_DA7A;
|
||||
|
||||
/// Length of a header in bytes.
|
||||
pub const HEADER_LEN: usize = 16;
|
||||
|
||||
/// Opcode of the first record of a session; its payload is 16 zero bytes.
|
||||
pub const HELLO: u32 = 2;
|
||||
/// Opcode of the version record; its payload is 12 zero bytes.
|
||||
pub const VERSION: u32 = 9;
|
||||
/// Opcode of the capabilities record; its payload is the `u32` 1.
|
||||
pub const CAPABILITIES: u32 = 22;
|
||||
/// Opcode of the open request, [`OPEN_LEN`] bytes plus the path as trailer.
|
||||
pub const OPEN: u32 = 4;
|
||||
/// Opcode of the close request; its payload is the `u64` handle then 8 zero bytes.
|
||||
pub const CLOSE: u32 = 5;
|
||||
/// Opcode of the last record of a session; its payload is 16 zero bytes.
|
||||
pub const END: u32 = 3;
|
||||
/// Opcode of the read request, answered with one record per chunk.
|
||||
pub const READ: u32 = 7;
|
||||
/// Opcode of the error record a host sends, with the refused record's sequence number, before its reply.
|
||||
pub const ERROR: u32 = 1;
|
||||
|
||||
/// Payload of the [`HELLO`] record.
|
||||
pub const HELLO_PAYLOAD: [u8; 16] = [0; 16];
|
||||
/// Payload of the [`VERSION`] record.
|
||||
pub const VERSION_PAYLOAD: [u8; 12] = [0; 12];
|
||||
/// Payload of the [`CAPABILITIES`] record: the one capability set every host accepts.
|
||||
pub const CAPABILITIES_PAYLOAD: [u8; 4] = 1_u32.to_le_bytes();
|
||||
/// Payload of the [`END`] record.
|
||||
pub const END_PAYLOAD: [u8; 16] = [0; 16];
|
||||
|
||||
/// Length of an open request or reply payload in bytes.
|
||||
pub const OPEN_LEN: usize = 60;
|
||||
/// The `u32` at offset 16 of every open request.
|
||||
const OPEN_WORD_16: u32 = 2;
|
||||
/// The `u32` at offset 20 of every open request.
|
||||
const OPEN_WORD_20: u32 = 30;
|
||||
|
||||
/// Errors of the record codec.
|
||||
#[derive(Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum Error {
|
||||
/// A header did not start with [`MAGIC`].
|
||||
#[error("record header magic {0:#010X} is not {MAGIC:#010X}")]
|
||||
Magic(u32),
|
||||
/// An open reply payload is shorter than [`OPEN_LEN`] bytes.
|
||||
#[error("the open reply holds {0} bytes instead of {OPEN_LEN}")]
|
||||
ShortOpenReply(usize),
|
||||
/// An error record payload is shorter than [`ERROR_LEN`] bytes.
|
||||
#[error("the error record holds {0} bytes instead of {ERROR_LEN}")]
|
||||
ShortErrorReply(usize),
|
||||
/// A read reply payload is shorter than [`READ_LEN`] bytes.
|
||||
#[error("the read reply holds {0} bytes instead of {READ_LEN}")]
|
||||
ShortReadReply(usize),
|
||||
}
|
||||
|
||||
/// A record header, without the magic.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Header {
|
||||
/// What the record asks or answers.
|
||||
pub opcode: u32,
|
||||
/// How many payload bytes follow the header.
|
||||
pub length: u32,
|
||||
/// The client's counter for the record, echoed by the host.
|
||||
pub sequence: u32,
|
||||
}
|
||||
|
||||
/// Encodes a header with the magic in front.
|
||||
#[must_use]
|
||||
pub fn encode(header: Header) -> [u8; HEADER_LEN] {
|
||||
let mut bytes = [0_u8; HEADER_LEN];
|
||||
bytes[..4].copy_from_slice(&MAGIC.to_le_bytes());
|
||||
bytes[4..8].copy_from_slice(&header.opcode.to_le_bytes());
|
||||
bytes[8..12].copy_from_slice(&header.length.to_le_bytes());
|
||||
bytes[12..].copy_from_slice(&header.sequence.to_le_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Decodes a header, checking its magic.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the first four bytes are not [`MAGIC`].
|
||||
pub fn decode(bytes: &[u8; HEADER_LEN]) -> Result<Header, Error> {
|
||||
let magic = word(bytes, 0);
|
||||
if magic != MAGIC {
|
||||
return Err(Error::Magic(magic));
|
||||
}
|
||||
Ok(Header {
|
||||
opcode: word(bytes, 4),
|
||||
length: word(bytes, 8),
|
||||
sequence: word(bytes, 12),
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds the payload of an open request for a path of `path_len` bytes.
|
||||
///
|
||||
/// The path itself follows the payload as a trailer, without a terminator.
|
||||
#[must_use]
|
||||
pub fn open_request(path_len: u32) -> [u8; OPEN_LEN] {
|
||||
let mut bytes = [0_u8; OPEN_LEN];
|
||||
bytes[..4].copy_from_slice(&path_len.to_le_bytes());
|
||||
bytes[16..20].copy_from_slice(&OPEN_WORD_16.to_le_bytes());
|
||||
bytes[20..24].copy_from_slice(&OPEN_WORD_20.to_le_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
/// What a successful open reply says about the disk.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Opened {
|
||||
/// The handle later records name the disk by.
|
||||
pub handle: u64,
|
||||
/// The disk's capacity in bytes.
|
||||
pub capacity: u64,
|
||||
/// The disk's sector size in bytes.
|
||||
pub sector_size: u32,
|
||||
}
|
||||
|
||||
/// Decodes an open reply into its handle, capacity and sector size.
|
||||
///
|
||||
/// The status is at offset 0, the handle at 8, the capacity at 28 and the
|
||||
/// sector size at 36, all little-endian.
|
||||
/// A non-zero status comes back as the `Err` of the inner result, so a
|
||||
/// refused open is told apart from a malformed reply.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the payload is shorter than [`OPEN_LEN`] bytes.
|
||||
pub fn open_reply(payload: &[u8]) -> Result<Result<Opened, u64>, Error> {
|
||||
if payload.len() < OPEN_LEN {
|
||||
return Err(Error::ShortOpenReply(payload.len()));
|
||||
}
|
||||
let status = long(payload, 0);
|
||||
if status != 0 {
|
||||
return Ok(Err(status));
|
||||
}
|
||||
Ok(Ok(Opened {
|
||||
handle: long(payload, 8),
|
||||
capacity: long(payload, 28),
|
||||
sector_size: word(payload, 36),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Length of a close request payload: the handle, then 8 zero bytes.
|
||||
pub const CLOSE_LEN: usize = 16;
|
||||
|
||||
/// Length of an error record payload: the code, a zero word, then the text length.
|
||||
pub const ERROR_LEN: usize = 16;
|
||||
|
||||
/// What an error record says: a code, and the length of the text that trails the payload.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Refusal {
|
||||
/// The host's error code, the `u64` at offset 0.
|
||||
pub code: u64,
|
||||
/// The length of the text following the payload, the `u32` at offset 12.
|
||||
pub text_len: u32,
|
||||
}
|
||||
|
||||
/// Decodes an error record payload into its code and text length.
|
||||
///
|
||||
/// The text itself follows the payload as a trailer the header length does
|
||||
/// not count, without a terminator.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the payload is shorter than [`ERROR_LEN`] bytes.
|
||||
pub fn error_reply(payload: &[u8]) -> Result<Refusal, Error> {
|
||||
if payload.len() < ERROR_LEN {
|
||||
return Err(Error::ShortErrorReply(payload.len()));
|
||||
}
|
||||
Ok(Refusal {
|
||||
code: long(payload, 0),
|
||||
text_len: word(payload, 12),
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds the payload of a close request for `handle`.
|
||||
#[must_use]
|
||||
pub fn close_request(handle: u64) -> [u8; CLOSE_LEN] {
|
||||
let mut bytes = [0_u8; CLOSE_LEN];
|
||||
bytes[..8].copy_from_slice(&handle.to_le_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Length of a read request or reply payload in bytes.
|
||||
pub const READ_LEN: usize = 44;
|
||||
|
||||
/// The word every read request carries after the handle.
|
||||
///
|
||||
/// Its meaning is not known: the host answers a read that carries 1 and
|
||||
/// echoes the word in each reply.
|
||||
const READ_CONSTANT: u32 = 1;
|
||||
|
||||
/// Builds a read request for `length` bytes at `offset` of disk `handle`.
|
||||
///
|
||||
/// The length is written three times, as the `u64` at offset 24 and the
|
||||
/// `u32`s at 32 and 36. The `compression` at offset 12 says how the host
|
||||
/// sends each chunk, 0 as stored, 1 zlib, 3 skipz, and every reply echoes it.
|
||||
#[must_use]
|
||||
pub fn read_request(handle: u64, offset: u64, length: u32, compression: u32) -> [u8; READ_LEN] {
|
||||
let mut bytes = [0_u8; READ_LEN];
|
||||
bytes[..8].copy_from_slice(&handle.to_le_bytes());
|
||||
bytes[8..12].copy_from_slice(&READ_CONSTANT.to_le_bytes());
|
||||
bytes[12..16].copy_from_slice(&compression.to_le_bytes());
|
||||
bytes[16..24].copy_from_slice(&offset.to_le_bytes());
|
||||
bytes[24..32].copy_from_slice(&u64::from(length).to_le_bytes());
|
||||
bytes[32..36].copy_from_slice(&length.to_le_bytes());
|
||||
bytes[36..40].copy_from_slice(&length.to_le_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
/// What one read reply says about the chunk whose bytes trail it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ReadReply {
|
||||
/// The host's status, the `u64` at offset 0; zero means the chunk is good.
|
||||
pub status: u64,
|
||||
/// The compression echoed at offset 12.
|
||||
pub compression: u32,
|
||||
/// The disk offset the request named, the `u64` at offset 16.
|
||||
pub base_offset: u64,
|
||||
/// The length the request named, the `u32` at offset 24.
|
||||
pub total_length: u32,
|
||||
/// Where the chunk starts within the request, the `u32` at offset 28.
|
||||
pub chunk_offset: u32,
|
||||
/// How many disk bytes the chunk holds, the `u32` at offset 32.
|
||||
pub chunk_length: u32,
|
||||
/// How many bytes trail the payload, the `u32` at offset 36.
|
||||
pub wire_length: u32,
|
||||
}
|
||||
|
||||
/// Decodes a read reply payload.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the payload is shorter than [`READ_LEN`] bytes.
|
||||
pub fn read_reply(payload: &[u8]) -> Result<ReadReply, Error> {
|
||||
if payload.len() < READ_LEN {
|
||||
return Err(Error::ShortReadReply(payload.len()));
|
||||
}
|
||||
Ok(ReadReply {
|
||||
status: long(payload, 0),
|
||||
compression: word(payload, 12),
|
||||
base_offset: long(payload, 16),
|
||||
total_length: word(payload, 24),
|
||||
chunk_offset: word(payload, 28),
|
||||
chunk_length: word(payload, 32),
|
||||
wire_length: word(payload, 36),
|
||||
})
|
||||
}
|
||||
|
||||
fn word(bytes: &[u8], at: usize) -> u32 {
|
||||
u32::from_le_bytes([bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]])
|
||||
}
|
||||
|
||||
fn long(bytes: &[u8], at: usize) -> u64 {
|
||||
u64::from_le_bytes([
|
||||
bytes[at],
|
||||
bytes[at + 1],
|
||||
bytes[at + 2],
|
||||
bytes[at + 3],
|
||||
bytes[at + 4],
|
||||
bytes[at + 5],
|
||||
bytes[at + 6],
|
||||
bytes[at + 7],
|
||||
])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_header_magic_is_written_little_endian_and_read_back() {
|
||||
let header = Header {
|
||||
opcode: OPEN,
|
||||
length: 60,
|
||||
sequence: 3,
|
||||
};
|
||||
let bytes = encode(header);
|
||||
assert_eq!(&bytes[..4], &[0x7A, 0xDA, 0x00, 0xA1]);
|
||||
assert_eq!(&bytes[4..], &[4, 0, 0, 0, 60, 0, 0, 0, 3, 0, 0, 0]);
|
||||
assert_eq!(decode(&bytes).unwrap(), header);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_header_with_another_magic_is_refused_naming_it() {
|
||||
let mut bytes = encode(Header {
|
||||
opcode: HELLO,
|
||||
length: 0,
|
||||
sequence: 0,
|
||||
});
|
||||
bytes[..4].copy_from_slice(&[0xA1, 0x00, 0xDA, 0x7A]);
|
||||
let error = decode(&bytes).unwrap_err();
|
||||
assert_eq!(error, Error::Magic(0x7ADA_00A1));
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"record header magic 0x7ADA00A1 is not 0xA100DA7A"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_open_request_names_the_path_length_and_the_two_fixed_words() {
|
||||
let bytes = open_request(24);
|
||||
assert_eq!(&bytes[..4], &[24, 0, 0, 0]);
|
||||
assert!(bytes[4..16].iter().all(|byte| *byte == 0));
|
||||
assert_eq!(&bytes[16..24], &[2, 0, 0, 0, 30, 0, 0, 0]);
|
||||
assert!(bytes[24..].iter().all(|byte| *byte == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_open_reply_yields_handle_capacity_and_sector_size() {
|
||||
let mut payload = [0_u8; OPEN_LEN];
|
||||
payload[8..16].copy_from_slice(&0x1122_3344_5566_7788_u64.to_le_bytes());
|
||||
payload[28..36].copy_from_slice(&(10 * 1024 * 1024 * 1024 + 512_u64).to_le_bytes());
|
||||
payload[36..40].copy_from_slice(&512_u32.to_le_bytes());
|
||||
payload[40..52].copy_from_slice(&[1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0]);
|
||||
assert_eq!(
|
||||
open_reply(&payload),
|
||||
Ok(Ok(Opened {
|
||||
handle: 0x1122_3344_5566_7788,
|
||||
capacity: 10 * 1024 * 1024 * 1024 + 512,
|
||||
sector_size: 512,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_zero_open_status_is_reported_and_a_short_reply_refused() {
|
||||
let mut payload = [0_u8; OPEN_LEN];
|
||||
payload[..8].copy_from_slice(&5_u64.to_le_bytes());
|
||||
assert_eq!(open_reply(&payload), Ok(Err(5)));
|
||||
let error = open_reply(&payload[..59]).unwrap_err();
|
||||
assert_eq!(error, Error::ShortOpenReply(59));
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"the open reply holds 59 bytes instead of 60"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_error_reply_yields_the_code_and_the_text_length_and_a_short_one_is_refused() {
|
||||
let mut payload = [0_u8; ERROR_LEN];
|
||||
payload[..8].copy_from_slice(&11_u64.to_le_bytes());
|
||||
payload[12..].copy_from_slice(&97_u32.to_le_bytes());
|
||||
assert_eq!(
|
||||
error_reply(&payload),
|
||||
Ok(Refusal {
|
||||
code: 11,
|
||||
text_len: 97,
|
||||
})
|
||||
);
|
||||
let error = error_reply(&payload[..15]).unwrap_err();
|
||||
assert_eq!(error, Error::ShortErrorReply(15));
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"the error record holds 15 bytes instead of 16"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_close_request_carries_the_handle_then_zeros() {
|
||||
let bytes = close_request(0x0102_0304_0506_0708);
|
||||
assert_eq!(&bytes[..8], &[8, 7, 6, 5, 4, 3, 2, 1]);
|
||||
assert_eq!(&bytes[8..], &[0; 8]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fixed_payloads_have_their_documented_bytes() {
|
||||
assert_eq!(HELLO_PAYLOAD, [0; 16]);
|
||||
assert_eq!(VERSION_PAYLOAD, [0; 12]);
|
||||
assert_eq!(CAPABILITIES_PAYLOAD, [1, 0, 0, 0]);
|
||||
assert_eq!(END_PAYLOAD, [0; 16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_read_request_is_built_byte_by_byte_with_the_compression_asked() {
|
||||
for compression in [0, 1, 3] {
|
||||
let bytes = read_request(7, 512, 4096, compression);
|
||||
assert_eq!(
|
||||
&bytes[12..16],
|
||||
&[u8::try_from(compression).unwrap(), 0, 0, 0],
|
||||
"compression {compression}"
|
||||
);
|
||||
}
|
||||
let bytes = read_request(0xFFFF_FFFF_8000_2A1F, 0x0000_0001_0010_0200, 0x0010_0000, 0);
|
||||
assert_eq!(
|
||||
&bytes[..8],
|
||||
&[0x1F, 0x2A, 0x00, 0x80, 0xFF, 0xFF, 0xFF, 0xFF]
|
||||
);
|
||||
assert_eq!(&bytes[8..12], &[1, 0, 0, 0]);
|
||||
assert_eq!(&bytes[12..16], &[0, 0, 0, 0], "compression none");
|
||||
assert_eq!(
|
||||
&bytes[16..24],
|
||||
&[0x00, 0x02, 0x10, 0x00, 0x01, 0x00, 0x00, 0x00]
|
||||
);
|
||||
assert_eq!(&bytes[24..32], &[0x00, 0x00, 0x10, 0x00, 0, 0, 0, 0]);
|
||||
assert_eq!(&bytes[32..36], &[0x00, 0x00, 0x10, 0x00]);
|
||||
assert_eq!(&bytes[36..40], &[0x00, 0x00, 0x10, 0x00]);
|
||||
assert_eq!(&bytes[40..], &[0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_read_reply_yields_base_offset_total_length_chunk_offset_chunk_length_and_wire_length() {
|
||||
let mut payload = [0_u8; READ_LEN];
|
||||
payload[..8].copy_from_slice(&3_u64.to_le_bytes());
|
||||
payload[8..12].copy_from_slice(&1_u32.to_le_bytes());
|
||||
payload[12..16].copy_from_slice(&3_u32.to_le_bytes());
|
||||
payload[16..24].copy_from_slice(&0x0000_0001_0010_0200_u64.to_le_bytes());
|
||||
payload[24..28].copy_from_slice(&0x0010_0000_u32.to_le_bytes());
|
||||
payload[28..32].copy_from_slice(&0x0003_0000_u32.to_le_bytes());
|
||||
payload[32..36].copy_from_slice(&0x0001_0000_u32.to_le_bytes());
|
||||
payload[36..40].copy_from_slice(&0x0000_FFFF_u32.to_le_bytes());
|
||||
assert_eq!(
|
||||
read_reply(&payload),
|
||||
Ok(ReadReply {
|
||||
status: 3,
|
||||
compression: 3,
|
||||
base_offset: 0x0000_0001_0010_0200,
|
||||
total_length: 0x0010_0000,
|
||||
chunk_offset: 0x0003_0000,
|
||||
chunk_length: 0x0001_0000,
|
||||
wire_length: 0x0000_FFFF,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_short_read_reply_is_refused_naming_its_length() {
|
||||
let error = read_reply(&[0_u8; 43]).unwrap_err();
|
||||
assert_eq!(error, Error::ShortReadReply(43));
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"the read reply holds 43 bytes instead of 44"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,552 @@
|
||||
//! The management plane: the vSphere API calls that yield a data-port ticket.
|
||||
//!
|
||||
//! Five calls, each a `POST /sdk` over its own TLS connection with a SOAP
|
||||
//! envelope in the `urn:vim25` namespace: `RetrieveServiceContent`, `Login`,
|
||||
//! `RetrieveInternalContent`, `NfcGetVmFiles` and `Logout`. The session
|
||||
//! cookie the `Login` response sets is echoed verbatim on every later call.
|
||||
|
||||
pub mod http;
|
||||
|
||||
use std::fmt;
|
||||
use std::io::Write;
|
||||
use std::time::Instant;
|
||||
|
||||
use quick_xml::escape::{escape, resolve_predefined_entity};
|
||||
use quick_xml::events::Event;
|
||||
use rustls::Stream;
|
||||
|
||||
use crate::tls;
|
||||
use crate::transcript::Transcript;
|
||||
|
||||
/// The managed object the service content is retrieved from.
|
||||
const SERVICE_INSTANCE: &str = "ServiceInstance";
|
||||
|
||||
/// Errors from the management plane, each naming the call that raised it.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
/// Connecting to the management port or its TLS handshake failed.
|
||||
#[error(transparent)]
|
||||
Tls(#[from] tls::Error),
|
||||
/// The HTTP exchange of a call failed.
|
||||
#[error("{call}: {source}")]
|
||||
Http {
|
||||
/// The call being made.
|
||||
call: &'static str,
|
||||
/// What went wrong on the wire.
|
||||
#[source]
|
||||
source: http::Error,
|
||||
},
|
||||
/// The host answered a call with a SOAP fault.
|
||||
#[error("{call}: the host answered: {text}")]
|
||||
Fault {
|
||||
/// The call being made.
|
||||
call: &'static str,
|
||||
/// The fault string, as the host wrote it.
|
||||
text: String,
|
||||
},
|
||||
/// The host answered a call with an HTTP status other than 200 and no fault.
|
||||
#[error("{call}: the host answered with HTTP status {status}")]
|
||||
Status {
|
||||
/// The call being made.
|
||||
call: &'static str,
|
||||
/// The status code.
|
||||
status: u16,
|
||||
},
|
||||
/// A reply lacks an element the call needs.
|
||||
#[error("{call}: the reply has no {element} element")]
|
||||
Missing {
|
||||
/// The call being made.
|
||||
call: &'static str,
|
||||
/// The element that should have been there.
|
||||
element: &'static str,
|
||||
},
|
||||
/// A reply element holds text of the wrong shape.
|
||||
#[error("NfcGetVmFiles: the {element} element holds {text:?}, {reason}")]
|
||||
Invalid {
|
||||
/// The element concerned.
|
||||
element: &'static str,
|
||||
/// Its text.
|
||||
text: String,
|
||||
/// What was expected of it.
|
||||
reason: String,
|
||||
},
|
||||
/// The `Login` response set no cookie to carry the session.
|
||||
#[error("Login: the host set no session cookie")]
|
||||
NoCookie,
|
||||
}
|
||||
|
||||
/// The operator's password, read once from the environment.
|
||||
///
|
||||
/// Its `Debug` rendering never shows the value.
|
||||
#[derive(Clone)]
|
||||
pub struct Password(String);
|
||||
|
||||
impl Password {
|
||||
/// Wraps the password.
|
||||
#[must_use]
|
||||
pub fn new(text: String) -> Password {
|
||||
Password(text)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Password {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("Password(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
/// The session cookie the `Login` response set, echoed verbatim.
|
||||
///
|
||||
/// Its `Debug` rendering never shows the value.
|
||||
#[derive(Clone)]
|
||||
struct Cookie(String);
|
||||
|
||||
impl fmt::Debug for Cookie {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("Cookie(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
/// A data-port ticket, as `NfcGetVmFiles` hands it out.
|
||||
///
|
||||
/// The secret is the ticket itself: an opaque bearer credential with an
|
||||
/// expiry and a use count. It is never validated for shape, and the `Debug`
|
||||
/// rendering never shows it.
|
||||
#[derive(Clone)]
|
||||
pub struct Ticket {
|
||||
/// The host serving the data port, when it is not the one that issued the ticket.
|
||||
///
|
||||
/// A vCenter names the ESXi host that owns the VM; an ESXi host leaves it out.
|
||||
pub host: Option<String>,
|
||||
/// The data port to connect to.
|
||||
pub port: u16,
|
||||
/// The SHA-1 thumbprint of the certificate the data port presents.
|
||||
pub thumbprint: tls::Pin,
|
||||
/// The ticket itself, presented in the data-port dialogue.
|
||||
pub secret: String,
|
||||
}
|
||||
|
||||
impl fmt::Debug for Ticket {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("Ticket")
|
||||
.field("host", &self.host)
|
||||
.field("port", &self.port)
|
||||
.field("thumbprint", &self.thumbprint)
|
||||
.field("secret", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// A logged-in management session.
|
||||
#[derive(Debug)]
|
||||
pub struct Session {
|
||||
host: String,
|
||||
port: u16,
|
||||
client: tls::Client,
|
||||
manager: String,
|
||||
cookie: Cookie,
|
||||
transcript: Transcript,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Retrieves the service content and logs in.
|
||||
///
|
||||
/// The host is an ESXi host or a vCenter server.
|
||||
///
|
||||
/// # Errors
|
||||
/// When a call fails or the host refuses the credentials.
|
||||
pub fn login(
|
||||
host: &str,
|
||||
port: u16,
|
||||
trust: &tls::Trust,
|
||||
user: &str,
|
||||
password: &Password,
|
||||
transcript: Transcript,
|
||||
) -> Result<Session, Error> {
|
||||
let client = tls::Client::new(trust)?;
|
||||
let content = call(
|
||||
host,
|
||||
port,
|
||||
&client,
|
||||
None,
|
||||
"RetrieveServiceContent",
|
||||
&this(SERVICE_INSTANCE, SERVICE_INSTANCE),
|
||||
transcript,
|
||||
)?;
|
||||
let manager = service_content(&String::from_utf8_lossy(&content.body))?;
|
||||
let inner = format!(
|
||||
"{}<userName>{}</userName><password>{}</password>",
|
||||
this("SessionManager", &manager),
|
||||
escape(user),
|
||||
escape(password.0.as_str())
|
||||
);
|
||||
let response = call(host, port, &client, None, "Login", &inner, transcript)?;
|
||||
let cookie = response
|
||||
.header("Set-Cookie")
|
||||
.map(|value| {
|
||||
value
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_owned()
|
||||
})
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or(Error::NoCookie)?;
|
||||
Ok(Session {
|
||||
host: host.to_owned(),
|
||||
port,
|
||||
client,
|
||||
manager,
|
||||
cookie: Cookie(cookie),
|
||||
transcript,
|
||||
})
|
||||
}
|
||||
|
||||
/// Asks the NFC service for a fresh ticket to the files of the VM `vm_id` names.
|
||||
///
|
||||
/// # Errors
|
||||
/// When a call fails or the reply lacks a field.
|
||||
pub fn ticket(&self, vm_id: &str) -> Result<Ticket, Error> {
|
||||
let content = self.call(
|
||||
"RetrieveInternalContent",
|
||||
&this(SERVICE_INSTANCE, SERVICE_INSTANCE),
|
||||
)?;
|
||||
let service = element(&content, "nfcService").ok_or(Error::Missing {
|
||||
call: "RetrieveInternalContent",
|
||||
element: "nfcService",
|
||||
})?;
|
||||
let inner = format!(
|
||||
"{}<vm type=\"VirtualMachine\">{}</vm>",
|
||||
this("NfcService", &service),
|
||||
escape(vm_id)
|
||||
);
|
||||
ticket(&self.call("NfcGetVmFiles", &inner)?)
|
||||
}
|
||||
|
||||
/// Logs the session out; the host forgets the cookie.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the call fails, which after a long session may mean it had
|
||||
/// already expired.
|
||||
pub fn logout(self) -> Result<(), Error> {
|
||||
self.call("Logout", &this("SessionManager", &self.manager))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn call(&self, name: &'static str, inner: &str) -> Result<String, Error> {
|
||||
let response = call(
|
||||
&self.host,
|
||||
self.port,
|
||||
&self.client,
|
||||
Some(&self.cookie),
|
||||
name,
|
||||
inner,
|
||||
self.transcript,
|
||||
)?;
|
||||
Ok(String::from_utf8_lossy(&response.body).into_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes one call on its own TLS connection and returns the checked response.
|
||||
fn call(
|
||||
host: &str,
|
||||
port: u16,
|
||||
client: &tls::Client,
|
||||
cookie: Option<&Cookie>,
|
||||
name: &'static str,
|
||||
inner: &str,
|
||||
transcript: Transcript,
|
||||
) -> Result<http::Response, Error> {
|
||||
let started = Instant::now();
|
||||
let wire = |source| Error::Http { call: name, source };
|
||||
let mut stream = tls::connect(host, port)?;
|
||||
let mut session = client.handshake(&mut stream, host)?;
|
||||
let mut tls = Stream::new(&mut session, &mut stream);
|
||||
let body = envelope(name, inner);
|
||||
let request = http::request(
|
||||
host,
|
||||
port,
|
||||
cookie.map(|cookie| cookie.0.as_str()),
|
||||
body.as_bytes(),
|
||||
);
|
||||
tls.write_all(&request)
|
||||
.and_then(|()| tls.flush())
|
||||
.map_err(|error| wire(http::Error::Io(error)))?;
|
||||
let response = http::read_response(&mut tls).map_err(wire)?;
|
||||
transcript.call(name, response.status, started.elapsed());
|
||||
let text = String::from_utf8_lossy(&response.body);
|
||||
if let Some(text) = element(&text, "faultstring") {
|
||||
return Err(Error::Fault { call: name, text });
|
||||
}
|
||||
if response.status != 200 {
|
||||
return Err(Error::Status {
|
||||
call: name,
|
||||
status: response.status,
|
||||
});
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Wraps the operation `name` and its `inner` elements in a SOAP envelope.
|
||||
fn envelope(name: &str, inner: &str) -> String {
|
||||
format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
|
||||
<soapenv:Envelope xmlns:soapenv=\"http://schemas.xmlsoap.org/soap/envelope/\">\
|
||||
<soapenv:Body><{name} xmlns=\"urn:vim25\">{inner}</{name}></soapenv:Body>\
|
||||
</soapenv:Envelope>"
|
||||
)
|
||||
}
|
||||
|
||||
/// The `_this` element naming the managed object a call is made on.
|
||||
fn this(kind: &str, reference: &str) -> String {
|
||||
format!("<_this type=\"{kind}\">{}</_this>", escape(reference))
|
||||
}
|
||||
|
||||
/// Returns the session manager the service content names.
|
||||
fn service_content(content: &str) -> Result<String, Error> {
|
||||
element(content, "sessionManager").ok_or(Error::Missing {
|
||||
call: "RetrieveServiceContent",
|
||||
element: "sessionManager",
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads the ticket out of an `NfcGetVmFiles` reply.
|
||||
fn ticket(reply: &str) -> Result<Ticket, Error> {
|
||||
let field = |element| {
|
||||
self::element(reply, element).ok_or(Error::Missing {
|
||||
call: "NfcGetVmFiles",
|
||||
element,
|
||||
})
|
||||
};
|
||||
let port = field("port")?;
|
||||
let thumbprint = field("sslThumbprint")?;
|
||||
Ok(Ticket {
|
||||
host: element(reply, "host").filter(|host| !host.is_empty()),
|
||||
port: port.parse().map_err(|error| Error::Invalid {
|
||||
element: "port",
|
||||
text: port.clone(),
|
||||
reason: format!("not a port: {error}"),
|
||||
})?,
|
||||
thumbprint: tls::Pin::sha1(&thumbprint).map_err(|error| Error::Invalid {
|
||||
element: "sslThumbprint",
|
||||
text: thumbprint.clone(),
|
||||
reason: error.to_string(),
|
||||
})?,
|
||||
secret: field("sessionId")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// The text of the first element named `name`, whatever its namespace prefix.
|
||||
fn element(xml: &str, name: &str) -> Option<String> {
|
||||
let mut reader = quick_xml::Reader::from_str(xml);
|
||||
let mut inside = false;
|
||||
let mut text = String::new();
|
||||
loop {
|
||||
let event = reader.read_event().ok()?;
|
||||
if event == Event::Eof {
|
||||
return None;
|
||||
}
|
||||
match event {
|
||||
Event::Start(start) if start.local_name().as_ref() == name => inside = true,
|
||||
Event::End(_) if inside => return Some(text),
|
||||
Event::Text(content) if inside => text.push_str(&content.xml10_content()),
|
||||
Event::CData(content) if inside => text.push_str(&content.xml10_content()),
|
||||
Event::GeneralRef(reference) if inside => match reference.resolve_char_ref() {
|
||||
Ok(Some(character)) => text.push(character),
|
||||
_ => text.push_str(resolve_predefined_entity(&reference).unwrap_or_default()),
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SERVICE_CONTENT: &str = "<soapenv:Envelope xmlns:soapenv=\"http://schemas.xmlsoap.org/soap/envelope/\">\
|
||||
<soapenv:Body><RetrieveServiceContentResponse xmlns=\"urn:vim25\"><returnval>\
|
||||
<rootFolder type=\"Folder\">ha-folder-root</rootFolder>\
|
||||
<about><name>VMware ESXi</name><fullName>VMware ESXi 8.0.1 build-21495797</fullName>\
|
||||
<apiType>HostAgent</apiType></about>\
|
||||
<sessionManager type=\"SessionManager\">ha-sessionmgr</sessionManager>\
|
||||
</returnval></RetrieveServiceContentResponse></soapenv:Body></soapenv:Envelope>";
|
||||
|
||||
const TICKET: &str = "<returnval><port>902</port>\
|
||||
<sslThumbprint>A9:99:3E:36:47:06:81:6A:BA:3E:25:71:78:50:C2:6C:9C:D0:D8:9D</sslThumbprint>\
|
||||
<service>nfc</service><serviceVersion>1.1</serviceVersion>\
|
||||
<sessionId>00000000-mock-ticket-000000000000</sessionId></returnval>";
|
||||
|
||||
#[test]
|
||||
fn the_password_never_appears_in_a_debug_rendering() {
|
||||
let password = Password::new("hunter2".to_owned());
|
||||
|
||||
let rendered = format!("{password:?}");
|
||||
|
||||
assert!(!rendered.contains("hunter2"), "{rendered}");
|
||||
assert_eq!(rendered, "Password(<redacted>)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cookie_never_appears_in_a_debug_rendering_of_a_session() {
|
||||
let session = Session {
|
||||
host: "esxi.test".to_owned(),
|
||||
port: 443,
|
||||
client: tls::Client::new(&tls::Trust::Pin(
|
||||
tls::Pin::sha256(&"ab".repeat(32)).unwrap(),
|
||||
))
|
||||
.unwrap(),
|
||||
manager: "ha-sessionmgr".to_owned(),
|
||||
cookie: Cookie("vmware_soap_session=\"mock-cookie\"".to_owned()),
|
||||
transcript: Transcript::new(false),
|
||||
};
|
||||
|
||||
let rendered = format!("{session:?}");
|
||||
|
||||
assert!(!rendered.contains("mock-cookie"), "{rendered}");
|
||||
assert!(rendered.contains("Cookie(<redacted>)"), "{rendered}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ticket_reply_yields_port_thumbprint_and_secret() {
|
||||
let ticket = ticket(TICKET).unwrap();
|
||||
|
||||
assert_eq!(ticket.port, 902);
|
||||
assert_eq!(
|
||||
ticket.thumbprint.to_string(),
|
||||
"sha1:A9:99:3E:36:47:06:81:6A:BA:3E:25:71:78:50:C2:6C:9C:D0:D8:9D"
|
||||
);
|
||||
assert_eq!(ticket.secret, "00000000-mock-ticket-000000000000");
|
||||
assert_eq!(ticket.host, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ticket_from_a_vcenter_names_the_host_serving_the_data_port() {
|
||||
let reply = TICKET.replace("<port>", "<host>esxi-02.test</host><port>");
|
||||
|
||||
assert_eq!(
|
||||
ticket(&reply).unwrap().host.as_deref(),
|
||||
Some("esxi-02.test")
|
||||
);
|
||||
let empty = TICKET.replace("<port>", "<host></host><port>");
|
||||
assert_eq!(ticket(&empty).unwrap().host, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_secret_never_appears_in_a_debug_rendering_of_the_ticket() {
|
||||
let rendered = format!("{:?}", ticket(TICKET).unwrap());
|
||||
|
||||
assert!(!rendered.contains("mock-ticket"), "{rendered}");
|
||||
assert!(rendered.contains("secret: \"<redacted>\""), "{rendered}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ticket_reply_missing_a_field_names_it() {
|
||||
let error = ticket("<returnval><port>902</port></returnval>").unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"NfcGetVmFiles: the reply has no sslThumbprint element"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ticket_with_a_bad_port_or_thumbprint_quotes_the_text() {
|
||||
let reply = TICKET.replace("902", "port-902");
|
||||
let error = ticket(&reply).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.starts_with("NfcGetVmFiles: the port element holds \"port-902\", not a port: "),
|
||||
"{error}"
|
||||
);
|
||||
|
||||
let reply = TICKET.replace("A9:99:3E", "zz:99:3E");
|
||||
let error = ticket(&reply).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.starts_with("NfcGetVmFiles: the sslThumbprint element holds \"zz:99:3E:"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_esxi_service_content_yields_its_session_manager() {
|
||||
assert_eq!(service_content(SERVICE_CONTENT).unwrap(), "ha-sessionmgr");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vcenter_service_content_yields_its_session_manager() {
|
||||
let content = SERVICE_CONTENT
|
||||
.replace("HostAgent", "VirtualCenter")
|
||||
.replace("ha-sessionmgr", "SessionManager");
|
||||
|
||||
assert_eq!(service_content(&content).unwrap(), "SessionManager");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_service_content_without_a_session_manager_names_the_element() {
|
||||
let error = service_content("<about><apiType>HostAgent</apiType></about>").unwrap_err();
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"RetrieveServiceContent: the reply has no sessionManager element"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_element_is_found_by_local_name_with_its_entities_decoded() {
|
||||
let xml = "<a:r xmlns:a=\"urn:x\"><a:faultstring>Cannot & will not <log in></a:faultstring></a:r>";
|
||||
|
||||
assert_eq!(
|
||||
element(xml, "faultstring").as_deref(),
|
||||
Some("Cannot & will not <log in>")
|
||||
);
|
||||
assert_eq!(element(xml, "other"), None);
|
||||
assert_eq!(element("<broken", "faultstring"), None);
|
||||
assert_eq!(
|
||||
element("<x><![CDATA[a<b]]></x>", "x").as_deref(),
|
||||
Some("a<b")
|
||||
);
|
||||
assert_eq!(
|
||||
element("<r>out<![CDATA[side]]>&<f>in</f></r>", "f").as_deref(),
|
||||
Some("in")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_envelope_names_the_operation_in_the_vim25_namespace() {
|
||||
let body = envelope("Logout", &this("SessionManager", "ha-sessionmgr"));
|
||||
|
||||
assert_eq!(
|
||||
body,
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
|
||||
<soapenv:Envelope xmlns:soapenv=\"http://schemas.xmlsoap.org/soap/envelope/\">\
|
||||
<soapenv:Body><Logout xmlns=\"urn:vim25\">\
|
||||
<_this type=\"SessionManager\">ha-sessionmgr</_this>\
|
||||
</Logout></soapenv:Body></soapenv:Envelope>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operator_supplied_values_are_xml_escaped() {
|
||||
assert_eq!(
|
||||
this("VirtualMachine", "<3 & \"4\">"),
|
||||
"<_this type=\"VirtualMachine\"><3 & "4"></_this>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fault_error_carries_the_call_and_the_host_text() {
|
||||
let error = Error::Fault {
|
||||
call: "Login",
|
||||
text: "Cannot complete login due to an incorrect user name or password.".to_owned(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"Login: the host answered: Cannot complete login due to an incorrect user name or password."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
//! The HTTP/1.1 client under the management plane: one request, one response.
|
||||
//!
|
||||
//! Every request is a `POST /sdk` with `Connection: close`, so a response is
|
||||
//! read up to its `Content-Length`, through its chunked encoding, or to the
|
||||
//! close. No redirects, no compression, no keep-alive.
|
||||
|
||||
use std::io::{self, BufRead, BufReader, Read};
|
||||
|
||||
use crate::tls;
|
||||
|
||||
/// Longest status or header line accepted; real ones are under 200 bytes.
|
||||
const MAX_LINE_LEN: usize = 8192;
|
||||
|
||||
/// Most headers accepted, so a host cannot grow the list without bound.
|
||||
const MAX_HEADERS: usize = 64;
|
||||
|
||||
/// Largest body accepted. A SOAP reply is a few KiB; a `Content-Length`
|
||||
/// beyond this is refused before anything is allocated.
|
||||
pub const MAX_BODY_LEN: usize = 1 << 20;
|
||||
|
||||
/// Errors reading a response.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
/// A read or write failed or timed out.
|
||||
#[error("{}", tls::describe(.0))]
|
||||
Io(#[from] io::Error),
|
||||
/// The host closed the connection before the response was complete.
|
||||
#[error("the host closed the connection before the response was complete")]
|
||||
Closed,
|
||||
/// The response does not parse as HTTP/1.1.
|
||||
#[error("malformed response: {0}")]
|
||||
Malformed(&'static str),
|
||||
/// The response body exceeds [`MAX_BODY_LEN`].
|
||||
#[error("the response body exceeds {MAX_BODY_LEN} bytes")]
|
||||
TooLarge,
|
||||
}
|
||||
|
||||
/// A response: its status code, its headers in order, and its body.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Response {
|
||||
/// The three-digit status code.
|
||||
pub status: u16,
|
||||
/// Header names and values, trimmed, in the order received.
|
||||
pub headers: Vec<(String, String)>,
|
||||
/// The body, decoded from its transfer encoding.
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Response {
|
||||
/// The value of the first header named `name`, compared case-insensitively.
|
||||
#[must_use]
|
||||
pub fn header(&self, name: &str) -> Option<&str> {
|
||||
self.headers
|
||||
.iter()
|
||||
.find(|(candidate, _)| candidate.eq_ignore_ascii_case(name))
|
||||
.map(|(_, value)| value.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a `POST /sdk` request carrying `body`, and `cookie` when there is one.
|
||||
#[must_use]
|
||||
pub fn request(host: &str, port: u16, cookie: Option<&str>, body: &[u8]) -> Vec<u8> {
|
||||
let authority = match (host.contains(':'), port) {
|
||||
(true, 443) => format!("[{host}]"),
|
||||
(true, _) => format!("[{host}]:{port}"),
|
||||
(false, 443) => host.to_owned(),
|
||||
(false, _) => format!("{host}:{port}"),
|
||||
};
|
||||
let cookie = cookie
|
||||
.map(|cookie| format!("Cookie: {cookie}\r\n"))
|
||||
.unwrap_or_default();
|
||||
let head = format!(
|
||||
"POST /sdk HTTP/1.1\r\n\
|
||||
Host: {authority}\r\n\
|
||||
Content-Type: text/xml; charset=utf-8\r\n\
|
||||
SOAPAction: \"urn:vim25\"\r\n\
|
||||
Connection: close\r\n\
|
||||
Content-Length: {}\r\n\
|
||||
{cookie}\r\n",
|
||||
body.len()
|
||||
);
|
||||
let mut bytes = head.into_bytes();
|
||||
bytes.extend_from_slice(body);
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Reads one response from `reader`.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the read fails, the host closes early, the head does not parse, or
|
||||
/// the body is longer than [`MAX_BODY_LEN`].
|
||||
pub fn read_response<R: Read>(reader: R) -> Result<Response, Error> {
|
||||
let mut reader = BufReader::new(reader);
|
||||
let status_line = line(&mut reader)?;
|
||||
let status = status_line
|
||||
.strip_prefix("HTTP/1.")
|
||||
.and_then(|rest| rest.split(' ').nth(1))
|
||||
.and_then(|code| code.parse().ok())
|
||||
.ok_or(Error::Malformed("status line"))?;
|
||||
let mut headers = Vec::new();
|
||||
loop {
|
||||
let text = line(&mut reader)?;
|
||||
if text.is_empty() {
|
||||
break;
|
||||
}
|
||||
if headers.len() == MAX_HEADERS {
|
||||
return Err(Error::Malformed("too many headers"));
|
||||
}
|
||||
let (name, value) = text.split_once(':').ok_or(Error::Malformed("header"))?;
|
||||
headers.push((name.trim().to_owned(), value.trim().to_owned()));
|
||||
}
|
||||
let mut response = Response {
|
||||
status,
|
||||
headers,
|
||||
body: Vec::new(),
|
||||
};
|
||||
let chunked = response
|
||||
.header("Transfer-Encoding")
|
||||
.is_some_and(|encoding| encoding.eq_ignore_ascii_case("chunked"));
|
||||
response.body = if chunked {
|
||||
chunks(&mut reader)?
|
||||
} else if let Some(length) = response.header("Content-Length") {
|
||||
let length = length
|
||||
.parse()
|
||||
.map_err(|_error| Error::Malformed("content length"))?;
|
||||
sized(&mut reader, length)?
|
||||
} else {
|
||||
rest(&mut reader)?
|
||||
};
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Reads one line without its line end; empty means the blank line ending the head.
|
||||
fn line<R: BufRead>(reader: &mut R) -> Result<String, Error> {
|
||||
let mut bytes = Vec::new();
|
||||
let read = reader
|
||||
.by_ref()
|
||||
.take(MAX_LINE_LEN as u64)
|
||||
.read_until(b'\n', &mut bytes)?;
|
||||
if read == 0 {
|
||||
return Err(Error::Closed);
|
||||
}
|
||||
if bytes.last() != Some(&b'\n') {
|
||||
return Err(if bytes.len() >= MAX_LINE_LEN {
|
||||
Error::Malformed("line too long")
|
||||
} else {
|
||||
Error::Closed
|
||||
});
|
||||
}
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
Ok(text.trim_end_matches(['\r', '\n']).to_owned())
|
||||
}
|
||||
|
||||
/// Reads exactly `length` bytes of body.
|
||||
fn sized<R: Read>(reader: &mut R, length: usize) -> Result<Vec<u8>, Error> {
|
||||
if length > MAX_BODY_LEN {
|
||||
return Err(Error::TooLarge);
|
||||
}
|
||||
let mut body = vec![0_u8; length];
|
||||
reader.read_exact(&mut body).map_err(closed)?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Reads a chunked body: hex sizes, chunks, then the terminating empty chunk.
|
||||
fn chunks<R: BufRead>(reader: &mut R) -> Result<Vec<u8>, Error> {
|
||||
let mut body = Vec::new();
|
||||
loop {
|
||||
let size_line = line(reader)?;
|
||||
let size = size_line.split(';').next().unwrap_or_default().trim();
|
||||
let size =
|
||||
usize::from_str_radix(size, 16).map_err(|_error| Error::Malformed("chunk size"))?;
|
||||
if size == 0 {
|
||||
while !line(reader)?.is_empty() {}
|
||||
return Ok(body);
|
||||
}
|
||||
if body.len().saturating_add(size) > MAX_BODY_LEN {
|
||||
return Err(Error::TooLarge);
|
||||
}
|
||||
body.extend_from_slice(&sized(reader, size)?);
|
||||
if !line(reader)?.is_empty() {
|
||||
return Err(Error::Malformed("chunk end"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the body up to the close, which may come without a TLS close notify.
|
||||
fn rest<R: Read>(reader: &mut R) -> Result<Vec<u8>, Error> {
|
||||
let mut body = Vec::new();
|
||||
match reader.take(MAX_BODY_LEN as u64 + 1).read_to_end(&mut body) {
|
||||
Ok(_) | Err(_) if body.len() > MAX_BODY_LEN => Err(Error::TooLarge),
|
||||
Ok(_) => Ok(body),
|
||||
Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => Ok(body),
|
||||
Err(error) => Err(Error::Io(error)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps the end of the stream inside a body to [`Error::Closed`].
|
||||
fn closed(error: io::Error) -> Error {
|
||||
match error.kind() {
|
||||
io::ErrorKind::UnexpectedEof => Error::Closed,
|
||||
_ => Error::Io(error),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::Cursor;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_request_posts_to_sdk_with_the_soap_headers_and_the_body_length() {
|
||||
let bytes = request("esxi.test", 443, None, b"<x/>");
|
||||
|
||||
assert_eq!(
|
||||
String::from_utf8(bytes).unwrap(),
|
||||
"POST /sdk HTTP/1.1\r\n\
|
||||
Host: esxi.test\r\n\
|
||||
Content-Type: text/xml; charset=utf-8\r\n\
|
||||
SOAPAction: \"urn:vim25\"\r\n\
|
||||
Connection: close\r\n\
|
||||
Content-Length: 4\r\n\
|
||||
\r\n\
|
||||
<x/>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_carries_the_cookie_verbatim_and_the_port_when_not_443() {
|
||||
let text =
|
||||
|host, port, cookie| String::from_utf8(request(host, port, cookie, b"")).unwrap();
|
||||
|
||||
let with_cookie = text("2001:db8::10", 8443, Some("vmware_soap_session=\"x\""));
|
||||
assert!(
|
||||
with_cookie.contains("Host: [2001:db8::10]:8443\r\n"),
|
||||
"{with_cookie}"
|
||||
);
|
||||
assert!(
|
||||
with_cookie.contains("\r\nCookie: vmware_soap_session=\"x\"\r\n\r\n"),
|
||||
"{with_cookie}"
|
||||
);
|
||||
assert!(text("2001:db8::10", 443, None).contains("Host: [2001:db8::10]\r\n"));
|
||||
assert!(text("esxi.test", 8443, None).contains("Host: esxi.test:8443\r\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_response_with_a_content_length_stops_at_that_length() {
|
||||
let bytes =
|
||||
b"HTTP/1.1 200 OK\r\nContent-Type: text/xml\r\ncontent-length: 5\r\n\r\nhelloEXTRA";
|
||||
|
||||
let response = read_response(Cursor::new(bytes)).unwrap();
|
||||
|
||||
assert_eq!(response.status, 200);
|
||||
assert_eq!(response.header("CONTENT-TYPE"), Some("text/xml"));
|
||||
assert_eq!(response.body, b"hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chunked_response_is_reassembled_without_its_sizes_and_trailers() {
|
||||
let bytes = b"HTTP/1.1 500 Internal Server Error\r\nTransfer-Encoding: chunked\r\n\r\n\
|
||||
3;ext=1\r\nabc\r\n2\r\nde\r\n0\r\nTrailer: x\r\n\r\n";
|
||||
|
||||
let response = read_response(Cursor::new(bytes)).unwrap();
|
||||
|
||||
assert_eq!(response.status, 500);
|
||||
assert_eq!(response.body, b"abcde");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_response_without_framing_runs_to_the_close() {
|
||||
let bytes = b"HTTP/1.0 200 OK\r\n\r\nall of it";
|
||||
|
||||
let response = read_response(Cursor::new(bytes)).unwrap();
|
||||
|
||||
assert_eq!(response.body, b"all of it");
|
||||
}
|
||||
|
||||
/// A stream that fails with `kind` where it would report its end.
|
||||
struct Failing(Cursor<Vec<u8>>, io::ErrorKind);
|
||||
|
||||
impl Read for Failing {
|
||||
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
|
||||
match self.0.read(buffer)? {
|
||||
0 => Err(io::Error::from(self.1)),
|
||||
read => Ok(read),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn failing(bytes: &[u8], kind: io::ErrorKind) -> Failing {
|
||||
Failing(Cursor::new(bytes.to_vec()), kind)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_close_without_tls_close_notify_still_ends_an_unframed_body() {
|
||||
let stream = failing(b"HTTP/1.1 200 OK\r\n\r\nbody", io::ErrorKind::UnexpectedEof);
|
||||
|
||||
let response = read_response(stream).unwrap();
|
||||
|
||||
assert_eq!(response.body, b"body");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_read_error_inside_an_unframed_body_is_reported_not_taken_for_the_close() {
|
||||
let stream = failing(
|
||||
b"HTTP/1.1 200 OK\r\n\r\nbody",
|
||||
io::ErrorKind::ConnectionReset,
|
||||
);
|
||||
|
||||
let error = read_response(stream).unwrap_err();
|
||||
|
||||
assert!(
|
||||
matches!(error, Error::Io(ref inner) if inner.kind() == io::ErrorKind::ConnectionReset),
|
||||
"{error:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_of_exactly_the_limit_is_accepted_under_every_framing() {
|
||||
let body = vec![b'x'; MAX_BODY_LEN];
|
||||
|
||||
let mut sized =
|
||||
format!("HTTP/1.1 200 OK\r\nContent-Length: {MAX_BODY_LEN}\r\n\r\n").into_bytes();
|
||||
sized.extend_from_slice(&body);
|
||||
assert_eq!(read_response(Cursor::new(sized)).unwrap().body, body);
|
||||
|
||||
let mut chunked =
|
||||
format!("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n{MAX_BODY_LEN:x}\r\n")
|
||||
.into_bytes();
|
||||
chunked.extend_from_slice(&body);
|
||||
chunked.extend_from_slice(b"\r\n0\r\n\r\n");
|
||||
assert_eq!(read_response(Cursor::new(chunked)).unwrap().body, body);
|
||||
|
||||
let mut unframed = b"HTTP/1.1 200 OK\r\n\r\n".to_vec();
|
||||
unframed.extend_from_slice(&body);
|
||||
assert_eq!(read_response(Cursor::new(unframed)).unwrap().body, body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_cut_short_of_its_content_length_is_a_closed_connection() {
|
||||
let bytes = b"HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\nshort";
|
||||
|
||||
let error = read_response(Cursor::new(bytes)).unwrap_err();
|
||||
|
||||
assert!(matches!(error, Error::Closed), "{error:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_status_line_that_is_not_http_is_malformed() {
|
||||
let error = read_response(Cursor::new(b"220 hello\r\n\r\n")).unwrap_err();
|
||||
|
||||
assert_eq!(error.to_string(), "malformed response: status line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_header_without_a_colon_is_malformed() {
|
||||
let error = read_response(Cursor::new(b"HTTP/1.1 200 OK\r\nnocolon\r\n\r\n")).unwrap_err();
|
||||
|
||||
assert_eq!(error.to_string(), "malformed response: header");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_content_length_beyond_the_limit_is_refused_before_reading_the_body() {
|
||||
let bytes = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n",
|
||||
MAX_BODY_LEN + 1
|
||||
);
|
||||
|
||||
let error = read_response(Cursor::new(bytes.into_bytes())).unwrap_err();
|
||||
|
||||
assert_eq!(error.to_string(), "the response body exceeds 1048576 bytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunks_summing_beyond_the_limit_and_a_bad_chunk_size_are_refused() {
|
||||
let mut bytes =
|
||||
format!("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n{MAX_BODY_LEN:x}\r\n")
|
||||
.into_bytes();
|
||||
bytes.resize(bytes.len() + MAX_BODY_LEN, b'x');
|
||||
bytes.extend_from_slice(b"\r\n1\r\nx\r\n0\r\n\r\n");
|
||||
let error = read_response(Cursor::new(bytes)).unwrap_err();
|
||||
assert!(matches!(error, Error::TooLarge), "{error:?}");
|
||||
|
||||
let bytes = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\nzz\r\n";
|
||||
let error = read_response(Cursor::new(bytes)).unwrap_err();
|
||||
assert_eq!(error.to_string(), "malformed response: chunk size");
|
||||
|
||||
let bytes = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n2\r\nabXX\r\n0\r\n\r\n";
|
||||
let error = read_response(Cursor::new(bytes)).unwrap_err();
|
||||
assert_eq!(error.to_string(), "malformed response: chunk end");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unframed_body_beyond_the_limit_is_refused() {
|
||||
let mut bytes = b"HTTP/1.1 200 OK\r\n\r\n".to_vec();
|
||||
bytes.resize(bytes.len() + MAX_BODY_LEN + 1, b'x');
|
||||
|
||||
let error = read_response(Cursor::new(bytes)).unwrap_err();
|
||||
|
||||
assert!(matches!(error, Error::TooLarge), "{error:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_without_an_end_within_the_limit_and_too_many_headers_are_malformed() {
|
||||
let bytes = vec![b'x'; MAX_LINE_LEN + 1];
|
||||
let error = read_response(Cursor::new(bytes)).unwrap_err();
|
||||
assert_eq!(error.to_string(), "malformed response: line too long");
|
||||
|
||||
let mut bytes = b"HTTP/1.1 200 OK\r\n".to_vec();
|
||||
for _ in 0..=MAX_HEADERS {
|
||||
bytes.extend_from_slice(b"X: y\r\n");
|
||||
}
|
||||
let error = read_response(Cursor::new(bytes)).unwrap_err();
|
||||
assert_eq!(error.to_string(), "malformed response: too many headers");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_stream_or_a_head_cut_mid_line_is_a_closed_connection() {
|
||||
let error = read_response(Cursor::new(b"HTTP/1.1 200 OK\r\nContent-Le")).unwrap_err();
|
||||
assert!(matches!(error, Error::Closed), "{error:?}");
|
||||
|
||||
let error = read_response(Cursor::new(b"")).unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"the host closed the connection before the response was complete"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,692 @@
|
||||
//! TLS toward a host: connect with timeouts, judge or capture its certificate, compute thumbprints.
|
||||
//!
|
||||
//! [`Client`] carries real traffic and judges the certificate by a [`Trust`]:
|
||||
//! the system roots, a bundle of anchors, or one pinned thumbprint. The
|
||||
//! accept-everything verifier never leaves this module: [`capture`] runs a TLS
|
||||
//! handshake with it, sends nothing afterwards and drops the connection
|
||||
//! before returning.
|
||||
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::time::Duration;
|
||||
|
||||
use aws_lc_rs::digest;
|
||||
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
|
||||
use rustls::crypto::aws_lc_rs::default_provider;
|
||||
use rustls::crypto::{CryptoProvider, verify_tls12_signature, verify_tls13_signature};
|
||||
use rustls::{
|
||||
CertificateError, CipherSuite, ClientConfig, ClientConnection, DigitallySignedStruct,
|
||||
ProtocolVersion, RootCertStore, SignatureScheme,
|
||||
};
|
||||
use rustls_pki_types::{CertificateDer, InvalidDnsNameError, ServerName, UnixTime};
|
||||
|
||||
/// How long a TCP connect may take before the host counts as unreachable.
|
||||
///
|
||||
/// Ten seconds: a host on the same network answers within milliseconds, and
|
||||
/// one that takes longer is down or filtered, so waiting out the kernel's own
|
||||
/// limit of about two minutes would only stall the operator.
|
||||
pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// How long one read or write on a host connection may block.
|
||||
///
|
||||
/// Sixty seconds: a busy host can take tens of seconds to answer a request,
|
||||
/// so a shorter limit would report false failures, while a longer one leaves
|
||||
/// a dead connection undetected for as long. Every host socket carries it.
|
||||
pub const IO_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Errors from connecting to a host or judging its certificate.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
/// No address of the host accepted the connection.
|
||||
#[error("could not connect: {}", describe(.0))]
|
||||
Connect(#[source] io::Error),
|
||||
/// The TLS handshake failed, or its I/O timed out.
|
||||
#[error("tls handshake failed: {}", describe(.0))]
|
||||
TlsHandshake(#[source] io::Error),
|
||||
/// The host presented a certificate whose thumbprint is not the pinned one.
|
||||
#[error("certificate thumbprint mismatch: expected {expected}, the host presented {presented}")]
|
||||
Mismatch {
|
||||
/// The pin the certificate had to match.
|
||||
expected: Pin,
|
||||
/// The thumbprint of the certificate the host presented, same algorithm.
|
||||
presented: String,
|
||||
},
|
||||
/// The TLS handshake ended without something a completed one always has.
|
||||
#[error("the tls handshake ended without {0}")]
|
||||
Incomplete(&'static str),
|
||||
/// The host is neither a DNS name nor an IP address.
|
||||
#[error("invalid host name: {0}")]
|
||||
HostName(#[source] InvalidDnsNameError),
|
||||
/// A thumbprint is not the hex digest its algorithm produces.
|
||||
#[error("{text:?} is not a {name} thumbprint of {digits} hex digits")]
|
||||
Thumbprint {
|
||||
/// The algorithm the thumbprint was parsed for.
|
||||
name: &'static str,
|
||||
/// How many hex digits that algorithm's digest has.
|
||||
digits: usize,
|
||||
/// The text that was offered.
|
||||
text: String,
|
||||
},
|
||||
/// No trust anchor could be loaded.
|
||||
#[error("no trust anchor: {0}")]
|
||||
Roots(String),
|
||||
/// rustls rejected the client configuration.
|
||||
#[error("tls configuration rejected: {0}")]
|
||||
Config(#[source] rustls::Error),
|
||||
}
|
||||
|
||||
/// What a host presented during a TLS handshake that carried nothing else.
|
||||
#[derive(Debug)]
|
||||
pub struct Presented {
|
||||
/// The first certificate of the chain the host sent, DER encoded.
|
||||
pub certificate: CertificateDer<'static>,
|
||||
/// The negotiated protocol version.
|
||||
pub version: ProtocolVersion,
|
||||
/// The negotiated cipher suite.
|
||||
pub suite: CipherSuite,
|
||||
}
|
||||
|
||||
/// How the certificate a host presents is judged.
|
||||
#[derive(Debug)]
|
||||
pub enum Trust {
|
||||
/// The system trust store, with the host name checked against the certificate.
|
||||
System,
|
||||
/// The anchors of a PEM bundle, with the host name checked against the certificate.
|
||||
Anchors(Vec<CertificateDer<'static>>),
|
||||
/// One exact certificate named by its thumbprint; no chain and no host name.
|
||||
Pin(Pin),
|
||||
}
|
||||
|
||||
/// The digest the DER certificate a host presents must have.
|
||||
///
|
||||
/// Parsed from hex text, colons and case ignored, and compared as bytes.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct Pin {
|
||||
algorithm: &'static digest::Algorithm,
|
||||
name: &'static str,
|
||||
expected: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Pin {
|
||||
/// Parses a SHA-256 thumbprint: 64 hex digits, colons and case ignored.
|
||||
///
|
||||
/// # Errors
|
||||
/// When `text` is not 64 hex digits once its colons are removed.
|
||||
pub fn sha256(text: &str) -> Result<Pin, Error> {
|
||||
Pin::parse(&digest::SHA256, "sha256", text)
|
||||
}
|
||||
|
||||
/// Parses a SHA-1 thumbprint: 40 hex digits, colons and case ignored.
|
||||
///
|
||||
/// # Errors
|
||||
/// When `text` is not 40 hex digits once its colons are removed.
|
||||
pub fn sha1(text: &str) -> Result<Pin, Error> {
|
||||
Pin::parse(&digest::SHA1_FOR_LEGACY_USE_ONLY, "sha1", text)
|
||||
}
|
||||
|
||||
fn parse(
|
||||
algorithm: &'static digest::Algorithm,
|
||||
name: &'static str,
|
||||
text: &str,
|
||||
) -> Result<Pin, Error> {
|
||||
let digits = algorithm.output_len() * 2;
|
||||
let malformed = || Error::Thumbprint {
|
||||
name,
|
||||
digits,
|
||||
text: text.to_owned(),
|
||||
};
|
||||
let hex: Vec<u8> = text.bytes().filter(|byte| *byte != b':').collect();
|
||||
if hex.len() != digits {
|
||||
return Err(malformed());
|
||||
}
|
||||
let expected = hex
|
||||
.chunks(2)
|
||||
.map(|pair| {
|
||||
std::str::from_utf8(pair)
|
||||
.ok()
|
||||
.and_then(|pair| u8::from_str_radix(pair, 16).ok())
|
||||
.ok_or_else(malformed)
|
||||
})
|
||||
.collect::<Result<Vec<u8>, Error>>()?;
|
||||
Ok(Pin {
|
||||
algorithm,
|
||||
name,
|
||||
expected,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `der` has the pinned digest.
|
||||
#[must_use]
|
||||
pub fn matches(&self, der: &[u8]) -> bool {
|
||||
digest::digest(self.algorithm, der).as_ref() == self.expected.as_slice()
|
||||
}
|
||||
|
||||
/// The digest of `der` with this pin's algorithm, rendered like the pin.
|
||||
#[must_use]
|
||||
pub fn digest_of(&self, der: &[u8]) -> String {
|
||||
colon_hex(digest::digest(self.algorithm, der).as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Pin {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(formatter, "{}:{}", self.name, colon_hex(&self.expected))
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Pin {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
fmt::Display::fmt(self, formatter)
|
||||
}
|
||||
}
|
||||
|
||||
/// A TLS client configuration built for one [`Trust`].
|
||||
#[derive(Debug)]
|
||||
pub struct Client {
|
||||
config: Arc<ClientConfig>,
|
||||
pinned: Option<Arc<Verifier>>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Builds a client that judges certificates by `trust`.
|
||||
///
|
||||
/// # Errors
|
||||
/// When no anchor can be loaded or rustls rejects the configuration.
|
||||
pub fn new(trust: &Trust) -> Result<Client, Error> {
|
||||
let provider = Arc::new(default_provider());
|
||||
let builder = ClientConfig::builder_with_provider(Arc::clone(&provider))
|
||||
.with_safe_default_protocol_versions()
|
||||
.map_err(Error::Config)?;
|
||||
let mut roots = RootCertStore::empty();
|
||||
let (config, pinned) = match trust {
|
||||
Trust::Pin(pin) => {
|
||||
let verifier = Arc::new(Verifier::new(Some(pin.clone()), provider));
|
||||
let config = builder
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(
|
||||
Arc::clone(&verifier) as Arc<dyn ServerCertVerifier>
|
||||
)
|
||||
.with_no_client_auth();
|
||||
(config, Some(verifier))
|
||||
}
|
||||
Trust::Anchors(anchors) => {
|
||||
roots.add_parsable_certificates(anchors.iter().cloned());
|
||||
if roots.is_empty() {
|
||||
return Err(Error::Roots("the bundle holds no certificate".to_owned()));
|
||||
}
|
||||
(
|
||||
builder.with_root_certificates(roots).with_no_client_auth(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
Trust::System => {
|
||||
let loaded = rustls_native_certs::load_native_certs();
|
||||
roots.add_parsable_certificates(loaded.certs);
|
||||
if roots.is_empty() {
|
||||
let reason = loaded.errors.first().map_or_else(
|
||||
|| "the system store holds no certificate".to_owned(),
|
||||
ToString::to_string,
|
||||
);
|
||||
return Err(Error::Roots(reason));
|
||||
}
|
||||
(
|
||||
builder.with_root_certificates(roots).with_no_client_auth(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
};
|
||||
Ok(Client {
|
||||
config: Arc::new(config),
|
||||
pinned,
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs a TLS handshake for `host` on `stream` and returns the session.
|
||||
///
|
||||
/// # Errors
|
||||
/// When `host` is neither a DNS name nor an IP address, the certificate is
|
||||
/// refused, or the TLS handshake fails or times out.
|
||||
pub fn handshake(&self, stream: &mut TcpStream, host: &str) -> Result<ClientConnection, Error> {
|
||||
let name = ServerName::try_from(host)
|
||||
.map_err(Error::HostName)?
|
||||
.to_owned();
|
||||
let mut connection =
|
||||
ClientConnection::new(Arc::clone(&self.config), name).map_err(Error::Config)?;
|
||||
while connection.is_handshaking() {
|
||||
if let Err(error) = connection.complete_io(stream) {
|
||||
return Err(self
|
||||
.pinned
|
||||
.as_ref()
|
||||
.and_then(|verifier| verifier.rejection())
|
||||
.unwrap_or(Error::TlsHandshake(error)));
|
||||
}
|
||||
}
|
||||
Ok(connection)
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a TCP connection to `host:port` with both timeouts applied.
|
||||
///
|
||||
/// Every address the name resolves to is tried in turn, with
|
||||
/// [`CONNECT_TIMEOUT`] each; the stream returned carries [`IO_TIMEOUT`] for
|
||||
/// reads and writes.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the name does not resolve, no address accepts the connection in
|
||||
/// time, or the socket timeouts cannot be set.
|
||||
pub fn connect(host: &str, port: u16) -> Result<TcpStream, Error> {
|
||||
let mut last = io::Error::new(io::ErrorKind::NotFound, "the name resolves to no address");
|
||||
for address in (host, port).to_socket_addrs().map_err(Error::Connect)? {
|
||||
match TcpStream::connect_timeout(&address, CONNECT_TIMEOUT) {
|
||||
Ok(stream) => {
|
||||
stream
|
||||
.set_read_timeout(Some(IO_TIMEOUT))
|
||||
.map_err(Error::Connect)?;
|
||||
stream
|
||||
.set_write_timeout(Some(IO_TIMEOUT))
|
||||
.map_err(Error::Connect)?;
|
||||
return Ok(stream);
|
||||
}
|
||||
Err(error) => last = error,
|
||||
}
|
||||
}
|
||||
Err(Error::Connect(last))
|
||||
}
|
||||
|
||||
/// Runs a TLS handshake on `stream` for `host` and returns what the host presented.
|
||||
///
|
||||
/// The certificate is accepted whatever it says, because the caller wants its
|
||||
/// thumbprints rather than a verdict. The TLS handshake signatures are still
|
||||
/// checked, so the host does hold the private key of the certificate reported.
|
||||
/// Nothing is sent after the TLS handshake, and the connection is dropped on
|
||||
/// return.
|
||||
///
|
||||
/// # Errors
|
||||
/// When `host` is neither a DNS name nor an IP address, the TLS handshake
|
||||
/// fails or times out, or it ends without a certificate.
|
||||
pub(crate) fn capture(stream: &mut TcpStream, host: &str) -> Result<Presented, Error> {
|
||||
let provider = Arc::new(default_provider());
|
||||
let config = ClientConfig::builder_with_provider(Arc::clone(&provider))
|
||||
.with_safe_default_protocol_versions()
|
||||
.map_err(Error::Config)?
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(Verifier::new(None, provider)))
|
||||
.with_no_client_auth();
|
||||
let client = Client {
|
||||
config: Arc::new(config),
|
||||
pinned: None,
|
||||
};
|
||||
let connection = client.handshake(stream, host)?;
|
||||
let certificate = peer_certificate(&connection)?;
|
||||
let (version, suite) = negotiated(&connection)?;
|
||||
Ok(Presented {
|
||||
certificate,
|
||||
version,
|
||||
suite,
|
||||
})
|
||||
}
|
||||
|
||||
/// The protocol version and cipher suite a completed session negotiated.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the session has not completed its handshake.
|
||||
pub(crate) fn negotiated(
|
||||
connection: &ClientConnection,
|
||||
) -> Result<(ProtocolVersion, CipherSuite), Error> {
|
||||
let version = connection
|
||||
.protocol_version()
|
||||
.ok_or(Error::Incomplete("a protocol version"))?;
|
||||
let suite = connection
|
||||
.negotiated_cipher_suite()
|
||||
.ok_or(Error::Incomplete("a cipher suite"))?
|
||||
.suite();
|
||||
Ok((version, suite))
|
||||
}
|
||||
|
||||
/// The first certificate of the chain a completed session's peer sent.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the session carries no certificate.
|
||||
pub(crate) fn peer_certificate(
|
||||
connection: &ClientConnection,
|
||||
) -> Result<CertificateDer<'static>, Error> {
|
||||
connection
|
||||
.peer_certificates()
|
||||
.and_then(|chain| chain.first())
|
||||
.cloned()
|
||||
.ok_or(Error::Incomplete("a certificate"))
|
||||
}
|
||||
|
||||
/// Formats the SHA-256 digest of `der` as colon-separated upper-case hex.
|
||||
#[must_use]
|
||||
pub fn sha256(der: &[u8]) -> String {
|
||||
colon_hex(digest::digest(&digest::SHA256, der).as_ref())
|
||||
}
|
||||
|
||||
/// Formats the SHA-1 digest of `der` as colon-separated upper-case hex.
|
||||
#[must_use]
|
||||
pub fn sha1(der: &[u8]) -> String {
|
||||
colon_hex(digest::digest(&digest::SHA1_FOR_LEGACY_USE_ONLY, der).as_ref())
|
||||
}
|
||||
|
||||
/// Renders an I/O error, naming a timeout for what it is.
|
||||
///
|
||||
/// A socket with a read or write timeout reports `WouldBlock` on Linux, whose
|
||||
/// own message ("Resource temporarily unavailable") hides the cause.
|
||||
pub(crate) fn describe(error: &io::Error) -> String {
|
||||
match error.kind() {
|
||||
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut => "timed out".to_owned(),
|
||||
_ => error.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn colon_hex(bytes: &[u8]) -> String {
|
||||
bytes
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02X}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(":")
|
||||
}
|
||||
|
||||
/// Judges the end-entity certificate by its digest alone, remembering what was presented.
|
||||
///
|
||||
/// Without a pin every certificate is accepted, which only [`capture`] wants.
|
||||
/// TLS handshake signatures are verified with the provider's algorithms
|
||||
/// either way, so a completed TLS handshake proves the host holds the
|
||||
/// certificate's key.
|
||||
#[derive(Debug)]
|
||||
struct Verifier {
|
||||
pin: Option<Pin>,
|
||||
provider: Arc<CryptoProvider>,
|
||||
presented: Mutex<Option<CertificateDer<'static>>>,
|
||||
}
|
||||
|
||||
impl Verifier {
|
||||
fn new(pin: Option<Pin>, provider: Arc<CryptoProvider>) -> Verifier {
|
||||
Verifier {
|
||||
pin,
|
||||
provider,
|
||||
presented: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The mismatch error, when a certificate was presented and refused.
|
||||
fn rejection(&self) -> Option<Error> {
|
||||
let pin = self.pin.as_ref()?;
|
||||
let presented = self
|
||||
.presented
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner);
|
||||
let der = presented.as_ref().filter(|der| !pin.matches(der))?;
|
||||
Some(Error::Mismatch {
|
||||
expected: pin.clone(),
|
||||
presented: pin.digest_of(der),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ServerCertVerifier for Verifier {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
end_entity: &CertificateDer<'_>,
|
||||
_intermediates: &[CertificateDer<'_>],
|
||||
_server_name: &ServerName<'_>,
|
||||
_ocsp_response: &[u8],
|
||||
_now: UnixTime,
|
||||
) -> Result<ServerCertVerified, rustls::Error> {
|
||||
*self
|
||||
.presented
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner) = Some(end_entity.clone().into_owned());
|
||||
match &self.pin {
|
||||
Some(pin) if !pin.matches(end_entity) => Err(rustls::Error::InvalidCertificate(
|
||||
CertificateError::ApplicationVerificationFailure,
|
||||
)),
|
||||
_ => Ok(ServerCertVerified::assertion()),
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &CertificateDer<'_>,
|
||||
dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
verify_tls12_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&self.provider.signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &CertificateDer<'_>,
|
||||
dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
verify_tls13_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&self.provider.signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
||||
self.provider
|
||||
.signature_verification_algorithms
|
||||
.supported_schemes()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::Read;
|
||||
use std::net::TcpListener;
|
||||
use std::thread;
|
||||
|
||||
use rustls::{ServerConfig, ServerConnection};
|
||||
use rustls_pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer};
|
||||
|
||||
use super::*;
|
||||
|
||||
const SHA256_OF_ABC: &str = "BA:78:16:BF:8F:01:CF:EA:41:41:40:DE:5D:AE:22:23:\
|
||||
B0:03:61:A3:96:17:7A:9C:B4:10:FF:61:F2:00:15:AD";
|
||||
|
||||
/// A loopback TLS server presenting a fresh self-signed certificate.
|
||||
fn server() -> (CertificateDer<'static>, u16) {
|
||||
let issued = rcgen::generate_simple_self_signed(vec!["esxi.test".to_owned()]).unwrap();
|
||||
let certificate = issued.cert.der().clone();
|
||||
let key =
|
||||
PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(issued.signing_key.serialize_der()));
|
||||
let config = Arc::new(
|
||||
ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(vec![certificate.clone()], key)
|
||||
.unwrap(),
|
||||
);
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut session = ServerConnection::new(config).unwrap();
|
||||
while session.is_handshaking() {
|
||||
if session.complete_io(&mut stream).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
(certificate, port)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha256_is_rendered_as_upper_case_hex_with_colons() {
|
||||
assert_eq!(sha256(b"abc"), SHA256_OF_ABC);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha1_is_rendered_as_upper_case_hex_with_colons() {
|
||||
assert_eq!(
|
||||
sha1(b"abc"),
|
||||
"A9:99:3E:36:47:06:81:6A:BA:3E:25:71:78:50:C2:6C:9C:D0:D8:9D"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pin_ignores_colons_and_case_and_prints_canonically() {
|
||||
let lower = SHA256_OF_ABC.replace(':', "").to_lowercase();
|
||||
let pin = Pin::sha256(&lower).unwrap();
|
||||
assert_eq!(pin, Pin::sha256(SHA256_OF_ABC).unwrap());
|
||||
assert!(pin.matches(b"abc"));
|
||||
assert!(!pin.matches(b"abd"));
|
||||
assert_eq!(pin.to_string(), format!("sha256:{SHA256_OF_ABC}"));
|
||||
assert_eq!(format!("{pin:?}"), pin.to_string());
|
||||
assert_eq!(pin.digest_of(b"abc"), SHA256_OF_ABC);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sha1_pin_wants_forty_digits_and_a_sha256_pin_sixty_four() {
|
||||
let sha1_text = sha1(b"abc");
|
||||
assert!(Pin::sha1(&sha1_text).unwrap().matches(b"abc"));
|
||||
let error = Pin::sha256(&sha1_text).unwrap_err();
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
format!("{sha1_text:?} is not a sha256 thumbprint of 64 hex digits")
|
||||
);
|
||||
let error = Pin::sha1(SHA256_OF_ABC).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.ends_with("is not a sha1 thumbprint of 40 hex digits")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pin_with_a_non_hex_digit_or_a_multibyte_character_is_refused() {
|
||||
let with_letter = format!("G{}", &SHA256_OF_ABC[1..]);
|
||||
assert!(matches!(
|
||||
Pin::sha256(&with_letter),
|
||||
Err(Error::Thumbprint { name: "sha256", .. })
|
||||
));
|
||||
let with_accent = format!("é{}", &SHA256_OF_ABC[2..]);
|
||||
assert!(Pin::sha256(&with_accent).is_err());
|
||||
let with_three_byte = format!("€{}", &SHA256_OF_ABC[3..]);
|
||||
assert!(Pin::sha256(&with_three_byte).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pinned_client_completes_the_handshake_with_the_pinned_certificate() {
|
||||
let (certificate, port) = server();
|
||||
let client = Client::new(&Trust::Pin(Pin::sha256(&sha256(&certificate)).unwrap())).unwrap();
|
||||
let mut stream = connect("127.0.0.1", port).unwrap();
|
||||
|
||||
let session = client.handshake(&mut stream, "127.0.0.1").unwrap();
|
||||
|
||||
assert_eq!(peer_certificate(&session).unwrap(), certificate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pinned_client_refuses_another_certificate_naming_both_thumbprints() {
|
||||
let (certificate, port) = server();
|
||||
let pin = Pin::sha1(&sha1(b"another certificate")).unwrap();
|
||||
let client = Client::new(&Trust::Pin(pin.clone())).unwrap();
|
||||
let mut stream = connect("127.0.0.1", port).unwrap();
|
||||
|
||||
let error = client.handshake(&mut stream, "127.0.0.1").unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
format!(
|
||||
"certificate thumbprint mismatch: expected {pin}, the host presented {}",
|
||||
sha1(&certificate)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_self_signed_certificate_is_its_own_anchor_but_needs_the_right_name() {
|
||||
let (certificate, port) = server();
|
||||
let client = Client::new(&Trust::Anchors(vec![certificate])).unwrap();
|
||||
let mut stream = connect("127.0.0.1", port).unwrap();
|
||||
|
||||
let error = client.handshake(&mut stream, "127.0.0.1").unwrap_err();
|
||||
|
||||
assert!(matches!(error, Error::TlsHandshake(_)), "{error:?}");
|
||||
assert!(error.to_string().starts_with("tls handshake failed: "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_bundle_is_refused_before_connecting() {
|
||||
let error = Client::new(&Trust::Anchors(Vec::new())).unwrap_err();
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"no trust anchor: the bundle holds no certificate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_system_roots_refuse_a_self_signed_certificate() {
|
||||
let (_, port) = server();
|
||||
let client = Client::new(&Trust::System).unwrap();
|
||||
let mut stream = connect("127.0.0.1", port).unwrap();
|
||||
|
||||
let error = client.handshake(&mut stream, "127.0.0.1").unwrap_err();
|
||||
|
||||
assert!(matches!(error, Error::TlsHandshake(_)), "{error:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_timeout_is_described_as_such_and_other_errors_keep_their_message() {
|
||||
let timeout = io::Error::from(io::ErrorKind::WouldBlock);
|
||||
assert_eq!(describe(&timeout), "timed out");
|
||||
let timeout = io::Error::from(io::ErrorKind::TimedOut);
|
||||
assert_eq!(describe(&timeout), "timed out");
|
||||
let other = io::Error::other("boom");
|
||||
assert_eq!(describe(&other), "boom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_applies_the_io_timeout_to_both_directions() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
|
||||
let stream = connect("127.0.0.1", port).unwrap();
|
||||
|
||||
assert_eq!(stream.read_timeout().unwrap(), Some(IO_TIMEOUT));
|
||||
assert_eq!(stream.write_timeout().unwrap(), Some(IO_TIMEOUT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_port_is_reported_as_a_connect_failure() {
|
||||
let port = TcpListener::bind("127.0.0.1:0")
|
||||
.unwrap()
|
||||
.local_addr()
|
||||
.unwrap()
|
||||
.port();
|
||||
|
||||
let error = connect("127.0.0.1", port).unwrap_err();
|
||||
|
||||
assert!(matches!(error, Error::Connect(_)), "{error:?}");
|
||||
assert!(error.to_string().starts_with("could not connect: "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_invalid_host_name_is_refused_before_any_byte_is_sent() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let mut stream = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
|
||||
|
||||
let error = capture(&mut stream, "not a name").unwrap_err();
|
||||
|
||||
assert!(matches!(error, Error::HostName(_)), "{error:?}");
|
||||
assert!(error.to_string().starts_with("invalid host name: "));
|
||||
drop(stream);
|
||||
let (mut peer, _) = listener.accept().unwrap();
|
||||
let mut received = Vec::new();
|
||||
peer.read_to_end(&mut received).unwrap();
|
||||
assert!(received.is_empty(), "bytes were sent: {received:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! The transcript `--verbose` writes to standard error.
|
||||
//!
|
||||
//! The transcript's lines are formatted here and nowhere else, so what it may
|
||||
//! say is checked in one place: no password, ticket or cookie is ever passed
|
||||
//! in, and the dialogue line that carries the ticket is rendered with a
|
||||
//! placeholder.
|
||||
|
||||
use std::io::{self, Write};
|
||||
use std::time::Duration;
|
||||
|
||||
use rustls::{CipherSuite, ProtocolVersion};
|
||||
|
||||
/// Bytes served between two progress lines: one gibibyte.
|
||||
pub const PROGRESS_STEP: u64 = 1 << 30;
|
||||
|
||||
/// Where a session's lines go: standard error when enabled, nowhere otherwise.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Transcript {
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl Transcript {
|
||||
/// A transcript that writes its lines when `enabled`.
|
||||
#[must_use]
|
||||
pub const fn new(enabled: bool) -> Transcript {
|
||||
Transcript { enabled }
|
||||
}
|
||||
|
||||
/// A management call, the status it was answered with, and how long it took.
|
||||
pub fn call(&self, name: &str, status: u16, took: Duration) {
|
||||
self.line(&format!("{name}: {status} in {} ms", took.as_millis()));
|
||||
}
|
||||
|
||||
/// The capability list the data port greeted with.
|
||||
pub fn greeting(&self, capabilities: &[String]) {
|
||||
self.line(&format!("greeting: {}", capabilities.join(", ")));
|
||||
}
|
||||
|
||||
/// The protocol version and cipher suite the TLS session at `place` negotiated.
|
||||
pub fn tls(&self, place: &str, version: ProtocolVersion, suite: CipherSuite) {
|
||||
self.line(&format!("{place} tls {version:?} {suite:?}"));
|
||||
}
|
||||
|
||||
/// A dialogue line sent to the host, without its line end; never the ticket.
|
||||
pub fn sent(&self, line: &str) {
|
||||
self.line(&format!("> {line}"));
|
||||
}
|
||||
|
||||
/// A dialogue reply the host sent, without its line end.
|
||||
pub fn received(&self, line: &str) {
|
||||
self.line(&format!("< {line}"));
|
||||
}
|
||||
|
||||
/// A handshake message of type `kind` sent.
|
||||
pub fn handshake_sent(&self, kind: u32) {
|
||||
self.line(&format!("> handshake type {kind}"));
|
||||
}
|
||||
|
||||
/// A handshake message of type `kind` received.
|
||||
pub fn handshake_received(&self, kind: u32) {
|
||||
self.line(&format!("< handshake type {kind}"));
|
||||
}
|
||||
|
||||
/// The disk as the host opened it: its capacity and sector size in bytes.
|
||||
pub fn opened(&self, capacity: u64, sector_size: u32) {
|
||||
self.line(&format!(
|
||||
"open: {capacity} bytes, sector size {sector_size}"
|
||||
));
|
||||
}
|
||||
|
||||
/// An NBD option by number and what it was answered.
|
||||
pub fn option(&self, option: u32, reply: &str) {
|
||||
self.line(&format!("nbd option {option}: {reply}"));
|
||||
}
|
||||
|
||||
/// The export the client entered, by size.
|
||||
pub fn export(&self, size: u64) {
|
||||
self.line(&format!("nbd export: {size} bytes"));
|
||||
}
|
||||
|
||||
/// Progress: whole gibibytes served, wire bytes per disk byte so far, the depth in use.
|
||||
pub fn progress(&self, served: u64, wire: u64, logical: u64, depth: usize) {
|
||||
self.line(&format!(
|
||||
"progress: served {} GiB, compression ratio {}, depth {depth}",
|
||||
served / PROGRESS_STEP,
|
||||
ratio(wire, logical)
|
||||
));
|
||||
}
|
||||
|
||||
/// The run's end: bytes served, wire bytes, host reads, time since negotiation began.
|
||||
pub fn summary(&self, served: u64, wire: u64, reads: u64, elapsed: Duration) {
|
||||
self.line(&format!(
|
||||
"summary: bytes served {served}, wire bytes {wire}, host reads {reads}, elapsed {:.1} s",
|
||||
elapsed.as_secs_f64()
|
||||
));
|
||||
}
|
||||
|
||||
fn line(self, text: &str) {
|
||||
if self.enabled {
|
||||
// A line standard error cannot take is dropped: the disk stream on
|
||||
// standard output must not stop because the transcript's reader left.
|
||||
let _ = writeln!(io::stderr().lock(), "{text}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `wire` over `logical` with two decimals, in integer arithmetic; 0.00 before any byte.
|
||||
fn ratio(wire: u64, logical: u64) -> String {
|
||||
let hundredths = wire.saturating_mul(100) / logical.max(1);
|
||||
format!("{}.{:02}", hundredths / 100, hundredths % 100)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_ratio_has_two_decimals_and_is_zero_before_any_byte() {
|
||||
for (wire, logical, expected) in [
|
||||
(0, 0, "0.00"),
|
||||
(42, 100, "0.42"),
|
||||
(100, 100, "1.00"),
|
||||
(1_234, 1_000, "1.23"),
|
||||
(5, 1_000, "0.00"),
|
||||
(3, 2, "1.50"),
|
||||
] {
|
||||
assert_eq!(ratio(wire, logical), expected, "{wire}/{logical}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Holds the binary that ships next to the sources.
|
||||
//!
|
||||
//! `@xen-orchestra/vmware-explorer` runs the `vectura` binary of the crate
|
||||
//! root, so it is the build of the version of Cargo.toml and it stays
|
||||
//! executable. scripts/release.sh bumps the version and rebuilds the binary
|
||||
//! in the same commit.
|
||||
|
||||
#![cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn the_binary_at_the_crate_root_runs_as_this_version() {
|
||||
let binary = Path::new(env!("CARGO_MANIFEST_DIR")).join("vectura");
|
||||
|
||||
let output = Command::new(&binary)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.expect("the binary at the crate root runs");
|
||||
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
format!("vectura {}\n", env!("CARGO_PKG_VERSION")),
|
||||
"release with `scripts/bump-pkg vectura <type>` rather than editing the version by hand"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Runs the built `vectura` binary the way an operator would.
|
||||
//!
|
||||
//! Spawning the real binary covers `main` itself, including in the mutation
|
||||
//! run.
|
||||
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn vectura(args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_vectura"))
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("the vectura binary runs")
|
||||
}
|
||||
|
||||
/// Asserts exit 2 with an explanation on standard error and empty standard output.
|
||||
fn assert_usage_error(output: &Output) {
|
||||
assert_eq!(output.status.code(), Some(2), "usage errors exit with 2");
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.starts_with("vectura: "),
|
||||
"the explanation goes to standard error: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
output.stdout.is_empty(),
|
||||
"nothing is written to standard output"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_without_a_command_is_a_usage_error() {
|
||||
assert_usage_error(&vectura(&[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_flag_is_a_usage_error() {
|
||||
let output = vectura(&["fingerprint", "--host", "esxi.test", "--bogus"]);
|
||||
assert_usage_error(&output);
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("--bogus"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_host_with_an_unparsable_port_is_a_usage_error() {
|
||||
let output = vectura(&["fingerprint", "--host", "esxi.test:notaport"]);
|
||||
assert_usage_error(&output);
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("notaport"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_goes_to_standard_output_and_succeeds() {
|
||||
let output = vectura(&["--help"]);
|
||||
assert_eq!(output.status.code(), Some(0));
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("fingerprint"));
|
||||
assert!(output.stderr.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_depth_outside_one_to_thirty_two_is_a_usage_error() {
|
||||
for depth in ["0", "33"] {
|
||||
let output = vectura(&[
|
||||
"serve",
|
||||
"--host",
|
||||
"esxi.test",
|
||||
"--user",
|
||||
"root",
|
||||
"--vm-id",
|
||||
"1",
|
||||
"--disk",
|
||||
"d",
|
||||
"--depth",
|
||||
depth,
|
||||
]);
|
||||
assert_usage_error(&output);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains(&format!("{depth} is not in 1..=32")),
|
||||
"{stderr}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_serve_help_says_the_depth_limit_is_shared_per_host() {
|
||||
let output = vectura(&["serve", "--help"]);
|
||||
assert_eq!(output.status.code(), Some(0));
|
||||
let help = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(help.contains("--depth <DEPTH>"), "{help}");
|
||||
assert!(help.contains("per host"), "{help}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transport_other_than_nfcssl_or_nfc_is_a_usage_error() {
|
||||
let output = vectura(&[
|
||||
"serve",
|
||||
"--host",
|
||||
"esxi.test",
|
||||
"--user",
|
||||
"root",
|
||||
"--vm-id",
|
||||
"1",
|
||||
"--disk",
|
||||
"d",
|
||||
"--transport",
|
||||
"tcp",
|
||||
]);
|
||||
assert_usage_error(&output);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(stderr.contains("tcp is not nfcssl or nfc"), "{stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_serve_help_lists_the_transport_defaulting_to_nfcssl_and_the_verbose_flag() {
|
||||
let output = vectura(&["serve", "--help"]);
|
||||
assert_eq!(output.status.code(), Some(0));
|
||||
let help = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(help.contains("--transport <TRANSPORT>"), "{help}");
|
||||
assert!(help.contains("[default: nfcssl]"), "{help}");
|
||||
assert!(help.contains("--verbose"), "{help}");
|
||||
assert!(help.contains("[default: 16]"), "{help}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_compression_other_than_skipz_zlib_or_none_is_a_usage_error() {
|
||||
let output = vectura(&[
|
||||
"serve",
|
||||
"--host",
|
||||
"esxi.test",
|
||||
"--user",
|
||||
"root",
|
||||
"--vm-id",
|
||||
"1",
|
||||
"--disk",
|
||||
"d",
|
||||
"--compression",
|
||||
"gzip",
|
||||
]);
|
||||
assert_usage_error(&output);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("gzip is not none, zlib or skipz"),
|
||||
"{stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_serve_help_lists_the_three_compressions_with_skipz_as_default() {
|
||||
let output = vectura(&["serve", "--help"]);
|
||||
assert_eq!(output.status.code(), Some(0));
|
||||
let help = String::from_utf8_lossy(&output.stdout);
|
||||
for text in [
|
||||
"--compression <COMPRESSION>",
|
||||
"skipz",
|
||||
"zlib",
|
||||
"none",
|
||||
"[default: skipz]",
|
||||
] {
|
||||
assert!(help.contains(text), "{text}: {help}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Holds the line coverage floor of `src/`.
|
||||
//!
|
||||
//! Reads the `cargo llvm-cov` JSON report and compares the line coverage of
|
||||
//! `src/` (minus `main.rs`) against [`FLOOR_PERCENT`].
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// The floor, in whole percent of the lines of `src/`.
|
||||
///
|
||||
/// A pull request that drops below it adds tests; the number only goes up.
|
||||
const FLOOR_PERCENT: u64 = 80;
|
||||
|
||||
/// Line totals `(covered, count)` over the files the floor applies to.
|
||||
fn src_lines(report: &Value, root: &Path) -> (u64, u64) {
|
||||
let files = report["data"][0]["files"]
|
||||
.as_array()
|
||||
.expect("an llvm-cov report lists its files");
|
||||
let src = root.join("src");
|
||||
let main = src.join("main.rs");
|
||||
let mut covered = 0;
|
||||
let mut count = 0;
|
||||
for file in files {
|
||||
let name = Path::new(file["filename"].as_str().expect("a file has a name"));
|
||||
if !name.starts_with(&src) || name == main {
|
||||
continue;
|
||||
}
|
||||
let lines = &file["summary"]["lines"];
|
||||
covered += lines["covered"]
|
||||
.as_u64()
|
||||
.expect("covered lines is an integer");
|
||||
count += lines["count"].as_u64().expect("line count is an integer");
|
||||
}
|
||||
(covered, count)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs target/coverage.json from cargo llvm-cov; CI runs it with -- --ignored"]
|
||||
fn src_line_coverage_meets_the_floor() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let report = fs::read_to_string(root.join("target/coverage.json"))
|
||||
.expect("target/coverage.json exists; run cargo llvm-cov first");
|
||||
let report: Value = serde_json::from_str(&report).expect("the report is JSON");
|
||||
|
||||
let (covered, count) = src_lines(&report, root);
|
||||
if count == 0 {
|
||||
// Nothing executable under src/ yet: there is no percentage to hold.
|
||||
return;
|
||||
}
|
||||
let percent = covered * 100 / count;
|
||||
assert!(
|
||||
covered * 100 >= FLOOR_PERCENT * count,
|
||||
"line coverage of src/ is {percent}% ({covered}/{count}), below the {FLOOR_PERCENT}% \
|
||||
floor; add tests, do not lower the floor"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn main_rs_and_files_outside_src_are_left_out() {
|
||||
let root = Path::new("/repo");
|
||||
let report = serde_json::json!({ "data": [{ "files": [
|
||||
{ "filename": "/repo/src/lib.rs", "summary": { "lines": { "covered": 8, "count": 10 } } },
|
||||
{ "filename": "/repo/src/nfc/record.rs", "summary": { "lines": { "covered": 1, "count": 2 } } },
|
||||
{ "filename": "/repo/src/main.rs", "summary": { "lines": { "covered": 0, "count": 50 } } },
|
||||
{ "filename": "/repo/tests/cli.rs", "summary": { "lines": { "covered": 0, "count": 50 } } }
|
||||
] }] });
|
||||
|
||||
assert_eq!(src_lines(&report, root), (9, 12));
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Holds the "no escape hatch" rules in place.
|
||||
//!
|
||||
//! Every file under `src/` is scanned for lint suppressions and for
|
||||
//! `.unwrap()` / `.expect(` outside a `#[cfg(test)]` region; `tests/` is not
|
||||
//! scanned. The scan is textual on purpose: it reads what a reviewer reads.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A rule violation: which file, which line (1-based), what was found.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct Finding {
|
||||
path: PathBuf,
|
||||
line: usize,
|
||||
what: &'static str,
|
||||
}
|
||||
|
||||
/// Lint suppressions, refused anywhere in `src/`, test regions included.
|
||||
///
|
||||
/// A `cfg_attr` that carries one is refused too; a plain `cfg_attr` is not.
|
||||
const REFUSED_EVERYWHERE: [&str; 4] = ["#[allow(", "#![allow(", "#[expect(", "#![expect("];
|
||||
|
||||
/// Panics on `Option`/`Result`, refused outside `#[cfg(test)]` regions.
|
||||
const REFUSED_OUTSIDE_TESTS: [&str; 2] = [".unwrap()", ".expect("];
|
||||
|
||||
/// The source files to scan: every `.rs` file under `dir`, recursively.
|
||||
fn rust_files(dir: &Path) -> Vec<PathBuf> {
|
||||
let mut files = Vec::new();
|
||||
let entries = fs::read_dir(dir).unwrap_or_else(|error| panic!("{}: {error}", dir.display()));
|
||||
for entry in entries {
|
||||
let path = entry.expect("directory entry is readable").path();
|
||||
if path.is_dir() {
|
||||
files.extend(rust_files(&path));
|
||||
} else if path.extension().is_some_and(|extension| extension == "rs") {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
files
|
||||
}
|
||||
|
||||
/// Scans one file's text and reports every refused construct.
|
||||
///
|
||||
/// A `#[cfg(test)]` attribute opens a region, from the next `{` to its matching
|
||||
/// `}`, exempt from [`REFUSED_OUTSIDE_TESTS`]. Line comments are stripped
|
||||
/// first. Braces in strings can only lengthen a region, never hide a finding.
|
||||
fn scan(path: &Path, text: &str) -> Vec<Finding> {
|
||||
let mut findings = Vec::new();
|
||||
let mut depth = 0_usize;
|
||||
let mut region_pending = false;
|
||||
let mut in_region = false;
|
||||
|
||||
for (index, raw) in text.lines().enumerate() {
|
||||
let code = raw.split("//").next().unwrap_or_default();
|
||||
if code.trim_start().starts_with("#[cfg(test)]") {
|
||||
region_pending = true;
|
||||
}
|
||||
for what in REFUSED_EVERYWHERE {
|
||||
if code.contains(what) {
|
||||
findings.push(Finding {
|
||||
path: path.to_path_buf(),
|
||||
line: index + 1,
|
||||
what,
|
||||
});
|
||||
}
|
||||
}
|
||||
if code.contains("cfg_attr(") && (code.contains("allow(") || code.contains("expect(")) {
|
||||
findings.push(Finding {
|
||||
path: path.to_path_buf(),
|
||||
line: index + 1,
|
||||
what: "cfg_attr(",
|
||||
});
|
||||
}
|
||||
if !in_region {
|
||||
for what in REFUSED_OUTSIDE_TESTS {
|
||||
if code.contains(what) {
|
||||
findings.push(Finding {
|
||||
path: path.to_path_buf(),
|
||||
line: index + 1,
|
||||
what,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for character in code.chars() {
|
||||
match character {
|
||||
'{' => {
|
||||
if region_pending {
|
||||
region_pending = false;
|
||||
in_region = true;
|
||||
depth = 0;
|
||||
}
|
||||
depth += 1;
|
||||
}
|
||||
'}' => {
|
||||
depth = depth.saturating_sub(1);
|
||||
if in_region && depth == 0 {
|
||||
in_region = false;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
findings
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn src_contains_no_forbidden_tool() {
|
||||
let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut findings = Vec::new();
|
||||
for path in rust_files(&src) {
|
||||
let text =
|
||||
fs::read_to_string(&path).unwrap_or_else(|error| panic!("{}: {error}", path.display()));
|
||||
findings.extend(scan(&path, &text));
|
||||
}
|
||||
|
||||
assert!(
|
||||
findings.is_empty(),
|
||||
"forbidden constructs under src/ (fix the cause, do not suppress the warning):\n{}",
|
||||
findings
|
||||
.iter()
|
||||
.map(|finding| format!(
|
||||
" {}:{}: {}",
|
||||
finding.path.display(),
|
||||
finding.line,
|
||||
finding.what
|
||||
))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lint_suppression_is_found_anywhere() {
|
||||
let text = "#![allow(dead_code)]\n\n#[cfg(test)]\nmod tests {\n #[allow(unused)]\n fn f() {}\n}\n";
|
||||
|
||||
let found: Vec<_> = scan(Path::new("x.rs"), text)
|
||||
.into_iter()
|
||||
.map(|f| (f.line, f.what))
|
||||
.collect();
|
||||
|
||||
assert_eq!(found, [(1, "#![allow("), (5, "#[allow(")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panic_on_option_is_found_outside_a_test_region_only() {
|
||||
let text = "fn f() -> u8 {\n Some(1).unwrap()\n}\n\n#[cfg(test)]\nmod tests {\n #[test]\n fn g() {\n Some(1).unwrap();\n Ok::<(), ()>(()).expect(\"fine here\");\n }\n}\n\nfn h() {\n None::<u8>.expect(\"back outside\");\n}\n";
|
||||
|
||||
let found: Vec<_> = scan(Path::new("x.rs"), text)
|
||||
.into_iter()
|
||||
.map(|f| (f.line, f.what))
|
||||
.collect();
|
||||
|
||||
assert_eq!(found, [(2, ".unwrap()"), (15, ".expect(")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cfg_attr_is_refused_only_when_it_carries_a_suppression() {
|
||||
let text = "#[cfg_attr(test, derive(PartialEq))]\nstruct A;\n#[cfg_attr(test, allow(dead_code))]\nstruct B;\n";
|
||||
|
||||
let found: Vec<_> = scan(Path::new("x.rs"), text)
|
||||
.into_iter()
|
||||
.map(|f| (f.line, f.what))
|
||||
.collect();
|
||||
|
||||
assert_eq!(found, [(3, "cfg_attr(")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_comment_about_unwrap_is_not_a_finding() {
|
||||
let text = "// never call .unwrap() here\nfn f() {} // .expect( in a trailing comment\n";
|
||||
|
||||
assert!(scan(Path::new("x.rs"), text).is_empty());
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! Holds the live tests to their rules: ignored, and in one file.
|
||||
//!
|
||||
//! The live tests reach the lab hosts, so they must stay out of every
|
||||
//! `cargo test` that lacks `--ignored`, and stay in the one file this test
|
||||
//! reads.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The one file that may read the lab.
|
||||
const LIVE: &str = "tests/process/live.rs";
|
||||
|
||||
/// The text of the file at `relative`, from the crate root.
|
||||
fn read(relative: &Path) -> String {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(relative);
|
||||
fs::read_to_string(&path).unwrap_or_else(|error| panic!("{}: {error}", path.display()))
|
||||
}
|
||||
|
||||
/// Every `.rs` file under `dir`, recursively, sorted.
|
||||
fn rust_files(dir: &Path) -> Vec<PathBuf> {
|
||||
let mut files = Vec::new();
|
||||
let entries = fs::read_dir(dir).unwrap_or_else(|error| panic!("{}: {error}", dir.display()));
|
||||
for entry in entries {
|
||||
let path = entry.expect("directory entry is readable").path();
|
||||
if path.is_dir() {
|
||||
files.extend(rust_files(&path));
|
||||
} else if path.extension().is_some_and(|extension| extension == "rs") {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
files
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_live_test_is_ignored() {
|
||||
let live = read(Path::new(LIVE));
|
||||
let tests = live
|
||||
.match_indices("#[test]")
|
||||
.map(|(at, _)| at)
|
||||
.collect::<Vec<_>>();
|
||||
assert!(!tests.is_empty(), "{LIVE} holds no test");
|
||||
for at in tests {
|
||||
let next = live[at + "#[test]".len()..].trim_start();
|
||||
assert!(
|
||||
next.starts_with("#[ignore"),
|
||||
"the test at byte {at} of {LIVE} is not ignored"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_source_outside_the_live_file_reads_the_lab() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let files = rust_files(&root.join("src"))
|
||||
.into_iter()
|
||||
.chain(rust_files(&root.join("tests")));
|
||||
for path in files {
|
||||
if path.ends_with(LIVE) || path.ends_with(file!()) {
|
||||
continue;
|
||||
}
|
||||
let text =
|
||||
fs::read_to_string(&path).unwrap_or_else(|error| panic!("{}: {error}", path.display()));
|
||||
assert!(
|
||||
!text.contains("VECTURA_LAB_"),
|
||||
"{} reads the lab outside {LIVE}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Runs `vectura fingerprint` against a mock host on loopback.
|
||||
|
||||
use std::net::TcpListener;
|
||||
use std::process::{Command, Output};
|
||||
|
||||
use crate::support::{DataPort, GREETING, GREETING_WITHOUT_SHA256, Host, Observed, text};
|
||||
|
||||
/// Runs the built binary with `args` and waits for it.
|
||||
fn vectura(args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_vectura"))
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("the vectura binary runs")
|
||||
}
|
||||
|
||||
/// Runs `vectura fingerprint` against `host`.
|
||||
fn fingerprint(host: &Host, verbose: bool) -> Output {
|
||||
let management = format!("127.0.0.1:{}", host.management_port);
|
||||
let data = host.data_port.to_string();
|
||||
let mut args = vec!["fingerprint", "--host", &management, "--nfc-port", &data];
|
||||
if verbose {
|
||||
args.push("--verbose");
|
||||
}
|
||||
vectura(&args)
|
||||
}
|
||||
|
||||
/// The four lines `fingerprint` must print for `host`, management port first.
|
||||
fn expected_stdout(host: &Host) -> String {
|
||||
let (sha256, sha1) = (host.sha256(), host.sha1());
|
||||
let (management, data) = (host.management_port, host.data_port);
|
||||
format!(
|
||||
"{management} sha256 {sha256}\n{management} sha1 {sha1}\n\
|
||||
{data} sha256 {sha256}\n{data} sha1 {sha1}\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// The verbose line for `port`, built from what the mock negotiated.
|
||||
fn verbose_line(port: u16, observed: &Observed) -> String {
|
||||
let version = observed
|
||||
.version
|
||||
.as_deref()
|
||||
.expect("a version was negotiated");
|
||||
let suite = observed.suite.as_deref().expect("a suite was negotiated");
|
||||
format!("{port} tls {version} {suite}\n")
|
||||
}
|
||||
|
||||
/// A loopback port nobody listens on, released again before the test uses it.
|
||||
fn refused_port() -> u16 {
|
||||
TcpListener::bind("127.0.0.1:0")
|
||||
.expect("loopback is bindable")
|
||||
.local_addr()
|
||||
.expect("the listener has an address")
|
||||
.port()
|
||||
}
|
||||
|
||||
/// Asserts exit 1 with nothing on standard output and returns the explanation.
|
||||
///
|
||||
/// The explanation starts with `vectura: <address>: `, naming the port that failed.
|
||||
fn failure_naming(output: &Output, address: &str) -> String {
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
assert!(output.stdout.is_empty(), "stdout: {}", text(&output.stdout));
|
||||
let stderr = text(&output.stderr);
|
||||
assert!(
|
||||
stderr.starts_with(&format!("vectura: {address}: ")),
|
||||
"{stderr}"
|
||||
);
|
||||
stderr
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_prints_both_thumbprints_management_port_first() {
|
||||
let host = Host::with_data_port(DataPort::Greeting(GREETING));
|
||||
|
||||
let output = fingerprint(&host, false);
|
||||
|
||||
assert_eq!(
|
||||
output.status.code(),
|
||||
Some(0),
|
||||
"stderr: {}",
|
||||
text(&output.stderr)
|
||||
);
|
||||
assert_eq!(text(&output.stdout), expected_stdout(&host));
|
||||
assert!(output.stderr.is_empty(), "stderr: {}", text(&output.stderr));
|
||||
for observed in [host.management(), host.data()] {
|
||||
assert!(observed.handshake_completed, "{observed:?}");
|
||||
assert_eq!(
|
||||
observed.bytes_after_tls_handshake, 0,
|
||||
"nothing is sent after the TLS handshake: {observed:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verbose_adds_the_negotiated_version_and_suite_on_standard_error_only() {
|
||||
let host = Host::with_data_port(DataPort::Greeting(GREETING));
|
||||
|
||||
let output = fingerprint(&host, true);
|
||||
|
||||
assert_eq!(
|
||||
output.status.code(),
|
||||
Some(0),
|
||||
"stderr: {}",
|
||||
text(&output.stderr)
|
||||
);
|
||||
assert_eq!(text(&output.stdout), expected_stdout(&host));
|
||||
let expected = verbose_line(host.management_port, &host.management())
|
||||
+ &verbose_line(host.data_port, &host.data());
|
||||
assert_eq!(text(&output.stderr), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_greeting_without_sha256_support_is_refused_before_tls_starts() {
|
||||
let host = Host::with_data_port(DataPort::Greeting(GREETING_WITHOUT_SHA256));
|
||||
|
||||
let output = fingerprint(&host, false);
|
||||
|
||||
let stderr = failure_naming(&output, &format!("127.0.0.1:{}", host.data_port));
|
||||
assert!(stderr.contains("\"SHA256 supported\""), "{stderr}");
|
||||
assert!(
|
||||
stderr.contains(
|
||||
"SSL Required, ServerDaemonProtocol:SOAP, MKSDisplayProtocol:VNC, \
|
||||
VMXARGS supported, NFCSSL supported"
|
||||
),
|
||||
"the announced list is quoted: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
!host.data().handshake_started,
|
||||
"no TLS is started on the data port"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_host_that_closes_without_a_greeting_fails_naming_the_greeting() {
|
||||
let host = Host::with_data_port(DataPort::Silent);
|
||||
|
||||
let output = fingerprint(&host, false);
|
||||
|
||||
let stderr = failure_naming(&output, &format!("127.0.0.1:{}", host.data_port));
|
||||
assert!(stderr.contains("greeting"), "{stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_management_port_fails_naming_its_address() {
|
||||
let address = format!("127.0.0.1:{}", refused_port());
|
||||
|
||||
let output = vectura(&["fingerprint", "--host", &address]);
|
||||
|
||||
let stderr = failure_naming(&output, &address);
|
||||
assert!(stderr.contains("connect"), "{stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_data_port_fails_naming_its_address() {
|
||||
let host = Host::with_data_port(DataPort::Greeting(GREETING));
|
||||
let management = format!("127.0.0.1:{}", host.management_port);
|
||||
let data = refused_port();
|
||||
|
||||
let output = vectura(&[
|
||||
"fingerprint",
|
||||
"--host",
|
||||
&management,
|
||||
"--nfc-port",
|
||||
&data.to_string(),
|
||||
]);
|
||||
|
||||
failure_naming(&output, &format!("127.0.0.1:{data}"));
|
||||
assert!(host.management().handshake_completed);
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
//! Live tests against the lab hosts, ignored and run by hand.
|
||||
//!
|
||||
//! `cargo test --locked -- --ignored` runs them. A test that serves a disk
|
||||
//! takes its lab from `VECTURA_LAB_HOST`, `VECTURA_LAB_USER`, `VECTURA_LAB_VM`,
|
||||
//! `VECTURA_LAB_DISK` and `VECTURA_LAB_THUMBPRINT`; the legacy check takes
|
||||
//! `VECTURA_LAB_LEGACY_HOST` alone. A test skips, naming what is missing, when
|
||||
//! a variable is not set. The child inherits `VECTURA_PASSWORD`; no test reads it.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{self, Read, Seek, SeekFrom, Write};
|
||||
use std::panic::{self, AssertUnwindSafe};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::serve::{READ_ONLY, SECTOR, disconnect, finish, negotiate, read_at};
|
||||
use crate::support::text;
|
||||
|
||||
/// The longest read the export accepts: the oracle's step and each sampled range.
|
||||
const READ: u64 = 32 << 20;
|
||||
|
||||
/// The idle gap when `VECTURA_LAB_IDLE_GAP` is not set.
|
||||
///
|
||||
/// Five minutes: longer than any pause seen between two reads of one session
|
||||
/// and inside the 30 minutes after which hostd expires an abandoned session.
|
||||
const DEFAULT_IDLE_GAP: Duration = Duration::from_secs(300);
|
||||
|
||||
/// The transcript line `serve` writes when the client negotiated with `NBD_OPT_GO`.
|
||||
const GO: &str = "nbd option 7: info\n";
|
||||
|
||||
/// The bytes the libnbd program writes for the export size: one big-endian `u64`.
|
||||
const SIZE_BYTES: usize = std::mem::size_of::<u64>();
|
||||
|
||||
/// Writes a line straight to standard error, which the test harness does not capture.
|
||||
///
|
||||
/// A skip and a measurement show up without `--nocapture`.
|
||||
fn report(line: &str) {
|
||||
let _ = writeln!(io::stderr(), "{line}");
|
||||
}
|
||||
|
||||
/// Whether `variable` is set, with a skip line naming it when it is not.
|
||||
///
|
||||
/// The value itself is never read, so `VECTURA_PASSWORD` stays in the environment.
|
||||
fn present(variable: &str) -> bool {
|
||||
let present = std::env::var_os(variable).is_some();
|
||||
if !present {
|
||||
report(&format!("skipping: {variable} is not set"));
|
||||
}
|
||||
present
|
||||
}
|
||||
|
||||
/// The value of `variable`, or `None` after a skip line naming it.
|
||||
fn lab(variable: &str) -> Option<String> {
|
||||
present(variable).then(|| std::env::var(variable).expect("a set variable is unicode"))
|
||||
}
|
||||
|
||||
/// The lab disk the tests serve.
|
||||
struct Lab {
|
||||
host: String,
|
||||
user: String,
|
||||
vm: String,
|
||||
disk: String,
|
||||
thumbprint: String,
|
||||
}
|
||||
|
||||
impl Lab {
|
||||
/// The lab the environment describes, or `None` after naming every missing variable.
|
||||
fn from_env() -> Option<Self> {
|
||||
let host = lab("VECTURA_LAB_HOST");
|
||||
let user = lab("VECTURA_LAB_USER");
|
||||
let vm = lab("VECTURA_LAB_VM");
|
||||
let disk = lab("VECTURA_LAB_DISK");
|
||||
let thumbprint = lab("VECTURA_LAB_THUMBPRINT");
|
||||
let password = present("VECTURA_PASSWORD");
|
||||
let lab = Self {
|
||||
host: host?,
|
||||
user: user?,
|
||||
vm: vm?,
|
||||
disk: disk?,
|
||||
thumbprint: thumbprint?,
|
||||
};
|
||||
password.then_some(lab)
|
||||
}
|
||||
|
||||
/// The `serve` arguments for the lab disk, followed by `extra`.
|
||||
fn arguments(&self, extra: &[&str]) -> Vec<String> {
|
||||
[
|
||||
"serve",
|
||||
"--host",
|
||||
&self.host,
|
||||
"--user",
|
||||
&self.user,
|
||||
"--vm-id",
|
||||
&self.vm,
|
||||
"--disk",
|
||||
&self.disk,
|
||||
"--thumbprint",
|
||||
&self.thumbprint,
|
||||
]
|
||||
.into_iter()
|
||||
.chain(extra.iter().copied())
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Spawns `serve` on the lab disk with `extra` flags, its three streams piped.
|
||||
fn serve(&self, extra: &[&str]) -> Child {
|
||||
Command::new(env!("CARGO_BIN_EXE_vectura"))
|
||||
.args(self.arguments(extra))
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("the vectura binary runs")
|
||||
}
|
||||
}
|
||||
|
||||
/// The raw image `VECTURA_LAB_REFERENCE` names and its length, or `None` after a skip line.
|
||||
///
|
||||
/// The image is produced once, with the lab VM powered off so the disk does not
|
||||
/// change under the tests: the VMDK's descriptor and every extent it names, and
|
||||
/// for a snapshot its parents' too, are downloaded through the datastore browser
|
||||
/// (`https://<host>/folder/<path>?dcPath=ha-datacenter&dsName=<datastore>`) into
|
||||
/// one directory, then `qemu-img convert -O raw <descriptor>.vmdk <image>`
|
||||
/// flattens them, following the parent hints of the descriptors.
|
||||
fn reference() -> Option<(File, u64)> {
|
||||
let path = lab("VECTURA_LAB_REFERENCE")?;
|
||||
let file = File::open(&path).unwrap_or_else(|error| panic!("{path}: {error}"));
|
||||
let length = file.metadata().expect("the reference has metadata").len();
|
||||
Some((file, length))
|
||||
}
|
||||
|
||||
/// Asserts `data` is what the reference holds at `offset`, naming the first difference.
|
||||
fn matches_reference(reference: &mut File, offset: u64, data: &[u8]) {
|
||||
reference
|
||||
.seek(SeekFrom::Start(offset))
|
||||
.expect("the reference seeks");
|
||||
let mut expected = vec![0; data.len()];
|
||||
reference
|
||||
.read_exact(&mut expected)
|
||||
.expect("the reference holds the range");
|
||||
if let Some(index) = data
|
||||
.iter()
|
||||
.zip(&expected)
|
||||
.position(|(served, held)| served != held)
|
||||
{
|
||||
let at = offset + u64::try_from(index).expect("an index fits");
|
||||
panic!(
|
||||
"byte {at} differs: served {:#04x}, reference {:#04x}",
|
||||
data[index], expected[index]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Three `READ`-long ranges of a `size`-byte disk: the start, a sector-aligned middle, the end.
|
||||
fn ranges(size: u64) -> [u64; 3] {
|
||||
assert!(
|
||||
size >= 2 * READ,
|
||||
"a disk of {size} bytes is too small to sample"
|
||||
);
|
||||
[0, size / 2 / SECTOR * SECTOR, size - READ]
|
||||
}
|
||||
|
||||
/// Reads `length` bytes at `offset`, which must succeed.
|
||||
fn read(child: &mut Child, cookie: u64, offset: u64, length: u64) -> Vec<u8> {
|
||||
read_at(child, cookie, offset, length)
|
||||
.unwrap_or_else(|code| panic!("read of {length} bytes at {offset}: NBD error {code}"))
|
||||
}
|
||||
|
||||
/// Disconnects, waits for the child and asserts it exited 0; returns its standard error.
|
||||
fn ends_cleanly(mut child: Child) -> String {
|
||||
disconnect(&mut child);
|
||||
let output = finish(child);
|
||||
let stderr = text(&output.stderr);
|
||||
assert_eq!(output.status.code(), Some(0), "stderr: {stderr}");
|
||||
stderr
|
||||
}
|
||||
|
||||
/// The `wire bytes` field of the verbose summary line in `stderr`.
|
||||
fn wire_bytes(stderr: &str) -> &str {
|
||||
stderr
|
||||
.lines()
|
||||
.find(|line| line.starts_with("summary: "))
|
||||
.and_then(|summary| {
|
||||
summary
|
||||
.split(", ")
|
||||
.find(|field| field.starts_with("wire bytes "))
|
||||
})
|
||||
.unwrap_or_else(|| panic!("no wire bytes in the summary:\n{stderr}"))
|
||||
}
|
||||
|
||||
/// Whether `nbdsh` is on the path, with a skip line when it is not.
|
||||
fn nbdsh_present() -> bool {
|
||||
let present = Command::new("nbdsh").arg("--version").output().is_ok();
|
||||
if !present {
|
||||
report("skipping: nbdsh (libnbd) is not on the path");
|
||||
}
|
||||
present
|
||||
}
|
||||
|
||||
/// A Python string literal holding `text`.
|
||||
fn python_string(text: &str) -> String {
|
||||
format!("'{}'", text.replace('\\', "\\\\").replace('\'', "\\'"))
|
||||
}
|
||||
|
||||
/// Serves a lab disk over the real protocol and checks the export.
|
||||
#[test]
|
||||
#[ignore = "needs a lab host"]
|
||||
fn a_lab_host_exports_its_disk_read_only_and_the_session_ends_cleanly() {
|
||||
let Some(lab) = Lab::from_env() else { return };
|
||||
|
||||
let mut child = lab.serve(&[]);
|
||||
let export = negotiate(&mut child, false);
|
||||
let stderr = ends_cleanly(child);
|
||||
|
||||
assert!(stderr.is_empty(), "stderr: {stderr}");
|
||||
assert!(export.size > 0, "{export:?}");
|
||||
assert_eq!(export.flags, READ_ONLY, "{export:?}");
|
||||
}
|
||||
|
||||
/// Compares the whole served disk, byte for byte, to the `qemu-img` raw image.
|
||||
///
|
||||
/// The lab VM is powered off and the image is the one [`reference`] describes.
|
||||
/// One sector is read first, then the disk in `READ`-long steps.
|
||||
#[test]
|
||||
#[ignore = "needs a lab host and the raw image in VECTURA_LAB_REFERENCE"]
|
||||
fn the_served_disk_matches_the_qemu_img_raw_image_byte_for_byte() {
|
||||
let Some(lab) = Lab::from_env() else { return };
|
||||
let Some((mut reference, length)) = reference() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut child = lab.serve(&[]);
|
||||
let export = negotiate(&mut child, false);
|
||||
assert_eq!(export.size, length, "export size against the reference");
|
||||
let sector = read(&mut child, 0, 0, SECTOR);
|
||||
matches_reference(&mut reference, 0, §or);
|
||||
let mut offset = 0;
|
||||
while offset < length {
|
||||
let step = READ.min(length - offset);
|
||||
let data = read(&mut child, offset, offset, step);
|
||||
matches_reference(&mut reference, offset, &data);
|
||||
offset += step;
|
||||
}
|
||||
let stderr = ends_cleanly(child);
|
||||
|
||||
assert!(stderr.is_empty(), "stderr: {stderr}");
|
||||
}
|
||||
|
||||
/// Drives the binary with libnbd, an NBD client the crate shares no code with.
|
||||
///
|
||||
/// `nbdsh` spawns `serve --verbose` through `connect_command`, so the transcript
|
||||
/// on its standard error shows the `NBD_OPT_GO` the client sent; it reads one
|
||||
/// range at the start, in the middle and at the end of the disk, and each one
|
||||
/// must match the `qemu-img` raw image.
|
||||
#[test]
|
||||
#[ignore = "needs a lab host, the raw image in VECTURA_LAB_REFERENCE and nbdsh"]
|
||||
fn libnbd_negotiates_with_go_and_reads_the_start_the_middle_and_the_end() {
|
||||
let Some(lab) = Lab::from_env() else { return };
|
||||
let Some((mut reference, length)) = reference() else {
|
||||
return;
|
||||
};
|
||||
if !nbdsh_present() {
|
||||
return;
|
||||
}
|
||||
let offsets = ranges(length);
|
||||
let argv = std::iter::once(env!("CARGO_BIN_EXE_vectura").to_owned())
|
||||
.chain(lab.arguments(&["--verbose"]))
|
||||
.map(|argument| python_string(&argument))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let program = format!(
|
||||
"import sys
|
||||
h.connect_command([{argv}])
|
||||
out = sys.stdout.buffer
|
||||
out.write(h.get_size().to_bytes({SIZE_BYTES}, 'big'))
|
||||
for offset in ({}):
|
||||
out.write(h.pread({READ}, offset))
|
||||
h.shutdown()
|
||||
",
|
||||
offsets.map(|offset| offset.to_string()).join(", ")
|
||||
);
|
||||
|
||||
let output = Command::new("nbdsh")
|
||||
.args(["-c", &program])
|
||||
.output()
|
||||
.expect("nbdsh runs");
|
||||
|
||||
let stderr = text(&output.stderr);
|
||||
assert_eq!(output.status.code(), Some(0), "nbdsh: {stderr}");
|
||||
assert!(
|
||||
stderr.contains(GO),
|
||||
"no NBD_OPT_GO in the transcript:\n{stderr}"
|
||||
);
|
||||
let (size, data) = output.stdout.split_at(SIZE_BYTES);
|
||||
assert_eq!(
|
||||
u64::from_be_bytes(size.try_into().expect("eight bytes")),
|
||||
length
|
||||
);
|
||||
let range = usize::try_from(READ).expect("a range fits");
|
||||
assert_eq!(data.len(), 3 * range, "three ranges came back");
|
||||
for (offset, bytes) in offsets.into_iter().zip(data.chunks(range)) {
|
||||
matches_reference(&mut reference, offset, bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// Samples three ranges in `skipz`, `zlib` and `none` and reports each mode's wire bytes.
|
||||
///
|
||||
/// The modes must serve identical bytes; the verbose summary of each run
|
||||
/// says how many bytes crossed the wire for them, which is the line reported.
|
||||
#[test]
|
||||
#[ignore = "needs a lab host"]
|
||||
fn the_three_compression_modes_serve_identical_bytes_and_report_their_wire_bytes() {
|
||||
let Some(lab) = Lab::from_env() else { return };
|
||||
|
||||
let mut previous: Option<Vec<u8>> = None;
|
||||
for mode in ["skipz", "zlib", "none"] {
|
||||
let mut child = lab.serve(&["--verbose", "--compression", mode]);
|
||||
let export = negotiate(&mut child, false);
|
||||
let mut data = Vec::new();
|
||||
for (cookie, offset) in (1..).zip(ranges(export.size)) {
|
||||
data.extend(read(&mut child, cookie, offset, READ));
|
||||
}
|
||||
let stderr = ends_cleanly(child);
|
||||
report(&format!(
|
||||
"compression {mode}: {} for {} bytes served",
|
||||
wire_bytes(&stderr),
|
||||
data.len()
|
||||
));
|
||||
if let Some(previous) = &previous {
|
||||
assert!(
|
||||
*previous == data,
|
||||
"{mode} served other bytes than the mode before"
|
||||
);
|
||||
}
|
||||
previous = Some(data);
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds the disk open across an idle gap and records whether it stays readable.
|
||||
///
|
||||
/// The gap, in seconds, is `VECTURA_LAB_IDLE_GAP` and defaults to five minutes.
|
||||
/// The outcome line on standard error is the measurement: how an open disk
|
||||
/// fares without traffic, which nothing public states.
|
||||
#[test]
|
||||
#[ignore = "needs a lab host; VECTURA_LAB_IDLE_GAP sets the gap in seconds"]
|
||||
fn the_disk_stays_readable_across_the_idle_gap() {
|
||||
let Some(lab) = Lab::from_env() else { return };
|
||||
let gap = std::env::var("VECTURA_LAB_IDLE_GAP").map_or(DEFAULT_IDLE_GAP, |seconds| {
|
||||
Duration::from_secs(seconds.parse().expect("a number of seconds"))
|
||||
});
|
||||
|
||||
let mut child = lab.serve(&["--verbose"]);
|
||||
negotiate(&mut child, false);
|
||||
let first = read(&mut child, 1, 0, READ);
|
||||
thread::sleep(gap);
|
||||
let second = panic::catch_unwind(AssertUnwindSafe(|| read_at(&mut child, 2, 0, READ)));
|
||||
|
||||
let succeeded = matches!(&second, Ok(Ok(data)) if *data == first);
|
||||
let outcome = match &second {
|
||||
Ok(Ok(_)) if succeeded => "succeeded with the same bytes".to_owned(),
|
||||
Ok(Ok(_)) => "succeeded with other bytes".to_owned(),
|
||||
Ok(Err(code)) => format!("failed with NBD error {code}"),
|
||||
Err(_) => "failed: the stream closed".to_owned(),
|
||||
};
|
||||
report(&format!(
|
||||
"idle gap of {} s: second read {outcome}",
|
||||
gap.as_secs()
|
||||
));
|
||||
let stderr = if succeeded {
|
||||
ends_cleanly(child)
|
||||
} else {
|
||||
text(&finish(child).stderr)
|
||||
};
|
||||
assert!(succeeded, "second read {outcome}; transcript:\n{stderr}");
|
||||
}
|
||||
|
||||
/// A legacy host is refused at its greeting, the message listing what it announced.
|
||||
///
|
||||
/// `fingerprint` reads the port-902 greeting through the parser `serve` uses,
|
||||
/// so the refusal is the same and needs neither credentials nor a pin;
|
||||
/// `VECTURA_LAB_LEGACY_HOST` names the host as `host[:port]`.
|
||||
#[test]
|
||||
#[ignore = "needs a legacy host in VECTURA_LAB_LEGACY_HOST"]
|
||||
fn a_legacy_host_is_refused_at_the_greeting_with_its_capability_list() {
|
||||
let Some(host) = lab("VECTURA_LAB_LEGACY_HOST") else {
|
||||
return;
|
||||
};
|
||||
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_vectura"))
|
||||
.args(["fingerprint", "--host", &host])
|
||||
.output()
|
||||
.expect("the vectura binary runs");
|
||||
|
||||
let stderr = text(&output.stderr);
|
||||
assert_eq!(output.status.code(), Some(1), "{stderr}");
|
||||
assert!(
|
||||
stderr.contains("does not announce \"SHA256 supported\"; announced: ["),
|
||||
"{stderr}"
|
||||
);
|
||||
report(&format!("legacy host refused: {}", stderr.trim_end()));
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
//! Runs the built binary against a mock host on loopback.
|
||||
|
||||
mod fingerprint;
|
||||
mod live;
|
||||
mod serve;
|
||||
mod support;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,399 @@
|
||||
//! A minimal fixed-newstyle NBD client for the child's pipes.
|
||||
//!
|
||||
//! It negotiates with `NBD_OPT_EXPORT_NAME`, `NBD_OPT_GO`, `NBD_OPT_INFO`
|
||||
//! or `NBD_OPT_ABORT`, sends requests of any kind and reads simple replies.
|
||||
//! The constants come from the NBD protocol document, so the crate is not
|
||||
//! its own oracle.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
/// `NBDMAGIC`, the first eight bytes of the server greeting.
|
||||
const NBDMAGIC: u64 = 0x4E42_444D_4147_4943;
|
||||
/// `IHAVEOPT`, the next eight bytes and the start of every option.
|
||||
const IHAVEOPT: u64 = 0x4948_4156_454F_5054;
|
||||
/// The magic opening every option reply.
|
||||
const REPLY_MAGIC: u64 = 0x0003_e889_0455_65a9;
|
||||
/// Handshake flags the server may offer.
|
||||
const FLAG_FIXED_NEWSTYLE: u16 = 1;
|
||||
const FLAG_NO_ZEROES: u16 = 2;
|
||||
/// Client flags answering them.
|
||||
const FLAG_C_FIXED_NEWSTYLE: u32 = 1;
|
||||
const FLAG_C_NO_ZEROES: u32 = 2;
|
||||
/// The options.
|
||||
pub const OPT_EXPORT_NAME: u32 = 1;
|
||||
pub const OPT_ABORT: u32 = 2;
|
||||
pub const OPT_LIST: u32 = 3;
|
||||
pub const OPT_STARTTLS: u32 = 5;
|
||||
pub const OPT_INFO: u32 = 6;
|
||||
pub const OPT_GO: u32 = 7;
|
||||
pub const OPT_STRUCTURED_REPLY: u32 = 8;
|
||||
/// The option replies.
|
||||
pub const REP_ACK: u32 = 1;
|
||||
pub const REP_INFO: u32 = 3;
|
||||
pub const REP_ERR_UNSUP: u32 = 0x8000_0001;
|
||||
/// The info types.
|
||||
pub const INFO_EXPORT: u16 = 0;
|
||||
pub const INFO_BLOCK_SIZE: u16 = 3;
|
||||
/// Zero bytes after the export flags unless `NO_ZEROES` was negotiated.
|
||||
const ZEROES_LEN: usize = 124;
|
||||
/// The request magic, the commands, the one command flag, the simple reply magic.
|
||||
const REQUEST_MAGIC: u32 = 0x2560_9513;
|
||||
pub const CMD_READ: u16 = 0;
|
||||
pub const CMD_WRITE: u16 = 1;
|
||||
const CMD_DISC: u16 = 2;
|
||||
pub const CMD_FLUSH: u16 = 3;
|
||||
pub const CMD_TRIM: u16 = 4;
|
||||
pub const CMD_WRITE_ZEROES: u16 = 6;
|
||||
/// `NBD_CMD_FLAG_FUA`, meaningless on a read.
|
||||
pub const CMD_FLAG_FUA: u16 = 1;
|
||||
const SIMPLE_REPLY_MAGIC: u32 = 0x6744_6698;
|
||||
/// The errors a simple reply may carry.
|
||||
pub const EPERM: u32 = 1;
|
||||
pub const EINVAL: u32 = 22;
|
||||
|
||||
/// What the server said about the export.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Export {
|
||||
/// The export size in bytes.
|
||||
pub size: u64,
|
||||
/// The transmission flags.
|
||||
pub flags: u16,
|
||||
/// Whether the 124 zero bytes followed the flags.
|
||||
pub padded: bool,
|
||||
}
|
||||
|
||||
/// What `NBD_OPT_INFO` or `NBD_OPT_GO` reported.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Info {
|
||||
/// The export size in bytes.
|
||||
pub size: u64,
|
||||
/// The transmission flags.
|
||||
pub flags: u16,
|
||||
/// The minimum, preferred and maximum request sizes, when reported.
|
||||
pub block_size: Option<(u32, u32, u32)>,
|
||||
/// The types of the other infos, in order.
|
||||
pub other: Vec<u16>,
|
||||
}
|
||||
|
||||
/// Reads exactly enough bytes to fill `bytes`.
|
||||
fn read<R: Read>(input: &mut R, bytes: &mut [u8]) {
|
||||
input
|
||||
.read_exact(bytes)
|
||||
.expect("the server sends the bytes the protocol calls for");
|
||||
}
|
||||
|
||||
fn read_u16<R: Read>(input: &mut R) -> u16 {
|
||||
let mut bytes = [0_u8; 2];
|
||||
read(input, &mut bytes);
|
||||
u16::from_be_bytes(bytes)
|
||||
}
|
||||
|
||||
fn read_u32<R: Read>(input: &mut R) -> u32 {
|
||||
let mut bytes = [0_u8; 4];
|
||||
read(input, &mut bytes);
|
||||
u32::from_be_bytes(bytes)
|
||||
}
|
||||
|
||||
fn read_u64<R: Read>(input: &mut R) -> u64 {
|
||||
let mut bytes = [0_u8; 8];
|
||||
read(input, &mut bytes);
|
||||
u64::from_be_bytes(bytes)
|
||||
}
|
||||
|
||||
/// Writes `bytes` and flushes them to the server.
|
||||
fn write<W: Write>(output: &mut W, bytes: &[u8]) {
|
||||
output
|
||||
.write_all(bytes)
|
||||
.and_then(|()| output.flush())
|
||||
.expect("the client's bytes are written");
|
||||
}
|
||||
|
||||
/// Reads the greeting and returns the handshake flags the server offers.
|
||||
fn greeting<R: Read>(input: &mut R) -> u16 {
|
||||
assert_eq!(
|
||||
read_u64(input),
|
||||
NBDMAGIC,
|
||||
"the greeting starts with NBDMAGIC"
|
||||
);
|
||||
assert_eq!(read_u64(input), IHAVEOPT, "IHAVEOPT follows");
|
||||
let offered = read_u16(input);
|
||||
assert_ne!(
|
||||
offered & FLAG_FIXED_NEWSTYLE,
|
||||
0,
|
||||
"the server offers fixed newstyle"
|
||||
);
|
||||
offered
|
||||
}
|
||||
|
||||
/// Reads the greeting and answers it, asking `NO_ZEROES` when `no_zeroes`.
|
||||
///
|
||||
/// Returns whether the export reply will carry the 124 zero bytes.
|
||||
pub fn handshake<R: Read, W: Write>(input: &mut R, output: &mut W, no_zeroes: bool) -> bool {
|
||||
let offered = greeting(input);
|
||||
let mut flags = FLAG_C_FIXED_NEWSTYLE;
|
||||
if no_zeroes && offered & FLAG_NO_ZEROES != 0 {
|
||||
flags |= FLAG_C_NO_ZEROES;
|
||||
}
|
||||
write(output, &flags.to_be_bytes());
|
||||
flags & FLAG_C_NO_ZEROES == 0
|
||||
}
|
||||
|
||||
/// Reads the greeting and answers it with `flags` as given, whatever is offered.
|
||||
pub fn handshake_with<R: Read, W: Write>(input: &mut R, output: &mut W, flags: u32) {
|
||||
greeting(input);
|
||||
write(output, &flags.to_be_bytes());
|
||||
}
|
||||
|
||||
/// Sends the header of `option` announcing `length` bytes of data, and no data.
|
||||
pub fn option_header<W: Write>(output: &mut W, option: u32, length: u32) {
|
||||
let mut bytes = IHAVEOPT.to_be_bytes().to_vec();
|
||||
bytes.extend_from_slice(&option.to_be_bytes());
|
||||
bytes.extend_from_slice(&length.to_be_bytes());
|
||||
write(output, &bytes);
|
||||
}
|
||||
|
||||
/// Sends `option` with `data`.
|
||||
pub fn option<W: Write>(output: &mut W, option: u32, data: &[u8]) {
|
||||
let length = u32::try_from(data.len()).expect("short option data");
|
||||
option_header(output, option, length);
|
||||
write(output, data);
|
||||
}
|
||||
|
||||
/// Reads one reply to `option`: its type and its data.
|
||||
pub fn option_reply<R: Read>(input: &mut R, option: u32) -> (u32, Vec<u8>) {
|
||||
assert_eq!(
|
||||
read_u64(input),
|
||||
REPLY_MAGIC,
|
||||
"the reply starts with the reply magic"
|
||||
);
|
||||
assert_eq!(read_u32(input), option, "the reply names the option");
|
||||
let reply = read_u32(input);
|
||||
let length = read_u32(input);
|
||||
let mut data = vec![0_u8; length as usize];
|
||||
read(input, &mut data);
|
||||
(reply, data)
|
||||
}
|
||||
|
||||
/// Sends `NBD_OPT_EXPORT_NAME` for the unnamed export and reads the export.
|
||||
pub fn export_name<R: Read, W: Write>(input: &mut R, output: &mut W, padded: bool) -> Export {
|
||||
option(output, OPT_EXPORT_NAME, b"");
|
||||
let size = read_u64(input);
|
||||
let flags = read_u16(input);
|
||||
if padded {
|
||||
let mut zeroes = [0_u8; ZEROES_LEN];
|
||||
read(input, &mut zeroes);
|
||||
assert_eq!(zeroes, [0_u8; ZEROES_LEN], "the padding is zero");
|
||||
}
|
||||
Export {
|
||||
size,
|
||||
flags,
|
||||
padded,
|
||||
}
|
||||
}
|
||||
|
||||
/// Negotiates the unnamed export with `NBD_OPT_EXPORT_NAME`, asking `NO_ZEROES` when `no_zeroes`.
|
||||
pub fn negotiate<R: Read, W: Write>(input: &mut R, output: &mut W, no_zeroes: bool) -> Export {
|
||||
let padded = handshake(input, output, no_zeroes);
|
||||
export_name(input, output, padded)
|
||||
}
|
||||
|
||||
/// The data of `NBD_OPT_INFO` or `NBD_OPT_GO`: `name`, then the `requests` asked.
|
||||
pub fn info_data(name: &[u8], requests: &[u16]) -> Vec<u8> {
|
||||
let mut data = u32::try_from(name.len())
|
||||
.expect("a short name")
|
||||
.to_be_bytes()
|
||||
.to_vec();
|
||||
data.extend_from_slice(name);
|
||||
let count = u16::try_from(requests.len()).expect("a few requests");
|
||||
data.extend_from_slice(&count.to_be_bytes());
|
||||
for request in requests {
|
||||
data.extend_from_slice(&request.to_be_bytes());
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
/// Sends `NBD_OPT_INFO` or `NBD_OPT_GO` (`kind`) naming `name` and asking `requests`.
|
||||
///
|
||||
/// Returns what the server reported, or the error reply it sent instead.
|
||||
pub fn info<R: Read, W: Write>(
|
||||
input: &mut R,
|
||||
output: &mut W,
|
||||
kind: u32,
|
||||
name: &[u8],
|
||||
requests: &[u16],
|
||||
) -> Result<Info, u32> {
|
||||
option(output, kind, &info_data(name, requests));
|
||||
let mut info = Info {
|
||||
size: 0,
|
||||
flags: 0,
|
||||
block_size: None,
|
||||
other: Vec::new(),
|
||||
};
|
||||
let mut export_seen = false;
|
||||
loop {
|
||||
let (reply, data) = option_reply(input, kind);
|
||||
match reply {
|
||||
REP_ACK => {
|
||||
assert!(export_seen, "NBD_INFO_EXPORT comes before the ack");
|
||||
assert!(data.is_empty(), "the ack carries no data");
|
||||
return Ok(info);
|
||||
}
|
||||
REP_INFO => match u16::from_be_bytes([data[0], data[1]]) {
|
||||
INFO_EXPORT => {
|
||||
assert_eq!(data.len(), 12, "NBD_INFO_EXPORT is twelve bytes");
|
||||
info.size = u64::from_be_bytes(data[2..10].try_into().expect("eight bytes"));
|
||||
info.flags = u16::from_be_bytes([data[10], data[11]]);
|
||||
export_seen = true;
|
||||
}
|
||||
INFO_BLOCK_SIZE => {
|
||||
assert_eq!(data.len(), 14, "NBD_INFO_BLOCK_SIZE is fourteen bytes");
|
||||
let size = |from: usize| {
|
||||
u32::from_be_bytes(data[from..from + 4].try_into().expect("four bytes"))
|
||||
};
|
||||
info.block_size = Some((size(2), size(6), size(10)));
|
||||
}
|
||||
other => info.other.push(other),
|
||||
},
|
||||
error => return Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends `NBD_OPT_ABORT` and reads its acknowledgement.
|
||||
pub fn abort<R: Read, W: Write>(input: &mut R, output: &mut W) {
|
||||
option(output, OPT_ABORT, b"");
|
||||
let (reply, data) = option_reply(input, OPT_ABORT);
|
||||
assert_eq!(reply, REP_ACK, "the abort is acknowledged");
|
||||
assert!(data.is_empty(), "the ack carries no data");
|
||||
}
|
||||
|
||||
/// Sends `kind` with `data` and returns the type of the reply.
|
||||
pub fn refused<R: Read, W: Write>(input: &mut R, output: &mut W, kind: u32, data: &[u8]) -> u32 {
|
||||
option(output, kind, data);
|
||||
option_reply(input, kind).0
|
||||
}
|
||||
|
||||
/// Sends one request of `kind` carrying `flags`.
|
||||
pub fn request<W: Write>(
|
||||
output: &mut W,
|
||||
flags: u16,
|
||||
kind: u16,
|
||||
cookie: u64,
|
||||
offset: u64,
|
||||
length: u32,
|
||||
) {
|
||||
write(output, &request_bytes(flags, kind, cookie, offset, length));
|
||||
}
|
||||
|
||||
/// The bytes of one request of `kind` carrying `flags`.
|
||||
fn request_bytes(flags: u16, kind: u16, cookie: u64, offset: u64, length: u32) -> Vec<u8> {
|
||||
let mut request = REQUEST_MAGIC.to_be_bytes().to_vec();
|
||||
request.extend_from_slice(&flags.to_be_bytes());
|
||||
request.extend_from_slice(&kind.to_be_bytes());
|
||||
request.extend_from_slice(&cookie.to_be_bytes());
|
||||
request.extend_from_slice(&offset.to_be_bytes());
|
||||
request.extend_from_slice(&length.to_be_bytes());
|
||||
request
|
||||
}
|
||||
|
||||
/// Sends one `NBD_CMD_READ` per (cookie, offset, length), all in one write.
|
||||
pub fn requests<W: Write>(output: &mut W, reads: &[(u64, u64, u32)]) {
|
||||
let bytes = reads
|
||||
.iter()
|
||||
.flat_map(|&(cookie, offset, length)| request_bytes(0, CMD_READ, cookie, offset, length))
|
||||
.collect::<Vec<_>>();
|
||||
write(output, &bytes);
|
||||
}
|
||||
|
||||
/// Reads one reply per (cookie, length) expected, in the order they arrive.
|
||||
///
|
||||
/// Each is the cookie with the bytes read, or the error the reply carries.
|
||||
pub fn replies<R: Read>(
|
||||
input: &mut R,
|
||||
expected: &[(u64, u32)],
|
||||
) -> Vec<(u64, Result<Vec<u8>, u32>)> {
|
||||
expected
|
||||
.iter()
|
||||
.map(|_| {
|
||||
let (cookie, error) = reply_header(input);
|
||||
let Some((_, length)) = expected.iter().find(|(it, _)| *it == cookie) else {
|
||||
panic!("a reply for cookie {cookie:#x}, which no request carried");
|
||||
};
|
||||
if error != 0 {
|
||||
return (cookie, Err(error));
|
||||
}
|
||||
let mut data = vec![0_u8; *length as usize];
|
||||
read(input, &mut data);
|
||||
(cookie, Ok(data))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Reads a simple reply header: the cookie it echoes and its error.
|
||||
fn reply_header<R: Read>(input: &mut R) -> (u64, u32) {
|
||||
assert_eq!(
|
||||
read_u32(input),
|
||||
SIMPLE_REPLY_MAGIC,
|
||||
"the reply starts with the simple reply magic"
|
||||
);
|
||||
let error = read_u32(input);
|
||||
(read_u64(input), error)
|
||||
}
|
||||
|
||||
/// Reads a simple reply, asserts it echoes `cookie`, and returns its error.
|
||||
pub fn reply<R: Read>(input: &mut R, cookie: u64) -> u32 {
|
||||
let (echoed, error) = reply_header(input);
|
||||
assert_eq!(echoed, cookie, "the reply echoes the cookie");
|
||||
error
|
||||
}
|
||||
|
||||
/// Sends a request of `kind` carrying `flags` for a range and returns the reply's error.
|
||||
pub fn command<R: Read, W: Write>(
|
||||
input: &mut R,
|
||||
output: &mut W,
|
||||
flags: u16,
|
||||
kind: u16,
|
||||
cookie: u64,
|
||||
offset: u64,
|
||||
length: u32,
|
||||
) -> u32 {
|
||||
request(output, flags, kind, cookie, offset, length);
|
||||
reply(input, cookie)
|
||||
}
|
||||
|
||||
/// Sends `NBD_CMD_WRITE` with `payload` and returns the reply's error.
|
||||
pub fn write_at<R: Read, W: Write>(
|
||||
input: &mut R,
|
||||
output: &mut W,
|
||||
cookie: u64,
|
||||
offset: u64,
|
||||
payload: &[u8],
|
||||
) -> u32 {
|
||||
let length = u32::try_from(payload.len()).expect("a length a request can carry");
|
||||
request(output, 0, CMD_WRITE, cookie, offset, length);
|
||||
write(output, payload);
|
||||
reply(input, cookie)
|
||||
}
|
||||
|
||||
/// Sends `NBD_CMD_READ` and returns the bytes, or the error the reply carries.
|
||||
pub fn read_at<R: Read, W: Write>(
|
||||
input: &mut R,
|
||||
output: &mut W,
|
||||
cookie: u64,
|
||||
offset: u64,
|
||||
length: u32,
|
||||
) -> Result<Vec<u8>, u32> {
|
||||
request(output, 0, CMD_READ, cookie, offset, length);
|
||||
let error = reply(input, cookie);
|
||||
if error != 0 {
|
||||
return Err(error);
|
||||
}
|
||||
let mut data = vec![0_u8; length as usize];
|
||||
read(input, &mut data);
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// Sends `NBD_CMD_DISC`.
|
||||
pub fn disconnect<W: Write>(output: &mut W) {
|
||||
request(output, 0, CMD_DISC, 0x1234_5678_9ABC_DEF0, 0, 0);
|
||||
}
|
||||
BIN
Binary file not shown.
@@ -27,6 +27,7 @@
|
||||
- [i18n] Add Turkish and update Czech, Dutch, Slovak and Swedish translations (PR [#10316](https://github.com/vatesfr/xen-orchestra/pull/10316))
|
||||
- [XO6/Host] Add icon for disabled host (PR [#10188](https://github.com/vatesfr/xen-orchestra/pull/10188))
|
||||
- [V2V] When a VM has Changed Block Tracking enabled on the source host, a migration now asks the host which blocks it has to read — the blocks a disk uses for a full transfer, the blocks written since the previous pass for a delta — instead of scanning the disk through the VDDK. Faster to start on large disks, and one less moving part. VMs without CBT are migrated exactly as before (PR [#10384](https://github.com/vatesfr/xen-orchestra/pull/10384))
|
||||
- [V2V] Migrations no longer need the VDDK library, `nbdkit` and `nbdinfo`. The disks are read through `vectura`, a single binary shipped with XO and installed in one click from the import screen. The Broadcom download, the tar.gz upload and the two compile-from-source buttons are gone. Note that the source must now be an ESXi 7.x or 8.x host reached directly, a vCenter address is no longer supported
|
||||
- [Proxy] Check proxy licenses at XOA level instead of blocking backups on it (PR [#10280](https://github.com/vatesfr/xen-orchestra/pull/10280))
|
||||
- [RPU] A rolling pool update is now refused while a previous one is still in progress or was left incomplete, and asks for confirmation when the master is already up to date but other hosts are not (PR [#10394](https://github.com/vatesfr/xen-orchestra/pull/10394))
|
||||
- [RPU] An incomplete rolling pool update can now be closed from the pool's Patches tab, or with `pool.finalizeRollingUpdate` and the REST route `POST /rest/v0/pools/{id}/actions/finalize_rolling_update`. The closing is refused while the update left something it had changed unrestored (HA, WLB, load balancer, backup schedules, disabled hosts, displaced or halted VMs); forcing it lists the abandoned items in the task and changes nothing in the pool (PR [#10418](https://github.com/vatesfr/xen-orchestra/pull/10418))
|
||||
@@ -124,6 +125,7 @@
|
||||
- @xen-orchestra/web minor
|
||||
- @xen-orchestra/web-core minor
|
||||
- @xen-orchestra/xapi patch
|
||||
- vectura major
|
||||
- xen-api major
|
||||
- xo-remote-parser major
|
||||
- xo-server minor
|
||||
|
||||
@@ -6,12 +6,12 @@ sidebar_label: VMware migration (V2V)
|
||||
|
||||
## Introduction {#-introduction}
|
||||
|
||||
Xen Orchestra 5.110 introduced a new migration backend for moving virtual machines from VMware to XCP-ng (V2V). It replaces the old NFS-based method with `nbdkit` and VMware's VDDK libraries, delivering better performance and reliability.
|
||||
Xen Orchestra 5.110 introduced a new migration backend for moving virtual machines from VMware to XCP-ng (V2V). It replaces the old NFS-based method with `vectura`, a standalone reader of VMware's NFC protocol, delivering better performance and reliability without any VMware-provided library.
|
||||
|
||||
The whole process is driven from Xen Orchestra, in **Import → VM → From VMware**. XO connects to your vCenter or ESXi host, reads the VM disks directly, and imports them into an XCP-ng storage repository:
|
||||
|
||||
<Schema label="XO reads the VM disks from ESXi through nbdkit and the VDDK libraries, and imports them into an XCP-ng pool. The source VM keeps running during most of the transfer" legend={[["#5ac8c8", "VMware"], ["#8e83fe", "XCP-ng"], ["#6aabf0", "XO"]]} maxWidth="640px">
|
||||
<svg viewBox="0 0 640 240" role="img" aria-label="A VM running on an ESXi host inside a VMware infrastructure is exported by Xen Orchestra using nbdkit and VDDK, then imported into an XCP-ng host in the destination pool, where the migrated VM is ready to boot; the source VM keeps running during the warm phase of the transfer">
|
||||
<Schema label="XO reads the VM disks from ESXi through vectura, and imports them into an XCP-ng pool. The source VM keeps running during most of the transfer" legend={[["#5ac8c8", "VMware"], ["#8e83fe", "XCP-ng"], ["#6aabf0", "XO"]]} maxWidth="640px">
|
||||
<svg viewBox="0 0 640 240" role="img" aria-label="A VM running on an ESXi host inside a VMware infrastructure is exported by Xen Orchestra using vectura, then imported into an XCP-ng host in the destination pool, where the migrated VM is ready to boot; the source VM keeps running during the warm phase of the transfer">
|
||||
<rect x="16" y="40" width="192" height="168" rx="8" fill="none" stroke="rgba(255,255,255,0.22)" strokeDasharray="6 5" />
|
||||
<text x="28" y="62" fill="#c6d2e1" fontSize="12">VMware</text>
|
||||
<text x="28" y="78" fill="#7a8699" fontSize="10">source infrastructure</text>
|
||||
@@ -25,7 +25,7 @@ The whole process is driven from Xen Orchestra, in **Import → VM → From VMwa
|
||||
<text x="228" y="122" fill="#5ac8c8" fontSize="9" textAnchor="middle">export</text>
|
||||
<rect x="266" y="108" width="108" height="52" rx="6" fill="rgba(255,255,255,0.04)" stroke="#6aabf0" />
|
||||
<text x="320" y="129" fill="#c6d2e1" fontSize="11" textAnchor="middle">Xen Orchestra</text>
|
||||
<text x="320" y="146" fill="#7a8699" fontSize="9" textAnchor="middle">nbdkit + VDDK</text>
|
||||
<text x="320" y="146" fill="#7a8699" fontSize="9" textAnchor="middle">vectura</text>
|
||||
<text x="320" y="182" fill="#7a8699" fontSize="9" textAnchor="middle">reads only allocated blocks</text>
|
||||
<line x1="378" y1="134" x2="430" y2="134" stroke="#56c288" strokeWidth="2" className="schema-flow" strokeDasharray="5 4" />
|
||||
<polygon points="440,134 430,128 430,140" fill="#56c288" />
|
||||
@@ -90,76 +90,20 @@ Performance gains can be significant, though results depend on the environment:
|
||||
|
||||
### Update Xen Orchestra
|
||||
|
||||
Make sure your Xen Orchestra instance is up to date. The import page includes an automatic check that verifies the installed versions of `nbdkit`, the VDDK plugin and `nbdinfo`, and highlights errors and warnings before the migration. Errors must be resolved, and warnings should be addressed when possible.
|
||||
Make sure your Xen Orchestra instance is up to date. The import page includes an automatic check that verifies that `vectura`, shipped with your Xen Orchestra, runs on this system. Errors must be resolved before the migration can start.
|
||||
|
||||
<UiDetail src="/img/xo5/v2v-prerequisite-check.png" alt="The prerequisite check on the import page, listing nbdkit, the VDDK plugin and nbdinfo with their status" width={480} />
|
||||
<UiDetail src="/img/xo5/v2v-prerequisite-check.png" alt="The prerequisite check on the import page, showing the status of vectura" width={480} />
|
||||
|
||||
### Dependencies
|
||||
|
||||
Install the following dependencies:
|
||||
The only dependency is `vectura`, a single binary that reads VMware disks over the NFC protocol. It
|
||||
is shipped with Xen Orchestra and runs from there: there is nothing to install, nothing to download
|
||||
from VMware or Broadcom, and nothing to compile. It needs a Linux x86-64 system with glibc 2.34 or
|
||||
later, such as Debian 12 or Ubuntu 22.04.
|
||||
|
||||
- `nbdkit`
|
||||
- The VDDK plugin
|
||||
- `nbdinfo`
|
||||
Go to the **Import → VM → From VMware** section. Once the check passes, a **transfer form** appears:
|
||||
|
||||
#### Installing `nbdinfo` on Debian
|
||||
|
||||
:::warning
|
||||
This procedure requires an active Internet connection and has only been tested on Debian 12 and 13.
|
||||
|
||||
In the future, we plan to include the latest libraries directly in XOA.
|
||||
:::
|
||||
|
||||
To install `nbdinfo` on Debian:
|
||||
|
||||
1. Go to the **Import → VM → From VMware** section.
|
||||
2. Click the button called **install nbdinfo (debian based system)**:
|
||||
|
||||
<UiDetail src="/img/xo5/v2v-install-nbdinfo-button.png" alt="The install nbdinfo (debian based system) button on the From VMware import page" width={620} />
|
||||
|
||||
This starts the install.
|
||||
|
||||
3. Keep an eye on the current page to make sure the install goes as planned.
|
||||
- The **install nbdinfo** button updates to show the installation progress, and the page displays any warnings in real time as they appear:
|
||||
|
||||
<UiDetail src="/img/xo5/v2v-nbdinfo-progress-import.png" alt="The import page while the nbdinfo installation task is running, with warnings shown in real time" width={620} />
|
||||
|
||||
- Additionally, the **Tasks** screen shows the `nbdinfo` install task, with its name, start date, start time and status indicator:
|
||||
|
||||
<UiDetail src="/img/xo5/v2v-nbdinfo-progress-tasks.png" alt="The Tasks screen showing the running nbdinfo install task with its status indicator" width={620} />
|
||||
|
||||
The task is removed from the list upon completion.
|
||||
|
||||
4. Once the installation finishes, the `nbdinfo` install screen updates automatically. It lets you know if everything was completed successfully and flags any warnings that came up during the process:
|
||||
|
||||
<UiDetail src="/img/xo5/v2v-nbdinfo-install-complete.png" alt="The import page after a completed nbdinfo installation, with the final status and warnings" width={620} />
|
||||
|
||||
:::tip
|
||||
`nbdkit` and the `nbdkit-plugin-vddk` are installed using the same approach.
|
||||
:::
|
||||
|
||||
#### Installing the other dependencies
|
||||
|
||||
:::warning
|
||||
You can compile `nbdkit` and `libnbd` from source on GitLab, but the correct dependencies must be in place for `nbdinfo` and VDDK.
|
||||
:::
|
||||
|
||||
##### Installing VDDK
|
||||
|
||||
1. Download the VMware Virtual Disk Development Kit (VDDK) from the [Broadcom developer portal](https://developer.broadcom.com/sdks/vmware-virtual-disk-development-kit-vddk/9.0?ref=xen-orchestra.com).
|
||||
2. Select the _tar.gz_ archive, then drag and drop it directly into the Xen Orchestra interface:
|
||||
|
||||
<UiDetail src="/img/xo5/v2v-upload-vddk.png" alt="The import page with the drop zone used to upload the VDDK tar.gz archive" width={620} />
|
||||
|
||||
An **install button** will appear.
|
||||
|
||||
3. Click the button:
|
||||
|
||||
<UiDetail src="/img/xo5/v2v-install-vddk-button.png" alt="The button confirming the installation of the uploaded VDDK archive" width={620} />
|
||||
|
||||
The installation should take just a few seconds. Once it is done, a **transfer form** will appear:
|
||||
|
||||
<UiDetail src="/img/xo5/v2v-transfer-form.png" alt="The transfer form used to connect to VMware and import a VM into Xen Orchestra" width={620} />
|
||||
<UiDetail src="/img/xo5/v2v-transfer-form.png" alt="The transfer form used to connect to VMware and import a VM into Xen Orchestra" width={620} />
|
||||
|
||||
:::warning
|
||||
Filling in this form and clicking the **Connect** button will start the VM import. Make sure your VMware environment is ready first!
|
||||
@@ -175,7 +119,7 @@ Before starting the migration, make sure your VMware environment meets the follo
|
||||
|
||||
#### Network
|
||||
|
||||
XO must be able to connect to the vSphere or ESXi host through the port running the web UI (default port: 443) and the VDDK data port (default port: 902). At the time of writing, there is no way to select one network or another, so if possible, keep a single network path from XO to VMware.
|
||||
XO must be able to connect to the ESXi host, or to the vCenter, through the port running the web UI (default port: 443), and to the ESXi host holding the VM through the NFC data port (default port: 902), even when going through a vCenter. At the time of writing, there is no way to select one network or another, so if possible, keep a single network path from XO to VMware.
|
||||
|
||||
#### VMware disk support
|
||||
|
||||
@@ -310,7 +254,7 @@ If you encounter problems during migration, try these steps to diagnose and reso
|
||||
Look for disk access errors in the VMware logs. These may point to issues with the source storage or VM configuration.
|
||||
|
||||
- **Update software versions**\
|
||||
Ensure `nbdkit` and `nbdinfo` are up to date. Older versions can cause compatibility issues with the VDDK plugin. The [prerequisite check](#update-xen-orchestra) on the import page flags outdated versions.
|
||||
Keep your Xen Orchestra up to date: `vectura` ships with it. The [prerequisite check](#update-xen-orchestra) on the import page tells whether it runs on this system.
|
||||
|
||||
- **Remove active snapshots**\
|
||||
Make sure no active snapshots remain on the source VM. Snapshots can disrupt migration consistency.
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"eslint"
|
||||
],
|
||||
".USAGE.md": "scripts/usage-to-readme.js --git-add",
|
||||
"@xen-orchestra/vmware-explorer/vectura/**/*.rs": "sh -c 'cd @xen-orchestra/vmware-explorer/vectura && cargo fmt --all --check' --",
|
||||
"./CHANGELOG.unreleased.md": "sh -c 'scripts/gen-deps-list.js --check-order' --"
|
||||
},
|
||||
"private": true,
|
||||
|
||||
@@ -1,26 +1,5 @@
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'path'
|
||||
import fs from 'node:fs/promises'
|
||||
import { createWriteStream } from 'node:fs'
|
||||
import { VDDK_LIB_DIR } from '@xen-orchestra/vmware-explorer/esxi.mjs'
|
||||
import { exec } from 'node:child_process'
|
||||
import { Task } from '@xen-orchestra/mixins/Tasks.mjs'
|
||||
import { format } from 'json-rpc-peer'
|
||||
|
||||
import { noop, safeDateFormat } from '../utils.mjs'
|
||||
|
||||
function execPromise(command, opts = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
exec(command, { maxBuffer: 10 * 1024 * 1024, ...opts }, (error, stdout) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve(stdout)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function listVms({ host, password, sslVerify = true, user }) {
|
||||
return this.connectToEsxiAndList({ host, user, password, sslVerify })
|
||||
}
|
||||
@@ -35,123 +14,13 @@ listVms.params = {
|
||||
listVms.permission = 'admin'
|
||||
|
||||
export function checkInstall() {
|
||||
return this.checkVddkDependencies()
|
||||
return this.checkEsxiDependencies()
|
||||
}
|
||||
|
||||
checkInstall.params = {}
|
||||
|
||||
checkInstall.permission = 'admin'
|
||||
|
||||
async function handleImport(req, res) {
|
||||
return Task.run({ properties: { name: 'handling vddk import and installing' } }, async () => {
|
||||
const tmpDir = await Task.run({ properties: { name: `create directory` } }, () =>
|
||||
fs.mkdtemp(join(tmpdir(), 'xo-server'))
|
||||
)
|
||||
Task.info(`temp directory is ${tmpDir}`)
|
||||
const tempFilePath = join(tmpDir, 'vix.tar.gz')
|
||||
Task.info(`temp upload path is ${tempFilePath}`)
|
||||
await Task.run({ properties: { name: `create vddk lib directory` } }, () =>
|
||||
fs.mkdir(VDDK_LIB_DIR, { recursive: true })
|
||||
)
|
||||
|
||||
await Task.run(
|
||||
{ properties: { name: 'uploading file' } },
|
||||
() =>
|
||||
new Promise((resolve, reject) => {
|
||||
req.pipe(createWriteStream(tempFilePath))
|
||||
req.on('end', resolve)
|
||||
req.on('error', reject)
|
||||
})
|
||||
)
|
||||
await Task.run({ properties: { name: `installing to ${VDDK_LIB_DIR}` } }, () =>
|
||||
execPromise(`tar xfz ${tempFilePath} -C ${VDDK_LIB_DIR}`)
|
||||
)
|
||||
res.end(format.response(0))
|
||||
})
|
||||
}
|
||||
export async function installVddkLib() {
|
||||
return {
|
||||
$sendTo: await this.registerHttpRequest(handleImport),
|
||||
}
|
||||
}
|
||||
installVddkLib.params = {}
|
||||
|
||||
installVddkLib.permission = 'admin'
|
||||
|
||||
async function install({ repository, version, executable }) {
|
||||
const tmpDir = await Task.run({ properties: { name: `create directory` } }, () => {
|
||||
return fs.mkdtemp(join(tmpdir(), 'xo-server'))
|
||||
})
|
||||
|
||||
Task.info(`temp directory is ${tmpDir}`)
|
||||
await Task.run({ properties: { name: `check if nbdinfo is already connected` } }, async () => {
|
||||
const libPath = await execPromise(`which ${executable}`).catch(() => {})
|
||||
if (libPath) {
|
||||
throw new Error(
|
||||
`${executable} is already installed at ${libPath} on this system please uninstall before rerunning this script`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
await Task.run({ properties: { name: `check if user is root` } }, async () => {
|
||||
let id
|
||||
if ((id = (await execPromise('id -u')).trim() !== '0')) {
|
||||
throw new Error(`install script can only be run as root ${id}`)
|
||||
}
|
||||
})
|
||||
|
||||
await Task.run({ properties: { name: `check if os is debian based` } }, async () => {
|
||||
try {
|
||||
await execPromise('apt-get --version')
|
||||
} catch (err) {
|
||||
const error = new Error(`The ${executable} auto install can only be run on a debian based system`)
|
||||
error.cause = err
|
||||
throw err
|
||||
}
|
||||
})
|
||||
|
||||
await Task.run({ properties: { name: `installing dependencies` } }, () =>
|
||||
execPromise('apt-get install -y git dh-autoreconf pkg-config make libxml2-dev ocaml libc-bin')
|
||||
)
|
||||
|
||||
await Task.run({ properties: { name: `cloning repo` } }, () => execPromise(`git clone ${repository} ${tmpDir}`))
|
||||
await Task.run({ properties: { name: `switching to version ${version}` } }, () =>
|
||||
execPromise(`git checkout ${version} `, { cwd: tmpDir })
|
||||
)
|
||||
|
||||
await Task.run({ properties: { name: `running autoreconf` } }, () =>
|
||||
execPromise('autoreconf -i > autoreconf.log', { cwd: tmpDir })
|
||||
)
|
||||
await Task.run({ properties: { name: `running configure` } }, () =>
|
||||
execPromise('./configure > configure.log', { cwd: tmpDir })
|
||||
)
|
||||
await Task.run({ properties: { name: `compiling` } }, () => execPromise('make', { cwd: tmpDir }))
|
||||
|
||||
await Task.run({ properties: { name: `installing` } }, () => execPromise('make install', { cwd: tmpDir }))
|
||||
|
||||
await Task.run({ properties: { name: `linking` } }, () => execPromise('ldconfig', { cwd: tmpDir }))
|
||||
|
||||
await Task.run({ properties: { name: `version check ` } }, () => execPromise(`${executable} --version`))
|
||||
}
|
||||
|
||||
export async function installNbdInfoFromSource() {
|
||||
await Task.run({ properties: { name: 'Compile and installl nbdinfo' } }, async () => {
|
||||
await install({ executable: 'nbdinfo', repository: 'https://gitlab.com/nbdkit/libnbd.git', version: 'v1.23.4' })
|
||||
})
|
||||
}
|
||||
|
||||
installNbdInfoFromSource.params = {}
|
||||
installNbdInfoFromSource.permission = 'admin'
|
||||
|
||||
export async function installNbdKitFromSource() {
|
||||
await Task.run({ properties: { name: 'Compile and installl nbdkit' } }, async () => {
|
||||
await install({ executable: 'nbdkit', repository: 'https://gitlab.com/nbdkit/nbdkit.git', version: 'v1.44.3' })
|
||||
})
|
||||
}
|
||||
|
||||
installNbdKitFromSource.params = {}
|
||||
installNbdKitFromSource.permission = 'admin'
|
||||
|
||||
async function handleExport(req, res, { disk, format, host, password, user, vm }) {
|
||||
const stream = await this.exportEsxiDisk({ disk, format, host, user, password, vm })
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Disposable } from 'promise-toolbox'
|
||||
import { Task } from '@xen-orchestra/mixins/Tasks.mjs'
|
||||
import { QCOW2_CLUSTER_SIZE, VDI_FORMAT_QCOW2, VDI_FORMAT_VHD, VHD_BLOCK_SIZE, VHD_MAX_SIZE } from '@xen-orchestra/xapi'
|
||||
import { ReadAhead } from '@xen-orchestra/disk-transform'
|
||||
import { toVhdStream } from 'vhd-lib/disk-consumer/index.mjs'
|
||||
import { NbdDisk } from '@vates/nbd-client/NbdDisk.mjs'
|
||||
import { NbdStdioClient } from '@vates/nbd-client'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import { toQcow2Stream } from '@xen-orchestra/qcow2'
|
||||
import { TaskProgressHandler } from '@xen-orchestra/backups/_runners/_vmRunners/_TaskProgressHandler.mjs'
|
||||
@@ -16,46 +16,53 @@ import {
|
||||
|
||||
const { warn } = createLogger('xo:importdiskfromdatastore')
|
||||
|
||||
export async function importStream({ esxi, dataMap, disk, vmId, format }, consumerCallback) {
|
||||
export async function importStream(
|
||||
{
|
||||
esxi,
|
||||
disk,
|
||||
// vectura serves the disk and nothing else: there is no block map to fall back on, so the
|
||||
// whole disk is read. `capacity` and not the aligned size, reading past the export would fail
|
||||
dataMap = [{ offset: 0, length: disk.capacity, type: 0 }],
|
||||
vmId,
|
||||
format,
|
||||
},
|
||||
consumerCallback
|
||||
) {
|
||||
const signal = Task.abortSignal
|
||||
const { datastore: datastoreName, diskPath } = disk
|
||||
|
||||
// `NbdStdioClient` spawns vectura on connect and kills it on disconnect
|
||||
// we read the data from the full chain to ensure we don't have partial blocks ( blocks with 0 when clusters are in parent only)
|
||||
const vecturaSettings = await esxi.getVecturaSpawnSettings(vmId, `[${datastoreName}] ${diskPath}`)
|
||||
|
||||
let vmdk
|
||||
let stream
|
||||
try {
|
||||
// the server is stopped whatever happens here: it used to be killed by a `finally` which had
|
||||
// to name the disk and repeat the exact spawn options
|
||||
// we read the data from the full chain to ensure we don't have partial blocks ( blocks with 0 when clusters are in parent only)
|
||||
return await Disposable.use(esxi.getNbdServer(vmId, `[${datastoreName}] ${diskPath}`), async ({ nbdInfos }) => {
|
||||
let vmdk
|
||||
let stream
|
||||
try {
|
||||
signal?.throwIfAborted()
|
||||
vmdk = new NbdDisk(nbdInfos, READ_BLOCK_SIZE, { dataMap })
|
||||
signal?.throwIfAborted()
|
||||
vmdk = new NbdDisk(vecturaSettings, READ_BLOCK_SIZE, { dataMap, ClientClass: NbdStdioClient })
|
||||
|
||||
await vmdk.init()
|
||||
signal?.throwIfAborted()
|
||||
vmdk = new ReadAhead(vmdk)
|
||||
await vmdk.init()
|
||||
signal?.throwIfAborted()
|
||||
vmdk = new ReadAhead(vmdk)
|
||||
|
||||
vmdk.addProgressHandler(new TaskProgressHandler())
|
||||
vmdk.addProgressHandler(new TaskProgressHandler())
|
||||
|
||||
if (format === VDI_FORMAT_QCOW2) {
|
||||
stream = await toQcow2Stream(vmdk, { signal })
|
||||
} else {
|
||||
stream = await toVhdStream(vmdk, { signal })
|
||||
}
|
||||
Task.info(`got source stream for ${diskPath}`)
|
||||
await consumerCallback(stream)
|
||||
return Math.round((vmdk.getNbGeneratedBlock() * vmdk.getBlockSize()) / 1024 / 1024)
|
||||
} catch (err) {
|
||||
stream?.destroy(err)
|
||||
throw err
|
||||
} finally {
|
||||
await vmdk?.close().catch(err => warn('error while closing source vmdk', err))
|
||||
}
|
||||
})
|
||||
if (format === VDI_FORMAT_QCOW2) {
|
||||
stream = await toQcow2Stream(vmdk, { signal })
|
||||
} else {
|
||||
stream = await toVhdStream(vmdk, { signal })
|
||||
}
|
||||
Task.info(`got source stream for ${diskPath}`)
|
||||
await consumerCallback(stream)
|
||||
return Math.round((vmdk.getNbGeneratedBlock() * vmdk.getBlockSize()) / 1024 / 1024)
|
||||
} catch (err) {
|
||||
// a failure to start the server belongs in the report too, e.g. nbdkit not being installed
|
||||
stream?.destroy(err)
|
||||
// a failure to start the server belongs in the report too, e.g. vectura not being installed
|
||||
Task.warning(err)
|
||||
throw err
|
||||
} finally {
|
||||
// this is what stops the vectura process
|
||||
await vmdk?.close().catch(err => warn('error while closing source vmdk', err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +105,7 @@ async function importDiskChain({ esxi, sr, vm, chainByNode, changeTracking, user
|
||||
if (previouslyImportedIndex !== -1) {
|
||||
if (previouslyImportedIndex < chainByNode.length - 2) {
|
||||
throw new Error(
|
||||
'vddk import does not support importing multiple snapshots. Coalesce the non imported snapshot into one or force a full import'
|
||||
'the import does not support importing multiple snapshots. Coalesce the non imported snapshot into one or force a full import'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -114,8 +121,7 @@ async function importDiskChain({ esxi, sr, vm, chainByNode, changeTracking, user
|
||||
}
|
||||
|
||||
// asked in both cases: the blocks written since `baseDiskPath`, or every block the disk uses.
|
||||
// `undefined` when the host cannot answer for a whole disk, and the disk is then read to find
|
||||
// out, as it always was
|
||||
// `undefined` when the host cannot answer for a whole disk, `importStream` then reads all of it
|
||||
const dataMap = await esxi.getDataMap(vmId, datastoreName, diskPath, {
|
||||
baseDiskPath,
|
||||
changeTracking,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Task } from '@xen-orchestra/mixins/Tasks.mjs'
|
||||
import asyncMapSettled from '@xen-orchestra/async-map/legacy.js'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import Esxi from '@xen-orchestra/vmware-explorer/esxi.mjs'
|
||||
import { checkVddkDependencies } from '@xen-orchestra/vmware-explorer/checks.mjs'
|
||||
import { checkDependencies } from '@xen-orchestra/vmware-explorer/checks.mjs'
|
||||
import { VDI_FORMAT_VHD } from '@xen-orchestra/xapi'
|
||||
import OTHER_CONFIG_TEMPLATE from '../../xapi/other-config-template.mjs'
|
||||
import { importDisksFromDatastore, importStream } from './importDisksfromDatastore.mjs'
|
||||
@@ -264,8 +264,8 @@ export default class MigrateVm {
|
||||
return vm.uuid
|
||||
}
|
||||
|
||||
async checkVddkDependencies() {
|
||||
return checkVddkDependencies()
|
||||
async checkEsxiDependencies() {
|
||||
return checkDependencies()
|
||||
}
|
||||
|
||||
async exportEsxiDisk({ disk: diskId, format, host, user, password, vm: vmId }) {
|
||||
|
||||
@@ -21,25 +21,11 @@ const messages = {
|
||||
hostIp: 'Host IP',
|
||||
interfaces: 'Interfaces',
|
||||
keyValue: '{key}: {value}',
|
||||
esxiLibraryInfo: 'The V2V tool needs the vddk library, provided by Broadcom. Please download it from their website',
|
||||
esxiLibraryLink: 'Download link',
|
||||
|
||||
esxiVddkLibrary: 'Drop the tar.gz file of the vddk library (linux)',
|
||||
esxiVddkLibraryImport: 'Import and install the Vddk library. VDDK9 need nbdkit 1.42+',
|
||||
esxiLibraryManualInstall:
|
||||
'For other systems, you can install manually from https://gitlab.com/nbdkit/ . For reference the list of packages need for a debian 13 is **git dh-autoreconf pkg-config make libxml2-dev ocaml libc-bin**',
|
||||
esxiLibraryAutoInstall: 'install {library} (debian based system)',
|
||||
esxiLibraryInstalling:
|
||||
"Installing {library} can take a few minutes. You can check the progress in the XO tasks screen while it's running.",
|
||||
esxiProgressLinkText: 'Track progress',
|
||||
|
||||
esxiLibraryOutdated:
|
||||
'{library} library is outdated expecting {expectedVersion}, got {version}. Please uninstall it and install the required version.',
|
||||
esxiCheckingPrerequisite: 'Checking prerequisite on XO',
|
||||
esxiCheckedPrerequisite: 'Result of the prerequisite check on XO',
|
||||
|
||||
esxiCheckingPrerequisiteError: 'Must be corrected before importing VM',
|
||||
esxiCheckedPrerequisiteVersion: 'expected version {expectedVersion} , {version} installed',
|
||||
esxiImportSslCertificate: 'Skip SSL check',
|
||||
esxiImportRememberLogin: 'Remember connection in this browser',
|
||||
esxiImportRememberWarning:
|
||||
|
||||
@@ -4444,38 +4444,6 @@ export const esxiListVms = (host, user, password, sslVerify) =>
|
||||
export const esxiCheckInstall = () => _call('esxi.checkInstall')
|
||||
export const importVmsFromEsxi = params => _call('vm.importMultipleFromEsxi', params)
|
||||
|
||||
export const importVddkLib = file => {
|
||||
return _call('esxi.installVddkLib').then(({ $sendTo }) => {
|
||||
return post($sendTo, file.file)
|
||||
.then(res => {
|
||||
if (res.status !== 200) {
|
||||
throw res.status
|
||||
}
|
||||
success('lib successfully installed')
|
||||
})
|
||||
.catch(err => {
|
||||
error('fail to install vddk lib', err)
|
||||
})
|
||||
})
|
||||
}
|
||||
export const installNbdInfo = file => {
|
||||
return _call('esxi.installNbdInfoFromSource')
|
||||
.then(() => {
|
||||
success('nbdInfo successfullly installed successfully installed')
|
||||
})
|
||||
.catch(err => {
|
||||
error('fail to install nbdInfo', err)
|
||||
})
|
||||
}
|
||||
export const installNbdKit = file => {
|
||||
return _call('esxi.installNbdKitFromSource')
|
||||
.then(() => {
|
||||
success('nbdkit successfullly installed successfully installed')
|
||||
})
|
||||
.catch(err => {
|
||||
error('fail to install nbdkit', err)
|
||||
})
|
||||
}
|
||||
// GitHub API ---------------------------------------------------------------
|
||||
const _callGithubApi = async (endpoint = '') => {
|
||||
const url = new URL('https://api.github.com/repos/vatesfr/xen-orchestra')
|
||||
|
||||
@@ -3,21 +3,12 @@ import ActionButton from 'action-button'
|
||||
import Button from 'button'
|
||||
import Collapse from 'collapse'
|
||||
import Component from 'base-component'
|
||||
import Dropzone from 'dropzone'
|
||||
import Icon from 'icon'
|
||||
import Link from 'link'
|
||||
import React from 'react'
|
||||
import { connectStore, resolveId } from 'utils'
|
||||
import { createGetObjectsOfType, createSelector } from 'selectors'
|
||||
import {
|
||||
esxiCheckInstall,
|
||||
esxiListVms,
|
||||
importVddkLib,
|
||||
importVmsFromEsxi,
|
||||
installNbdInfo,
|
||||
installNbdKit,
|
||||
isSrWritable,
|
||||
} from 'xo'
|
||||
import { esxiCheckInstall, esxiListVms, importVmsFromEsxi, isSrWritable } from 'xo'
|
||||
import { find, forEach, isEmpty, keyBy, map, pick } from 'lodash'
|
||||
import { injectIntl } from 'react-intl'
|
||||
import { Input } from 'debounce-input-decorator'
|
||||
@@ -33,7 +24,7 @@ function EsxiCheckResults({ esxiCheck }) {
|
||||
return (
|
||||
<ul className='list-group'>
|
||||
{Object.entries(esxiCheck).map(([name, value]) => {
|
||||
const { status, error, version, expectedVersion } = value
|
||||
const { status, error } = value
|
||||
return (
|
||||
<li key={name}>
|
||||
<Icon icon={status} size='lg' fixedWidth />
|
||||
@@ -42,7 +33,6 @@ function EsxiCheckResults({ esxiCheck }) {
|
||||
{status === 'error' && `"${error}"`}
|
||||
|
||||
{status === 'error' && _('esxiCheckingPrerequisiteError')}
|
||||
{version && status === 'alarm' && _('esxiCheckedPrerequisiteVersion', { version, expectedVersion })}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
@@ -63,7 +53,6 @@ class EsxiImport extends Component {
|
||||
state = {
|
||||
concurrency: N_IMPORT_VMS_IN_PARALLEL,
|
||||
hostIp: window.localStorage.getItem('esxi_host') ?? '',
|
||||
installingEsxiLib: false,
|
||||
importing: false,
|
||||
isConnected: false,
|
||||
password: window.localStorage.getItem('esxi_password') ?? '',
|
||||
@@ -72,9 +61,7 @@ class EsxiImport extends Component {
|
||||
stopOnError: true,
|
||||
template: undefined,
|
||||
user: window.localStorage.getItem('esxi_user') ?? '',
|
||||
vddkFile: undefined,
|
||||
esxiCheck: undefined,
|
||||
esxiCheckError: undefined,
|
||||
rememberConnection: !!window.localStorage.getItem('esxi_host'),
|
||||
}
|
||||
|
||||
@@ -82,33 +69,11 @@ class EsxiImport extends Component {
|
||||
this._esxiCheck()
|
||||
}
|
||||
_esxiCheck() {
|
||||
this.setState({ esxiCheck: undefined, installingEsxiLib: false }, async () => {
|
||||
this.setState({ esxiCheck: undefined }, async () => {
|
||||
const esxiCheck = await esxiCheckInstall()
|
||||
this.setState({ esxiCheck })
|
||||
})
|
||||
}
|
||||
_handleDropVddk = files => {
|
||||
this.setState({ vddkFile: files?.[0] })
|
||||
}
|
||||
_handleImportVddk = async () => {
|
||||
this.setState({ installingEsxiLib: true })
|
||||
try {
|
||||
await importVddkLib({ file: this.state.vddkFile })
|
||||
} catch (error) {
|
||||
this.setState({ esxiCheckError: error })
|
||||
}
|
||||
return this._esxiCheck()
|
||||
}
|
||||
_installNbdInfo = async () => {
|
||||
this.setState({ installingEsxiLib: true })
|
||||
await installNbdInfo()
|
||||
return this._esxiCheck()
|
||||
}
|
||||
_installNbKit = async () => {
|
||||
this.setState({ installingEsxiLib: true })
|
||||
await installNbdKit()
|
||||
return this._esxiCheck()
|
||||
}
|
||||
_getDefaultNetwork = createSelector(
|
||||
() => this.state.pool?.master,
|
||||
() => this.props.hostsById,
|
||||
@@ -266,7 +231,6 @@ class EsxiImport extends Component {
|
||||
concurrency,
|
||||
esxiCheck,
|
||||
hostIp,
|
||||
installingEsxiLib,
|
||||
importing,
|
||||
isConnected,
|
||||
network = this._getDefaultNetwork(),
|
||||
@@ -278,7 +242,6 @@ class EsxiImport extends Component {
|
||||
stopSource,
|
||||
stopOnError,
|
||||
user,
|
||||
vddkFile,
|
||||
vms,
|
||||
vmsById,
|
||||
} = this.state
|
||||
@@ -287,72 +250,12 @@ class EsxiImport extends Component {
|
||||
return <div>checking</div>
|
||||
}
|
||||
|
||||
// cehck nbdkit, nbdinfo, nbdkit plugin vddk
|
||||
for (const [library, fn] of [
|
||||
['nbdinfo', this._installNbdInfo],
|
||||
['nbdkit', this._installNbKit],
|
||||
['nbdkitPluginVddk', this._installNbKit],
|
||||
]) {
|
||||
const check = esxiCheck[library]
|
||||
if (check.status !== 'success') {
|
||||
return (
|
||||
<div>
|
||||
<Row>
|
||||
<EsxiCheckResults esxiCheck={esxiCheck} />
|
||||
</Row>
|
||||
{check.version === undefined && (
|
||||
<div>
|
||||
<div className='mt-1 form-group pull-right'>
|
||||
<ActionButton btnStyle='primary' className='mr-1' handler={fn} icon='import'>
|
||||
{_('esxiLibraryAutoInstall', { library })}
|
||||
</ActionButton>
|
||||
{installingEsxiLib && (
|
||||
<p>
|
||||
{_('esxiLibraryInstalling', { library })}
|
||||
<Link to='/tasks?s_xo=1_3_desc-status%3Apending+esxi.install' target='_blank'>
|
||||
{_('esxiProgressLinkText')}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
{!installingEsxiLib && <p>{_('esxiLibraryManualInstall')}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{check.version !== undefined && (
|
||||
<p>
|
||||
{_('esxiLibraryOutdated', { library, expectedVersion: check.expectedVersion, version: check.version })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (esxiCheck.vddk?.status === 'error') {
|
||||
// vectura is the only prerequisite: one binary, shipped with xo-server
|
||||
if (esxiCheck.vectura.status !== 'success') {
|
||||
return (
|
||||
<div>
|
||||
<Row>
|
||||
<EsxiCheckResults esxiCheck={esxiCheck} />
|
||||
</Row>
|
||||
<p>
|
||||
{_('esxiLibraryInfo')} :{' '}
|
||||
<a
|
||||
href='https://developer.broadcom.com/sdks/vmware-virtual-disk-development-kit-vddk/9.0'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
{_('esxiLibraryLink')}
|
||||
</a>
|
||||
</p>
|
||||
<Dropzone multiple={false} onDrop={this._handleDropVddk} message={_('esxiVddkLibrary')} accept='.tar.gz' />
|
||||
{vddkFile && (
|
||||
<div className='form-group pull-right'>
|
||||
<ActionButton btnStyle='primary' className='mr-1' handler={this._handleImportVddk} icon='import'>
|
||||
{_('esxiVddkLibraryImport')}
|
||||
</ActionButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Row>
|
||||
<EsxiCheckResults esxiCheck={esxiCheck} />
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,11 @@ cd "$(dirname "$(which "$0")")/.."
|
||||
pkg=$1
|
||||
|
||||
case "$pkg" in
|
||||
vectura)
|
||||
# a Rust crate, which @xen-orchestra/vmware-explorer bundles
|
||||
shift
|
||||
exec @xen-orchestra/vmware-explorer/vectura/scripts/release.sh "$@"
|
||||
;;
|
||||
@*/*)
|
||||
cd "$pkg"
|
||||
;;
|
||||
|
||||
@@ -33,6 +33,10 @@ const IGNORED_PACKAGES = new Set([
|
||||
'@xen-orchestra/lite',
|
||||
])
|
||||
|
||||
// vectura is a Rust crate, released on its own: @xen-orchestra/vmware-explorer
|
||||
// bundles its binary and `gen-deps-list.js` releases it after vectura
|
||||
const VECTURA_DIR = '@xen-orchestra/vmware-explorer/vectura/'
|
||||
|
||||
// files which don't change what is shipped to users: a package whose changes
|
||||
// are limited to them does not need to be released
|
||||
const IGNORED_FILES = [
|
||||
@@ -72,7 +76,7 @@ function getChangedPackages(changedFiles, packageNamesByDir) {
|
||||
continue
|
||||
}
|
||||
|
||||
const name = packageNamesByDir.get(`${parts[0]}/${parts[1]}`)
|
||||
const name = file.startsWith(VECTURA_DIR) ? 'vectura' : packageNamesByDir.get(`${parts[0]}/${parts[1]}`)
|
||||
if (name === undefined || IGNORED_PACKAGES.has(name) || isIgnoredFile(file)) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ const PACKAGE_NAMES_BY_DIR = new Map([
|
||||
['@xen-orchestra/babel-config', '@xen-orchestra/babel-config'],
|
||||
['@xen-orchestra/backups', '@xen-orchestra/backups'],
|
||||
['@xen-orchestra/lite', '@xen-orchestra/lite'],
|
||||
['@xen-orchestra/vmware-explorer', '@xen-orchestra/vmware-explorer'],
|
||||
['packages/xo-server', 'xo-server'],
|
||||
])
|
||||
|
||||
@@ -114,6 +115,16 @@ describe('getChangedPackages', function () {
|
||||
)
|
||||
})
|
||||
|
||||
it('maps a vectura file to vectura, not to the package which bundles it', function () {
|
||||
assert.deepEqual(
|
||||
changedPackageNames([
|
||||
'@xen-orchestra/vmware-explorer/vectura/src/main.rs',
|
||||
'@xen-orchestra/vmware-explorer/vectura/tests/cli.rs',
|
||||
]),
|
||||
['vectura']
|
||||
)
|
||||
})
|
||||
|
||||
it('handles an empty list of changed files', function () {
|
||||
assert.deepEqual(changedPackageNames([]), [])
|
||||
})
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
'use strict'
|
||||
|
||||
const semver = require('semver')
|
||||
const { getPackages } = require('./utils')
|
||||
const { join } = require('path')
|
||||
const { getPackages, readFile } = require('./utils')
|
||||
const { readChangelogPackages } = require('./_readChangelogPackages.js')
|
||||
const invert = require('lodash/invert')
|
||||
const keyBy = require('lodash/keyBy')
|
||||
@@ -78,6 +79,16 @@ async function main(args, scriptName) {
|
||||
}
|
||||
|
||||
allPackages = keyBy(await getPackages(true), 'name')
|
||||
|
||||
// vectura is a Rust crate, not an npm package: @xen-orchestra/vmware-explorer
|
||||
// bundles its binary, so a vectura release is followed by one of
|
||||
// vmware-explorer, as if it depended on this exact version
|
||||
const vecturaVersion = /^version = "(.+)"$/m.exec(
|
||||
await readFile(join(__dirname, '../@xen-orchestra/vmware-explorer/vectura/Cargo.toml'))
|
||||
)[1]
|
||||
allPackages.vectura = { name: 'vectura', package: { name: 'vectura', version: vecturaVersion } }
|
||||
allPackages['@xen-orchestra/vmware-explorer'].package.dependencies.vectura = vecturaVersion
|
||||
|
||||
const releaseOrder = computeDepOrder(allPackages)
|
||||
|
||||
Object.entries(toRelease).forEach(([packageName, releaseType]) => {
|
||||
|
||||
Reference in New Issue
Block a user