Compare commits

..

3 Commits

Author SHA1 Message Date
debian
eed3d72f75 fix incorrect parentage issue 2026-02-17 11:55:01 -05:00
debian
cb85e44aec fix isDirectory 2026-02-17 10:17:18 -05:00
debian
29b7a67dc3 fix backup health check & added debug 2026-02-17 08:44:36 -05:00
3470 changed files with 39266 additions and 247298 deletions

View File

@@ -85,21 +85,6 @@ module.exports = {
XO_LITE_GIT_HEAD: true,
},
rules: {
'import/extensions': [
'error',
'ignorePackages',
{
pattern: { ts: 'always', vue: 'always' },
checkTypeImports: true,
// '@core/*' and '@/*' look like scoped packages to the plugin, which 'ignorePackages' would skip
pathGroupOverrides: [
{ pattern: '@core/packages/*', action: 'ignore' }, // barrel files
{ pattern: '@core/icons', action: 'ignore' },
{ pattern: '@core/**', action: 'enforce' },
{ pattern: '@/**', action: 'enforce' },
],
},
],
'import/order': [
'error',
{
@@ -266,11 +251,6 @@ module.exports = {
'ja',
'fi',
'pl',
'zh-Hans',
'sk',
'pt',
'ar',
'tr',
],
},
],
@@ -298,7 +278,7 @@ module.exports = {
},
},
{
files: ['@xen-orchestra/{web-core,lite,web}/src/route-map.d.ts'],
files: ['@xen-orchestra/{web-core,lite,web}/typed-router.d.ts'],
parserOptions: {
sourceType: 'module',
},
@@ -307,17 +287,6 @@ module.exports = {
'eslint-comments/no-unlimited-disable': 'off',
},
},
{
files: ['*.d.ts'],
parser: '@typescript-eslint/parser',
parserOptions: {
sourceType: 'module',
},
rules: {
'n/no-unsupported-features/es-syntax': 'off',
'no-unused-vars': 'off',
},
},
],
ignorePatterns: ['@vates/fatfs/'],

View File

@@ -1,36 +0,0 @@
name: Changelog
# only pull requests targeting `master`: the check compares the branch to
# `origin/master`, it would report unrelated packages on a PR based on another
# branch
on:
pull_request:
branches:
- master
jobs:
changed-packages:
# the release PR bumps every listed package and empties the list
if: github.head_ref != 'release'
runs-on: ubuntu-latest
steps:
# https://github.com/actions/checkout
- uses: actions/checkout@v4
with:
# the whole history is required to compute the merge base with `master`
fetch-depth: 0
# the check only needs commits and trees: `git diff --name-only
# --no-renames` compares tree entries and never reads file contents,
# skipping the historical blobs halves the fetch (~112 MiB → ~53 MiB)
filter: blob:none
# https://github.com/actions/setup-node
- name: Setup Node environment
uses: actions/setup-node@v4
with:
node-version: '>=22.12'
# the script only uses Node builtins, no `yarn install` necessary
- name: Ensure changed packages are listed in CHANGELOG.unreleased.md
run: ./scripts/check-changed-pkgs.js

View File

@@ -10,7 +10,7 @@ jobs:
- name: Install packages
run: |
sudo apt-get update
sudo apt-get install -y curl qemu-utils python3-vmdkstream git libxml2-utils libfuse2 nbdkit open-iscsi
sudo apt-get install -y curl qemu-utils python3-vmdkstream git libxml2-utils libfuse2 nbdkit
# https://turbo.build/repo/docs/guides/ci-vendors/github-actions#caching-with-github-actionscache
- name: Cache Turbo
@@ -25,7 +25,7 @@ jobs:
- name: Setup Node environment
uses: actions/setup-node@v4
with:
node-version: '>=22.12'
node-version: '20'
cache: 'yarn'
- name: Install project dependencies

View File

@@ -1,73 +0,0 @@
name: Vectura
# only when vectura changes: the rest of the repository does not build it
#
# pull requests rather than every push: GitHub filters a push on the diff with
# the previous push, which after a rebase on `master` (or a merge of it)
# contains the vectura changes of `master`, whereas it filters a pull request
# on the diff with the merge base, i.e. the changes of the branch only
on:
pull_request:
paths:
- '@xen-orchestra/vmware-explorer/vectura/**'
- .github/workflows/vectura.yml
push:
branches:
- master
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: cargo-deny@0.20.2,cargo-llvm-cov@0.8.5
- 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 release.sh does, checks it, then checks the one in git,
# which xo-server runs, is that build
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: cargo-zigbuild@0.23.4
- run: scripts/build.sh "$RUNNER_TEMP/build"
- run: scripts/check-binary.sh "$RUNNER_TEMP/build/vectura"
- name: The binary in git is the build of these sources
run: |
cmp "$RUNNER_TEMP/build/vectura" vectura || {
echo '::error::vectura is not the build of these sources: run scripts/build.sh . in vectura/ and commit the binary'
exit 1
}

1
.gitignore vendored
View File

@@ -5,7 +5,6 @@
/@vates/*/dist/
/@vates/*/node_modules/
/@vates/node-vsphere-soap/test/config-test.mjs
/@xen-orchestra/*/dist/
/@xen-orchestra/*/node_modules/
/@xen-orchestra/rest-api/open-api/spec/swagger.json

View File

@@ -3,9 +3,4 @@
**/*.hbs
# Generated files
/@xen-orchestra/web/src/route-map.d.ts
/@xen-orchestra/lite/src/route-map.d.ts
# Docusaurus MDX content: prettier's markdown parser corrupts JSX
# comments ({/* … */} becomes {/_ … _/}) and other MDX constructs
/docs/docs/**/*.md
/@xen-orchestra/web/typed-router.d.ts

View File

@@ -1,5 +0,0 @@
export function asyncEach<Item>(
iterable: Iterable<Item> | AsyncIterable<Item>,
iteratee: (item: Item, index: number, iterable: Iterable<Item> | AsyncIterable<Item>) => Promise<void>,
opts?: { concurrency?: number; signal?: AbortSignal; stopOnError?: boolean }
): Promise<void>

View File

@@ -67,17 +67,20 @@ describe('asyncEach', () => {
})
it('stops on first error when stopOnError is true', async () => {
const tracker = new assert.CallTracker()
const error = new Error()
const iteratee = spy((_, i) => {
const iteratee = tracker.calls((_, i) => {
if (i === 1) {
throw error
}
})
}, 2)
assert.deepStrictEqual(
await rejectionOf(asyncEach(iterable, iteratee, { concurrency: 1, stopOnError: true })),
error
)
assert.deepStrictEqual(iteratee.callCount, 2)
tracker.verify()
})
it('rejects AggregateError when stopOnError is false', async () => {
@@ -97,16 +100,19 @@ describe('asyncEach', () => {
})
it('can be interrupted with an AbortSignal', async () => {
const tracker = new assert.CallTracker()
const ac = new AbortController()
const iteratee = spy((_, i) => {
const iteratee = tracker.calls((_, i) => {
if (i === 1) {
ac.abort()
}
})
}, 2)
await assert.rejects(asyncEach(iterable, iteratee, { concurrency: 1, signal: ac.signal }), {
message: 'asyncEach aborted',
})
assert.deepStrictEqual(iteratee.callCount, 2)
tracker.verify()
})
it('handle error on iterable', async () => {

View File

@@ -24,8 +24,7 @@
"url": "https://vates.fr"
},
"license": "ISC",
"types": "index.d.ts",
"version": "1.0.3",
"version": "1.0.1",
"engines": {
"node": ">=8.10"
},

View File

@@ -24,8 +24,8 @@
},
"dependencies": {
"@vates/multi-key-map": "^0.2.0",
"@xen-orchestra/async-map": "^0.1.5",
"@xen-orchestra/log": "^0.7.2",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/log": "^0.7.1",
"ensure-array": "^1.0.0"
},
"devDependencies": {

View File

@@ -1,12 +1,12 @@
import Fuse from 'fuse-native'
import LRU from 'lru-cache'
import Fuse from 'fuse-native'
import { createLogger } from '@xen-orchestra/log'
import { VhdSynthetic } from 'vhd-lib'
import { Disposable, fromCallback } from 'promise-toolbox'
const { warn } = createLogger('vates:fuse-vhd')
// build a stat object from https://github.com/fuse-friends/fuse-native/blob/master/test/fixtures/stat.js
// build a s stat object from https://github.com/fuse-friends/fuse-native/blob/master/test/fixtures/stat.js
const stat = st => ({
mtime: st.mtime || new Date(),
atime: st.atime || new Date(),
@@ -20,109 +20,42 @@ const stat = st => ({
export const mount = Disposable.factory(async function* mount(handler, diskPath, mountDir) {
const vhd = yield VhdSynthetic.fromVhdChain(handler, diskPath)
const cache = new LRU({
max: 16, // each cached block is 2MB in size
})
await vhd.readBlockAllocationTable()
const { blockSize } = vhd.header
// A zeroed buffer returned for VHD blocks that are not present in the chain.
// Never mutated — safe to share across all reads.
const EMPTY_BLOCK = Buffer.alloc(blockSize, 0)
// --- coalescing block read queue ---
//
// FUSE read requests from NTFS-3g arrive in small chunks (typically 4 KiB)
// while VHD blocks are 2 MiB. Reading blocks one at a time from the backing
// store (CIFS/NFS/…) bounds libuv threadpool usage to 1 concurrent I/O call.
//
// Coalescing: all pending requests for the same blockId share a single
// readBlock() call. When the block resolves, every waiter is fulfilled at once.
//
// Together these two properties prevent the FUSE-on-FUSE threadpool deadlock:
// concurrent NTFS-3g reads no longer starve the VHD block reads they depend on.
// In-memory block cache. Each VHD block is 2 MiB; 16 blocks = 32 MiB.
// This is the primary tool against the 300× CIFS amplification: NTFS-3g
// reads a 2 MiB block in ~512 sequential 4 KiB FUSE requests. Without a
// cache, each request causes a full 2 MiB CIFS read. With the cache, only
// the first request reads from CIFS; the rest are served from RAM.
const blockCache = new LRU({ max: 64 })
const pendingBlocks = new Map() // blockId → Promise<Buffer>
const readQueue = [] // { blockId, resolve, reject }[]
let queueRunning = false
// log stats every 10 s while there is activity
function enqueueBlock(blockId) {
// 1. serve from cache if available
if (blockCache.has(blockId)) {
return Promise.resolve(blockCache.get(blockId))
}
// 2. coalesce: join an existing in-flight read for this block
if (pendingBlocks.has(blockId)) {
return pendingBlocks.get(blockId)
}
const p = new Promise((resolve, reject) => {
readQueue.push({ blockId, resolve, reject })
})
pendingBlocks.set(blockId, p)
p.then(
data => {
pendingBlocks.delete(blockId)
blockCache.set(blockId, data)
},
() => pendingBlocks.delete(blockId)
)
drainQueue()
return p
}
function drainQueue() {
if (queueRunning) return
queueRunning = true
// async IIFE — errors are forwarded to individual reject() callbacks,
// so the outer floating promise is always fulfilled
;(async () => {
while (readQueue.length > 0) {
const { blockId, resolve, reject } = readQueue.shift()
try {
resolve((await vhd.readBlock(blockId)).data)
} catch (err) {
reject(err)
}
}
queueRunning = false
})()
}
async function readData(buf, len, pos) {
if (len === 0) return 0
const startBlockId = Math.floor(pos / blockSize)
// use (pos + len - 1) so an exact block boundary doesn't include the next block
const endBlockId = Math.floor((pos + len - 1) / blockSize)
let copied = 0
for (let blockId = startBlockId; blockId <= endBlockId; blockId++) {
const data = vhd.containsBlock(blockId) ? await enqueueBlock(blockId) : EMPTY_BLOCK
const offsetStart = blockId === startBlockId ? pos % blockSize : 0
const offsetEnd = blockId === endBlockId ? ((pos + len - 1) % blockSize) + 1 : blockSize
data.copy(buf, copied, offsetStart, offsetEnd)
copied += offsetEnd - offsetStart
}
return copied
}
const fuse = new Fuse(mountDir, {
async readdir(path, cb) {
if (path === '/') return cb(null, ['vhd0'])
if (path === '/') {
return cb(null, ['vhd0'])
}
cb(Fuse.ENOENT)
},
async getattr(path, cb) {
if (path === '/') return cb(null, stat({ mode: 'dir', size: 4096 }))
if (path === '/vhd0') return cb(null, stat({ mode: 'file', size: vhd.footer.currentSize }))
if (path === '/') {
return cb(
null,
stat({
mode: 'dir',
size: 4096,
})
)
}
if (path === '/vhd0') {
return cb(
null,
stat({
mode: 'file',
size: vhd.footer.currentSize,
})
)
}
cb(Fuse.ENOENT)
},
read(path, fd, buf, len, pos, cb) {
if (path === '/vhd0') {
return readData(buf, len, pos).then(cb, error => {
return vhd.readRawData(pos, len, cache, buf).then(cb, error => {
warn('read error', { path, len, pos, error })
cb(Fuse.EIO)
})
@@ -131,9 +64,7 @@ export const mount = Disposable.factory(async function* mount(handler, diskPath,
},
})
return new Disposable(
() => {
return fromCallback(cb => fuse.unmount(cb))
},
() => fromCallback(cb => fuse.unmount(cb)),
fromCallback(cb => fuse.mount(cb))
)
})

View File

@@ -1,6 +1,6 @@
{
"name": "@vates/fuse-vhd",
"version": "2.1.3",
"version": "2.1.2",
"license": "ISC",
"private": false,
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/fuse-vhd",
@@ -19,12 +19,12 @@
},
"main": "./index.mjs",
"dependencies": {
"@xen-orchestra/fs": "^4.10.0",
"@xen-orchestra/log": "^0.7.2",
"@xen-orchestra/fs": "^4.6.6",
"@xen-orchestra/log": "^0.7.1",
"fuse-native": "^2.2.6",
"lru-cache": "^7.14.0",
"promise-toolbox": "^0.21.0",
"vhd-lib": "^4.17.0"
"vhd-lib": "^4.14.7"
},
"bin": {
"xo-fuse-vhd": "./cli.mjs"

View File

@@ -1,6 +1,6 @@
{
"name": "@vates/generator-toolbox",
"version": "1.1.3",
"version": "1.1.0",
"main": "dist/index.mjs",
"license": "MIT",
"private": false,
@@ -10,7 +10,7 @@
"rimraf": "^6.0.1",
"@types/node": "^20.6",
"typescript": "~5.6",
"typescript-eslint": "^8.61.0"
"typescript-eslint": "^8.23.0"
},
"scripts": {
"build": "tsc",

View File

@@ -144,7 +144,14 @@ class Forked<T, TReturn, TNext> implements AsyncGenerator<T, TReturn, TNext> {
return this.#parent.remove(this.#uid, e)
}
[Symbol.asyncIterator](): AsyncGenerator<T, TReturn, TNext> {
return this
async *[Symbol.asyncIterator](): AsyncGenerator<T> {
while (true) {
const res = await this.next()
if (res.done) {
break
}
yield res.value
}
}
}

View File

@@ -2,7 +2,8 @@ import assert from 'node:assert'
import { suite, test } from 'node:test'
import { Synchronized } from './synchronized.mjs'
async function* makeRangeGenerator(end = Infinity, progress = { yielded: 0 }, onYielded = (_val: unknown) => {}) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function* makeRangeGenerator(end = Infinity, progress = { yielded: 0 }, onYielded = (val: unknown) => {}) {
for (let i = 0; i < end; i++) {
await new Promise(resolve => setTimeout(resolve, 10))
yield i
@@ -16,7 +17,7 @@ async function consume(
iterable: AsyncGenerator,
delay = 2500,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onConsumed = (_val: unknown, _iterable: AsyncGenerator) => Promise.resolve(false)
onConsumed = (val: unknown, iterable: AsyncGenerator) => Promise.resolve(false)
) {
for await (const val of iterable) {
await new Promise(resolve => setTimeout(resolve, delay))
@@ -27,30 +28,6 @@ async function consume(
}
suite('success', () => {
test('for-await-of break stops the source generator', async () => {
let sourceClosed = false
const source = (async function* () {
try {
for (let i = 0; i < 10; i++) {
await new Promise(resolve => setTimeout(resolve, 10))
yield i
}
} finally {
sourceClosed = true
}
})()
const forker = new Synchronized(source)
const fork = forker.fork('first')
// for-await-of must call fork.return() on break, which propagates to the source
for await (const val of fork) {
if (val === 2) break
}
assert.strictEqual(sourceClosed, true)
})
test('if works with multiple consumer', async () => {
const progress = { yielded: 0 }
const generator = makeRangeGenerator(3, progress)

View File

@@ -1,7 +1,4 @@
import assert from 'node:assert'
const TIMEOUT = Symbol('timeout')
export class Timeout<T> implements AsyncGenerator {
#source: AsyncGenerator<T>
#timeout: number
@@ -11,30 +8,21 @@ export class Timeout<T> implements AsyncGenerator {
this.#timeout = timeout
}
async next(): Promise<IteratorResult<T>> {
let timeoutHandle: ReturnType<typeof setTimeout>
const sourceNext = this.#source.next()
const result = await Promise.race([
sourceNext.then(res => {
clearTimeout(timeoutHandle)
return res
}),
new Promise<typeof TIMEOUT>(resolve => {
timeoutHandle = setTimeout(() => resolve(TIMEOUT), this.#timeout)
}),
])
if (result === TIMEOUT) {
// stop the source once the in-flight next settles to avoid data loss on the next call
// we don't want to await for the end of timeouted code
sourceNext.then(
() => this.#source.return(undefined).catch(() => {}),
() => {} // source already errored, nothing to clean up
)
throw new Error('Timeout reached')
}
return result
let timeout: ReturnType<typeof setTimeout>
const promiseTimeout = new Promise((_, reject) => {
timeout = setTimeout(() => {
reject(new Error('Timeout reached '))
}, this.#timeout)
})
const promiseNext = new Promise((resolve, reject) => {
this.#source.next().then(res => {
// ensure timetout won't fire later
clearTimeout(timeout)
resolve(res)
}, reject)
})
// promiseTimeout will never resolve
return Promise.race([promiseNext, promiseTimeout]) as Promise<IteratorResult<T>>
}
return(): Promise<IteratorResult<T>> {
return this.#source.return(undefined)

View File

@@ -64,24 +64,4 @@ describe('Timeout class', () => {
await assert.rejects(timeout.throw(new Error('Test error')), 'Expected error was not thrown')
})
it('should close the source generator after a timeout', async () => {
let sourceClosed = false
const slowSource = (async function* () {
try {
// resolves after 100ms so the in-flight next() eventually settles
await new Promise(resolve => setTimeout(resolve, 100))
yield 1
} finally {
sourceClosed = true
}
})()
const timeout = new Timeout(slowSource, 10) // fires before source yields
await assert.rejects(() => timeout.next(), /Timeout reached/)
// wait for the in-flight next() to settle so source.return() can be called
await new Promise(resolve => setTimeout(resolve, 200))
assert.strictEqual(sourceClosed, true)
})
})

View File

@@ -1,65 +0,0 @@
### `create([opts], [requestListener])`
Creates a new server instance. `opts` may contain `createServer` and `createSecureServer` factories (defaults to `http.createServer` and `https.createServer`). `requestListener` is registered as a `request` event listener.
```js
import { create } from '@vates/http-server-plus'
const server = create(function onRequest(req, res) {
res.end('Hello')
})
```
### `server.listen(opts)`
Starts listening. Returns a promise that resolves to a human-readable address string, or rejects with `error.niceAddress` set.
```js
// HTTP on a specific port
await server.listen({ port: 8080 })
// HTTPS — detected automatically when cert+key are provided
await server.listen({
port: 443,
cert: fs.readFileSync('certificate.pem'),
key: fs.readFileSync('private_key.pem'),
})
// Unix socket
await server.listen({ socket: '/var/run/http.sock' })
// File descriptor
await server.listen({ fd: 3 })
// Systemd socket activation
await server.listen({ systemdSocket: 0 })
```
> As a convenience, if `hostname` is `'localhost'`, it will listen on both `127.0.0.1` and `::1`, similar to what Node does when no hostname is provided.
Multiple endpoints can be started on the same instance:
```js
const [httpAddr, httpsAddr] = await Promise.all([server.listen({ port: 80 }), server.listen({ port: 443, cert, key })])
console.log('listening on', httpAddr, httpsAddr)
```
### HTTP/2
Pass a custom `createSecureServer` factory to enable HTTP/2:
```js
import { createSecureServer } from 'node:http2'
import { create } from '@vates/http-server-plus'
const server = create({ createSecureServer }, app)
await server.listen({ port: 443, cert, key })
```
### `server.close()`
Closes all underlying servers. Returns a promise that resolves once the `close` event is emitted.
```js
await server.close()
```

View File

@@ -1 +0,0 @@
../../scripts/npmignore

View File

@@ -1,98 +0,0 @@
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
# @vates/http-server-plus
[![Package Version](https://badgen.net/npm/v/@vates/http-server-plus)](https://npmjs.org/package/@vates/http-server-plus) ![License](https://badgen.net/npm/license/@vates/http-server-plus) [![PackagePhobia](https://badgen.net/bundlephobia/minzip/@vates/http-server-plus)](https://bundlephobia.com/result?p=@vates/http-server-plus) [![Node compatibility](https://badgen.net/npm/node/@vates/http-server-plus)](https://npmjs.org/package/@vates/http-server-plus)
> Augmented &#x60;http.Server&#x60;, HTTP/HTTPS/HTTP2 and multiple ports on the same instance
## Install
Installation of the [npm package](https://npmjs.org/package/@vates/http-server-plus):
```sh
npm install --save @vates/http-server-plus
```
## Usage
### `create([opts], [requestListener])`
Creates a new server instance. `opts` may contain `createServer` and `createSecureServer` factories (defaults to `http.createServer` and `https.createServer`). `requestListener` is registered as a `request` event listener.
```js
import { create } from '@vates/http-server-plus'
const server = create(function onRequest(req, res) {
res.end('Hello')
})
```
### `server.listen(opts)`
Starts listening. Returns a promise that resolves to a human-readable address string, or rejects with `error.niceAddress` set.
```js
// HTTP on a specific port
await server.listen({ port: 8080 })
// HTTPS — detected automatically when cert+key are provided
await server.listen({
port: 443,
cert: fs.readFileSync('certificate.pem'),
key: fs.readFileSync('private_key.pem'),
})
// Unix socket
await server.listen({ socket: '/var/run/http.sock' })
// File descriptor
await server.listen({ fd: 3 })
// Systemd socket activation
await server.listen({ systemdSocket: 0 })
```
> As a convenience, if `hostname` is `'localhost'`, it will listen on both `127.0.0.1` and `::1`, similar to what Node does when no hostname is provided.
Multiple endpoints can be started on the same instance:
```js
const [httpAddr, httpsAddr] = await Promise.all([server.listen({ port: 80 }), server.listen({ port: 443, cert, key })])
console.log('listening on', httpAddr, httpsAddr)
```
### HTTP/2
Pass a custom `createSecureServer` factory to enable HTTP/2:
```js
import { createSecureServer } from 'node:http2'
import { create } from '@vates/http-server-plus'
const server = create({ createSecureServer }, app)
await server.listen({ port: 443, cert, key })
```
### `server.close()`
Closes all underlying servers. Returns a promise that resolves once the `close` event is emitted.
```js
await server.close()
```
## Contributions
Contributions are _very_ welcomed, either on the documentation or on
the code.
You may:
- report any [issue](https://github.com/vatesfr/xen-orchestra/issues)
you've encountered;
- fork and create a pull request.
## License
[ISC](https://spdx.org/licenses/ISC) © [Vates SAS](https://vates.fr)

View File

@@ -1,268 +0,0 @@
'use strict'
// ===================================================================
const EventEmitter = require('events').EventEmitter
const formatUrl = require('url').format
const fromEvent = require('promise-toolbox/fromEvent')
const inherits = require('util').inherits
const resolvePath = require('path').resolve
// ===================================================================
const forwardedEvents = [
'checkContinue',
'checkExpectation',
'clientError',
'connect',
'connection',
'listening',
'request',
'secureConnection',
'upgrade',
]
function extractProperty(obj, prop) {
const value = obj[prop]
if (value !== undefined) {
delete obj[prop]
return value
}
}
function forOwn(obj, cb) {
const keys = Object.keys(obj)
for (let i = 0, n = keys.length; i < n; ++i) {
cb(obj[keys[i]])
}
}
function getSystemdFd(index) {
if (process.pid !== +process.env.LISTEN_PID) {
throw new Error('systemd sockets are not meant for us (LISTEN_PID)')
}
const listenFds = process.env.LISTEN_FDS
if (listenFds == null) {
throw new Error('no systemd sockets found (LISTEN_FDS)')
}
const max = listenFds - 1
if (index > max) {
throw new Error('no such systemd socket found')
}
return index + 3 // 3 = SD_LISTEN_FDS_START
}
const isEmpty = obj => Object.keys(obj).length === 0
// ===================================================================
function onNewListener(event, listener) {
if (forwardedEvents.includes(event)) {
forOwn(this._servers, server => {
server.on(event, listener)
})
}
}
function onRemoveListener(event, listener) {
if (forwardedEvents.includes(event)) {
forOwn(this._servers, server => {
server.off(event, listener)
})
}
}
function Server(opts) {
EventEmitter.call(this)
this._createServer = (opts != null && opts.createServer) || require('http').createServer
this._createSecureServer = (opts != null && opts.createSecureServer) || require('https').createServer
this._servers = Object.create(null)
this._niceAddresses = Object.create(null)
this.on('removeListener', onRemoveListener).on('newListener', onNewListener)
}
inherits(Server, EventEmitter)
const proto = Server.prototype
function getAddress(serverId) {
return this[serverId].address()
}
proto.addresses = function Server$addresses() {
const servers = this._servers
return Object.keys(servers).map(getAddress, servers)
}
function getNiceAddress(serverId) {
return this[serverId]
}
proto.niceAddresses = function Server$niceAddresses() {
const niceAddresses = this._niceAddresses
return Object.keys(niceAddresses).map(getNiceAddress, niceAddresses)
}
function close(server) {
server.close()
}
proto.close = function Server$close(callback) {
if (callback) {
this.once('close', callback)
}
// Emit the close event even if there are no registered servers.
if (isEmpty(this._servers)) {
const self = this
setImmediate(function () {
self.emit('close')
})
return Promise.resolve()
}
// Closes each servers.
forOwn(this._servers, close)
return fromEvent(this, 'close')
}
let nextId = 0
proto.listen = function Server$listen(opts) {
opts = Object.assign({}, opts)
if (opts.hostname === 'localhost') {
return Promise.all([
this.listen(Object.assign(opts, { hostname: '::1' })),
this.listen(Object.assign(opts, { hostname: '127.0.0.1' })),
]).then(addresses => addresses[0])
}
const hostname = extractProperty(opts, 'hostname')
let port = extractProperty(opts, 'port')
const systemdSocket = extractProperty(opts, 'systemdSocket')
let fd = extractProperty(opts, 'fd')
let socket = extractProperty(opts, 'socket')
const servers = this._servers
const niceAddresses = this._niceAddresses
let server, protocol
if (opts.pfx || opts.SNICallback || (opts.cert && opts.key)) {
server = this._createSecureServer(opts)
protocol = 'https'
} else {
server = this._createServer(opts)
protocol = 'http'
}
const id = nextId++
servers[id] = server
if (systemdSocket != null) {
fd = getSystemdFd(systemdSocket)
}
// Compute a temporary nice address to display in case of error.
let niceAddress
if (fd != null) {
server.listen({ fd })
niceAddress = protocol + '://<fd:' + fd + '>'
} else if (socket != null) {
socket = resolvePath(socket)
server.listen(socket)
niceAddress = protocol + '://' + socket
} else {
if (port == null) {
port = protocol === 'https' ? 443 : 80
}
server.listen(port, hostname)
niceAddress = formatUrl({
protocol,
// Hostname default to localhost.
hostname: hostname || 'localhost',
// No port means random, unknown for now.
port: port || '<unknown>',
})
}
const emit = this.emit.bind(this)
server.once('close', function onClose() {
delete servers[id]
delete niceAddresses[id]
if (isEmpty(servers)) {
emit('close')
}
})
server.on('error', function onError() {
delete servers[id]
// FIXME: Should it be forwarded and be fatal if there are no
// listeners?
})
forwardedEvents.forEach(event => {
this.listeners(event).forEach(listener => {
server.on(event, listener)
})
})
return fromEvent(server, 'listening').then(
function () {
const address = server.address()
const niceAddress =
typeof address === 'string'
? protocol + '://' + address
: formatUrl({
protocol,
hostname: address.address,
port: address.port,
})
niceAddresses[id] = niceAddress
return niceAddress
},
function (error) {
error.niceAddress = niceAddress
throw error
}
)
}
function ref(server) {
server.ref()
}
proto.ref = function Server$ref() {
forOwn(this._servers, ref)
}
function unref(server) {
server.unref()
}
proto.unref = function Server$unref() {
forOwn(this._servers, unref)
}
// ===================================================================
module.exports = exports = Server
exports.create = function create(opts, requestListener) {
if (typeof opts === 'function') {
requestListener = opts
opts = undefined
}
const server = new Server(opts)
if (requestListener !== undefined) {
server.on('request', requestListener)
}
return server
}

View File

@@ -1,303 +0,0 @@
'use strict'
// ===================================================================
const assert = require('assert/strict')
const { EventEmitter } = require('events')
const resolvePath = require('path').resolve
const { describe, it } = require('node:test')
const HttpServerPlus = require('./')
// ===================================================================
// Creates a minimal mock server that can be used as a drop-in for http/https.Server.
// `address` is the value returned by mock.address() — use an object for TCP, a string for sockets.
function createMock(address) {
const mock = new EventEmitter()
mock.listen = function () {
setImmediate(() => this.emit('listening'))
}
mock.address = () => address
mock.close = function () {
setImmediate(() => this.emit('close'))
}
return mock
}
// ===================================================================
describe('HttpServerPlus', function () {
describe('#create()', function () {
it('creates a new instance', function () {
assert(HttpServerPlus.create() instanceof HttpServerPlus)
})
it('can register a `request` listener', function () {
const listener = function () {}
const server = HttpServerPlus.create(listener)
assert.deepEqual(server.listeners('request'), [listener])
})
})
describe('.addresses()', function () {
it('returns the list of addresses the server is listening at', function () {
const addr = { address: '127.0.0.1', port: 8080, family: 'IPv4' }
const mock = createMock(addr)
const server = HttpServerPlus.create({ createServer: () => mock })
return server.listen({ port: 8080 }).then(function () {
assert.deepEqual(server.addresses(), [addr])
return server.close()
})
})
})
describe('.niceAddresses()', function () {
it('returns the list of human readable addresses the server is listening at', function () {
const mock = createMock({ address: '127.0.0.1', port: 8080 })
const server = HttpServerPlus.create({ createServer: () => mock })
return server.listen({ hostname: '127.0.0.1', port: 8080 }).then(function (address) {
assert.deepEqual(server.niceAddresses(), [address])
return server.close()
})
})
})
describe('secureConnection event forwarding', function () {
function createMockHttpsServer() {
const server = new EventEmitter()
server.listen = function () {
setImmediate(() => this.emit('listening'))
}
server.address = () => ({ address: '127.0.0.1', port: 8443 })
server.close = function () {
setImmediate(() => this.emit('close'))
}
return server
}
function createServerWithMock() {
let mockServer
const server = HttpServerPlus.create({
createSecureServer: () => {
mockServer = createMockHttpsServer()
return mockServer
},
})
return {
server,
getMock: () => mockServer,
}
}
it('forwards secureConnection to listeners registered before listen()', function () {
const { server, getMock } = createServerWithMock()
const received = []
server.on('secureConnection', socket => received.push(socket))
const fakeSocket = {}
return server.listen({ cert: 'fake-cert', key: 'fake-key' }).then(function () {
getMock().emit('secureConnection', fakeSocket)
assert.deepEqual(received, [fakeSocket])
return server.close()
})
})
it('forwards secureConnection to listeners registered after listen()', function () {
const { server, getMock } = createServerWithMock()
return server.listen({ cert: 'fake-cert', key: 'fake-key' }).then(function () {
const received = []
server.on('secureConnection', socket => received.push(socket))
const fakeSocket = {}
getMock().emit('secureConnection', fakeSocket)
assert.deepEqual(received, [fakeSocket])
return server.close()
})
})
it('stops forwarding secureConnection after listener is removed', function () {
const { server, getMock } = createServerWithMock()
const received = []
const listener = socket => received.push(socket)
server.on('secureConnection', listener)
return server.listen({ cert: 'fake-cert', key: 'fake-key' }).then(function () {
server.off('secureConnection', listener)
getMock().emit('secureConnection', {})
assert.deepEqual(received, [])
return server.close()
})
})
})
describe('.listen()', function () {
it('can use a host:port', function () {
let listenArgs
const mock = createMock({ address: '127.0.0.1', port: 8080 })
mock.listen = function (...args) {
listenArgs = args
setImmediate(() => this.emit('listening'))
}
const server = HttpServerPlus.create({ createServer: () => mock })
return server.listen({ hostname: '127.0.0.1', port: 8080 }).then(function () {
assert.deepEqual(listenArgs, [8080, '127.0.0.1'])
return server.close()
})
})
it('can use a systemd socket', function () {
const SD_LISTEN_FDS_START = 3
const SOCKET_INDEX = 0
let listenArgs
const mock = createMock('http://<fd:3>')
mock.listen = function (...args) {
listenArgs = args
setImmediate(() => this.emit('listening'))
}
const server = HttpServerPlus.create({ createServer: () => mock })
const env = process.env
env.LISTEN_FDS = String(SOCKET_INDEX + 1)
env.LISTEN_PID = String(process.pid)
return server.listen({ systemdSocket: SOCKET_INDEX }).then(function () {
delete env.LISTEN_FDS
delete env.LISTEN_PID
assert.deepEqual(listenArgs, [{ fd: SOCKET_INDEX + SD_LISTEN_FDS_START }])
return server.close()
})
})
it('can use a socket', function () {
const socketPath = '/tmp/http-server-plus-test.sock'
let listenArgs
const mock = createMock(socketPath)
mock.listen = function (...args) {
listenArgs = args
setImmediate(() => this.emit('listening'))
}
const server = HttpServerPlus.create({ createServer: () => mock })
return server.listen({ socket: socketPath }).then(function () {
assert.deepEqual(listenArgs, [resolvePath(socketPath)])
return server.close()
})
})
it('can use a HTTPS certificate', function () {
let capturedOpts
const mock = createMock({ address: '127.0.0.1', port: 8443 })
const server = HttpServerPlus.create({
createSecureServer: opts => {
capturedOpts = opts
return mock
},
})
return server.listen({ cert: 'my-cert', key: 'my-key', port: 8443 }).then(function (address) {
assert.equal(capturedOpts.cert, 'my-cert')
assert.equal(capturedOpts.key, 'my-key')
assert.ok(address.startsWith('https://'), `expected https URL, got: ${address}`)
return server.close()
})
})
it('returns a promise which will resolve once listening', function () {
const mock = createMock({ address: '127.0.0.1', port: 8080 })
const server = HttpServerPlus.create({ createServer: () => mock })
return server.listen({ port: 8080 }).then(function (address) {
assert.equal(typeof address, 'string')
assert.ok(address.startsWith('http://'), `expected http URL, got: ${address}`)
return server.close()
})
})
it('returns a promise which will reject if failure', function () {
const listenError = new Error('EADDRINUSE')
const mock = createMock(null)
mock.listen = function () {
setImmediate(() => this.emit('error', listenError))
}
const server = HttpServerPlus.create({ createServer: () => mock })
return server.listen({ port: 8080 }).then(
function () {
assert.fail('should have rejected')
},
function (err) {
assert.equal(err, listenError)
assert.equal(typeof err.niceAddress, 'string')
}
)
})
})
describe('.close()', function () {
it('closes all servers', function () {
const closed = []
let callCount = 0
const server = HttpServerPlus.create({
createServer: () => {
const id = callCount++
const mock = createMock({ address: '127.0.0.1', port: id })
mock.close = function () {
closed.push(id)
setImmediate(() => this.emit('close'))
}
return mock
},
})
return Promise.all([server.listen({ port: 0 }), server.listen({ port: 0 })])
.then(function () {
return server.close()
})
.then(function () {
assert.equal(closed.length, 2)
})
})
it('emit the `close` event when all servers are closed', function () {
const mock = createMock({ address: '127.0.0.1', port: 8080 })
const server = HttpServerPlus.create({ createServer: () => mock })
let closeEmitted = false
server.on('close', () => {
closeEmitted = true
})
return server
.listen({ port: 8080 })
.then(function () {
return server.close()
})
.then(function () {
assert.ok(closeEmitted)
})
})
it('emit the `close` event even if there are no servers', function () {
const server = HttpServerPlus.create()
// close() with no servers returns Promise.resolve() immediately (before emitting
// 'close' via setImmediate), so we must wait for the event itself, not the promise.
return new Promise(function (resolve) {
server.close(resolve)
})
})
})
})

View File

@@ -1,38 +0,0 @@
{
"name": "@vates/http-server-plus",
"version": "2.0.2",
"license": "ISC",
"description": "Augmented `http.Server`, HTTP/HTTPS/HTTP2 and multiple ports on the same instance",
"keywords": [
"http",
"https",
"server",
"ports"
],
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/http-server-plus",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {
"directory": "@vates/http-server-plus",
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"promise-toolbox": "^0.21.0"
},
"devDependencies": {
"eslint-plugin-n": "^15.1.0"
},
"scripts": {
"pretest": "eslint .",
"test": "node --test index.spec.js",
"postversion": "npm publish --access public"
},
"private": false,
"author": {
"name": "Vates SAS",
"url": "https://vates.fr"
}
}

View File

@@ -1,261 +0,0 @@
A small, dependency-light iSCSI **target** (server) written in TypeScript. It
exposes exactly one read/write LUN backed by a pluggable byte-range
`BlockDevice`, and interoperates with the standard Linux **open-iscsi**
initiator (the same one XCP-ng/XAPI uses to attach an `lvmoiscsi` SR).
### Scope
The target drives login negotiation onto a single, fixed code path:
- single initiator, **`MaxConnections=1`**, **`ErrorRecoveryLevel=0`**
- no header/data digests (`HeaderDigest=None`, `DataDigest=None`)
- all writes are R2T-solicited (`InitialR2T=Yes`, `ImmediateData=No`)
- one LUN (LUN 0)
It implements SendTargets discovery, login (`AuthMethod=None` or one-way
**CHAP** — see [Authentication](#authentication-chap)), the ~10 SCSI
commands a Linux initiator issues to attach and use a block device
(`INQUIRY` + VPD `0x00`/`0x80`/`0x83`/`0xb0`, `REPORT LUNS`, `READ CAPACITY 10/16`,
`READ`/`WRITE 10/16`, `TEST UNIT READY`, `REQUEST SENSE`, `MODE SENSE`,
`SYNCHRONIZE CACHE`), the full R2T / Data-Out write path, NOP keepalive replies,
and Logout. Commands may be outstanding concurrently (Data-Out PDUs are routed
by Initiator Task Tag), so it does not stall under real guest I/O.
The package also ships a userspace **initiator** (`IscsiInitiator`) and an
`IscsiDisk` adapter that exposes a remote LUN as an `@xen-orchestra/disk-transform`
`RandomAccessDisk` — see [Initiator](#initiator).
Not implemented (yet): mutual (two-way) CHAP, Task Management (abort/reset),
target-initiated NOP keepalives, multiple connections/LUNs.
What a peer can make the target do is bounded:
- connections that have not logged in yet are limited in time (`loginTimeoutMs`)
and number (`maxPendingConnections`), and `allowedInitiatorAddresses` (the
source addresses of the expected initiators) closes the other ones as soon as
they are accepted, reporting them to `onConnectionRefused`
- a PDU data segment is at most 8 KiB during login, then the declared
`MaxRecvDataSegmentLength` (256 KiB), checked before it is read
- a single `READ`/`WRITE` is at most `maxTransferLength` (8 MiB by default),
advertised in the block limits VPD page so that initiators split their I/O
- a `BlockDevice` with `readOnly: true` is reported write-protected, and `WRITE`
is refused with `DATA PROTECT` before any data is solicited
### CLI
The package ships a `vates-iscsi` binary that serves a file as a LUN:
```sh
> vates-iscsi /srv/lun.img
> vates-iscsi /srv/lun.img --serial MYSERIAL01 --port 3260
> vates-iscsi /srv/new.img --size 10G # create/grow the backing file first
```
```
Usage: vates-iscsi <file> [options]
Options:
--serial <serial> LUN serial number (default: derived from the IQN).
--size <bytes> Create/grow the backing file (suffixes K, M, G, T).
--iqn <iqn> Target IQN (default: iqn.2024-01.tech.vates:<file>).
--host <host> Listen address (default: 0.0.0.0).
--port <port> Listen port (default: 3260).
--block-size <bytes> Logical block size (default: 512).
-h, --help Show this help.
```
### Library
```js
import { IscsiTarget, FileBlockDevice } from '@vates/iscsi'
// The backing file must already exist and be sized to the LUN capacity,
// e.g. `fs.truncate('/srv/lun.img', 10 * 1024 ** 3)`.
const target = new IscsiTarget({
iqn: 'iqn.2024-01.tech.vates:lun0',
host: '0.0.0.0',
port: 3260,
lun: new FileBlockDevice({ path: '/srv/lun.img', blockSize: 512 }),
})
await target.listen()
// ... target is now discoverable and attachable by open-iscsi ...
await target.close()
```
#### Adding a target backend
A "backend" is whatever stores the LUN's bytes. The protocol layer translates
SCSI `READ`/`WRITE` CDBs (LBA + block count) into byte offsets/lengths against a
single small interface, so a backend only has to provide flat random access — it
never sees a PDU. `FileBlockDevice` (a sparse file) is the built-in one; implement
`BlockDevice` to expose anything else (a raw device, an object store, an
`@xen-orchestra/disk-transform` `RandomAccessDisk`, …):
```ts
interface BlockDevice {
open?(): Promise<void> // optional, awaited once before serving I/O
getSize(): number // total capacity in bytes; MUST be a multiple of getBlockSize()
getBlockSize(): number // logical block size, typically 512
read(offset: number, length: number): Promise<Buffer> // exactly `length` bytes at byte `offset`
write(offset: number, data: Buffer): Promise<void> // write `data.length` bytes at byte `offset`
flush(): Promise<void> // SYNCHRONIZE CACHE — persist any buffering
close(): Promise<void> // release resources
}
```
Contract to respect:
- **`getSize()` must be a multiple of `getBlockSize()`** and stable for the life of
the device. `open()` is the place to compute it (e.g. `stat` the file).
- **`read` must return exactly `length` bytes** — never short. Zero-fill a sparse
or past-EOF tail rather than returning fewer bytes; the SCSI layer already
bounds `offset + length` to the capacity.
- `offset` and `length` are always block-aligned (multiples of `getBlockSize()`).
- `read`/`write` may be **called concurrently** (commands are interleaved), so the
backend must be safe under overlapping calls; honour back-pressure and don't
buffer unboundedly.
- Throwing from `read`/`write`/`flush` tears the connection down; fail fast and
attach context to the error.
Minimal example — expose a read-only `RandomAccessDisk` (VHD / qcow2 / raw / a
remote NBD or iSCSI LUN) as a target LUN by mapping byte reads onto block reads:
```js
import { IscsiTarget } from '@vates/iscsi'
class RandomAccessDiskLun {
// `disk` is an initialized @xen-orchestra/disk-transform RandomAccessDisk.
constructor(disk) {
this.disk = disk
this.blockSize = disk.getBlockSize()
}
getSize() {
return this.disk.getVirtualSize()
}
getBlockSize() {
return this.blockSize
}
async read(offset, length) {
const out = Buffer.alloc(length) // pre-zeroed: sparse/holes read as zeros
const first = Math.floor(offset / this.blockSize)
const last = Math.floor((offset + length - 1) / this.blockSize)
for (let index = first; index <= last; index++) {
if (!this.disk.hasBlock(index)) {
continue // unallocated: leave zeros
}
const { data } = await this.disk.readBlock(index)
const blockStart = index * this.blockSize
const from = Math.max(0, offset - blockStart)
const to = Math.min(this.blockSize, offset + length - blockStart)
data.copy(out, blockStart + from - offset, from, to)
}
return out
}
// reported write-protected: WRITE is refused before reaching write()
readOnly = true
write() {
throw new Error('read-only LUN')
}
async flush() {}
async close() {
await this.disk.close()
}
}
const target = new IscsiTarget({ iqn: 'iqn.2024-01.tech.vates:disk0', lun: new RandomAccessDiskLun(disk) })
await target.listen()
```
For a writable backend, implement `write(offset, data)` symmetrically and make
`flush()` durable (e.g. `fsync`), as `FileBlockDevice` does.
### Initiator
`IscsiInitiator` is a minimal userspace iSCSI **client**: it connects to one
target portal, logs in, and reads the LUN. `IscsiDisk` wraps it as an
`@xen-orchestra/disk-transform` `RandomAccessDisk`, so a remote LUN drops straight
into the backup/transform pipeline (the iSCSI counterpart of `@vates/nbd-client`'s
`NbdDisk`). It is **read-only**, single-connection, digests off.
```js
import { IscsiInitiator, IscsiDisk } from '@vates/iscsi'
// Low-level byte reads:
const initiator = new IscsiInitiator({
host: '10.0.0.10',
port: 3260,
targetIqn: 'iqn.2024-01.tech.vates:lun0',
chap: { user: 'alice', secret: 's3cr3t' }, // optional, see Authentication
})
await initiator.connect()
console.log(initiator.getSize(), initiator.getBlockSize())
const bytes = await initiator.read(/* offset */ 0, /* length */ 1024 * 1024)
await initiator.close()
// …or as a RandomAccessDisk (blockSize must be a multiple of the LUN block size):
const disk = new IscsiDisk(
{ host: '10.0.0.10', targetIqn: 'iqn.2024-01.tech.vates:lun0' },
{ blockSize: 2 * 1024 * 1024 }
)
await disk.init()
// disk.getVirtualSize(), disk.getBlockIndexes(), await disk.readBlock(i), …
await disk.close()
```
First-cut `IscsiDisk` treats the LUN as fully allocated (every block reported as
present, like `RawDisk`); a changed-block map (e.g. a Pure "Volume Difference")
can narrow it later.
### Authentication (CHAP)
The target and initiator support **one-way CHAP** (MD5) in opposite roles; mutual
(two-way) CHAP is not yet implemented.
**Target as authenticator.** Pass `chap` to require each _data_ session to prove a
credential; the target challenges the initiator and rejects the login (Login
Response status-class _Initiator Error_ / _authentication failure_) on a mismatch:
```js
const target = new IscsiTarget({
iqn: 'iqn.2024-01.tech.vates:lun0',
lun,
chap: { user: 'alice', secret: 's3cr3t' },
})
```
This interoperates with the Linux **open-iscsi** initiator (the one XCP-ng uses);
configure its node record's incoming credential:
```sh
iscsiadm -m node -T <iqn> -p <portal> -o update -n node.session.auth.authmethod -v CHAP
iscsiadm -m node -T <iqn> -p <portal> -o update -n node.session.auth.username -v alice
iscsiadm -m node -T <iqn> -p <portal> -o update -n node.session.auth.password -v s3cr3t
```
CHAP is enforced only on **Normal (data) sessions**; **Discovery** sessions (which
can only run SendTargets and never reach the LUN) stay unauthenticated, so plain
`iscsiadm -m discovery` keeps working. open-iscsi requires the secret to be 12–16
characters.
**Initiator as responder.** Pass `chap` to `IscsiInitiator`/`IscsiDisk` to answer a
target's challenge (e.g. a Pure array acting as authenticator). If the target
_requires_ mutual CHAP the login is cleanly rejected (we never challenge back).
### Security
iSCSI has no data-path encryption of its own. CHAP authenticates the login only;
with no digests, **CDBs and data remain cleartext**. Anyone who can reach an
unauthenticated port can read/write the LUN, and a CHAP-protected one is still
exposed to eavesdropping and tampering on the wire. Expose targets only on a
trusted, isolated storage network, and use **IPsec** (the RFC-specified mechanism)
or equivalent if confidentiality is required.
### Testing
- `yarn test` — fast unit tests (PDU framing, SCSI encoders/decoders, login
negotiation, sequence-number arithmetic).
- `yarn test-integration` — a self-contained loopback test (an in-process
minimal initiator drives discovery, identity, write/read-back, and command
interleaving), plus an open-iscsi interop test that **requires root and
`iscsiadm`** and skips gracefully otherwise.

View File

@@ -1 +0,0 @@
../../scripts/npmignore

View File

@@ -1,124 +0,0 @@
# Protocol coverage
`@vates/iscsi` implements a deliberately narrow subset of iSCSI (RFC 7143, formerly RFC 3720) and the SCSI
command set it carries (SPC-3 primary commands, SBC-3 block commands) — enough to serve or consume exactly one
read/write LUN over a single connection, not a general-purpose iSCSI stack. This is the detailed companion to
the README's "Scope" section, verified directly against the source (`constants.mts`, `login.mts`,
`connection.mts`, `types.mts`, `initiator.mts`, `chap.mts`) rather than inferred.
The package has two independent roles with very different maturity:
- **Target** (`IscsiTarget`) — the primary, production-used role (backs `@xen-orchestra/mixins/live-mount`).
Handles real-world Linux/XCP-ng initiators robustly: concurrent commands, real write ordering, keepalives,
connection recovery.
- **Initiator** (`IscsiInitiator`) — a minimal, read-only client used to read from a third-party array as a
disk source. Built against `IscsiTarget` and standard-tooling targets (`tgt`); interop testing this session
found it does **not** complete login against a LIO kernel target (see below) — a real, previously-unknown
gap, not yet fixed.
## Session and connection
| Aspect | Target | Initiator |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------- |
| Connections per session | `MaxConnections=1`, enforced | opens exactly one |
| Session reinstatement | **no** — no ISID tracking; once a new connection has established a Normal session (CHAP included), it tears down and replaces whatever connection currently exists, rather than RFC 7143's ISID-matched reinstatement (see `index.mts`'s class doc for the reasoning: safe here because the target is ephemeral, single-consumer, and CHAP-guarded). Until then it does not affect the current one, and is bounded by `loginTimeoutMs` and `maxPendingConnections` | n/a (single connection) |
| Multiple LUNs | **no** — exactly one LUN, always LUN 0 | reads are hardcoded to LUN 0 |
| `ErrorRecoveryLevel` | `0` only | requests `0` |
| Header/data digests | `HeaderDigest=None`, `DataDigest=None` — no CRC32C on the wire | same |
| Markers (`IFMarker`/`OFMarker`) | not offered (RFC-deprecated) | not offered |
## Login / text negotiation
**Target** (`login.mts`) reactively answers whatever the initiator proposes, fixed to:
`HeaderDigest=None`, `DataDigest=None`, `MaxConnections=1`, `InitialR2T=Yes`, `ImmediateData=No`,
`ErrorRecoveryLevel=0`, `DataPDUInOrder=Yes`, `DataSequenceInOrder=Yes`, `DefaultTime2Wait=2`,
`DefaultTime2Retain=0`, `MaxOutstandingR2T=1` — `MaxBurstLength`/`FirstBurstLength` are the only two keys
actually negotiated down from the initiator's offer (capped at an internal maximum). Both `Normal` and
`Discovery` session types are supported (`SessionType`), with CHAP skipped for `Discovery` per RFC — text-mode
negotiation beyond that is limited to `SendTargets` discovery (`#handleText`), not general text negotiation.
**Initiator** (`initiator.mts#operationalLogin`) sends exactly **one** operational-stage login PDU proposing
its own fixed key set (`HeaderDigest=None`, `DataDigest=None`, `InitialR2T=Yes`, `ImmediateData=No`,
`ErrorRecoveryLevel=0`, `MaxRecvDataSegmentLength`) with the transit bit (`T=1`) set immediately, and only
checks whether the reply also has `T=1` — there is no second round to answer a target's own counter-proposed
keys if it declines transit on the first exchange.
- Works against `tgt` and against this package's own `IscsiTarget` — both transit on the first round.
- **Does not complete against a LIO kernel target** (`targetcli-fb`): LIO counter-proposes its own operational
keys (`MaxBurstLength`, `DefaultTime2Wait`, `DefaultTime2Retain`, `MaxOutstandingR2T`) in the same response
and withholds transit until they're answered — confirmed directly via `dmesg`:
```
No response for proposed key "MaxBurstLength".
iSCSI Login negotiation failed.
```
This is a real, previously-unexercised limitation of the initiator's login state machine (single
operational round-trip only), not a bug on LIO's side. Worth weighing given the initiator's real intended
use — reading from unknown-strictness third-party array software, not `tgt`/this package's own target.
## Authentication (CHAP)
- One-way only (RFC 1994, algorithm MD5 — the only one RFC 7143 defines, so this isn't a partial
implementation of a wider set).
- **Target as authenticator**: when `chap` is configured, every login must present the right `CHAP_N`/`CHAP_R`
or the login is rejected (`INITIATOR_ERROR`/`AUTHENTICATION_FAILURE`). Interop-tested against the real
`open-iscsi` kernel initiator (both correct-secret and wrong-secret paths).
- **Initiator as responder**: when `chap` is configured, answers a target's challenge with the same MD5
scheme. Never issues its own challenge.
- **Not supported**: mutual (two-way) CHAP in either role — a target that _requires_ mutual auth will reject
the initiator's login, surfaced as a normal login failure rather than attempted.
## SCSI commands
Decoded explicitly (`types.mts#decodeCdb`); anything else maps to `unsupported` → `CHECK CONDITION` /
`ILLEGAL_REQUEST` / `INVALID COMMAND OPERATION CODE`:
| Command | Variants |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `INQUIRY` | standard + EVPD `0x00`/`0x80`/`0x83` (vendor/product/revision/serial from `identity`) and `0xb0` (block limits: `maxTransferLength`) |
| `REPORT LUNS` | |
| `READ CAPACITY` | `(10)` and `(16)` |
| `READ` | `(10)` and `(16)`; longer than `maxTransferLength` → `INVALID FIELD IN CDB` |
| `WRITE` | `(10)` and `(16)`; longer than `maxTransferLength` → `INVALID FIELD IN CDB`; on a `readOnly` LUN → `DATA PROTECT` / `WRITE PROTECTED`, without soliciting data |
| `TEST UNIT READY` | |
| `REQUEST SENSE` | |
| `MODE SENSE` | `(6)` and `(10)`; `WP` set on a `readOnly` LUN |
| `SYNCHRONIZE CACHE` | `(10)` and `(16)` — flushes the backing `BlockDevice` |
**Not implemented**: persistent reservations, VAAI/thin-provisioning primitives (`UNMAP`, `WRITE SAME`, `COMPARE
AND WRITE`, ...), any vendor-specific commands — anything beyond the ~10 commands a Linux initiator actually
issues to attach and use a plain block device.
## Data transfer
- **Read**: `Data-In`, concurrent commands dispatched up to `readConcurrency` (default 16) regardless of the
advertised command window — bounds how many `lun.read()` calls run at once against the real backend.
Residual under/overflow reported against the CDB's requested transfer length.
- **Write**: always solicited (`InitialR2T=Yes`, `ImmediateData=No`) — every write goes through `R2T` →
`Data-Out`, one outstanding R2T at a time (`MaxOutstandingR2T=1`). `Data-Out` PDUs are routed by Initiator
Task Tag, so writes/reads for different commands can interleave; within one write, out-of-order or
overlapping `Data-Out` segments are rejected outright (`writePath.mts`) rather than silently accepted, to
avoid leaking uninitialized staging-buffer memory to the LUN (a real bug this was hardened against — see
`VALIDATION.md`).
- **Not implemented**: unsolicited/immediate data, `MaxOutstandingR2T > 1`.
## Task management, error recovery, keepalives
- **SCSI Task Management** (`ABORT TASK`, `LUN RESET`, etc.) — not implemented; any `SCSI Task Mgmt Request`
is rejected (`COMMAND_NOT_SUPPORTED`).
- **SNACK** (retransmission requests, part of `ErrorRecoveryLevel > 0`) — not implemented; rejected the same
way. Consistent with `ErrorRecoveryLevel=0` — nothing should ever need to SNACK.
- **NOP / keepalives**:
- Target replies to an initiator-sent `NOP-Out` ping, but does **not** proactively send its own periodic
`NOP-In` probes (no `nop_interval` equivalent) — it relies entirely on the initiator (or TCP-level
connection loss) to notice a dead peer.
- Initiator answers a target-initiated `NOP-In` ping with `NOP-Out` and keeps its read loop draining for the
life of the connection specifically so it never misses one (this was a real, fixed bug — see
`VALIDATION.md`, item 5).
## Summary: not implemented
Mutual (two-way) CHAP · SCSI Task Management (abort/reset) · target-initiated NOP-In keepalives · multiple
connections per session · multiple LUNs · session reinstatement (ISID) · header/data digests · unsolicited
data / `MaxOutstandingR2T>1` · SNACK/error recovery beyond level 0 · persistent reservations and VAAI-style
primitives · general text-mode negotiation beyond `SendTargets` · initiator-side multi-round operational login
negotiation (works against this package's own target and `tgt`; does not against LIO — see above).

View File

@@ -1,294 +0,0 @@
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
# @vates/iscsi
[![Package Version](https://badgen.net/npm/v/@vates/iscsi)](https://npmjs.org/package/@vates/iscsi) ![License](https://badgen.net/npm/license/@vates/iscsi) [![PackagePhobia](https://badgen.net/bundlephobia/minzip/@vates/iscsi)](https://bundlephobia.com/result?p=@vates/iscsi) [![Node compatibility](https://badgen.net/npm/node/@vates/iscsi)](https://npmjs.org/package/@vates/iscsi)
> Minimal iSCSI target and initiator: expose or read a single LUN, with one-way CHAP
## Install
Installation of the [npm package](https://npmjs.org/package/@vates/iscsi):
```sh
npm install --save @vates/iscsi
```
## Usage
A small, dependency-light iSCSI **target** (server) written in TypeScript. It
exposes exactly one read/write LUN backed by a pluggable byte-range
`BlockDevice`, and interoperates with the standard Linux **open-iscsi**
initiator (the same one XCP-ng/XAPI uses to attach an `lvmoiscsi` SR).
### Scope
The target drives login negotiation onto a single, fixed code path:
- single initiator, **`MaxConnections=1`**, **`ErrorRecoveryLevel=0`**
- no header/data digests (`HeaderDigest=None`, `DataDigest=None`)
- all writes are R2T-solicited (`InitialR2T=Yes`, `ImmediateData=No`)
- one LUN (LUN 0)
It implements SendTargets discovery, login (`AuthMethod=None` or one-way
**CHAP** — see [Authentication](#authentication-chap)), the ~10 SCSI
commands a Linux initiator issues to attach and use a block device
(`INQUIRY` + VPD `0x00`/`0x80`/`0x83`/`0xb0`, `REPORT LUNS`, `READ CAPACITY 10/16`,
`READ`/`WRITE 10/16`, `TEST UNIT READY`, `REQUEST SENSE`, `MODE SENSE`,
`SYNCHRONIZE CACHE`), the full R2T / Data-Out write path, NOP keepalive replies,
and Logout. Commands may be outstanding concurrently (Data-Out PDUs are routed
by Initiator Task Tag), so it does not stall under real guest I/O.
The package also ships a userspace **initiator** (`IscsiInitiator`) and an
`IscsiDisk` adapter that exposes a remote LUN as an `@xen-orchestra/disk-transform`
`RandomAccessDisk` — see [Initiator](#initiator).
Not implemented (yet): mutual (two-way) CHAP, Task Management (abort/reset),
target-initiated NOP keepalives, multiple connections/LUNs.
What a peer can make the target do is bounded:
- connections that have not logged in yet are limited in time (`loginTimeoutMs`)
and number (`maxPendingConnections`), and `allowedInitiatorAddresses` (the
source addresses of the expected initiators) closes the other ones as soon as
they are accepted, reporting them to `onConnectionRefused`
- a PDU data segment is at most 8 KiB during login, then the declared
`MaxRecvDataSegmentLength` (256 KiB), checked before it is read
- a single `READ`/`WRITE` is at most `maxTransferLength` (8 MiB by default),
advertised in the block limits VPD page so that initiators split their I/O
- a `BlockDevice` with `readOnly: true` is reported write-protected, and `WRITE`
is refused with `DATA PROTECT` before any data is solicited
### CLI
The package ships a `vates-iscsi` binary that serves a file as a LUN:
```sh
> vates-iscsi /srv/lun.img
> vates-iscsi /srv/lun.img --serial MYSERIAL01 --port 3260
> vates-iscsi /srv/new.img --size 10G # create/grow the backing file first
```
```
Usage: vates-iscsi <file> [options]
Options:
--serial <serial> LUN serial number (default: derived from the IQN).
--size <bytes> Create/grow the backing file (suffixes K, M, G, T).
--iqn <iqn> Target IQN (default: iqn.2024-01.tech.vates:<file>).
--host <host> Listen address (default: 0.0.0.0).
--port <port> Listen port (default: 3260).
--block-size <bytes> Logical block size (default: 512).
-h, --help Show this help.
```
### Library
```js
import { IscsiTarget, FileBlockDevice } from '@vates/iscsi'
// The backing file must already exist and be sized to the LUN capacity,
// e.g. `fs.truncate('/srv/lun.img', 10 * 1024 ** 3)`.
const target = new IscsiTarget({
iqn: 'iqn.2024-01.tech.vates:lun0',
host: '0.0.0.0',
port: 3260,
lun: new FileBlockDevice({ path: '/srv/lun.img', blockSize: 512 }),
})
await target.listen()
// ... target is now discoverable and attachable by open-iscsi ...
await target.close()
```
#### Adding a target backend
A "backend" is whatever stores the LUN's bytes. The protocol layer translates
SCSI `READ`/`WRITE` CDBs (LBA + block count) into byte offsets/lengths against a
single small interface, so a backend only has to provide flat random access — it
never sees a PDU. `FileBlockDevice` (a sparse file) is the built-in one; implement
`BlockDevice` to expose anything else (a raw device, an object store, an
`@xen-orchestra/disk-transform` `RandomAccessDisk`, …):
```ts
interface BlockDevice {
open?(): Promise<void> // optional, awaited once before serving I/O
getSize(): number // total capacity in bytes; MUST be a multiple of getBlockSize()
getBlockSize(): number // logical block size, typically 512
read(offset: number, length: number): Promise<Buffer> // exactly `length` bytes at byte `offset`
write(offset: number, data: Buffer): Promise<void> // write `data.length` bytes at byte `offset`
flush(): Promise<void> // SYNCHRONIZE CACHE — persist any buffering
close(): Promise<void> // release resources
}
```
Contract to respect:
- **`getSize()` must be a multiple of `getBlockSize()`** and stable for the life of
the device. `open()` is the place to compute it (e.g. `stat` the file).
- **`read` must return exactly `length` bytes** — never short. Zero-fill a sparse
or past-EOF tail rather than returning fewer bytes; the SCSI layer already
bounds `offset + length` to the capacity.
- `offset` and `length` are always block-aligned (multiples of `getBlockSize()`).
- `read`/`write` may be **called concurrently** (commands are interleaved), so the
backend must be safe under overlapping calls; honour back-pressure and don't
buffer unboundedly.
- Throwing from `read`/`write`/`flush` tears the connection down; fail fast and
attach context to the error.
Minimal example — expose a read-only `RandomAccessDisk` (VHD / qcow2 / raw / a
remote NBD or iSCSI LUN) as a target LUN by mapping byte reads onto block reads:
```js
import { IscsiTarget } from '@vates/iscsi'
class RandomAccessDiskLun {
// `disk` is an initialized @xen-orchestra/disk-transform RandomAccessDisk.
constructor(disk) {
this.disk = disk
this.blockSize = disk.getBlockSize()
}
getSize() {
return this.disk.getVirtualSize()
}
getBlockSize() {
return this.blockSize
}
async read(offset, length) {
const out = Buffer.alloc(length) // pre-zeroed: sparse/holes read as zeros
const first = Math.floor(offset / this.blockSize)
const last = Math.floor((offset + length - 1) / this.blockSize)
for (let index = first; index <= last; index++) {
if (!this.disk.hasBlock(index)) {
continue // unallocated: leave zeros
}
const { data } = await this.disk.readBlock(index)
const blockStart = index * this.blockSize
const from = Math.max(0, offset - blockStart)
const to = Math.min(this.blockSize, offset + length - blockStart)
data.copy(out, blockStart + from - offset, from, to)
}
return out
}
// reported write-protected: WRITE is refused before reaching write()
readOnly = true
write() {
throw new Error('read-only LUN')
}
async flush() {}
async close() {
await this.disk.close()
}
}
const target = new IscsiTarget({ iqn: 'iqn.2024-01.tech.vates:disk0', lun: new RandomAccessDiskLun(disk) })
await target.listen()
```
For a writable backend, implement `write(offset, data)` symmetrically and make
`flush()` durable (e.g. `fsync`), as `FileBlockDevice` does.
### Initiator
`IscsiInitiator` is a minimal userspace iSCSI **client**: it connects to one
target portal, logs in, and reads the LUN. `IscsiDisk` wraps it as an
`@xen-orchestra/disk-transform` `RandomAccessDisk`, so a remote LUN drops straight
into the backup/transform pipeline (the iSCSI counterpart of `@vates/nbd-client`'s
`NbdDisk`). It is **read-only**, single-connection, digests off.
```js
import { IscsiInitiator, IscsiDisk } from '@vates/iscsi'
// Low-level byte reads:
const initiator = new IscsiInitiator({
host: '10.0.0.10',
port: 3260,
targetIqn: 'iqn.2024-01.tech.vates:lun0',
chap: { user: 'alice', secret: 's3cr3t' }, // optional, see Authentication
})
await initiator.connect()
console.log(initiator.getSize(), initiator.getBlockSize())
const bytes = await initiator.read(/* offset */ 0, /* length */ 1024 * 1024)
await initiator.close()
// …or as a RandomAccessDisk (blockSize must be a multiple of the LUN block size):
const disk = new IscsiDisk(
{ host: '10.0.0.10', targetIqn: 'iqn.2024-01.tech.vates:lun0' },
{ blockSize: 2 * 1024 * 1024 }
)
await disk.init()
// disk.getVirtualSize(), disk.getBlockIndexes(), await disk.readBlock(i), …
await disk.close()
```
First-cut `IscsiDisk` treats the LUN as fully allocated (every block reported as
present, like `RawDisk`); a changed-block map (e.g. a Pure "Volume Difference")
can narrow it later.
### Authentication (CHAP)
The target and initiator support **one-way CHAP** (MD5) in opposite roles; mutual
(two-way) CHAP is not yet implemented.
**Target as authenticator.** Pass `chap` to require each _data_ session to prove a
credential; the target challenges the initiator and rejects the login (Login
Response status-class _Initiator Error_ / _authentication failure_) on a mismatch:
```js
const target = new IscsiTarget({
iqn: 'iqn.2024-01.tech.vates:lun0',
lun,
chap: { user: 'alice', secret: 's3cr3t' },
})
```
This interoperates with the Linux **open-iscsi** initiator (the one XCP-ng uses);
configure its node record's incoming credential:
```sh
iscsiadm -m node -T <iqn> -p <portal> -o update -n node.session.auth.authmethod -v CHAP
iscsiadm -m node -T <iqn> -p <portal> -o update -n node.session.auth.username -v alice
iscsiadm -m node -T <iqn> -p <portal> -o update -n node.session.auth.password -v s3cr3t
```
CHAP is enforced only on **Normal (data) sessions**; **Discovery** sessions (which
can only run SendTargets and never reach the LUN) stay unauthenticated, so plain
`iscsiadm -m discovery` keeps working. open-iscsi requires the secret to be 12–16
characters.
**Initiator as responder.** Pass `chap` to `IscsiInitiator`/`IscsiDisk` to answer a
target's challenge (e.g. a Pure array acting as authenticator). If the target
_requires_ mutual CHAP the login is cleanly rejected (we never challenge back).
### Security
iSCSI has no data-path encryption of its own. CHAP authenticates the login only;
with no digests, **CDBs and data remain cleartext**. Anyone who can reach an
unauthenticated port can read/write the LUN, and a CHAP-protected one is still
exposed to eavesdropping and tampering on the wire. Expose targets only on a
trusted, isolated storage network, and use **IPsec** (the RFC-specified mechanism)
or equivalent if confidentiality is required.
### Testing
- `yarn test` — fast unit tests (PDU framing, SCSI encoders/decoders, login
negotiation, sequence-number arithmetic).
- `yarn test-integration` — a self-contained loopback test (an in-process
minimal initiator drives discovery, identity, write/read-back, and command
interleaving), plus an open-iscsi interop test that **requires root and
`iscsiadm`** and skips gracefully otherwise.
## Contributions
Contributions are _very_ welcomed, either on the documentation or on
the code.
You may:
- report any [issue](https://github.com/vatesfr/xen-orchestra/issues)
you've encountered;
- fork and create a pull request.
## License
[ISC](https://spdx.org/licenses/ISC) © [Vates SAS](https://vates.fr)

View File

@@ -1,170 +0,0 @@
# Validation notes
Extracted from a longer production-readiness pass covering both this package and the `LiveMount` mixin that
consumes it; this file keeps only what's specific to `@vates/iscsi` itself — protocol-level correctness,
concurrency, and interop with standard tooling. Local testing notes covering the LiveMount/XAPI integration,
VM boot mechanics, and deployment specifics live elsewhere and aren't reproduced here.
## Bugs found and fixed
### In this validation pass
1. **Read-path error isolation** (`scsi.mts`) — `lun.read()` failures were previously uncaught, killing the
whole connection for one bad block. Now wrapped in try/catch, reporting `CHECK_CONDITION`/`MEDIUM_ERROR` for
that command only, mirroring the write path's existing `#handleDataOut` error handling.
- Unit test: `scsi.test.mts` — "reports a failed lun.read() as CHECK CONDITION / MEDIUM ERROR, not an
uncaught throw".
- Regression test: `loopback.integ.mts`, `describe('READ error isolation', ...)` — a LUN whose `read()`
throws for one LBA still returns `CHECK_CONDITION` for that command; the connection stays alive and a
different, healthy LBA read afterward still succeeds.
2. **Connection replacement instead of refusal** (`index.mts`) — found via a real end-to-end guest-boot test
against an S3-backed backup repository: `IscsiTarget#onConnection` used to permanently _refuse_ any new
connection while an existing one was tracked (`MaxConnections=1` enforcement), with no way to tell a
genuinely stale/abandoned connection (initiator gave up and reconnected without ever closing the old TCP
socket) from a healthy one still in use. Under a slow backend this wedges the mount forever: the host's
initiator times out and retries, gets refused, retries again, forever — reproduced live as an 8+ minute
guest boot stall, `WARN refusing concurrent connection (MaxConnections=1)` repeating in the log the whole
time. Now a new connection always tears down whatever connection currently exists and takes over, rather
than being refused. Deliberately not full RFC 7143 session reinstatement (no ISID matching) — chosen as the
simpler, sufficient fix for this target's single-consumer, ephemeral, CHAP-guarded design; see the class doc
comment in `index.mts` for the reasoning and the more rigorous alternative considered.
- Regression test: `loopback.integ.mts`, `describe('connection replacement', ...)` — a second connection is
accepted (not refused) while the first is still open, the first's socket gets torn down, and the new
connection is fully usable (read + logout succeed).
- Re-verified end-to-end after deploying the fix: same S3-backed boot scenario completed successfully
(~5 min total, vs ~20-25s for the equivalent local-storage boot — S3 itself is slow, but the mount no
longer wedges).
- Refined afterwards: the replacement used to happen as soon as the TCP connection was accepted, so any
peer reaching the port (a port scan, a failed CHAP login, a Discovery session) interrupted the session in
use. It now only happens once the new connection has established a Normal session; until then,
connections are bounded by `loginTimeoutMs` and `maxPendingConnections`. Regression tests:
`loopback.integ.mts`, `describe('connection admission', ...)`.
### Independently landed, cross-referenced against this pass's coverage
Five more fixes landed on this branch outside this validation effort. Read in full and checked against what
had (and hadn't) actually been exercised black-box, since two are severe:
1. **`CachedDiskBlockDevice.mts` — acknowledged writes silently and permanently reverted.** A write fully
covering a not-yet-cached block used to skip fetching the source (an "optimization" — the write supplies
all the bytes anyway). But if a _read_ to that same block had already triggered an in-flight fetch, that
fetch could land **after** the write and silently overwrite it with stale source data — guest gets `GOOD`,
data reverts moments later. Fixed by always joining any in-flight fetch before writing. Has a white-box
regression test (pauses a fake disk mid-fetch to force the race). This is exactly the class of bug this
whole effort was looking for, and its vulnerable path — read+write racing on the same never-touched block —
was never actually exercised by this pass's own earlier black-box testing (sequential `dd` and single-job
`fio` both avoid same-offset self-overlap by construction), so the "clean" results up to that point were
consistent with the bug being present, not proof of its absence. Closed that gap afterward (see below).
2. **`connection.mts`/`writePath.mts` — uninitialized heap memory leaked to the LUN on a malformed Data-Out.**
Staging buffer is `Buffer.allocUnsafe`, never zeroed; the old completion check summed received bytes, but
`Buffer.copy` silently clamps on out-of-range/overlapping offsets — a malformed/out-of-order Data-Out
sequence could reach "totalLength received" with gaps of recycled heap memory that then get written to the
LUN and read back by any guest. Real info-disclosure bug, needs a misbehaving/malicious initiator to trigger
(a well-behaved one never sends malformed Data-Out). Fixed with strict contiguity validation; extensively
tested (`connection.test.mts` +586 lines, `writePath.test.mts` +168 lines, both white-box, covering the
malformed-input space directly — not something black-box testing with real tooling can easily reach at all).
3. **`connection.mts` — CmdSN window corrupted by every Data-Out (i.e. every write).** Command-window tracking
read bytes 24-27 as CmdSN off every inbound PDU, but those bytes are reserved on Data-Out per RFC 7143
§11.7 — every write rewound the advertised window. Benign at normal scale (initiators discard the rewind as
stale via serial-number comparison until session CmdSN passes 2³¹) — none of this pass's testing came close
to that scale, so nothing here calls prior results into question. Fixed with an opcode allowlist; has its
own regression test.
4. **`CachedDiskBlockDevice.hydrate()` — no longer throws/orphans on a concurrent unmount.** `hydrate()` now
accepts an `AbortSignal`, stopping at the next block boundary and rejecting cleanly once in-flight blocks
settle, rather than the caller having to serialize hydrate against unmount itself (the orchestration half of
this fix — not running hydrate inside the same long-lived task as the mount — lives in the LiveMount mixin,
outside this package). Tested at the `CachedDiskBlockDevice` level: aborting mid-hydrate keeps everything
already cached, rejects rather than silently returning partial success, and a fresh signal that's already
aborted refuses to start at all.
5. **`initiator.mts` — went silent between commands, dropped by a target's ping timeout.** `IscsiInitiator`'s
read loop used to stop once nothing was outstanding, so during any idle period a target's NOP-In keepalive
sat unread — real targets (LIO's `nopin_response_timeout`, 30s by default) drop sessions with unanswered
pings, with no reconnect logic to recover. Fixed with a loop that keeps draining for the life of the
connection and replies to NOP-In pings (`#pump`/`#replyNop`); added `initiator.integ.mts` (white-box,
real loopback). Never exercised by this pass's own black-box testing at all — the whole effort was
LiveMount-focused, which only ever uses the _target_ role. Attempting to close that gap surfaced two
further, independent findings — see "Initiator interop findings" below.
## Black-box protocol-level testing (standalone target, real tooling)
- **Data integrity under concurrent load**: 30-minute `fio --rw=randrw --bs=4k --iodepth=64 --numjobs=1
--verify=crc32c` soak against the standalone target over real `iscsiadm` loopback — 0 verify errors, ~16.4k
read + ~10.6k write IOPS sustained throughout. (An earlier `numjobs=4` run produced ~116 "bad header"
errors; bisected and reproduced identically against plain local file I/O with zero iSCSI involvement —
a known fio limitation where independent jobs pointed at the same file race each other's own bookkeeping,
not a target bug. `numjobs=1` at higher `iodepth` avoids the false positive while still exercising real
queue depth.)
- **Filesystem round-trip across a full session teardown**: real corpus (2419 files, 53 MB) written, hashed,
then a full cold logout/login cycle plus a host-side page-cache drop before re-reading — all 2419 files
byte-identical.
- **Kill the target process mid-write**: `kill -9`'d mid-transfer with a direct-I/O write in flight. The write
correctly blocked (not a fast, silent "success") until the session recovered, then completed with the exact
intended pattern across the full range — no silent data loss, no torn/partial write at the interruption point.
- **Real cross-machine network partition** (two separate VMs, not loopback): a mid-transfer `iptables DROP`
held for 15s against the target's own port. Compared directly to `tgt` as a baseline over the identical
topology — both stalled through the outage and completed with 0 corrupted bytes once restored; no divergent
behavior from a mature reference implementation.
- **Sequential throughput vs `tgt`** (same two-VM link, plain iSCSI, no XAPI/SM in the path): `@vates/iscsi`
reached ~70% (write) / ~76% (read) of `tgt`'s numbers — noticeably slower but the same order of magnitude,
not a large gap. (A separate, much larger ~10x slowdown measured through the full LiveMount/XAPI/SM stack
was isolated to the host's own `iscsi` SM/tapdisk driver by elimination — outside this package.)
- **Targeted regression testing for bug 1 above**, using a single-job, high-`iodepth`, small-range `fio`
pattern (deliberately avoiding the `numjobs>1` false-positive from the first bullet) to force many concurrent
operations onto the same underlying cache block without needing multiple uncoordinated processes: clean
(0 verify errors) against local storage, against a slow S3-backed remote (a wider fetch-latency window gives
a racing write far more time to land mid-fetch — the exact scenario the bug was about), and against an
actively-running `hydrate()` sweep overlapping guest I/O on the same disk. All three passed.
## Flagged, not fixed
- **`FileBlockDevice.read()` zero-fills past a short/EOF read instead of throwing** — unlike `LocalBlockDevice`
(the actual production cache backend), which throws on the identical condition. Confirmed via grep: only the
standalone CLI tool and this package's own tests use `FileBlockDevice` today, so there's no reachable data-
loss path in production right now — but it's a public export, so a future feature backing a LUN directly
with a file would silently inherit the zero-fill-over-error behavior. This is a design-intent question (is
the CLI tool's "sparse tail" leniency deliberate?), not a clear bug — flagging for a decision rather than
silently changing it.
## Initiator interop findings (this session)
Bug 5's fix (above) had never been exercised against a real target under real conditions — only the white-box
`initiator.integ.mts`. Attempting to close that gap with a live idle-period/NOP-In test against two different
real target implementations surfaced two independent, real findings before the actual keepalive behavior could
be exercised:
- **`tgt` reserves LUN 0 as a built-in, zero-size "controller" device** on every target, and this is not
removable via `tgtadm` (`tgtadm: this logical unit number already exists` / `invalid request` on any attempt
to delete or replace it). `IscsiInitiator.connect()`/`read()` are hardcoded to LUN 0 (matching this package's
own single-LUN-at-0 target design), so `connect()` fails immediately with `CHECK_CONDITION` against `tgt`
regardless of where a real data LUN is added (tried LUN 1). Pure tooling mismatch — `tgt`'s own convention
doesn't fit a client that only ever speaks to LUN 0 — not a bug in either side.
- **LIO (`targetcli-fb`/kernel target) does complete login differently, and `IscsiInitiator` can't get through
it.** With a real data LUN at LUN 0 (LIO doesn't reserve it), `connect()` still fails — `#operationalLogin`
(`initiator.mts`) sends exactly one operational-stage login PDU, requests immediate transit (`T=1`), and only
checks the transit bit on the reply; there's no second round to answer a target's own counter-proposal if it
declines transit on the first exchange. LIO's kernel target does exactly that: it counter-proposes several of
its own operational keys (`MaxBurstLength`, `DefaultTime2Wait`, `DefaultTime2Retain`, `MaxOutstandingR2T`) in
the same response and withholds transit until they're answered, confirmed directly in `dmesg`:
```
No response for proposed key "MaxBurstLength".
No response for proposed key "DefaultTime2Wait".
No response for proposed key "DefaultTime2Retain".
No response for proposed key "MaxOutstandingR2T".
iSCSI Login negotiation failed.
```
This works fine against `tgt` and against this package's own `IscsiTarget` (both transit on the first round),
so it's never been visible before — but it's a **real, previously-unknown limitation of `IscsiInitiator`'s
login state machine**: single operational round-trip only, no handling for a target that insists on its own
multi-round negotiation before agreeing to transit. Worth knowing given the initiator role's real intended
use (reading from a third-party storage array as an initiator) targets unknown-strictness third-party target
software, not `tgt` or this package's own target.
Given both blockers are about _login/LUN setup_, not the keepalive mechanism itself, the actual idle-period/
NOP-In behavior could not be exercised live this session. The white-box `initiator.integ.mts` coverage for
bug 5 stands as the only current test of that specific fix.

View File

@@ -1,6 +0,0 @@
// @ts-check
import eslint from '@eslint/js'
import tseslint from 'typescript-eslint'
export default tseslint.config(eslint.configs.recommended, tseslint.configs.recommended)

View File

@@ -1,61 +0,0 @@
{
"name": "@vates/iscsi",
"version": "0.4.0",
"main": "dist/index.mjs",
"types": "dist/index.d.mts",
"bin": {
"vates-iscsi": "dist/cli.mjs"
},
"license": "ISC",
"private": false,
"type": "module",
"description": "Minimal iSCSI target and initiator: expose or read a single LUN, with one-way CHAP",
"keywords": [
"iscsi",
"target",
"initiator",
"chap",
"scsi",
"lun",
"block",
"storage"
],
"dependencies": {
"@vates/async-each": "^1.0.3",
"@vates/read-chunk": "^1.2.1",
"@xen-orchestra/disk-transform": "^1.3.4",
"@xen-orchestra/log": "^0.7.2",
"promise-toolbox": "^0.21.0"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@types/node": "^22.0",
"rimraf": "^6.0.1",
"typescript": "~5.6",
"typescript-eslint": "^8.61.0"
},
"scripts": {
"build": "tsc",
"clean": "rimraf dist/",
"dev": "tsc --watch",
"postversion": "npm publish --access public",
"prebuild": "rimraf dist/",
"predev": "yarn run prebuild",
"test": "tsc && node --test \"dist/**/*.test.mjs\"",
"test-integration": "tsc && node --test \"dist/**/*.integ.mjs\""
},
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/iscsi",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {
"directory": "@vates/iscsi",
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"author": {
"name": "Vates SAS",
"url": "https://vates.fr"
},
"engines": {
"node": ">=22"
}
}

View File

@@ -1,321 +0,0 @@
import { asyncEach } from '@vates/async-each'
import { createLogger, type Logger } from '@xen-orchestra/log'
import type { ProgressHandler, RandomAccessDisk } from '@xen-orchestra/disk-transform'
import type { BlockDevice } from './backend.mjs'
const log: Logger = createLogger('vates:iscsi:cached-disk-block-device')
const DEFAULT_BLOCK_SIZE = 512
const DEFAULT_HYDRATE_CONCURRENCY = 8
export interface CachedDiskBlockDeviceOptions {
/**
* Source disk, already `init()`ed by the caller. Only ever read from, one
* whole block at a time.
*/
readonly disk: RandomAccessDisk
/**
* Writable store holding what has been read from {@link disk} so far, already
* `open()`ed and at least as large as the disk's virtual size.
*/
readonly cache: BlockDevice
/**
* Logical block size advertised to initiators, in bytes. Defaults to 512.
* The disk's own block size must be a multiple of it.
*/
readonly blockSize?: number
/**
* Notified with the materialized fraction (0..1) every time a new block is
* cached — on demand or through {@link hydrate} — and via `done()` once
* every block has been. Reused from the `disk-transform` export/import
* pipelines rather than inventing a new shape.
*/
readonly progressHandler?: ProgressHandler
/**
* A previously-persisted bitmap to resume from, one byte per block (see
* {@link CachedDiskBlockDevice}'s class doc). Its length must match the
* block count computed from `disk`/`blockSize`; a mismatch (e.g. the disk
* was resized since it was saved) is treated as stale and discarded rather
* than rejected, so a corrupt/outdated resume file never blocks a mount.
*/
readonly initialBitmap?: Buffer
/**
* Notified with a block's index the moment it is newly cached — narrower
* than `progressHandler`, which only carries an aggregate fraction and
* cannot say *which* block just landed. Meant for persisting the bitmap
* one byte at a time; best-effort like `progressHandler`, a failure here
* must never break block materialization.
*/
readonly onBlockCached?: (index: number) => void
}
/**
* A read-write {@link BlockDevice} over a {@link RandomAccessDisk}, backed by
* a local writable store: each source block is materialized into the store
* the first time it's needed, tracked by a bitmap so repeat access never goes
* back to the source. Call {@link hydrate} to force the whole disk in upfront.
*
* The bitmap is one byte per block, not one bit: a caller that persists it
* incrementally (see `onBlockCached`) writes one block's byte at a time, and
* every block's offset must be independent of every other's for concurrent
* writes to be safe. A real bit-packed bitmap fails that: several blocks'
* bits sharing one byte would need a read-modify-write of that byte, which
* two blocks completing concurrently could race on and lose an update. One
* byte per block costs a little more memory (negligible in absolute terms —
* a few hundred KB even for a huge disk) and buys that independence for free.
*
* Blocks the source doesn't have are marked present without being written —
* relies on the store reading as zero where nothing was ever written.
*
* Writes land in the store only; the source is never written to, so the
* mount diverges from it as soon as anything writes.
*/
export class CachedDiskBlockDevice implements BlockDevice {
readonly #disk: RandomAccessDisk
readonly #cache: BlockDevice
readonly #blockSize: number
readonly #progressHandler?: ProgressHandler
readonly #onBlockCached?: (index: number) => void
readonly #initialBitmap?: Buffer
// one byte per source block, 0 or 1, set once the block is in the cache
#bitmap: Buffer = Buffer.alloc(0)
#blockCount = 0
#cachedCount = 0
// source blocks being materialized right now, so concurrent reads of
// overlapping ranges fetch each block once
readonly #inFlight: Map<number, Promise<void>> = new Map()
#diskBlockSize?: number
#size?: number
constructor({
disk,
cache,
blockSize = DEFAULT_BLOCK_SIZE,
progressHandler,
initialBitmap,
onBlockCached,
}: CachedDiskBlockDeviceOptions) {
if (!Number.isInteger(blockSize) || blockSize <= 0) {
throw new Error(`blockSize must be a positive integer, got ${blockSize}`)
}
this.#disk = disk
this.#cache = cache
this.#blockSize = blockSize
this.#progressHandler = progressHandler
this.#initialBitmap = initialBitmap
this.#onBlockCached = onBlockCached
}
/** Read the source geometry and allocate (or resume) the bitmap. */
async open(): Promise<void> {
const blockSize = this.#blockSize
const diskBlockSize = this.#disk.getBlockSize()
if (diskBlockSize % blockSize !== 0) {
throw new Error(`disk block size (${diskBlockSize}) is not a multiple of the LUN block size (${blockSize})`)
}
const size = this.#disk.getVirtualSize()
if (size <= 0 || size % blockSize !== 0) {
throw new Error(`disk virtual size (${size}) is not a positive multiple of the LUN block size (${blockSize})`)
}
if (this.#cache.getSize() < size) {
throw new Error(`cache (${this.#cache.getSize()} bytes) is smaller than the disk (${size} bytes)`)
}
this.#diskBlockSize = diskBlockSize
this.#size = size
this.#blockCount = Math.ceil(size / diskBlockSize)
const initialBitmap = this.#initialBitmap
if (initialBitmap !== undefined && initialBitmap.length === this.#blockCount) {
this.#bitmap = initialBitmap
let cachedCount = 0
for (const byte of initialBitmap) {
if (byte !== 0) {
cachedCount++
}
}
this.#cachedCount = cachedCount
} else {
if (initialBitmap !== undefined) {
log.warn('discarding stale initial bitmap (block count mismatch)', {
gotLength: initialBitmap.length,
expectedLength: this.#blockCount,
})
}
this.#bitmap = Buffer.alloc(this.#blockCount)
}
log.debug('opened', {
size,
blockSize,
diskBlockSize,
blockCount: this.#blockCount,
resumedBlocks: this.#cachedCount,
})
}
getSize(): number {
const size = this.#size
if (size === undefined) {
throw new Error('CachedDiskBlockDevice.open() must be called before I/O')
}
return size
}
getBlockSize(): number {
return this.#blockSize
}
/** How much of the source has been materialized into the cache. */
getMaterialized(): { blocks: number; total: number } {
return { blocks: this.#cachedCount, total: this.#blockCount }
}
/**
* Force every block into the cache — including holes, so `getMaterialized()`
* reaches `total` once done, matching what a full sequential read would
* naturally mark. Already-materialized blocks are skipped, so this resumes
* rather than redoes whatever on-demand reads (or a previous, interrupted
* hydration) already did.
*
* Hydrating a large disk takes hours, so it must be interruptible: `signal`
* stops it at the next block boundary, rejecting once the blocks already in
* flight settle. Nothing is rolled back — every block cached so far stays
* cached, and because this call resumes, aborting and calling again later
* picks up where it stopped rather than starting over.
*/
async hydrate({
concurrency = DEFAULT_HYDRATE_CONCURRENCY,
signal,
}: { concurrency?: number; signal?: AbortSignal } = {}): Promise<void> {
signal?.throwIfAborted()
const blockCount = this.#blockCount
await asyncEach(
(function* allBlockIndexes() {
for (let index = 0; index < blockCount; index++) {
yield index
}
})(),
index => this.#ensureBlock(index),
{ concurrency, signal }
)
}
#isCached(index: number): boolean {
return this.#bitmap[index] !== 0
}
#setCached(index: number): void {
if (!this.#isCached(index)) {
this.#bitmap[index] = 1
this.#cachedCount++
this.#reportProgress()
// best-effort, same contract as #reportProgress: a synchronous throw
// here must never break block materialization. Any async failure (e.g.
// the persisted-bitmap write itself) is the callback's own job to catch.
try {
this.#onBlockCached?.(index)
} catch (error) {
log.warn('onBlockCached failed', { error, index })
}
}
}
// best-effort: a failure here (sync or async) must never break block
// materialization. Wrapping the body in an `async` function, rather than a
// plain try/catch, is what makes a *synchronous* throw from the handler
// land in the same `.catch()` as an asynchronous rejection.
#reportProgress(): void {
const handler = this.#progressHandler
if (handler === undefined) {
return
}
const fraction = this.#cachedCount / this.#blockCount
;(async () => {
await handler.setProgress(fraction)
// every block is in the cache: nothing more will ever be reported
if (fraction === 1) {
await handler.done()
}
})().catch(error => log.warn('progress handler failed', { error }))
}
/**
* Copy one source block into the cache, unless it is already there. Concurrent
* calls for the same block share a single fetch.
*/
#ensureBlock(index: number): Promise<void> {
if (this.#isCached(index)) {
return Promise.resolve()
}
let pending = this.#inFlight.get(index)
if (pending === undefined) {
// the entry goes away whether the fetch succeeded or not, so a failed
// block is retried later instead of being remembered as broken
pending = this.#fetchBlock(index).finally(() => this.#inFlight.delete(index))
this.#inFlight.set(index, pending)
}
return pending
}
async #fetchBlock(index: number): Promise<void> {
const diskBlockSize = this.#diskBlockSize as number
if (this.#disk.hasBlock(index)) {
const offset = index * diskBlockSize
const { data } = await this.#disk.readBlock(index)
// a source block is always full-size: the last one may run past the end of
// the disk, and the cache must not be written beyond it
const length = Math.min(diskBlockSize, this.getSize() - offset)
await this.#cache.write(offset, length < data.length ? data.subarray(0, length) : data)
}
// a block the source does not have stays as the cache has it, which is
// zeroes as long as nothing has written there
this.#setCached(index)
}
#checkRange(offset: number, length: number): void {
const size = this.getSize()
if (offset < 0 || length < 0 || offset + length > size) {
throw new Error(`access of ${length} bytes at ${offset} is out of range (size ${size})`)
}
}
async read(offset: number, length: number): Promise<Buffer> {
this.#checkRange(offset, length)
if (length === 0) {
return Buffer.alloc(0)
}
const diskBlockSize = this.#diskBlockSize as number
const last = Math.floor((offset + length - 1) / diskBlockSize)
for (let index = Math.floor(offset / diskBlockSize); index <= last; index++) {
await this.#ensureBlock(index)
}
return this.#cache.read(offset, length)
}
async write(offset: number, data: Buffer): Promise<void> {
this.#checkRange(offset, data.length)
if (data.length === 0) {
return
}
// every covered block must be materialized first, even the ones this write
// covers entirely: skipping the fetch would let an already in-flight one
// land *after* the payload and overwrite it with the source's bytes.
// (optimization left out on purpose — it has to join the in-flight fetch)
await this.read(offset, data.length)
await this.#cache.write(offset, data)
}
async flush(): Promise<void> {
await this.#cache.flush()
}
async close(): Promise<void> {
try {
await this.#cache.close()
} finally {
await this.#disk.close()
}
}
}

View File

@@ -1,605 +0,0 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { RandomAccessDisk, type DiskBlock, type ProgressHandler } from '@xen-orchestra/disk-transform'
import { CachedDiskBlockDevice } from './CachedDiskBlockDevice.mjs'
import type { BlockDevice } from './backend.mjs'
const DISK_BLOCK_SIZE = 4096
const BLOCK_COUNT = 4
const DISK_SIZE = BLOCK_COUNT * DISK_BLOCK_SIZE
const bytePattern = (i: number) => (i * 31 + 11) & 0xff
/** Sparse source: only blocks 0 and 2 exist, like a differencing chain. */
class StubDisk extends RandomAccessDisk {
readBlockCalls: Array<number> = []
closed = false
/** set to hold every fetch open until it resolves */
pause?: Promise<void>
constructor(readonly allocated: Set<number> = new Set([0, 2])) {
super()
}
getBlockSize(): number {
return DISK_BLOCK_SIZE
}
getVirtualSize(): number {
return DISK_SIZE
}
isDifferencing(): boolean {
return false
}
getBlockIndexes(): Array<number> {
return [...this.allocated]
}
hasBlock(index: number): boolean {
return this.allocated.has(index)
}
async init(): Promise<void> {}
async close(): Promise<void> {
this.closed = true
}
async readBlock(index: number): Promise<DiskBlock> {
if (!this.allocated.has(index)) {
throw new Error(`Block ${index} not found in chain`)
}
this.readBlockCalls.push(index)
if (this.pause !== undefined) {
await this.pause
}
const data = Buffer.alloc(DISK_BLOCK_SIZE)
for (let i = 0; i < data.length; i++) {
data[i] = bytePattern(index * DISK_BLOCK_SIZE + i)
}
return { index, data }
}
}
/** In-memory stand-in for the local VDI. */
class StubCache implements BlockDevice {
readonly content: Buffer
writes: Array<{ offset: number; length: number }> = []
closed = false
flushed = 0
failNextWrite = false
constructor(size = DISK_SIZE) {
this.content = Buffer.alloc(size)
}
getSize(): number {
return this.content.length
}
getBlockSize(): number {
return 512
}
async read(offset: number, length: number): Promise<Buffer> {
return Buffer.from(this.content.subarray(offset, offset + length))
}
async write(offset: number, data: Buffer): Promise<void> {
if (this.failNextWrite) {
this.failNextWrite = false
throw new Error('cache write failed')
}
this.writes.push({ offset, length: data.length })
data.copy(this.content, offset)
}
async flush(): Promise<void> {
this.flushed++
}
async close(): Promise<void> {
this.closed = true
}
}
const expectedAt = (offset: number, length: number, allocated: Set<number>): Buffer => {
const expected = Buffer.alloc(length)
for (let i = 0; i < length; i++) {
const absolute = offset + i
if (allocated.has(Math.floor(absolute / DISK_BLOCK_SIZE))) {
expected[i] = bytePattern(absolute)
}
}
return expected
}
const make = async (allocated?: Set<number>, cacheSize?: number) => {
const disk = new StubDisk(allocated)
const cache = new StubCache(cacheSize)
const device = new CachedDiskBlockDevice({ disk, cache })
await device.open()
return { cache, device, disk }
}
describe('open', () => {
it('exposes the disk capacity and a fully cold bitmap', async () => {
const { device } = await make()
assert.equal(device.getSize(), DISK_SIZE)
assert.equal(device.getBlockSize(), 512)
assert.deepEqual(device.getMaterialized(), { blocks: 0, total: BLOCK_COUNT })
})
it('refuses a cache smaller than the disk', async () => {
const device = new CachedDiskBlockDevice({ disk: new StubDisk(), cache: new StubCache(DISK_SIZE - 512) })
await assert.rejects(device.open(), /smaller than the disk/)
})
it('resumes from an initial bitmap of the right length', async () => {
// all blocks allocated, so the not-yet-cached one below is a real fetch,
// not a hole (which would never call readBlock regardless of the bitmap)
const disk = new StubDisk(new Set([0, 1, 2, 3]))
const cache = new StubCache()
const initialBitmap = Buffer.alloc(BLOCK_COUNT)
initialBitmap[0] = 1
initialBitmap[2] = 1
const device = new CachedDiskBlockDevice({ disk, cache, initialBitmap })
await device.open()
assert.deepEqual(device.getMaterialized(), { blocks: 2, total: BLOCK_COUNT })
// already marked cached: served from the (empty) cache store, source untouched
assert.deepEqual(await device.read(0, 512), Buffer.alloc(512))
assert.deepEqual(disk.readBlockCalls, [])
// not marked cached: still fetched from the source as usual
assert.deepEqual(await device.read(DISK_BLOCK_SIZE, 512), expectedAt(DISK_BLOCK_SIZE, 512, disk.allocated))
assert.deepEqual(disk.readBlockCalls, [1])
})
it('discards an initial bitmap of the wrong length and starts cold', async () => {
const disk = new StubDisk()
const cache = new StubCache()
const device = new CachedDiskBlockDevice({ disk, cache, initialBitmap: Buffer.alloc(BLOCK_COUNT - 1, 1) })
await device.open()
assert.deepEqual(device.getMaterialized(), { blocks: 0, total: BLOCK_COUNT })
assert.deepEqual(await device.read(0, 512), expectedAt(0, 512, disk.allocated))
assert.deepEqual(disk.readBlockCalls, [0])
})
})
describe('read', () => {
it('materializes a block on first read and serves later ones from the cache', async () => {
const { device, disk, cache } = await make()
assert.deepEqual(await device.read(0, 512), expectedAt(0, 512, disk.allocated))
assert.deepEqual(disk.readBlockCalls, [0])
// the whole source block was written, not just the part that was read
assert.deepEqual(cache.writes, [{ offset: 0, length: DISK_BLOCK_SIZE }])
assert.deepEqual(device.getMaterialized(), { blocks: 1, total: BLOCK_COUNT })
// a second read of a different part of the same block does not go back
assert.deepEqual(await device.read(1024, 512), expectedAt(1024, 512, disk.allocated))
assert.deepEqual(disk.readBlockCalls, [0])
assert.equal(cache.writes.length, 1)
})
it('marks a hole as present without writing it', async () => {
const { device, disk, cache } = await make()
assert.deepEqual(await device.read(DISK_BLOCK_SIZE, 512), Buffer.alloc(512))
assert.deepEqual(disk.readBlockCalls, [])
assert.deepEqual(cache.writes, [])
assert.deepEqual(device.getMaterialized(), { blocks: 1, total: BLOCK_COUNT })
})
it('stitches a range spanning a present and a missing block', async () => {
const { device, disk } = await make()
const offset = DISK_BLOCK_SIZE - 512
const length = DISK_BLOCK_SIZE + 1024
assert.deepEqual(await device.read(offset, length), expectedAt(offset, length, disk.allocated))
assert.deepEqual(disk.readBlockCalls, [0, 2])
})
it('fetches a block once for concurrent overlapping reads', async () => {
const { device, disk } = await make()
// hold the fetch open until both reads are in flight
let release = () => {}
disk.pause = new Promise<void>(resolve => {
release = resolve
})
const first = device.read(0, 1024)
const second = device.read(512, 1024)
await new Promise(resolve => setImmediate(resolve))
release()
assert.deepEqual(await first, expectedAt(0, 1024, disk.allocated))
assert.deepEqual(await second, expectedAt(512, 1024, disk.allocated))
assert.deepEqual(disk.readBlockCalls, [0])
})
it('rejects a read past the end of the disk', async () => {
const { device } = await make()
await assert.rejects(device.read(DISK_SIZE - 256, 512), /out of range/)
})
it('clamps the tail block to the disk size', async () => {
// 3 blocks + half a block, so the last source block runs past the end
class ShortDisk extends StubDisk {
getVirtualSize(): number {
return 3 * DISK_BLOCK_SIZE + 2048
}
}
const disk = new ShortDisk(new Set([3]))
const cache = new StubCache(3 * DISK_BLOCK_SIZE + 2048)
const device = new CachedDiskBlockDevice({ disk, cache })
await device.open()
await device.read(3 * DISK_BLOCK_SIZE, 2048)
assert.deepEqual(cache.writes, [{ offset: 3 * DISK_BLOCK_SIZE, length: 2048 }])
})
})
describe('write', () => {
it('lands in the cache and is readable back, without touching the source', async () => {
const { device, cache, disk } = await make()
const payload = Buffer.alloc(512, 0xee)
await device.write(0, payload)
assert.deepEqual(await device.read(0, 512), payload)
// the block was materialized first, since the write covers only part of it
assert.deepEqual(disk.readBlockCalls, [0])
// ... and the bytes after the write are still the source's
assert.deepEqual(await device.read(512, 512), expectedAt(512, 512, disk.allocated))
assert.equal(cache.closed, false)
})
it('materializes the block even when the write covers it entirely', async () => {
const { device, disk } = await make()
await device.write(0, Buffer.alloc(DISK_BLOCK_SIZE, 0xee))
// the fetch cannot be skipped: see the race covered by the next test
assert.deepEqual(disk.readBlockCalls, [0])
assert.deepEqual(device.getMaterialized(), { blocks: 1, total: BLOCK_COUNT })
})
it('is not overwritten by a fetch already in flight when it lands', async () => {
const { device, disk } = await make()
// hold block 0's fetch open, so the write lands while it is in flight
let release = () => {}
disk.pause = new Promise<void>(resolve => {
release = resolve
})
const reading = device.read(0, 512)
await new Promise(resolve => setImmediate(resolve))
const payload = Buffer.alloc(DISK_BLOCK_SIZE, 0xee)
const writing = device.write(0, payload)
await new Promise(resolve => setImmediate(resolve))
release()
await reading
await writing
// the source block was fetched once, and its bytes did not come back on top
assert.deepEqual(disk.readBlockCalls, [0])
assert.deepEqual(await device.read(0, DISK_BLOCK_SIZE), payload)
})
})
describe('hydrate', () => {
it('materializes every block the source has, holes included', async () => {
const { device, disk, cache } = await make()
await device.hydrate()
assert.deepEqual(device.getMaterialized(), { blocks: BLOCK_COUNT, total: BLOCK_COUNT })
// only the allocated ones were actually fetched...
assert.deepEqual([...disk.readBlockCalls].sort(), [0, 2])
// ...and the whole disk now reads correctly straight from the cache
assert.deepEqual(await cache.read(0, DISK_SIZE), expectedAt(0, DISK_SIZE, disk.allocated))
})
it('skips blocks already materialized by an earlier read', async () => {
const { device, disk } = await make()
await device.read(0, 512)
assert.deepEqual(disk.readBlockCalls, [0])
await device.hydrate()
// block 0 was not fetched a second time
assert.deepEqual(disk.readBlockCalls, [0, 2])
assert.deepEqual(device.getMaterialized(), { blocks: BLOCK_COUNT, total: BLOCK_COUNT })
})
it('is a no-op once fully materialized', async () => {
const { device, disk } = await make()
await device.hydrate()
disk.readBlockCalls.length = 0
await device.hydrate()
assert.deepEqual(disk.readBlockCalls, [])
})
it('stops on an aborted signal, keeping what it already cached', async () => {
const disk = new StubDisk(new Set([0, 1, 2, 3]))
const cache = new StubCache()
const device = new CachedDiskBlockDevice({ disk, cache })
await device.open()
// every block but the first parks, so the hydration is still mid-flight
// when it is aborted
let release = () => {}
const parked = new Promise<void>(resolve => {
release = resolve
})
disk.readBlock = async index => {
if (index > 0) {
await parked
}
return { index, data: Buffer.alloc(DISK_BLOCK_SIZE, index) }
}
const controller = new AbortController()
const hydrating = device.hydrate({ concurrency: 1, signal: controller.signal })
for (let turn = 0; turn < 20 && device.getMaterialized().blocks === 0; turn++) {
await new Promise(resolve => setImmediate(resolve))
}
assert.deepEqual(device.getMaterialized(), { blocks: 1, total: BLOCK_COUNT })
controller.abort()
// aborting is a failure, not a silent partial success
await assert.rejects(hydrating)
// and the block already cached stays cached, so a later hydration resumes
assert.deepEqual(device.getMaterialized(), { blocks: 1, total: BLOCK_COUNT })
release() // let the parked fetch finish rather than leaving it dangling
})
it('refuses to start on an already-aborted signal', async () => {
const { device, disk } = await make()
const controller = new AbortController()
controller.abort()
await assert.rejects(device.hydrate({ signal: controller.signal }))
assert.deepEqual(disk.readBlockCalls, [])
assert.deepEqual(device.getMaterialized(), { blocks: 0, total: BLOCK_COUNT })
})
it('respects the concurrency limit', async () => {
const disk = new StubDisk(new Set([0, 1, 2, 3]))
const cache = new StubCache()
const device = new CachedDiskBlockDevice({ disk, cache })
await device.open()
let inFlight = 0
let maxInFlight = 0
const releases: Array<() => void> = []
disk.readBlock = async index => {
inFlight++
maxInFlight = Math.max(maxInFlight, inFlight)
await new Promise<void>(resolve => releases.push(resolve))
inFlight--
return { index, data: Buffer.alloc(DISK_BLOCK_SIZE) }
}
const hydrating = device.hydrate({ concurrency: 2 })
// let the first batch start
await new Promise(resolve => setImmediate(resolve))
assert.equal(maxInFlight, 2)
while (releases.length > 0) {
releases.shift()!()
await new Promise(resolve => setImmediate(resolve))
}
await hydrating
assert.equal(maxInFlight, 2)
})
})
describe('progress handler', () => {
class StubProgressHandler implements ProgressHandler {
calls: Array<number> = []
doneCalls = 0
throwSync = false
rejectAsync = false
async setProgress(fraction: number): Promise<void> {
this.calls.push(fraction)
if (this.throwSync) {
throw new Error('sync failure')
}
if (this.rejectAsync) {
throw new Error('async failure')
}
}
done(): void {
this.doneCalls++
}
}
it('reports the materialized fraction as blocks are read, once each', async () => {
const disk = new StubDisk()
const cache = new StubCache()
const progressHandler = new StubProgressHandler()
const device = new CachedDiskBlockDevice({ disk, cache, progressHandler })
await device.open()
await device.read(0, 512)
// let the fire-and-forget notification run
await new Promise(resolve => setImmediate(resolve))
assert.deepEqual(progressHandler.calls, [1 / BLOCK_COUNT])
// a hole is a materialization too, just with nothing to write
await device.read(DISK_BLOCK_SIZE, 512)
await new Promise(resolve => setImmediate(resolve))
assert.deepEqual(progressHandler.calls, [1 / BLOCK_COUNT, 2 / BLOCK_COUNT])
// re-reading an already materialized block reports nothing new
await device.read(0, 512)
await new Promise(resolve => setImmediate(resolve))
assert.deepEqual(progressHandler.calls, [1 / BLOCK_COUNT, 2 / BLOCK_COUNT])
})
it('reports a fully-covered write the same way as a read', async () => {
const disk = new StubDisk()
const cache = new StubCache()
const progressHandler = new StubProgressHandler()
const device = new CachedDiskBlockDevice({ disk, cache, progressHandler })
await device.open()
await device.write(0, Buffer.alloc(DISK_BLOCK_SIZE, 0xee))
await new Promise(resolve => setImmediate(resolve))
assert.deepEqual(progressHandler.calls, [1 / BLOCK_COUNT])
})
it('does not let a synchronously throwing handler break the read', async () => {
const disk = new StubDisk()
const cache = new StubCache()
const progressHandler = new StubProgressHandler()
progressHandler.throwSync = true
const device = new CachedDiskBlockDevice({ disk, cache, progressHandler })
await device.open()
assert.deepEqual(await device.read(0, 512), expectedAt(0, 512, disk.allocated))
assert.deepEqual(device.getMaterialized(), { blocks: 1, total: BLOCK_COUNT })
})
it('does not let a rejecting handler surface as an unhandled rejection', async () => {
const disk = new StubDisk()
const cache = new StubCache()
const progressHandler = new StubProgressHandler()
progressHandler.rejectAsync = true
const device = new CachedDiskBlockDevice({ disk, cache, progressHandler })
await device.open()
assert.deepEqual(await device.read(0, 512), expectedAt(0, 512, disk.allocated))
// give the rejected promise's `.catch` a turn to run before the test ends
await new Promise(resolve => setImmediate(resolve))
})
it('calls done() once every block is cached, via hydrate', async () => {
const disk = new StubDisk(new Set([0, 1, 2, 3]))
const cache = new StubCache()
const progressHandler = new StubProgressHandler()
const device = new CachedDiskBlockDevice({ disk, cache, progressHandler })
await device.open()
await device.hydrate()
await new Promise(resolve => setImmediate(resolve))
assert.deepEqual(progressHandler.calls, [0.25, 0.5, 0.75, 1])
assert.equal(progressHandler.doneCalls, 1)
})
it('calls done() once every block is cached, via on-demand reads alone', async () => {
const disk = new StubDisk(new Set([0, 1, 2, 3]))
const cache = new StubCache()
const progressHandler = new StubProgressHandler()
const device = new CachedDiskBlockDevice({ disk, cache, progressHandler })
await device.open()
await device.read(0, DISK_BLOCK_SIZE * BLOCK_COUNT)
await new Promise(resolve => setImmediate(resolve))
assert.equal(progressHandler.doneCalls, 1)
})
it('does not call done() before every block is cached', async () => {
const disk = new StubDisk(new Set([0, 1, 2, 3]))
const cache = new StubCache()
const progressHandler = new StubProgressHandler()
const device = new CachedDiskBlockDevice({ disk, cache, progressHandler })
await device.open()
await device.read(0, 512)
await new Promise(resolve => setImmediate(resolve))
assert.equal(progressHandler.doneCalls, 0)
})
it('does not let a throwing done() surface as an unhandled rejection', async () => {
const disk = new StubDisk(new Set([0, 1, 2, 3]))
const cache = new StubCache()
const progressHandler = new StubProgressHandler()
progressHandler.done = () => {
throw new Error('done failure')
}
const device = new CachedDiskBlockDevice({ disk, cache, progressHandler })
await device.open()
await device.hydrate()
await new Promise(resolve => setImmediate(resolve))
})
})
describe('onBlockCached', () => {
it('fires with the index of each newly cached block, once each', async () => {
const disk = new StubDisk(new Set([0, 1, 2, 3]))
const cache = new StubCache()
const cached: Array<number> = []
const device = new CachedDiskBlockDevice({ disk, cache, onBlockCached: index => cached.push(index) })
await device.open()
await device.read(0, 512)
assert.deepEqual(cached, [0])
// a hole is a materialization too
await device.read(DISK_BLOCK_SIZE, 512)
assert.deepEqual(cached, [0, 1])
// re-reading an already materialized block does not fire again
await device.read(0, 512)
assert.deepEqual(cached, [0, 1])
})
it('does not fire for a block resumed from the initial bitmap', async () => {
const disk = new StubDisk()
const cache = new StubCache()
const initialBitmap = Buffer.alloc(BLOCK_COUNT)
initialBitmap[0] = 1
const cached: Array<number> = []
const device = new CachedDiskBlockDevice({ disk, cache, initialBitmap, onBlockCached: index => cached.push(index) })
await device.open()
await device.read(0, 512)
assert.deepEqual(cached, [])
})
it('does not let a synchronously throwing callback break the read', async () => {
const disk = new StubDisk()
const cache = new StubCache()
const device = new CachedDiskBlockDevice({
disk,
cache,
onBlockCached: () => {
throw new Error('persistence failure')
},
})
await device.open()
assert.deepEqual(await device.read(0, 512), expectedAt(0, 512, disk.allocated))
assert.deepEqual(device.getMaterialized(), { blocks: 1, total: BLOCK_COUNT })
})
})
describe('flush and close', () => {
it('flushes the cache', async () => {
const { device, cache } = await make()
await device.flush()
assert.equal(cache.flushed, 1)
})
it('closes the cache and the disk', async () => {
const { device, cache, disk } = await make()
await device.close()
assert.equal(cache.closed, true)
assert.equal(disk.closed, true)
})
it('still closes the disk when closing the cache fails', async () => {
const { device, cache, disk } = await make()
cache.close = async () => {
throw new Error('nope')
}
await assert.rejects(device.close(), /nope/)
assert.equal(disk.closed, true)
})
})

View File

@@ -1,120 +0,0 @@
import { createLogger, type Logger } from '@xen-orchestra/log'
import type { RandomAccessDisk } from '@xen-orchestra/disk-transform'
import type { BlockDevice } from './backend.mjs'
const log: Logger = createLogger('vates:iscsi:disk-block-device')
const DEFAULT_BLOCK_SIZE = 512
export interface DiskBlockDeviceOptions {
/**
* Source disk, already `init()`ed by the caller (this adapter never calls
* `init()` — the disk may be part of a chain the caller assembled).
*/
readonly disk: RandomAccessDisk
/**
* Logical block size advertised to initiators, in bytes. Defaults to 512.
* The disk's own block size must be a multiple of it.
*/
readonly blockSize?: number
}
/**
* A read-only {@link BlockDevice} for a {@link RandomAccessDisk} (the inverse
* of {@link IscsiDisk}). Unallocated blocks read as zero; the source must
* never be asked for a block it doesn't have (e.g. `RemoteVhdDiskChain`
* throws).
*
* Nothing is cached — every read re-fetches a whole source block. Put a
* caching decorator in front of the source disk, not here.
*/
export class DiskBlockDevice implements BlockDevice {
readonly #disk: RandomAccessDisk
readonly #blockSize: number
#diskBlockSize?: number
#size?: number
constructor({ disk, blockSize = DEFAULT_BLOCK_SIZE }: DiskBlockDeviceOptions) {
if (!Number.isInteger(blockSize) || blockSize <= 0) {
throw new Error(`blockSize must be a positive integer, got ${blockSize}`)
}
this.#disk = disk
this.#blockSize = blockSize
}
/**
* Read the source disk's geometry and check it can be exposed as a LUN.
* Awaited by the target before it serves any I/O.
*/
async open(): Promise<void> {
const blockSize = this.#blockSize
const diskBlockSize = this.#disk.getBlockSize()
if (diskBlockSize % blockSize !== 0) {
throw new Error(`disk block size (${diskBlockSize}) is not a multiple of the LUN block size (${blockSize})`)
}
const size = this.#disk.getVirtualSize()
if (size <= 0 || size % blockSize !== 0) {
throw new Error(`disk virtual size (${size}) is not a positive multiple of the LUN block size (${blockSize})`)
}
this.#diskBlockSize = diskBlockSize
this.#size = size
log.debug('opened', { size, blockSize, diskBlockSize })
}
getSize(): number {
const size = this.#size
if (size === undefined) {
throw new Error('DiskBlockDevice.open() must be called before I/O')
}
return size
}
getBlockSize(): number {
return this.#blockSize
}
async read(offset: number, length: number): Promise<Buffer> {
const size = this.getSize()
if (offset < 0 || length < 0 || offset + length > size) {
throw new Error(`read of ${length} bytes at ${offset} is out of range (size ${size})`)
}
if (length === 0) {
return Buffer.alloc(0)
}
const diskBlockSize = this.#diskBlockSize as number
// zero-filled: unallocated blocks, and any source block shorter than
// announced, are left as zeroes
const result = Buffer.alloc(length)
const end = offset + length
const lastIndex = Math.floor((end - 1) / diskBlockSize)
for (let index = Math.floor(offset / diskBlockSize); index <= lastIndex; index++) {
if (!this.#disk.hasBlock(index)) {
continue
}
const blockStart = index * diskBlockSize
const from = Math.max(offset, blockStart)
const to = Math.min(end, blockStart + diskBlockSize)
const { data } = await this.#disk.readBlock(index)
data.copy(result, from - offset, from - blockStart, to - blockStart)
}
return result
}
// the target refuses WRITE commands before they reach `write()`
readonly readOnly = true
/** Always throws: the source disk is read-only. */
async write(): Promise<void> {
throw new Error('DiskBlockDevice is read-only')
}
async flush(): Promise<void> {
// nothing to flush: read-only
}
async close(): Promise<void> {
await this.#disk.close()
}
}

View File

@@ -1,169 +0,0 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { RandomAccessDisk, type DiskBlock } from '@xen-orchestra/disk-transform'
import { DiskBlockDevice } from './DiskBlockDevice.mjs'
const BLOCK_SIZE = 4096
/**
* Sparse in-memory {@link RandomAccessDisk}: `blocks` maps a block index to its
* content, every other index is unallocated and must never be read (the real
* `RemoteVhdDiskChain` throws in that case, which is what this asserts).
*/
class StubDisk extends RandomAccessDisk {
readBlockCalls: Array<number> = []
closed = false
constructor(
readonly blocks: Map<number, Buffer>,
readonly virtualSize: number,
readonly blockSize: number = BLOCK_SIZE
) {
super()
}
getBlockSize(): number {
return this.blockSize
}
getVirtualSize(): number {
return this.virtualSize
}
isDifferencing(): boolean {
return false
}
getBlockIndexes(): Array<number> {
return [...this.blocks.keys()]
}
hasBlock(index: number): boolean {
return this.blocks.has(index)
}
async init(): Promise<void> {}
async close(): Promise<void> {
this.closed = true
}
async readBlock(index: number): Promise<DiskBlock> {
const data = this.blocks.get(index)
if (data === undefined) {
throw new Error(`Block ${index} not found`)
}
this.readBlockCalls.push(index)
return { index, data }
}
}
const filled = (byte: number, length = BLOCK_SIZE) => Buffer.alloc(length, byte)
// 4 blocks, only 0 and 2 allocated
const makeDevice = async (blockSize?: number) => {
const disk = new StubDisk(
new Map([
[0, filled(0xaa)],
[2, filled(0xcc)],
]),
4 * BLOCK_SIZE
)
const device = new DiskBlockDevice({ disk, blockSize })
await device.open()
return { disk, device }
}
describe('open', () => {
it('exposes the disk capacity and the requested block size', async () => {
const { device } = await makeDevice()
assert.equal(device.getSize(), 4 * BLOCK_SIZE)
assert.equal(device.getBlockSize(), 512)
})
it('rejects a disk block size that is not a multiple of the LUN block size', async () => {
const disk = new StubDisk(new Map(), 4096, 600)
await assert.rejects(new DiskBlockDevice({ disk }).open(), /not a multiple of the LUN block size/)
})
it('rejects a capacity that is not a multiple of the LUN block size', async () => {
const disk = new StubDisk(new Map(), BLOCK_SIZE + 100)
await assert.rejects(new DiskBlockDevice({ disk }).open(), /not a positive multiple/)
})
it('rejects an invalid block size', () => {
const disk = new StubDisk(new Map(), BLOCK_SIZE)
assert.throws(() => new DiskBlockDevice({ disk, blockSize: 0 }), /positive integer/)
})
it('refuses I/O before open()', () => {
const disk = new StubDisk(new Map(), BLOCK_SIZE)
assert.throws(() => new DiskBlockDevice({ disk }).getSize(), /open\(\) must be called/)
})
})
describe('read', () => {
it('reads a whole allocated block', async () => {
const { device } = await makeDevice()
assert.deepEqual(await device.read(0, BLOCK_SIZE), filled(0xaa))
})
it('reads an unaligned slice inside one block', async () => {
const { device, disk } = await makeDevice()
assert.deepEqual(await device.read(BLOCK_SIZE * 2 + 512, 1024), filled(0xcc, 1024))
// a single source block was fetched, not the whole disk
assert.deepEqual(disk.readBlockCalls, [2])
})
it('zero-fills unallocated blocks instead of reading them', async () => {
const { device, disk } = await makeDevice()
assert.deepEqual(await device.read(BLOCK_SIZE, BLOCK_SIZE), Buffer.alloc(BLOCK_SIZE))
assert.deepEqual(disk.readBlockCalls, [])
})
it('stitches a range spanning allocated and unallocated blocks', async () => {
const { device } = await makeDevice()
const data = await device.read(BLOCK_SIZE - 512, BLOCK_SIZE + 1024)
assert.deepEqual(
data,
Buffer.concat([
filled(0xaa, 512), // tail of block 0
Buffer.alloc(BLOCK_SIZE), // block 1, unallocated
filled(0xcc, 512), // head of block 2
])
)
})
it('reads the last block of the disk', async () => {
const { device } = await makeDevice()
assert.deepEqual(await device.read(3 * BLOCK_SIZE, BLOCK_SIZE), Buffer.alloc(BLOCK_SIZE))
})
it('returns an empty buffer for a zero-length read', async () => {
const { device, disk } = await makeDevice()
assert.equal((await device.read(0, 0)).length, 0)
assert.deepEqual(disk.readBlockCalls, [])
})
it('rejects a read past the end of the disk', async () => {
const { device } = await makeDevice()
await assert.rejects(device.read(4 * BLOCK_SIZE - 512, 1024), /out of range/)
await assert.rejects(device.read(-512, 512), /out of range/)
})
it('zero-pads a source block shorter than announced', async () => {
const disk = new StubDisk(new Map([[0, filled(0xaa, 1024)]]), BLOCK_SIZE)
const device = new DiskBlockDevice({ disk })
await device.open()
assert.deepEqual(await device.read(0, BLOCK_SIZE), Buffer.concat([filled(0xaa, 1024)], BLOCK_SIZE))
})
})
describe('write', () => {
it('always fails', async () => {
const { device } = await makeDevice()
await assert.rejects(device.write(), /read-only/)
})
})
describe('close', () => {
it('closes the underlying disk', async () => {
const { device, disk } = await makeDevice()
await device.close()
assert.equal(disk.closed, true)
})
})

View File

@@ -1,98 +0,0 @@
import { RandomAccessDisk, type DiskBlock } from '@xen-orchestra/disk-transform'
import { IscsiInitiator, type IscsiInitiatorOptions } from './initiator.mjs'
export interface IscsiDiskOptions {
/**
* Block size, in bytes, presented to the disk-transform pipeline. Must be a
* multiple of the LUN's logical block size (checked at {@link IscsiDisk.init}).
* A single SCSI READ(16) fetches one whole block.
*/
readonly blockSize: number
}
/**
* A {@link RandomAccessDisk} backed by a remote iSCSI LUN read through an
* {@link IscsiInitiator} — the iSCSI analogue of `@vates/nbd-client`'s `NbdDisk`.
* It plugs a target LUN into the `@xen-orchestra/disk-transform` pipeline
* (`ReadAhead`, `DiskLargerBlock`, block generator) unchanged.
*
* First implementation: the LUN is treated as fully allocated — every block is
* reported as present (dense, like `RawDisk`). A future version can narrow this
* with a changed-block map (e.g. a Pure "Volume Difference"). Read-only.
*/
export class IscsiDisk extends RandomAccessDisk {
readonly #options: IscsiInitiatorOptions
readonly #blockSize: number
#initiator?: IscsiInitiator
constructor(options: IscsiInitiatorOptions, { blockSize }: IscsiDiskOptions) {
super()
this.#options = options
this.#blockSize = blockSize
}
async init(): Promise<void> {
const initiator = new IscsiInitiator(this.#options)
await initiator.connect()
const lunBlockSize = initiator.getBlockSize()
if (this.#blockSize % lunBlockSize !== 0) {
await initiator.close()
throw new Error(`block size ${this.#blockSize} is not a multiple of the LUN block size ${lunBlockSize}`)
}
this.#initiator = initiator
}
async close(): Promise<void> {
const initiator = this.#initiator
this.#initiator = undefined
await initiator?.close()
}
#requireInitiator(): IscsiInitiator {
const initiator = this.#initiator
if (initiator === undefined) {
throw new Error('IscsiDisk.init() must be called first')
}
return initiator
}
getBlockSize(): number {
return this.#blockSize
}
getVirtualSize(): number {
return this.#requireInitiator().getSize()
}
isDifferencing(): boolean {
return false
}
getBlockIndexes(): Array<number> {
const count = Math.ceil(this.getVirtualSize() / this.#blockSize)
const indexes = new Array<number>(count)
for (let i = 0; i < count; i++) {
indexes[i] = i
}
return indexes
}
hasBlock(index: number): boolean {
return index >= 0 && index * this.#blockSize < this.getVirtualSize()
}
async readBlock(index: number): Promise<DiskBlock> {
const initiator = this.#requireInitiator()
const size = this.getVirtualSize()
const offset = index * this.#blockSize
const length = Math.min(this.#blockSize, size - offset)
let data = await initiator.read(offset, length)
if (data.length < this.#blockSize) {
// Last (short) block on a LUN whose capacity is not a multiple of the
// disk block size: zero-pad the tail to a full block.
data = Buffer.concat([data], this.#blockSize)
}
return { index, data }
}
}

View File

@@ -1,129 +0,0 @@
import assert from 'node:assert/strict'
import { execFile, execFileSync } from 'node:child_process'
import { mkdtemp, rm, truncate, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { after, before, describe, it } from 'node:test'
import { FileBlockDevice } from './index.mjs'
const execFileP = promisify(execFile)
const BLOCK_SIZE = 512
const SIZE = 4 * 1024 * 1024 // 4 MiB
/**
* Why this cannot run here, or `false` if it can. A real block device is the
* whole point, and the cheapest one to make is a loop device — which needs root
* and `losetup`.
*/
function skipReason(): string | false {
if (process.getuid === undefined || process.getuid() !== 0) {
return 'requires root to attach a loop device'
}
try {
execFileSync('losetup', ['--version'], { stdio: 'ignore' })
} catch {
return 'requires util-linux (losetup not found)'
}
return false
}
const bytePattern = (i: number) => (i * 31 + 11) & 0xff
describe('FileBlockDevice on a loop device', { skip: skipReason() }, () => {
let dir: string
let devicePath: string
before(async () => {
dir = await mkdtemp(join(tmpdir(), 'vates-iscsi-loop-'))
const backing = join(dir, 'backing.img')
await writeFile(backing, '')
await truncate(backing, SIZE)
const { stdout } = await execFileP('losetup', ['--find', '--show', backing])
devicePath = stdout.trim()
})
after(async () => {
if (devicePath !== undefined) {
await execFileP('losetup', ['--detach', devicePath])
}
await rm(dir, { force: true, recursive: true })
})
const withDevice = async (fn: (device: FileBlockDevice) => Promise<void>): Promise<void> => {
const device = new FileBlockDevice({ path: devicePath, size: SIZE })
await device.open()
try {
await fn(device)
} finally {
await device.close()
}
}
it('accepts the device node and reports the capacity it was given', async () => {
await withDevice(async device => {
assert.equal(device.getSize(), SIZE)
assert.equal(device.getBlockSize(), BLOCK_SIZE)
})
})
it('round-trips block-aligned data', async () => {
await withDevice(async device => {
const payload = Buffer.alloc(8 * BLOCK_SIZE)
for (let i = 0; i < payload.length; i++) {
payload[i] = bytePattern(i)
}
await device.write(16 * BLOCK_SIZE, payload)
assert.deepEqual(await device.read(16 * BLOCK_SIZE, payload.length), payload)
})
})
it('round-trips data straddling block boundaries, at an unaligned offset', async () => {
await withDevice(async device => {
const payload = Buffer.from('a payload that is not a whole number of sectors long')
const offset = 3 * BLOCK_SIZE + 17
await device.write(offset, payload)
assert.deepEqual(await device.read(offset, payload.length), payload)
// the surrounding sector was read-modify-written by the kernel, not clobbered
assert.deepEqual(await device.read(3 * BLOCK_SIZE, 17), Buffer.alloc(17))
})
})
it('survives a reopen, which is what makes it a cache', async () => {
const payload = Buffer.alloc(BLOCK_SIZE, 0x5a)
await withDevice(device => device.write(2 * BLOCK_SIZE, payload))
await withDevice(async device => {
await device.flush()
assert.deepEqual(await device.read(2 * BLOCK_SIZE, BLOCK_SIZE), payload)
})
})
it('reads a large range in one call', async () => {
await withDevice(async device => {
const payload = Buffer.alloc(SIZE / 2)
for (let i = 0; i < payload.length; i++) {
payload[i] = bytePattern(i + 7)
}
await device.write(0, payload)
assert.deepEqual(await device.read(0, payload.length), payload)
})
})
it('throws on a read running past the end, instead of zero-filling it', async () => {
// the behaviour that sets the block device mode apart: a short read on a
// device of a known size is an I/O error, never a sparse tail
await withDevice(async device => {
await assert.rejects(device.read(SIZE, BLOCK_SIZE), /ended after 0 bytes/)
await assert.rejects(device.read(SIZE - BLOCK_SIZE, 2 * BLOCK_SIZE), /ended after 512 bytes/)
})
})
it('flushes without error', async () => {
await withDevice(async device => {
await device.write(0, Buffer.alloc(BLOCK_SIZE, 0xff))
await device.flush()
})
})
})

View File

@@ -1,175 +0,0 @@
import { open, type FileHandle } from 'node:fs/promises'
/**
* Byte-range random-access store backing a single iSCSI LUN.
*
* The protocol layer translates SCSI READ/WRITE CDBs (LBA + block count) into
* byte offsets/lengths against this interface, so an implementation only has to
* provide flat random access — it is intentionally decoupled from the wire
* code. `DiskBlockDevice` is such an implementation, over
* `@xen-orchestra/disk-transform`'s `RandomAccessDisk` (VHD/raw/NBD).
*/
export interface BlockDevice {
/** Optional one-time initialization, awaited by the target before serving I/O. */
open?(): Promise<void>
/** Total capacity in bytes. Must be a multiple of {@link BlockDevice.getBlockSize}. */
getSize(): number
/** Logical block size in bytes (typically 512). */
getBlockSize(): number
/**
* When true, the LUN is reported as write-protected and WRITE commands are
* refused up front (DATA PROTECT), without soliciting their data.
*/
readonly readOnly?: boolean
/** Read exactly `length` bytes starting at byte `offset`. */
read(offset: number, length: number): Promise<Buffer>
/** Write `data` starting at byte `offset`. */
write(offset: number, data: Buffer): Promise<void>
/** Flush any buffered data to stable storage (SYNCHRONIZE CACHE). */
flush(): Promise<void>
/** Release underlying resources. */
close(): Promise<void>
}
const DEFAULT_BLOCK_SIZE = 512
export interface FileBlockDeviceOptions {
/**
* Path to the backing file. It must already exist and be sized to capacity —
* or be a block device node, see {@link FileBlockDeviceOptions.size}.
*/
readonly path: string
/** Logical block size in bytes. Defaults to 512. */
readonly blockSize?: number
/**
* Capacity in bytes, for a block device node (e.g. a VDI hot-plugged onto the
* appliance's VM, `/dev/xvdX`), whose inode reports a size of 0.
*
* Setting it switches to the block device mode:
* - `path` must be a block device: a typo naming the wrong node is the
* difference between a cache and a destroyed disk;
* - a short read is an I/O error, not a sparse tail: zero-filling it would hand
* the initiator fabricated data.
*/
readonly size?: number
}
/**
* A {@link BlockDevice} backed by a regular (sparse) file, or a block device
* node, opened read/write.
*
* The file must already exist and be sized to the desired capacity (e.g. via
* `fs.truncate`); this keeps the device a pure data store with no provisioning
* policy of its own.
*
* I/O is buffered, never `O_DIRECT`, which would need buffers aligned to the
* logical block size that Node cannot allocate.
*/
export class FileBlockDevice implements BlockDevice {
readonly #path: string
readonly #blockSize: number
readonly #isBlockDevice: boolean
#handle?: FileHandle
#size = 0
constructor({ path, blockSize = DEFAULT_BLOCK_SIZE, size }: FileBlockDeviceOptions) {
if (!Number.isInteger(blockSize) || blockSize <= 0) {
throw new Error(`blockSize must be a positive integer, got ${blockSize}`)
}
if (size !== undefined) {
if (!Number.isInteger(size) || size <= 0) {
throw new Error(`size must be a positive integer, got ${size}`)
}
if (size % blockSize !== 0) {
throw new Error(`size (${size}) is not a multiple of block size (${blockSize})`)
}
this.#size = size
}
this.#path = path
this.#blockSize = blockSize
this.#isBlockDevice = size !== undefined
}
/** Open the backing file and read its current size. Must be called before any I/O. */
async open(): Promise<void> {
if (this.#handle !== undefined) {
return
}
const handle = await open(this.#path, 'r+')
try {
const stats = await handle.stat()
if (this.#isBlockDevice) {
if (!stats.isBlockDevice()) {
throw new Error(`${this.#path} is not a block device`)
}
} else {
if (stats.size % this.#blockSize !== 0) {
throw new Error(`backing file size (${stats.size}) is not a multiple of block size (${this.#blockSize})`)
}
this.#size = stats.size
}
this.#handle = handle
} catch (error) {
await handle.close()
throw error
}
}
#requireHandle(): FileHandle {
const handle = this.#handle
if (handle === undefined) {
throw new Error('FileBlockDevice.open() must be called before I/O')
}
return handle
}
getSize(): number {
return this.#size
}
getBlockSize(): number {
return this.#blockSize
}
async read(offset: number, length: number): Promise<Buffer> {
const handle = this.#requireHandle()
const buffer = Buffer.alloc(length, 0)
let read = 0
// A single fh.read may return fewer bytes than requested; loop to fill.
while (read < length) {
const { bytesRead } = await handle.read(buffer, read, length - read, offset + read)
if (bytesRead === 0) {
if (this.#isBlockDevice) {
// the device is exactly as long as the caller said: the I/O failed
throw new Error(`read of ${length} bytes at ${offset} on ${this.#path} ended after ${read} bytes`)
}
// Reading past EOF (sparse tail): zero-fill the remainder.
buffer.fill(0, read)
break
}
read += bytesRead
}
return buffer
}
async write(offset: number, data: Buffer): Promise<void> {
const handle = this.#requireHandle()
let written = 0
while (written < data.length) {
const { bytesWritten } = await handle.write(data, written, data.length - written, offset + written)
written += bytesWritten
}
}
async flush(): Promise<void> {
await this.#requireHandle().sync()
}
async close(): Promise<void> {
const handle = this.#handle
if (handle !== undefined) {
this.#handle = undefined
await handle.close()
}
}
}

View File

@@ -1,173 +0,0 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { mkdtemp, rm, truncate, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { FileBlockDevice } from './backend.mjs'
const BLOCK_SIZE = 512
/** A temporary directory removed whatever the test does. */
const withTmpDir = async (fn: (dir: string) => Promise<void>): Promise<void> => {
const dir = await mkdtemp(join(tmpdir(), 'vates-iscsi-backend-'))
try {
await fn(dir)
} finally {
await rm(dir, { force: true, recursive: true })
}
}
/** A sparse file of `size` bytes, as the class requires: existing and pre-sized. */
const makeFile = async (dir: string, size: number, name = 'lun.img'): Promise<string> => {
const path = join(dir, name)
await writeFile(path, '')
await truncate(path, size)
return path
}
describe('FileBlockDevice', () => {
it('rejects a non-positive-integer block size', () => {
assert.throws(() => new FileBlockDevice({ path: '/nope', blockSize: 0 }), /positive integer/)
assert.throws(() => new FileBlockDevice({ path: '/nope', blockSize: -1 }), /positive integer/)
assert.throws(() => new FileBlockDevice({ path: '/nope', blockSize: 1.5 }), /positive integer/)
})
it('reads the capacity from the backing file', async () => {
await withTmpDir(async dir => {
const device = new FileBlockDevice({ path: await makeFile(dir, 4 * BLOCK_SIZE) })
await device.open()
try {
assert.equal(device.getSize(), 4 * BLOCK_SIZE)
assert.equal(device.getBlockSize(), BLOCK_SIZE)
} finally {
await device.close()
}
})
})
it('refuses a file whose size is not a multiple of the block size', async () => {
await withTmpDir(async dir => {
const device = new FileBlockDevice({ path: await makeFile(dir, BLOCK_SIZE + 1) })
await assert.rejects(device.open(), /not a multiple of block size/)
})
})
it('throws on I/O before open()', async () => {
const device = new FileBlockDevice({ path: '/nope' })
await assert.rejects(device.read(0, BLOCK_SIZE), /must be called before I\/O/)
await assert.rejects(device.write(0, Buffer.alloc(BLOCK_SIZE)), /must be called before I\/O/)
await assert.rejects(device.flush(), /must be called before I\/O/)
})
it('round-trips a write, at an offset and unaligned', async () => {
await withTmpDir(async dir => {
const device = new FileBlockDevice({ path: await makeFile(dir, 4 * BLOCK_SIZE) })
await device.open()
try {
const payload = Buffer.from('hello, lun')
await device.write(BLOCK_SIZE + 3, payload)
assert.deepEqual(await device.read(BLOCK_SIZE + 3, payload.length), payload)
// the bytes around it are untouched
assert.deepEqual(await device.read(BLOCK_SIZE, 3), Buffer.alloc(3))
} finally {
await device.close()
}
})
})
it('zero-fills the sparse tail of a read past EOF', async () => {
await withTmpDir(async dir => {
// a hole: nothing was ever written, so the whole file reads as zeroes
const device = new FileBlockDevice({ path: await makeFile(dir, 2 * BLOCK_SIZE) })
await device.open()
try {
assert.deepEqual(await device.read(0, 2 * BLOCK_SIZE), Buffer.alloc(2 * BLOCK_SIZE))
// past the end of the file, `fh.read` reports 0 bytes read: the remainder is
// zero-filled rather than being reported as an error
assert.deepEqual(await device.read(2 * BLOCK_SIZE, BLOCK_SIZE), Buffer.alloc(BLOCK_SIZE))
} finally {
await device.close()
}
})
})
it('reads back a large buffer in full', async () => {
await withTmpDir(async dir => {
const size = 1024 * BLOCK_SIZE
const device = new FileBlockDevice({ path: await makeFile(dir, size) })
await device.open()
try {
const payload = Buffer.alloc(size)
for (let i = 0; i < payload.length; i++) {
payload[i] = (i * 31 + 11) & 0xff
}
await device.write(0, payload)
assert.deepEqual(await device.read(0, size), payload)
} finally {
await device.close()
}
})
})
it('flushes and closes, and tolerates being opened or closed twice', async () => {
await withTmpDir(async dir => {
const device = new FileBlockDevice({ path: await makeFile(dir, BLOCK_SIZE) })
await device.open()
await device.open() // no-op
await device.write(0, Buffer.alloc(BLOCK_SIZE, 0xaa))
await device.flush()
await device.close()
await device.close() // no-op
await assert.rejects(device.read(0, BLOCK_SIZE), /must be called before I\/O/)
})
})
it('leaves no handle behind when open() fails', async () => {
await withTmpDir(async dir => {
const device = new FileBlockDevice({ path: await makeFile(dir, BLOCK_SIZE + 1) })
await assert.rejects(device.open())
// the handle opened before the size check was closed again
await assert.rejects(device.read(0, BLOCK_SIZE), /must be called before I\/O/)
})
})
})
// A real device node cannot be created without root, so what is covered here is
// everything around the I/O: the guards, the capacity, and the lifecycle. The
// I/O itself is covered by `backend.integ.mts`, against a loop device.
describe('FileBlockDevice with a size, over a block device', () => {
it('rejects a non-positive-integer size', () => {
for (const size of [0, -BLOCK_SIZE, 1.5]) {
assert.throws(() => new FileBlockDevice({ path: '/dev/null', size }), /size must be a positive integer/)
}
})
it('rejects a size that is not a multiple of the block size', () => {
assert.throws(
() => new FileBlockDevice({ path: '/dev/null', size: BLOCK_SIZE + 1 }),
/is not a multiple of block size/
)
})
it('reports the capacity it was given, never the inode', () => {
// nothing may be read from the inode, which for a real device node reports 0
const device = new FileBlockDevice({ path: '/dev/null', size: 4 * BLOCK_SIZE })
assert.equal(device.getSize(), 4 * BLOCK_SIZE)
assert.equal(device.getBlockSize(), BLOCK_SIZE)
})
it('refuses to open a path that is not a block device', async () => {
await withTmpDir(async dir => {
const path = await makeFile(dir, 4 * BLOCK_SIZE)
const device = new FileBlockDevice({ path, size: 4 * BLOCK_SIZE })
// the guard against a misconfigured path naming, say, a real disk's
// filesystem instead of the cache device
await assert.rejects(device.open(), new RegExp(`${path} is not a block device`))
// and the handle it opened to check was closed again
await assert.rejects(device.read(0, BLOCK_SIZE), /must be called before I\/O/)
})
})
})

View File

@@ -1,90 +0,0 @@
// Shared CHAP (RFC 1994) primitives for both login directions: the target as
// authenticator, the initiator as responder. Kept in one place so the MD5
// computation and large-binary codec agree byte-for-byte between the two
// (cross-tested against each other in loopback).
//
// RFC 7143 §11.1.4 pins CHAP to MD5 (algorithm id 5) — a wire-format
// requirement, not a security choice.
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
/** The only CHAP algorithm iSCSI defines / we support: MD5 (RFC 7143 §11.1.4). */
export const CHAP_ALGORITHM_MD5 = 5
/** Default challenge length in bytes; 16 is what open-iscsi and most targets use. */
const DEFAULT_CHALLENGE_LENGTH = 16
/**
* Compute a CHAP response: `MD5(id ‖ secret ‖ challenge)` (RFC 1994 §4.1).
*
* `id` is the single CHAP_I octet (0-255) — the raw byte, NOT its ASCII decimal.
* `secret` is the shared secret (a string is hashed as its UTF-8 bytes).
*/
export function computeResponse(id: number, secret: string | Buffer, challenge: Buffer): Buffer {
return createHash('md5')
.update(Buffer.from([id & 0xff]))
.update(secret)
.update(challenge)
.digest()
}
/**
* Encode a binary value (CHAP_C / CHAP_R) as an iSCSI hex-constant, e.g.
* `0xa1b2…` (RFC 7143 §5.1). We always emit hex; {@link decodeChapValue} accepts
* both hex and base64 so we interoperate with peers that send either.
*/
export function encodeChapValue(value: Buffer): string {
return '0x' + value.toString('hex')
}
/**
* Decode an iSCSI large-binary value: `0x`/`0X` hex-constant or `0b`/`0B`
* base64-constant (RFC 7143 §5.1). Throws on an unrecognized encoding.
*/
export function decodeChapValue(value: string): Buffer {
const prefix = value.slice(0, 2)
const body = value.slice(2)
if (prefix === '0x' || prefix === '0X') {
return Buffer.from(body, 'hex')
}
if (prefix === '0b' || prefix === '0B') {
return Buffer.from(body, 'base64')
}
throw new Error(`unsupported CHAP value encoding: ${value.slice(0, 2)}…`)
}
/** Generate a random CHAP challenge (CHAP_C). */
export function generateChallenge(length: number = DEFAULT_CHALLENGE_LENGTH): Buffer {
return randomBytes(length)
}
/** Generate a random CHAP identifier (CHAP_I), a single octet. */
export function generateId(): number {
return randomBytes(1)[0]
}
/** Parse a CHAP_A value (`"5"` or a `"5,6,7"` preference list) into algorithm ids. */
export function parseAlgorithmList(value: string): number[] {
return value
.split(',')
.map(part => Number.parseInt(part.trim(), 10))
.filter(id => Number.isInteger(id))
}
/**
* Pick the algorithm we will use from the ones the peer offered: MD5 if present,
* else `undefined` (we only implement MD5, so an offer without it must fail —
* never fall back to the first offered id).
*/
export function selectAlgorithm(offered: readonly number[]): number | undefined {
return offered.includes(CHAP_ALGORITHM_MD5) ? CHAP_ALGORITHM_MD5 : undefined
}
/**
* Constant-time comparison of an expected response against a received one. A
* wrong-length `got` (or the length-mismatch `timingSafeEqual` would throw on)
* counts as a failed authentication, not an exception.
*/
export function verifyResponse(expected: Buffer, got: Buffer): boolean {
return expected.length === got.length && timingSafeEqual(expected, got)
}

View File

@@ -1,108 +0,0 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { describe, it } from 'node:test'
import {
CHAP_ALGORITHM_MD5,
computeResponse,
decodeChapValue,
encodeChapValue,
generateChallenge,
generateId,
parseAlgorithmList,
selectAlgorithm,
verifyResponse,
} from './chap.mjs'
describe('computeResponse', () => {
// Known-answer vector: MD5(0x01 ‖ "s3cr3t" ‖ <16-byte challenge>), computed
// independently so this pins the exact byte layout (id octet first), not MD5
// against itself.
const id = 1
const secret = 's3cr3t'
const challenge = Buffer.from('0102030405060708090a0b0c0d0e0f10', 'hex')
const expected = 'fc14fb49f184c847514a10278d75a10f'
it('hashes id ‖ secret ‖ challenge (RFC 1994)', () => {
assert.equal(computeResponse(id, secret, challenge).toString('hex'), expected)
})
it('treats a string secret as its UTF-8 bytes', () => {
assert.deepEqual(
computeResponse(id, Buffer.from(secret, 'utf8'), challenge),
computeResponse(id, secret, challenge)
)
})
it('uses the id as a raw octet, not its ASCII decimal', () => {
// computeResponse(1, …) must hash the byte 0x01, whereas hashing the ASCII
// text "1" would hash 0x31 — the two must differ.
const rawOctet = createHash('md5')
.update(Buffer.from([1]))
.update(secret)
.update(challenge)
.digest()
const asciiText = createHash('md5').update(Buffer.from('1', 'ascii')).update(secret).update(challenge).digest()
assert.deepEqual(computeResponse(1, secret, challenge), rawOctet)
assert.notDeepEqual(computeResponse(1, secret, challenge), asciiText)
})
})
describe('CHAP value codec', () => {
it('encodes as a 0x hex-constant', () => {
assert.equal(encodeChapValue(Buffer.from([0xab, 0xcd, 0x01])), '0xabcd01')
})
it('round-trips through hex', () => {
const value = generateChallenge()
assert.deepEqual(decodeChapValue(encodeChapValue(value)), value)
})
it('decodes a 0b base64-constant', () => {
const raw = Buffer.from('0102030405060708090a0b0c0d0e0f10', 'hex')
assert.deepEqual(decodeChapValue('0b' + raw.toString('base64')), raw)
})
it('accepts uppercase 0X / 0B prefixes', () => {
assert.deepEqual(decodeChapValue('0Xabcd'), Buffer.from('abcd', 'hex'))
assert.deepEqual(decodeChapValue('0B' + Buffer.from([1, 2]).toString('base64')), Buffer.from([1, 2]))
})
it('rejects an unknown encoding', () => {
assert.throws(() => decodeChapValue('12345'), /unsupported CHAP value encoding/)
})
})
describe('algorithm selection', () => {
it('parses a preference list', () => {
assert.deepEqual(parseAlgorithmList('5,6,7'), [5, 6, 7])
assert.deepEqual(parseAlgorithmList('5'), [5])
})
it('picks MD5 when offered', () => {
assert.equal(selectAlgorithm([1, CHAP_ALGORITHM_MD5, 6]), CHAP_ALGORITHM_MD5)
})
it('returns undefined when MD5 is not offered (never falls back)', () => {
assert.equal(selectAlgorithm([1, 2]), undefined)
})
})
describe('verifyResponse', () => {
const secret = 's3cr3t'
const challenge = generateChallenge()
const id = generateId()
const good = computeResponse(id, secret, challenge)
it('accepts a matching response', () => {
assert.equal(verifyResponse(good, computeResponse(id, secret, challenge)), true)
})
it('rejects a wrong secret', () => {
assert.equal(verifyResponse(good, computeResponse(id, 'wrong', challenge)), false)
})
it('rejects a wrong-length response without throwing', () => {
assert.equal(verifyResponse(good, Buffer.alloc(4)), false)
})
})

View File

@@ -1,141 +0,0 @@
#!/usr/bin/env node
import { stat, truncate, writeFile } from 'node:fs/promises'
import { basename } from 'node:path'
import { parseArgs } from 'node:util'
import { FileBlockDevice, IscsiTarget } from './index.mjs'
const USAGE = `Usage: vates-iscsi <file> [options]
Serve a single read/write iSCSI LUN backed by <file>.
Arguments:
<file> Backing file. Must exist and have a size that is a
multiple of the block size, unless --size is given.
Options:
--serial <serial> LUN serial number (default: derived from the IQN).
--size <bytes> Create/grow the backing file to this many bytes.
Accepts suffixes K, M, G, T (e.g. 10G).
--iqn <iqn> Target IQN (default: iqn.2024-01.tech.vates:<file>).
--host <host> Listen address (default: 0.0.0.0).
--port <port> Listen port (default: 3260).
--block-size <bytes> Logical block size (default: 512).
-h, --help Show this help.
`
function fail(message: string): never {
process.stderr.write(`vates-iscsi: ${message}\n`)
process.exit(1)
}
/** Parse a positive integer with optional K/M/G/T suffix (powers of 1024). */
function parseSize(value: string, label: string): number {
const match = /^(\d+)([KMGT]?)$/i.exec(value.trim())
if (match === null) {
fail(`invalid ${label}: ${value}`)
}
const units: Record<string, number> = { '': 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 }
return Number(match[1]) * units[match[2].toUpperCase()]
}
/** Sanitize a string into the characters allowed in the IQN-specific part. */
function sanitizeIqnPart(value: string): string {
const cleaned = value.toLowerCase().replace(/[^a-z0-9.-]+/g, '-').replace(/^-+|-+$/g, '')
return cleaned.length > 0 ? cleaned : 'lun0'
}
const { values, positionals } = parseArgs({
allowPositionals: true,
options: {
serial: { type: 'string' },
size: { type: 'string' },
iqn: { type: 'string' },
host: { type: 'string' },
port: { type: 'string' },
'block-size': { type: 'string' },
help: { type: 'boolean', short: 'h' },
},
})
if (values.help || positionals.length === 0) {
process.stdout.write(USAGE)
process.exit(values.help ? 0 : 1)
}
if (positionals.length > 1) {
fail(`expected a single <file>, got ${positionals.length}`)
}
const path = positionals[0]
const blockSize = values['block-size'] !== undefined ? parseSize(values['block-size'], 'block size') : 512
const port = values.port !== undefined ? Number.parseInt(values.port, 10) : 3260
if (!Number.isInteger(port) || port < 0 || port > 65535) {
fail(`invalid port: ${values.port}`)
}
// Create/grow the backing file when --size is requested.
if (values.size !== undefined) {
const size = parseSize(values.size, 'size')
if (size % blockSize !== 0) {
fail(`--size (${size}) must be a multiple of the block size (${blockSize})`)
}
const exists = await stat(path).then(
() => true,
() => false
)
if (!exists) {
await writeFile(path, Buffer.alloc(0))
}
await truncate(path, size)
}
// Validate the backing file before binding the socket.
const stats = await stat(path).catch(() => fail(`backing file not found: ${path} (create one, e.g. \`truncate -s 10G ${path}\`, or pass --size)`))
if (stats.size === 0) {
fail(`backing file is empty: ${path} (size it first, e.g. \`truncate -s 10G ${path}\`, or pass --size)`)
}
const iqn = values.iqn ?? `iqn.2024-01.tech.vates:${sanitizeIqnPart(basename(path).replace(/\.[^.]+$/, ''))}`
const target = new IscsiTarget({
iqn,
host: values.host,
port,
lun: new FileBlockDevice({ path, blockSize }),
identity: values.serial !== undefined ? { serial: values.serial } : undefined,
})
await target.listen()
const address = target.address()
const listenHost = values.host ?? '0.0.0.0'
const listenPort = address?.port ?? port
process.stdout.write(
[
`iSCSI target listening on ${listenHost}:${listenPort}`,
` IQN: ${iqn}`,
` LUN: ${path} (${stats.size} bytes, ${blockSize}-byte blocks)`,
` serial: ${values.serial ?? iqn}`,
``,
`Discover with: iscsiadm -m discovery -t sendtargets -p ${listenHost === '0.0.0.0' ? '127.0.0.1' : listenHost}:${listenPort}`,
`Press Ctrl-C to stop.`,
``,
].join('\n')
)
let stopping = false
function stop(): void {
if (stopping) {
return
}
stopping = true
target.close().then(
() => process.exit(0),
error => {
process.stderr.write(`vates-iscsi: error on shutdown: ${String(error)}\n`)
process.exit(1)
}
)
}
process.once('SIGINT', stop)
process.once('SIGTERM', stop)

View File

@@ -1,622 +0,0 @@
import type { Socket } from 'node:net'
import { createLogger, type Logger } from '@xen-orchestra/log'
import type { BlockDevice } from './backend.mjs'
import {
Asc,
DATA_IN_FLAG_FINAL,
DATA_IN_FLAG_OVERFLOW,
DATA_IN_FLAG_STATUS,
DATA_IN_FLAG_UNDERFLOW,
FLAG_FINAL,
InitiatorOpcode,
ISCSI_VERSION,
LoginStatusClass,
LogoutResponse,
RejectReason,
RESERVED_TAG,
SCSI_RESP_FLAG_OVERFLOW,
SCSI_RESP_FLAG_UNDERFLOW,
SCSI_RESPONSE_COMMAND_COMPLETED,
ScsiStatus,
SenseKey,
TargetOpcode,
TEXT_FLAG_FINAL,
} from './constants.mjs'
import { buildFixedSense, handleScsiCommand, type ScsiIdentity } from './scsi.mjs'
import {
buildSendTargetsResponse,
LOGIN_MAX_DATA_SEGMENT_LENGTH,
LoginNegotiator,
OUR_MAX_RECV_DATA_SEGMENT_LENGTH,
parseTextKeys,
} from './login.mjs'
import { allocBhs, assemblePdu, type IncomingPdu, readPdu, writePdu } from './pdu.mjs'
import {
decodeCdb,
type ChapCredentials,
type CommandContext,
type NegotiatedParams,
type ScsiResponseOptions,
type SessionType,
} from './types.mjs'
import { buildR2t, receiveDataOut, type PendingWrite } from './writePath.mjs'
import { Semaphore } from './semaphore.mjs'
const log: Logger = createLogger('vates:iscsi')
/** 2^32: the modulus for all iSCSI sequence-number arithmetic. */
export const SERIAL_MODULO = 0x1_0000_0000
/** Increment an iSCSI serial number with 32-bit wraparound. */
export function incrementSerial(value: number): number {
return (value + 1) % SERIAL_MODULO
}
/** Add `delta` to an iSCSI serial number with 32-bit wraparound. */
export function addSerial(value: number, delta: number): number {
return (value + delta) % SERIAL_MODULO
}
/**
* Initiator opcodes whose BHS bytes 24-27 are CmdSN. The two that are missing —
* SCSI Data-Out and SNACK — have those bytes reserved instead; see the comment
* on `Connection`'s `#updateCommandWindow` for what reading them would cost.
*/
const CARRIES_CMD_SN: ReadonlySet<number> = new Set([
InitiatorOpcode.NOP_OUT,
InitiatorOpcode.SCSI_COMMAND,
InitiatorOpcode.SCSI_TASK_MGMT_REQUEST,
InitiatorOpcode.LOGIN_REQUEST,
InitiatorOpcode.TEXT_REQUEST,
InitiatorOpcode.LOGOUT_REQUEST,
])
/** Everything a {@link Connection} needs from the owning target. */
export interface ConnectionDeps {
readonly iqn: string
readonly identity: ScsiIdentity
readonly lun: BlockDevice
/** Drain timeout for outbound PDUs, in ms (0 disables). */
readonly writeTimeoutMs: number
/** Size of the CmdSN command window advertised to the initiator. */
readonly cmdWindow: number
/** Max number of READs served concurrently (see {@link Connection}). */
readonly readConcurrency: number
/** Allocate a fresh, non-zero Target Session Identifying Handle. */
allocateTsih(): number
/** When set, require one-way CHAP: the target challenges and verifies the initiator. */
readonly chap?: ChapCredentials
/**
* Time allowed to establish a Normal session, in ms (0 or unset disables):
* past it, the connection is closed. Also bounds Discovery sessions, which
* never become Normal ones.
*/
readonly loginTimeoutMs?: number
/** Called once this connection has established a Normal session. */
onSessionEstablished?(connection: Connection): void
/** Max length of a single READ or WRITE, in bytes. */
readonly maxTransferLength?: number
}
type Phase = 'login' | 'fullFeature' | 'closed'
/**
* Drives one iSCSI connection: login, command-window/StatSN sequencing, and
* full-feature-phase PDU dispatch. With MaxConnections=1 a connection is also
* the whole session.
*
* Everything but READ runs one at a time; the read loop advances only once a
* command fully completes. READ is dispatched without waiting on its own
* `lun.read()` (can be a slow network fetch) — up to `readConcurrency` at
* once, via `#readGate` — so one slow read can't block every other command.
*
* StatSN is allocated and written to the socket synchronously, with no
* `await` in between, so responses reach the wire in whatever order the event
* loop runs their completions — no extra sequencing needed. This does not
* order a READ against a concurrent WRITE to the same LBA range: it may see
* old or new bytes, like an untagged SCSI command with no ordering barrier.
*/
export class Connection implements CommandContext {
readonly #socket: Socket
readonly #deps: ConnectionDeps
readonly #login: LoginNegotiator
readonly #readGate: Semaphore
#phase: Phase = 'login'
#sessionType: SessionType = 'Normal'
#params: NegotiatedParams = { initiatorMaxRecvDataSegmentLength: 8192, maxBurstLength: 262144 }
#statSN = 0
#expCmdSN = 0
#maxCmdSN = 0
#cmdSNInitialized = false
#tsih = 0
// In-progress WRITEs keyed by Initiator Task Tag. Data-Out PDUs are routed
// here, so writes can be interleaved with (and outstanding alongside) other
// commands without blocking the read loop.
readonly #pendingWrites = new Map<number, PendingWrite>()
#nextTargetTransferTag = 1
#loginTimer?: ReturnType<typeof setTimeout>
constructor(socket: Socket, deps: ConnectionDeps) {
this.#socket = socket
this.#deps = deps
this.#login = new LoginNegotiator(deps.chap)
this.#readGate = new Semaphore(deps.readConcurrency)
}
get lun(): BlockDevice {
return this.#deps.lun
}
/** Forcibly tear down the connection (used when the target is closing). */
destroy(): void {
this.#phase = 'closed'
this.#socket.destroy()
}
/** Read and service PDUs until the peer logs out or the connection drops. */
async serve(): Promise<void> {
const { loginTimeoutMs = 0 } = this.#deps
if (loginTimeoutMs > 0) {
this.#loginTimer = setTimeout(() => {
log.warn('no session established in time, closing the connection', { remote: this.#socket.remoteAddress })
this.destroy()
}, loginTimeoutMs).unref()
}
try {
for (;;) {
const pdu = await readPdu(
this.#socket,
this.#phase === 'login' ? LOGIN_MAX_DATA_SEGMENT_LENGTH : OUR_MAX_RECV_DATA_SEGMENT_LENGTH
)
if (pdu === null) {
break // peer closed the connection
}
const finished = await this.#dispatch(pdu)
if (finished) {
break
}
}
} catch (error) {
this.#fail(error)
} finally {
clearTimeout(this.#loginTimer)
this.#phase = 'closed'
this.#socket.destroy()
}
}
/**
* Log a connection-ending error and tear the socket down — the same outcome
* an uncaught error in the main `serve()` loop already had, now also reached
* by a concurrently-dispatched READ that fails (e.g. a backend read error):
* with nothing else awaiting it directly, it would otherwise surface only as
* an unhandled rejection while the initiator hangs waiting for a response
* that will never come. Safe to call more than once — `socket.destroy()` is
* itself idempotent.
*/
#fail(error: unknown): void {
if (this.#phase === 'closed') {
return
}
const err = error instanceof Error ? error : new Error(String(error))
const code = (err as NodeJS.ErrnoException).code
// A peer dropping the socket (e.g. on logout) is normal, not a fault.
if (code === 'ECONNRESET' || code === 'EPIPE' || code === 'ECONNABORTED') {
log.debug('connection closed by peer', { code })
} else {
log.warn('connection error', err)
}
this.#phase = 'closed'
this.#socket.destroy()
}
// --- sequencing -----------------------------------------------------------
/**
* Advance the command window from a freshly received PDU — but only from one
* that actually carries a CmdSN.
*
* BHS bytes 24-27 hold CmdSN on command PDUs only; on a SCSI Data-Out (RFC
* 7143 §11.7) and a SNACK they are reserved, as is the immediate bit. An
* initiator zeroes them, so treating such a PDU as a command reads CmdSN=0,
* takes the non-immediate branch, and rewinds the window to ExpCmdSN=1 /
* MaxCmdSN=1+window — then advertises it on the very next R2T, SCSI Response,
* or concurrent READ's Data-In. Initiators discard the rewind as stale only
* while the session's CmdSN stays below 2^31; past that it compares as newer
* under serial arithmetic, is adopted, and the window collapses into a stall.
*
* Since every WRITE is R2T-solicited (InitialR2T=Yes), Data-Out is the common
* case, not an edge one. Allowlisting the opcodes that do carry CmdSN, rather
* than skipping the two that don't, keeps a newly handled opcode from
* silently inheriting the bug.
*/
#updateCommandWindow(pdu: IncomingPdu): void {
if (!CARRIES_CMD_SN.has(pdu.opcode)) {
return
}
const cmdSN = pdu.cmdSN
if (!this.#cmdSNInitialized) {
this.#expCmdSN = cmdSN
this.#cmdSNInitialized = true
}
// Immediate PDUs (Login, NOP pings, aborts) do not consume a CmdSN slot.
if (!pdu.immediate) {
this.#expCmdSN = incrementSerial(cmdSN)
}
this.#maxCmdSN = addSerial(this.#expCmdSN, this.#deps.cmdWindow)
}
/** Current StatSN without advancing (for R2T, NOP-In, Reject, non-final Data-In). */
#currentStatSN(): number {
return this.#statSN
}
/** Return the current StatSN and advance it (for status-bearing responses). */
#nextStatSN(): number {
const value = this.#statSN
this.#statSN = incrementSerial(value)
return value
}
#send(buffer: Buffer): Promise<void> {
return writePdu(this.#socket, buffer, this.#deps.writeTimeoutMs)
}
// --- dispatch -------------------------------------------------------------
/** Returns true when the connection should be closed (after a logout). */
async #dispatch(pdu: IncomingPdu): Promise<boolean> {
this.#updateCommandWindow(pdu)
if (this.#phase === 'login') {
if (pdu.opcode === InitiatorOpcode.LOGIN_REQUEST) {
return await this.#handleLogin(pdu)
}
await this.#sendReject(pdu, RejectReason.PROTOCOL_ERROR)
return false
}
switch (pdu.opcode) {
case InitiatorOpcode.SCSI_COMMAND:
if (this.#sessionType === 'Discovery') {
await this.#sendReject(pdu, RejectReason.PROTOCOL_ERROR)
} else {
await this.#handleScsiCommand(pdu)
}
return false
case InitiatorOpcode.TEXT_REQUEST:
await this.#handleText(pdu)
return false
case InitiatorOpcode.NOP_OUT:
await this.#handleNopOut(pdu)
return false
case InitiatorOpcode.LOGOUT_REQUEST:
await this.#handleLogout(pdu)
return true
case InitiatorOpcode.SCSI_DATA_OUT:
await this.#handleDataOut(pdu)
return false
default:
await this.#sendReject(pdu, RejectReason.COMMAND_NOT_SUPPORTED)
return false
}
}
/** Returns true when the login failed and the connection must be closed. */
async #handleLogin(pdu: IncomingPdu): Promise<boolean> {
const { flagsByte, data, statusClass = LoginStatusClass.SUCCESS, statusDetail = 0 } = this.#login.process(pdu)
if (this.#login.complete && this.#tsih === 0) {
this.#tsih = this.#deps.allocateTsih()
}
const bhs = allocBhs(TargetOpcode.LOGIN_RESPONSE)
bhs[1] = flagsByte
bhs[2] = ISCSI_VERSION // Version-max
bhs[3] = ISCSI_VERSION // Version-active
pdu.bhs.copy(bhs, 8, 8, 14) // echo ISID (6 bytes)
bhs.writeUInt16BE(this.#tsih, 14)
bhs.writeUInt32BE(pdu.itt, 16)
bhs.writeUInt32BE(this.#nextStatSN(), 24)
bhs.writeUInt32BE(this.#expCmdSN, 28)
bhs.writeUInt32BE(this.#maxCmdSN, 32)
bhs[36] = statusClass
bhs[37] = statusDetail
await this.#send(assemblePdu(bhs, data))
// destroyed while the response was being written (login timeout, target
// closing): the write still succeeds, but this connection must not go on
// and replace the current session
if (this.#phase === 'closed') {
return true
}
if (statusClass !== LoginStatusClass.SUCCESS) {
// RFC 7143 §6.3: the failure response goes out first (above), then the
// target closes the connection.
log.warn('login rejected', { statusClass, statusDetail, initiator: this.#login.initiatorName })
this.#phase = 'closed'
return true
}
if (this.#login.complete) {
this.#phase = 'fullFeature'
this.#sessionType = this.#login.sessionType
this.#params = this.#login.params
log.info('session established', {
initiator: this.#login.initiatorName,
sessionType: this.#sessionType,
target: this.#login.targetName,
})
if (this.#sessionType === 'Normal') {
clearTimeout(this.#loginTimer)
this.#deps.onSessionEstablished?.(this)
}
}
return false
}
async #handleText(pdu: IncomingPdu): Promise<void> {
const keys = parseTextKeys(pdu.data)
// SendTargets discovery: advertise this target at the address the initiator
// actually connected to.
const data =
keys.get('SendTargets') !== undefined
? buildSendTargetsResponse(
this.#deps.iqn,
this.#socket.localAddress ?? '0.0.0.0',
this.#socket.localPort ?? 3260
)
: Buffer.alloc(0)
const bhs = allocBhs(TargetOpcode.TEXT_RESPONSE)
bhs[1] = TEXT_FLAG_FINAL
bhs.writeUInt32BE(pdu.itt, 16)
bhs.writeUInt32BE(RESERVED_TAG, 20) // Target Transfer Tag: none / final
bhs.writeUInt32BE(this.#nextStatSN(), 24)
bhs.writeUInt32BE(this.#expCmdSN, 28)
bhs.writeUInt32BE(this.#maxCmdSN, 32)
await this.#send(assemblePdu(bhs, data))
}
async #handleNopOut(pdu: IncomingPdu): Promise<void> {
// A NOP-Out with the reserved ITT is the initiator's reply to a NOP-In we
// sent; nothing to do. Otherwise echo it back as a NOP-In ping reply.
if (pdu.itt === RESERVED_TAG) {
return
}
const bhs = allocBhs(TargetOpcode.NOP_IN)
bhs[1] = FLAG_FINAL
pdu.bhs.copy(bhs, 8, 8, 16) // echo LUN
bhs.writeUInt32BE(pdu.itt, 16)
bhs.writeUInt32BE(RESERVED_TAG, 20) // Target Transfer Tag
bhs.writeUInt32BE(this.#currentStatSN(), 24) // NOP-In does not advance StatSN
bhs.writeUInt32BE(this.#expCmdSN, 28)
bhs.writeUInt32BE(this.#maxCmdSN, 32)
await this.#send(assemblePdu(bhs, pdu.data)) // echo ping data
}
async #handleLogout(pdu: IncomingPdu): Promise<void> {
const bhs = allocBhs(TargetOpcode.LOGOUT_RESPONSE)
bhs[1] = FLAG_FINAL
bhs[2] = LogoutResponse.SUCCESS
bhs.writeUInt32BE(pdu.itt, 16)
bhs.writeUInt32BE(this.#nextStatSN(), 24)
bhs.writeUInt32BE(this.#expCmdSN, 28)
bhs.writeUInt32BE(this.#maxCmdSN, 32)
await this.#send(assemblePdu(bhs))
}
async #handleScsiCommand(pdu: IncomingPdu): Promise<void> {
// Login declares ImmediateData=No unconditionally, so a data segment here is
// write data we never solicited and have no path to apply. Ignoring it would
// acknowledge a write missing its first bytes — exactly the silent loss the
// solicited path is built to avoid — so fail the command loudly instead and
// let the initiator retry or give up.
if (pdu.data.length > 0) {
log.warn('immediate data is not supported', { itt: pdu.itt, length: pdu.data.length })
const sense = buildFixedSense({ key: SenseKey.ILLEGAL_REQUEST, ...Asc.INVALID_FIELD_IN_CDB })
await this.sendScsiResponse(pdu.itt, ScsiStatus.CHECK_CONDITION, { sense })
return
}
const cdb = pdu.bhs.subarray(32, 48)
if (decodeCdb(cdb).kind === 'read') {
// Not awaited: the read loop must move on immediately rather than block on
// this `lun.read()`. See the class doc for why no extra ordering is needed.
void this.#readGate
.run(() => handleScsiCommand(cdb, pdu.itt, this, this.#deps.identity, this.#deps.maxTransferLength))
.catch(error => {
this.#fail(error)
})
return
}
await handleScsiCommand(cdb, pdu.itt, this, this.#deps.identity, this.#deps.maxTransferLength)
}
async #sendReject(pdu: IncomingPdu, reason: number): Promise<void> {
log.debug('rejecting PDU', { opcode: pdu.opcode, itt: pdu.itt, reason, phase: this.#phase })
const bhs = allocBhs(TargetOpcode.REJECT)
bhs[1] = FLAG_FINAL
bhs[2] = reason
bhs.writeUInt32BE(RESERVED_TAG, 16)
bhs.writeUInt32BE(this.#currentStatSN(), 24) // Reject snapshots StatSN
bhs.writeUInt32BE(this.#expCmdSN, 28)
bhs.writeUInt32BE(this.#maxCmdSN, 32)
// The rejected PDU's header is returned as the data segment.
await this.#send(assemblePdu(bhs, pdu.bhs))
}
// --- CommandContext implementation ----------------------------------------
async sendReadData(itt: number, data: Buffer, allocationLength: number): Promise<void> {
let payload = data
let residualOverflow = 0
let residualUnderflow = 0
if (data.length > allocationLength) {
payload = data.subarray(0, allocationLength)
residualOverflow = data.length - allocationLength
} else if (data.length < allocationLength) {
residualUnderflow = allocationLength - data.length
}
if (payload.length === 0) {
return this.sendScsiResponse(itt, ScsiStatus.GOOD, { residualOverflow, residualUnderflow })
}
const maxSegment = this.#params.initiatorMaxRecvDataSegmentLength
let offset = 0
let dataSN = 0
while (offset < payload.length) {
const end = Math.min(offset + maxSegment, payload.length)
const chunk = payload.subarray(offset, end)
const isLast = end >= payload.length
const bhs = allocBhs(TargetOpcode.SCSI_DATA_IN)
let flags = 0
if (isLast) {
// Piggyback GOOD status on the final Data-In PDU.
flags |= DATA_IN_FLAG_FINAL | DATA_IN_FLAG_STATUS
if (residualOverflow > 0) {
flags |= DATA_IN_FLAG_OVERFLOW
}
if (residualUnderflow > 0) {
flags |= DATA_IN_FLAG_UNDERFLOW
}
bhs[3] = ScsiStatus.GOOD // Status valid only when the S bit is set
}
bhs[1] = flags
bhs.writeUInt32BE(itt, 16)
bhs.writeUInt32BE(RESERVED_TAG, 20) // Target Transfer Tag
if (isLast) {
bhs.writeUInt32BE(this.#nextStatSN(), 24) // StatSN valid only with the S bit
}
bhs.writeUInt32BE(this.#expCmdSN, 28)
bhs.writeUInt32BE(this.#maxCmdSN, 32)
bhs.writeUInt32BE(dataSN, 36)
bhs.writeUInt32BE(offset, 40) // Buffer Offset
if (isLast) {
bhs.writeUInt32BE(residualOverflow || residualUnderflow, 44)
}
await this.#send(assemblePdu(bhs, chunk))
offset = end
dataSN++
}
}
async beginWrite(itt: number, lunOffset: number, totalLength: number): Promise<void> {
const pending: PendingWrite = {
itt,
targetTransferTag: this.#nextTargetTransferTag++,
lunOffset,
// zero-filled, not `allocUnsafe`: belt to `receiveDataOut`'s braces, so a
// buffer that somehow reaches the LUN partly filled writes zeroes rather
// than recycled heap (which the initiator could then read straight back)
buffer: Buffer.alloc(totalLength),
totalLength,
contiguousReceived: 0,
solicited: 0,
r2tSN: 0,
}
this.#pendingWrites.set(itt, pending)
log.debug('write begin', { itt, lunOffset, totalLength })
await this.#sendNextR2t(pending)
}
/** Solicit the next burst (≤ MaxBurstLength) of a pending write via an R2T. */
async #sendNextR2t(pending: PendingWrite): Promise<void> {
const desiredLength = Math.min(pending.totalLength - pending.solicited, this.#params.maxBurstLength)
const bufferOffset = pending.solicited
const r2tSN = pending.r2tSN
pending.solicited += desiredLength
pending.r2tSN++
await this.#send(
buildR2t(
{ itt: pending.itt, r2tSN, targetTransferTag: pending.targetTransferTag, bufferOffset, desiredLength },
{ statSN: this.#currentStatSN(), expCmdSN: this.#expCmdSN, maxCmdSN: this.#maxCmdSN }
)
)
}
async #handleDataOut(pdu: IncomingPdu): Promise<void> {
const pending = this.#pendingWrites.get(pdu.itt)
if (pending === undefined) {
// Data-Out for an unknown command: protocol desync.
await this.#sendReject(pdu, RejectReason.PROTOCOL_ERROR)
return
}
const bufferOffset = pdu.readU32(40) // BufferOffset is relative to the command's data
const outcome = receiveDataOut(pending, {
targetTransferTag: pdu.readU32(20),
bufferOffset,
data: pdu.data,
})
log.debug('write data-out', {
itt: pending.itt,
offset: bufferOffset,
length: pdu.data.length,
received: pending.contiguousReceived,
total: pending.totalLength,
})
if (!outcome.ok) {
// The command is dropped, not just the PDU: leaving it pending would hang
// the initiator on an R2T that never comes, until its command timeout.
this.#pendingWrites.delete(pdu.itt)
log.warn('unplaceable write data-out', { itt: pending.itt, reason: outcome.reason })
await this.#sendReject(pdu, RejectReason.PROTOCOL_ERROR)
return
}
if (outcome.complete) {
this.#pendingWrites.delete(pdu.itt)
try {
await this.lun.write(pending.lunOffset, pending.buffer)
} catch (error) {
log.warn('LUN write failed', error instanceof Error ? error : new Error(String(error)))
const sense = buildFixedSense({ key: SenseKey.MEDIUM_ERROR, ...Asc.NO_ADDITIONAL_SENSE })
await this.sendScsiResponse(pending.itt, ScsiStatus.CHECK_CONDITION, { sense })
return
}
await this.sendScsiResponse(pending.itt, ScsiStatus.GOOD)
} else if (outcome.burstComplete) {
// Current burst complete and data remains: solicit the next one.
await this.#sendNextR2t(pending)
}
}
async sendScsiResponse(itt: number, status: number, options: ScsiResponseOptions = {}): Promise<void> {
const { sense, residualOverflow = 0, residualUnderflow = 0 } = options
const bhs = allocBhs(TargetOpcode.SCSI_RESPONSE)
let flags = FLAG_FINAL // bit 7 is always set on a SCSI Response
if (residualOverflow > 0) {
flags |= SCSI_RESP_FLAG_OVERFLOW
}
if (residualUnderflow > 0) {
flags |= SCSI_RESP_FLAG_UNDERFLOW
}
bhs[1] = flags
bhs[2] = SCSI_RESPONSE_COMMAND_COMPLETED
bhs[3] = status
bhs.writeUInt32BE(itt, 16)
bhs.writeUInt32BE(this.#nextStatSN(), 24)
bhs.writeUInt32BE(this.#expCmdSN, 28)
bhs.writeUInt32BE(this.#maxCmdSN, 32)
bhs.writeUInt32BE(residualOverflow || residualUnderflow, 44)
let data: Buffer | undefined
if (sense !== undefined) {
// Sense data in a SCSI Response is prefixed with a 2-byte length.
data = Buffer.alloc(2 + sense.length)
data.writeUInt16BE(sense.length, 0)
sense.copy(data, 2)
}
await this.#send(assemblePdu(bhs, data))
}
}

View File

@@ -1,712 +0,0 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import type { Socket } from 'node:net'
import { Duplex } from 'node:stream'
import type { BlockDevice } from './backend.mjs'
import {
DATA_IN_FLAG_FINAL,
DATA_IN_FLAG_OVERFLOW,
DATA_IN_FLAG_STATUS,
DATA_IN_FLAG_UNDERFLOW,
FLAG_FINAL,
InitiatorOpcode,
LOGIN_CSG_SHIFT,
LOGIN_FLAG_TRANSIT,
LoginStage,
LoginStatusClass,
OPCODE_IMMEDIATE,
RejectReason,
RESERVED_TAG,
ScsiStatus,
SenseKey,
TargetOpcode,
} from './constants.mjs'
import { Connection, type ConnectionDeps } from './connection.mjs'
import { serializeTextKeys } from './login.mjs'
import { assemblePdu, IncomingPdu } from './pdu.mjs'
import type { ScsiIdentity } from './scsi.mjs'
const IDENTITY: ScsiIdentity = { vendor: 'VATES', product: 'ISCSI LUN', revision: '0001', serial: 'unit-serial-1' }
const BLOCK_SIZE = 512
const LUN_SIZE = 16 * BLOCK_SIZE
// Small enough that a handful of blocks splits into several PDUs / bursts, so
// the chunking and multi-burst paths are exercised without huge buffers.
const MAX_RECV_DATA_SEGMENT_LENGTH = 512
const MAX_BURST_LENGTH = 512
// --- test doubles -----------------------------------------------------------
/**
* Stands in for the TCP socket. `deliver()` queues a PDU for the connection's
* read loop (the readable side); everything the connection writes is captured
* in `sent` rather than transmitted. `Connection` only ever reads, writes, and
* destroys its socket, plus `localAddress`/`localPort` for SendTargets — hence
* the narrow cast in `asSocket()`.
*/
class FakeSocket extends Duplex {
readonly sent: Array<Buffer> = []
readonly localAddress = '10.0.0.1'
readonly localPort = 3260
override _read(): void {}
override _write(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
this.sent.push(Buffer.from(chunk))
callback()
}
/** Hand a PDU to the connection. */
deliver(pdu: Buffer): void {
this.push(pdu)
}
/** Clean end-of-stream, which ends `serve()`'s read loop. */
finish(): void {
this.push(null)
}
asSocket(): Socket {
return this as unknown as Socket
}
}
class MemoryLun implements BlockDevice {
readonly content = Buffer.alloc(LUN_SIZE)
/** Set to make the next `write()` fail, standing in for a backend error. */
failNextWrite = false
getSize(): number {
return this.content.length
}
getBlockSize(): number {
return BLOCK_SIZE
}
async read(offset: number, length: number): Promise<Buffer> {
return Buffer.from(this.content.subarray(offset, offset + length))
}
async write(offset: number, data: Buffer): Promise<void> {
if (this.failNextWrite) {
this.failNextWrite = false
throw new Error('backend write failed')
}
data.copy(this.content, offset)
}
async flush(): Promise<void> {}
async close(): Promise<void> {}
}
// --- initiator-side PDU builders --------------------------------------------
/** A single-PDU operational login that transits straight to Full Feature Phase. */
const loginPdu = (): Buffer => {
const bhs = Buffer.alloc(48)
bhs[0] = InitiatorOpcode.LOGIN_REQUEST | OPCODE_IMMEDIATE
bhs[1] = LOGIN_FLAG_TRANSIT | (LoginStage.OPERATIONAL_NEGOTIATION << LOGIN_CSG_SHIFT) | LoginStage.FULL_FEATURE_PHASE
bhs.writeUInt32BE(1, 16) // ITT
return assemblePdu(
bhs,
serializeTextKeys([
['InitiatorName', 'iqn.2026-01.tech.vates:test-initiator'],
['SessionType', 'Normal'],
['HeaderDigest', 'None'],
['DataDigest', 'None'],
['MaxRecvDataSegmentLength', String(MAX_RECV_DATA_SEGMENT_LENGTH)],
['MaxBurstLength', String(MAX_BURST_LENGTH)],
])
)
}
const scsiCommandPdu = ({
itt,
cmdSN,
cdb,
data,
}: {
itt: number
cmdSN: number
cdb: Buffer
data?: Buffer
}): Buffer => {
const bhs = Buffer.alloc(48)
bhs[0] = InitiatorOpcode.SCSI_COMMAND
bhs[1] = FLAG_FINAL
bhs.writeUInt32BE(itt, 16)
bhs.writeUInt32BE(cmdSN, 24)
cdb.copy(bhs, 32)
return assemblePdu(bhs, data)
}
const dataOutPdu = ({
itt,
targetTransferTag,
bufferOffset,
data,
dataSN = 0,
}: {
itt: number
targetTransferTag: number
bufferOffset: number
data: Buffer
dataSN?: number
}): Buffer => {
const bhs = Buffer.alloc(48)
bhs[0] = InitiatorOpcode.SCSI_DATA_OUT
bhs[1] = FLAG_FINAL
bhs.writeUInt32BE(itt, 16)
bhs.writeUInt32BE(targetTransferTag, 20)
bhs.writeUInt32BE(dataSN, 36)
bhs.writeUInt32BE(bufferOffset, 40)
return assemblePdu(bhs, data)
}
const cdb10 = (opcode: number, lba: number, blocks: number): Buffer => {
const cdb = Buffer.alloc(16)
cdb[0] = opcode
cdb.writeUInt32BE(lba, 2)
cdb.writeUInt16BE(blocks, 7)
return cdb
}
const read10 = (lba: number, blocks: number) => cdb10(0x28, lba, blocks)
const write10 = (lba: number, blocks: number) => cdb10(0x2a, lba, blocks)
const cdb16 = (opcode: number, lba: bigint, blocks: number): Buffer => {
const cdb = Buffer.alloc(16)
cdb[0] = opcode
cdb.writeBigUInt64BE(lba, 2)
cdb.writeUInt32BE(blocks, 10)
return cdb
}
const read16 = (lba: bigint, blocks: number) => cdb16(0x88, lba, blocks)
const write16 = (lba: bigint, blocks: number) => cdb16(0x8a, lba, blocks)
const inquiry = (allocationLength: number): Buffer => {
const cdb = Buffer.alloc(16)
cdb[0] = 0x12
cdb.writeUInt16BE(allocationLength, 3)
return cdb
}
// --- harness ----------------------------------------------------------------
/** Re-read a PDU the target sent, so assertions can talk in field names. */
const parseSent = (buffer: Buffer): IncomingPdu => {
const dataSegmentLength = buffer.readUIntBE(5, 3)
return new IncomingPdu(buffer.subarray(0, 48), Buffer.alloc(0), buffer.subarray(48, 48 + dataSegmentLength))
}
/**
* Sense key of a SCSI Response: its data segment is a 2-byte sense length then
* the fixed-format sense data, whose byte 2 holds the key in its low nibble.
*/
const senseKey = (response: IncomingPdu): number => response.data[4] & 0x0f
class Harness {
readonly socket = new FakeSocket()
readonly lun = new MemoryLun()
readonly serving: Promise<void>
constructor(overrides: Partial<ConnectionDeps> = {}) {
const deps: ConnectionDeps = {
iqn: 'iqn.2026-01.tech.vates:target',
identity: IDENTITY,
lun: this.lun,
writeTimeoutMs: 0,
cmdWindow: 64,
readConcurrency: 4,
allocateTsih: () => 1,
...overrides,
}
this.serving = new Connection(this.socket.asSocket(), deps).serve()
}
/** Every PDU the target has sent, parsed. */
get sent(): Array<IncomingPdu> {
return this.socket.sent.map(parseSent)
}
/**
* Let the read loop run until the target has sent `count` PDUs in total,
* optionally delivering something first. Bounded so a response that never
* comes fails the test instead of hanging it.
*/
async untilSent(count: number, deliver?: () => void): Promise<Array<IncomingPdu>> {
deliver?.()
for (let turn = 0; turn < 500 && this.socket.sent.length < count; turn++) {
await new Promise(resolve => setImmediate(resolve))
}
const sent = this.sent
assert.ok(
sent.length >= count,
`expected ${count} PDUs, got ${sent.length}: ${sent.map(pdu => pdu.opcode.toString(16)).join(',')}`
)
return sent
}
/** Log in and confirm the target accepted it. */
async login(): Promise<void> {
// relative to what has already been sent, so this works mid-connection too
const baseline = this.socket.sent.length
const response = (await this.untilSent(baseline + 1, () => this.socket.deliver(loginPdu())))[baseline]
assert.equal(response.opcode, TargetOpcode.LOGIN_RESPONSE)
assert.equal(response.readU8(36), LoginStatusClass.SUCCESS)
}
async close(): Promise<void> {
this.socket.finish()
await this.serving
}
}
/** Log in, then run `body`, always tearing the connection down afterwards. */
const withSession = async (body: (harness: Harness) => Promise<void>, overrides?: Partial<ConnectionDeps>) => {
const harness = new Harness(overrides)
try {
await harness.login()
await body(harness)
} finally {
await harness.close()
}
}
const CMD_WINDOW = 64
/** ExpCmdSN (bytes 28-31) and MaxCmdSN (32-35), the command window we advertise. */
const commandWindow = (pdu: IncomingPdu) => ({ expCmdSN: pdu.readU32(28), maxCmdSN: pdu.readU32(32) })
describe('command window', () => {
// Bytes 24-27 are CmdSN only on command PDUs. On a Data-Out (RFC 7143 §11.7)
// and a SNACK they are reserved, so reading a window out of them yields the
// zeroes an initiator puts there — advertising ExpCmdSN=1 mid-session. Real
// initiators discard that as stale only while session CmdSN < 2^31; past the
// wrap it compares as newer, is adopted, and the window collapses to a stall.
it('is not recomputed from the reserved bytes of a Data-Out', async () => {
await withSession(async harness => {
// two bursts, so an R2T is emitted *after* a Data-Out has been processed
harness.socket.deliver(scsiCommandPdu({ itt: 31, cmdSN: 50, cdb: write10(0, 2) }))
const [, firstR2t] = await harness.untilSent(2)
const expected = { expCmdSN: 51, maxCmdSN: 51 + CMD_WINDOW }
assert.deepEqual(commandWindow(firstR2t), expected)
const ttt = firstR2t.readU32(20)
harness.socket.deliver(
dataOutPdu({ itt: 31, targetTransferTag: ttt, bufferOffset: 0, data: Buffer.alloc(BLOCK_SIZE, 0x11) })
)
const [, , secondR2t] = await harness.untilSent(3)
assert.equal(secondR2t.opcode, TargetOpcode.R2T)
assert.deepEqual(commandWindow(secondR2t), expected)
harness.socket.deliver(
dataOutPdu({
itt: 31,
targetTransferTag: ttt,
bufferOffset: BLOCK_SIZE,
data: Buffer.alloc(BLOCK_SIZE, 0x22),
dataSN: 1,
})
)
const [, , , response] = await harness.untilSent(4)
assert.equal(response.opcode, TargetOpcode.SCSI_RESPONSE)
assert.deepEqual(commandWindow(response), expected)
})
})
it('is not recomputed from an opcode the target does not implement', async () => {
await withSession(async harness => {
harness.socket.deliver(scsiCommandPdu({ itt: 33, cmdSN: 70, cdb: read10(0, 1) }))
const [, dataIn] = await harness.untilSent(2)
const expected = { expCmdSN: 71, maxCmdSN: 71 + CMD_WINDOW }
assert.deepEqual(commandWindow(dataIn), expected)
// SNACK also has bytes 24-27 reserved; it is rejected, but the Reject it
// gets must still carry the window the session actually reached
const bhs = Buffer.alloc(48)
bhs[0] = InitiatorOpcode.SNACK_REQUEST
harness.socket.deliver(assemblePdu(bhs))
const [, , rejected] = await harness.untilSent(3)
assert.equal(rejected.opcode, TargetOpcode.REJECT)
assert.equal(rejected.readU8(2), RejectReason.COMMAND_NOT_SUPPORTED)
assert.deepEqual(commandWindow(rejected), expected)
})
})
it('advances the window for each command PDU, but not for an immediate one', async () => {
await withSession(async harness => {
harness.socket.deliver(scsiCommandPdu({ itt: 35, cmdSN: 5, cdb: read10(0, 1) }))
const [, dataIn] = await harness.untilSent(2)
assert.deepEqual(commandWindow(dataIn), { expCmdSN: 6, maxCmdSN: 6 + CMD_WINDOW })
// an immediate NOP-Out is answered but consumes no CmdSN slot
const bhs = Buffer.alloc(48)
bhs[0] = InitiatorOpcode.NOP_OUT | OPCODE_IMMEDIATE
bhs[1] = FLAG_FINAL
bhs.writeUInt32BE(37, 16)
bhs.writeUInt32BE(RESERVED_TAG, 20)
bhs.writeUInt32BE(6, 24)
harness.socket.deliver(assemblePdu(bhs))
const [, , nopIn] = await harness.untilSent(3)
assert.equal(nopIn.opcode, TargetOpcode.NOP_IN)
assert.deepEqual(commandWindow(nopIn), { expCmdSN: 6, maxCmdSN: 6 + CMD_WINDOW })
})
})
})
describe('login', () => {
it('reaches full feature phase and only then accepts commands', async () => {
const harness = new Harness()
try {
// a command before login is a protocol error, not a served command
harness.socket.deliver(scsiCommandPdu({ itt: 5, cmdSN: 0, cdb: read10(0, 1) }))
const [rejected] = await harness.untilSent(1)
assert.equal(rejected.opcode, TargetOpcode.REJECT)
assert.equal(rejected.readU8(2), RejectReason.PROTOCOL_ERROR)
await harness.login()
// the same command now gets served
harness.socket.deliver(scsiCommandPdu({ itt: 5, cmdSN: 1, cdb: read10(0, 1) }))
const sent = await harness.untilSent(3)
assert.equal(sent[2].opcode, TargetOpcode.SCSI_DATA_IN)
} finally {
await harness.close()
}
})
})
describe('write path wiring', () => {
it('solicits the data, commits it to the LUN, and answers GOOD', async () => {
await withSession(async harness => {
harness.socket.deliver(scsiCommandPdu({ itt: 9, cmdSN: 1, cdb: write10(2, 1) }))
const [, r2t] = await harness.untilSent(2)
assert.equal(r2t.opcode, TargetOpcode.R2T)
assert.equal(r2t.itt, 9)
assert.equal(r2t.readU32(36), 0) // R2TSN
assert.equal(r2t.readU32(40), 0) // BufferOffset
assert.equal(r2t.readU32(44), BLOCK_SIZE) // DesiredDataTransferLength
const payload = Buffer.alloc(BLOCK_SIZE, 0xab)
harness.socket.deliver(dataOutPdu({ itt: 9, targetTransferTag: r2t.readU32(20), bufferOffset: 0, data: payload }))
const [, , response] = await harness.untilSent(3)
assert.equal(response.opcode, TargetOpcode.SCSI_RESPONSE)
assert.equal(response.readU8(3), ScsiStatus.GOOD)
// ... and the bytes landed at the LBA the CDB asked for, nowhere else
assert.deepEqual(harness.lun.content.subarray(2 * BLOCK_SIZE, 3 * BLOCK_SIZE), payload)
assert.deepEqual(harness.lun.content.subarray(0, 2 * BLOCK_SIZE), Buffer.alloc(2 * BLOCK_SIZE))
assert.deepEqual(harness.lun.content.subarray(3 * BLOCK_SIZE), Buffer.alloc(LUN_SIZE - 3 * BLOCK_SIZE))
})
})
it('solicits one burst at a time for a write larger than MaxBurstLength', async () => {
await withSession(async harness => {
// 2 blocks with a 1-block burst limit: two R2Ts, second only after the first lands
harness.socket.deliver(scsiCommandPdu({ itt: 11, cmdSN: 1, cdb: write10(0, 2) }))
const [, first] = await harness.untilSent(2)
assert.equal(first.readU32(36), 0) // R2TSN
assert.equal(first.readU32(40), 0) // BufferOffset
assert.equal(first.readU32(44), MAX_BURST_LENGTH)
const ttt = first.readU32(20)
harness.socket.deliver(
dataOutPdu({ itt: 11, targetTransferTag: ttt, bufferOffset: 0, data: Buffer.alloc(BLOCK_SIZE, 0x11) })
)
const [, , second] = await harness.untilSent(3)
assert.equal(second.opcode, TargetOpcode.R2T)
assert.equal(second.readU32(36), 1) // R2TSN advanced
assert.equal(second.readU32(40), BLOCK_SIZE) // next burst picks up where the first ended
assert.equal(second.readU32(44), MAX_BURST_LENGTH)
// nothing is committed until the whole command has landed
assert.deepEqual(harness.lun.content.subarray(0, BLOCK_SIZE), Buffer.alloc(BLOCK_SIZE))
harness.socket.deliver(
dataOutPdu({
itt: 11,
targetTransferTag: ttt,
bufferOffset: BLOCK_SIZE,
data: Buffer.alloc(BLOCK_SIZE, 0x22),
dataSN: 1,
})
)
const [, , , response] = await harness.untilSent(4)
assert.equal(response.opcode, TargetOpcode.SCSI_RESPONSE)
assert.equal(response.readU8(3), ScsiStatus.GOOD)
assert.deepEqual(
harness.lun.content.subarray(0, 2 * BLOCK_SIZE),
Buffer.concat([Buffer.alloc(BLOCK_SIZE, 0x11), Buffer.alloc(BLOCK_SIZE, 0x22)])
)
})
})
it('rejects an unplaceable Data-Out and drops the command with it', async () => {
await withSession(async harness => {
harness.socket.deliver(scsiCommandPdu({ itt: 13, cmdSN: 1, cdb: write10(0, 1) }))
const [, r2t] = await harness.untilSent(2)
const ttt = r2t.readU32(20)
// BufferOffset past the end of the command's data: a copy there would land
// nothing, so the write must be refused rather than acknowledged
harness.socket.deliver(
dataOutPdu({
itt: 13,
targetTransferTag: ttt,
bufferOffset: BLOCK_SIZE,
data: Buffer.alloc(BLOCK_SIZE, 0xcc),
})
)
const [, , rejected] = await harness.untilSent(3)
assert.equal(rejected.opcode, TargetOpcode.REJECT)
assert.equal(rejected.readU8(2), RejectReason.PROTOCOL_ERROR)
assert.deepEqual(harness.lun.content, Buffer.alloc(LUN_SIZE))
// the command is gone, not merely stalled: a now-valid Data-Out for it is
// an unknown ITT, and no SCSI Response ever completes it
harness.socket.deliver(
dataOutPdu({ itt: 13, targetTransferTag: ttt, bufferOffset: 0, data: Buffer.alloc(BLOCK_SIZE, 0xdd) })
)
const sent = await harness.untilSent(4)
assert.equal(sent[3].opcode, TargetOpcode.REJECT)
assert.deepEqual(harness.lun.content, Buffer.alloc(LUN_SIZE))
assert.equal(
sent.some(pdu => pdu.opcode === TargetOpcode.SCSI_RESPONSE),
false
)
})
})
it("rejects a Data-Out carrying another command's Target Transfer Tag", async () => {
await withSession(async harness => {
harness.socket.deliver(scsiCommandPdu({ itt: 15, cmdSN: 1, cdb: write10(0, 1) }))
const [, r2t] = await harness.untilSent(2)
harness.socket.deliver(
dataOutPdu({
itt: 15,
targetTransferTag: r2t.readU32(20) + 1,
bufferOffset: 0,
data: Buffer.alloc(BLOCK_SIZE, 0xee),
})
)
const [, , rejected] = await harness.untilSent(3)
assert.equal(rejected.opcode, TargetOpcode.REJECT)
assert.deepEqual(harness.lun.content, Buffer.alloc(LUN_SIZE))
})
})
it('rejects a Data-Out for a command it knows nothing about', async () => {
await withSession(async harness => {
harness.socket.deliver(
dataOutPdu({ itt: 404, targetTransferTag: 1, bufferOffset: 0, data: Buffer.alloc(BLOCK_SIZE, 0xff) })
)
const [, rejected] = await harness.untilSent(2)
assert.equal(rejected.opcode, TargetOpcode.REJECT)
assert.equal(rejected.readU8(2), RejectReason.PROTOCOL_ERROR)
})
})
it('reports a failed backend write as CHECK CONDITION, not a dropped connection', async () => {
await withSession(async harness => {
harness.lun.failNextWrite = true
harness.socket.deliver(scsiCommandPdu({ itt: 17, cmdSN: 1, cdb: write10(0, 1) }))
const [, r2t] = await harness.untilSent(2)
harness.socket.deliver(
dataOutPdu({
itt: 17,
targetTransferTag: r2t.readU32(20),
bufferOffset: 0,
data: Buffer.alloc(BLOCK_SIZE, 0xab),
})
)
const [, , response] = await harness.untilSent(3)
assert.equal(response.opcode, TargetOpcode.SCSI_RESPONSE)
assert.equal(response.readU8(3), ScsiStatus.CHECK_CONDITION)
assert.equal(senseKey(response), SenseKey.MEDIUM_ERROR)
})
})
it('fails a command carrying immediate data instead of silently dropping it', async () => {
await withSession(async harness => {
// ImmediateData=No is declared at login, so this cannot be honoured — and
// must not be ignored either, since the write would be short by these bytes
harness.socket.deliver(
scsiCommandPdu({ itt: 19, cmdSN: 1, cdb: write10(0, 1), data: Buffer.alloc(BLOCK_SIZE, 0x99) })
)
const [, response] = await harness.untilSent(2)
assert.equal(response.opcode, TargetOpcode.SCSI_RESPONSE)
assert.equal(response.readU8(3), ScsiStatus.CHECK_CONDITION)
assert.equal(senseKey(response), SenseKey.ILLEGAL_REQUEST)
assert.deepEqual(harness.lun.content, Buffer.alloc(LUN_SIZE))
// no R2T either: the command never started
assert.equal(
harness.sent.some(pdu => pdu.opcode === TargetOpcode.R2T),
false
)
})
})
})
describe('Data-In chunking', () => {
/** Read `blocks` blocks of a LUN pre-filled with a recognizable pattern. */
const readBack = async (harness: Harness, blocks: number): Promise<Array<IncomingPdu>> => {
for (let i = 0; i < harness.lun.content.length; i++) {
harness.lun.content[i] = (i * 7 + 3) & 0xff
}
harness.socket.deliver(scsiCommandPdu({ itt: 21, cmdSN: 1, cdb: read10(0, blocks) }))
const expected = Math.ceil((blocks * BLOCK_SIZE) / MAX_RECV_DATA_SEGMENT_LENGTH)
const sent = await harness.untilSent(1 + expected)
return sent.slice(1)
}
it('splits the payload at the initiator MaxRecvDataSegmentLength', async () => {
await withSession(async harness => {
const dataIn = await readBack(harness, 4)
assert.equal(dataIn.length, 4)
for (const pdu of dataIn) {
assert.equal(pdu.opcode, TargetOpcode.SCSI_DATA_IN)
assert.equal(pdu.data.length, MAX_RECV_DATA_SEGMENT_LENGTH)
}
// reassembled, it is exactly what the LUN holds
assert.deepEqual(Buffer.concat(dataIn.map(pdu => pdu.data)), harness.lun.content.subarray(0, 4 * BLOCK_SIZE))
})
})
it('numbers the PDUs with a gapless DataSN and stamps each BufferOffset', async () => {
await withSession(async harness => {
const dataIn = await readBack(harness, 3)
assert.deepEqual(
dataIn.map(pdu => pdu.readU32(36)),
[0, 1, 2]
)
assert.deepEqual(
dataIn.map(pdu => pdu.readU32(40)),
[0, MAX_RECV_DATA_SEGMENT_LENGTH, 2 * MAX_RECV_DATA_SEGMENT_LENGTH]
)
// Target Transfer Tag is unused on Data-In
for (const pdu of dataIn) {
assert.equal(pdu.readU32(20), RESERVED_TAG)
}
})
})
it('carries the status only on the final PDU, and advances StatSN once', async () => {
await withSession(async harness => {
const statSNAfterLogin = parseSent(harness.socket.sent[0]).readU32(24) + 1
const dataIn = await readBack(harness, 3)
for (const pdu of dataIn.slice(0, -1)) {
assert.equal(pdu.flags & DATA_IN_FLAG_FINAL, 0)
assert.equal(pdu.flags & DATA_IN_FLAG_STATUS, 0)
// both fields are defined as meaningless without the S bit, and are left
// unset rather than filled with a value an initiator might act on
assert.equal(pdu.readU8(3), 0)
assert.equal(pdu.readU32(24), 0)
}
const last = dataIn[dataIn.length - 1]
assert.equal(last.flags & DATA_IN_FLAG_FINAL, DATA_IN_FLAG_FINAL)
assert.equal(last.flags & DATA_IN_FLAG_STATUS, DATA_IN_FLAG_STATUS)
assert.equal(last.readU8(3), ScsiStatus.GOOD)
// exactly one StatSN consumed for the whole command, on its final PDU
assert.equal(last.readU32(24), statSNAfterLogin)
})
})
it('sends a single Data-In when the payload fits one segment', async () => {
await withSession(async harness => {
const dataIn = await readBack(harness, 1)
assert.equal(dataIn.length, 1)
assert.equal(
dataIn[0].flags & (DATA_IN_FLAG_FINAL | DATA_IN_FLAG_STATUS),
DATA_IN_FLAG_FINAL | DATA_IN_FLAG_STATUS
)
assert.equal(dataIn[0].readU32(36), 0)
})
})
it('flags residual overflow when the allocation length truncates the payload', async () => {
await withSession(async harness => {
// standard INQUIRY data is 36 bytes; ask for 8
harness.socket.deliver(scsiCommandPdu({ itt: 23, cmdSN: 1, cdb: inquiry(8) }))
const [, dataIn] = await harness.untilSent(2)
assert.equal(dataIn.opcode, TargetOpcode.SCSI_DATA_IN)
assert.equal(dataIn.data.length, 8)
assert.equal(dataIn.flags & DATA_IN_FLAG_OVERFLOW, DATA_IN_FLAG_OVERFLOW)
assert.equal(dataIn.flags & DATA_IN_FLAG_UNDERFLOW, 0)
assert.equal(dataIn.readU32(44), 36 - 8) // Residual Count
})
})
it('flags residual underflow when the payload is shorter than requested', async () => {
await withSession(async harness => {
harness.socket.deliver(scsiCommandPdu({ itt: 25, cmdSN: 1, cdb: inquiry(100) }))
const [, dataIn] = await harness.untilSent(2)
assert.equal(dataIn.data.length, 36)
assert.equal(dataIn.flags & DATA_IN_FLAG_UNDERFLOW, DATA_IN_FLAG_UNDERFLOW)
assert.equal(dataIn.flags & DATA_IN_FLAG_OVERFLOW, 0)
assert.equal(dataIn.readU32(44), 100 - 36)
})
})
it('answers with status alone, no Data-In, when nothing is allocated for the data', async () => {
await withSession(async harness => {
harness.socket.deliver(scsiCommandPdu({ itt: 27, cmdSN: 1, cdb: inquiry(0) }))
const [, response] = await harness.untilSent(2)
assert.equal(response.opcode, TargetOpcode.SCSI_RESPONSE)
assert.equal(response.readU8(3), ScsiStatus.GOOD)
assert.equal(
harness.sent.some(pdu => pdu.opcode === TargetOpcode.SCSI_DATA_IN),
false
)
})
})
})
describe('unservable CDBs', () => {
// An LBA above 2^53 cannot be held exactly by a JS number. Decoding it must
// not throw: the error would unwind out of the dispatch loop and destroy the
// socket, turning one bogus CDB — a buggy or hostile initiator's — into the
// loss of the whole session and every command in flight on it.
for (const [name, cdb] of [
['READ(16)', read16(0xffffffffffffffffn, 1)],
['WRITE(16)', write16(0xffffffffffffffffn, 1)],
] as const) {
it(`answers a ${name} past the addressable range with CHECK CONDITION, keeping the session`, async () => {
await withSession(async harness => {
harness.socket.deliver(scsiCommandPdu({ itt: 31, cmdSN: 1, cdb }))
const [, response] = await harness.untilSent(2)
assert.equal(response.opcode, TargetOpcode.SCSI_RESPONSE)
assert.equal(response.readU8(3), ScsiStatus.CHECK_CONDITION)
assert.equal(senseKey(response), SenseKey.ILLEGAL_REQUEST)
assert.equal(
harness.sent.some(pdu => pdu.opcode === TargetOpcode.R2T),
false
)
// the connection is still serving: a following command is answered
harness.socket.deliver(scsiCommandPdu({ itt: 32, cmdSN: 2, cdb: inquiry(36) }))
const [, , dataIn] = await harness.untilSent(3)
assert.equal(dataIn.opcode, TargetOpcode.SCSI_DATA_IN)
})
})
}
})

View File

@@ -1,200 +0,0 @@
// iSCSI (RFC 7143 / RFC 3720) and SCSI (SPC-3 / SBC-3) protocol constants.
//
// Only the subset required by a minimal single-LUN target is defined here.
// --- iSCSI PDU layout -------------------------------------------------------
/** Length of the Basic Header Segment, in bytes. Fixed by the protocol. */
export const BHS_LENGTH = 48
/** Bit set in byte 0 for initiator PDUs that must be processed immediately. */
export const OPCODE_IMMEDIATE = 0x40
/** Mask extracting the 6-bit opcode from byte 0. */
export const OPCODE_MASK = 0x3f
/** Final bit, byte 1, set on the last PDU of a sequence. */
export const FLAG_FINAL = 0x80
/** Sentinel for "no tag" in Initiator/Target Task Tag fields. */
export const RESERVED_TAG = 0xffffffff
// --- iSCSI opcodes ----------------------------------------------------------
/** Opcodes sent by the initiator (request PDUs). */
export const InitiatorOpcode = {
NOP_OUT: 0x00,
SCSI_COMMAND: 0x01,
SCSI_TASK_MGMT_REQUEST: 0x02,
LOGIN_REQUEST: 0x03,
TEXT_REQUEST: 0x04,
SCSI_DATA_OUT: 0x05,
LOGOUT_REQUEST: 0x06,
SNACK_REQUEST: 0x10,
} as const
/** Opcodes sent by the target (response PDUs). */
export const TargetOpcode = {
NOP_IN: 0x20,
SCSI_RESPONSE: 0x21,
SCSI_TASK_MGMT_RESPONSE: 0x22,
LOGIN_RESPONSE: 0x23,
TEXT_RESPONSE: 0x24,
SCSI_DATA_IN: 0x25,
LOGOUT_RESPONSE: 0x26,
R2T: 0x31,
ASYNC_MESSAGE: 0x32,
REJECT: 0x3f,
} as const
// --- Login negotiation ------------------------------------------------------
/** Login/text stage codes used in the CSG/NSG fields. */
export const LoginStage = {
SECURITY_NEGOTIATION: 0,
OPERATIONAL_NEGOTIATION: 1,
FULL_FEATURE_PHASE: 3,
} as const
/** Login Request/Response byte 1 flags. */
export const LOGIN_FLAG_TRANSIT = 0x80
export const LOGIN_FLAG_CONTINUE = 0x40
/** Mask + shift for the Current Stage (CSG) field in byte 1. */
export const LOGIN_CSG_MASK = 0x0c
export const LOGIN_CSG_SHIFT = 2
/** Mask for the Next Stage (NSG) field in byte 1. */
export const LOGIN_NSG_MASK = 0x03
/** iSCSI version (only version 0 exists). */
export const ISCSI_VERSION = 0x00
/** Login Response Status-Class values. */
export const LoginStatusClass = {
SUCCESS: 0x00,
REDIRECTION: 0x01,
INITIATOR_ERROR: 0x02,
TARGET_ERROR: 0x03,
} as const
/**
* Login Response Status-Detail values (byte 37), qualifying the Status-Class.
* `AUTHENTICATION_FAILURE` pairs with `INITIATOR_ERROR` to reject a bad CHAP
* login (RFC 7143 §11.1.5).
*/
export const LoginStatusDetail = {
SUCCESS: 0x00,
AUTHENTICATION_FAILURE: 0x01,
} as const
/** `AuthMethod` login-key values we understand (RFC 7143 §12.1). */
export const AuthMethod = {
NONE: 'None',
CHAP: 'CHAP',
} as const
// --- Text/Data flags --------------------------------------------------------
export const TEXT_FLAG_FINAL = 0x80
export const TEXT_FLAG_CONTINUE = 0x40
/** SCSI Data-In byte 1 flags. */
export const DATA_IN_FLAG_FINAL = 0x80
export const DATA_IN_FLAG_ACK = 0x40
export const DATA_IN_FLAG_OVERFLOW = 0x04
export const DATA_IN_FLAG_UNDERFLOW = 0x02
export const DATA_IN_FLAG_STATUS = 0x01
/** SCSI Command (initiator) byte 1 flags. */
export const SCSI_CMD_FLAG_READ = 0x40
export const SCSI_CMD_FLAG_WRITE = 0x20
/** SCSI Response byte 1 residual flags. */
export const SCSI_RESP_FLAG_OVERFLOW = 0x04
export const SCSI_RESP_FLAG_UNDERFLOW = 0x02
/** SCSI Response "Response" field (byte 2). */
export const SCSI_RESPONSE_COMMAND_COMPLETED = 0x00
/** Logout reason codes (byte 1, low 7 bits). */
export const LogoutReason = {
CLOSE_SESSION: 0,
CLOSE_CONNECTION: 1,
REMOVE_CONNECTION_FOR_RECOVERY: 2,
} as const
/** Logout Response codes (byte 2). */
export const LogoutResponse = {
SUCCESS: 0,
} as const
// --- SCSI status codes ------------------------------------------------------
export const ScsiStatus = {
GOOD: 0x00,
CHECK_CONDITION: 0x02,
} as const
// --- SCSI CDB opcodes -------------------------------------------------------
export const ScsiOpcode = {
TEST_UNIT_READY: 0x00,
REQUEST_SENSE: 0x03,
INQUIRY: 0x12,
MODE_SENSE_6: 0x1a,
READ_CAPACITY_10: 0x25,
READ_10: 0x28,
WRITE_10: 0x2a,
SYNCHRONIZE_CACHE_10: 0x35,
MODE_SENSE_10: 0x5a,
READ_16: 0x88,
WRITE_16: 0x8a,
SYNCHRONIZE_CACHE_16: 0x91,
SERVICE_ACTION_IN_16: 0x9e, // READ CAPACITY(16) lives under this opcode
REPORT_LUNS: 0xa0,
} as const
/** Service action (CDB byte 1, low 5 bits) for SERVICE_ACTION_IN_16. */
export const SERVICE_ACTION_READ_CAPACITY_16 = 0x10
// --- SCSI sense keys / additional sense codes -------------------------------
export const SenseKey = {
NO_SENSE: 0x00,
RECOVERED_ERROR: 0x01,
NOT_READY: 0x02,
MEDIUM_ERROR: 0x03,
HARDWARE_ERROR: 0x04,
ILLEGAL_REQUEST: 0x05,
UNIT_ATTENTION: 0x06,
DATA_PROTECT: 0x07,
} as const
/** Additional Sense Code / Qualifier pairs we may emit. */
export const Asc = {
NO_ADDITIONAL_SENSE: { asc: 0x00, ascq: 0x00 },
INVALID_COMMAND_OPERATION_CODE: { asc: 0x20, ascq: 0x00 },
INVALID_FIELD_IN_CDB: { asc: 0x24, ascq: 0x00 },
LOGICAL_BLOCK_ADDRESS_OUT_OF_RANGE: { asc: 0x21, ascq: 0x00 },
WRITE_PROTECTED: { asc: 0x27, ascq: 0x00 },
} as const
/** Reject reason codes (Reject PDU byte 2). */
export const RejectReason = {
PROTOCOL_ERROR: 0x04,
COMMAND_NOT_SUPPORTED: 0x05,
} as const
// --- debugging --------------------------------------------------------------
// Initiator opcodes (0x00-0x10) and target opcodes (0x20-0x3f) never collide,
// so one reverse map names both directions for logs.
const OPCODE_NAMES: Record<number, string> = {
...Object.fromEntries(Object.entries(InitiatorOpcode).map(([name, code]) => [code, name])),
...Object.fromEntries(Object.entries(TargetOpcode).map(([name, code]) => [code, name])),
}
/** Human-readable name of a PDU opcode (for debug logs). */
export function opcodeName(opcode: number): string {
const code = opcode & OPCODE_MASK
return OPCODE_NAMES[code] ?? `0x${code.toString(16).padStart(2, '0')}`
}

View File

@@ -1,30 +0,0 @@
// Ambient declarations for untyped external packages used by this module.
// (CLAUDE.md: "Add declaration.d.ts for untyped external packages.")
declare module '@vates/read-chunk' {
import type { Readable } from 'node:stream'
/**
* Resolves to exactly `size` bytes read from `stream`, or `null` if the
* stream ends before any byte of the requested chunk is available.
*/
export function readChunk(stream: Readable, size?: number): Promise<Buffer | null>
/**
* Resolves to exactly `size` bytes read from `stream`; rejects if the stream
* ends before `size` bytes have been read.
*/
export function readChunkStrict(stream: Readable, size: number): Promise<Buffer>
}
declare module 'promise-toolbox' {
/**
* Reject the promise it is bound to if it does not settle within `delay`
* milliseconds. Used as `pTimeout.call(promise, delay)`.
*/
interface PTimeout {
<T>(this: Promise<T>, delay: number, onReject?: () => void): Promise<T>
call<T>(promise: Promise<T>, delay: number, onReject?: () => void): Promise<T>
}
export const pTimeout: PTimeout
}

View File

@@ -1,281 +0,0 @@
import { createServer, type AddressInfo, type Server, type Socket } from 'node:net'
import { createLogger, type Logger } from '@xen-orchestra/log'
import type { BlockDevice } from './backend.mjs'
import { Connection, type ConnectionDeps } from './connection.mjs'
import { DEFAULT_MAX_TRANSFER_LENGTH, type ScsiIdentity } from './scsi.mjs'
import type { ChapCredentials } from './types.mjs'
export { FileBlockDevice } from './backend.mjs'
export type { BlockDevice, FileBlockDeviceOptions } from './backend.mjs'
export type { ScsiIdentity } from './scsi.mjs'
export type { ChapCredentials } from './types.mjs'
export { IscsiInitiator } from './initiator.mjs'
export type { IscsiInitiatorOptions } from './initiator.mjs'
export { IscsiDisk } from './IscsiDisk.mjs'
export type { IscsiDiskOptions } from './IscsiDisk.mjs'
export { DiskBlockDevice } from './DiskBlockDevice.mjs'
export type { DiskBlockDeviceOptions } from './DiskBlockDevice.mjs'
export { CachedDiskBlockDevice } from './CachedDiskBlockDevice.mjs'
export type { CachedDiskBlockDeviceOptions } from './CachedDiskBlockDevice.mjs'
const log: Logger = createLogger('vates:iscsi')
const DEFAULT_PORT = 3260
const DEFAULT_WRITE_TIMEOUT_MS = 30_000
const DEFAULT_CMD_WINDOW = 64
const DEFAULT_READ_CONCURRENCY = 16
const DEFAULT_LOGIN_TIMEOUT_MS = 15_000
const DEFAULT_MAX_PENDING_CONNECTIONS = 8
const DEFAULT_IDENTITY: ScsiIdentity = {
vendor: 'VATES',
product: 'ISCSI LUN',
revision: '0001',
serial: '',
}
export interface IscsiTargetOptions {
/** The target's iSCSI Qualified Name, e.g. `iqn.2024-01.tech.vates:lun0`. */
readonly iqn: string
/** The single LUN exposed by this target. */
readonly lun: BlockDevice
/** Listen address. Defaults to all interfaces. */
readonly host?: string
/** Listen port. Defaults to 3260. */
readonly port?: number
/** Overrides for the SCSI INQUIRY identity strings. */
readonly identity?: Partial<ScsiIdentity>
/** Drain timeout for outbound PDUs in ms (0 disables). Defaults to 30000. */
readonly writeTimeoutMs?: number
/** CmdSN command-window depth advertised to the initiator. Defaults to 64. */
readonly cmdWindow?: number
/**
* Max number of READ commands served concurrently — the command window lets
* the initiator keep many outstanding, but this caps how many `lun.read()`
* calls actually run at once regardless (e.g. so a deep queue depth doesn't
* fire dozens of simultaneous fetches against a real backend). Defaults to 16.
*/
readonly readConcurrency?: number
/**
* When set, require one-way CHAP: the target challenges each initiator and
* rejects the login unless it proves this credential (interop with the
* open-iscsi `node.session.auth` username/password). Omit for no authentication.
*/
readonly chap?: ChapCredentials
/**
* Time a new connection has to establish a Normal session, in ms (0
* disables). Also bounds Discovery sessions. Defaults to 15000.
*/
readonly loginTimeoutMs?: number
/**
* Max number of connections that have not established a Normal session yet;
* past it, new ones are refused. Defaults to 8.
*/
readonly maxPendingConnections?: number
/**
* Source addresses the initiators may connect from. When set, connections
* from any other address are closed as soon as they are accepted, before any
* login: only these initiators can take a pending connection slot.
* IPv4-mapped IPv6 addresses (`::ffff:a.b.c.d`) match their IPv4 form.
*/
readonly allowedInitiatorAddresses?: Iterable<string>
/**
* Called with the source address of each connection closed because it is
* not in `allowedInitiatorAddresses`, e.g. to tell why an initiator behind a
* NAT cannot connect.
*/
readonly onConnectionRefused?: (address: string) => void
/**
* Max length of a single READ or WRITE, in bytes, advertised in the block
* limits VPD page; longer commands are refused. At least one logical block.
* Defaults to 8 MiB.
*/
readonly maxTransferLength?: number
}
const IPV4_MAPPED_PREFIX = '::ffff:'
function normalizeAddress(address: string): string {
return address.startsWith(IPV4_MAPPED_PREFIX) && address.includes('.')
? address.slice(IPV4_MAPPED_PREFIX.length)
: address
}
/**
* A minimal iSCSI target exposing exactly one read/write LUN.
*
* Single initiator, single connection (`MaxConnections=1`),
* `ErrorRecoveryLevel=0`, no digests.
*
* A new connection replaces the current one rather than being refused (see
* `#onSessionEstablished`): there is no way to tell a stale, abandoned
* connection (initiator gave up without closing the socket) from a healthy
* one, so refusing would let one stuck connection wedge the target forever.
* The replacement only happens once the new connection has established a
* Normal session, CHAP included when configured, so that a port scan, a failed
* login or a Discovery session cannot interrupt the current one. Until then,
* connections are bounded in time (`loginTimeoutMs`) and in number
* (`maxPendingConnections`); with `allowedInitiatorAddresses`, only the expected
* initiators can take those slots. Not RFC 7143 session reinstatement (no ISID
* matching) — safe here since the target is ephemeral, CHAP-guarded, and
* single-consumer.
*/
export class IscsiTarget {
readonly #iqn: string
readonly #lun: BlockDevice
readonly #host?: string
readonly #port: number
readonly #identity: ScsiIdentity
readonly #writeTimeoutMs: number
readonly #cmdWindow: number
readonly #readConcurrency: number
readonly #chap?: ChapCredentials
readonly #loginTimeoutMs: number
readonly #maxPendingConnections: number
readonly #allowedInitiatorAddresses?: ReadonlySet<string>
readonly #onConnectionRefused?: (address: string) => void
readonly #maxTransferLength: number
#server?: Server
// the connection with an established Normal session, serving the LUN
#connection?: Connection
// every open connection, so that close() can drop them all
readonly #connections = new Set<Connection>()
#tsih = 0
constructor(options: IscsiTargetOptions) {
this.#iqn = options.iqn
this.#lun = options.lun
this.#host = options.host
this.#port = options.port ?? DEFAULT_PORT
this.#writeTimeoutMs = options.writeTimeoutMs ?? DEFAULT_WRITE_TIMEOUT_MS
this.#cmdWindow = options.cmdWindow ?? DEFAULT_CMD_WINDOW
this.#readConcurrency = options.readConcurrency ?? DEFAULT_READ_CONCURRENCY
this.#chap = options.chap
this.#loginTimeoutMs = options.loginTimeoutMs ?? DEFAULT_LOGIN_TIMEOUT_MS
this.#maxPendingConnections = options.maxPendingConnections ?? DEFAULT_MAX_PENDING_CONNECTIONS
if (options.allowedInitiatorAddresses !== undefined) {
this.#allowedInitiatorAddresses = new Set(Array.from(options.allowedInitiatorAddresses, normalizeAddress))
}
this.#onConnectionRefused = options.onConnectionRefused
this.#maxTransferLength = options.maxTransferLength ?? DEFAULT_MAX_TRANSFER_LENGTH
if (this.#maxTransferLength < options.lun.getBlockSize()) {
throw new RangeError('maxTransferLength must be at least one logical block')
}
this.#identity = {
...DEFAULT_IDENTITY,
// Default the serial to the IQN so the LUN has a stable, unique identity.
serial: options.iqn,
...options.identity,
}
}
#allocateTsih(): number {
this.#tsih = (this.#tsih % 0xffff) + 1 // non-zero, wraps within 16 bits
return this.#tsih
}
#deps(): ConnectionDeps {
return {
iqn: this.#iqn,
identity: this.#identity,
lun: this.#lun,
writeTimeoutMs: this.#writeTimeoutMs,
cmdWindow: this.#cmdWindow,
readConcurrency: this.#readConcurrency,
allocateTsih: () => this.#allocateTsih(),
chap: this.#chap,
loginTimeoutMs: this.#loginTimeoutMs,
onSessionEstablished: connection => this.#onSessionEstablished(connection),
maxTransferLength: this.#maxTransferLength,
}
}
#onConnection(socket: Socket): void {
const allowed = this.#allowedInitiatorAddresses
const remote = normalizeAddress(socket.remoteAddress ?? '')
if (allowed !== undefined && !allowed.has(remote)) {
log.warn('connection from an address that is not allowed, closing it', { remote })
socket.destroy()
this.#onConnectionRefused?.(remote)
return
}
socket.setNoDelay(true)
const connection = new Connection(socket, this.#deps())
this.#connections.add(connection)
// Long-lived per-connection task; serve() handles its own errors and never
// rejects, so detaching it here is safe.
void connection.serve().finally(() => {
this.#connections.delete(connection)
if (this.#connection === connection) {
this.#connection = undefined
}
})
}
#onSessionEstablished(connection: Connection): void {
const existing = this.#connection
this.#connection = connection
if (existing !== undefined) {
// Replaces a possibly-stale connection rather than refusing; see the class doc.
log.warn('replacing existing connection with a newly logged in one')
existing.destroy()
}
}
/** Open the LUN (if needed) and start accepting connections. */
async listen(): Promise<void> {
if (this.#server !== undefined) {
throw new Error('iSCSI target is already listening')
}
await this.#lun.open?.()
const server = createServer(socket => this.#onConnection(socket))
// + 1: the connection serving the LUN does not take a pending slot
server.maxConnections = this.#maxPendingConnections + 1
server.on('drop', data => {
log.warn('too many connections without a session, refusing a new one', { remote: data?.remoteAddress })
})
this.#server = server
try {
await new Promise<void>((resolve, reject) => {
const onError = (error: Error) => reject(error)
server.once('error', onError)
server.listen(this.#port, this.#host, () => {
server.removeListener('error', onError)
resolve()
})
})
} catch (error) {
this.#server = undefined
throw error
}
log.info('target listening', { iqn: this.#iqn, address: this.address() })
}
/** The bound address, or undefined if not listening on an IP socket. */
address(): AddressInfo | undefined {
const address = this.#server?.address()
return address !== null && typeof address === 'object' ? address : undefined
}
/** Stop accepting connections, drop every connection, and close the LUN. */
async close(): Promise<void> {
const server = this.#server
const connections = [...this.#connections]
this.#server = undefined
this.#connection = undefined
this.#connections.clear()
if (server !== undefined) {
await new Promise<void>((resolve, reject) => {
server.close(error => (error ? reject(error) : resolve()))
// Drop the live connections so server.close() can complete.
for (const connection of connections) {
connection.destroy()
}
})
}
await this.#lun.close()
}
}

View File

@@ -1,261 +0,0 @@
import assert from 'node:assert/strict'
import { createServer, type Server, type Socket } from 'node:net'
import { once } from 'node:events'
import { describe, it } from 'node:test'
import {
DATA_IN_FLAG_FINAL,
DATA_IN_FLAG_STATUS,
FLAG_FINAL,
InitiatorOpcode,
ISCSI_VERSION,
LOGIN_FLAG_TRANSIT,
LoginStage,
RESERVED_TAG,
ScsiStatus,
TargetOpcode,
} from './constants.mjs'
import { IscsiInitiator } from './initiator.mjs'
import { allocBhs, assemblePdu, type IncomingPdu, readPdu } from './pdu.mjs'
const IQN = 'iqn.2024-01.tech.vates:nop-test'
const BLOCK_SIZE = 512
const BLOCK_COUNT = 2048
/**
* A target that speaks just enough to get a session up — login, READ
* CAPACITY(16), READ(16) — and can then ping the initiator on demand, which
* the real {@link IscsiTarget} never does. Everything here is about what the
* *initiator* does with an unsolicited NOP-In.
*/
class PingingTarget {
readonly #server: Server
#socket?: Socket
/** PDUs received from the initiator after the session was established. */
readonly received: Array<IncomingPdu> = []
#statSN = 0
constructor() {
this.#server = createServer(socket => {
this.#socket = socket
socket.setNoDelay(true)
this.#serve(socket).catch(() => {}) // the initiator hanging up is normal
})
}
async listen(): Promise<number> {
this.#server.listen(0, '127.0.0.1')
await once(this.#server, 'listening')
return (this.#server.address() as { port: number }).port
}
async close(): Promise<void> {
this.#socket?.destroy()
this.#server.close()
}
#send(socket: Socket, buffer: Buffer): Promise<void> {
return new Promise((resolve, reject) => {
socket.write(buffer, error => (error ? reject(error) : resolve()))
})
}
/** Ping the initiator the way a target does when the session goes quiet. */
async ping(targetTransferTag: number, data = Buffer.alloc(0)): Promise<void> {
const socket = this.#socket
assert.ok(socket !== undefined, 'no initiator connected')
const bhs = allocBhs(TargetOpcode.NOP_IN)
bhs[1] = FLAG_FINAL
bhs.writeUInt32BE(RESERVED_TAG, 16) // ITT: unsolicited, so none
bhs.writeUInt32BE(targetTransferTag, 20)
bhs.writeUInt32BE(this.#statSN, 24) // NOP-In does not advance StatSN
await this.#send(socket, assemblePdu(bhs, data))
}
/** Resolves once the initiator has sent a PDU with `opcode`. */
async waitFor(opcode: number, timeoutMs = 2000): Promise<IncomingPdu> {
const deadline = Date.now() + timeoutMs
for (;;) {
const found = this.received.find(pdu => pdu.opcode === opcode)
if (found !== undefined) {
return found
}
assert.ok(Date.now() < deadline, `initiator never sent opcode 0x${opcode.toString(16)}`)
await new Promise(resolve => setTimeout(resolve, 10))
}
}
async #serve(socket: Socket): Promise<void> {
let established = false
for (;;) {
const pdu = await readPdu(socket)
if (pdu === null) {
return
}
if (established) {
this.received.push(pdu)
}
switch (pdu.opcode) {
case InitiatorOpcode.LOGIN_REQUEST:
await this.#sendLoginResponse(socket, pdu)
established = true
break
case InitiatorOpcode.SCSI_COMMAND:
await this.#sendReadData(socket, pdu)
break
case InitiatorOpcode.LOGOUT_REQUEST: {
const bhs = allocBhs(TargetOpcode.LOGOUT_RESPONSE)
bhs[1] = FLAG_FINAL
bhs.writeUInt32BE(pdu.itt, 16)
bhs.writeUInt32BE(this.#statSN++, 24)
await this.#send(socket, assemblePdu(bhs))
return
}
default: // NOP-Out replies land in `received`; nothing to answer
break
}
}
}
async #sendLoginResponse(socket: Socket, request: IncomingPdu): Promise<void> {
const bhs = allocBhs(TargetOpcode.LOGIN_RESPONSE)
bhs[1] = LOGIN_FLAG_TRANSIT | (LoginStage.OPERATIONAL_NEGOTIATION << 2) | LoginStage.FULL_FEATURE_PHASE
bhs[2] = ISCSI_VERSION
bhs[3] = ISCSI_VERSION
request.bhs.copy(bhs, 8, 8, 14) // echo ISID
bhs.writeUInt16BE(1, 14) // TSIH
bhs.writeUInt32BE(request.itt, 16)
bhs.writeUInt32BE(this.#statSN++, 24)
await this.#send(socket, assemblePdu(bhs))
}
/** Answer any read-type command with a single Data-In carrying GOOD status. */
async #sendReadData(socket: Socket, command: IncomingPdu): Promise<void> {
const cdb = command.bhs.subarray(32, 48)
const expectedLength = command.readU32(20)
let payload
if (cdb[0] === 0x9e) {
// SERVICE ACTION IN(16) / READ CAPACITY(16)
payload = Buffer.alloc(expectedLength)
payload.writeBigUInt64BE(BigInt(BLOCK_COUNT - 1), 0)
payload.writeUInt32BE(BLOCK_SIZE, 8)
} else {
payload = Buffer.alloc(expectedLength, 0x5a)
}
const bhs = allocBhs(TargetOpcode.SCSI_DATA_IN)
bhs[1] = DATA_IN_FLAG_FINAL | DATA_IN_FLAG_STATUS
bhs[3] = ScsiStatus.GOOD
bhs.writeUInt32BE(command.itt, 16)
bhs.writeUInt32BE(RESERVED_TAG, 20)
bhs.writeUInt32BE(this.#statSN++, 24)
await this.#send(socket, assemblePdu(bhs, payload))
}
}
/** Run `body` against a connected initiator, always tearing both ends down. */
const withSession = async (body: (initiator: IscsiInitiator, target: PingingTarget) => Promise<void>) => {
const target = new PingingTarget()
const port = await target.listen()
const initiator = new IscsiInitiator({
host: '127.0.0.1',
port,
targetIqn: IQN,
messageTimeoutMs: 5000,
})
await initiator.connect()
try {
await body(initiator, target)
} finally {
await initiator.close()
await target.close()
}
}
describe('NOP-In keepalives', () => {
// The read loop used to stop as soon as no read was outstanding, and it is
// the only reader of the socket — so a ping arriving while idle sat unread in
// a paused stream. Targets drop a session whose pings go unanswered (30s on
// LIO by default), and nothing here reconnects, so an idle mount died.
it('are answered while the session is idle', async () => {
await withSession(async (initiator, target) => {
// no read in flight, and none will be
await target.ping(7)
const reply = await target.waitFor(InitiatorOpcode.NOP_OUT)
assert.equal(reply.readU32(20), 7) // echoes the Target Transfer Tag
assert.equal(reply.itt, RESERVED_TAG) // unsolicited reply, so no ITT
assert.notEqual(reply.bhs[0] & 0x40, 0) // immediate
})
})
it('echo the ping payload back', async () => {
await withSession(async (initiator, target) => {
const ping = Buffer.from('are you there')
await target.ping(9, ping)
const reply = await target.waitFor(InitiatorOpcode.NOP_OUT)
assert.deepEqual(reply.data, ping)
})
})
it('are answered between reads, without disturbing them', async () => {
await withSession(async (initiator, target) => {
assert.equal((await initiator.read(0, BLOCK_SIZE)).length, BLOCK_SIZE)
await target.ping(11)
await target.waitFor(InitiatorOpcode.NOP_OUT)
// the session is still usable afterwards
const data = await initiator.read(BLOCK_SIZE, BLOCK_SIZE)
assert.deepEqual(data, Buffer.alloc(BLOCK_SIZE, 0x5a))
})
})
it('are ignored when they are a reply to our own ping', async () => {
await withSession(async (initiator, target) => {
// a NOP-In with no Target Transfer Tag is a reply, not a ping: answering
// it would start an endless exchange
await target.ping(RESERVED_TAG)
await new Promise(resolve => setTimeout(resolve, 200))
assert.equal(
target.received.some(pdu => pdu.opcode === InitiatorOpcode.NOP_OUT),
false
)
// and the session still works
assert.equal((await initiator.read(0, BLOCK_SIZE)).length, BLOCK_SIZE)
})
})
})
describe('session teardown', () => {
it('logs out cleanly even though the read loop owns the socket', async () => {
const target = new PingingTarget()
const port = await target.listen()
const initiator = new IscsiInitiator({ host: '127.0.0.1', port, targetIqn: IQN, messageTimeoutMs: 5000 })
await initiator.connect()
await initiator.close()
// the Logout Request reached the target, i.e. `close()` did not give up on
// its own response for lack of a reader
await target.waitFor(InitiatorOpcode.LOGOUT_REQUEST)
await target.close()
})
it('fails a read with the reason the session died, rather than timing out', async () => {
const target = new PingingTarget()
const port = await target.listen()
const initiator = new IscsiInitiator({ host: '127.0.0.1', port, targetIqn: IQN, messageTimeoutMs: 5000 })
await initiator.connect()
await target.close() // target vanishes while the initiator is idle
await new Promise(resolve => setTimeout(resolve, 100))
const started = Date.now()
await assert.rejects(initiator.read(0, BLOCK_SIZE), /connection closed/)
// far below messageTimeoutMs: the dead session is known, not discovered
assert.ok(Date.now() - started < 1000)
})
})

View File

@@ -1,501 +0,0 @@
import { connect, type Socket } from 'node:net'
import { once } from 'node:events'
import { createLogger, type Logger } from '@xen-orchestra/log'
import { pTimeout } from 'promise-toolbox'
import {
AuthMethod,
DATA_IN_FLAG_STATUS,
FLAG_FINAL,
InitiatorOpcode,
LOGIN_CSG_SHIFT,
LOGIN_FLAG_TRANSIT,
LOGIN_NSG_MASK,
LoginStage,
LoginStatusClass,
OPCODE_IMMEDIATE,
RESERVED_TAG,
SCSI_CMD_FLAG_READ,
ScsiOpcode,
ScsiStatus,
SERVICE_ACTION_READ_CAPACITY_16,
TargetOpcode,
} from './constants.mjs'
import { CHAP_ALGORITHM_MD5, computeResponse, decodeChapValue, encodeChapValue } from './chap.mjs'
import { parseTextKeys, serializeTextKeys } from './login.mjs'
import { allocBhs, assemblePdu, type IncomingPdu, readPdu, writePdu } from './pdu.mjs'
import type { ChapCredentials } from './types.mjs'
const log: Logger = createLogger('vates:iscsi:initiator')
const DEFAULT_PORT = 3260
const DEFAULT_MESSAGE_TIMEOUT_MS = 60_000
const DEFAULT_MAX_RECV_DATA_SEGMENT_LENGTH = 262144
const READ_CAPACITY_16_LENGTH = 32
export interface IscsiInitiatorOptions {
/** Portal address to connect to. */
readonly host: string
/** Portal port. Defaults to 3260. */
readonly port?: number
/** Target IQN (`TargetName`) to log into. */
readonly targetIqn: string
/** Our initiator IQN (`InitiatorName`). Defaults to a generated one. */
readonly initiatorIqn?: string
/**
* One-way CHAP responder credential. When the target requests CHAP we answer
* its challenge with this user/secret. Omit for an unauthenticated login.
*/
readonly chap?: ChapCredentials
/** Per-message timeout for login/read responses, in ms. Defaults to 60000. */
readonly messageTimeoutMs?: number
}
/** An outstanding SCSI read, keyed by its Initiator Task Tag. */
interface Pending {
/** Preallocated buffer sized to the expected transfer length. */
readonly buffer: Buffer
resolve(data: Buffer): void
reject(error: Error): void
}
/**
* A minimal userspace iSCSI initiator (client), mirroring {@link IscsiTarget}'s
* framing and negotiation (read-only, single connection, one LUN, digests
* off, `ErrorRecoveryLevel=0`).
*
* A backlog `Map` keyed by ITT, drained by a single read loop, demultiplexes
* responses so several reads can be outstanding at once (e.g. under a
* `ReadAhead` disk wrapper).
*/
export class IscsiInitiator {
readonly #host: string
readonly #port: number
readonly #targetIqn: string
readonly #initiatorIqn: string
readonly #chap?: ChapCredentials
readonly #messageTimeoutMs: number
#socket?: Socket
#itt = 0
#cmdSN = 0
#expStatSN = 0
#blockSize = 0
#capacityBytes = 0
// Outstanding reads, keyed by ITT. A single #readLoop() (guarded by #reading)
// is the only consumer of readPdu, so it is safe to have many entries here.
readonly #backlog = new Map<number, Pending>()
#reading = false
// set once `close()` starts, so the read loop's own end is not reported as a
// fault and it stops instead of racing the socket teardown
#closing = false
// why the read loop stopped, when it was not us closing
#failure?: Error
// resolved by the read loop when the target's Logout Response arrives
#logoutAnswered?: () => void
constructor(options: IscsiInitiatorOptions) {
this.#host = options.host
this.#port = options.port ?? DEFAULT_PORT
this.#targetIqn = options.targetIqn
this.#initiatorIqn = options.initiatorIqn ?? 'iqn.2024-01.tech.vates:initiator'
this.#chap = options.chap
this.#messageTimeoutMs = options.messageTimeoutMs ?? DEFAULT_MESSAGE_TIMEOUT_MS
}
/** LUN logical block size in bytes (valid after {@link connect}). */
getBlockSize(): number {
return this.#blockSize
}
/** LUN capacity in bytes (valid after {@link connect}). */
getSize(): number {
return this.#capacityBytes
}
// --- lifecycle ------------------------------------------------------------
/** Open the TCP connection, log in, and read the LUN capacity. */
async connect(): Promise<void> {
const socket = connect(this.#port, this.#host)
this.#socket = socket
try {
await once(socket, 'connect')
socket.setNoDelay(true)
// A socket error with no read in flight would otherwise go unnoticed.
socket.on('error', error => this.#rejectAll(error instanceof Error ? error : new Error(String(error))))
// `#login` reads its own responses; from here on the read loop owns the
// socket, and it must be running before anything can answer a NOP-In
await this.#login()
void this.#pump()
await this.#readCapacity()
log.info('session established', { target: this.#targetIqn, capacity: this.#capacityBytes })
} catch (error) {
socket.destroy()
this.#socket = undefined
throw error
}
}
#requireSocket(): Socket {
const socket = this.#socket
if (socket === undefined) {
throw new Error('IscsiInitiator.connect() must be called first')
}
return socket
}
#send(buffer: Buffer): Promise<void> {
return writePdu(this.#requireSocket(), buffer, this.#messageTimeoutMs)
}
#nextItt(): number {
this.#itt = (this.#itt + 1) >>> 0
return this.#itt
}
/** Best-effort Logout, then close the socket. */
async close(): Promise<void> {
const socket = this.#socket
if (socket === undefined) {
return
}
// stops the read loop and keeps its own end from being logged as a fault
this.#closing = true
try {
const bhs = allocBhs(InitiatorOpcode.LOGOUT_REQUEST | OPCODE_IMMEDIATE)
bhs[1] = FLAG_FINAL // reason 0: close the session
bhs.writeUInt32BE(this.#nextItt(), 16)
bhs.writeUInt32BE(this.#cmdSN, 24)
bhs.writeUInt32BE(this.#expStatSN, 28)
// the read loop owns the socket, so the response comes through it: reading
// here too would put two consumers on one stream and split PDUs between them
const answered = new Promise<void>(resolve => {
this.#logoutAnswered = resolve
})
await this.#send(assemblePdu(bhs))
await pTimeout.call(answered, this.#messageTimeoutMs)
} catch (error) {
log.debug('logout failed, closing anyway', { error })
} finally {
this.#logoutAnswered = undefined
this.#socket = undefined
socket.destroy()
}
}
// --- login ----------------------------------------------------------------
/** Build the flags byte (T/CSG/NSG) of a Login Request. */
#loginFlags(transit: boolean, csg: number, nsg: number): number {
return (transit ? LOGIN_FLAG_TRANSIT : 0) | (csg << LOGIN_CSG_SHIFT) | (nsg & LOGIN_NSG_MASK)
}
async #sendLogin(itt: number, flags: number, keys: Array<[string, string]>): Promise<IncomingPdu> {
const bhs = allocBhs(InitiatorOpcode.LOGIN_REQUEST | OPCODE_IMMEDIATE)
bhs[1] = flags
Buffer.from([0x80, 0, 0, 0, 0, 1]).copy(bhs, 8) // ISID (fixed; single session)
bhs.writeUInt32BE(itt, 16)
bhs.writeUInt32BE(this.#cmdSN, 24) // login is immediate: does not advance CmdSN
bhs.writeUInt32BE(this.#expStatSN, 28)
log.debug('login request', { flags: `0x${flags.toString(16)}`, keys: Object.fromEntries(keys) })
await this.#send(assemblePdu(bhs, serializeTextKeys(keys)))
const pdu = await pTimeout.call(readPdu(this.#requireSocket()), this.#messageTimeoutMs)
if (pdu === null) {
throw new Error('connection closed during login')
}
if (pdu.opcode !== TargetOpcode.LOGIN_RESPONSE) {
throw new Error(`expected Login Response, got opcode 0x${pdu.opcode.toString(16)}`)
}
const statusClass = pdu.readU8(36)
log.debug('login response', {
statusClass,
statusDetail: pdu.readU8(37),
transit: (pdu.readU8(1) & LOGIN_FLAG_TRANSIT) !== 0,
keys: Object.fromEntries(parseTextKeys(pdu.data)),
})
if (statusClass !== LoginStatusClass.SUCCESS) {
throw new Error(
`login rejected (status-class 0x${statusClass.toString(16)}, detail 0x${pdu.readU8(37).toString(16)})`
)
}
this.#expStatSN = (pdu.readU32(24) + 1) >>> 0
return pdu
}
/**
* Drive the login: an optional CHAP security stage (we are the responder),
* then the operational stage into the full-feature phase. All PDUs of one
* login share a single ITT.
*/
async #login(): Promise<void> {
const itt = this.#nextItt()
const names: Array<[string, string]> = [
['InitiatorName', this.#initiatorIqn],
['TargetName', this.#targetIqn],
['SessionType', 'Normal'],
]
if (this.#chap !== undefined) {
// Round 1: offer CHAP (falling back to None) in the security stage.
const r1 = await this.#sendLogin(
itt,
this.#loginFlags(false, LoginStage.SECURITY_NEGOTIATION, LoginStage.OPERATIONAL_NEGOTIATION),
[...names, ['AuthMethod', `${AuthMethod.CHAP},${AuthMethod.NONE}`]]
)
const method = parseTextKeys(r1.data).get('AuthMethod')
log.debug('chap: target selected AuthMethod', { method })
if (method === AuthMethod.CHAP) {
// Round 2: propose MD5.
const r2 = await this.#sendLogin(
itt,
this.#loginFlags(false, LoginStage.SECURITY_NEGOTIATION, LoginStage.OPERATIONAL_NEGOTIATION),
[['CHAP_A', String(CHAP_ALGORITHM_MD5)]]
)
// Round 3: answer the target's challenge, transiting to operational.
// (Mutual CHAP is not supported: we never challenge the target; a target
// that *requires* mutual auth will reject this login, surfaced above.)
const challenge = parseTextKeys(r2.data)
const id = Number.parseInt(challenge.get('CHAP_I') ?? '', 10)
const challengeValue = challenge.get('CHAP_C')
if (!Number.isInteger(id) || challengeValue === undefined) {
throw new Error('malformed CHAP challenge from target')
}
const response = computeResponse(id, this.#chap.secret, decodeChapValue(challengeValue))
log.debug('chap: answering challenge', { id, user: this.#chap.user })
await this.#sendLogin(
itt,
this.#loginFlags(true, LoginStage.SECURITY_NEGOTIATION, LoginStage.OPERATIONAL_NEGOTIATION),
[
['CHAP_N', this.#chap.user],
['CHAP_R', encodeChapValue(response)],
]
)
await this.#operationalLogin(itt, [])
return
}
if (method !== AuthMethod.NONE && method !== undefined) {
throw new Error(`target selected unsupported AuthMethod=${method}`)
}
// Target did not require CHAP: continue straight to operational.
await this.#operationalLogin(itt, [])
return
}
// No CHAP: a single operational-stage PDU straight to the full-feature phase.
await this.#operationalLogin(itt, names)
}
/** Operational-stage login PDU, transiting to the full-feature phase. */
async #operationalLogin(itt: number, extraKeys: Array<[string, string]>): Promise<void> {
const pdu = await this.#sendLogin(
itt,
this.#loginFlags(true, LoginStage.OPERATIONAL_NEGOTIATION, LoginStage.FULL_FEATURE_PHASE),
[
...extraKeys,
['HeaderDigest', 'None'],
['DataDigest', 'None'],
['InitialR2T', 'Yes'],
['ImmediateData', 'No'],
['ErrorRecoveryLevel', '0'],
['MaxRecvDataSegmentLength', String(DEFAULT_MAX_RECV_DATA_SEGMENT_LENGTH)],
]
)
if ((pdu.readU8(1) & LOGIN_FLAG_TRANSIT) === 0) {
throw new Error('target did not transit to the full-feature phase')
}
}
// --- read path ------------------------------------------------------------
/**
* Read exactly `length` bytes from byte `offset` of the LUN. `offset` and
* `length` must be block-aligned (multiples of {@link getBlockSize}).
*/
read(offset: number, length: number): Promise<Buffer> {
const blockSize = this.#blockSize
if (offset % blockSize !== 0 || length % blockSize !== 0) {
return Promise.reject(new Error(`unaligned read (offset ${offset}, length ${length}, block ${blockSize})`))
}
const cdb = Buffer.alloc(16)
cdb[0] = ScsiOpcode.READ_16
cdb.writeBigUInt64BE(BigInt(offset / blockSize), 2) // LBA
cdb.writeUInt32BE(length / blockSize, 10) // transfer length, in blocks
return this.#scsiRead(cdb, length)
}
/** Issue READ CAPACITY(16) and record the LUN block size and capacity. */
async #readCapacity(): Promise<void> {
const cdb = Buffer.alloc(16)
cdb[0] = ScsiOpcode.SERVICE_ACTION_IN_16
cdb[1] = SERVICE_ACTION_READ_CAPACITY_16
cdb.writeUInt32BE(READ_CAPACITY_16_LENGTH, 10) // allocation length
// Block size is unknown here, so bypass the alignment guard in read().
const data = await this.#scsiRead(cdb, READ_CAPACITY_16_LENGTH)
const maxLba = data.readBigUInt64BE(0)
this.#blockSize = data.readUInt32BE(8)
this.#capacityBytes = Number((maxLba + 1n) * BigInt(this.#blockSize))
log.debug('read capacity', { blockSize: this.#blockSize, capacityBytes: this.#capacityBytes })
}
/** Issue a read-type SCSI command and resolve with its assembled Data-In. */
#scsiRead(cdb: Buffer, expectedLength: number): Promise<Buffer> {
const failure = this.#failure
if (failure !== undefined) {
// the session is already gone: fail with why, rather than writing into a
// half-closed socket and waiting out the message timeout
return Promise.reject(failure)
}
const socket = this.#requireSocket()
const itt = this.#nextItt()
const bhs = allocBhs(InitiatorOpcode.SCSI_COMMAND)
bhs[1] = FLAG_FINAL | SCSI_CMD_FLAG_READ
bhs.writeUInt32BE(itt, 16)
bhs.writeUInt32BE(expectedLength, 20) // Expected Data Transfer Length
bhs.writeUInt32BE(this.#cmdSN, 24)
this.#cmdSN = (this.#cmdSN + 1) >>> 0
bhs.writeUInt32BE(this.#expStatSN, 28)
cdb.copy(bhs, 32)
log.debug('scsi read', { itt, cdb: `0x${cdb[0].toString(16)}`, expectedLength, cmdSN: (this.#cmdSN - 1) >>> 0 })
// Register the pending read BEFORE writing, so a fast response cannot arrive
// before the backlog entry exists. No await between allocate → register →
// send → pump, which keeps concurrent reads correct.
const promise = new Promise<Buffer>((resolve, reject) => {
this.#backlog.set(itt, { buffer: Buffer.alloc(expectedLength), resolve, reject })
})
writePdu(socket, assemblePdu(bhs), this.#messageTimeoutMs).catch(error => {
const pending = this.#backlog.get(itt)
if (pending !== undefined) {
this.#backlog.delete(itt)
pending.reject(error instanceof Error ? error : new Error(String(error)))
}
})
void this.#pump()
return pTimeout.call(promise, this.#messageTimeoutMs)
}
/**
* Drain inbound PDUs for as long as the session lives. Only ever one runs at
* a time, and it is the only reader of the socket after login.
*
* It deliberately does not stop when the backlog empties. A target sends its
* NOP-In keepalives precisely when nothing else is going on, and it is the
* only reader — so a loop that parked between reads would leave those pings
* sitting unread in a paused stream. Targets drop a session whose pings go
* unanswered (LIO's `nopin_response_timeout` is 30s by default, arrays are
* similar), and nothing here reconnects, so the session would die during any
* idle period: right after `connect()`, between reads, or while the consumer
* of a mounted disk stalls.
*/
async #pump(): Promise<void> {
if (this.#reading) {
return
}
this.#reading = true
try {
while (!this.#closing) {
const socket = this.#socket
if (socket === undefined) {
throw new Error('connection closed')
}
const pdu = await readPdu(socket)
if (pdu === null) {
throw new Error('connection closed by target')
}
this.#route(pdu)
}
} catch (error) {
const failure = error instanceof Error ? error : new Error(String(error))
if (!this.#closing) {
// The loop now runs while idle, so it is usually the first to notice the
// session die — with nothing in the backlog to reject and nobody to
// return an error to. Log it where it happens, and keep the reason so
// later reads fail with the original cause instead of whatever the dead
// socket produces.
this.#failure = failure
log.warn('read loop stopped, session is dead', { error: failure })
this.#rejectAll(failure)
}
} finally {
this.#reading = false
}
}
/** Route one inbound PDU to its pending read (by ITT), completing it if done. */
#route(pdu: IncomingPdu): void {
switch (pdu.opcode) {
case TargetOpcode.SCSI_DATA_IN: {
const pending = this.#backlog.get(pdu.itt)
if (pending === undefined) {
return // stale/duplicate; ignore
}
pdu.data.copy(pending.buffer, pdu.readU32(40)) // copy at Buffer Offset
const hasStatus = (pdu.flags & DATA_IN_FLAG_STATUS) !== 0
log.debug('data-in', { itt: pdu.itt, offset: pdu.readU32(40), length: pdu.data.length, final: hasStatus })
if (hasStatus) {
this.#expStatSN = (pdu.readU32(24) + 1) >>> 0
this.#complete(pdu.itt, pending, pdu.readU8(3))
}
return
}
case TargetOpcode.SCSI_RESPONSE: {
const pending = this.#backlog.get(pdu.itt)
if (pending !== undefined) {
this.#expStatSN = (pdu.readU32(24) + 1) >>> 0
this.#complete(pdu.itt, pending, pdu.readU8(3))
}
return
}
case TargetOpcode.NOP_IN: {
// A target-initiated NOP ping (TTT set) must be answered to keep the
// session alive; a NOP reply to our own ping (none here) is ignored.
const ttt = pdu.readU32(20)
if (ttt !== RESERVED_TAG) {
void this.#replyNop(ttt, pdu.data)
}
return
}
case TargetOpcode.LOGOUT_RESPONSE:
// awaited by `close()`, which cannot read the socket itself
this.#logoutAnswered?.()
return
default:
// R2T/Async/Reject are not expected on the read-only path; ignore.
log.debug('ignoring unexpected PDU', { opcode: pdu.opcode })
}
}
#complete(itt: number, pending: Pending, status: number): void {
this.#backlog.delete(itt)
log.debug('read complete', { itt, status, pending: this.#backlog.size })
if (status === ScsiStatus.GOOD) {
pending.resolve(pending.buffer)
} else {
pending.reject(new Error(`SCSI command failed with status 0x${status.toString(16)}`))
}
}
async #replyNop(targetTransferTag: number, ping: Buffer): Promise<void> {
const bhs = allocBhs(InitiatorOpcode.NOP_OUT | OPCODE_IMMEDIATE)
bhs[1] = FLAG_FINAL
bhs.writeUInt32BE(RESERVED_TAG, 16) // ITT: none (this is an unsolicited reply)
bhs.writeUInt32BE(targetTransferTag, 20)
bhs.writeUInt32BE(this.#cmdSN, 24)
bhs.writeUInt32BE(this.#expStatSN, 28)
await this.#send(assemblePdu(bhs, ping)).catch(error => log.debug('NOP reply failed', { error }))
}
#rejectAll(error: Error): void {
if (this.#backlog.size > 0) {
log.debug('rejecting all pending reads', { count: this.#backlog.size, error: error.message })
}
for (const [itt, pending] of this.#backlog) {
this.#backlog.delete(itt)
pending.reject(error)
}
}
}

View File

@@ -1,372 +0,0 @@
import { isIP } from 'node:net'
import { createLogger, type Logger } from '@xen-orchestra/log'
import {
AuthMethod,
LOGIN_CSG_MASK,
LOGIN_CSG_SHIFT,
LOGIN_FLAG_CONTINUE,
LOGIN_FLAG_TRANSIT,
LOGIN_NSG_MASK,
LoginStage,
LoginStatusClass,
LoginStatusDetail,
} from './constants.mjs'
import {
computeResponse,
encodeChapValue,
decodeChapValue,
generateChallenge,
generateId,
parseAlgorithmList,
selectAlgorithm,
verifyResponse,
} from './chap.mjs'
import type { IncomingPdu } from './pdu.mjs'
import type { ChapCredentials, NegotiatedParams, SessionType } from './types.mjs'
const EMPTY = Buffer.alloc(0)
// Target-side login negotiation trace. Enable the `vates:iscsi:login` debug
// namespace to see every negotiated key and CHAP state change — the usual root
// cause when a particular initiator fails to attach.
const log: Logger = createLogger('vates:iscsi:login')
// Operational values the target imposes. The target drives negotiation, so these
// are pinned to a single code path (see plan): all writes are R2T-solicited
// (InitialR2T=Yes, ImmediateData=No), no digests, one connection.
export const OUR_MAX_RECV_DATA_SEGMENT_LENGTH = 262144
const OUR_MAX_BURST_LENGTH = 262144
const DEFAULT_INITIATOR_MAX_RECV_DATA_SEGMENT_LENGTH = 8192 // RFC 7143 default
// a declared MaxRecvDataSegmentLength only applies once logged in: until then,
// both sides keep to the 8192-byte default (RFC 7143 §13.12)
export const LOGIN_MAX_DATA_SEGMENT_LENGTH = 8192
/** Parse a NUL-delimited iSCSI text key=value data segment. */
export function parseTextKeys(data: Buffer): Map<string, string> {
const keys = new Map<string, string>()
for (const pair of data.toString('utf8').split('\0')) {
if (pair.length === 0) {
continue
}
const eq = pair.indexOf('=')
if (eq !== -1) {
keys.set(pair.slice(0, eq), pair.slice(eq + 1))
}
}
return keys
}
/** Serialize key=value pairs into a NUL-delimited (and NUL-terminated) buffer. */
export function serializeTextKeys(entries: ReadonlyArray<readonly [string, string]>): Buffer {
if (entries.length === 0) {
return Buffer.alloc(0)
}
return Buffer.from(entries.map(([key, value]) => `${key}=${value}\0`).join(''), 'utf8')
}
function parsePositiveInt(value: string | undefined, fallback: number): number {
if (value === undefined) {
return fallback
}
const parsed = Number.parseInt(value, 10)
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
}
/**
* Format an `address:port,portal-group-tag` value for a SendTargets
* `TargetAddress`, per the iSCSI grammar:
* - unwrap IPv4-mapped IPv6 (`::ffff:1.2.3.4`, produced by a dual-stack socket
* accepting an IPv4 connection) to plain IPv4, so initiators get a clean,
* matchable portal;
* - bracket real IPv6 literals (`[2001:db8::1]:3260,1`).
*/
export function formatPortal(address: string, port: number): string {
const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(address)
const normalized = mapped !== null ? mapped[1] : address
const bracketed = isIP(normalized) === 6 ? `[${normalized}]` : normalized
return `${bracketed}:${port},1`
}
/** Build the SendTargets discovery Text Response data segment. */
export function buildSendTargetsResponse(iqn: string, address: string, port: number): Buffer {
return serializeTextKeys([
['TargetName', iqn],
['TargetAddress', formatPortal(address, port)],
])
}
/** Outcome of processing a single Login Request PDU. */
export interface LoginStepResult {
/** Byte 1 (T/C/CSG/NSG) of the Login Response. */
flagsByte: number
/** Response key=value text segment. */
data: Buffer
/** Login Response Status-Class (byte 36). Defaults to SUCCESS when omitted. */
statusClass?: number
/** Login Response Status-Detail (byte 37). Defaults to 0 when omitted. */
statusDetail?: number
}
/** A rejected login: the Status-Class/Detail bytes the response must carry. */
interface AuthFailure {
statusClass: number
statusDetail: number
}
/** CHAP authentication failure (RFC 7143 §11.1.5): Initiator Error / auth failure. */
const AUTH_FAILURE: AuthFailure = {
statusClass: LoginStatusClass.INITIATOR_ERROR,
statusDetail: LoginStatusDetail.AUTHENTICATION_FAILURE,
}
/** Progress of the target-side one-way CHAP exchange (RFC 7143 §11.1.4). */
type ChapState = 'none' | 'awaitAlgorithm' | 'awaitResponse' | 'authenticated' | 'failed'
/**
* Negotiates one connection's login: the target is authoritative, so every
* key is answered with the pinned value, and RFC 7143's boolean rules
* guarantee the session collapses to the one supported path regardless of
* what the initiator offers.
*
* With {@link ChapCredentials}, the target challenges the initiator during
* security negotiation and refuses to reach the operational stage until it
* proves the secret; without one, it uses the legacy `AuthMethod=None` path.
*/
export class LoginNegotiator {
#firstResponseSent = false
#complete = false
readonly #chap?: ChapCredentials
#chapState: ChapState = 'none'
#chapId = 0
#chapChallenge: Buffer = EMPTY
sessionType: SessionType = 'Normal'
initiatorName?: string
targetName?: string
readonly params: NegotiatedParams = {
initiatorMaxRecvDataSegmentLength: DEFAULT_INITIATOR_MAX_RECV_DATA_SEGMENT_LENGTH,
maxBurstLength: OUR_MAX_BURST_LENGTH,
}
constructor(chap?: ChapCredentials) {
this.#chap = chap
}
/** True once the session has transited to the full-feature phase. */
get complete(): boolean {
return this.#complete
}
/** True once CHAP authentication has definitively failed for this connection. */
get failed(): boolean {
return this.#chapState === 'failed'
}
/** CHAP is enforced only when configured and only for Normal (data) sessions. */
get #chapRequired(): boolean {
return this.#chap !== undefined && this.sessionType === 'Normal'
}
process(pdu: IncomingPdu): LoginStepResult {
const flags = pdu.flags
const csg = (flags & LOGIN_CSG_MASK) >> LOGIN_CSG_SHIFT
const nsg = flags & LOGIN_NSG_MASK
const transit = (flags & LOGIN_FLAG_TRANSIT) !== 0
const cont = (flags & LOGIN_FLAG_CONTINUE) !== 0
const keys = parseTextKeys(pdu.data)
log.debug('login request', { csg, nsg, transit, cont, keys: Object.fromEntries(keys) })
const sessionType = keys.get('SessionType')
if (sessionType !== undefined) {
this.sessionType = sessionType === 'Discovery' ? 'Discovery' : 'Normal'
}
this.initiatorName ??= keys.get('InitiatorName')
this.targetName ??= keys.get('TargetName')
const responseKeys: Array<[string, string]> = []
// A normal session's first Login Response must carry the portal group tag.
if (!this.#firstResponseSent && this.sessionType === 'Normal') {
responseKeys.push(['TargetPortalGroupTag', '1'])
}
let failure: AuthFailure | undefined
if (csg === LoginStage.SECURITY_NEGOTIATION) {
// Act only on a complete key set (C bit clear); while the initiator is
// still sending keys we accumulate without answering the exchange.
if (!cont) {
failure = this.#handleSecurity(keys, responseKeys)
}
} else if (csg === LoginStage.OPERATIONAL_NEGOTIATION) {
if (this.#chapRequired && this.#chapState !== 'authenticated') {
// Normal session jumped to operational negotiation without completing the
// CHAP security stage we require.
this.#chapState = 'failed'
failure = AUTH_FAILURE
} else {
this.#negotiateOperational(keys, responseKeys)
}
}
let flagsByte = csg << LOGIN_CSG_SHIFT
// Transit only when the initiator asked to, is not still sending keys, and
// (if CHAP is required) authentication has succeeded. This single gate blocks
// a premature Transit bit and any transit before/without CHAP.
const authReady = !this.#chapRequired || this.#chapState === 'authenticated'
if (transit && !cont && authReady) {
flagsByte |= LOGIN_FLAG_TRANSIT | nsg
if (nsg === LoginStage.FULL_FEATURE_PHASE) {
this.#complete = true
}
}
this.#firstResponseSent = true
log.debug('login response', {
transit: (flagsByte & LOGIN_FLAG_TRANSIT) !== 0,
nsg: flagsByte & LOGIN_NSG_MASK,
keys: Object.fromEntries(responseKeys),
statusClass: failure?.statusClass ?? 0,
statusDetail: failure?.statusDetail ?? 0,
chapState: this.#chapState,
})
return {
flagsByte,
data: serializeTextKeys(responseKeys),
statusClass: failure?.statusClass,
statusDetail: failure?.statusDetail,
}
}
/**
* Drive one round of the target-side one-way CHAP exchange, mutating
* {@link ChapState} and appending any response keys. Returns an
* {@link AuthFailure} when authentication is rejected, otherwise `undefined`
* (success or "still in progress, awaiting the next PDU").
*/
#handleSecurity(keys: Map<string, string>, responseKeys: Array<[string, string]>): AuthFailure | undefined {
// CHAP applies only to Normal (data) sessions. Discovery sessions can only
// run SendTargets — they never reach the LUN — so they negotiate
// AuthMethod=None, keeping the standard unauthenticated `iscsiadm -m
// discovery` flow working. Without CHAP configured, this is the only path.
const chap = this.#chap
if (chap === undefined || this.sessionType !== 'Normal') {
if (keys.has('AuthMethod')) {
responseKeys.push(['AuthMethod', AuthMethod.NONE])
}
return undefined
}
switch (this.#chapState) {
case 'none': {
const offer = keys.get('AuthMethod')
if (offer === undefined) {
return undefined // still waiting for the AuthMethod offer
}
if (!offer.split(',').includes(AuthMethod.CHAP)) {
// Initiator offered only None (or an unknown method) while we require CHAP.
log.debug('chap: initiator did not offer CHAP', { offered: offer })
this.#chapState = 'failed'
return AUTH_FAILURE
}
responseKeys.push(['AuthMethod', AuthMethod.CHAP])
this.#chapState = 'awaitAlgorithm'
return undefined
}
case 'awaitAlgorithm': {
const offered = keys.get('CHAP_A')
if (offered === undefined) {
return undefined // still waiting for CHAP_A
}
const algorithm = selectAlgorithm(parseAlgorithmList(offered))
if (algorithm === undefined) {
log.debug('chap: no supported algorithm offered', { offered })
this.#chapState = 'failed'
return AUTH_FAILURE
}
this.#chapId = generateId()
this.#chapChallenge = generateChallenge()
responseKeys.push(['CHAP_A', String(algorithm)])
responseKeys.push(['CHAP_I', String(this.#chapId)])
responseKeys.push(['CHAP_C', encodeChapValue(this.#chapChallenge)])
log.debug('chap: issued challenge', { algorithm, id: this.#chapId })
this.#chapState = 'awaitResponse'
return undefined
}
case 'awaitResponse': {
const name = keys.get('CHAP_N')
const response = keys.get('CHAP_R')
if (name === undefined || response === undefined) {
return undefined // still waiting for CHAP_N / CHAP_R
}
let got: Buffer
try {
got = decodeChapValue(response)
} catch {
this.#chapState = 'failed'
return AUTH_FAILURE
}
const expected = computeResponse(this.#chapId, chap.secret, this.#chapChallenge)
if (name === chap.user && verifyResponse(expected, got)) {
log.debug('chap: authenticated', { user: name })
this.#chapState = 'authenticated'
return undefined
}
log.debug('chap: verification failed', { user: name, userMatch: name === chap.user })
this.#chapState = 'failed'
return AUTH_FAILURE
}
default:
return undefined
}
}
#negotiateOperational(keys: Map<string, string>, responseKeys: Array<[string, string]>): void {
// Only answer what the initiator actually offered: for every key below, the
// RFC 7143 default of an unoffered key is already the value we want, so
// staying silent and letting the default stand is equivalent.
const answer = (key: string, value: string) => {
if (keys.has(key)) {
responseKeys.push([key, value])
}
}
answer('HeaderDigest', 'None')
answer('DataDigest', 'None')
answer('MaxConnections', '1')
answer('InitialR2T', 'Yes')
answer('ErrorRecoveryLevel', '0')
// ImmediateData is the one exception: its RFC 7143 default is *Yes*, so an
// initiator that never offers the key may legally put write data in the SCSI
// Command PDU itself — which this target does not implement and would
// otherwise silently drop, acknowledging a write it never applied. Declare
// No whether or not it was offered. Costs nothing: there is no immediate
// data path to lose, and every mainstream initiator offers the key anyway,
// so for them this is byte-identical to before.
responseKeys.push(['ImmediateData', 'No'])
answer('DataPDUInOrder', 'Yes')
answer('DataSequenceInOrder', 'Yes')
answer('DefaultTime2Wait', '2')
answer('DefaultTime2Retain', '0')
answer('MaxOutstandingR2T', '1')
// MaxRecvDataSegmentLength is declarative per direction: capture the
// initiator's (caps our Data-In) and always declare ours (caps its Data-Out).
this.params.initiatorMaxRecvDataSegmentLength = parsePositiveInt(
keys.get('MaxRecvDataSegmentLength'),
this.params.initiatorMaxRecvDataSegmentLength
)
responseKeys.push(['MaxRecvDataSegmentLength', String(OUR_MAX_RECV_DATA_SEGMENT_LENGTH)])
// MaxBurstLength / FirstBurstLength negotiate to the minimum of both offers.
const maxBurst = Math.min(parsePositiveInt(keys.get('MaxBurstLength'), OUR_MAX_BURST_LENGTH), OUR_MAX_BURST_LENGTH)
this.params.maxBurstLength = maxBurst
if (keys.has('MaxBurstLength')) {
responseKeys.push(['MaxBurstLength', String(maxBurst)])
}
if (keys.has('FirstBurstLength')) {
const firstBurst = Math.min(parsePositiveInt(keys.get('FirstBurstLength'), maxBurst), maxBurst)
responseKeys.push(['FirstBurstLength', String(firstBurst)])
}
}
}

View File

@@ -1,313 +0,0 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
InitiatorOpcode,
LOGIN_CSG_SHIFT,
LOGIN_FLAG_CONTINUE,
LOGIN_FLAG_TRANSIT,
LOGIN_NSG_MASK,
LoginStage,
LoginStatusClass,
LoginStatusDetail,
OPCODE_IMMEDIATE,
} from './constants.mjs'
import { buildSendTargetsResponse, formatPortal, LoginNegotiator, parseTextKeys, serializeTextKeys } from './login.mjs'
import { computeResponse, decodeChapValue, encodeChapValue } from './chap.mjs'
import { IncomingPdu } from './pdu.mjs'
function loginFlags(options: { transit?: boolean; cont?: boolean; csg: number; nsg: number }): number {
return (
(options.transit ? LOGIN_FLAG_TRANSIT : 0) |
(options.cont ? LOGIN_FLAG_CONTINUE : 0) |
(options.csg << LOGIN_CSG_SHIFT) |
(options.nsg & LOGIN_NSG_MASK)
)
}
function makeLoginPdu(flags: number, keys: Record<string, string>): IncomingPdu {
const bhs = Buffer.alloc(48)
bhs[0] = InitiatorOpcode.LOGIN_REQUEST | OPCODE_IMMEDIATE
bhs[1] = flags
const data = serializeTextKeys(Object.entries(keys))
return new IncomingPdu(bhs, Buffer.alloc(0), data)
}
describe('text key serialization', () => {
it('round-trips NUL-delimited key=value pairs', () => {
const buffer = serializeTextKeys([
['A', '1'],
['HeaderDigest', 'None'],
])
assert.equal(buffer.toString('binary'), 'A=1\0HeaderDigest=None\0')
const keys = parseTextKeys(buffer)
assert.equal(keys.get('A'), '1')
assert.equal(keys.get('HeaderDigest'), 'None')
})
it('ignores empty fragments and malformed pairs', () => {
const keys = parseTextKeys(Buffer.from('\0Key=Value\0bogus\0', 'binary'))
assert.equal(keys.size, 1)
assert.equal(keys.get('Key'), 'Value')
})
})
describe('LoginNegotiator', () => {
it('completes a single-PDU operational login and pins the negotiated values', () => {
const negotiator = new LoginNegotiator()
const pdu = makeLoginPdu(
loginFlags({
transit: true,
csg: LoginStage.OPERATIONAL_NEGOTIATION,
nsg: LoginStage.FULL_FEATURE_PHASE,
}),
{
InitiatorName: 'iqn.1994-05.com.example:initiator',
SessionType: 'Normal',
HeaderDigest: 'None,CRC32C',
DataDigest: 'None,CRC32C',
InitialR2T: 'No',
ImmediateData: 'Yes',
MaxRecvDataSegmentLength: '262144',
MaxBurstLength: '16776192',
}
)
const { flagsByte, data } = negotiator.process(pdu)
assert.equal(negotiator.complete, true)
assert.ok((flagsByte & LOGIN_FLAG_TRANSIT) !== 0)
assert.equal(flagsByte & LOGIN_NSG_MASK, LoginStage.FULL_FEATURE_PHASE)
const keys = parseTextKeys(data)
assert.equal(keys.get('TargetPortalGroupTag'), '1')
assert.equal(keys.get('HeaderDigest'), 'None')
assert.equal(keys.get('DataDigest'), 'None')
// Pinned to the single write code path regardless of the initiator's offer.
assert.equal(keys.get('InitialR2T'), 'Yes')
assert.equal(keys.get('ImmediateData'), 'No')
// MaxBurstLength negotiates down to our cap.
assert.equal(keys.get('MaxBurstLength'), '262144')
assert.equal(negotiator.sessionType, 'Normal')
assert.equal(negotiator.initiatorName, 'iqn.1994-05.com.example:initiator')
assert.equal(negotiator.params.initiatorMaxRecvDataSegmentLength, 262144)
assert.equal(negotiator.params.maxBurstLength, 262144)
})
it('handles a two-stage security then operational login', () => {
const negotiator = new LoginNegotiator()
const security = negotiator.process(
makeLoginPdu(
loginFlags({
transit: true,
csg: LoginStage.SECURITY_NEGOTIATION,
nsg: LoginStage.OPERATIONAL_NEGOTIATION,
}),
{ InitiatorName: 'iqn.x:i', SessionType: 'Normal', AuthMethod: 'None' }
)
)
assert.equal(negotiator.complete, false)
assert.equal(parseTextKeys(security.data).get('AuthMethod'), 'None')
const operational = negotiator.process(
makeLoginPdu(
loginFlags({
transit: true,
csg: LoginStage.OPERATIONAL_NEGOTIATION,
nsg: LoginStage.FULL_FEATURE_PHASE,
}),
{ HeaderDigest: 'None', DataDigest: 'None' }
)
)
assert.equal(negotiator.complete, true)
// The portal group tag is only emitted in the first response.
assert.equal(parseTextKeys(operational.data).get('TargetPortalGroupTag'), undefined)
})
it('omits the portal group tag for a discovery session', () => {
const negotiator = new LoginNegotiator()
const { data } = negotiator.process(
makeLoginPdu(
loginFlags({
transit: true,
csg: LoginStage.OPERATIONAL_NEGOTIATION,
nsg: LoginStage.FULL_FEATURE_PHASE,
}),
{ SessionType: 'Discovery' }
)
)
assert.equal(negotiator.sessionType, 'Discovery')
assert.equal(parseTextKeys(data).get('TargetPortalGroupTag'), undefined)
})
it('does not transit while the initiator is still sending keys (C bit)', () => {
const negotiator = new LoginNegotiator()
const { flagsByte } = negotiator.process(
makeLoginPdu(
loginFlags({
transit: true,
cont: true,
csg: LoginStage.OPERATIONAL_NEGOTIATION,
nsg: LoginStage.FULL_FEATURE_PHASE,
}),
{ HeaderDigest: 'None' }
)
)
assert.equal(negotiator.complete, false)
assert.equal(flagsByte & LOGIN_FLAG_TRANSIT, 0)
})
})
describe('LoginNegotiator CHAP authenticator', () => {
const CHAP = { user: 'alice', secret: 's3cr3t' }
const securityFlags = (transit = false) =>
loginFlags({ transit, csg: LoginStage.SECURITY_NEGOTIATION, nsg: LoginStage.OPERATIONAL_NEGOTIATION })
/** Round 1: offer AuthMethod. */
function offerAuth(negotiator: LoginNegotiator, methods = 'CHAP,None', transit = false) {
return negotiator.process(
makeLoginPdu(securityFlags(transit), { InitiatorName: 'iqn.x:i', SessionType: 'Normal', AuthMethod: methods })
)
}
/** Round 2: offer CHAP_A, return the target's parsed challenge keys. */
function offerAlgorithm(negotiator: LoginNegotiator, list = '5') {
const result = negotiator.process(makeLoginPdu(securityFlags(false), { CHAP_A: list }))
return { result, keys: parseTextKeys(result.data) }
}
/** Round 3: answer with CHAP_N/CHAP_R for the given challenge. */
function answerChallenge(
negotiator: LoginNegotiator,
challengeKeys: Map<string, string>,
{ user = CHAP.user, secret = CHAP.secret } = {}
) {
const id = Number.parseInt(challengeKeys.get('CHAP_I') ?? '', 10)
const challenge = decodeChapValue(challengeKeys.get('CHAP_C') ?? '')
const response = encodeChapValue(computeResponse(id, secret, challenge))
return negotiator.process(makeLoginPdu(securityFlags(true), { CHAP_N: user, CHAP_R: response }))
}
it('challenges then transits only after a correct response', () => {
const negotiator = new LoginNegotiator(CHAP)
// Round 1: we answer AuthMethod=CHAP and do NOT transit.
const r1 = offerAuth(negotiator)
assert.equal(parseTextKeys(r1.data).get('AuthMethod'), 'CHAP')
assert.equal(r1.flagsByte & LOGIN_FLAG_TRANSIT, 0)
assert.equal(r1.statusClass ?? LoginStatusClass.SUCCESS, LoginStatusClass.SUCCESS)
// Round 2: we issue CHAP_A=5, an identifier and a challenge, still no transit.
const { result: r2, keys: challenge } = offerAlgorithm(negotiator)
assert.equal(challenge.get('CHAP_A'), '5')
assert.ok(challenge.has('CHAP_I'))
assert.match(challenge.get('CHAP_C') ?? '', /^0x[0-9a-f]+$/)
assert.equal(r2.flagsByte & LOGIN_FLAG_TRANSIT, 0)
// Round 3: a correct response transits to the operational stage.
const r3 = answerChallenge(negotiator, challenge)
assert.equal(r3.statusClass ?? LoginStatusClass.SUCCESS, LoginStatusClass.SUCCESS)
assert.ok((r3.flagsByte & LOGIN_FLAG_TRANSIT) !== 0, 'transit granted after auth')
assert.equal(r3.flagsByte & LOGIN_NSG_MASK, LoginStage.OPERATIONAL_NEGOTIATION)
assert.equal(negotiator.failed, false)
// The operational stage then completes as usual.
const op = negotiator.process(
makeLoginPdu(
loginFlags({ transit: true, csg: LoginStage.OPERATIONAL_NEGOTIATION, nsg: LoginStage.FULL_FEATURE_PHASE }),
{ HeaderDigest: 'None', DataDigest: 'None' }
)
)
assert.equal(negotiator.complete, true)
assert.equal(parseTextKeys(op.data).get('HeaderDigest'), 'None')
})
it('rejects a wrong secret with Initiator Error / authentication failure', () => {
const negotiator = new LoginNegotiator(CHAP)
offerAuth(negotiator)
const { keys: challenge } = offerAlgorithm(negotiator)
const r3 = answerChallenge(negotiator, challenge, { secret: 'wrong' })
assert.equal(r3.statusClass, LoginStatusClass.INITIATOR_ERROR)
assert.equal(r3.statusDetail, LoginStatusDetail.AUTHENTICATION_FAILURE)
assert.equal(r3.flagsByte & LOGIN_FLAG_TRANSIT, 0)
assert.equal(negotiator.complete, false)
assert.equal(negotiator.failed, true)
})
it('rejects a wrong username even with the right secret', () => {
const negotiator = new LoginNegotiator(CHAP)
offerAuth(negotiator)
const { keys: challenge } = offerAlgorithm(negotiator)
const r3 = answerChallenge(negotiator, challenge, { user: 'mallory' })
assert.equal(r3.statusClass, LoginStatusClass.INITIATOR_ERROR)
assert.equal(negotiator.failed, true)
})
it('fails when the initiator offers only AuthMethod=None', () => {
const negotiator = new LoginNegotiator(CHAP)
const r1 = offerAuth(negotiator, 'None')
assert.equal(r1.statusClass, LoginStatusClass.INITIATOR_ERROR)
assert.equal(r1.statusDetail, LoginStatusDetail.AUTHENTICATION_FAILURE)
assert.equal(negotiator.failed, true)
})
it('fails when CHAP_A does not offer MD5', () => {
const negotiator = new LoginNegotiator(CHAP)
offerAuth(negotiator)
const { result } = offerAlgorithm(negotiator, '6,7')
assert.equal(result.statusClass, LoginStatusClass.INITIATOR_ERROR)
assert.equal(negotiator.failed, true)
})
it('does not transit if the initiator sets the T bit before authenticating', () => {
const negotiator = new LoginNegotiator(CHAP)
// Round 1 with the Transit bit set: must be ignored until CHAP completes.
const r1 = offerAuth(negotiator, 'CHAP,None', true)
assert.equal(r1.flagsByte & LOGIN_FLAG_TRANSIT, 0)
assert.equal(negotiator.complete, false)
})
it('fails if the initiator skips security and jumps to operational negotiation', () => {
const negotiator = new LoginNegotiator(CHAP)
const result = negotiator.process(
makeLoginPdu(
loginFlags({ transit: true, csg: LoginStage.OPERATIONAL_NEGOTIATION, nsg: LoginStage.FULL_FEATURE_PHASE }),
{ HeaderDigest: 'None' }
)
)
assert.equal(result.statusClass, LoginStatusClass.INITIATOR_ERROR)
assert.equal(result.flagsByte & LOGIN_FLAG_TRANSIT, 0)
assert.equal(negotiator.complete, false)
assert.equal(negotiator.failed, true)
})
})
describe('formatPortal', () => {
it('keeps plain IPv4 addresses', () => {
assert.equal(formatPortal('10.0.0.1', 3260), '10.0.0.1:3260,1')
})
it('unwraps IPv4-mapped IPv6 (dual-stack socket artifact)', () => {
assert.equal(formatPortal('::ffff:192.168.1.8', 3260), '192.168.1.8:3260,1')
})
it('brackets real IPv6 literals', () => {
assert.equal(formatPortal('2a01:e0a:e6a:9500::1', 3260), '[2a01:e0a:e6a:9500::1]:3260,1')
})
})
describe('buildSendTargetsResponse', () => {
it('advertises the target name and address', () => {
const keys = parseTextKeys(buildSendTargetsResponse('iqn.2024-01.tech.vates:lun0', '10.0.0.1', 3260))
assert.equal(keys.get('TargetName'), 'iqn.2024-01.tech.vates:lun0')
assert.equal(keys.get('TargetAddress'), '10.0.0.1:3260,1')
})
it('advertises a clean IPv4 portal for an IPv4-mapped local address', () => {
const keys = parseTextKeys(buildSendTargetsResponse('iqn.x:y', '::ffff:192.168.1.8', 3260))
assert.equal(keys.get('TargetAddress'), '192.168.1.8:3260,1')
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,174 +0,0 @@
import type { Socket } from 'node:net'
import type { Readable } from 'node:stream'
import { readChunk, readChunkStrict } from '@vates/read-chunk'
import { pTimeout } from 'promise-toolbox'
import { createLogger, type Logger } from '@xen-orchestra/log'
import { BHS_LENGTH, opcodeName, OPCODE_IMMEDIATE, OPCODE_MASK, FLAG_FINAL } from './constants.mjs'
const EMPTY = Buffer.alloc(0)
// Framing-level wire trace, shared by target and initiator. Enable with the
// `vates:iscsi:pdu` debug namespace to see every PDU that crosses the socket —
// the first thing to look at when a new initiator/target combination misbehaves.
const log: Logger = createLogger('vates:iscsi:pdu')
/** Round `n` up to the next multiple of 4 (iSCSI segments are 4-byte padded). */
export function roundUp4(n: number): number {
return (n + 3) & ~3
}
/**
* A fully-received inbound PDU: the 48-byte Basic Header Segment, any Additional
* Header Segment, and the (unpadded) data segment. Field accessors read the BHS
* at the offsets defined by RFC 7143; callers pick the ones relevant to the
* opcode they are handling.
*/
export class IncomingPdu {
constructor(
readonly bhs: Buffer,
readonly ahs: Buffer,
readonly data: Buffer
) {}
/** 6-bit opcode (immediate bit masked off). */
get opcode(): number {
return this.bhs[0] & OPCODE_MASK
}
/** Whether the immediate bit is set (initiator requests immediate processing). */
get immediate(): boolean {
return (this.bhs[0] & OPCODE_IMMEDIATE) !== 0
}
/** Raw opcode-specific flags byte (byte 1). */
get flags(): number {
return this.bhs[1]
}
get finalBit(): boolean {
return (this.bhs[1] & FLAG_FINAL) !== 0
}
readU8(offset: number): number {
return this.bhs.readUInt8(offset)
}
readU16(offset: number): number {
return this.bhs.readUInt16BE(offset)
}
readU32(offset: number): number {
return this.bhs.readUInt32BE(offset)
}
/** Initiator Task Tag (bytes 16-19), present in every initiator PDU we handle. */
get itt(): number {
return this.bhs.readUInt32BE(16)
}
/** CmdSN (bytes 24-27) — meaningful on command-carrying PDUs. */
get cmdSN(): number {
return this.bhs.readUInt32BE(24)
}
/** ExpStatSN (bytes 28-31). */
get expStatSN(): number {
return this.bhs.readUInt32BE(28)
}
}
/**
* Read exactly one PDU from `stream`, reassembling across TCP boundaries.
* Resolves to `null` on a clean EOF between PDUs; throws on a truncated PDU
* or read error. No header/data digest bytes: digests are pinned off.
*
* No per-read timeout, intentionally — a target may sit idle between commands
* indefinitely. Liveness is a transport concern (socket timeout / NOP
* keepalives), handled elsewhere.
*
* A PDU announcing a data segment longer than `maxDataSegmentLength` is
* rejected before its payload is read, so a peer cannot make us buffer up to
* the 16 MiB the 24-bit length field allows.
*/
export async function readPdu(stream: Readable, maxDataSegmentLength = Infinity): Promise<IncomingPdu | null> {
const bhs = await readChunk(stream, BHS_LENGTH)
if (bhs === null) {
return null // clean EOF between PDUs
}
if (bhs.length !== BHS_LENGTH) {
throw new Error(`truncated iSCSI BHS (got ${bhs.length} of ${BHS_LENGTH} bytes)`)
}
const totalAhsLength = bhs.readUInt8(4)
const dataSegmentLength = bhs.readUIntBE(5, 3)
if (dataSegmentLength > maxDataSegmentLength) {
throw new Error(`iSCSI data segment too long (${dataSegmentLength} > ${maxDataSegmentLength} bytes)`)
}
const ahsBytes = totalAhsLength * 4
const dataBytesPadded = roundUp4(dataSegmentLength)
let ahs = EMPTY
let data = EMPTY
if (ahsBytes + dataBytesPadded > 0) {
const rest = await readChunkStrict(stream, ahsBytes + dataBytesPadded)
if (ahsBytes > 0) {
ahs = rest.subarray(0, ahsBytes)
}
data = rest.subarray(ahsBytes, ahsBytes + dataSegmentLength) // strip 4-byte padding
}
const pdu = new IncomingPdu(bhs, ahs, data)
log.debug('rx', {
opcode: opcodeName(bhs[0]),
itt: pdu.itt,
flags: `0x${bhs[1].toString(16).padStart(2, '0')}`,
ahs: ahsBytes,
data: dataSegmentLength,
})
return pdu
}
/** Allocate a zeroed 48-byte BHS with byte 0 set to `opcode`. */
export function allocBhs(opcode: number): Buffer {
const bhs = Buffer.alloc(BHS_LENGTH)
bhs[0] = opcode
return bhs
}
/** Write the 24-bit DataSegmentLength field (bytes 5-7) of a BHS. */
export function setDataSegmentLength(bhs: Buffer, length: number): void {
bhs.writeUIntBE(length, 5, 3)
}
/**
* Assemble a complete PDU buffer from a 48-byte BHS and an optional data
* segment, setting DataSegmentLength and applying 4-byte padding.
*/
export function assemblePdu(bhs: Buffer, data?: Buffer): Buffer {
const length = data?.length ?? 0
setDataSegmentLength(bhs, length)
if (length === 0) {
return bhs
}
const padding = roundUp4(length) - length
return padding === 0 ? Buffer.concat([bhs, data!]) : Buffer.concat([bhs, data!, Buffer.alloc(padding)])
}
/**
* Write a PDU to the socket, resolving once the kernel has accepted it (honoring
* back-pressure via the write callback). Rejects if the write errors or does not
* drain within `timeoutMs` (0 disables the timeout).
*/
export function writePdu(socket: Socket, buffer: Buffer, timeoutMs: number): Promise<void> {
log.debug('tx', {
opcode: opcodeName(buffer[0]),
itt: buffer.readUInt32BE(16),
flags: `0x${buffer[1].toString(16).padStart(2, '0')}`,
length: buffer.length,
})
const promise = new Promise<void>((resolve, reject) => {
socket.write(buffer, error => (error ? reject(error) : resolve()))
})
return timeoutMs > 0 ? pTimeout.call(promise, timeoutMs) : promise
}

View File

@@ -1,118 +0,0 @@
import assert from 'node:assert/strict'
import { PassThrough } from 'node:stream'
import { describe, it } from 'node:test'
import { InitiatorOpcode } from './constants.mjs'
import { allocBhs, assemblePdu, IncomingPdu, readPdu, roundUp4 } from './pdu.mjs'
function buildScsiCommandPdu(itt: number, cmdSN: number, cdb: Buffer, data?: Buffer): Buffer {
const bhs = allocBhs(InitiatorOpcode.SCSI_COMMAND)
bhs[1] = 0x80 // Final
bhs.writeUInt32BE(itt, 16)
bhs.writeUInt32BE(0, 20) // EDTL
bhs.writeUInt32BE(cmdSN, 24)
cdb.copy(bhs, 32)
return assemblePdu(bhs, data)
}
async function readOne(buffer: Buffer): Promise<IncomingPdu> {
const stream = new PassThrough()
stream.end(buffer)
const pdu = await readPdu(stream)
assert.ok(pdu !== null)
return pdu
}
describe('roundUp4', () => {
it('rounds up to the next multiple of 4', () => {
assert.equal(roundUp4(0), 0)
assert.equal(roundUp4(1), 4)
assert.equal(roundUp4(4), 4)
assert.equal(roundUp4(5), 8)
assert.equal(roundUp4(15), 16)
})
})
describe('assemblePdu / readPdu round-trip', () => {
it('preserves opcode and header fields', async () => {
const cdb = Buffer.alloc(16)
cdb[0] = 0x28 // READ(10)
const pdu = await readOne(buildScsiCommandPdu(0xdeadbeef, 42, cdb))
assert.equal(pdu.opcode, InitiatorOpcode.SCSI_COMMAND)
assert.equal(pdu.immediate, false)
assert.equal(pdu.finalBit, true)
assert.equal(pdu.itt, 0xdeadbeef)
assert.equal(pdu.cmdSN, 42)
assert.equal(pdu.bhs.subarray(32, 48)[0], 0x28)
})
it('carries a data segment and strips its 4-byte padding', async () => {
const data = Buffer.from([1, 2, 3, 4, 5]) // 5 bytes -> padded to 8 on the wire
const wire = buildScsiCommandPdu(1, 1, Buffer.alloc(16), data)
assert.equal(wire.length, 48 + 8, 'data segment is padded to a 4-byte boundary')
const pdu = await readOne(wire)
assert.deepEqual(pdu.data, data, 'padding is stripped from the parsed data')
})
})
describe('readPdu framing', () => {
it('rejects a data segment longer than the limit before reading it', async () => {
const bhs = allocBhs(InitiatorOpcode.SCSI_COMMAND)
bhs.writeUIntBE(16 * 1024 * 1024 - 1, 5, 3) // announced data segment length
const stream = new PassThrough()
stream.write(bhs) // the announced payload is never sent
await assert.rejects(readPdu(stream, 8192), /data segment too long/)
})
it('accepts a data segment of exactly the limit', async () => {
const data = Buffer.alloc(8192, 0x5a)
const pdu = await (async () => {
const stream = new PassThrough()
stream.end(buildScsiCommandPdu(1, 1, Buffer.alloc(16), data))
return readPdu(stream, 8192)
})()
assert.deepEqual(pdu?.data, data)
})
it('reassembles a PDU split across multiple TCP chunks', async () => {
const data = Buffer.from('hello world!!') // 13 bytes -> padded to 16
const wire = buildScsiCommandPdu(7, 7, Buffer.alloc(16), data)
const stream = new PassThrough()
const pduPromise = readPdu(stream)
// Feed the bytes in awkward fragments.
stream.write(wire.subarray(0, 10))
stream.write(wire.subarray(10, 48))
stream.write(wire.subarray(48, 55))
stream.end(wire.subarray(55))
const pdu = await pduPromise
assert.ok(pdu !== null)
assert.equal(pdu.itt, 7)
assert.deepEqual(pdu.data, data)
})
it('reads two PDUs coalesced into one chunk', async () => {
const first = buildScsiCommandPdu(1, 1, Buffer.alloc(16), Buffer.from([0xaa]))
const second = buildScsiCommandPdu(2, 2, Buffer.alloc(16), Buffer.from([0xbb, 0xcc]))
const stream = new PassThrough()
stream.end(Buffer.concat([first, second]))
const a = await readPdu(stream)
const b = await readPdu(stream)
assert.equal(a?.itt, 1)
assert.deepEqual(a?.data, Buffer.from([0xaa]))
assert.equal(b?.itt, 2)
assert.deepEqual(b?.data, Buffer.from([0xbb, 0xcc]))
})
it('returns null on a clean end-of-stream', async () => {
const stream = new PassThrough()
stream.end()
assert.equal(await readPdu(stream), null)
})
it('throws on a truncated BHS', async () => {
const stream = new PassThrough()
stream.end(Buffer.alloc(20)) // fewer than 48 bytes, then EOF
await assert.rejects(readPdu(stream), /truncated/)
})
})

View File

@@ -1,293 +0,0 @@
import { createLogger, type Logger } from '@xen-orchestra/log'
import { Asc, ScsiStatus, SenseKey } from './constants.mjs'
import { decodeCdb, type CommandContext, type SenseInfo } from './types.mjs'
// SCSI command trace. Enable `vates:iscsi:scsi` to see every decoded CDB the
// target services — useful when an initiator issues a command we mishandle.
const log: Logger = createLogger('vates:iscsi:scsi')
/** SCSI INQUIRY identity strings advertised by the LUN. */
export interface ScsiIdentity {
/** T10 vendor identification (≤ 8 ASCII chars). */
vendor: string
/** Product identification (≤ 16 ASCII chars). */
product: string
/** Product revision level (≤ 4 ASCII chars). */
revision: string
/** Unit serial number; also seeds the VPD 0x83 device identifier. */
serial: string
}
// --- low-level encoders -----------------------------------------------------
function writeAscii(buffer: Buffer, offset: number, length: number, value: string): void {
buffer.fill(0x20, offset, offset + length) // space pad
buffer.write(value.slice(0, length), offset, 'ascii')
}
/** Standard INQUIRY data (36 bytes) for a direct-access block device. */
export function buildStandardInquiry(identity: ScsiIdentity): Buffer {
const buffer = Buffer.alloc(36)
buffer[0] = 0x00 // peripheral qualifier 0, device type 0x00 (direct-access block device)
buffer[1] = 0x00 // not removable
buffer[2] = 0x05 // version: SPC-3
buffer[3] = 0x02 // response data format 2
buffer[4] = 31 // additional length (total 36 - 5)
writeAscii(buffer, 8, 8, identity.vendor)
writeAscii(buffer, 16, 16, identity.product)
writeAscii(buffer, 32, 4, identity.revision)
return buffer
}
const SUPPORTED_VPD_PAGES = [0x00, 0x80, 0x83, 0xb0]
/** Default max length of a single READ or WRITE, in bytes. */
export const DEFAULT_MAX_TRANSFER_LENGTH = 8 * 1024 * 1024
/**
* Build an INQUIRY EVPD page, or `undefined` for an unsupported page code (the
* caller then returns CHECK CONDITION / INVALID FIELD IN CDB).
*/
export function buildVpd(
pageCode: number,
identity: ScsiIdentity,
maxTransferBlocks = DEFAULT_MAX_TRANSFER_LENGTH / 512
): Buffer | undefined {
switch (pageCode) {
case 0x00: {
// Supported VPD pages.
const buffer = Buffer.alloc(4 + SUPPORTED_VPD_PAGES.length)
buffer[1] = 0x00
buffer.writeUInt16BE(SUPPORTED_VPD_PAGES.length, 2)
SUPPORTED_VPD_PAGES.forEach((page, i) => {
buffer[4 + i] = page
})
return buffer
}
case 0x80: {
// Unit serial number.
const serial = Buffer.from(identity.serial, 'ascii')
const buffer = Buffer.alloc(4 + serial.length)
buffer[1] = 0x80
buffer.writeUInt16BE(serial.length, 2)
serial.copy(buffer, 4)
return buffer
}
case 0x83: {
// Device identification: one T10-vendor-ID-based designator (ASCII).
const designator = Buffer.alloc(8 + identity.serial.length)
writeAscii(designator, 0, 8, identity.vendor)
designator.write(identity.serial, 8, 'ascii')
const descriptor = Buffer.alloc(4 + designator.length)
descriptor[0] = 0x02 // code set: ASCII
descriptor[1] = 0x01 // association: logical unit, designator type: T10 vendor ID based
descriptor[3] = designator.length
designator.copy(descriptor, 4)
const buffer = Buffer.alloc(4 + descriptor.length)
buffer[1] = 0x83
buffer.writeUInt16BE(descriptor.length, 2)
descriptor.copy(buffer, 4)
return buffer
}
case 0xb0: {
// Block limits: initiators split their I/O to the max transfer length
// instead of seeing it refused.
const buffer = Buffer.alloc(64)
buffer[1] = 0xb0
buffer.writeUInt16BE(0x3c, 2)
buffer.writeUInt32BE(maxTransferBlocks, 8) // maximum transfer length
return buffer
}
default:
return undefined
}
}
/** READ CAPACITY(10) parameter data (8 bytes). */
export function buildReadCapacity10(blockCount: number, blockSize: number): Buffer {
const buffer = Buffer.alloc(8)
const maxLba = blockCount - 1
// If the capacity overflows 32 bits, return 0xffffffff to force the initiator
// to issue READ CAPACITY(16).
buffer.writeUInt32BE(maxLba > 0xffffffff ? 0xffffffff : maxLba, 0)
buffer.writeUInt32BE(blockSize, 4)
return buffer
}
/** READ CAPACITY(16) parameter data (32 bytes). */
export function buildReadCapacity16(blockCount: number, blockSize: number): Buffer {
const buffer = Buffer.alloc(32)
buffer.writeBigUInt64BE(BigInt(blockCount - 1), 0)
buffer.writeUInt32BE(blockSize, 8)
return buffer
}
/** REPORT LUNS parameter data advertising a single LUN 0 (16 bytes). */
export function buildReportLuns(): Buffer {
const buffer = Buffer.alloc(16)
buffer.writeUInt32BE(8, 0) // LUN list length: one 8-byte LUN entry
return buffer // bytes 8-15 are LUN 0 (all zero)
}
/** Fixed-format sense data (18 bytes). */
export function buildFixedSense({ key, asc, ascq }: SenseInfo): Buffer {
const buffer = Buffer.alloc(18)
buffer[0] = 0x70 // response code: current error, fixed format
buffer[2] = key
buffer[7] = 10 // additional sense length (18 - 8)
buffer[12] = asc
buffer[13] = ascq
return buffer
}
// WP bit of the device-specific parameter in the mode parameter header
const MODE_SENSE_WRITE_PROTECT = 0x80
/** Minimal MODE SENSE parameter data: a header with no block descriptor or pages. */
export function buildModeSense(long: boolean, writeProtected = false): Buffer {
const deviceSpecific = writeProtected ? MODE_SENSE_WRITE_PROTECT : 0
if (long) {
const buffer = Buffer.alloc(8)
buffer.writeUInt16BE(6, 0) // mode data length (8 - 2)
buffer[3] = deviceSpecific
return buffer
}
const buffer = Buffer.alloc(4)
buffer[0] = 3 // mode data length (4 - 1)
buffer[2] = deviceSpecific
return buffer
}
const ILLEGAL_REQUEST_INVALID_OPCODE: SenseInfo = {
key: SenseKey.ILLEGAL_REQUEST,
...Asc.INVALID_COMMAND_OPERATION_CODE,
}
const ILLEGAL_REQUEST_INVALID_FIELD: SenseInfo = {
key: SenseKey.ILLEGAL_REQUEST,
...Asc.INVALID_FIELD_IN_CDB,
}
const ILLEGAL_REQUEST_LBA_OUT_OF_RANGE: SenseInfo = {
key: SenseKey.ILLEGAL_REQUEST,
...Asc.LOGICAL_BLOCK_ADDRESS_OUT_OF_RANGE,
}
const MEDIUM_ERROR_NO_ADDITIONAL_SENSE: SenseInfo = { key: SenseKey.MEDIUM_ERROR, ...Asc.NO_ADDITIONAL_SENSE }
const DATA_PROTECT_WRITE_PROTECTED: SenseInfo = { key: SenseKey.DATA_PROTECT, ...Asc.WRITE_PROTECTED }
function checkCondition(ctx: CommandContext, itt: number, sense: SenseInfo): Promise<void> {
return ctx.sendScsiResponse(itt, ScsiStatus.CHECK_CONDITION, { sense: buildFixedSense(sense) })
}
/**
* Decode and execute one SCSI Command, driving completion through `ctx`.
*
* `cdb` is the 16-byte CDB from the SCSI Command PDU and `itt` its Initiator
* Task Tag. Read commands stream Data-In; WRITE solicits Data-Out via R2T;
* everything else returns status (with sense on error).
*
* A READ or WRITE longer than `maxTransferLength` bytes, advertised in the
* block limits VPD page, is refused with INVALID FIELD IN CDB: it also bounds
* the buffer a WRITE allocates.
*/
export async function handleScsiCommand(
cdb: Buffer,
itt: number,
ctx: CommandContext,
identity: ScsiIdentity,
maxTransferLength = DEFAULT_MAX_TRANSFER_LENGTH
): Promise<void> {
const { lun } = ctx
const blockSize = lun.getBlockSize()
const blockCount = lun.getSize() / blockSize
const maxTransferBlocks = Math.floor(maxTransferLength / blockSize)
const request = decodeCdb(cdb)
log.debug('scsi command', { itt, ...request })
switch (request.kind) {
case 'inquiry': {
if (!request.evpd) {
if (request.pageCode !== 0) {
// EVPD clear but a page code was given: invalid per SPC.
return checkCondition(ctx, itt, ILLEGAL_REQUEST_INVALID_FIELD)
}
return ctx.sendReadData(itt, buildStandardInquiry(identity), request.allocationLength)
}
const page = buildVpd(request.pageCode, identity, maxTransferBlocks)
if (page === undefined) {
return checkCondition(ctx, itt, ILLEGAL_REQUEST_INVALID_FIELD)
}
return ctx.sendReadData(itt, page, request.allocationLength)
}
case 'reportLuns':
return ctx.sendReadData(itt, buildReportLuns(), request.allocationLength)
case 'readCapacity10':
return ctx.sendReadData(itt, buildReadCapacity10(blockCount, blockSize), 8)
case 'readCapacity16':
return ctx.sendReadData(itt, buildReadCapacity16(blockCount, blockSize), request.allocationLength)
case 'read': {
if (request.lba + request.blocks > blockCount) {
return checkCondition(ctx, itt, ILLEGAL_REQUEST_LBA_OUT_OF_RANGE)
}
if (request.blocks > maxTransferBlocks) {
return checkCondition(ctx, itt, ILLEGAL_REQUEST_INVALID_FIELD)
}
const byteLength = request.blocks * blockSize
if (byteLength === 0) {
return ctx.sendReadData(itt, Buffer.alloc(0), 0)
}
let data: Buffer
try {
data = await lun.read(request.lba * blockSize, byteLength)
} catch (error) {
// A single bad block (backend hiccup, corrupt source) must fail only
// this command, not the whole connection — mirrors how a failed
// lun.write() is handled in connection.mts's #handleDataOut.
log.warn('LUN read failed', error instanceof Error ? error : new Error(String(error)))
return checkCondition(ctx, itt, MEDIUM_ERROR_NO_ADDITIONAL_SENSE)
}
return ctx.sendReadData(itt, data, byteLength)
}
case 'write': {
if (lun.readOnly === true) {
return checkCondition(ctx, itt, DATA_PROTECT_WRITE_PROTECTED)
}
if (request.lba + request.blocks > blockCount) {
return checkCondition(ctx, itt, ILLEGAL_REQUEST_LBA_OUT_OF_RANGE)
}
if (request.blocks > maxTransferBlocks) {
return checkCondition(ctx, itt, ILLEGAL_REQUEST_INVALID_FIELD)
}
const byteLength = request.blocks * blockSize
if (byteLength === 0) {
return ctx.sendScsiResponse(itt, ScsiStatus.GOOD)
}
// Hand off to the connection: it solicits the data via R2T and sends the
// SCSI Response once the (possibly interleaved) Data-Out PDUs complete.
return ctx.beginWrite(itt, request.lba * blockSize, byteLength)
}
case 'testUnitReady':
return ctx.sendScsiResponse(itt, ScsiStatus.GOOD)
case 'requestSense': {
// No deferred error model in v1: always report NO SENSE.
const sense = buildFixedSense({ key: SenseKey.NO_SENSE, ...Asc.NO_ADDITIONAL_SENSE })
return ctx.sendReadData(itt, sense, request.allocationLength)
}
case 'modeSense':
return ctx.sendReadData(itt, buildModeSense(request.long, lun.readOnly === true), request.allocationLength)
case 'syncCache':
await lun.flush()
return ctx.sendScsiResponse(itt, ScsiStatus.GOOD)
case 'unsupported':
return checkCondition(ctx, itt, ILLEGAL_REQUEST_INVALID_OPCODE)
}
}

View File

@@ -1,302 +0,0 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import type { BlockDevice } from './backend.mjs'
import { ScsiStatus, SenseKey } from './constants.mjs'
import {
buildReadCapacity10,
buildReadCapacity16,
buildReportLuns,
buildStandardInquiry,
buildVpd,
handleScsiCommand,
type ScsiIdentity,
} from './scsi.mjs'
import { decodeCdb, type CommandContext, type ScsiResponseOptions } from './types.mjs'
const IDENTITY: ScsiIdentity = { vendor: 'VATES', product: 'ISCSI LUN', revision: '0001', serial: 'unit-serial-1' }
// --- a trivial in-memory LUN ------------------------------------------------
class MemoryBlockDevice implements BlockDevice {
readonly #buffer: Buffer
readonly #blockSize: number
constructor(sizeBytes: number, blockSize = 512) {
this.#buffer = Buffer.alloc(sizeBytes)
this.#blockSize = blockSize
}
get buffer(): Buffer {
return this.#buffer
}
getSize(): number {
return this.#buffer.length
}
getBlockSize(): number {
return this.#blockSize
}
async read(offset: number, length: number): Promise<Buffer> {
return Buffer.from(this.#buffer.subarray(offset, offset + length))
}
async write(offset: number, data: Buffer): Promise<void> {
data.copy(this.#buffer, offset)
}
async flush(): Promise<void> {}
async close(): Promise<void> {}
}
class FakeContext implements CommandContext {
readonly reads: Array<{ data: Buffer; allocationLength: number }> = []
readonly responses: Array<{ status: number; options?: ScsiResponseOptions }> = []
readonly writes: Array<{ itt: number; lunOffset: number; totalLength: number }> = []
constructor(readonly lun: BlockDevice) {}
async sendReadData(_itt: number, data: Buffer, allocationLength: number): Promise<void> {
this.reads.push({ data, allocationLength })
}
async beginWrite(itt: number, lunOffset: number, totalLength: number): Promise<void> {
this.writes.push({ itt, lunOffset, totalLength })
}
async sendScsiResponse(_itt: number, status: number, options?: ScsiResponseOptions): Promise<void> {
this.responses.push({ status, options })
}
}
function cdb(...bytes: number[]): Buffer {
const buffer = Buffer.alloc(16)
Buffer.from(bytes).copy(buffer)
return buffer
}
describe('decodeCdb', () => {
it('decodes READ(10) LBA and transfer length', () => {
const buffer = Buffer.alloc(16)
buffer[0] = 0x28
buffer.writeUInt32BE(0x01020304, 2)
buffer.writeUInt16BE(8, 7)
assert.deepEqual(decodeCdb(buffer), { kind: 'read', lba: 0x01020304, blocks: 8 })
})
it('decodes WRITE(16) LBA and transfer length', () => {
const buffer = Buffer.alloc(16)
buffer[0] = 0x8a
buffer.writeBigUInt64BE(123456789n, 2)
buffer.writeUInt32BE(16, 10)
assert.deepEqual(decodeCdb(buffer), { kind: 'write', lba: 123456789, blocks: 16 })
})
it('decodes READ CAPACITY(16) only for the right service action', () => {
assert.equal(decodeCdb(cdb(0x9e, 0x10)).kind, 'readCapacity16')
assert.equal(decodeCdb(cdb(0x9e, 0x12)).kind, 'unsupported')
})
it('clamps an out-of-range 64-bit LBA instead of throwing', () => {
// Past 2^53 an LBA no longer fits a JS number — and is past any capacity we
// can serve, so it must come back as an unservable LBA the range check
// rejects, not as an error that would tear the connection down.
const buffer = Buffer.alloc(16)
buffer[0] = 0x88
buffer.writeBigUInt64BE(0xffffffffffffffffn, 2)
buffer.writeUInt32BE(1, 10)
assert.deepEqual(decodeCdb(buffer), { kind: 'read', lba: Number.MAX_SAFE_INTEGER, blocks: 1 })
})
it('maps unknown opcodes to unsupported', () => {
assert.deepEqual(decodeCdb(cdb(0xff)), { kind: 'unsupported', opcode: 0xff })
})
})
describe('response encoders', () => {
it('builds 36-byte standard INQUIRY data for a block device', () => {
const data = buildStandardInquiry(IDENTITY)
assert.equal(data.length, 36)
assert.equal(data[0], 0x00) // direct-access block device
assert.equal(data[4], 31) // additional length
assert.equal(data.subarray(8, 16).toString('ascii').trim(), 'VATES')
assert.equal(data.subarray(16, 32).toString('ascii').trim(), 'ISCSI LUN')
})
it('builds READ CAPACITY(10) as (maxLba, blockSize)', () => {
const data = buildReadCapacity10(2048, 512)
assert.equal(data.readUInt32BE(0), 2047)
assert.equal(data.readUInt32BE(4), 512)
})
it('clamps READ CAPACITY(10) max LBA to force RC16 on large disks', () => {
const data = buildReadCapacity10(0x1_0000_0001, 512)
assert.equal(data.readUInt32BE(0), 0xffffffff)
})
it('builds READ CAPACITY(16) with a 64-bit max LBA', () => {
const data = buildReadCapacity16(0x1_0000_0001, 512)
assert.equal(data.readBigUInt64BE(0), 0x1_0000_0000n)
assert.equal(data.readUInt32BE(8), 512)
})
it('reports a single LUN 0', () => {
const data = buildReportLuns()
assert.equal(data.readUInt32BE(0), 8) // one 8-byte LUN entry
assert.deepEqual(data.subarray(8, 16), Buffer.alloc(8))
})
it('advertises supported VPD pages and rejects unknown ones', () => {
const page00 = buildVpd(0x00, IDENTITY)
assert.ok(page00 !== undefined)
assert.deepEqual([...page00.subarray(4)], [0x00, 0x80, 0x83, 0xb0])
assert.equal(buildVpd(0x55, IDENTITY), undefined)
})
it('advertises the max transfer length in the block limits page', () => {
const page = buildVpd(0xb0, IDENTITY, 2048)
assert.ok(page !== undefined)
assert.equal(page[1], 0xb0)
assert.equal(page.readUInt16BE(2), 0x3c)
assert.equal(page.readUInt32BE(8), 2048)
})
})
describe('handleScsiCommand', () => {
it('INQUIRY returns standard data trimmed to the allocation length', async () => {
const ctx = new FakeContext(new MemoryBlockDevice(1024 * 1024))
await handleScsiCommand(cdb(0x12, 0x00, 0x00, 0x00, 36), 1, ctx, IDENTITY)
assert.equal(ctx.reads.length, 1)
assert.equal(ctx.reads[0].data.length, 36)
assert.equal(ctx.reads[0].allocationLength, 36)
})
it('READ(10) returns the backing bytes for the requested blocks', async () => {
const lun = new MemoryBlockDevice(4096)
lun.buffer.fill(0xab, 512, 1024) // block 1
const ctx = new FakeContext(lun)
const buffer = Buffer.alloc(16)
buffer[0] = 0x28
buffer.writeUInt32BE(1, 2) // LBA 1
buffer.writeUInt16BE(1, 7) // 1 block
await handleScsiCommand(buffer, 1, ctx, IDENTITY)
assert.equal(ctx.reads.length, 1)
assert.deepEqual(ctx.reads[0].data, Buffer.alloc(512, 0xab))
})
it('WRITE(10) begins a solicited write at the right LUN offset and length', async () => {
const ctx = new FakeContext(new MemoryBlockDevice(4096))
const buffer = Buffer.alloc(16)
buffer[0] = 0x2a
buffer.writeUInt32BE(2, 2) // LBA 2
buffer.writeUInt16BE(3, 7) // 3 blocks
await handleScsiCommand(buffer, 7, ctx, IDENTITY)
// The data transfer + GOOD response are completed later by the connection.
assert.deepEqual(ctx.writes, [{ itt: 7, lunOffset: 2 * 512, totalLength: 3 * 512 }])
assert.equal(ctx.responses.length, 0)
})
it('WRITE(10) of zero blocks returns GOOD immediately with no transfer', async () => {
const ctx = new FakeContext(new MemoryBlockDevice(4096))
const buffer = Buffer.alloc(16)
buffer[0] = 0x2a // LBA 0, 0 blocks
await handleScsiCommand(buffer, 1, ctx, IDENTITY)
assert.equal(ctx.writes.length, 0)
assert.deepEqual(ctx.responses, [{ status: ScsiStatus.GOOD, options: undefined }])
})
it('rejects an out-of-range READ with CHECK CONDITION', async () => {
const ctx = new FakeContext(new MemoryBlockDevice(4096)) // 8 blocks
const buffer = Buffer.alloc(16)
buffer[0] = 0x28
buffer.writeUInt32BE(7, 2) // LBA 7
buffer.writeUInt16BE(4, 7) // 4 blocks -> past end
await handleScsiCommand(buffer, 1, ctx, IDENTITY)
assert.equal(ctx.responses.length, 1)
assert.equal(ctx.responses[0].status, ScsiStatus.CHECK_CONDITION)
assert.equal(ctx.responses[0].options?.sense?.[2], SenseKey.ILLEGAL_REQUEST)
})
it('reports a failed lun.read() as CHECK CONDITION / MEDIUM ERROR, not an uncaught throw', async () => {
const lun: BlockDevice = {
getSize: () => 4096,
getBlockSize: () => 512,
read: async () => {
throw new Error('backend read failed')
},
write: async () => {},
flush: async () => {},
close: async () => {},
}
const ctx = new FakeContext(lun)
const buffer = Buffer.alloc(16)
buffer[0] = 0x28
buffer.writeUInt32BE(1, 2) // LBA 1
buffer.writeUInt16BE(1, 7) // 1 block
await handleScsiCommand(buffer, 1, ctx, IDENTITY)
assert.equal(ctx.responses.length, 1)
assert.equal(ctx.responses[0].status, ScsiStatus.CHECK_CONDITION)
assert.equal(ctx.responses[0].options?.sense?.[2], SenseKey.MEDIUM_ERROR)
assert.equal(ctx.reads.length, 0) // sendReadData was never reached
})
it('returns CHECK CONDITION for an unsupported opcode', async () => {
const ctx = new FakeContext(new MemoryBlockDevice(4096))
await handleScsiCommand(cdb(0xff), 1, ctx, IDENTITY)
assert.equal(ctx.responses[0].status, ScsiStatus.CHECK_CONDITION)
assert.equal(ctx.responses[0].options?.sense?.[12], 0x20) // INVALID COMMAND OPERATION CODE
})
for (const [name, opcode] of [
['READ(10)', 0x28],
['WRITE(10)', 0x2a],
] as const) {
it(`refuses a ${name} longer than the max transfer length, without reading or soliciting data`, async () => {
let reads = 0
const lun = new MemoryBlockDevice(8192)
const read = lun.read.bind(lun)
lun.read = (offset, length) => {
reads++
return read(offset, length)
}
const ctx = new FakeContext(lun)
const buffer = Buffer.alloc(16)
buffer[0] = opcode
buffer.writeUInt16BE(3, 7) // 3 blocks, the limit is 2
await handleScsiCommand(buffer, 1, ctx, IDENTITY, 1024)
assert.equal(ctx.responses[0].status, ScsiStatus.CHECK_CONDITION)
assert.equal(ctx.responses[0].options?.sense?.[2], SenseKey.ILLEGAL_REQUEST)
assert.equal(ctx.responses[0].options?.sense?.[12], 0x24) // INVALID FIELD IN CDB
assert.equal(reads, 0)
assert.equal(ctx.writes.length, 0)
})
}
describe('read-only LUN', () => {
class ReadOnlyBlockDevice extends MemoryBlockDevice {
readonly readOnly = true
}
it('refuses WRITE(10) with DATA PROTECT, without soliciting its data', async () => {
const ctx = new FakeContext(new ReadOnlyBlockDevice(4096))
const buffer = Buffer.alloc(16)
buffer[0] = 0x2a
buffer.writeUInt16BE(1, 7)
await handleScsiCommand(buffer, 1, ctx, IDENTITY)
assert.equal(ctx.writes.length, 0)
assert.equal(ctx.responses[0].status, ScsiStatus.CHECK_CONDITION)
assert.equal(ctx.responses[0].options?.sense?.[2], SenseKey.DATA_PROTECT)
assert.equal(ctx.responses[0].options?.sense?.[12], 0x27) // WRITE PROTECTED
})
it('reports write protection in MODE SENSE(6) and MODE SENSE(10)', async () => {
const ctx = new FakeContext(new ReadOnlyBlockDevice(4096))
await handleScsiCommand(cdb(0x1a, 0x00, 0x3f, 0x00, 255), 1, ctx, IDENTITY)
await handleScsiCommand(cdb(0x5a, 0x00, 0x3f, 0, 0, 0, 0, 0x01, 0x00), 2, ctx, IDENTITY)
assert.equal(ctx.reads[0].data[2] & 0x80, 0x80)
assert.equal(ctx.reads[1].data[3] & 0x80, 0x80)
})
it('does not report a writable LUN as write-protected', async () => {
const ctx = new FakeContext(new MemoryBlockDevice(4096))
await handleScsiCommand(cdb(0x1a, 0x00, 0x3f, 0x00, 255), 1, ctx, IDENTITY)
assert.equal(ctx.reads[0].data[2] & 0x80, 0)
})
})
})

View File

@@ -1,33 +0,0 @@
/**
* A minimal counting semaphore: bounds how many `run()`-wrapped tasks execute
* at once, queueing the rest in FIFO order until a slot frees up.
*/
export class Semaphore {
#available: number
readonly #waiters: Array<() => void> = []
constructor(concurrency: number) {
if (!Number.isInteger(concurrency) || concurrency <= 0) {
throw new Error(`concurrency must be a positive integer, got ${concurrency}`)
}
this.#available = concurrency
}
async run<T>(fn: () => Promise<T>): Promise<T> {
if (this.#available > 0) {
this.#available--
} else {
await new Promise<void>(resolve => this.#waiters.push(resolve))
}
try {
return await fn()
} finally {
const next = this.#waiters.shift()
if (next !== undefined) {
next()
} else {
this.#available++
}
}
}
}

View File

@@ -1,70 +0,0 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { Semaphore } from './semaphore.mjs'
describe('Semaphore', () => {
it('rejects a non-positive-integer concurrency', () => {
assert.throws(() => new Semaphore(0), /positive integer/)
assert.throws(() => new Semaphore(-1), /positive integer/)
assert.throws(() => new Semaphore(1.5), /positive integer/)
})
it('runs tasks immediately while under the limit', async () => {
const semaphore = new Semaphore(2)
const started: Array<number> = []
await Promise.all(
[0, 1].map(i =>
semaphore.run(async () => {
started.push(i)
})
)
)
assert.deepEqual(started.sort(), [0, 1])
})
it('caps concurrency and admits the next task as soon as a slot frees', async () => {
const semaphore = new Semaphore(2)
let inFlight = 0
let maxInFlight = 0
const releases: Array<() => void> = []
const run = () =>
semaphore.run(async () => {
inFlight++
maxInFlight = Math.max(maxInFlight, inFlight)
await new Promise<void>(resolve => releases.push(resolve))
inFlight--
})
const tasks = [run(), run(), run(), run()]
// let the first batch (2, the concurrency limit) start
await new Promise(resolve => setImmediate(resolve))
assert.equal(inFlight, 2)
assert.equal(releases.length, 2)
while (releases.length > 0) {
releases.shift()!()
await new Promise(resolve => setImmediate(resolve))
}
await Promise.all(tasks)
assert.equal(maxInFlight, 2)
})
it('propagates a task failure without leaking its slot', async () => {
const semaphore = new Semaphore(1)
await assert.rejects(
semaphore.run(async () => {
throw new Error('boom')
}),
/boom/
)
// the slot must have been released despite the failure
let ran = false
await semaphore.run(async () => {
ran = true
})
assert.equal(ran, true)
})
})

View File

@@ -1,24 +0,0 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { addSerial, incrementSerial, SERIAL_MODULO } from './connection.mjs'
describe('iSCSI serial-number arithmetic', () => {
it('increments within range', () => {
assert.equal(incrementSerial(0), 1)
assert.equal(incrementSerial(41), 42)
})
it('wraps at 2^32', () => {
assert.equal(SERIAL_MODULO, 0x1_0000_0000)
assert.equal(incrementSerial(0xffffffff), 0)
assert.equal(addSerial(0xfffffffe, 5), 3)
assert.equal(addSerial(0xffffffff, 1), 0)
})
it('advertises a command window above the next expected CmdSN, wrapping cleanly', () => {
const expCmdSN = 0xfffffff0
const window = 64
assert.equal(addSerial(expCmdSN, window), (0xfffffff0 + 64) % SERIAL_MODULO)
})
})

View File

@@ -1,244 +0,0 @@
import assert from 'node:assert/strict'
import { execFile, execFileSync } from 'node:child_process'
import { mkdtemp, open as openFile, readFile, rm, truncate, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { setTimeout as delay } from 'node:timers/promises'
import { promisify } from 'node:util'
import { after, before, describe, it } from 'node:test'
import { FileBlockDevice, IscsiTarget } from './index.mjs'
const execFileP = promisify(execFile)
const IQN = 'iqn.2024-01.tech.vates:integ'
const LUN_SIZE = 8 * 1024 * 1024 // 8 MiB
const BLOCK_SIZE = 512
/**
* Determine why the open-iscsi interop test cannot run here (or `false` if it
* can). It needs root (to manage iSCSI sessions and flush block buffers) and the
* open-iscsi initiator — the exact software XCP-ng uses. As a lighter, root-free
* local alternative, the `libiscsi` userspace tools (`iscsi-inq`, `iscsi-ls`)
* can smoke-test discovery/INQUIRY without touching the kernel initiator.
*/
function skipReason(): string | false {
if (process.getuid === undefined || process.getuid() !== 0) {
return 'requires root to manage iSCSI sessions'
}
try {
execFileSync('iscsiadm', ['--version'], { stdio: 'ignore' })
} catch {
return 'requires open-iscsi (iscsiadm not found)'
}
return false
}
async function iscsiadm(args: string[]): Promise<string> {
const { stdout } = await execFileP('iscsiadm', args)
return stdout
}
/** Parse `iscsiadm -m session -P 3` for the attached SCSI disk's name (e.g. `sdb`) of our target. */
function parseAttachedDisk(sessionOutput: string): string | undefined {
const lines = sessionOutput.split('\n')
const targetLine = lines.findIndex(line => line.includes(IQN))
if (targetLine === -1) {
return undefined
}
for (let i = targetLine; i < lines.length; i++) {
const match = lines[i].match(/Attached scsi disk (sd\w+)/)
if (match !== null) {
return match[1]
}
}
return undefined
}
/**
* Neither the device node nor a nonzero `/sys/class/block/<name>/size` means
* the disk can be opened: the sd driver sets the size before
* `device_add_disk()`, which creates the sysfs entry, then the `/dev` node, then
* registers the block device: until then `open()` fails with ENOENT or ENXIO.
* Only an `open()` that succeeds means ready.
*/
async function isDeviceReady(name: string): Promise<boolean> {
try {
await (await openFile(`/dev/${name}`, 'r')).close()
return true
} catch (error) {
if (error instanceof Error && 'code' in error && (error.code === 'ENXIO' || error.code === 'ENOENT')) {
return false
}
throw error
}
}
async function waitForDevice(): Promise<string> {
for (let attempt = 0; attempt < 40; attempt++) {
const session = await iscsiadm(['-m', 'session', '-P', '3']).catch(() => '')
const name = parseAttachedDisk(session)
if (name !== undefined && (await isDeviceReady(name))) {
return `/dev/${name}`
}
await delay(250)
}
throw new Error('iSCSI block device did not appear')
}
describe('open-iscsi interop', { skip: skipReason() }, () => {
let dir: string
let backingPath: string
let target: IscsiTarget
let portal: string
before(async () => {
dir = await mkdtemp(join(tmpdir(), 'vates-iscsi-integ-'))
backingPath = join(dir, 'lun.img')
await writeFile(backingPath, Buffer.alloc(0))
await truncate(backingPath, LUN_SIZE)
target = new IscsiTarget({
iqn: IQN,
host: '127.0.0.1',
port: 0,
lun: new FileBlockDevice({ path: backingPath, blockSize: BLOCK_SIZE }),
})
await target.listen()
const address = target.address()
assert.ok(address !== undefined)
portal = `127.0.0.1:${address.port}`
})
after(async () => {
// Best-effort teardown: logout + drop the node record so the host is clean.
await iscsiadm(['-m', 'node', '-T', IQN, '-p', portal, '--logout']).catch(() => {})
await iscsiadm(['-m', 'node', '-o', 'delete', '-T', IQN, '-p', portal]).catch(() => {})
await target?.close()
await rm(dir, { recursive: true, force: true })
})
it('discovers, logs in, writes and reads back a LUN', async () => {
const discovery = await iscsiadm(['-m', 'discovery', '-t', 'sendtargets', '-p', portal])
assert.match(discovery, new RegExp(IQN), 'SendTargets discovery should list our IQN')
await iscsiadm(['-m', 'node', '-T', IQN, '-p', portal, '--login'])
const device = await waitForDevice()
// Write a recognizable pattern at block 10 through the kernel initiator.
const lba = 10
const offset = lba * BLOCK_SIZE
const pattern = Buffer.alloc(BLOCK_SIZE)
for (let i = 0; i < pattern.length; i++) {
pattern[i] = (i * 3 + 5) & 0xff
}
const writeHandle = await openFile(device, 'r+')
try {
await writeHandle.write(pattern, 0, pattern.length, offset)
await writeHandle.sync()
} finally {
await writeHandle.close()
}
// The WRITE path must have landed the bytes in the backing file.
const onDisk = await readFile(backingPath)
assert.deepEqual(onDisk.subarray(offset, offset + BLOCK_SIZE), pattern, 'backing file updated by WRITE')
// Drop the page cache so the read traverses the target (READ path).
await execFileP('blockdev', ['--flushbufs', device]).catch(() => {})
const readHandle = await openFile(device, 'r')
try {
const buffer = Buffer.alloc(BLOCK_SIZE)
await readHandle.read(buffer, 0, BLOCK_SIZE, offset)
assert.deepEqual(buffer, pattern, 'data read back through the target matches')
} finally {
await readHandle.close()
}
})
})
// The target as CHAP authenticator against the real open-iscsi initiator (the
// XCP-ng interop role). Credentials go into the node record's node.session.auth
// keys, exactly as XCP-ng's `device-config:chapuser`/`chappassword` do.
describe('open-iscsi CHAP interop', { skip: skipReason() }, () => {
// open-iscsi requires the initiator (outgoing) secret to be 12-16 characters.
const CHAP = { user: 'alice', secret: 'supersecret1234' }
let dir: string
let backingPath: string
let target: IscsiTarget
let portal: string
async function configureAuth(user: string, secret: string): Promise<void> {
const set = (name: string, value: string) =>
iscsiadm(['-m', 'node', '-T', IQN, '-p', portal, '-o', 'update', '-n', name, '-v', value])
await set('node.session.auth.authmethod', 'CHAP')
await set('node.session.auth.username', user)
await set('node.session.auth.password', secret)
}
before(async () => {
dir = await mkdtemp(join(tmpdir(), 'vates-iscsi-chap-integ-'))
backingPath = join(dir, 'lun.img')
await writeFile(backingPath, Buffer.alloc(0))
await truncate(backingPath, LUN_SIZE)
target = new IscsiTarget({
iqn: IQN,
host: '127.0.0.1',
port: 0,
lun: new FileBlockDevice({ path: backingPath, blockSize: BLOCK_SIZE }),
chap: CHAP,
})
await target.listen()
const address = target.address()
assert.ok(address !== undefined)
portal = `127.0.0.1:${address.port}`
// Discovery creates the node record we then attach auth to.
await iscsiadm(['-m', 'discovery', '-t', 'sendtargets', '-p', portal])
})
after(async () => {
await iscsiadm(['-m', 'node', '-T', IQN, '-p', portal, '--logout']).catch(() => {})
await iscsiadm(['-m', 'node', '-o', 'delete', '-T', IQN, '-p', portal]).catch(() => {})
await target?.close()
await rm(dir, { recursive: true, force: true })
})
it('rejects login with a wrong secret', async () => {
await configureAuth(CHAP.user, 'wrongsecret12345')
await assert.rejects(
iscsiadm(['-m', 'node', '-T', IQN, '-p', portal, '--login']),
'open-iscsi login must fail when the CHAP secret is wrong'
)
})
it('authenticates with the right secret and does IO', async () => {
await configureAuth(CHAP.user, CHAP.secret)
await iscsiadm(['-m', 'node', '-T', IQN, '-p', portal, '--login'])
const device = await waitForDevice()
const lba = 7
const offset = lba * BLOCK_SIZE
const pattern = Buffer.alloc(BLOCK_SIZE)
for (let i = 0; i < pattern.length; i++) {
pattern[i] = (i * 5 + 9) & 0xff
}
const writeHandle = await openFile(device, 'r+')
try {
await writeHandle.write(pattern, 0, pattern.length, offset)
await writeHandle.sync()
} finally {
await writeHandle.close()
}
await execFileP('blockdev', ['--flushbufs', device]).catch(() => {})
const readHandle = await openFile(device, 'r')
try {
const buffer = Buffer.alloc(BLOCK_SIZE)
await readHandle.read(buffer, 0, BLOCK_SIZE, offset)
assert.deepEqual(buffer, pattern, 'data read back over an authenticated session matches')
} finally {
await readHandle.close()
}
})
})

View File

@@ -1,149 +0,0 @@
import type { BlockDevice } from './backend.mjs'
import { ScsiOpcode, SERVICE_ACTION_READ_CAPACITY_16 } from './constants.mjs'
// --- Decoded SCSI requests --------------------------------------------------
/** A SCSI command we recognize, decoded from its CDB. */
export type ScsiRequest =
| { kind: 'inquiry'; evpd: boolean; pageCode: number; allocationLength: number }
| { kind: 'reportLuns'; allocationLength: number }
| { kind: 'readCapacity10' }
| { kind: 'readCapacity16'; allocationLength: number }
| { kind: 'read'; lba: number; blocks: number }
| { kind: 'write'; lba: number; blocks: number }
| { kind: 'testUnitReady' }
| { kind: 'requestSense'; allocationLength: number }
| { kind: 'modeSense'; long: boolean; pageCode: number; allocationLength: number }
| { kind: 'syncCache' }
| { kind: 'unsupported'; opcode: number }
/** Largest LBA representable as a safe JS integer; guards 64-bit CDB decoding. */
const MAX_SAFE_LBA = Number.MAX_SAFE_INTEGER
/**
* Read a 64-bit LBA, clamped to what a JS number holds exactly.
*
* An LBA above that is past the capacity of any device we can serve, so the
* clamp cannot collide with an addressable block: the caller's
* `lba + blocks > blockCount` check rejects it with LBA OUT OF RANGE, which is
* the answer SCSI wants anyway. Throwing here instead would take the whole
* connection down (the error unwinds to `serve()`, which destroys the socket)
* over a single bogus CDB.
*/
function readLba64(cdb: Buffer, offset: number): number {
const lba = cdb.readBigUInt64BE(offset)
return lba > BigInt(MAX_SAFE_LBA) ? MAX_SAFE_LBA : Number(lba)
}
/**
* Decode a SCSI CDB into a {@link ScsiRequest}. Unknown opcodes (and READ
* CAPACITY(16)'s sibling service actions we do not implement) map to
* `{ kind: 'unsupported' }` so the caller can return CHECK CONDITION. Never
* throws: every 16-byte CDB decodes to something the caller can answer.
*/
export function decodeCdb(cdb: Buffer): ScsiRequest {
const opcode = cdb[0]
switch (opcode) {
case ScsiOpcode.INQUIRY:
return {
kind: 'inquiry',
evpd: (cdb[1] & 0x01) !== 0,
pageCode: cdb[2],
allocationLength: cdb.readUInt16BE(3),
}
case ScsiOpcode.REPORT_LUNS:
return { kind: 'reportLuns', allocationLength: cdb.readUInt32BE(6) }
case ScsiOpcode.READ_CAPACITY_10:
return { kind: 'readCapacity10' }
case ScsiOpcode.SERVICE_ACTION_IN_16:
if ((cdb[1] & 0x1f) === SERVICE_ACTION_READ_CAPACITY_16) {
return { kind: 'readCapacity16', allocationLength: cdb.readUInt32BE(10) }
}
return { kind: 'unsupported', opcode }
case ScsiOpcode.READ_10:
return { kind: 'read', lba: cdb.readUInt32BE(2), blocks: cdb.readUInt16BE(7) }
case ScsiOpcode.WRITE_10:
return { kind: 'write', lba: cdb.readUInt32BE(2), blocks: cdb.readUInt16BE(7) }
case ScsiOpcode.READ_16:
return { kind: 'read', lba: readLba64(cdb, 2), blocks: cdb.readUInt32BE(10) }
case ScsiOpcode.WRITE_16:
return { kind: 'write', lba: readLba64(cdb, 2), blocks: cdb.readUInt32BE(10) }
case ScsiOpcode.TEST_UNIT_READY:
return { kind: 'testUnitReady' }
case ScsiOpcode.REQUEST_SENSE:
return { kind: 'requestSense', allocationLength: cdb[4] }
case ScsiOpcode.MODE_SENSE_6:
return { kind: 'modeSense', long: false, pageCode: cdb[2] & 0x3f, allocationLength: cdb[4] }
case ScsiOpcode.MODE_SENSE_10:
return { kind: 'modeSense', long: true, pageCode: cdb[2] & 0x3f, allocationLength: cdb.readUInt16BE(7) }
case ScsiOpcode.SYNCHRONIZE_CACHE_10:
case ScsiOpcode.SYNCHRONIZE_CACHE_16:
return { kind: 'syncCache' }
default:
return { kind: 'unsupported', opcode }
}
}
// --- Session / connection state shared across modules -----------------------
/** Operational parameters fixed during login that the SCSI layer depends on. */
export interface NegotiatedParams {
/** Initiator's MaxRecvDataSegmentLength — the cap on each Data-In PDU we send. */
initiatorMaxRecvDataSegmentLength: number
/** Max bytes solicited per R2T. */
maxBurstLength: number
}
export type SessionType = 'Discovery' | 'Normal'
/**
* A single one-way CHAP credential. On the target it is the incoming credential
* an initiator must present; on the initiator it is the credential presented to
* the target when challenged. Mutual (two-way) CHAP is not yet supported.
*/
export interface ChapCredentials {
/** CHAP username (`CHAP_N`). */
readonly user: string
/** CHAP shared secret. */
readonly secret: string
}
/** Sense data to attach to a CHECK CONDITION response. */
export interface SenseInfo {
key: number
asc: number
ascq: number
}
export interface ScsiResponseOptions {
/** Fixed-format sense data buffer (already serialized). */
sense?: Buffer
/** Residual byte counts for under/overflow reporting. */
residualOverflow?: number
residualUnderflow?: number
}
/**
* The capabilities the SCSI command handler needs from its connection: the LUN
* backend plus the three ways a command can complete (stream read data, begin a
* solicited write, or just return status). The connection implements this;
* keeping it an interface avoids a dependency cycle between `scsi` and
* `connection`.
*/
export interface CommandContext {
readonly lun: BlockDevice
/**
* Send `data` as Data-In PDU(s) with GOOD status piggybacked on the last,
* trimmed/residual-reported against `allocationLength`.
*/
sendReadData(itt: number, data: Buffer, allocationLength: number): Promise<void>
/**
* Begin a solicited write of `totalLength` bytes landing at `lunOffset`: the
* connection registers the transfer and sends the first R2T, then returns. The
* write executes and its SCSI Response is sent once the Data-Out PDUs arrive
* (which may be interleaved with other commands).
*/
beginWrite(itt: number, lunOffset: number, totalLength: number): Promise<void>
/** Send a standalone SCSI Response carrying `status` (+ optional sense/residual). */
sendScsiResponse(itt: number, status: number, options?: ScsiResponseOptions): Promise<void>
}

View File

@@ -1,151 +0,0 @@
import { FLAG_FINAL, TargetOpcode } from './constants.mjs'
import { allocBhs, assemblePdu } from './pdu.mjs'
/** Sequence numbers stamped into an outbound R2T (which does not advance StatSN). */
export interface SequenceSnapshot {
statSN: number
expCmdSN: number
maxCmdSN: number
}
/**
* Build a Ready To Transfer (R2T) PDU soliciting `desiredLength` bytes of write
* data starting at `bufferOffset`.
*/
export function buildR2t(
params: {
itt: number
r2tSN: number
targetTransferTag: number
bufferOffset: number
desiredLength: number
},
sequence: SequenceSnapshot
): Buffer {
const bhs = allocBhs(TargetOpcode.R2T)
bhs[1] = FLAG_FINAL // R2T always has the Final bit set
bhs.writeUInt32BE(params.itt, 16)
bhs.writeUInt32BE(params.targetTransferTag, 20)
bhs.writeUInt32BE(sequence.statSN, 24)
bhs.writeUInt32BE(sequence.expCmdSN, 28)
bhs.writeUInt32BE(sequence.maxCmdSN, 32)
bhs.writeUInt32BE(params.r2tSN, 36)
bhs.writeUInt32BE(params.bufferOffset, 40)
bhs.writeUInt32BE(params.desiredLength, 44)
return assemblePdu(bhs)
}
/**
* State of one in-progress WRITE: data is solicited one burst at a time (we pin
* MaxOutstandingR2T=1) and accumulated as Data-Out PDUs arrive, interleaved with
* any other commands on the connection.
*/
export interface PendingWrite {
/** Initiator Task Tag of the WRITE command; Data-Out PDUs are routed by it. */
readonly itt: number
/** Target Transfer Tag echoed in this command's R2Ts / Data-Out PDUs. */
readonly targetTransferTag: number
/** Byte offset within the LUN where the data must land. */
readonly lunOffset: number
/** Assembled write payload. */
readonly buffer: Buffer
/** Total bytes expected. */
readonly totalLength: number
/**
* Bytes of {@link buffer} filled, counted from offset 0 and contiguous by
* construction — see {@link receiveDataOut}. Never a plain count of bytes
* received off the wire, which is not the same thing.
*/
contiguousReceived: number
/** Bytes solicited so far (one burst ahead of `contiguousReceived`). */
solicited: number
/** Next R2TSN to emit. */
r2tSN: number
}
/** One inbound Data-Out PDU, reduced to what the write path needs from it. */
export interface DataOutSegment {
/** Target Transfer Tag echoed by the initiator (BHS bytes 20-23). */
readonly targetTransferTag: number
/** Where these bytes belong, relative to the start of the command's data (BHS bytes 40-43). */
readonly bufferOffset: number
readonly data: Buffer
}
/** The PDU was placed in the staging buffer. */
export interface PlacedDataOut {
readonly ok: true
/** Every byte of the command has landed: the write can be committed to the LUN. */
readonly complete: boolean
/** The solicited burst is fully received: the next one can be solicited. */
readonly burstComplete: boolean
}
/** The PDU cannot be placed; the command must be aborted rather than committed. */
export interface RefusedDataOut {
readonly ok: false
/** Why, for the log line accompanying the Reject. */
readonly reason: string
}
export type DataOutOutcome = PlacedDataOut | RefusedDataOut
/**
* Validate one Data-Out PDU against its pending write and, only if it belongs
* exactly where the buffer is still unfilled, copy it in. Validation and copy
* live together on purpose: the checks are what make the copy in-bounds, so a
* caller must not be able to do one without the other.
*
* A Data-Out must start exactly where the previous one ended. That strictness
* is what makes `contiguousReceived` mean "bytes of the buffer filled" — and
* only that meaning makes the completion test sound. Counting bytes as they
* arrive off the wire does not work, because `Buffer.copy` silently clamps:
*
* - 512 bytes at BufferOffset 512 of a 512-byte write copies *nothing*,
* - 512 bytes at BufferOffset 256 of a 512-byte write copies only 256,
* - the same 256 bytes sent twice copies 256 bytes over themselves.
*
* Each of those reaches `totalLength` bytes received with part of the buffer
* never written, so the target would commit a partly-applied write and answer
* GOOD — an acknowledged write silently losing data, which is worse than
* failing it. Before this was a contiguity check the staging buffer was also
* `allocUnsafe`, so the unwritten part leaked recycled heap to the initiator.
*
* Requiring contiguity is legitimate only because of what login declares in
* `#negotiateOperational`: `InitialR2T=Yes` (no unsolicited data, so every
* Data-Out is covered by an R2T and its TTT must match), `DataPDUInOrder=Yes`
* (RFC 7143: within a sequence, Data-Out PDUs MUST be in increasing
* BufferOffset order) and `MaxOutstandingR2T=1` (only one burst is ever
* solicited, so no other range can legitimately be in flight). Each of those
* is the winning side of its negotiation rule, so an initiator cannot talk us
* out of them. Should any of the three ever change, this must become per-burst
* range accounting instead of a single high-water mark.
*/
export function receiveDataOut(pending: PendingWrite, segment: DataOutSegment): DataOutOutcome {
const { targetTransferTag, bufferOffset, data } = segment
if (targetTransferTag !== pending.targetTransferTag) {
return {
ok: false,
reason: `Target Transfer Tag ${targetTransferTag} is not this command's ${pending.targetTransferTag}`,
}
}
if (bufferOffset !== pending.contiguousReceived) {
return { ok: false, reason: `BufferOffset ${bufferOffset} is not the expected ${pending.contiguousReceived}` }
}
// `solicited` only ever grows to at most `totalLength`, so bounding by it also
// keeps the copy inside the buffer — no clamping, no silent short copy.
if (bufferOffset + data.length > pending.solicited) {
return {
ok: false,
reason: `${data.length} bytes at ${bufferOffset} exceed the ${pending.solicited} bytes solicited`,
}
}
data.copy(pending.buffer, bufferOffset)
pending.contiguousReceived += data.length
return {
ok: true,
complete: pending.contiguousReceived === pending.totalLength,
burstComplete: pending.contiguousReceived === pending.solicited,
}
}

View File

@@ -1,168 +0,0 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { FLAG_FINAL, TargetOpcode } from './constants.mjs'
import { buildR2t, receiveDataOut, type PendingWrite } from './writePath.mjs'
const TTT = 7
const BLOCK = 512
/**
* A pending WRITE with its first burst already solicited, as `beginWrite`
* leaves it. `solicited` defaults to the whole command (one burst), which is
* what a write below MaxBurstLength gets.
*/
const pending = ({
totalLength = BLOCK,
solicited = totalLength,
}: { totalLength?: number; solicited?: number } = {}): PendingWrite => ({
itt: 1,
targetTransferTag: TTT,
lunOffset: 0,
buffer: Buffer.alloc(totalLength),
totalLength,
contiguousReceived: 0,
solicited,
r2tSN: 1,
})
const dataOut = (bufferOffset: number, length: number, fill = 0xab) => ({
targetTransferTag: TTT,
bufferOffset,
data: Buffer.alloc(length, fill),
})
describe('buildR2t', () => {
it('solicits the requested range, always with the final bit set', () => {
const r2t = buildR2t(
{ itt: 42, r2tSN: 3, targetTransferTag: TTT, bufferOffset: 1024, desiredLength: 512 },
{ statSN: 9, expCmdSN: 10, maxCmdSN: 74 }
)
assert.equal(r2t[0], TargetOpcode.R2T)
assert.equal(r2t[1], FLAG_FINAL)
assert.equal(r2t.readUInt32BE(16), 42)
assert.equal(r2t.readUInt32BE(20), TTT)
assert.equal(r2t.readUInt32BE(36), 3)
assert.equal(r2t.readUInt32BE(40), 1024)
assert.equal(r2t.readUInt32BE(44), 512)
})
})
describe('receiveDataOut', () => {
it('places a burst delivered in one PDU and reports it complete', () => {
const write = pending()
const outcome = receiveDataOut(write, dataOut(0, BLOCK))
assert.deepEqual(outcome, { ok: true, complete: true, burstComplete: true })
assert.equal(write.contiguousReceived, BLOCK)
assert.deepEqual(write.buffer, Buffer.alloc(BLOCK, 0xab))
})
it('places consecutive PDUs of one burst, completing only on the last', () => {
const write = pending({ totalLength: 3 * BLOCK })
const first = receiveDataOut(write, dataOut(0, BLOCK, 0x11))
const second = receiveDataOut(write, dataOut(BLOCK, BLOCK, 0x22))
const third = receiveDataOut(write, dataOut(2 * BLOCK, BLOCK, 0x33))
assert.deepEqual(first, { ok: true, complete: false, burstComplete: false })
assert.deepEqual(second, { ok: true, complete: false, burstComplete: false })
assert.deepEqual(third, { ok: true, complete: true, burstComplete: true })
assert.deepEqual(
write.buffer,
Buffer.concat([Buffer.alloc(BLOCK, 0x11), Buffer.alloc(BLOCK, 0x22), Buffer.alloc(BLOCK, 0x33)])
)
})
it('reports the burst complete, but not the command, when more remains to solicit', () => {
const write = pending({ totalLength: 2 * BLOCK, solicited: BLOCK })
const outcome = receiveDataOut(write, dataOut(0, BLOCK))
assert.deepEqual(outcome, { ok: true, complete: false, burstComplete: true })
})
// The three ways a plain count of received bytes reached `totalLength` with
// part of the staging buffer never written — each one an acknowledged write
// that silently lost data, since `Buffer.copy` clamps instead of throwing.
describe('refuses a PDU that would leave a hole in the buffer', () => {
it('past the end of the buffer, where the copy would land nothing', () => {
const write = pending()
const outcome = receiveDataOut(write, dataOut(BLOCK, BLOCK))
assert.equal(outcome.ok, false)
assert.equal(write.contiguousReceived, 0)
assert.deepEqual(write.buffer, Buffer.alloc(BLOCK))
})
it('straddling the end, where the copy would be silently short', () => {
const write = pending()
const outcome = receiveDataOut(write, dataOut(BLOCK / 2, BLOCK))
assert.equal(outcome.ok, false)
assert.equal(write.contiguousReceived, 0)
})
it('repeating a range already received, which would never fill the rest', () => {
const write = pending()
assert.equal(receiveDataOut(write, dataOut(0, BLOCK / 2, 0x11)).ok, true)
const outcome = receiveDataOut(write, dataOut(0, BLOCK / 2, 0x22))
assert.equal(outcome.ok, false)
// the first PDU's bytes are still there, and the rest is still unfilled
assert.equal(write.contiguousReceived, BLOCK / 2)
assert.deepEqual(write.buffer, Buffer.concat([Buffer.alloc(BLOCK / 2, 0x11), Buffer.alloc(BLOCK / 2)]))
})
it('skipping ahead, leaving a gap before it', () => {
const write = pending({ totalLength: 2 * BLOCK })
const outcome = receiveDataOut(write, dataOut(BLOCK, BLOCK))
assert.equal(outcome.ok, false)
assert.equal(write.contiguousReceived, 0)
})
})
it('refuses bytes beyond the current burst, even when the buffer could hold them', () => {
// 2 blocks solicited of a 4-block write: the last 2 are only legitimate
// after the next R2T, so accepting them now would break burst accounting
const write = pending({ totalLength: 4 * BLOCK, solicited: 2 * BLOCK })
const outcome = receiveDataOut(write, dataOut(0, 3 * BLOCK))
assert.equal(outcome.ok, false)
assert.equal(write.contiguousReceived, 0)
})
it("refuses a PDU carrying another command's Target Transfer Tag", () => {
const write = pending()
const outcome = receiveDataOut(write, { ...dataOut(0, BLOCK), targetTransferTag: TTT + 1 })
assert.equal(outcome.ok, false)
assert.equal(write.contiguousReceived, 0)
})
it('explains why it refused, for the log line next to the Reject', () => {
const outcome = receiveDataOut(pending(), dataOut(BLOCK, BLOCK))
assert.equal(outcome.ok, false)
assert.match(outcome.ok === false ? outcome.reason : '', /BufferOffset/)
})
it('accepts a zero-length PDU without disturbing the coverage it has', () => {
const write = pending()
receiveDataOut(write, dataOut(0, BLOCK / 2, 0x11))
const outcome = receiveDataOut(write, dataOut(BLOCK / 2, 0))
assert.deepEqual(outcome, { ok: true, complete: false, burstComplete: false })
assert.equal(write.contiguousReceived, BLOCK / 2)
})
})

View File

@@ -1,14 +0,0 @@
{
"compilerOptions": {
"target": "es2022",
"module": "ESNext",
"types": ["node"], // Only use types from Node.js and packages that are explicitly imported
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true, // Skip type-checking dependency .d.ts (e.g. @xen-orchestra/disk-transform), as it does itself
"declaration": true, // Generates `.d.ts` files for type safety
"outDir": "dist" // Outputs compiled code to the `dist` directory
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,12 +1,6 @@
This package provides a NBD client, in two flavors sharing the same protocol
implementation (`AbstractNbdClient`): over the network, or over the standard
streams of a NBD server run as a child process.
### `new NdbClient({address, exportname, secure = true, port = 10809})`
### `new NbdClient({address, exportname, cert, port = 10809})`
Create a new nbd client connecting to a server over TCP.
`NbdClient` is also exported as `NbdTcpClient`.
create a new nbd client
```js
import NbdClient from '@vates/nbd-client'
@@ -20,55 +14,3 @@ await client.connect()
const block = await client.readBlock(blockIndex, BlockSize)
await client.disconnect()
```
### `new NbdStdioClient({command, args, exportname})`
Create a new nbd client talking to a server through its standard streams, like
`nbdkit --single` or any server reading its queries on stdin and writing its
answers on stdout.
The process is spawned by `connect()` and stopped by `disconnect()`.
```js
import { NbdStdioClient } from '@vates/nbd-client'
const client = new NbdStdioClient({
command: 'nbdkit',
args: ['--single', '--exit-with-parent', '--read-only', 'file', '/path/to/disk.raw'],
exportname: '', // optional, the default export of the server
})
await client.connect()
const block = await client.readBlock(blockIndex, BlockSize)
await client.disconnect()
```
There is no TLS here: the transport is a pair of pipes, there is nothing to
encrypt.
`readBlock()` retries on error, and a retry reconnects: with this client that
means killing the server and spawning a new one, so the command must be
restartable and must serve the same content on each run.
`getMap()` is not implemented, since `nbdinfo` needs either an URI or a server
supporting systemd socket activation: compute the map on the caller side and
pass it explicitly, for example `new NbdDisk(infos, blockSize, { dataMap })`.
The tail of the server stderr is available as `client.stderr` and attached to
the errors raised when the server dies unexpectedly.
### Writing another client
Subclass `AbstractNbdClient` and implement the transport:
- `_openTransport()`: **required**, returns `{ readable, writable }`, called on
every (re)connection.
- `_secureTransport(transport)`: optional, called during the handshake, the only
place where the transport can be upgraded (`NBD_OPT_STARTTLS`).
- `_closeTransport(transport, lastMessage)`: optional, must hand over
`lastMessage` (`NBD_CMD_DISC`) before closing.
- `_destroyTransport(transport)`: optional, forceful cleanup, must not throw.
- `getMap(signal)`: optional, the base implementation rejects with the code
`NBD_MAP_UNSUPPORTED`.
Any extra property set on the transport object is passed back to
`_closeTransport()` and `_destroyTransport()`.

View File

@@ -1,586 +0,0 @@
import assert from 'node:assert'
import { pRetry, pDelay, pTimeout, pFromCallback } from 'promise-toolbox'
import { readChunkStrict } from '@vates/read-chunk'
import { createLogger } from '@xen-orchestra/log'
import {
INIT_PASSWD,
NBD_CMD_DISC,
NBD_CMD_READ,
NBD_DEFAULT_BLOCK_SIZE,
NBD_FLAG_FIXED_NEWSTYLE,
NBD_FLAG_HAS_FLAGS,
NBD_OPT_EXPORT_NAME,
NBD_OPT_REPLY_MAGIC,
NBD_REPLY_ACK,
NBD_REPLY_MAGIC,
NBD_REQUEST_MAGIC,
OPTS_MAGIC,
} from './constants.mjs'
const { debug, warn } = createLogger('vates:nbd-client')
// documentation is here : https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md
/**
* A pair of streams carrying the NBD protocol.
*
* For a TCP client both are the same socket, for a client talking to a server
* through its standard streams they are the two ends of two different pipes.
*
* @typedef {object} NbdTransport
* @property {import('node:stream').Readable} readable - where the server answers are read from
* @property {import('node:stream').Writable} writable - where the client queries are written to
*/
/**
* Transport agnostic NBD client: it implements the handshake and the
* transmission phases, and delegates the opening/closing of the underlying
* streams to its subclasses.
*
* Subclasses MUST implement `_openTransport()` and MAY override
* `_secureTransport()`, `_closeTransport()`, `_destroyTransport()` and `getMap()`.
*
* @abstract
*/
export default class AbstractNbdClient {
#exportName
#exportSize
/** @type {NbdTransport|undefined} */
#transport
// incremented on each connection attempt: an attempt which is not the last
// one anymore has been superseded (its caller timed out on it) and must not
// publish itself
#connectGeneration = 0
// generation of the attempt which published the current transport, undefined
// when not connected: an error on a transport of another generation is stale
// and must be ignored
#connectedGeneration
#waitBeforeReconnect
#readBlockRetries
#reconnectRetry
#connectTimeout
#messageTimeout
// AFAIK, there is no guaranty the server answers in the same order as the queries
// so we handle a backlog of command waiting for response and handle concurrency manually
#waitingForResponse // there is already a listener waiting for a response
#nextCommandQueryId = BigInt(0)
// map of command waiting for a response queryId => { size/*in byte*/, resolve, reject}
#commandQueryBacklog = new Map()
#reconnectingPromise
/**
* @param {object} settings
* @param {string} [settings.exportname] - the NBD export to open, empty for the default one
* @param {object} [options]
* @param {number} [options.connectTimeout]
* @param {number} [options.messageTimeout]
* @param {number} [options.waitBeforeReconnect]
* @param {number} [options.readBlockRetries]
* @param {number} [options.reconnectRetry]
*/
constructor(
{ exportname = '' },
{
connectTimeout = 6e4,
messageTimeout = 6e4,
waitBeforeReconnect = 1e3,
readBlockRetries = 5,
reconnectRetry = 5,
} = {}
) {
this.#exportName = exportname
this.#waitBeforeReconnect = waitBeforeReconnect
this.#readBlockRetries = readBlockRetries
this.#reconnectRetry = reconnectRetry
this.#connectTimeout = connectTimeout
this.#messageTimeout = messageTimeout
}
get exportSize() {
return this.#exportSize
}
get exportName() {
return this.#exportName
}
get connected() {
return this.#connectedGeneration !== undefined
}
/* ---------------------------------------------------------------------------
* transport, to be implemented by the subclasses
* ------------------------------------------------------------------------- */
/**
* Open the streams used to talk to the NBD server.
*
* Called on every (re)connection: it must be able to open a brand new
* transport each time it is called.
*
* @abstract
* @returns {Promise<NbdTransport>}
*/
async _openTransport() {
throw new Error(`${this.constructor.name} must implement _openTransport()`)
}
/**
* Hook called during the handshake, after the client flags have been sent and
* before the export is selected: this is the only moment where the transport
* can be upgraded (NBD_OPT_STARTTLS).
*
* Returning another transport than the one received replaces it, returning
* the same one is a no-op.
*
* @param {NbdTransport} transport
* @returns {Promise<NbdTransport>}
*/
async _secureTransport(transport) {
return transport
}
/**
* Gracefully close the transport.
*
* `lastMessage` is a ready to send NBD_CMD_DISC: implementations must hand it
* over to the server (it is the only clean way to tell it we're done) before
* closing their side.
*
* Failing or timing out here is not fatal: `_destroyTransport()` will be
* called as a fallback.
*
* @param {NbdTransport} transport
* @param {Buffer} lastMessage
* @returns {Promise<void>}
*/
async _closeTransport(transport, lastMessage) {
await pFromCallback(cb => transport.writable.end(lastMessage, cb))
}
/**
* Forcefully release the transport and everything backing it.
*
* Must be idempotent, and must not throw.
*
* @param {NbdTransport} transport
* @returns {void}
*/
_destroyTransport(transport) {
transport.readable.destroy()
if (transport.writable !== transport.readable) {
transport.writable.destroy()
}
}
/**
* Returns the map of the file with holes, zeros and data, useful to handle
* efficiently sparse sources.
*
* The base implementation is unable to compute it: subclasses able to do so
* must override this method.
*
* To implement this here: use structured replies if the server supports them,
* and then ask for BLOCK_STATUS.
*
* @returns {Promise<{ offset: number, length: number, type: number }[]>}
* A promise that resolves to an array where each object represents a segment:
* - `offset` — The byte offset from the start.
* - `length` — The size of the segment in bytes.
* - `type` — A numeric code indicating the segment type ( 0 means data).
*/
async getMap() {
const error = new Error(`${this.constructor.name} does not support getMap()`)
error.code = 'NBD_MAP_UNSUPPORTED'
throw error
}
/* ---------------------------------------------------------------------------
* connection
* ------------------------------------------------------------------------- */
#onTransportError(generation, error) {
// without this listener an error on the transport would be thrown as an
// uncaught exception, and pending reads would only fail on message timeout
//
// an error on a transport which is not the current one is expected (a
// closed transport can be reset late by the other end, after a reconnection)
// and must not reject the reads of the current transport
//
// an error during the handshake is reported by the handshake reads
// themselves, through the connect() rejection
if (generation !== this.#connectedGeneration) {
debug('error on a stale nbd transport', { error })
return
}
warn('error on the nbd transport', { error })
this.#rejectAll(error)
}
// the generation is used instead of the transport itself since an attempt can
// own multiple transports (the raw one and the one upgraded to TLS)
#watchTransport(transport, generation) {
const onError = error => this.#onTransportError(generation, error)
transport.readable.on('error', onError)
if (transport.writable !== transport.readable) {
transport.writable.on('error', onError)
}
}
async #connect() {
const generation = ++this.#connectGeneration
// the transport is kept local until the client is connected: connect() does
// not cancel on timeout, so a superseded attempt must not be able to use,
// replace or destroy the transport of the attempt which replaced it
let transport = await this._openTransport()
this.#watchTransport(transport, generation)
try {
// the transport can be replaced during the handshake (TLS upgrade)
transport = await this.#handshake(transport, generation)
if (generation !== this.#connectGeneration) {
const error = new Error('this connection has been superseded by a newer one')
error.code = 'NBD_CONNECT_SUPERSEDED'
throw error
}
} catch (error) {
// don't leak the transport (a socket, a child process, ...): disconnect()
// is a no-op as long as we're not connected, so nobody else will close it
this.#destroyTransport(transport)
throw error
}
this.#transport = transport
this.#connectedGeneration = generation
// reset internal state if we reconnected a nbd client
this.#commandQueryBacklog = new Map()
this.#waitingForResponse = false
}
async connect() {
const promise = this.#connect()
try {
return await pTimeout.call(promise, this.#connectTimeout)
} catch (error) {
// pTimeout does not cancel: #connect() may still be running and end up
// with an opened transport nobody owns anymore
promise.then(
() =>
this.disconnect().catch(cleanupError => warn('error while cleaning up a late connection', { cleanupError })),
() => {} // the failure is already reported through `error`
)
throw error
}
}
async disconnect() {
if (this.#connectedGeneration === undefined) {
return
}
this.#connectedGeneration = undefined
const transport = this.#transport
this.#transport = undefined
if (transport === undefined) {
return
}
const queryId = this.#nextCommandQueryId
this.#nextCommandQueryId++
const buffer = Buffer.alloc(28)
buffer.writeInt32BE(NBD_REQUEST_MAGIC, 0) // it is a nbd request
buffer.writeInt16BE(0, 4) // no command flags for a disconnect
buffer.writeInt16BE(NBD_CMD_DISC, 6) // we want to disconnect from nbd server
buffer.writeBigUInt64BE(queryId, 8)
buffer.writeBigUInt64BE(0n, 16)
buffer.writeInt32BE(0, 24)
try {
await pTimeout.call(this._closeTransport(transport, buffer), this.#messageTimeout)
} catch (error) {
// expected when the other end is already gone, nothing actionable: the
// transport is destroyed instead of being closed gracefully
debug('error while closing the nbd transport, destroying it', { error })
this.#destroyTransport(transport)
}
}
#destroyTransport(transport) {
try {
this._destroyTransport(transport)
} catch (error) {
warn('error while destroying the nbd transport', { error })
}
}
#clearReconnectPromise = () => {
this.#reconnectingPromise = undefined
}
async #reconnect() {
await this.disconnect().catch(() => {})
await pDelay(this.#waitBeforeReconnect) // need to let the xapi clean things on its side
await this.connect()
}
async reconnect() {
// we need to ensure reconnections do not occur in parallel
if (this.#reconnectingPromise === undefined) {
this.#reconnectingPromise = pRetry(() => this.#reconnect(), {
tries: this.#reconnectRetry,
})
this.#reconnectingPromise.then(this.#clearReconnectPromise, this.#clearReconnectPromise)
}
return this.#reconnectingPromise
}
/* ---------------------------------------------------------------------------
* handshake
* ------------------------------------------------------------------------- */
/**
* Send an option to the server and check it acknowledged it.
*
* We can use individual read/write from the transport here since there is no
* concurrency during the handshake.
*
* The transport is passed explicitly since the client is not connected yet:
* it is only published once the handshake succeeded.
*
* protected: subclasses need it to negotiate their own options (NBD_OPT_STARTTLS)
*
* @param {NbdTransport} transport
* @param {number} option
* @param {Buffer} [buffer] - the payload of the option
*/
async _sendOption(transport, option, buffer = Buffer.alloc(0)) {
await this.#write(transport, OPTS_MAGIC)
await this.#writeInt32(transport, option)
await this.#writeInt32(transport, buffer.length)
await this.#write(transport, buffer)
assert.strictEqual(await this.#readInt64(transport), NBD_OPT_REPLY_MAGIC) // magic number everywhere
assert.strictEqual(await this.#readInt32(transport), option) // the option passed
assert.strictEqual(await this.#readInt32(transport), NBD_REPLY_ACK) // ACK
const length = await this.#readInt32(transport)
assert.strictEqual(length, 0) // length
}
// we can use individual read/write from the transport here since there is only one handshake at once, no concurrency
//
// it works on the transport it is given and returns the one to use for the
// transmission phase, which is not necessarily the same (TLS upgrade)
//
/**
* @param {NbdTransport} transport
* @param {number} generation - the connection attempt owning the transport
* @returns {Promise<NbdTransport>}
*/
async #handshake(transport, generation) {
assert((await this.#read(transport, 8)).equals(INIT_PASSWD))
assert((await this.#read(transport, 8)).equals(OPTS_MAGIC))
const flagsBuffer = await this.#read(transport, 2)
const flags = flagsBuffer.readInt16BE(0)
assert.strictEqual(flags & NBD_FLAG_FIXED_NEWSTYLE, NBD_FLAG_FIXED_NEWSTYLE) // only FIXED_NEWSTYLE one is supported from the server options
await this.#writeInt32(transport, NBD_FLAG_FIXED_NEWSTYLE) // client also support NBD_FLAG_C_FIXED_NEWSTYLE
// let the subclass upgrade the transport if it needs to (TLS)
const secured = await this._secureTransport(transport)
if (secured !== transport) {
this.#watchTransport(secured, generation)
transport = secured
}
// send export name we want to access.
// it's implicitly closing the negotiation phase.
await this.#write(transport, OPTS_MAGIC)
await this.#writeInt32(transport, NBD_OPT_EXPORT_NAME)
const exportNameBuffer = Buffer.from(this.#exportName)
await this.#writeInt32(transport, exportNameBuffer.length)
await this.#write(transport, exportNameBuffer)
// 8 (export size ) + 2 (flags) + 124 zero = 134
// must read all to ensure nothing stays in the buffer
const answer = await this.#read(transport, 134)
this.#exportSize = answer.readBigUInt64BE(0)
const transmissionFlags = answer.readInt16BE(8)
assert.strictEqual(transmissionFlags & NBD_FLAG_HAS_FLAGS, NBD_FLAG_HAS_FLAGS, 'NBD_FLAG_HAS_FLAGS') // must always be 1 by the norm
// note : xapi server always send NBD_FLAG_READ_ONLY (3) as a flag
return transport
}
/* ---------------------------------------------------------------------------
* raw read/write
* ------------------------------------------------------------------------- */
#getTransport() {
const transport = this.#transport
if (transport === undefined) {
const error = new Error('the nbd client is not connected')
error.code = 'NBD_NOT_CONNECTED'
throw error
}
return transport
}
#read(transport, length) {
const promise = readChunkStrict(transport.readable, length)
return pTimeout.call(promise, this.#messageTimeout)
}
#write(transport, buffer) {
let timeout
const messageTimeout = this.#messageTimeout
const { writable } = transport
return Promise.race([
new Promise((resolve, reject) => {
timeout = setTimeout(() => {
reject(new Error('timeout'))
}, messageTimeout)
}),
new Promise(resolve =>
writable.write(buffer, () => {
clearTimeout(timeout)
resolve()
})
),
])
}
async #readInt32(transport) {
const buffer = await this.#read(transport, 4)
return buffer.readInt32BE(0)
}
async #readInt64(transport) {
const buffer = await this.#read(transport, 8)
return buffer.readBigUInt64BE(0)
}
#writeInt32(transport, int) {
const buffer = Buffer.alloc(4)
buffer.writeInt32BE(int)
return this.#write(transport, buffer)
}
/* ---------------------------------------------------------------------------
* transmission
* ------------------------------------------------------------------------- */
// when one read fail ,stop everything
// (sync: it must be usable from an event handler without leaving a floating promise)
#rejectAll(error) {
this.#commandQueryBacklog.forEach(({ reject }) => {
reject(error)
})
}
async #readBlockResponse() {
// ensure at most one read occur in parallel
if (this.#waitingForResponse) {
return
}
try {
this.#waitingForResponse = true
const transport = this.#getTransport()
const buffer = await this.#read(transport, 16)
const magic = buffer.readInt32BE(0)
if (magic !== NBD_REPLY_MAGIC) {
throw new Error(`magic number for block answer is wrong : ${magic} ${NBD_REPLY_MAGIC}`)
}
const error = buffer.readInt32BE(4)
if (error !== 0) {
// @todo use error code from constants.mjs
throw new Error(`GOT ERROR CODE : ${error}`)
}
const blockQueryId = buffer.readBigUInt64BE(8)
const query = this.#commandQueryBacklog.get(blockQueryId)
if (!query) {
throw new Error(` no query associated with id ${blockQueryId}`)
}
this.#commandQueryBacklog.delete(blockQueryId)
const data = await this.#read(transport, query.size)
query.resolve(data)
this.#waitingForResponse = false
if (this.#commandQueryBacklog.size > 0) {
// it doesn't throw directly but will throw all relevant promise on failure
this.#readBlockResponse()
}
} catch (error) {
// reject all the promises
// we don't need to call readBlockResponse on failure
// since we will empty the backlog
this.#rejectAll(error)
}
}
async #readBlock(index, size) {
// we don't want to add anything in backlog while reconnecting
if (this.#reconnectingPromise) {
await this.#reconnectingPromise
}
const queryId = this.#nextCommandQueryId
this.#nextCommandQueryId++
// create and send command at once to ensure there is no concurrency issue
const buffer = Buffer.alloc(28)
buffer.writeInt32BE(NBD_REQUEST_MAGIC, 0) // it is a nbd request
buffer.writeInt16BE(0, 4) // no command flags for a simple block read
buffer.writeInt16BE(NBD_CMD_READ, 6) // we want to read a data block
buffer.writeBigUInt64BE(queryId, 8)
// byte offset in the raw disk
const offset = BigInt(index) * BigInt(size)
const remaining = this.#exportSize - offset
if (remaining < BigInt(size)) {
size = Number(remaining)
}
buffer.writeBigUInt64BE(offset, 16)
buffer.writeInt32BE(size, 24)
return new Promise((resolve, reject) => {
function decoratedReject(error) {
error.index = index
error.size = size
reject(error)
}
// this will handle one block response, but it can be another block
// since server does not guaranty to handle query in order
this.#commandQueryBacklog.set(queryId, {
size,
resolve,
reject: decoratedReject,
})
// really send the command to the server
this.#write(this.#getTransport(), buffer).catch(decoratedReject)
// #readBlockResponse never throws directly
// but if it fails it will reject all the promises in the backlog
this.#readBlockResponse()
})
}
async readBlock(index, size = NBD_DEFAULT_BLOCK_SIZE) {
return pRetry(() => this.#readBlock(index, size), {
tries: this.#readBlockRetries,
when: error => error.code !== 'ERR_ABORTED',
onRetry: async err => {
warn('will retry reading block ', index, err)
await this.reconnect()
},
})
}
}

View File

@@ -1,199 +0,0 @@
import assert from 'node:assert/strict'
import { PassThrough } from 'node:stream'
import { setTimeout as pSleep } from 'node:timers/promises'
import { describe, it } from 'node:test'
import AbstractNbdClient from './AbstractNbdClient.mjs'
import { serveNbd } from './tests/fake-nbd-server.mjs'
const BLOCK_SIZE = 512
// non aligned size, to check the truncated last block
const DATA = Buffer.alloc(BLOCK_SIZE * 3 + 100)
for (let i = 0; i < DATA.length; i += 4) {
DATA.writeUInt32BE(i, i)
}
// exercises AbstractNbdClient without any I/O: the fake server runs in the same
// process, on the other end of two in-memory streams
class InMemoryNbdClient extends AbstractNbdClient {
#serverOptions
/** @type {Array<object>} */
transports = []
/** @type {Array<object>} */
destroyedTransports = []
secureTransportCalls = 0
constructor({ exportname, ...serverOptions } = {}, options) {
super({ exportname }, options)
this.#serverOptions = serverOptions
}
async _openTransport() {
const toServer = new PassThrough()
const toClient = new PassThrough()
const transport = { readable: toClient, writable: toServer }
this.transports.push(transport)
// the server runs alongside the client, a failure on its side is reported
// to the client the same way a network error would be
serveNbd({ readable: toServer, writable: toClient, data: DATA, ...this.#serverOptions }).catch(error =>
toClient.destroy(error)
)
return transport
}
async _secureTransport(transport) {
this.secureTransportCalls++
return super._secureTransport(transport)
}
_destroyTransport(transport) {
this.destroyedTransports.push(transport)
super._destroyTransport(transport)
}
}
// the first connection is opened on a transport nobody ever answers on: its
// handshake stalls until the message timeout, well after connect() gave up
class StallingFirstConnectClient extends InMemoryNbdClient {
#openCalls = 0
async _openTransport() {
if (this.#openCalls++ === 0) {
const transport = { readable: new PassThrough(), writable: new PassThrough() }
this.transports.push(transport)
return transport
}
return super._openTransport()
}
}
describe('AbstractNbdClient', () => {
it('handshakes and reads blocks', async () => {
const client = new InMemoryNbdClient()
await client.connect()
try {
assert.equal(client.exportSize, BigInt(DATA.length))
assert.equal(client.connected, true)
const block = await client.readBlock(1, BLOCK_SIZE)
assert.ok(block.equals(DATA.subarray(BLOCK_SIZE, 2 * BLOCK_SIZE)))
} finally {
await client.disconnect()
}
assert.equal(client.connected, false)
})
it('truncates the last block of a non aligned export', async () => {
const client = new InMemoryNbdClient()
await client.connect()
try {
const block = await client.readBlock(3, BLOCK_SIZE)
assert.equal(block.length, DATA.length - 3 * BLOCK_SIZE)
assert.ok(block.equals(DATA.subarray(3 * BLOCK_SIZE)))
} finally {
await client.disconnect()
}
})
it('handles answers coming out of order', async () => {
const client = new InMemoryNbdClient({ answerInReverse: true })
await client.connect()
try {
const [first, second] = await Promise.all([client.readBlock(0, BLOCK_SIZE), client.readBlock(1, BLOCK_SIZE)])
assert.ok(first.equals(DATA.subarray(0, BLOCK_SIZE)))
assert.ok(second.equals(DATA.subarray(BLOCK_SIZE, 2 * BLOCK_SIZE)))
} finally {
await client.disconnect()
}
})
it('calls _secureTransport during the handshake', async () => {
const client = new InMemoryNbdClient()
await client.connect()
assert.equal(client.secureTransportCalls, 1)
await client.disconnect()
})
it('does not leak the transport when the handshake fails', async () => {
// the server expects another export name, and fails the handshake
const client = new InMemoryNbdClient({ exportname: 'wrong', exportName: 'expected' })
await assert.rejects(client.connect())
assert.equal(client.connected, false)
assert.equal(client.transports.length, 1)
assert.deepEqual(client.destroyedTransports, client.transports)
assert.equal(client.transports[0].writable.destroyed, true)
})
it('reports the errors answered by the server, after having retried', async () => {
const client = new InMemoryNbdClient(
{ errorCode: () => 5 /* EIO */ },
{ readBlockRetries: 2, reconnectRetry: 1, waitBeforeReconnect: 0 }
)
await client.connect()
try {
await assert.rejects(client.readBlock(0, BLOCK_SIZE), /ERROR CODE/)
// one transport for the initial connection, one for the retry
assert.equal(client.transports.length, 2)
} finally {
await client.disconnect()
}
})
it('does not let a connection which timed out disturb the next one', async () => {
const client = new StallingFirstConnectClient({}, { connectTimeout: 50, messageTimeout: 150 })
await assert.rejects(client.connect())
// the previous attempt is still running: it must not publish itself, nor
// touch the transport of this one
await client.connect()
try {
// let the stalled handshake reach its message timeout
await pSleep(250)
assert.equal(client.connected, true)
assert.equal(client.transports.length, 2)
// only the stalled transport has been destroyed
assert.deepEqual(client.destroyedTransports, [client.transports[0]])
const block = await client.readBlock(0, BLOCK_SIZE)
assert.ok(block.equals(DATA.subarray(0, BLOCK_SIZE)))
} finally {
await client.disconnect()
}
})
it('does not let an error on a superseded transport disturb the current one', async () => {
const client = new InMemoryNbdClient({}, { readBlockRetries: 1, waitBeforeReconnect: 0 })
await client.connect()
await client.reconnect()
try {
assert.equal(client.transports.length, 2)
const [supersededTransport] = client.transports
// the read is queued synchronously, the in memory server answers later
const promise = client.readBlock(0, BLOCK_SIZE)
// the other end of the closed transport resets it late
supersededTransport.readable.emit('error', Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }))
const block = await promise
assert.ok(block.equals(DATA.subarray(0, BLOCK_SIZE)))
// no reconnection has been triggered
assert.equal(client.transports.length, 2)
} finally {
await client.disconnect()
}
})
it('does not support getMap()', async () => {
const client = new InMemoryNbdClient()
await assert.rejects(client.getMap(), ({ code }) => code === 'NBD_MAP_UNSUPPORTED')
})
it('can be disconnected without being connected', async () => {
await new InMemoryNbdClient().disconnect()
})
})

View File

@@ -5,18 +5,12 @@ import NbdClient from '@vates/nbd-client'
* @typedef {{ offset: number, length: number }} DataRange
*/
/**
* any NBD client, whatever transport it uses
*
* @typedef {import('@vates/nbd-client').AbstractNbdClient} AbstractNbdClient
*/
/**
* @extends {RandomAccessDisk}
*/
export class NbdDisk extends RandomAccessDisk {
#nbdInfos
/** @type {AbstractNbdClient|undefined} */
/** @type {NbdClient|undefined} */
#nbdClient
/** @type {Array<DataRange> | undefined} */
@@ -25,63 +19,19 @@ export class NbdDisk extends RandomAccessDisk {
/** @type {number} */
#blockSize
/** @type {number | undefined} */
#hasBlockCursor
/** @type {number | undefined} */
#hasBlockPreviousIndex
/** @type {typeof AbstractNbdClient} */
#ClientClass
/**
* @param {object} nbdInfos - the settings of the client, depends on `ClientClass`
* @param {number} blockSize
* @param {object} [options]
* @param {Array<{offset: number, length: number, type: number}>} [options.dataMap] - computed through `getMap()` when not provided
* @param {typeof AbstractNbdClient} [options.ClientClass] - to talk to a NBD server through another transport
*/
constructor(nbdInfos, blockSize, { dataMap, ClientClass = NbdClient } = {}) {
constructor(nbdInfos, blockSize, { dataMap } = {}) {
super()
this.#blockSize = blockSize
this.#nbdInfos = nbdInfos
this.#ClientClass = ClientClass
this.#dataMap = dataMap && this.#processDatamap(dataMap)
}
#processDatamap(rawDataMap) {
const ranges = rawDataMap
.filter(({ type, length }) => type === 0 && length > 0)
return rawDataMap
.filter(({ type }) => type === 0)
.map(({ offset, length }) => ({ offset, length }))
.sort(({ offset: offset1 }, { offset: offset2 }) => offset1 - offset2)
// hasBlock()'s forward-only cursor is only correct if the extents are
// sorted AND disjoint: an earlier extent must never reach into a block
// after a later one, otherwise the cursor could skip it and wrongly report
// a block as empty, dropping data and corrupting the output.
// Touching extents (cur.offset === prevEnd) are merged
const merged = []
for (const range of ranges) {
const last = merged[merged.length - 1]
if (last !== undefined) {
const lastEnd = last.offset + last.length
if (range.offset < lastEnd) {
throw new Error(
`overlapping ranges in data map: [${last.offset}, ${lastEnd}) and [${range.offset}, ${range.offset + range.length})`
)
}
if (range.offset === lastEnd) {
// touching: extend the previous extent
last.length += range.length
continue
}
}
// gap (or first range): new extent
merged.push(range)
}
return merged
}
/**
* @param {number} index
* @returns {Promise<DiskBlock>}
@@ -128,7 +78,7 @@ export class NbdDisk extends RandomAccessDisk {
* @returns {Promise<void>}
*/
async init() {
this.#nbdClient = new this.#ClientClass(this.#nbdInfos)
this.#nbdClient = new NbdClient(this.#nbdInfos)
await this.#nbdClient.connect()
if (this.#dataMap === undefined) {
this.#dataMap = this.#processDatamap(await this.#nbdClient.getMap())
@@ -167,34 +117,6 @@ export class NbdDisk extends RandomAccessDisk {
return [...indexes].sort((a, b) => a - b)
}
/**
* Counts the allocated blocks without materializing the index list.
*
* @returns {number}
*/
getBlockIndexesCount() {
if (!this.#dataMap) {
throw new Error("can't getBlockIndexesCount before init")
}
const blockSize = this.getBlockSize()
let count = 0
let lastCountedBlock = -1
for (const { offset, length } of this.#dataMap) {
const firstBlockIndex = Math.floor(offset / blockSize)
const lastBlockIndex = Math.floor((offset + length - 1) / blockSize)
const from = Math.max(firstBlockIndex, lastCountedBlock + 1)
if (lastBlockIndex >= from) {
count += lastBlockIndex - from + 1
lastCountedBlock = lastBlockIndex
}
}
return count
}
/**
* @param {number} index
* @returns {boolean}
@@ -203,32 +125,8 @@ export class NbdDisk extends RandomAccessDisk {
if (!this.#dataMap) {
throw new Error("can't hasBlock before init")
}
const blockSize = this.getBlockSize()
const blockStart = index * blockSize
const blockEnd = blockStart + blockSize
let startExtentIndex = 0
if (this.#hasBlockCursor !== undefined && index >= this.#hasBlockPreviousIndex) {
startExtentIndex = this.#hasBlockCursor
} else {
this.#hasBlockCursor = undefined
}
this.#hasBlockPreviousIndex = index
const dataMap = this.#dataMap
const l = dataMap.length
for (let i = startExtentIndex; i < l; i++) {
const { offset, length } = dataMap[i]
if (offset >= blockEnd) {
// extents are sorted: nothing from here on can overlap this block
break
}
if (offset + length > blockStart) {
this.#hasBlockCursor = i
return true
}
}
return false
const blockStart = index * this.getBlockSize()
const blockEnd = (index + 1) * this.getBlockSize()
return this.#dataMap.some(({ offset, length }) => offset + length > blockStart && offset < blockEnd)
}
}

View File

@@ -1,127 +0,0 @@
import { describe, it } from 'node:test'
import assert from 'node:assert'
import { NbdDisk } from './NbdDisk.mjs'
const KiB = 1024
const MiB = 1024 * KiB
const TiB = 1024 * 1024 * MiB
const SOURCE_BLOCK_SIZE = 2 * MiB // VHD_BLOCK_SIZE, the block size used when importing from VMware
const CLUSTER_SIZE = 64 * KiB // qcow2 cluster size
// hasBlock() only needs the dataMap (passed through the constructor) and the
// block size, so we can exercise it without a live NBD connection.
describe('NbdDisk.hasBlock', () => {
it('reports allocated and unallocated blocks correctly', () => {
const dataMap = [
{ type: 0, offset: 0, length: SOURCE_BLOCK_SIZE }, // block 0
{ type: 0, offset: 5 * SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE }, // block 5
]
const disk = new NbdDisk({}, SOURCE_BLOCK_SIZE, { dataMap })
assert.strictEqual(disk.hasBlock(0), true)
assert.strictEqual(disk.hasBlock(1), false)
assert.strictEqual(disk.hasBlock(4), false)
assert.strictEqual(disk.hasBlock(5), true)
assert.strictEqual(disk.hasBlock(6), false)
})
it('handles repeated and out-of-order queries with the resume cursor', () => {
const dataMap = [
{ type: 0, offset: 0, length: SOURCE_BLOCK_SIZE }, // block 0
{ type: 0, offset: 5 * SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE }, // block 5
]
const disk = new NbdDisk({}, SOURCE_BLOCK_SIZE, { dataMap })
// repeated indexes (the qcow2 path queries each 2MB block ~32 times as it
// walks 64KB clusters) must return a stable answer
assert.strictEqual(disk.hasBlock(5), true)
assert.strictEqual(disk.hasBlock(5), true)
assert.strictEqual(disk.hasBlock(6), false)
assert.strictEqual(disk.hasBlock(6), false)
// querying backwards must still be correct (falls back to a full scan)
assert.strictEqual(disk.hasBlock(0), true)
assert.strictEqual(disk.hasBlock(5), true)
})
it('invalidates the cursor on a backward miss (no stale skip)', () => {
const dataMap = [
{ type: 0, offset: 3 * SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE }, // block 3
{ type: 0, offset: 10 * SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE }, // block 10
]
const disk = new NbdDisk({}, SOURCE_BLOCK_SIZE, { dataMap })
assert.strictEqual(disk.hasBlock(10), true) // forward hit: cursor parks on the high extent
assert.strictEqual(disk.hasBlock(1), false) // backward miss: must invalidate the cursor
assert.strictEqual(disk.hasBlock(3), true) // lower extent must not be skipped
})
it('throws on overlapping ranges (would break the resume cursor)', () => {
const dataMap = [
{ type: 0, offset: 0, length: SOURCE_BLOCK_SIZE },
{ type: 0, offset: SOURCE_BLOCK_SIZE / 2, length: SOURCE_BLOCK_SIZE }, // overlaps the previous range
]
assert.throws(() => new NbdDisk({}, SOURCE_BLOCK_SIZE, { dataMap }), /overlapping ranges/)
})
it('merges touching ranges and drops zero-length / non type-0 entries', () => {
const dataMap = [
{ type: 0, offset: 2 * SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE }, // [2, 3)
{ type: 0, offset: 0, length: SOURCE_BLOCK_SIZE / 2 }, // [0, .5)
{ type: 0, offset: SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE }, // [1, 2)
{ type: 0, offset: SOURCE_BLOCK_SIZE / 2, length: SOURCE_BLOCK_SIZE / 2 }, // [.5, 1), touches [0, .5)
{ type: 1, offset: 10 * SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE }, // wrong type: ignored
{ type: 0, offset: 7 * SOURCE_BLOCK_SIZE, length: 0 }, // zero length: ignored
]
const disk = new NbdDisk({}, SOURCE_BLOCK_SIZE, { dataMap })
assert.strictEqual(disk.hasBlock(0), true)
assert.strictEqual(disk.hasBlock(1), true)
assert.strictEqual(disk.hasBlock(2), true)
assert.strictEqual(disk.hasBlock(3), false)
assert.strictEqual(disk.hasBlock(7), false) // zero-length range dropped
assert.strictEqual(disk.hasBlock(10), false) // non type-0 range dropped
})
})
describe('NbdDisk.getBlockIndexesCount', () => {
const cases = {
'aligned, disjoint blocks': [
{ type: 0, offset: 0, length: SOURCE_BLOCK_SIZE }, // block 0
{ type: 0, offset: 5 * SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE }, // block 5
],
'a range spanning several blocks': [
{ type: 0, offset: SOURCE_BLOCK_SIZE / 2, length: 3 * SOURCE_BLOCK_SIZE }, // blocks 0,1,2,3
],
'two disjoint extents sharing block 0 (needs dedup)': [
{ type: 0, offset: 0, length: SOURCE_BLOCK_SIZE / 2 }, // [0, .5) => block 0
{ type: 0, offset: 3 * (SOURCE_BLOCK_SIZE / 4), length: SOURCE_BLOCK_SIZE }, // [.75, 1.75) => blocks 0,1
],
'touching sub-block fragments': [
{ type: 0, offset: 0, length: SOURCE_BLOCK_SIZE / 2 },
{ type: 0, offset: SOURCE_BLOCK_SIZE / 2, length: SOURCE_BLOCK_SIZE / 2 },
{ type: 0, offset: SOURCE_BLOCK_SIZE, length: SOURCE_BLOCK_SIZE },
],
empty: [],
}
for (const [name, dataMap] of Object.entries(cases)) {
it(`matches getBlockIndexes().length: ${name}`, () => {
const disk = new NbdDisk({}, SOURCE_BLOCK_SIZE, { dataMap })
assert.strictEqual(disk.getBlockIndexesCount(), disk.getBlockIndexes().length)
})
}
it('matches getBlockIndexes().length on the large sparse map', () => {
const virtualSize = Math.floor(3.5 * TiB)
const NB_RANGES = 113528
const dataMap = []
for (let i = 0; i < NB_RANGES; i++) {
dataMap.push({ type: 0, offset: Math.floor((i / NB_RANGES) * virtualSize), length: CLUSTER_SIZE })
}
const disk = new NbdDisk({}, SOURCE_BLOCK_SIZE, { dataMap })
assert.strictEqual(disk.getBlockIndexesCount(), disk.getBlockIndexes().length)
})
})

View File

@@ -1,157 +0,0 @@
import assert from 'node:assert'
import { spawn } from 'node:child_process'
import { pFromCallback } from 'promise-toolbox'
import { createLogger } from '@xen-orchestra/log'
import AbstractNbdClient from './AbstractNbdClient.mjs'
const { warn } = createLogger('vates:nbd-client:stdio')
// only the tail is kept: it's the part describing why the server died
const STDERR_MAX_LENGTH = 4096
/**
* NBD client talking to a server through its standard streams, like
* `nbdkit -s` or `qemu-nbd` in `--fd` mode: the queries are written to the
* server stdin and its answers are read from its stdout.
*
* The process is spawned on `connect()` and killed on `disconnect()`, and
* `reconnect()` (used by `readBlock()` retries) spawns a brand new one: the
* server must be restartable, and must serve the same content on each run.
*
* There is no TLS here: the transport is a pair of pipes, nothing to encrypt.
*
* @extends {AbstractNbdClient}
*/
export default class NbdStdioClient extends AbstractNbdClient {
#args
#command
#cwd
#env
/** @type {{ child: import('node:child_process').ChildProcess, closing: boolean, stderr: string }|undefined} */
#state
/**
* @param {object} settings
* @param {string} settings.command - the NBD server to run
* @param {ReadonlyArray<string>} [settings.args] - its arguments
* @param {string} [settings.exportname] - empty (the default export) for most single connection servers
* @param {NodeJS.ProcessEnv} [settings.env]
* @param {string} [settings.cwd]
* @param {object} [options] - see {@link AbstractNbdClient}
*/
constructor({ command, args = [], exportname, env, cwd }, options) {
super({ exportname }, options)
assert.strictEqual(typeof command, 'string', 'command must be a string')
this.#command = command
this.#args = args
this.#env = env
this.#cwd = cwd
}
/**
* the tail of what the server wrote on stderr since it was spawned, useful to
* report why it failed
*
* @returns {string}
*/
get stderr() {
return this.#state?.stderr ?? ''
}
/**
* the pid of the current server process, undefined if it's not running
*
* @returns {number|undefined}
*/
get pid() {
return this.#state?.child.pid
}
async _openTransport() {
const child = spawn(this.#command, this.#args, {
cwd: this.#cwd,
env: this.#env,
// the protocol goes through stdin/stdout, stderr is only used for diagnostics
stdio: ['pipe', 'pipe', 'pipe'],
})
const state = { child, closing: false, stderr: '' }
this.#state = state
child.stderr.setEncoding('utf8')
child.stderr.on('data', chunk => {
state.stderr = (state.stderr + chunk).slice(-STDERR_MAX_LENGTH)
})
// spawn failures (ENOENT, EACCES, ...) are reported asynchronously
await new Promise((resolve, reject) => {
const onError = error => {
error.command = this.#command
reject(error)
}
child.once('error', onError)
child.once('spawn', () => {
child.removeListener('error', onError)
// from now on, an error on the process itself (a failed kill for
// example) must not be thrown as an uncaught exception
child.on('error', error => warn('error on the nbd server process', { error, command: this.#command }))
resolve()
})
})
child.once('exit', (code, signal) => {
if (state.closing) {
// expected: disconnect() asked it to quit and is waiting for this
return
}
const error = new Error(`the nbd server process exited (code: ${code}, signal: ${signal})`)
error.code = 'NBD_SERVER_EXITED'
error.exitCode = code
error.signal = signal
error.command = this.#command
error.stderr = state.stderr
warn('the nbd server process exited unexpectedly', { error })
// make the pending and future reads fail with this error instead of
// waiting for the message timeout on a stream that will never answer
child.stdout.destroy(error)
child.stdin.destroy()
})
return { readable: child.stdout, writable: child.stdin, state }
}
async _closeTransport(transport, lastMessage) {
const { state } = transport
state.closing = true
// hand over NBD_CMD_DISC, then close stdin: that's how the server knows
// it can quit
await pFromCallback(cb => transport.writable.end(lastMessage, cb))
// don't let the server block on a write while we wait for its exit
transport.readable.resume()
const { child } = state
if (child.exitCode === null && child.signalCode === null) {
await new Promise(resolve => child.once('exit', () => resolve()))
}
}
_destroyTransport(transport) {
const { state } = transport
const { child } = state
// we're the one tearing it down: the exit that follows is expected, not a
// crash, and destroying the streams can be enough to make the server quit
state.closing = true
transport.readable.destroy()
transport.writable.destroy()
if (child.exitCode === null && child.signalCode === null) {
child.kill('SIGKILL')
}
}
// getMap() is not implemented: `nbdinfo` needs an URI or a server supporting
// systemd socket activation, and this one only speaks through its standard
// streams. Compute the map on the caller side and pass it explicitly (
// `new NbdDisk(infos, blockSize, { dataMap })`).
}

View File

@@ -1,197 +0,0 @@
import { once } from 'node:events'
import { request as httpRequest } from 'node:http'
import { request as httpsRequest } from 'node:https'
import { isIPv6, Socket } from 'node:net'
import { connect } from 'node:tls'
import { spawn } from 'node:child_process'
import AbstractNbdClient from './AbstractNbdClient.mjs'
import { NBD_DEFAULT_PORT, NBD_OPT_STARTTLS } from './constants.mjs'
/**
* NBD client talking to a server over TCP, optionally upgrading the connection
* to TLS during the handshake.
*
* @extends {AbstractNbdClient}
*/
export default class NbdTcpClient extends AbstractNbdClient {
#httpProxy
#proxyRejectUnauthorized
#proxyTimeout
#serverAddress
#serverCert
#serverPort
/**
* @param {object} settings
* @param {string} settings.address
* @param {number} [settings.port]
* @param {string} [settings.exportname]
* @param {string} [settings.cert] - PEM certificate, enables TLS when set
* @param {string} [settings.httpProxy] - URL of an HTTP(S) proxy, the connection is tunneled through it with `CONNECT`
* @param {boolean} [settings.proxyRejectUnauthorized=true] - whether to check the certificate of an HTTPS proxy
* @param {number} [settings.proxyTimeout=6e4] - delay in ms after which an unanswered `CONNECT` is aborted
* @param {object} [options] - see {@link AbstractNbdClient}
*/
constructor(
{
address,
port = NBD_DEFAULT_PORT,
exportname,
cert,
httpProxy,
proxyRejectUnauthorized = true,
proxyTimeout = 6e4,
},
options
) {
super({ exportname }, options)
// other proxy protocols (e.g. SOCKS) are not supported for now
if (httpProxy !== undefined) {
const { protocol } = new URL(httpProxy)
if (protocol !== 'http:' && protocol !== 'https:') {
const error = new Error(`unsupported proxy protocol ${protocol}, only http: and https: are supported`)
error.code = 'NBD_PROXY_UNSUPPORTED_PROTOCOL'
throw error
}
}
this.#serverAddress = address
this.#serverPort = port
this.#serverCert = cert
this.#httpProxy = httpProxy
this.#proxyRejectUnauthorized = proxyRejectUnauthorized
this.#proxyTimeout = proxyTimeout
}
/**
* Open a raw TCP tunnel to the NBD server through an HTTP proxy
*
* https://datatracker.ietf.org/doc/html/rfc9110#section-9.3.6
*
* @returns {Promise<import('node:net').Socket>}
*/
async #connectThroughHttpProxy() {
const proxy = new URL(this.#httpProxy)
const isHttps = proxy.protocol === 'https:'
const address = this.#serverAddress
const target = `${isIPv6(address) ? `[${address}]` : address}:${this.#serverPort}`
const headers = { host: target }
if (proxy.username !== '' || proxy.password !== '') {
const credentials = `${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`
headers['proxy-authorization'] = `Basic ${Buffer.from(credentials).toString('base64')}`
}
const req = (isHttps ? httpsRequest : httpRequest)({
agent: false,
headers,
// URL keeps the brackets around IPv6 addresses
hostname: proxy.hostname.replace(/^\[(.*)\]$/, '$1'),
method: 'CONNECT',
path: target,
port: proxy.port,
rejectUnauthorized: this.#proxyRejectUnauthorized,
timeout: this.#proxyTimeout,
})
// `timeout` only emits an event, the request must be aborted explicitly
req.on('timeout', () => {
const error = new Error(`HTTP proxy did not answer the CONNECT to ${target} in time`)
error.code = 'NBD_PROXY_CONNECT_TIMEOUT'
req.destroy(error)
})
req.end()
// the NBD server may already have sent some data, it is in `head`
const [res, socket, head] = await once(req, 'connect')
// the tunnel is established, NBD has its own timeouts
socket.setTimeout(0)
if (res.statusCode !== 200) {
socket.destroy()
const error = new Error(`HTTP proxy refused to connect to ${target}: ${res.statusCode} ${res.statusMessage}`)
error.code = 'NBD_PROXY_CONNECT_FAILED'
throw error
}
if (head.length !== 0) {
socket.unshift(head)
}
return socket
}
// mandatory , at least to start the handshake: the connection always starts
// unsecured, and is upgraded to TLS during the handshake
async _openTransport() {
if (this.#httpProxy !== undefined) {
const socket = await this.#connectThroughHttpProxy()
return { readable: socket, writable: socket }
}
const socket = new Socket()
await new Promise((resolve, reject) => {
socket.connect(this.#serverPort, this.#serverAddress)
socket.once('error', reject)
socket.once('connect', () => {
socket.removeListener('error', reject)
resolve()
})
})
return { readable: socket, writable: socket }
}
async _secureTransport(transport) {
if (this.#serverCert === undefined) {
return transport
}
await this._sendOption(transport, NBD_OPT_STARTTLS)
const secured = await new Promise((resolve, reject) => {
const socket = connect({
socket: transport.writable,
rejectUnauthorized: false,
cert: this.#serverCert,
})
socket.once('error', reject)
socket.once('secureConnect', () => {
socket.removeListener('error', reject)
resolve(socket)
})
})
return { readable: secured, writable: secured }
}
/**
* @param {AbortSignal} [signal]
* @returns {Promise<{ offset: number, length: number, type: number }[]>}
*/
/* async */ getMap(signal) {
return new Promise((resolve, reject) => {
const process = spawn('nbdinfo', [
'--json',
'--map',
`nbd://${this.#serverAddress}:${this.#serverPort}/${encodeURIComponent(this.exportName)}`,
])
let text = ''
let errText = ''
process.stdout.on('data', data => (text += data))
process.stderr.on('data', data => (errText += data))
const onAbort = () => {
process.kill()
reject(signal.reason)
}
signal?.addEventListener('abort', onAbort, { once: true })
process.on('error', err => {
signal?.removeEventListener('abort', onAbort)
reject(err)
})
process.on('close', code => {
signal?.removeEventListener('abort', onAbort)
if (code !== 0) {
return reject(new Error(`Error during getMap (code: ${code}): ${errText}`))
}
try {
const json = JSON.parse(text)
resolve(json)
} catch (error) {
reject(new Error(`${error} ${text}`))
}
})
})
}
}

View File

@@ -1,107 +0,0 @@
import assert from 'node:assert/strict'
import { after, before, describe, it } from 'node:test'
import { createServer as createHttpServer } from 'node:http'
import { connect, createServer as createTcpServer } from 'node:net'
import { once } from 'node:events'
import NbdTcpClient from './NbdTcpClient.mjs'
import { serveNbd } from './tests/fake-nbd-server.mjs'
const BLOCK_SIZE = 64 * 1024
const DATA = Buffer.alloc(4 * BLOCK_SIZE)
for (let i = 0; i < DATA.length; i++) {
DATA[i] = i % 251
}
const CREDENTIALS = 'Basic ' + Buffer.from('user:p@ss').toString('base64')
describe('NbdTcpClient through an HTTP proxy', () => {
let nbdServer, proxyServer, nbdPort, proxyPort
const connectRequests = []
before(async () => {
nbdServer = createTcpServer(socket => {
serveNbd({ readable: socket, writable: socket, data: DATA }).catch(() => {})
})
nbdServer.listen(0, '127.0.0.1')
await once(nbdServer, 'listening')
nbdPort = nbdServer.address().port
// minimal CONNECT proxy, similar to the XO Proxy one
proxyServer = createHttpServer()
proxyServer.on('connect', (req, clientSocket, head) => {
connectRequests.push(req.url)
if (req.headers['proxy-authorization'] !== CREDENTIALS) {
clientSocket.end('HTTP/1.1 407 Proxy Authentication Required\r\n\r\n')
return
}
const { hostname, port } = new URL('http://' + req.url)
const serverSocket = connect(port, hostname, () => {
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
serverSocket.write(head)
serverSocket.pipe(clientSocket).pipe(serverSocket)
})
serverSocket.on('error', () => clientSocket.destroy())
clientSocket.on('error', () => serverSocket.destroy())
})
proxyServer.listen(0, '127.0.0.1')
await once(proxyServer, 'listening')
proxyPort = proxyServer.address().port
})
after(() => {
proxyServer.closeAllConnections()
proxyServer.close()
nbdServer.close()
})
it('tunnels the NBD connection with CONNECT', async () => {
const client = new NbdTcpClient({
address: '127.0.0.1',
port: nbdPort,
httpProxy: `http://user:p%40ss@127.0.0.1:${proxyPort}`,
})
await client.connect()
try {
assert.equal(client.exportSize, BigInt(DATA.length))
const block = await client.readBlock(2, BLOCK_SIZE)
assert.deepEqual(block, DATA.subarray(2 * BLOCK_SIZE, 3 * BLOCK_SIZE))
} finally {
await client.disconnect()
}
assert.equal(connectRequests.at(-1), `127.0.0.1:${nbdPort}`)
})
it('fails when the proxy refuses the connection', async () => {
const client = new NbdTcpClient({
address: '127.0.0.1',
port: nbdPort,
httpProxy: `http://user:wrong@127.0.0.1:${proxyPort}`,
})
await assert.rejects(client.connect(), { code: 'NBD_PROXY_CONNECT_FAILED' })
await client.disconnect().catch(() => {})
})
it('aborts the CONNECT when the proxy does not answer', async () => {
// accepts the TCP connection but never answers
const silentProxy = createTcpServer(() => {})
silentProxy.listen(0, '127.0.0.1')
await once(silentProxy, 'listening')
try {
const client = new NbdTcpClient({
address: '127.0.0.1',
port: nbdPort,
httpProxy: `http://127.0.0.1:${silentProxy.address().port}`,
proxyTimeout: 50,
})
await assert.rejects(client.connect(), { code: 'NBD_PROXY_CONNECT_TIMEOUT' })
} finally {
silentProxy.close()
}
})
it('rejects unsupported proxy protocols', () => {
assert.throws(() => new NbdTcpClient({ address: '127.0.0.1', httpProxy: 'socks5://127.0.0.1:1080' }), {
code: 'NBD_PROXY_UNSUPPORTED_PROTOCOL',
})
})
})

View File

@@ -14,15 +14,9 @@ npm install --save @vates/nbd-client
## Usage
This package provides a NBD client, in two flavors sharing the same protocol
implementation (`AbstractNbdClient`): over the network, or over the standard
streams of a NBD server run as a child process.
### `new NdbClient({address, exportname, secure = true, port = 10809})`
### `new NbdClient({address, exportname, cert, port = 10809})`
Create a new nbd client connecting to a server over TCP.
`NbdClient` is also exported as `NbdTcpClient`.
create a new nbd client
```js
import NbdClient from '@vates/nbd-client'
@@ -37,58 +31,6 @@ const block = await client.readBlock(blockIndex, BlockSize)
await client.disconnect()
```
### `new NbdStdioClient({command, args, exportname})`
Create a new nbd client talking to a server through its standard streams, like
`nbdkit --single` or any server reading its queries on stdin and writing its
answers on stdout.
The process is spawned by `connect()` and stopped by `disconnect()`.
```js
import { NbdStdioClient } from '@vates/nbd-client'
const client = new NbdStdioClient({
command: 'nbdkit',
args: ['--single', '--exit-with-parent', '--read-only', 'file', '/path/to/disk.raw'],
exportname: '', // optional, the default export of the server
})
await client.connect()
const block = await client.readBlock(blockIndex, BlockSize)
await client.disconnect()
```
There is no TLS here: the transport is a pair of pipes, there is nothing to
encrypt.
`readBlock()` retries on error, and a retry reconnects: with this client that
means killing the server and spawning a new one, so the command must be
restartable and must serve the same content on each run.
`getMap()` is not implemented, since `nbdinfo` needs either an URI or a server
supporting systemd socket activation: compute the map on the caller side and
pass it explicitly, for example `new NbdDisk(infos, blockSize, { dataMap })`.
The tail of the server stderr is available as `client.stderr` and attached to
the errors raised when the server dies unexpectedly.
### Writing another client
Subclass `AbstractNbdClient` and implement the transport:
- `_openTransport()`: **required**, returns `{ readable, writable }`, called on
every (re)connection.
- `_secureTransport(transport)`: optional, called during the handshake, the only
place where the transport can be upgraded (`NBD_OPT_STARTTLS`).
- `_closeTransport(transport, lastMessage)`: optional, must hand over
`lastMessage` (`NBD_CMD_DISC`) before closing.
- `_destroyTransport(transport)`: optional, forceful cleanup, must not throw.
- `getMap(signal)`: optional, the base implementation rejects with the code
`NBD_MAP_UNSUPPORTED`.
Any extra property set on the transport object is passed back to
`_closeTransport()` and `_destroyTransport()`.
## Contributions
Contributions are _very_ welcomed, either on the documentation or on

View File

@@ -1,6 +1,398 @@
// the TCP client is the historical default export of this package
export { default } from './NbdTcpClient.mjs'
import assert from 'node:assert'
import { Socket } from 'node:net'
import { connect } from 'node:tls'
import { pRetry, pDelay, pTimeout, pFromCallback } from 'promise-toolbox'
import { readChunkStrict } from '@vates/read-chunk'
import { createLogger } from '@xen-orchestra/log'
import {
INIT_PASSWD,
NBD_CMD_READ,
NBD_DEFAULT_BLOCK_SIZE,
NBD_DEFAULT_PORT,
NBD_FLAG_FIXED_NEWSTYLE,
NBD_FLAG_HAS_FLAGS,
NBD_OPT_EXPORT_NAME,
NBD_OPT_REPLY_MAGIC,
NBD_OPT_STARTTLS,
NBD_REPLY_ACK,
NBD_REPLY_MAGIC,
NBD_REQUEST_MAGIC,
OPTS_MAGIC,
NBD_CMD_DISC,
} from './constants.mjs'
import { spawn } from 'node:child_process'
export { default as AbstractNbdClient } from './AbstractNbdClient.mjs'
export { default as NbdStdioClient } from './NbdStdioClient.mjs'
export { default as NbdTcpClient } from './NbdTcpClient.mjs'
const { warn } = createLogger('vates:nbd-client')
// documentation is here : https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md
export default class NbdClient {
#serverAddress
#serverCert
#serverPort
#serverSocket
#exportName
#exportSize
#waitBeforeReconnect
#readBlockRetries
#reconnectRetry
#connectTimeout
#messageTimeout
// AFAIK, there is no guaranty the server answers in the same order as the queries
// so we handle a backlog of command waiting for response and handle concurrency manually
#waitingForResponse // there is already a listener waiting for a response
#nextCommandQueryId = BigInt(0)
#commandQueryBacklog // map of command waiting for a response queryId => { size/*in byte*/, resolve, reject}
#connected = false
#reconnectingPromise
constructor(
{ address, port = NBD_DEFAULT_PORT, exportname, cert },
{
connectTimeout = 6e4,
messageTimeout = 6e4,
waitBeforeReconnect = 1e3,
readBlockRetries = 5,
reconnectRetry = 5,
} = {}
) {
this.#serverAddress = address
this.#serverPort = port
this.#exportName = exportname
this.#serverCert = cert
this.#waitBeforeReconnect = waitBeforeReconnect
this.#readBlockRetries = readBlockRetries
this.#reconnectRetry = reconnectRetry
this.#connectTimeout = connectTimeout
this.#messageTimeout = messageTimeout
}
get exportSize() {
return this.#exportSize
}
async #tlsConnect() {
return new Promise((resolve, reject) => {
this.#serverSocket = connect({
socket: this.#serverSocket,
rejectUnauthorized: false,
cert: this.#serverCert,
})
this.#serverSocket.once('error', reject)
this.#serverSocket.once('secureConnect', () => {
this.#serverSocket.removeListener('error', reject)
resolve()
})
})
}
// mandatory , at least to start the handshake
async #unsecureConnect() {
this.#serverSocket = new Socket()
return new Promise((resolve, reject) => {
this.#serverSocket.connect(this.#serverPort, this.#serverAddress)
this.#serverSocket.once('error', reject)
this.#serverSocket.once('connect', () => {
this.#serverSocket.removeListener('error', reject)
resolve()
})
})
}
async #connect() {
// first we connect to the server without tls, and then we upgrade the connection
// to tls during the handshake
await this.#unsecureConnect()
await this.#handshake()
this.#connected = true
// reset internal state if we reconnected a nbd client
this.#commandQueryBacklog = new Map()
this.#waitingForResponse = false
}
async connect() {
return pTimeout.call(this.#connect(), this.#connectTimeout)
}
async disconnect() {
if (!this.#connected) {
return
}
this.#connected = false
const socket = this.#serverSocket
const queryId = this.#nextCommandQueryId
this.#nextCommandQueryId++
const buffer = Buffer.alloc(28)
buffer.writeInt32BE(NBD_REQUEST_MAGIC, 0) // it is a nbd request
buffer.writeInt16BE(0, 4) // no command flags for a disconnect
buffer.writeInt16BE(NBD_CMD_DISC, 6) // we want to disconnect from nbd server
buffer.writeBigUInt64BE(queryId, 8)
buffer.writeBigUInt64BE(0n, 16)
buffer.writeInt32BE(0, 24)
const promise = pFromCallback(cb => {
socket.end(buffer, 'utf8', cb)
})
try {
await pTimeout.call(promise, this.#messageTimeout)
} catch (error) {
socket.destroy()
}
this.#serverSocket = undefined
this.#connected = false
}
#clearReconnectPromise = () => {
this.#reconnectingPromise = undefined
}
async #reconnect() {
await this.disconnect().catch(() => {})
await pDelay(this.#waitBeforeReconnect) // need to let the xapi clean things on its side
await this.connect()
}
async reconnect() {
// we need to ensure reconnections do not occur in parallel
if (this.#reconnectingPromise === undefined) {
this.#reconnectingPromise = pRetry(() => this.#reconnect(), {
tries: this.#reconnectRetry,
})
this.#reconnectingPromise.then(this.#clearReconnectPromise, this.#clearReconnectPromise)
}
return this.#reconnectingPromise
}
// we can use individual read/write from the socket here since there is no concurrency
async #sendOption(option, buffer = Buffer.alloc(0)) {
await this.#write(OPTS_MAGIC)
await this.#writeInt32(option)
await this.#writeInt32(buffer.length)
await this.#write(buffer)
assert.strictEqual(await this.#readInt64(), NBD_OPT_REPLY_MAGIC) // magic number everywhere
assert.strictEqual(await this.#readInt32(), option) // the option passed
assert.strictEqual(await this.#readInt32(), NBD_REPLY_ACK) // ACK
const length = await this.#readInt32()
assert.strictEqual(length, 0) // length
}
// we can use individual read/write from the socket here since there is only one handshake at once, no concurrency
async #handshake() {
assert((await this.#read(8)).equals(INIT_PASSWD))
assert((await this.#read(8)).equals(OPTS_MAGIC))
const flagsBuffer = await this.#read(2)
const flags = flagsBuffer.readInt16BE(0)
assert.strictEqual(flags & NBD_FLAG_FIXED_NEWSTYLE, NBD_FLAG_FIXED_NEWSTYLE) // only FIXED_NEWSTYLE one is supported from the server options
await this.#writeInt32(NBD_FLAG_FIXED_NEWSTYLE) // client also support NBD_FLAG_C_FIXED_NEWSTYLE
if (this.#serverCert !== undefined) {
// upgrade socket to TLS if needed
await this.#sendOption(NBD_OPT_STARTTLS)
await this.#tlsConnect()
}
// send export name we want to access.
// it's implicitly closing the negotiation phase.
await this.#write(OPTS_MAGIC)
await this.#writeInt32(NBD_OPT_EXPORT_NAME)
const exportNameBuffer = Buffer.from(this.#exportName)
await this.#writeInt32(exportNameBuffer.length)
await this.#write(exportNameBuffer)
// 8 (export size ) + 2 (flags) + 124 zero = 134
// must read all to ensure nothing stays in the buffer
const answer = await this.#read(134)
this.#exportSize = answer.readBigUInt64BE(0)
const transmissionFlags = answer.readInt16BE(8)
assert.strictEqual(transmissionFlags & NBD_FLAG_HAS_FLAGS, NBD_FLAG_HAS_FLAGS, 'NBD_FLAG_HAS_FLAGS') // must always be 1 by the norm
// note : xapi server always send NBD_FLAG_READ_ONLY (3) as a flag
}
#read(length) {
const promise = readChunkStrict(this.#serverSocket, length)
return pTimeout.call(promise, this.#messageTimeout)
}
#write(buffer) {
let timeout
const messageTimeout = this.#messageTimeout
const socket = this.#serverSocket
return Promise.race([
new Promise((resolve, reject) => {
timeout = setTimeout(() => {
reject(new Error('timeout'))
}, messageTimeout)
}),
new Promise(resolve =>
socket.write(buffer, () => {
clearTimeout(timeout)
resolve()
})
),
])
}
async #readInt32() {
const buffer = await this.#read(4)
return buffer.readInt32BE(0)
}
async #readInt64() {
const buffer = await this.#read(8)
return buffer.readBigUInt64BE(0)
}
#writeInt32(int) {
const buffer = Buffer.alloc(4)
buffer.writeInt32BE(int)
return this.#write(buffer)
}
// when one read fail ,stop everything
async #rejectAll(error) {
this.#commandQueryBacklog.forEach(({ reject }) => {
reject(error)
})
}
async #readBlockResponse() {
// ensure at most one read occur in parallel
if (this.#waitingForResponse) {
return
}
try {
this.#waitingForResponse = true
const buffer = await this.#read(16)
const magic = buffer.readInt32BE(0)
if (magic !== NBD_REPLY_MAGIC) {
throw new Error(`magic number for block answer is wrong : ${magic} ${NBD_REPLY_MAGIC}`)
}
const error = buffer.readInt32BE(4)
if (error !== 0) {
// @todo use error code from constants.mjs
throw new Error(`GOT ERROR CODE : ${error}`)
}
const blockQueryId = buffer.readBigUInt64BE(8)
const query = this.#commandQueryBacklog.get(blockQueryId)
if (!query) {
throw new Error(` no query associated with id ${blockQueryId}`)
}
this.#commandQueryBacklog.delete(blockQueryId)
const data = await this.#read(query.size)
query.resolve(data)
this.#waitingForResponse = false
if (this.#commandQueryBacklog.size > 0) {
// it doesn't throw directly but will throw all relevant promise on failure
this.#readBlockResponse()
}
} catch (error) {
// reject all the promises
// we don't need to call readBlockResponse on failure
// since we will empty the backlog
await this.#rejectAll(error)
}
}
async #readBlock(index, size) {
// we don't want to add anything in backlog while reconnecting
if (this.#reconnectingPromise) {
await this.#reconnectingPromise
}
const queryId = this.#nextCommandQueryId
this.#nextCommandQueryId++
// create and send command at once to ensure there is no concurrency issue
const buffer = Buffer.alloc(28)
buffer.writeInt32BE(NBD_REQUEST_MAGIC, 0) // it is a nbd request
buffer.writeInt16BE(0, 4) // no command flags for a simple block read
buffer.writeInt16BE(NBD_CMD_READ, 6) // we want to read a data block
buffer.writeBigUInt64BE(queryId, 8)
// byte offset in the raw disk
const offset = BigInt(index) * BigInt(size)
const remaining = this.#exportSize - offset
if (remaining < BigInt(size)) {
size = Number(remaining)
}
buffer.writeBigUInt64BE(offset, 16)
buffer.writeInt32BE(size, 24)
return new Promise((resolve, reject) => {
function decoratedReject(error) {
error.index = index
error.size = size
reject(error)
}
// this will handle one block response, but it can be another block
// since server does not guaranty to handle query in order
this.#commandQueryBacklog.set(queryId, {
size,
resolve,
reject: decoratedReject,
})
// really send the command to the server
this.#write(buffer).catch(decoratedReject)
// #readBlockResponse never throws directly
// but if it fails it will reject all the promises in the backlog
this.#readBlockResponse()
})
}
async readBlock(index, size = NBD_DEFAULT_BLOCK_SIZE) {
return pRetry(() => this.#readBlock(index, size), {
tries: this.#readBlockRetries,
onRetry: async err => {
warn('will retry reading block ', index, err)
await this.reconnect()
},
})
}
/**
* returns the map of the file with holes, zeros and data, useful to handle efficiently sparse source
* to implement this internally: use structure response if the server supports it, and then ask for BLOCK_STATUS *
*
* @returns {Promise<{ offset: number, length: number, type: number }[]>}
* A promise that resolves to an array where each object represents a segment:
* - `offset` — The byte offset from the start.
* - `length` — The size of the segment in bytes.
* - `type` — A numeric code indicating the segment type ( 0 means data).
*/
/* async */ getMap() {
return new Promise((resolve, reject) => {
const process = spawn('nbdinfo', [
'--json',
'--map',
`nbd://${this.#serverAddress}:${this.#serverPort}/${encodeURIComponent(this.#exportName)}`,
])
let text = ''
let errText = ''
process.stdout.on('data', data => (text += data))
process.stderr.on('data', data => (errText += data))
process.on('error', reject)
process.on('close', code => {
if (code !== 0) {
return reject(new Error(`Error during getMap (code: ${code}): ${errText}`))
}
try {
const json = JSON.parse(text)
resolve(json)
} catch (error) {
reject(new Error(`${error} ${text}`))
}
})
})
}
}

View File

@@ -7,7 +7,6 @@ const { warn } = createLogger('vates:nbd-client:multi')
export default class MultiNbdClient {
#clients = []
#nbdConcurrency
#nextClient = 0
#options
#readAhead
#settings
@@ -98,31 +97,8 @@ export default class MultiNbdClient {
* @returns {Promise<Buffer>}
*/
async readBlock(index, size = NBD_DEFAULT_BLOCK_SIZE) {
const clientId = this.#nextClient++ % this.#clients.length
const client = this.#clients[clientId]
try {
return await client.readBlock(index, size)
} catch (err) {
// client.readBlock() already exhausted its own retries/reconnects: this connection is dead.
// Evict it so future reads stop being routed to it, and retry this read on a surviving
// client, since the data is usually still reachable through the others.
this.#evict(client)
if (this.#clients.length === 0) {
throw err
}
warn(`evicted a dead nbd client, retrying block ${index} on a remaining client`, { err })
return this.readBlock(index, size)
}
}
// no-op if `client` was already evicted by a concurrent failed read on the same client
#evict(client) {
const i = this.#clients.indexOf(client)
if (i === -1) {
return
}
this.#clients.splice(i, 1)
client.disconnect().catch(() => {})
const clientId = index % this.#clients.length
return this.#clients[clientId].readBlock(index, size)
}
/**
@@ -162,8 +138,8 @@ export default class MultiNbdClient {
* - `length` — The size of the segment in bytes.
* - `type` — A numeric code indicating the segment type (0 means no data).
*/
async getMap(signal) {
async getMap() {
// ask the map from one of the connected client
return this.#clients[Math.floor(this.#clients.length * Math.random())].getMap(signal)
return this.#clients[Math.floor(this.#clients.length * Math.random())].getMap()
}
}

View File

@@ -1,130 +0,0 @@
import { describe, it, mock } from 'node:test'
import assert from 'node:assert'
// MultiNbdClient instantiates NbdClient itself (`import NbdClient from './index.mjs'`), so the
// only way to control individual connections from a unit test is to replace that module with a
// fake before importing multi.mjs.
const created = []
class FakeNbdClient {
constructor(nbdInfo) {
this.nbdInfo = nbdInfo
this.disconnected = false
// overridden per-test after connect() to script this instance's behavior
this.readBlock = async () => {
throw new Error('readBlock not configured for this fake client')
}
created.push(this)
}
async connect() {}
async disconnect() {
this.disconnected = true
}
async getMap() {
return []
}
}
mock.module('./index.mjs', {
defaultExport: FakeNbdClient,
})
const { default: MultiNbdClient } = await import('./multi.mjs')
// a single settings entry is enough: connect() reuses candidates across concurrent slots, so
// nbdConcurrency still yields that many distinct FakeNbdClient instances
async function connectWithFakes(nbdConcurrency) {
created.length = 0
const client = new MultiNbdClient({ address: 'fake-host' }, { nbdConcurrency })
await client.connect()
return { client, fakes: created.slice() }
}
describe('MultiNbdClient.readBlock eviction', () => {
it('evicts a client whose readBlock rejects and retries on a surviving client', async () => {
const { client, fakes } = await connectWithFakes(2)
const [dead, alive] = fakes
dead.readBlock = async () => {
throw new Error('connection is dead')
}
alive.readBlock = async index => Buffer.from([index])
// index 0 initially routes to `dead` (0 % 2 === 0)
const data = await client.readBlock(0)
assert.deepStrictEqual(data, Buffer.from([0]))
assert.strictEqual(dead.disconnected, true, 'the dead client should have been disconnected')
// future reads must no longer be routed to the evicted client: with only `alive` left,
// every index should now succeed through it
alive.readBlock = async index => Buffer.from([index])
const data2 = await client.readBlock(1)
assert.deepStrictEqual(data2, Buffer.from([1]))
})
it('rejects once every client has been evicted, instead of retrying forever', async () => {
const { client, fakes } = await connectWithFakes(2)
const boom = new Error('connection is dead')
for (const fake of fakes) {
fake.readBlock = async () => {
throw boom
}
}
await assert.rejects(() => client.readBlock(0), boom)
assert.ok(
fakes.every(fake => fake.disconnected),
'every client should have been evicted (and disconnected) before giving up'
)
})
it('evicting the same dead client from concurrent failed reads is idempotent', async () => {
const { client, fakes } = await connectWithFakes(2)
const [dead, alive] = fakes
dead.readBlock = async () => {
throw new Error('connection is dead')
}
alive.readBlock = async index => Buffer.from([index])
let disconnectCalls = 0
dead.disconnect = async () => {
disconnectCalls++
dead.disconnected = true
}
// routing is round-robin by call order (0, 1, 0) over a 2-client pool: the 1st and 3rd
// calls both land on `dead` before either failure has been handled, so they race to evict
// the same client. The exact interleaving isn't guaranteed, only that eviction is safe
// either way and every read still completes.
const [data0, data1, data2] = await Promise.all([client.readBlock(10), client.readBlock(11), client.readBlock(12)])
assert.deepStrictEqual(data0, Buffer.from([10]))
assert.deepStrictEqual(data1, Buffer.from([11]))
assert.deepStrictEqual(data2, Buffer.from([12]))
assert.strictEqual(disconnectCalls, 1, 'the dead client must only be evicted/disconnected once')
})
})
describe('MultiNbdClient.readBlock routing', () => {
it('distributes reads round-robin by call order, not by index residue', async () => {
const { client, fakes } = await connectWithFakes(4)
const readsPerClient = fakes.map(() => 0)
fakes.forEach((fake, i) => {
fake.readBlock = async index => {
readsPerClient[i]++
return Buffer.from([index])
}
})
// a CBT-style changed-block set striding by exactly `nbdConcurrency`: under the old
// `index % clients.length` routing this would collapse entirely onto a single client
const stridedIndexes = Array.from({ length: 20 }, (_, i) => i * 4)
for (const index of stridedIndexes) {
await client.readBlock(index)
}
assert.deepStrictEqual(readsPerClient, [5, 5, 5, 5])
})
})

View File

@@ -13,19 +13,19 @@
"url": "https://vates.fr"
},
"license": "ISC",
"version": "3.7.0",
"version": "3.2.3",
"engines": {
"node": ">=14.0"
},
"main": "./index.mjs",
"dependencies": {
"@vates/async-each": "^1.0.3",
"@vates/async-each": "^1.0.1",
"@vates/read-chunk": "^1.2.1",
"@xen-orchestra/async-map": "^0.1.5",
"@xen-orchestra/disk-transform": "^1.3.4",
"@xen-orchestra/log": "^0.7.2",
"@xen-orchestra/async-map": "^0.1.2",
"@xen-orchestra/disk-transform": "^1.2.1",
"@xen-orchestra/log": "^0.7.1",
"promise-toolbox": "^0.21.0",
"xen-api": "^5.1.0"
"xen-api": "^4.7.6"
},
"devDependencies": {
"tap": "^18.7.0",
@@ -33,7 +33,6 @@
},
"scripts": {
"postversion": "npm publish --access public",
"test": "node --experimental-test-module-mocks --test",
"test-integration": "tap --allow-incomplete-coverage tests/*.integ.mjs"
"test-integration": "tap --allow-incomplete-coverage"
}
}

View File

@@ -1,132 +0,0 @@
import assert from 'node:assert'
import { pFromCallback } from 'promise-toolbox'
import { readChunk, readChunkStrict } from '@vates/read-chunk'
import {
INIT_PASSWD,
NBD_CMD_DISC,
NBD_CMD_READ,
NBD_FLAG_FIXED_NEWSTYLE,
NBD_FLAG_HAS_FLAGS,
NBD_FLAG_READ_ONLY,
NBD_OPT_EXPORT_NAME,
NBD_OPT_REPLY_MAGIC,
NBD_REPLY_ACK,
NBD_REPLY_MAGIC,
NBD_REQUEST_MAGIC,
OPTS_MAGIC,
} from '../constants.mjs'
const NBD_EINVAL = 22
/**
* Minimal read-only newstyle NBD server, used by the tests only.
*
* It is transport agnostic on purpose: the unit tests drive it through two
* in-memory `PassThrough`, the integration tests through the standard streams
* of a real process.
*
* @param {object} options
* @param {import('node:stream').Readable} options.readable - where the client queries are read from
* @param {import('node:stream').Writable} options.writable - where the answers are written to
* @param {Buffer} options.data - the content of the export
* @param {string} [options.exportName] - the export name the client must ask for
* @param {boolean} [options.answerInReverse] - answer by pairs, in reverse order, to check the client handles out of order answers
* @param {(request: {offset: bigint, length: number, index: number}) => number} [options.errorCode] - non zero to answer an error to this read
* @returns {Promise<void>} resolves when the client disconnected or closed the connection
*/
export async function serveNbd({ readable, writable, data, exportName = '', answerInReverse = false, errorCode }) {
const write = buffer => pFromCallback(cb => writable.write(buffer, cb))
// handshake: server flags
await write(INIT_PASSWD)
await write(OPTS_MAGIC)
const serverFlags = Buffer.alloc(2)
serverFlags.writeInt16BE(NBD_FLAG_FIXED_NEWSTYLE)
await write(serverFlags)
// client flags
await readChunkStrict(readable, 4)
// options, until the client selects an export
let selected = false
do {
assert.ok((await readChunkStrict(readable, 8)).equals(OPTS_MAGIC), 'option magic')
const option = (await readChunkStrict(readable, 4)).readInt32BE(0)
const length = (await readChunkStrict(readable, 4)).readInt32BE(0)
const payload = length > 0 ? await readChunkStrict(readable, length) : Buffer.alloc(0)
if (option === NBD_OPT_EXPORT_NAME) {
assert.strictEqual(payload.toString(), exportName, 'export name')
// 8 (size) + 2 (transmission flags) + 124 zeroes
const answer = Buffer.alloc(134)
answer.writeBigUInt64BE(BigInt(data.length), 0)
answer.writeInt16BE(NBD_FLAG_HAS_FLAGS | NBD_FLAG_READ_ONLY, 8)
await write(answer)
selected = true
} else {
// ack everything else
const answer = Buffer.alloc(20)
answer.writeBigUInt64BE(NBD_OPT_REPLY_MAGIC, 0)
answer.writeInt32BE(option, 8)
answer.writeInt32BE(NBD_REPLY_ACK, 12)
answer.writeInt32BE(0, 16)
await write(answer)
}
} while (!selected)
// transmission
let index = 0
let pending = []
const answer = async ({ handle, offset, length, index }, forcedCode) => {
const code = forcedCode ?? errorCode?.({ handle, offset, length, index }) ?? 0
const header = Buffer.alloc(16)
header.writeInt32BE(NBD_REPLY_MAGIC, 0)
header.writeInt32BE(code, 4)
header.writeBigUInt64BE(handle, 8)
await write(header)
if (code === 0) {
await write(data.subarray(Number(offset), Number(offset) + length))
}
}
const flush = async () => {
const requests = pending
pending = []
for (const request of requests.reverse()) {
await answer(request)
}
}
while (true) {
const request = await readChunk(readable, 28)
if (request === null || request.length < 28) {
// the client closed the connection without a NBD_CMD_DISC
break
}
assert.strictEqual(request.readInt32BE(0), NBD_REQUEST_MAGIC, 'request magic')
const type = request.readInt16BE(6)
const handle = request.readBigUInt64BE(8)
if (type === NBD_CMD_DISC) {
await flush()
break
}
if (type !== NBD_CMD_READ) {
await answer({ handle, offset: 0n, length: 0, index: index++ }, NBD_EINVAL)
continue
}
const read = {
handle,
offset: request.readBigUInt64BE(16),
length: request.readInt32BE(24),
index: index++,
}
pending.push(read)
if (!answerInReverse || pending.length === 2) {
await flush()
}
}
}

View File

@@ -153,13 +153,7 @@ CYu1Xn/FVPx1HoRgWc7E8wFhDcA/P3SJtfIQWHB9FzSaBflKGR4t8WCE2eE8+cTB
nbdServer = await spawnNbdKit(path)
}
}
// reading past the end of the export must reject: a single client so the failure isn't
// multiplied by nbdConcurrency evicting and retrying on every other (equally healthy, equally
// unable to satisfy this out-of-range request) client
const singleClient = new MultiNbdClient(connectionSettings, { nbdConcurrency: 1, readAhead: 2 })
await singleClient.connect()
await assert.rejects(() => singleClient.readBlock(100, CHUNK_SIZE))
await singleClient.disconnect()
assert.rejects(() => client.readBlock(100, CHUNK_SIZE))
await client.disconnect()
// double disconnection shouldn't pose any problem

View File

@@ -1,114 +0,0 @@
import assert from 'node:assert'
import fs from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { pFromCallback } from 'promise-toolbox'
import { test } from 'tap'
import tmp from 'tmp'
import NbdStdioClient from '../NbdStdioClient.mjs'
// nbdkit in single connection mode: the real thing this client targets
const NBDKIT = path => ({
command: 'nbdkit',
args: ['--single', '--exit-with-parent', '--read-only', 'file', path],
})
// a tiny NBD server we can ask to die in the middle of the transfer, which
// nbdkit won't do
const FLAKY_SERVER = fileURLToPath(new URL('./stdio-nbd-server.mjs', import.meta.url))
const CHUNK_SIZE = 64 * 1024
const FILE_SIZE = CHUNK_SIZE * 8 + 1232 // non aligned file size
async function createTempFile(size) {
const tmpPath = await pFromCallback(cb => tmp.file(cb))
const data = Buffer.alloc(size)
for (let i = 0; i < size; i += 4) {
data.writeUInt32BE(i, i)
}
await fs.writeFile(tmpPath, data)
return tmpPath
}
function checkBlock(tap, block, index) {
tap.equal(block.length, Math.min(CHUNK_SIZE, FILE_SIZE - index * CHUNK_SIZE), `block ${index} length`)
let ok = true
for (let i = 0; i < block.length; i += 4) {
ok = ok && block.readUInt32BE(i) === index * CHUNK_SIZE + i
}
tap.ok(ok, `block ${index} content`)
}
async function readWholeExport(tap, client) {
for (let index = 0; index < Math.ceil(FILE_SIZE / CHUNK_SIZE); index++) {
checkBlock(tap, await client.readBlock(index, CHUNK_SIZE), index)
}
}
const isAlive = pid => {
try {
process.kill(pid, 0)
return true
} catch (error) {
return false
}
}
test('it reads an export served by nbdkit on stdin/stdout', async tap => {
const path = await createTempFile(FILE_SIZE)
const client = new NbdStdioClient(NBDKIT(path))
await client.connect()
const { pid } = client
tap.equal(client.exportSize, BigInt(FILE_SIZE))
await readWholeExport(tap, client)
await client.disconnect()
tap.notOk(isAlive(pid), 'the server process is stopped after disconnect')
// double disconnection shouldn't pose any problem
await client.disconnect()
await fs.unlink(path)
})
test('it fails when the server does not serve the requested export', async tap => {
const path = await createTempFile(CHUNK_SIZE)
// the file plugin of nbdkit ignores the export name, so use our own server here
const client = new NbdStdioClient(
{
command: process.execPath,
args: [FLAKY_SERVER, path, '--export-name=MY_SECRET_EXPORT'],
exportname: 'NOPE',
},
{ connectTimeout: 5e3 }
)
await assert.rejects(client.connect())
tap.match(client.stderr, /NOPE/, 'the stderr of the server is captured')
await fs.unlink(path)
})
test('it recovers from a server dying in the middle of the transfer', async tap => {
const path = await createTempFile(FILE_SIZE)
const client = new NbdStdioClient(
{ command: process.execPath, args: [FLAKY_SERVER, path, '--die-after=3'] },
{ waitBeforeReconnect: 100 }
)
await client.connect()
await readWholeExport(tap, client)
await client.disconnect()
await fs.unlink(path)
})
test('it fails cleanly when the server cannot be spawned', async () => {
const client = new NbdStdioClient({ command: '/does/not/exist' })
await assert.rejects(client.connect(), ({ code, command }) => code === 'ENOENT' && command === '/does/not/exist')
// nothing to disconnect from
await client.disconnect()
})
test('it does not implement getMap', async () => {
const client = new NbdStdioClient({ command: 'nbdkit' })
await assert.rejects(client.getMap(), ({ code }) => code === 'NBD_MAP_UNSUPPORTED')
})

View File

@@ -1,52 +0,0 @@
#!/usr/bin/env node
// Read-only NBD server serving a file through its standard streams, à la
// `nbdkit -s`. Used by nbdstdio.integ.mjs, since nbdkit is not necessarily
// installed on the test machine.
//
// Usage: stdio-nbd-server.mjs <file> [--export-name=NAME] [--die-after=N]
//
// --die-after=N exits with a failure after having answered N read requests, to
// exercise the reconnection path of the client.
import fs from 'node:fs/promises'
import { serveNbd } from './fake-nbd-server.mjs'
const args = process.argv.slice(2)
const path = args.find(arg => !arg.startsWith('--'))
if (path === undefined) {
console.error('usage: stdio-nbd-server.mjs <file> [--export-name=NAME] [--die-after=N]')
process.exit(64)
}
const getOption = (name, defaultValue) => {
const prefix = `--${name}=`
const arg = args.find(arg => arg.startsWith(prefix))
return arg === undefined ? defaultValue : arg.slice(prefix.length)
}
const exportName = getOption('export-name', '')
const dieAfter = Number(getOption('die-after', Infinity))
const data = await fs.readFile(path)
let answered = 0
try {
await serveNbd({
readable: process.stdin,
writable: process.stdout,
data,
exportName,
errorCode: () => {
if (++answered > dieAfter) {
console.error(`stdio-nbd-server: dying after ${dieAfter} read(s)`)
process.exit(1)
}
return 0
},
})
} catch (error) {
console.error('stdio-nbd-server:', error)
process.exit(1)
}

View File

@@ -1,102 +0,0 @@
const XML_ENTITIES = {
'&amp;': '&',
'&apos;': "'",
'&gt;': '>',
'&lt;': '<',
'&quot;': '"',
}
const unescapeXml = text => text.replace(/&(?:amp|apos|gt|lt|quot);/g, entity => XML_ENTITIES[entity])
// node-soap wraps text nodes either as a plain string or as `{ $value }` depending on
// whether the element carries attributes
const valueOf = node => {
if (typeof node === 'string') {
return node
}
if (node !== null && typeof node === 'object' && typeof node.$value === 'string') {
return node.$value
}
}
const faultTypeOfElementName = name => name.replace(/^.*:/, '').replace(/Fault$/, '')
// SOAP 1.1 requires the children of `Fault` to be unqualified, and that is what ESXi and vCenter
// send, but the raw body is only read when node-soap failed to parse the response: it can then be
// anything, e.g. an error emitted by a reverse proxy in front of vCenter. Hence the tolerance to a
// namespace prefix and to attributes on the element
const textOfElement = name =>
new RegExp(`<(?:[\\w.-]+:)?${name}(?:\\s[^>]*)?>([\\s\\S]*?)</(?:[\\w.-]+:)?${name}>`, 'i')
const FAULTCODE_RE = textOfElement('faultcode')
const FAULTSTRING_RE = textOfElement('faultstring')
const LOCALIZED_MESSAGE_RE = textOfElement('localizedMessage')
const DETAIL_CHILD_RE = /<(?:[\w.-]+:)?detail(?:\s[^>]*)?>\s*<([^\s/>]+)/i
/**
* Extracts the vim25 fault of a failed SOAP call.
*
* The fault type (`FileLocked`, `InvalidDeviceSpec`, `ManagedObjectNotFound`, ...) is the only part
* a caller can branch on: `faultstring` and `localizedMessage` are meant for humans and change with
* the locale of the server.
*
* Two sources are used, in order of reliability:
* 1. `rawError.root`, the envelope parsed by node-soap ( see `wsdl.xmlToObject`, which throws on a
* fault and whose error is stored there by node-soap)
* 2. the raw response body, when the parsing itself failed
*
* @param {unknown} rawError - the error passed by node-soap to its callback
* @returns {{ code?: string, faultcode?: string, faultstring?: string, localizedMessage?: string, body?: string }}
*/
export function parseFault(rawError) {
const fault = rawError?.root?.Envelope?.Body?.Fault
const rawBody = rawError?.response?.data ?? rawError?.body
const body = typeof rawBody === 'string' && rawBody !== '<stream>' ? rawBody : undefined
let code, localizedMessage
let faultcode = valueOf(fault?.faultcode)
let faultstring = valueOf(fault?.faultstring)
const detail = fault?.detail
if (detail !== null && typeof detail === 'object') {
// the single child of `detail` names the fault, e.g.
// `<InvalidDeviceSpecFault xsi:type="InvalidDeviceSpec">`
for (const [name, value] of Object.entries(detail)) {
if (name === 'attributes') {
continue
}
code = value?.attributes?.['xsi:type'] ?? faultTypeOfElementName(name)
localizedMessage = valueOf(value?.localizedMessage)
break
}
}
if (body !== undefined) {
if (faultstring === undefined) {
const matches = body.match(FAULTSTRING_RE)
if (matches !== null) {
faultstring = unescapeXml(matches[1])
}
}
if (faultcode === undefined) {
const matches = body.match(FAULTCODE_RE)
if (matches !== null) {
faultcode = unescapeXml(matches[1])
}
}
if (code === undefined) {
const matches = body.match(DETAIL_CHILD_RE)
if (matches !== null) {
code = faultTypeOfElementName(matches[1])
}
}
if (localizedMessage === undefined) {
const matches = body.match(LOCALIZED_MESSAGE_RE)
if (matches !== null) {
localizedMessage = unescapeXml(matches[1])
}
}
}
return { code, faultcode, faultstring, localizedMessage, body }
}

View File

@@ -1,104 +0,0 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { parseFault } from './_parseFault.mjs'
// captured from an ESXi 8 host
const MANAGED_OBJECT_NOT_FOUND = `<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<soapenv:Body>
<soapenv:Fault><faultcode>ServerFaultCode</faultcode><faultstring>The object &apos;vim.VirtualMachine:vm-42&apos; has already been deleted or has not been completely created</faultstring><detail><ManagedObjectNotFoundFault xmlns="urn:vim25" xsi:type="ManagedObjectNotFound"><obj type="VirtualMachine">vm-42</obj></ManagedObjectNotFoundFault></detail></soapenv:Fault>
</soapenv:Body>
</soapenv:Envelope>`
// not what a vSphere host sends — SOAP 1.1 requires these children to be unqualified — but a body
// which node-soap failed to parse can come from anything sitting in front of vCenter
const NAMESPACE_PREFIXED = `<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:vim25="urn:vim25" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<soapenv:Body>
<soapenv:Fault><soapenv:faultcode>ServerFaultCode</soapenv:faultcode><soapenv:faultstring>Unable to access file since it is locked</soapenv:faultstring><soapenv:detail xmlns="urn:vim25"><vim25:FileLockedFault xsi:type="FileLocked"><vim25:localizedMessage>Unable to access file [ds1] vm/vm.vmdk since it is locked</vim25:localizedMessage></vim25:FileLockedFault></soapenv:detail></soapenv:Fault>
</soapenv:Body>
</soapenv:Envelope>`
describe('parseFault', function () {
it('reads the fault from the envelope parsed by node-soap', function () {
const { code, faultcode, faultstring, localizedMessage } = parseFault({
root: {
Envelope: {
Body: {
Fault: {
faultcode: 'ServerFaultCode',
faultstring: 'Unable to access file since it is locked',
detail: {
FileLockedFault: {
attributes: { 'xsi:type': 'FileLocked' },
localizedMessage: 'Unable to access file [ds1] vm/vm.vmdk since it is locked',
},
},
},
},
},
},
})
assert.equal(code, 'FileLocked')
assert.equal(faultcode, 'ServerFaultCode')
assert.equal(faultstring, 'Unable to access file since it is locked')
assert.equal(localizedMessage, 'Unable to access file [ds1] vm/vm.vmdk since it is locked')
})
it('falls back to the element name when xsi:type is absent', function () {
const { code } = parseFault({
root: { Envelope: { Body: { Fault: { detail: { InvalidDeviceSpecFault: {} } } } } },
})
assert.equal(code, 'InvalidDeviceSpec')
})
it('falls back to the raw body when the envelope could not be parsed', function () {
const { code, faultcode, faultstring, body } = parseFault({
message: 'Request failed with status code 500',
response: { data: MANAGED_OBJECT_NOT_FOUND },
})
assert.equal(code, 'ManagedObjectNotFound')
assert.equal(faultcode, 'ServerFaultCode')
// entities are decoded
assert.match(faultstring, /^The object 'vim\.VirtualMachine:vm-42' has already been deleted/)
assert.equal(body, MANAGED_OBJECT_NOT_FOUND)
})
it('reads a namespace prefixed fault from the raw body', function () {
const { code, faultcode, faultstring, localizedMessage } = parseFault({
response: { data: NAMESPACE_PREFIXED },
})
assert.equal(code, 'FileLocked')
assert.equal(faultcode, 'ServerFaultCode')
assert.equal(faultstring, 'Unable to access file since it is locked')
assert.equal(localizedMessage, 'Unable to access file [ds1] vm/vm.vmdk since it is locked')
})
it('handles a `$value` wrapped text node', function () {
const { faultstring } = parseFault({
root: { Envelope: { Body: { Fault: { faultstring: { $value: 'boom' } } } } },
})
assert.equal(faultstring, 'boom')
})
it('does not throw on an error without any fault', function () {
assert.deepEqual(parseFault(new Error('socket hang up')), {
code: undefined,
faultcode: undefined,
faultstring: undefined,
localizedMessage: undefined,
body: undefined,
})
assert.deepEqual(parseFault(undefined).code, undefined)
})
it('ignores the placeholder body used by node-soap for streamed responses', function () {
assert.equal(parseFault({ body: '<stream>' }).body, undefined)
})
})

View File

@@ -19,29 +19,16 @@ import soap from 'soap'
import Cookie from 'soap-cookie' // required for session persistence
import { createLogger } from '@xen-orchestra/log'
import { parseFault } from './_parseFault.mjs'
// exposed for the consumers talking to the host outside of the WSDL, e.g. `Fetch`
export { parseFault } from './_parseFault.mjs'
const { warn } = createLogger('xo:node-vsphere-soap:client')
class VmwareError extends Error {
constructor(rawError) {
const { code, faultcode, faultstring, localizedMessage, body } = parseFault(rawError)
super(localizedMessage ?? faultstring ?? rawError?.message ?? 'unknown SOAP error')
super(rawError.body)
const matches = rawError.body?.match(/<faultstring[^>]*>(.*)<\/faultstring>/im)
this.message = matches?.[1] ?? rawError.message
this.name = 'VmwareError'
// the vim25 fault type, the only part of a fault a caller can branch on
this.code = code
this.faultcode = faultcode
this.faultstring = faultstring
this.localizedMessage = localizedMessage
this.body = body
if (rawError?.stack !== undefined) {
this.stack = rawError.stack
}
// legacy constraint: not putting the cause since the error failed to be stringified correctly
// down the road, the fault is exposed through own properties instead
warn('SOAP call failed', { code, faultcode, faultstring, localizedMessage })
this.stack = rawError.stack
// not putting the cause since the error failed to be stringified correctly down the road
warn(rawError)
}
}
// Client class
@@ -51,7 +38,6 @@ class VmwareError extends Error {
export function Client(vCenterHostname, username, password, sslVerify) {
this.status = 'disconnected'
this.reconnectCount = 0
this._exitHook = undefined
sslVerify = typeof sslVerify !== 'undefined' ? sslVerify : false
@@ -104,49 +90,44 @@ util.inherits(Client, EventEmitter)
Client.prototype.runCommand = function (command, args) {
const self = this
const cmdargs = args ?? {}
let cmdargs
if (!args || args === null) {
cmdargs = {}
} else {
cmdargs = args
}
const emitter = new EventEmitter()
const onResponse = function (err, result, raw, soapHeader) {
if (err) {
// an error and a result are mutually exclusive: emitting both makes every caller listening
// for 'result' believe the call succeeded, with `result` undefined
_soapErrorHandler(self, emitter, command, cmdargs, err)
return
}
if (command === 'Logout') {
self.status = 'disconnected'
self._unregisterExitHook()
}
emitter.emit('result', result, raw, soapHeader)
}
const send = function () {
self.client.VimService.VimPort[command](cmdargs, onResponse)
}
// check if client has successfully connected
if (self.status === 'ready' || self.status === 'connecting') {
send()
self.client.VimService.VimPort[command](cmdargs, function (err, result, raw, soapHeader) {
if (err) {
_soapErrorHandler(self, emitter, command, cmdargs, err)
}
if (command === 'Logout') {
self.status = 'disconnected'
process.removeAllListeners('beforeExit')
}
emitter.emit('result', result, raw, soapHeader)
})
} else {
// if connection not ready or connecting, reconnect to instance
if (self.status === 'disconnected') {
self.emit('connect')
}
// a failed connection would otherwise leave this listener registered for ever, and the next
// successful one would replay every command whose caller has long given up
const onConnectionError = error => {
self.off('ready', onReady)
emitter.emit('error', error)
}
const onReady = () => {
self.off('error', onConnectionError)
send()
}
self.once('ready', onReady)
self.once('error', onConnectionError)
self.once('ready', function () {
self.client.VimService.VimPort[command](cmdargs, function (err, result, raw, soapHeader) {
if (err) {
_soapErrorHandler(self, emitter, command, cmdargs, err)
}
if (command === 'Logout') {
self.status = 'disconnected'
process.removeAllListeners('beforeExit')
}
emitter.emit('result', result, raw, soapHeader)
})
})
}
return emitter
@@ -172,11 +153,9 @@ Client.prototype._connect = function () {
self.clientopts,
function (err, client) {
if (err) {
self.status = 'disconnected'
// throwing here would be an uncaught exception: we are in an async callback, not in the
// stack of the caller
self.emit('error', new VmwareError(err))
return
const vErr = new VmwareError(err)
self.emit('error', vErr)
throw vErr
}
self.client = client // save client for later use
@@ -207,7 +186,7 @@ Client.prototype._connect = function () {
self.status = 'ready'
self.emit('ready')
self._registerExitHook()
process.once('beforeExit', self._close)
})
.once('error', function (err) {
self.status = 'disconnected'
@@ -223,36 +202,9 @@ Client.prototype._connect = function () {
)
}
// Logs out on process exit, so that a session is not left open on the server until it expires.
//
// The hook must be stored: passing `self._close` directly would call it with `this === process`,
// and removing it with `process.removeAllListeners('beforeExit')` would drop the hooks of every
// other module of the process.
Client.prototype._registerExitHook = function () {
const self = this
if (self._exitHook === undefined) {
self._exitHook = function () {
self._close()
}
process.once('beforeExit', self._exitHook)
}
}
Client.prototype._unregisterExitHook = function () {
const self = this
if (self._exitHook !== undefined) {
process.off('beforeExit', self._exitHook)
self._exitHook = undefined
}
}
Client.prototype._close = function () {
const self = this
self._unregisterExitHook()
if (self.status === 'ready') {
self
.runCommand('Logout', { _this: self.sessionManager })
@@ -268,64 +220,34 @@ Client.prototype._close = function () {
}
}
// Errors are only emitted on `emitter`, the emitter of the failed call: throwing from here would
// be an uncaught exception since we are always in an async callback, and emitting on the client
// would reject unrelated concurrent calls.
//
// Exported for the unit tests: building a `Client` connects to a host.
export function _soapErrorHandler(self, emitter, command, args, err) {
function _soapErrorHandler(self, emitter, command, args, err) {
err = err || { body: 'general error' }
const vErr = new VmwareError(err)
// `NotAuthenticated` is raised when the session expired: log in again and replay the call.
//
// The fault type is used instead of the previous `err.body?.match(...)`: node-soap only fills
// `body` for streamed responses, so the string test never matched and the session was never
// renewed
const isSessionExpired =
vErr.code === 'NotAuthenticated' || /session is not authenticated/i.test(vErr.faultstring ?? vErr.message)
if (!isSessionExpired || self.reconnectCount >= 10) {
emitter.emit('error', vErr)
return
}
// The call is replayed only once a new session is ready. Handing it straight to `runCommand`
// would send it right away while the status is 'connecting' — i.e. on the very session which
// just expired — and the failure of that replay would land back here.
const onConnectionError = function (error) {
self.off('ready', onReady)
emitter.emit('error', error)
}
const onReady = function () {
self.off('error', onConnectionError)
self
.runCommand(command, args)
.once('result', function (result, raw, soapHeader) {
emitter.emit('result', result, raw, soapHeader)
})
.once('error', function (retryError) {
emitter.emit('error', retryError)
})
}
self.once('ready', onReady)
self.once('error', onConnectionError)
// A whole wave of calls can fail on the same expired session, e.g. during an import. Only the
// first one to notice drops it and pays for a retry: the others ride the reconnect it started,
// instead of each burning one of the 10 and of resetting the status under a `_connect` already
// in flight — which used to start a second `soap.createClient` and replace `self.client` under
// the first one.
if (self.status === 'ready') {
if (err.body?.match(/session is not authenticated/)) {
self.status = 'disconnected'
self._unregisterExitHook()
self.reconnectCount += 1
}
if (self.status === 'disconnected') {
// no reconnect in flight: either this call is the first to notice, or the previous attempt
// already failed
self.emit('connect')
process.removeAllListeners('beforeExit')
if (self.reconnectCount < 10) {
self.reconnectCount += 1
self
.runCommand(command, args)
.once('result', function (result, raw, soapHeader) {
emitter.emit('result', result, raw, soapHeader)
})
.once('error', function (err) {
const vErr = new VmwareError(err)
emitter.emit('error', vErr)
throw vErr
})
} else {
const vErr = new VmwareError(err)
emitter.emit('error', vErr)
throw vErr
}
} else {
const vErr = new VmwareError(err)
emitter.emit('error', vErr)
throw vErr
}
}

View File

@@ -1,219 +0,0 @@
import assert from 'node:assert/strict'
import { EventEmitter } from 'node:events'
import { describe, it } from 'node:test'
import { _soapErrorHandler } from './client.mjs'
// what a host answers to any call made on a session which expired
const NOT_AUTHENTICATED = {
root: {
Envelope: {
Body: {
Fault: {
faultcode: 'ServerFaultCode',
faultstring: 'The session is not authenticated.',
detail: { NotAuthenticatedFault: { attributes: { 'xsi:type': 'NotAuthenticated' } } },
},
},
},
},
}
const FILE_LOCKED = {
root: {
Envelope: {
Body: {
Fault: {
faultcode: 'ServerFaultCode',
faultstring: 'Unable to access file since it is locked',
detail: { FileLockedFault: { attributes: { 'xsi:type': 'FileLocked' } } },
},
},
},
},
}
/**
* A stand-in for a connected `Client`, reduced to what `_soapErrorHandler` touches: building a real
* one talks to a host.
*
* `connect` reproduces `_connect`, which only starts connecting from 'disconnected'.
*/
function fakeClient() {
const self = new EventEmitter()
self.status = 'ready'
self.reconnectCount = 0
self.unregisterExitHookCalls = 0
self._unregisterExitHook = () => {
self.unregisterExitHookCalls += 1
}
// the calls handed to `runCommand`, i.e. the ones actually sent to the host
self.sent = []
self.runCommand = (command, args) => {
const emitter = new EventEmitter()
self.sent.push({ args, command, emitter, statusWhenSent: self.status })
return emitter
}
self.connectCalls = 0
self.on('connect', () => {
self.connectCalls += 1
if (self.status === 'disconnected') {
self.status = 'connecting'
}
})
return self
}
// makes one in-flight call fail, and reports what its own emitter received
function failingCall(self, command, rawError = NOT_AUTHENTICATED) {
const emitter = new EventEmitter()
const outcome = {}
emitter.once('result', result => {
outcome.result = result
})
emitter.once('error', error => {
outcome.error = error
})
_soapErrorHandler(self, emitter, command, { _this: command }, rawError)
return outcome
}
describe('_soapErrorHandler', function () {
it('replays a call made on an expired session, once the new session is ready', function () {
const self = fakeClient()
const outcome = failingCall(self, 'RetrievePropertiesEx')
assert.equal(self.reconnectCount, 1)
assert.equal(self.status, 'connecting')
assert.equal(self.unregisterExitHookCalls, 1)
assert.deepEqual(self.sent, [])
self.status = 'ready'
self.emit('ready')
assert.equal(self.sent.length, 1)
assert.equal(self.sent[0].command, 'RetrievePropertiesEx')
self.sent[0].emitter.emit('result', 'replayed')
assert.equal(outcome.result, 'replayed')
})
it('starts a single reconnect for a whole wave of calls on the same session', function () {
const self = fakeClient()
const outcomes = [failingCall(self, 'A'), failingCall(self, 'B'), failingCall(self, 'C')]
// one retry for the wave, not one per call: they used to exhaust the 10 of a busy import
assert.equal(self.reconnectCount, 1)
assert.equal(self.connectCalls, 1)
assert.equal(self.unregisterExitHookCalls, 1)
// and none of them is sent while the reconnect is in flight, which would mean sending it on
// the session which just expired
assert.deepEqual(self.sent, [])
self.status = 'ready'
self.emit('ready')
assert.deepEqual(
self.sent.map(({ command, statusWhenSent }) => [command, statusWhenSent]),
[
['A', 'ready'],
['B', 'ready'],
['C', 'ready'],
]
)
// each replay answers its own caller
self.sent[1].emitter.emit('result', 'B replayed')
assert.equal(outcomes[1].result, 'B replayed')
assert.equal(outcomes[0].result, undefined)
assert.equal(outcomes[2].result, undefined)
})
it('restarts a reconnect which already failed', function () {
const self = fakeClient()
failingCall(self, 'A')
assert.equal(self.connectCalls, 1)
// the reconnect failed, nothing is in flight any more
self.status = 'disconnected'
failingCall(self, 'B')
assert.equal(self.connectCalls, 2)
// the session was already dropped by the first call, the second one must not pay for it again
assert.equal(self.reconnectCount, 1)
})
it('reports a failed reconnect to every waiting call, and stops listening', function () {
const self = fakeClient()
const outcomes = [failingCall(self, 'A'), failingCall(self, 'B')]
const connectionError = new Error('host unreachable')
self.status = 'disconnected'
self.emit('error', connectionError)
assert.equal(outcomes[0].error, connectionError)
assert.equal(outcomes[1].error, connectionError)
assert.deepEqual(self.sent, [])
// a listener left behind would replay a call whose caller has long given up
assert.equal(self.listenerCount('ready'), 0)
assert.equal(self.listenerCount('error'), 0)
})
it('reports the error of a replay which failed too', function () {
const self = fakeClient()
const outcome = failingCall(self, 'A')
self.status = 'ready'
self.emit('ready')
const replayError = new Error('failed again')
self.sent[0].emitter.emit('error', replayError)
assert.equal(outcome.error, replayError)
})
it('gives up after 10 reconnects', function () {
const self = fakeClient()
self.reconnectCount = 10
const outcome = failingCall(self, 'A')
assert.equal(outcome.error.code, 'NotAuthenticated')
assert.equal(self.connectCalls, 0)
assert.equal(self.status, 'ready')
assert.deepEqual(self.sent, [])
})
it('emits an error which is not a session expiry as is, without reconnecting', function () {
const self = fakeClient()
const outcome = failingCall(self, 'A', FILE_LOCKED)
assert.equal(outcome.error.code, 'FileLocked')
assert.equal(self.connectCalls, 0)
assert.equal(self.reconnectCount, 0)
assert.equal(self.status, 'ready')
assert.deepEqual(self.sent, [])
})
it('recognizes an expiry which only says so in its faultstring', function () {
const self = fakeClient()
// no `detail`, hence no fault type to branch on
failingCall(self, 'A', {
root: { Envelope: { Body: { Fault: { faultstring: 'The session is not authenticated.' } } } },
})
assert.equal(self.reconnectCount, 1)
assert.equal(self.connectCalls, 1)
})
})

View File

@@ -1,6 +1,6 @@
{
"name": "@vates/node-vsphere-soap",
"version": "2.2.0",
"version": "2.1.2",
"description": "interface to vSphere SOAP/WSDL from node for interfacing with vCenter or ESXi, forked from node-vsphere-soap",
"main": "lib/client.mjs",
"author": "reedog117",
@@ -11,8 +11,8 @@
},
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"dependencies": {
"@xen-orchestra/log": "^0.7.2",
"axios": "^1.15.0",
"@xen-orchestra/log": "^0.7.1",
"axios": "^1.8.0",
"soap": "^1.0.0",
"soap-cookie": "^0.10.1"
},
@@ -31,7 +31,6 @@
"node": ">=14"
},
"scripts": {
"postversion": "npm publish --access public",
"test": "node --test lib/*.test.mjs"
"postversion": "npm publish --access public"
}
}

View File

@@ -1,7 +1,7 @@
// place your own credentials here for a vCenter or ESXi server: they are used by
// vsphere-soap.integ.mjs to connect to a real instance.
//
// copy this file next to it, as `config-test.mjs`. It is ignored by git, do not commit it.
// place your own credentials here for a vCenter or ESXi server
// this information will be used for connecting to a vCenter instance
// for module testing
// name the file config-test.js
export const vCenterTestCreds = {
vCenterIP: 'vcsa',

View File

@@ -0,0 +1,138 @@
/*
vsphere-soap.test.js
tests for the vCenterConnectionInstance class
*/
import assert from 'assert'
import { describe, it } from 'node:test'
import * as vc from '../lib/client.mjs'
// eslint-disable-next-line n/no-missing-import
import { vCenterTestCreds as TestCreds } from '../config-test.mjs'
const VItest = new vc.Client(TestCreds.vCenterIP, TestCreds.vCenterUser, TestCreds.vCenterPassword, false)
describe('Client object initialization:', function () {
it('provides a successful login', { timeout: 5000 }, function (t, done) {
VItest.once('ready', function () {
assert.notEqual(VItest.userName, null)
assert.notEqual(VItest.fullName, null)
assert.notEqual(VItest.serviceContent, null)
done()
}).once('error', function (err) {
console.error(err)
// this should fail if there's a problem
assert.notEqual(VItest.userName, null)
assert.notEqual(VItest.fullName, null)
assert.notEqual(VItest.serviceContent, null)
done()
})
})
})
describe('Client reconnection test:', function () {
it('can successfully reconnect', { timeout: 5000 }, function (t, done) {
VItest.runCommand('Logout', { _this: VItest.serviceContent.sessionManager })
.once('result', function (result) {
// now we're logged out, so let's try running a command to test automatic re-login
VItest.runCommand('CurrentTime', { _this: 'ServiceInstance' })
.once('result', function (result) {
assert(result.returnval instanceof Date)
done()
})
.once('error', function (err) {
console.error(err)
})
})
.once('error', function (err) {
console.error(err)
})
})
})
// these tests don't work yet
describe('Client tests - query commands:', function () {
it('retrieves current time', { timeout: 5000 }, function (t, done) {
VItest.runCommand('CurrentTime', { _this: 'ServiceInstance' }).once('result', function (result) {
assert(result.returnval instanceof Date)
done()
})
})
it('retrieves current time 2 (check for event clobbering)', { timeout: 5000 }, function (t, done) {
VItest.runCommand('CurrentTime', { _this: 'ServiceInstance' }).once('result', function (result) {
assert(result.returnval instanceof Date)
done()
})
})
it('can obtain the names of all Virtual Machines in the inventory', { timeout: 20000 }, function (t, done) {
// get property collector
const propertyCollector = VItest.serviceContent.propertyCollector
// get view manager
const viewManager = VItest.serviceContent.viewManager
// get root folder
const rootFolder = VItest.serviceContent.rootFolder
let containerView, objectSpec, traversalSpec, propertySpec, propertyFilterSpec
// this is the equivalent to
VItest.runCommand('CreateContainerView', {
_this: viewManager,
container: rootFolder,
type: ['VirtualMachine'],
recursive: true,
}).once('result', function (result) {
// build all the data structures needed to query all the vm names
containerView = result.returnval
objectSpec = {
attributes: { 'xsi:type': 'ObjectSpec' }, // setting attributes xsi:type is important or else the server may mis-recognize types!
obj: containerView,
skip: true,
}
traversalSpec = {
attributes: { 'xsi:type': 'TraversalSpec' },
name: 'traverseEntities',
type: 'ContainerView',
path: 'view',
skip: false,
}
objectSpec = { ...objectSpec, selectSet: [traversalSpec] }
propertySpec = {
attributes: { 'xsi:type': 'PropertySpec' },
type: 'VirtualMachine',
pathSet: ['name'],
}
propertyFilterSpec = {
attributes: { 'xsi:type': 'PropertyFilterSpec' },
propSet: [propertySpec],
objectSet: [objectSpec],
}
// TODO: research why it fails if propSet is declared after objectSet
VItest.runCommand('RetrievePropertiesEx', {
_this: propertyCollector,
specSet: [propertyFilterSpec],
options: { attributes: { type: 'RetrieveOptions' } },
})
.once('result', function (result, raw) {
assert.notEqual(result.returnval.objects, null)
if (Array.isArray(result.returnval.objects)) {
assert.strictEqual(result.returnval.objects[0].obj.attributes.type, 'VirtualMachine')
} else {
assert.strictEqual(result.returnval.objects.obj.attributes.type, 'VirtualMachine')
}
done()
})
.once('error', function (err) {
console.error('\n\nlast request : ' + VItest.client.lastRequest, err)
})
})
})
})

View File

@@ -23,7 +23,7 @@ exports.makeOnProgress = function ({ onRootTaskEnd = noop, onRootTaskStart = noo
Object.defineProperty(taskLog, '$root', { value: taskLog })
// start of a root task
onRootTaskStart(taskLog, event)
onRootTaskStart(taskLog)
} else {
// start of a subtask
const parent = taskLogs.get(parentId)
@@ -54,10 +54,10 @@ exports.makeOnProgress = function ({ onRootTaskEnd = noop, onRootTaskStart = noo
}
if (type === 'end' && taskLog.$root === taskLog) {
onRootTaskEnd(taskLog, event)
onRootTaskEnd(taskLog)
}
}
onTaskUpdate(taskLog, event)
onTaskUpdate(taskLog)
}
}

View File

@@ -7,27 +7,6 @@ function alreadyEnded() {
throw new Error('task has already ended')
}
const serializeErrors = errors => (Array.isArray(errors) ? errors.map(serializeError) : errors)
// Create a serializable object from an error.
//
// Otherwise some fields might be non-enumerable and missing from logs.
const serializeError = error => {
return error instanceof Error
? {
...error, // Copy enumerable properties.
cause: error.cause,
code: error.code,
errors: serializeErrors(error.errors), // supports AggregateError
message: error.message,
name: error.name,
stack: error.stack,
}
: error
}
exports.serializeError = serializeError
// define a read-only, non-enumerable, non-configurable property
function define(object, property, value) {
Object.defineProperty(object, property, { value })
@@ -93,11 +72,7 @@ class Task {
_onProgress
get id() {
// Use a compact, sortable, string representation of the creation date,
// and add a random part to differentiate tasks created at the same time
//
// Due to the padding, dates are sortable up to 5188-04-22T11:04:28.415Z
return (this.id = Date.now().toString(36).padStart(9, '0') + '-' + Math.random().toString(36).slice(2))
return (this.id = Math.random().toString(36).slice(2))
}
set id(value) {
define(this, 'id', value)
@@ -161,10 +136,6 @@ class Task {
assert.equal(this.#status, PENDING)
assert.equal(this.#running, false)
if (result instanceof Error && result.toJSON === undefined) {
result = serializeError(result)
}
this.#emit('end', { status, result })
this._onProgress = alreadyEnded
this.#status = status

View File

@@ -4,7 +4,7 @@ const assert = require('node:assert').strict
const { describe, it } = require('node:test')
const { makeOnProgress } = require('./combineEvents.js')
const { Task, serializeError } = require('./index.js')
const { Task } = require('./index.js')
const noop = Function.prototype
@@ -28,36 +28,6 @@ function createTask(opts) {
return task
}
describe('serializeError', function () {
it('serializes simple error', function () {
const err = new Error('test Error')
err.code = 404
const serialized = serializeError(err)
assert.equal(serialized.message, 'test Error')
assert.equal(serialized.name, 'Error')
assert.equal(serialized.code, 404)
assert.notEqual(serialized.stack, undefined)
})
it('serializes aggregate error', function () {
const err = new AggregateError([new Error('test error 1'), new Error('test error 2')], 'Error message', {
cause: 'broken',
})
const serialized = serializeError(err)
assert.equal(serialized.message, 'Error message')
assert.equal(serialized.name, 'AggregateError')
assert.equal(serialized.cause, 'broken')
assert.notEqual(serialized.stack, undefined)
assert.equal(serialized.errors.length, 2)
assert.equal(serialized.errors[0].name, 'Error')
assert.equal(serialized.errors[1].name, 'Error')
assert.equal(serialized.errors[0].message, 'test error 1')
assert.equal(serialized.errors[1].message, 'test error 2')
assert.notEqual(serialized.errors[0].stack, undefined)
assert.notEqual(serialized.errors[1].stack, undefined)
})
})
describe('Task', function () {
describe('constructor', function () {
it('data properties are passed to the start event', async function () {
@@ -136,7 +106,7 @@ describe('Task', function () {
assert.equal(task.$events.length, 3)
assertEvent(task, { type: 'start' }, 0)
assertEvent(task, { type: 'abortionRequested', reason }, 1)
assertEvent(task, { type: 'end', status: 'failure', result: serializeError(result) }, 2)
assertEvent(task, { type: 'end', status: 'failure', result }, 2)
})
it('does not abort if the task succeed', async function () {
@@ -206,7 +176,7 @@ describe('Task', function () {
assertEvent(task, {
status: 'failure',
result: serializeError(error),
result: error,
type: 'end',
})
})
@@ -505,7 +475,7 @@ describe('Task', function () {
assert.equal(task.status, 'failure')
assertEvent(task, {
status: 'failure',
result: serializeError(e),
result: e,
type: 'end',
})
})
@@ -541,7 +511,7 @@ describe('Task', function () {
assert.equal(task.status, 'failure')
assertEvent(task, {
status: 'failure',
result: serializeError(e),
result: e,
type: 'end',
})
})

View File

@@ -13,7 +13,7 @@
"url": "https://vates.fr"
},
"license": "ISC",
"version": "0.7.0",
"version": "0.6.2",
"engines": {
"node": ">=14"
},

View File

@@ -1,7 +1,7 @@
{
"name": "@vates/types",
"private": false,
"version": "2.5.0",
"version": "1.19.0",
"main": "./dist/index.mjs",
"exports": {
".": {
@@ -11,10 +11,6 @@
"./xo": "./dist/xo.d.mts",
"./xo-app": "./dist/xo-app.d.mts",
"./xen-api": "./dist/xen-api.d.mts",
"./backup-expression-context": {
"default": "./dist/backup-expression-context.mjs",
"types": "./dist/backup-expression-context.d.mts"
},
"./common": {
"default": "./dist/common.mjs",
"types": "./dist/common.d.mts"
@@ -36,9 +32,7 @@
"devDependencies": {
"@eslint/js": "^9.19.0",
"typescript": "~5.6.3",
"typescript-eslint": "^8.61.0",
"@types/express": "^5.0.0",
"@xen-orchestra/disk-transform": "^1.3.4"
"typescript-eslint": "^8.23.0"
},
"scripts": {
"build": "tsc",

View File

@@ -1,38 +0,0 @@
export const BACKUP_EXPRESSION_CONTEXT = {
run: {
dayOfWeek: { type: 'number' },
dayOfMonth: { type: 'number' },
hour: { type: 'number' },
month: { type: 'number' },
year: { type: 'number' },
},
sr: {
name_label: { type: 'string' },
type: { type: 'string' },
description: { type: 'string' },
tags: { type: 'string[]' },
},
backupRepository: {
name: { type: 'string' },
type: { type: 'string' },
tags: { type: 'string[]' },
},
vm: {
name_label: { type: 'string' },
tags: { type: 'string[]' },
power_state: { type: 'string' },
},
chainLength: { type: 'number' },
} as const
export type BACKUP_EXPRESSION_CONTEXT = (typeof BACKUP_EXPRESSION_CONTEXT)[keyof typeof BACKUP_EXPRESSION_CONTEXT]
export const BACKUP_SETTING_CONTEXT = {
healthCheckVmsWithTags: ['vm', 'run'],
fullInterval: ['vm', 'run', 'chainLength'],
vmFilter: ['vm', 'run'],
srs: ['sr', 'run'],
remotes: ['backupRepository', 'run'],
} as const
export type BACKUP_SETTING_CONTEXT = (typeof BACKUP_SETTING_CONTEXT)[keyof typeof BACKUP_SETTING_CONTEXT]

View File

@@ -1,4 +1,4 @@
import type { XapiXoRecord, XoHost, XoNetwork, XoVif } from './xo.mjs'
import type { XoHost } from './xo.mjs'
declare const __brand: unique symbol
@@ -663,19 +663,6 @@ export const SUPPORTED_VDI_FORMAT = {
export type SUPPORTED_VDI_FORMAT = (typeof SUPPORTED_VDI_FORMAT)[keyof typeof SUPPORTED_VDI_FORMAT]
/**
* Formats a VDI can be exported to by XO: the XAPI ones, plus the ones XO generates
* itself from the blocks of the disk.
*/
export const SUPPORTED_VDI_EXPORT_FORMAT = {
raw: 'raw',
vhd: 'vhd',
qcow2: 'qcow2',
vmdk: 'vmdk',
} as const
export type SUPPORTED_VDI_EXPORT_FORMAT = (typeof SUPPORTED_VDI_EXPORT_FORMAT)[keyof typeof SUPPORTED_VDI_EXPORT_FORMAT]
// ----- XAPI Stats
type XapiStatsResponse<T> = {
@@ -753,67 +740,3 @@ export type XapiVmStatsRaw = {
export type XapiVmStats = XapiStatsResponse<XapiVmStatsRaw>
export type XapiPoolStats = Record<XoHost['id'], XapiHostStats | { error: Record<string, unknown> }>
export type TrafficRuleProtocol = (typeof TRAFFIC_RULE_PROTOCOLS)[number]
export const SDN_CONTROLLER_OF_RULES_KEY = 'xo:sdn-controller:of-rules'
export const SDN_CONTROLLER_OF_METHOD_KEY = 'xo:sdn-controller:of-method'
export const SDN_CONTROLLER_OF_FORMAT_KEY = 'xo:sdn-controller:of-format'
export const TRAFFIC_RULE_PROTOCOLS = ['ARP', 'ICMP', 'IP', 'TCP', 'UDP'] as const
export const TRAFFIC_RULE_PROTOCOLS_WITH_PORT: readonly TrafficRuleProtocol[] = ['TCP', 'UDP']
export type TrafficRuleTargetType = XoNetwork['type'] | XoVif['type']
export type TrafficRuleDirection = 'from' | 'to' | 'from/to'
export type RawTrafficRule = {
allow: boolean
protocol: TrafficRuleProtocol
ipRange: string
direction: TrafficRuleDirection
port?: string
}
type BaseTrafficRule = RawTrafficRule & {
id: string
networkId: XoNetwork['id']
}
export type TrafficRule =
| (BaseTrafficRule & { type: XoVif['type']; sourceId: XoVif['id'] })
| (BaseTrafficRule & { type: XoNetwork['type']; sourceId: XoNetwork['id'] })
export const XAPI_TYPES: readonly string[] = [
'network',
'VIF',
'message',
'gpuGroup',
'host',
'PBD',
'PCI',
'PGPU',
'PIF',
'pool',
'SR',
'VBD',
'VDI',
'VDI-snapshot',
'VDI-unmanaged',
'vgpu',
'vgpuType',
'VM',
'VM-controller',
'VM-snapshot',
'VM-template',
'VTPM',
'SM',
] satisfies readonly XapiXoRecord['type'][]
/** steps of a rolling pool update on one host, in execution order */
export const RPU_RECOVERY_STEP_NAMES = ['evacuate', 'update', 'reboot', 'enable', 'restoreVms'] as const
export type RPU_RECOVERY_STEP_NAME = (typeof RPU_RECOVERY_STEP_NAMES)[number]
export const COMMON_TAG = {
NOBAK: 'xo:no-bak',
} as const

View File

@@ -3,7 +3,4 @@ export * from './xen-api.mjs'
export * from './xo.mjs'
export * from './xo-app.mjs'
export * from './lib/xen-orchestra-xapi.mjs'
export * from './lib/xen-orchestra-acl.mjs'
export * from './lib/xen-orchestra-plugins.mjs'
export * from './lib/complex-matcher.mjs'
export * from './lib/rest-api.mjs'

View File

@@ -1,96 +0,0 @@
import type { NextFunction, Request, Response } from 'express'
import type { VatesTask } from './vates-task.mjs'
import type { XoApp } from '../xo-app.mjs'
export type SecurityName = '*' | 'token' | 'basic' | 'none'
export type FieldDefinition = { optional?: boolean; nullable?: boolean } & (
| {
type: 'string'
example?: string
}
| {
type: 'boolean'
example?: boolean
}
| {
type: 'number'
example?: number
}
| {
type: 'enum'
enum: string[]
example?: string
}
| {
type: 'object'
fields: Record<string, FieldDefinition>
}
| {
type: 'array'
items: FieldDefinition
example?: unknown[]
}
)
export type ParamFieldDefinition = Exclude<
FieldDefinition,
{ type: 'boolean' } | { type: 'object' } | { type: 'array' }
>
export type QueryFieldDefinition = Exclude<FieldDefinition, { type: 'object' } | { type: 'array' }>
type RestApi = object & { xoApp: XoApp }
export type CreateAction = <Result>(
callback: (task: VatesTask) => MaybePromise<Result>,
opts: {
sync?: boolean
statusCode?: number
taskProperties: { name: string; [key: string]: unknown }
}
) => Promise<Result | undefined>
export interface BaseRouteDefinition<Middleware> {
method: 'get' | 'post' | 'put' | 'delete' | 'patch'
endpoint: string
description?: string
tags?: string[]
params?: Record<string, ParamFieldDefinition>
query?: Record<string, QueryFieldDefinition>
body?: Record<string, FieldDefinition>
responses?: Array<{
status: number
description: string
schema?: Record<string, FieldDefinition>
}>
middlewares?: Middleware[]
scope?: 'acl'
callback: (params: {
req: Request
res: Response
next: NextFunction
restApi: RestApi
createAction: CreateAction
}) => MaybePromise<unknown>
security?: SecurityName
}
export type MiddlewareDescriptor =
| { name: 'json' | 'urlencoded' | 'text' | 'raw'; options?: Record<string, unknown> }
| { name: 'acl'; acls: LooseAclEntry | LooseAclEntry[] }
type MaybePromise<T> = T | Promise<T>
/**
* Loosely-typed mirror of {@link https://github.com/vatesfr/xen-orchestra/blob/master/@xen-orchestra/rest-api/src/middlewares/acl.middleware.mts | AclEntry}
* from `@xen-orchestra/rest-api/src/middlewares/acl.middleware.mts`.
*/
export type LooseAclEntry = {
resource: string
action?: string | ((opts: { req: object; restApi: RestApi }) => string | undefined)
actions?: string[] | ((opts: { req: object; restApi: RestApi }) => string[])
objectId?: string | ((opts: { req: object; restApi: RestApi }) => string)
objectIds?: string[] | ((opts: { req: object; restApi: RestApi }) => string[])
object?: object | ((opts: { req: object; restApi: RestApi }) => MaybePromise<object> | undefined)
objects?: object[] | ((opts: { req: object; restApi: RestApi }) => MaybePromise<object[]> | undefined)
getObject?: (opts: { restApi: RestApi }) => (id: string) => MaybePromise<object>
}
export type PluginRestRouteDefinition = BaseRouteDefinition<MiddlewareDescriptor>

View File

@@ -1,83 +0,0 @@
import { Branded } from '../common.mjs'
import type { XoGroup, XoUser } from '../xo.mjs'
type XoAclBaseRole = {
id: Branded<'acl-v2-role'>
name: string
description?: string
privilegeIds: XoAclBasePrivilege['id'][]
}
type XoAclRegularRole = XoAclBaseRole & {
groupIds: XoGroup['id'][]
userIds: XoUser['id'][]
}
type XoAclTemplateRole = XoAclBaseRole & {
isTemplate: true
roleTemplateId: number
}
export type XoAclRole = XoAclRegularRole | XoAclTemplateRole
export type XoAclSupportedActionsByResource = {
[resource: string]: Record<string, unknown>
}
/**
* E.g
* vms {
* shutdown: {
* clean: true,
* hard: true,
* }
* }
*
* `shutdown | shutdown:clean | shutdown:hard`
*/
export type GetKeysRecursively<T, Prefix extends string = ''> = {
[K in keyof T]: T[K] extends object
? K extends string
? `${Prefix}${K}` | GetKeysRecursively<T[K], `${Prefix}${K}:`>
: never
: K extends string
? `${Prefix}${K}`
: never
}[keyof T]
export type XoAclSupportedResource<TActionsByResource extends XoAclSupportedActionsByResource> =
keyof TActionsByResource
export type XoAclSupportedActions<
TActionsByResource extends XoAclSupportedActionsByResource,
TResource extends XoAclSupportedResource<TActionsByResource>,
> = (GetKeysRecursively<TActionsByResource[TResource]> & string) | '*'
export type XoAclBasePrivilege = {
id: Branded<'acl-v2-privilege'>
resource: string
action: string
selector?: string
effect: 'allow' | 'deny'
roleId: XoAclRole['id']
}
export type XoAclPrivilege<
TActionsByResource extends XoAclSupportedActionsByResource,
TResource extends XoAclSupportedResource<TActionsByResource>,
> = XoAclBasePrivilege & {
resource: TResource
action: XoAclSupportedActions<TActionsByResource, TResource>
}
export type XoUserRole = {
id: string
roleId: XoAclRole['id']
userId: XoUser['id']
}
export type XoGroupRole = {
id: string
roleId: XoAclRole['id']
groupId: XoGroup['id']
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
import {
WrappedXenApiRecord,
XenApiHost,
XenApiHostWrapped,
XenApiNetwork,
XenApiNetworkWrapped,
XenApiRecord,
@@ -14,21 +13,9 @@ import {
XenApiVmWrapped,
XenApiVtpm,
} from '../xen-api.mjs'
import type {
BOND_MODE,
OPAQUE_REF_NULL,
SUPPORTED_VDI_FORMAT,
VBD_MODE,
VBD_TYPE,
VDI_TYPE,
VIF_LOCKING_MODE,
VM_OPERATIONS,
} from '../common.mjs'
import type { DiskPassthrough } from '@xen-orchestra/disk-transform'
import type { EventEmitter } from 'node:events'
import type { OPAQUE_REF_NULL, SUPPORTED_VDI_FORMAT, VBD_MODE, VBD_TYPE, VIF_LOCKING_MODE } from '../common.mjs'
import type { PassThrough, Readable } from 'node:stream'
import type {
XapiXoRecord,
XoGpuGroup,
XoHost,
XoNetwork,
@@ -40,67 +27,8 @@ import type {
XoVm,
XoVmTemplate,
XoVif,
XoVmSnapshot,
} from '../xo.mjs'
/**
* Properties accepted by {@link Xapi.editVm}.
*
* Field names match the canonical (camelCase) properties defined by the
* underlying `_editVm` (via `makeEditObject`). `_editVm` still resolves the
* snake_case and legacy aliases, but the canonical names are used here.
*/
export interface EditVmProps {
affinityHost?: string | null
autoPoweron?: boolean
blockedOperations?: Partial<Record<VM_OPERATIONS, boolean | string | null>>
coresPerSocket?: number | string | null
cpuCap?: number | null
cpuMask?: number[]
cpuWeight?: number | null
cpus?: number
cpusStaticMax?: number | string
/**
* Update VM creation metadata stored under `other_config.xo:*`. The object is
* merged with the existing data.
*/
creation?: { user?: string }
expNestedHvm?: boolean
hasVendorDevice?: boolean
highAvailability?: 'best-effort' | 'restart' | ''
hvmBootFirmware?: string | null
memory?: number | string
memoryMax?: number | string
memoryMin?: number | string
memoryStaticMax?: number | string
nameDescription?: string
nameLabel?: string
nestedVirt?: boolean
nicType?: string | null
notes?: string | null
PV_args?: string
secureBoot?: boolean
startDelay?: number
suspendSr?: string | null
tags?: string[]
uefiMode?: 'setup' | 'user'
vga?: 'std' | 'cirrus'
videoram?: 1 | 2 | 4 | 8 | 16
viridian?: boolean
virtualizationMode?: 'pv' | 'hvm'
xenStoreData?: Record<string, string | null>
}
/** Properties accepted by {@link Xapi.editVif}. */
export interface EditVifProps {
ipv4Allowed?: string[]
ipv6Allowed?: string[]
lockingMode?: VIF_LOCKING_MODE
// in kB/s, `null` removes the limit
rateLimit?: number | null
txChecksumming?: boolean
}
export type XcpPatches = {
changelog?: {
author: string
@@ -130,40 +58,16 @@ export type XsPatches = {
uuid?: string
}
type WrappedRecordOfType<Type extends WrappedXenApiRecord['$type']> = Extract<WrappedXenApiRecord, { $type: Type }>
export interface Xapi {
status: string
pool?: { uuid: string }
sessionId: string
_auth: { user: string; password: string }
_url: { protocol: string; hostname: string; hostnameRaw: string; port?: string }
call: <ReturnType>(...args: unknown[]) => Promise<ReturnType>
callAsync: <ReturnType>(...args: unknown[]) => Promise<ReturnType>
barrier<T extends XenApiRecord>(ref: T['$ref']): Promise<Extract<WrappedXenApiRecord, { $ref: T['$ref'] }>>
connectVif(vifId: XoVif['id']): Promise<void>
getField<T extends XenApiRecord, K extends keyof T>(
type: Extract<WrappedXenApiRecord, T>['$type'],
ref: T['$ref'],
field: K
): Promise<T[K]>
// the record is inferred from `type`, for a JS caller which cannot spell it out as a type argument
getField<Type extends WrappedXenApiRecord['$type'], Field extends keyof WrappedRecordOfType<Type>>(
type: Type,
ref: WrappedRecordOfType<Type>['$ref'],
field: Field
): Promise<WrappedRecordOfType<Type>[Field]>
/** Synchronous: the record as the object cache currently holds it. */
getObjectByRef<Ref extends WrappedXenApiRecord['$ref']>(ref: Ref): Extract<WrappedXenApiRecord, { $ref: Ref }>
getObject: <
XoRecord extends XapiXoRecord,
WrappedRecord extends WrappedXenApiRecord = Extract<WrappedXenApiRecord, { $type: XoRecord['type'] }>,
>(
idOrUuidOrRef: XoRecord['id'] | WrappedRecord['$ref'] | WrappedRecord['uuid'],
defaultValue?: WrappedRecord
) => WrappedRecord
createNetwork(
params:
| {
@@ -180,24 +84,9 @@ export interface Xapi {
vlan: number
}
): Promise<XenApiNetworkWrapped>
createBondedNetwork(params: {
bondMode: BOND_MODE
pifIds: XoPif['id'][]
name: string
description?: string
mtu?: number
}): Promise<XenApiNetworkWrapped>
deleteNetwork(id: XoNetwork['id']): Promise<void>
deleteVif(vifId: XoVif['id']): Promise<void>
disconnectVif(vifId: XoVif['id']): Promise<void>
exportVmOva(vmRef: XenApiVm['$ref']): Promise<PassThrough>
emergencyShutdownHost(hostId: XoHost['id']): Promise<void>
ejectHostFromPool(hostId: XoHost['id']): Promise<void>
host_smartReboot(
hostRef: XenApiHost['$ref'],
bypassBlockedSuspend?: boolean,
bypassCurrentVmCheck?: boolean
): Promise<void>
migrateVm(
vmId: XoVm['id'],
hostXapi: Xapi,
@@ -211,70 +100,10 @@ export interface Xapi {
bypassAssert?: boolean
}
): Promise<void>
joinPool(masterAddress: string, masterUsername: string, masterPassword: string, force?: boolean): Promise<void>
listMissingPatches(host: XoHost['id']): Promise<XcpPatches[] | XsPatches[]>
pool_emergencyShutdown(): Promise<void>
resumeVm(id: XoVm['id']): Promise<void>
revertVm(snapshotId: XoVmSnapshot['id']): Promise<void>
unpauseVm(id: XoVm['id']): Promise<void>
cloneVm(
vmId: XoVm['id'],
opts?: {
nameLabel?: string
fast?: boolean
}
): Promise<XenApiVmWrapped>
copyVm(
vmId: XoVm['id'],
opts?: {
nameLabel?: string
srOrSrId?: XoSr['id'] | XoSr
}
): Promise<XenApiVmWrapped>
remoteCopyVm(
vmId: XoVm['id'],
targetXapi: Xapi,
targetSrId: XoSr['id'],
opts?: {
compress?: 'zstd' | 'gzip' | boolean
nameLabel?: string
}
): Promise<{ vm: XenApiVmWrapped }>
forgetSr(id: XoSr['id']): Promise<void>
forgetHost(hostId: XoHost['id']): Promise<void>
powerOnHost(hostId: XoHost['id']): Promise<void>
rebootHost(hostId: XoHost['id'], force?: boolean): Promise<void>
shutdownHost(hostId: XoHost['id'], opts?: { force?: boolean; bypassEvacuate?: boolean }): Promise<void>
SR_create(params: {
content_type?: XoSr['content_type']
// index signature, not `Record<string, string>`: tsoa emits an empty schema for `Record`
// (https://github.com/lukeautry/tsoa/pull/1858). device_config keys are driver-specific (SR_type).
device_config: {
chapuser?: string
chappassword?: string
device?: string
legacy_mode?: string
location?: string
nfsversion?: string
options?: string
password?: string
SCSIid?: string
server?: string
serverpath?: string
target?: string
targetIQN?: string
username?: string
} & {
[key: string]: string
}
host: XenApiHost['$ref']
name_description?: XoSr['name_description']
name_label: XoSr['name_label']
physical_size?: number
shared: boolean
sm_config?: { [key: string]: string }
type: XoSr['SR_type']
}): Promise<XenApiSr['$ref']>
SR_importVdi(
ref: XenApiSr['$ref'],
stream: Readable,
@@ -292,49 +121,6 @@ export interface Xapi {
xenstore_data?: Record<string, string>
}
): Promise<XenApiVdi['$ref']>
SR_reclaimSpace(ref: XenApiSr['$ref']): Promise<void>
VBD_create(params: {
bootable?: boolean
currently_attached?: boolean
device?: string
empty?: boolean
mode?: VBD_MODE
other_config?: Record<string, string>
qos_algorithm_params?: Record<string, string>
qos_algorithm_type?: string
/** a plug failure, only logged otherwise, is thrown: the VBD exists all the same */
throwVbdPlug?: boolean
type?: VBD_TYPE
unpluggable?: boolean
userdevice?: string
VDI?: XenApiVdi['$ref']
VM: XenApiVm['$ref']
}): Promise<XenApiVbd['$ref']>
/** unplugs the VBD first, if needed */
VBD_destroy(ref: XenApiVbd['$ref']): Promise<void>
VDI_create(
params: {
name_description?: string
name_label: string
other_config?: Record<string, string>
read_only?: boolean
sharable?: boolean
SR?: XenApiSr['$ref']
tags?: string[]
type?: VDI_TYPE
virtual_size: number
xenstore_data?: Record<string, string>
},
opts?: { sm_config?: Record<string, string> }
): Promise<XenApiVdi['$ref']>
/** a VDI which no longer exists counts as destroyed */
VDI_destroy(ref: XenApiVdi['$ref']): Promise<void>
editVm(
id: XoVm['id'],
props: EditVmProps,
checkLimits?: (limits: Record<string, number>, vm: XenApiVmWrapped) => Promise<void>
): Promise<void>
editVif(id: XoVif['id'], props: EditVifProps): Promise<void>
startVm(
id: XoVm['id'],
opts?: {
@@ -352,7 +138,7 @@ export interface Xapi {
): Promise<void>
VM_export(
vmRef: XenApiVm['$ref'],
opts?: { cancelToken?: unknown; compress?: boolean | 'zstd' | 'gzip'; useSnapshot?: boolean }
opts?: { cancelToken?: unknown; compress?: boolean; useSnapshot?: boolean }
): ReturnType<Xapi['getResource']>
VM_import(
stream: Readable,
@@ -364,7 +150,6 @@ export interface Xapi {
metadataVm: {
affinityHost?: XoHost['id']
autoPoweron?: boolean
cpus?: number
memory?: number
name_description?: string
name_label: string
@@ -406,7 +191,6 @@ export interface Xapi {
vgpuType?: XoVgpuType['id']
gpuGroup?: XoGpuGroup['id']
copyHostBiosStrings?: boolean
highAvailability?: XoVm['high_availability']
hvmBootFirmware?: 'uefi' | 'bios'
secureBoot?: boolean
},
@@ -434,23 +218,6 @@ export interface Xapi {
}): Promise<XenApiVbd['$ref']>
VBD_destroy(vbdRef: XenApiVbd['$ref']): Promise<void>
VBD_unplug(vbdRef: XenApiVbd['$ref']): Promise<void>
VDI_create(
options: {
name_description?: XoVdi['name_description']
name_label?: XoVdi['name_label']
other_config?: XoVdi['other_config']
read_only?: boolean
sharable?: boolean
SR?: XenApiSr['$ref']
tags?: XoVdi['tags']
type?: VDI_TYPE
virtual_size: XoVdi['size']
xenstore_data?: Record<string, string>
},
extraOptions?: {
sm_config?: Record<string, string>
}
): Promise<XenApiVdi['$ref']>
VDI_destroy(vdiRef: XenApiVdi['$ref']): Promise<void>
VDI_destroyCloudInitConfig(vdiRef: XenApiVdi['$ref'], opts?: { timeLimit?: number }): Promise<void>
VDI_exportContent(
@@ -459,7 +226,7 @@ export interface Xapi {
): Promise<Readable & { length?: number }>
VDI_importContent(
vdiRef: XenApiVdi['$ref'],
stream: Readable | Buffer,
stream: Readable,
opts: { cancelToken?: unknown; format: SUPPORTED_VDI_FORMAT }
): Promise<void>
VIF_create(
@@ -498,74 +265,6 @@ export interface Xapi {
pathname: string,
params?: { host?: XenApiHost; query?: Record<string, unknown>; task?: boolean | XenApiTask['$ref'] }
): Promise<{ body: Readable }>
clearHost(
host: Pick<XenApiHostWrapped, '$ref' | '$pool'>,
force?: boolean,
opts?: { transient?: boolean }
): Promise<void>
disableHost(hostId: XoHost['id'], opts?: { transient?: boolean }): Promise<void>
enableHost(hostId: XoHost['id']): Promise<void>
getRecordByUuid<
Type extends WrappedXenApiRecord['$type'],
XenApiRecord extends WrappedXenApiRecord = Extract<WrappedXenApiRecord, { $type: Type }>,
>(
type: Type,
uuid: XenApiRecord['uuid']
): Promise<XenApiRecord>
isHyperThreadingEnabled(hostId: XoHost['id']): Promise<boolean | null>
VTPM_create(params: { VM: XenApiVm['$ref']; is_unique?: boolean; contents?: string }): Promise<XenApiVtpm['$ref']>
destroySr(id: XoSr['id']): Promise<void>
xostor_delete(ref: XenApiSr['$ref']): Promise<void>
objects: {
// emits `add`/`update`/`remove` only while the connection watches the pool events
allIndexes: {
type: {
getEventEmitterByType(type: WrappedXenApiRecord['$type']): EventEmitter
}
}
indexes: {
type: {
[XenApiRecord in WrappedXenApiRecord as XenApiRecord['$type']]: Record<XenApiRecord['uuid'], XenApiRecord>
}
}
}
}
/**
* Parameters accepted by the `XapiDiskSource` constructor.
*
* `baseRef` turns the export into a differencing one: leaving it out exports the whole disk.
* `preferNbd` only expresses a preference, {@link XapiDiskSource} silently falls back to a plain
* stream export — and, when `baseRef` can't be used as a base, to a full one.
*/
export interface XapiDiskSourceOptions {
baseRef?: XenApiVdi['$ref']
blockSize?: number
nbdConcurrency?: number
/** When true, only `getBlockIndexes()` may be called: `readBlock()` throws. */
onlyListChangedBlocks?: boolean
preferNbd?: boolean
timeout?: number
vdiRef: XenApiVdi['$ref']
xapi: Xapi
}
/**
* Disk source handling the fallback logic of a VDI export: NBD + CBT, then NBD + stream export
* for the block list, then plain stream export.
*
* {@link XapiDiskSourceOptions.preferNbd} is a preference, not a guarantee: call
* {@link XapiDiskSource.useNbd} and {@link XapiDiskSource.useCbt} after `init()` to know what the
* export actually used.
*/
export interface XapiDiskSource extends DiskPassthrough {
useCbt(): boolean
useNbd(): boolean
}
/**
* Signature of `@xen-orchestra/xapi`'s `parseDateTime`.
*
* Returns a Unix timestamp in seconds, or `null` if the field is empty (as encoded by XAPI).
*/
export type ParseDateTime = (input: string | number | Date) => number | null

View File

@@ -82,7 +82,6 @@ type TagCallMethods = {
type WrapperXenApi<T, Type extends string, Fn = { (): void }> = T & {
$call: Fn
$callAsync: Fn
$pool: XenApiPool
$type: Type
$snapshot(params: {
cancelToken?: unknown
@@ -192,7 +191,6 @@ export interface XenApiPool {
ha_host_failures_to_tolerate: number
ha_overcommitted: boolean
ha_plan_exists_for: number
ha_reboot_vm_on_internal_shutdown?: boolean
ha_statefiles: string[]
health_check_config: Record<string, string>
igmp_snooping_enabled?: boolean
@@ -492,9 +490,7 @@ export interface XenApiDrTask {
uuid: string
}
type XenApiHostCallMethods = TagCallMethods & {
<T>(method: 'call_plugin', plugin: string, fn: string, args: Record<string, string>): Promise<T>
}
type XenApiHostCallMethods = TagCallMethods & {}
export interface XenApiHost {
$ref: Branded<'host'>
address: string
@@ -571,9 +567,7 @@ export interface XenApiHost {
uuid: string
virtual_hardware_platform_versions: number[]
}
export interface XenApiHostWrapped extends WrapperXenApi<XenApiHost, 'host', XenApiHostCallMethods> {
$restartAgent(): Promise<void>
}
export type XenApiHostWrapped = WrapperXenApi<XenApiHost, 'host', XenApiHostCallMethods>
export interface XenApiHostCrashdump {
$ref: Branded<'host_crashdump'>
@@ -628,9 +622,7 @@ export interface XenApiHostCpu {
vendor: string
}
type XenApiNetworkCallMethods = TagCallMethods & {
(method: 'add_purpose', purpose: NETWORK_PURPOSE): Promise<void>
}
type XenApiNetworkCallMethods = TagCallMethods & {}
export interface XenApiNetwork {
$ref: Branded<'network'>
@@ -824,10 +816,7 @@ export interface XenApiSr {
VDIs: XenApiVdi['$ref'][]
virtual_allocation: number
}
export type XenApiSrWrapped = WrapperXenApi<XenApiSr, 'SR', XenApiSrCallMethods> & {
// resolved by xen-api from `PBDs`
$PBDs: XenApiPbdWrapped[]
}
export type XenApiSrWrapped = WrapperXenApi<XenApiSr, 'SR', XenApiSrCallMethods>
export interface XenApiSrStat {
$ref: Branded<'sr_stat'>

Some files were not shown because too many files have changed in this diff Show More