mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-09 17:06:13 -06:00
Compare commits
7 Commits
326c152fac
...
iscsi-03-x
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adfeee2fc3 | ||
|
|
db51aa791e | ||
|
|
ee79a4046e | ||
|
|
29ef485e74 | ||
|
|
13ea8c0996 | ||
|
|
b18fb1b8d3 | ||
|
|
056efe9652 |
246
@vates/iscsi/.USAGE.md
Normal file
246
@vates/iscsi/.USAGE.md
Normal file
@@ -0,0 +1,246 @@
|
||||
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`, `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.
|
||||
|
||||
### 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
|
||||
}
|
||||
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.
|
||||
1
@vates/iscsi/.npmignore
Symbolic link
1
@vates/iscsi/.npmignore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../scripts/npmignore
|
||||
279
@vates/iscsi/README.md
Normal file
279
@vates/iscsi/README.md
Normal file
@@ -0,0 +1,279 @@
|
||||
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
|
||||
|
||||
# @vates/iscsi
|
||||
|
||||
[](https://npmjs.org/package/@vates/iscsi)  [](https://bundlephobia.com/result?p=@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`, `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.
|
||||
|
||||
### 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
|
||||
}
|
||||
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)
|
||||
6
@vates/iscsi/eslint.config.mjs
Normal file
6
@vates/iscsi/eslint.config.mjs
Normal file
@@ -0,0 +1,6 @@
|
||||
// @ts-check
|
||||
|
||||
import eslint from '@eslint/js'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(eslint.configs.recommended, tseslint.configs.recommended)
|
||||
61
@vates/iscsi/package.json
Normal file
61
@vates/iscsi/package.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "@vates/iscsi",
|
||||
"version": "0.1.0",
|
||||
"main": "dist/index.mjs",
|
||||
"types": "dist/index.d.mts",
|
||||
"bin": {
|
||||
"vates-iscsi": "dist/cli.mjs"
|
||||
},
|
||||
"license": "ISC",
|
||||
"private": true,
|
||||
"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.0",
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
263
@vates/iscsi/src/CachedDiskBlockDevice.mts
Normal file
263
@vates/iscsi/src/CachedDiskBlockDevice.mts
Normal file
@@ -0,0 +1,263 @@
|
||||
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 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.
|
||||
*
|
||||
* 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
|
||||
|
||||
// one bit per source block, 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 }: 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
|
||||
}
|
||||
|
||||
/** Read the source geometry and allocate 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)
|
||||
this.#bitmap = Buffer.alloc(Math.ceil(this.#blockCount / 8))
|
||||
log.debug('opened', { size, blockSize, diskBlockSize, blockCount: this.#blockCount })
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
async hydrate({ concurrency = DEFAULT_HYDRATE_CONCURRENCY }: { concurrency?: number } = {}): Promise<void> {
|
||||
const blockCount = this.#blockCount
|
||||
await asyncEach(
|
||||
(function* allBlockIndexes() {
|
||||
for (let index = 0; index < blockCount; index++) {
|
||||
yield index
|
||||
}
|
||||
})(),
|
||||
index => this.#ensureBlock(index),
|
||||
{ concurrency }
|
||||
)
|
||||
}
|
||||
|
||||
#isCached(index: number): boolean {
|
||||
return (this.#bitmap[index >> 3] & (1 << (index & 7))) !== 0
|
||||
}
|
||||
|
||||
#setCached(index: number): void {
|
||||
if (!this.#isCached(index)) {
|
||||
this.#bitmap[index >> 3] |= 1 << (index & 7)
|
||||
this.#cachedCount++
|
||||
this.#reportProgress()
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
const diskBlockSize = this.#diskBlockSize as number
|
||||
const size = this.getSize()
|
||||
const end = offset + data.length
|
||||
const last = Math.floor((end - 1) / diskBlockSize)
|
||||
|
||||
// Blocks entirely covered by this write need no fetch — the write provides
|
||||
// all of their bytes. The others must be materialized first, so that the
|
||||
// bytes this write does not touch are the source's and not the cache's.
|
||||
const overwritten = []
|
||||
for (let index = Math.floor(offset / diskBlockSize); index <= last; index++) {
|
||||
if (this.#isCached(index)) {
|
||||
continue
|
||||
}
|
||||
const start = index * diskBlockSize
|
||||
if (offset <= start && end >= Math.min(start + diskBlockSize, size)) {
|
||||
overwritten.push(index)
|
||||
} else {
|
||||
await this.#ensureBlock(index)
|
||||
}
|
||||
}
|
||||
|
||||
await this.#cache.write(offset, data)
|
||||
|
||||
// only now: a failed write must not leave a block marked as present
|
||||
for (const index of overwritten) {
|
||||
this.#setCached(index)
|
||||
}
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
await this.#cache.flush()
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
try {
|
||||
await this.#cache.close()
|
||||
} finally {
|
||||
await this.#disk.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
465
@vates/iscsi/src/CachedDiskBlockDevice.test.mts
Normal file
465
@vates/iscsi/src/CachedDiskBlockDevice.test.mts
Normal file
@@ -0,0 +1,465 @@
|
||||
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/)
|
||||
})
|
||||
})
|
||||
|
||||
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('skips the fetch when the write covers whole blocks', async () => {
|
||||
const { device, disk } = await make()
|
||||
|
||||
await device.write(0, Buffer.alloc(DISK_BLOCK_SIZE, 0xee))
|
||||
|
||||
assert.deepEqual(disk.readBlockCalls, [])
|
||||
assert.deepEqual(device.getMaterialized(), { blocks: 1, total: BLOCK_COUNT })
|
||||
})
|
||||
|
||||
it('leaves the block cold when the cache write fails', async () => {
|
||||
const { device, cache, disk } = await make()
|
||||
cache.failNextWrite = true
|
||||
|
||||
await assert.rejects(device.write(0, Buffer.alloc(DISK_BLOCK_SIZE, 0xee)), /cache write failed/)
|
||||
|
||||
assert.deepEqual(device.getMaterialized(), { blocks: 0, total: BLOCK_COUNT })
|
||||
// so a later read still materializes from the source
|
||||
await device.read(0, 512)
|
||||
assert.deepEqual(disk.readBlockCalls, [0])
|
||||
})
|
||||
})
|
||||
|
||||
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('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('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)
|
||||
})
|
||||
})
|
||||
120
@vates/iscsi/src/DiskBlockDevice.mts
Normal file
120
@vates/iscsi/src/DiskBlockDevice.mts
Normal file
@@ -0,0 +1,120 @@
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Always throws: the source disk is read-only. The target answers CHECK
|
||||
* CONDITION / MEDIUM ERROR, which initiators surface as an I/O error.
|
||||
*/
|
||||
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()
|
||||
}
|
||||
}
|
||||
169
@vates/iscsi/src/DiskBlockDevice.test.mts
Normal file
169
@vates/iscsi/src/DiskBlockDevice.test.mts
Normal file
@@ -0,0 +1,169 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
98
@vates/iscsi/src/IscsiDisk.mts
Normal file
98
@vates/iscsi/src/IscsiDisk.mts
Normal file
@@ -0,0 +1,98 @@
|
||||
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 }
|
||||
}
|
||||
}
|
||||
131
@vates/iscsi/src/backend.mts
Normal file
131
@vates/iscsi/src/backend.mts
Normal file
@@ -0,0 +1,131 @@
|
||||
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
|
||||
/** 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. */
|
||||
readonly path: string
|
||||
/** Logical block size in bytes. Defaults to 512. */
|
||||
readonly blockSize?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@link BlockDevice} backed by a regular (sparse) file 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.
|
||||
*/
|
||||
export class FileBlockDevice implements BlockDevice {
|
||||
readonly #path: string
|
||||
readonly #blockSize: number
|
||||
#handle?: FileHandle
|
||||
#size = 0
|
||||
|
||||
constructor({ path, blockSize = DEFAULT_BLOCK_SIZE }: FileBlockDeviceOptions) {
|
||||
if (!Number.isInteger(blockSize) || blockSize <= 0) {
|
||||
throw new Error(`blockSize must be a positive integer, got ${blockSize}`)
|
||||
}
|
||||
this.#path = path
|
||||
this.#blockSize = blockSize
|
||||
}
|
||||
|
||||
/** 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 { size } = await handle.stat()
|
||||
if (size % this.#blockSize !== 0) {
|
||||
throw new Error(`backing file size (${size}) is not a multiple of block size (${this.#blockSize})`)
|
||||
}
|
||||
this.#handle = handle
|
||||
this.#size = size
|
||||
} 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.allocUnsafe(length)
|
||||
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) {
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
90
@vates/iscsi/src/chap.mts
Normal file
90
@vates/iscsi/src/chap.mts
Normal file
@@ -0,0 +1,90 @@
|
||||
// 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)
|
||||
}
|
||||
108
@vates/iscsi/src/chap.test.mts
Normal file
108
@vates/iscsi/src/chap.test.mts
Normal file
@@ -0,0 +1,108 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
141
@vates/iscsi/src/cli.mts
Executable file
141
@vates/iscsi/src/cli.mts
Executable file
@@ -0,0 +1,141 @@
|
||||
#!/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)
|
||||
520
@vates/iscsi/src/connection.mts
Normal file
520
@vates/iscsi/src/connection.mts
Normal file
@@ -0,0 +1,520 @@
|
||||
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, LoginNegotiator, 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, 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
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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> {
|
||||
try {
|
||||
for (;;) {
|
||||
const pdu = await readPdu(this.#socket)
|
||||
if (pdu === null) {
|
||||
break // peer closed the connection
|
||||
}
|
||||
const finished = await this.#dispatch(pdu)
|
||||
if (finished) {
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.#fail(error)
|
||||
} finally {
|
||||
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. */
|
||||
#updateCommandWindow(pdu: IncomingPdu): void {
|
||||
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))
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
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> {
|
||||
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))
|
||||
.catch(error => {
|
||||
this.#fail(error)
|
||||
})
|
||||
return
|
||||
}
|
||||
await handleScsiCommand(cdb, pdu.itt, this, this.#deps.identity)
|
||||
}
|
||||
|
||||
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,
|
||||
buffer: Buffer.allocUnsafe(totalLength),
|
||||
totalLength,
|
||||
received: 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
|
||||
}
|
||||
pdu.data.copy(pending.buffer, pdu.readU32(40)) // BufferOffset is absolute
|
||||
pending.received += pdu.data.length
|
||||
log.debug('write data-out', {
|
||||
itt: pending.itt,
|
||||
offset: pdu.readU32(40),
|
||||
length: pdu.data.length,
|
||||
received: pending.received,
|
||||
total: pending.totalLength,
|
||||
})
|
||||
|
||||
if (pending.received >= pending.totalLength) {
|
||||
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 (pending.received >= pending.solicited) {
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
198
@vates/iscsi/src/constants.mts
Normal file
198
@vates/iscsi/src/constants.mts
Normal file
@@ -0,0 +1,198 @@
|
||||
// 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,
|
||||
} 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 },
|
||||
} 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')}`
|
||||
}
|
||||
30
@vates/iscsi/src/declarations.d.mts
Normal file
30
@vates/iscsi/src/declarations.d.mts
Normal file
@@ -0,0 +1,30 @@
|
||||
// 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
|
||||
}
|
||||
193
@vates/iscsi/src/index.mts
Normal file
193
@vates/iscsi/src/index.mts
Normal file
@@ -0,0 +1,193 @@
|
||||
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 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_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
|
||||
}
|
||||
|
||||
/**
|
||||
* A minimal iSCSI target exposing exactly one read/write LUN.
|
||||
*
|
||||
* Single initiator, single connection (`MaxConnections=1`),
|
||||
* `ErrorRecoveryLevel=0`, no digests. A new connection always replaces the
|
||||
* current one rather than being refused (see `#onConnection`): 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. 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
|
||||
|
||||
#server?: Server
|
||||
#connection?: 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.#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,
|
||||
}
|
||||
}
|
||||
|
||||
#onConnection(socket: Socket): void {
|
||||
const existing = this.#connection
|
||||
if (existing !== undefined) {
|
||||
// Replaces a possibly-stale connection rather than refusing; see the class doc.
|
||||
log.warn('replacing existing connection with a new one', { remote: socket.remoteAddress })
|
||||
existing.destroy()
|
||||
}
|
||||
socket.setNoDelay(true)
|
||||
const connection = new Connection(socket, this.#deps())
|
||||
this.#connection = connection
|
||||
// Long-lived per-connection task; serve() handles its own errors and never
|
||||
// rejects, so detaching it here is safe.
|
||||
void connection.serve().finally(() => {
|
||||
if (this.#connection === connection) {
|
||||
this.#connection = undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 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))
|
||||
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 the active connection, and close the LUN. */
|
||||
async close(): Promise<void> {
|
||||
const server = this.#server
|
||||
const connection = this.#connection
|
||||
this.#server = undefined
|
||||
this.#connection = undefined
|
||||
if (server !== undefined) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close(error => (error ? reject(error) : resolve()))
|
||||
// Drop the live connection so server.close() can complete.
|
||||
connection?.destroy()
|
||||
})
|
||||
}
|
||||
await this.#lun.close()
|
||||
}
|
||||
}
|
||||
451
@vates/iscsi/src/initiator.mts
Normal file
451
@vates/iscsi/src/initiator.mts
Normal file
@@ -0,0 +1,451 @@
|
||||
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
|
||||
|
||||
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))))
|
||||
await this.#login()
|
||||
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
|
||||
}
|
||||
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)
|
||||
await this.#send(assemblePdu(bhs))
|
||||
await pTimeout.call(readPdu(socket), this.#messageTimeoutMs)
|
||||
} catch (error) {
|
||||
log.debug('logout failed, closing anyway', { error })
|
||||
} finally {
|
||||
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 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 until the backlog empties. Only ever one runs at a time. */
|
||||
async #pump(): Promise<void> {
|
||||
if (this.#reading) {
|
||||
return
|
||||
}
|
||||
this.#reading = true
|
||||
try {
|
||||
while (this.#backlog.size > 0) {
|
||||
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) {
|
||||
this.#rejectAll(error instanceof Error ? error : new Error(String(error)))
|
||||
} 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
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
358
@vates/iscsi/src/login.mts
Normal file
358
@vates/iscsi/src/login.mts
Normal file
@@ -0,0 +1,358 @@
|
||||
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.
|
||||
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
|
||||
|
||||
/** 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 {
|
||||
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('ImmediateData', 'No')
|
||||
answer('ErrorRecoveryLevel', '0')
|
||||
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)])
|
||||
}
|
||||
}
|
||||
}
|
||||
313
@vates/iscsi/src/login.test.mts
Normal file
313
@vates/iscsi/src/login.test.mts
Normal file
@@ -0,0 +1,313 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
933
@vates/iscsi/src/loopback.integ.mts
Normal file
933
@vates/iscsi/src/loopback.integ.mts
Normal file
@@ -0,0 +1,933 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp, readFile, rm, truncate, writeFile } from 'node:fs/promises'
|
||||
import { connect, type Socket } from 'node:net'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { once } from 'node:events'
|
||||
import { after, before, describe, it } from 'node:test'
|
||||
|
||||
import {
|
||||
DATA_IN_FLAG_STATUS,
|
||||
FLAG_FINAL,
|
||||
InitiatorOpcode,
|
||||
LOGIN_FLAG_TRANSIT,
|
||||
LOGIN_NSG_MASK,
|
||||
LoginStage,
|
||||
OPCODE_IMMEDIATE,
|
||||
ScsiStatus,
|
||||
TargetOpcode,
|
||||
} from './constants.mjs'
|
||||
import { RandomAccessDisk, type DiskBlock } from '@xen-orchestra/disk-transform'
|
||||
import type { BlockDevice } from './backend.mjs'
|
||||
import {
|
||||
CachedDiskBlockDevice,
|
||||
DiskBlockDevice,
|
||||
FileBlockDevice,
|
||||
IscsiDisk,
|
||||
IscsiInitiator,
|
||||
IscsiTarget,
|
||||
} from './index.mjs'
|
||||
import { allocBhs, assemblePdu, type IncomingPdu, readPdu } from './pdu.mjs'
|
||||
import { parseTextKeys, serializeTextKeys } from './login.mjs'
|
||||
|
||||
const IQN = 'iqn.2024-01.tech.vates:loopback'
|
||||
const LUN_SIZE = 1024 * 1024 // 1 MiB
|
||||
const BLOCK_SIZE = 512
|
||||
|
||||
// --- a minimal in-process iSCSI initiator (test-only) -----------------------
|
||||
//
|
||||
// Speaks just enough of the protocol to exercise the target end to end. It
|
||||
// reuses the package's framing helpers (independently covered by pdu.test); the
|
||||
// assertions here are on the target's session/SCSI behavior and data integrity.
|
||||
|
||||
const READ_BIT = 0x40
|
||||
const WRITE_BIT = 0x20
|
||||
|
||||
function send(socket: Socket, buffer: Buffer): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
socket.write(buffer, error => (error ? reject(error) : resolve()))
|
||||
})
|
||||
}
|
||||
|
||||
async function expectPdu(socket: Socket, opcode: number): Promise<IncomingPdu> {
|
||||
const pdu = await readPdu(socket)
|
||||
assert.ok(pdu !== null, 'expected a PDU, got end-of-stream')
|
||||
assert.equal(pdu.opcode, opcode, `expected opcode 0x${opcode.toString(16)}, got 0x${pdu.opcode.toString(16)}`)
|
||||
return pdu
|
||||
}
|
||||
|
||||
class MiniInitiator {
|
||||
#socket: Socket
|
||||
#itt = 0
|
||||
#cmdSN = 0
|
||||
|
||||
constructor(socket: Socket) {
|
||||
this.#socket = socket
|
||||
}
|
||||
|
||||
#nextItt(): number {
|
||||
return ++this.#itt
|
||||
}
|
||||
|
||||
async login(sessionType: 'Normal' | 'Discovery'): Promise<void> {
|
||||
const bhs = allocBhs(InitiatorOpcode.LOGIN_REQUEST | OPCODE_IMMEDIATE)
|
||||
bhs[1] = LOGIN_FLAG_TRANSIT | (LoginStage.OPERATIONAL_NEGOTIATION << 2) | LoginStage.FULL_FEATURE_PHASE
|
||||
Buffer.from([0x80, 0, 0, 0, 0, 1]).copy(bhs, 8) // ISID
|
||||
bhs.writeUInt32BE(this.#nextItt(), 16)
|
||||
bhs.writeUInt32BE(this.#cmdSN, 24)
|
||||
const keys: Array<[string, string]> = [
|
||||
['InitiatorName', 'iqn.1994-05.com.example:test'],
|
||||
['SessionType', sessionType],
|
||||
['HeaderDigest', 'None'],
|
||||
['DataDigest', 'None'],
|
||||
['MaxRecvDataSegmentLength', '262144'],
|
||||
]
|
||||
if (sessionType === 'Normal') {
|
||||
keys.push(['TargetName', IQN])
|
||||
}
|
||||
await send(this.#socket, assemblePdu(bhs, serializeTextKeys(keys)))
|
||||
|
||||
const response = await expectPdu(this.#socket, TargetOpcode.LOGIN_RESPONSE)
|
||||
assert.equal(response.bhs[36], 0, 'login status-class should be success')
|
||||
assert.ok((response.bhs[1] & LOGIN_FLAG_TRANSIT) !== 0, 'should transit')
|
||||
assert.equal(response.bhs[1] & LOGIN_NSG_MASK, LoginStage.FULL_FEATURE_PHASE)
|
||||
}
|
||||
|
||||
async sendTargets(): Promise<Map<string, string>> {
|
||||
const bhs = allocBhs(InitiatorOpcode.TEXT_REQUEST | OPCODE_IMMEDIATE)
|
||||
bhs[1] = FLAG_FINAL
|
||||
bhs.writeUInt32BE(this.#nextItt(), 16)
|
||||
bhs.writeUInt32BE(0xffffffff, 20) // Target Transfer Tag: none
|
||||
bhs.writeUInt32BE(this.#cmdSN, 24)
|
||||
await send(this.#socket, assemblePdu(bhs, serializeTextKeys([['SendTargets', 'All']])))
|
||||
const response = await expectPdu(this.#socket, TargetOpcode.TEXT_RESPONSE)
|
||||
return parseTextKeys(response.data)
|
||||
}
|
||||
|
||||
#scsiCommandBhs(cdb: Buffer, flags: number, edtl: number): Buffer {
|
||||
const bhs = allocBhs(InitiatorOpcode.SCSI_COMMAND)
|
||||
bhs[1] = FLAG_FINAL | flags
|
||||
bhs.writeUInt32BE(this.#nextItt(), 16)
|
||||
bhs.writeUInt32BE(edtl, 20)
|
||||
bhs.writeUInt32BE(this.#cmdSN++, 24)
|
||||
cdb.copy(bhs, 32)
|
||||
return bhs
|
||||
}
|
||||
|
||||
/** Issue a read-type command and collect Data-In; returns the assembled data + status. */
|
||||
async read(cdb: Buffer, edtl: number): Promise<{ data: Buffer; status: number }> {
|
||||
await send(this.#socket, assemblePdu(this.#scsiCommandBhs(cdb, READ_BIT, edtl)))
|
||||
const chunks: Buffer[] = []
|
||||
for (;;) {
|
||||
const pdu = await readPdu(this.#socket)
|
||||
assert.ok(pdu !== null)
|
||||
if (pdu.opcode === TargetOpcode.SCSI_DATA_IN) {
|
||||
chunks.push(pdu.data)
|
||||
if ((pdu.flags & DATA_IN_FLAG_STATUS) !== 0) {
|
||||
return { data: Buffer.concat(chunks), status: pdu.bhs[3] }
|
||||
}
|
||||
} else if (pdu.opcode === TargetOpcode.SCSI_RESPONSE) {
|
||||
return { data: Buffer.concat(chunks), status: pdu.bhs[3] }
|
||||
} else {
|
||||
assert.fail(`unexpected opcode during read: 0x${pdu.opcode.toString(16)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Issue a write-type command, satisfy its R2Ts with Data-Out, return the final status. */
|
||||
async write(cdb: Buffer, payload: Buffer): Promise<number> {
|
||||
const itt = this.#itt + 1 // matches the ITT #scsiCommandBhs is about to allocate
|
||||
await send(this.#socket, assemblePdu(this.#scsiCommandBhs(cdb, WRITE_BIT, payload.length)))
|
||||
for (;;) {
|
||||
const pdu = await readPdu(this.#socket)
|
||||
assert.ok(pdu !== null)
|
||||
if (pdu.opcode === TargetOpcode.R2T) {
|
||||
const ttt = pdu.readU32(20)
|
||||
const bufferOffset = pdu.readU32(40)
|
||||
const desiredLength = pdu.readU32(44)
|
||||
const dataOut = allocBhs(InitiatorOpcode.SCSI_DATA_OUT)
|
||||
dataOut[1] = FLAG_FINAL
|
||||
dataOut.writeUInt32BE(itt, 16)
|
||||
dataOut.writeUInt32BE(ttt, 20)
|
||||
dataOut.writeUInt32BE(bufferOffset, 40)
|
||||
await send(this.#socket, assemblePdu(dataOut, payload.subarray(bufferOffset, bufferOffset + desiredLength)))
|
||||
} else if (pdu.opcode === TargetOpcode.SCSI_RESPONSE) {
|
||||
return pdu.bhs[3]
|
||||
} else {
|
||||
assert.fail(`unexpected opcode during write: 0x${pdu.opcode.toString(16)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- low-level primitives (for explicit, interleaved orchestration) -------
|
||||
|
||||
/** Send a SCSI Command without consuming any reply; returns its ITT. */
|
||||
async scsiCommand(cdb: Buffer, kind: 'read' | 'write' | 'none', edtl: number): Promise<number> {
|
||||
const flags = kind === 'read' ? READ_BIT : kind === 'write' ? WRITE_BIT : 0
|
||||
await send(this.#socket, assemblePdu(this.#scsiCommandBhs(cdb, flags, edtl)))
|
||||
return this.#itt
|
||||
}
|
||||
|
||||
/** Read the next inbound PDU. */
|
||||
async recv(): Promise<IncomingPdu> {
|
||||
const pdu = await readPdu(this.#socket)
|
||||
assert.ok(pdu !== null, 'expected a PDU, got end-of-stream')
|
||||
return pdu
|
||||
}
|
||||
|
||||
/** Send a single (final) Data-Out PDU for `itt` carrying `payload` at offset 0. */
|
||||
async dataOut(itt: number, targetTransferTag: number, payload: Buffer): Promise<void> {
|
||||
const bhs = allocBhs(InitiatorOpcode.SCSI_DATA_OUT)
|
||||
bhs[1] = FLAG_FINAL
|
||||
bhs.writeUInt32BE(itt, 16)
|
||||
bhs.writeUInt32BE(targetTransferTag, 20)
|
||||
bhs.writeUInt32BE(0, 40) // buffer offset
|
||||
await send(this.#socket, assemblePdu(bhs, payload))
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
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)
|
||||
await send(this.#socket, assemblePdu(bhs))
|
||||
const response = await expectPdu(this.#socket, TargetOpcode.LOGOUT_RESPONSE)
|
||||
assert.equal(response.bhs[2], 0, 'logout should succeed')
|
||||
}
|
||||
}
|
||||
|
||||
function readCapacity10Cdb(): Buffer {
|
||||
const cdb = Buffer.alloc(16)
|
||||
cdb[0] = 0x25
|
||||
return cdb
|
||||
}
|
||||
function inquiryCdb(allocationLength: number): Buffer {
|
||||
const cdb = Buffer.alloc(16)
|
||||
cdb[0] = 0x12
|
||||
cdb.writeUInt16BE(allocationLength, 3)
|
||||
return cdb
|
||||
}
|
||||
function reportLunsCdb(allocationLength: number): Buffer {
|
||||
const cdb = Buffer.alloc(16)
|
||||
cdb[0] = 0xa0
|
||||
cdb.writeUInt32BE(allocationLength, 6)
|
||||
return cdb
|
||||
}
|
||||
function rwCdb(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
|
||||
}
|
||||
|
||||
describe('iSCSI target loopback', () => {
|
||||
let dir: string
|
||||
let backingPath: string
|
||||
let target: IscsiTarget
|
||||
let port: number
|
||||
|
||||
before(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'vates-iscsi-'))
|
||||
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, // ephemeral
|
||||
lun: new FileBlockDevice({ path: backingPath, blockSize: BLOCK_SIZE }),
|
||||
})
|
||||
await target.listen()
|
||||
const address = target.address()
|
||||
assert.ok(address !== undefined)
|
||||
port = address.port
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await target?.close()
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function open(): Promise<Socket> {
|
||||
const socket = connect(port, '127.0.0.1')
|
||||
await once(socket, 'connect')
|
||||
return socket
|
||||
}
|
||||
|
||||
it('answers SendTargets discovery with the target IQN', async () => {
|
||||
const socket = await open()
|
||||
try {
|
||||
const initiator = new MiniInitiator(socket)
|
||||
await initiator.login('Discovery')
|
||||
const keys = await initiator.sendTargets()
|
||||
assert.equal(keys.get('TargetName'), IQN)
|
||||
assert.match(keys.get('TargetAddress') ?? '', /^127\.0\.0\.1:\d+,1$/)
|
||||
await initiator.logout()
|
||||
} finally {
|
||||
socket.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('reports identity, capacity, and a single LUN', async () => {
|
||||
const socket = await open()
|
||||
try {
|
||||
const initiator = new MiniInitiator(socket)
|
||||
await initiator.login('Normal')
|
||||
|
||||
const inquiry = await initiator.read(inquiryCdb(36), 36)
|
||||
assert.equal(inquiry.status, ScsiStatus.GOOD)
|
||||
assert.equal(inquiry.data[0], 0x00, 'direct-access block device')
|
||||
|
||||
const reportLuns = await initiator.read(reportLunsCdb(64), 64)
|
||||
assert.equal(reportLuns.status, ScsiStatus.GOOD)
|
||||
assert.equal(reportLuns.data.readUInt32BE(0), 8, 'one LUN reported')
|
||||
|
||||
const capacity = await initiator.read(readCapacity10Cdb(), 8)
|
||||
assert.equal(capacity.status, ScsiStatus.GOOD)
|
||||
assert.equal(capacity.data.readUInt32BE(0), LUN_SIZE / BLOCK_SIZE - 1, 'max LBA')
|
||||
assert.equal(capacity.data.readUInt32BE(4), BLOCK_SIZE)
|
||||
|
||||
await initiator.logout()
|
||||
} finally {
|
||||
socket.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('writes blocks (R2T/Data-Out) and reads them back identically', async () => {
|
||||
const socket = await open()
|
||||
try {
|
||||
const initiator = new MiniInitiator(socket)
|
||||
await initiator.login('Normal')
|
||||
|
||||
// A recognizable multi-block pattern starting at LBA 3.
|
||||
const blocks = 4
|
||||
const payload = Buffer.alloc(blocks * BLOCK_SIZE)
|
||||
for (let i = 0; i < payload.length; i++) {
|
||||
payload[i] = (i * 7 + 1) & 0xff
|
||||
}
|
||||
const lba = 3
|
||||
|
||||
const writeStatus = await initiator.write(rwCdb(0x2a, lba, blocks), payload)
|
||||
assert.equal(writeStatus, ScsiStatus.GOOD)
|
||||
|
||||
const readBack = await initiator.read(rwCdb(0x28, lba, blocks), payload.length)
|
||||
assert.equal(readBack.status, ScsiStatus.GOOD)
|
||||
assert.deepEqual(readBack.data, payload, 'read data matches what was written')
|
||||
|
||||
await initiator.logout()
|
||||
} finally {
|
||||
socket.destroy()
|
||||
}
|
||||
|
||||
// The bytes must have actually landed in the backing file at the right offset.
|
||||
const onDisk = await readFile(backingPath)
|
||||
const expected = Buffer.alloc(BLOCK_SIZE)
|
||||
for (let i = 0; i < BLOCK_SIZE; i++) {
|
||||
expected[i] = (i * 7 + 1) & 0xff // first written block
|
||||
}
|
||||
assert.deepEqual(onDisk.subarray(3 * BLOCK_SIZE, 4 * BLOCK_SIZE), expected)
|
||||
})
|
||||
|
||||
// Regression test: real initiators (open-iscsi) send other commands while a
|
||||
// WRITE is awaiting its Data-Out. The target must service them and still
|
||||
// complete the write — not tear the connection down.
|
||||
it('services an interleaved command between R2T and Data-Out', async () => {
|
||||
const socket = await open()
|
||||
try {
|
||||
const initiator = new MiniInitiator(socket)
|
||||
await initiator.login('Normal')
|
||||
|
||||
const payload = Buffer.alloc(BLOCK_SIZE, 0x3c)
|
||||
|
||||
// 1. Issue a WRITE and receive its R2T, but do not send Data-Out yet.
|
||||
const writeItt = await initiator.scsiCommand(rwCdb(0x2a, 5, 1), 'write', BLOCK_SIZE)
|
||||
const r2t = await initiator.recv()
|
||||
assert.equal(r2t.opcode, TargetOpcode.R2T)
|
||||
assert.equal(r2t.itt, writeItt)
|
||||
const ttt = r2t.readU32(20)
|
||||
|
||||
// 2. Interleave a READ CAPACITY before satisfying the write.
|
||||
const capItt = await initiator.scsiCommand(readCapacity10Cdb(), 'read', 8)
|
||||
const capacity = await initiator.recv()
|
||||
assert.equal(capacity.opcode, TargetOpcode.SCSI_DATA_IN)
|
||||
assert.equal(capacity.itt, capItt)
|
||||
assert.equal(capacity.data.readUInt32BE(0), LUN_SIZE / BLOCK_SIZE - 1)
|
||||
|
||||
// 3. Now send the write's Data-Out; the write must complete with GOOD.
|
||||
await initiator.dataOut(writeItt, ttt, payload)
|
||||
const response = await initiator.recv()
|
||||
assert.equal(response.opcode, TargetOpcode.SCSI_RESPONSE)
|
||||
assert.equal(response.itt, writeItt)
|
||||
assert.equal(response.bhs[3], ScsiStatus.GOOD)
|
||||
|
||||
// 4. The interleaved write must have persisted correctly.
|
||||
const readBack = await initiator.read(rwCdb(0x28, 5, 1), BLOCK_SIZE)
|
||||
assert.deepEqual(readBack.data, payload)
|
||||
|
||||
await initiator.logout()
|
||||
} finally {
|
||||
socket.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
// The real IscsiInitiator drives the same target, exercising the client login
|
||||
// driver and the READ backlog/read-loop against the actual server.
|
||||
it('reads the LUN through the IscsiInitiator client', async () => {
|
||||
const initiator = new IscsiInitiator({ host: '127.0.0.1', port, targetIqn: IQN })
|
||||
await initiator.connect()
|
||||
try {
|
||||
assert.equal(initiator.getSize(), LUN_SIZE)
|
||||
assert.equal(initiator.getBlockSize(), BLOCK_SIZE)
|
||||
// Block 3 was written by the earlier test; read it back over READ(16).
|
||||
const data = await initiator.read(3 * BLOCK_SIZE, BLOCK_SIZE)
|
||||
const expected = Buffer.alloc(BLOCK_SIZE)
|
||||
for (let i = 0; i < BLOCK_SIZE; i++) {
|
||||
expected[i] = (i * 7 + 1) & 0xff
|
||||
}
|
||||
assert.deepEqual(data, expected)
|
||||
} finally {
|
||||
await initiator.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Regression coverage for concurrent READ dispatch: a slow read must not block
|
||||
// a subsequently-dispatched fast one, and StatSN on the wire must still come
|
||||
// out strictly increasing in actual completion/transmission order — not
|
||||
// necessarily dispatch order.
|
||||
describe('concurrent READ dispatch', () => {
|
||||
it('services a fast read before an earlier, slower one — StatSN follows completion order', async () => {
|
||||
let releaseSlow: () => void
|
||||
const slowGate = new Promise<void>(resolve => {
|
||||
releaseSlow = resolve
|
||||
})
|
||||
const lun: BlockDevice = {
|
||||
getSize: () => LUN_SIZE,
|
||||
getBlockSize: () => BLOCK_SIZE,
|
||||
read: async (offset, length) => {
|
||||
if (offset === 0) {
|
||||
await slowGate // LBA 0 is the slow one
|
||||
}
|
||||
return Buffer.alloc(length, offset === 0 ? 0xaa : 0xbb)
|
||||
},
|
||||
write: async () => {
|
||||
throw new Error('not used by this test')
|
||||
},
|
||||
flush: async () => {},
|
||||
close: async () => {},
|
||||
}
|
||||
|
||||
const target = new IscsiTarget({ iqn: IQN, host: '127.0.0.1', port: 0, lun })
|
||||
await target.listen()
|
||||
const address = target.address()
|
||||
assert.ok(address !== undefined)
|
||||
|
||||
const socket = connect(address.port, '127.0.0.1')
|
||||
await once(socket, 'connect')
|
||||
// Both IscsiTarget and IscsiInitiator disable Nagle internally; this raw
|
||||
// test socket doesn't get that for free, and these tests send several
|
||||
// commands back to back with no read in between — exactly the pattern
|
||||
// Nagle + delayed ACK stalls (~40ms observed without this).
|
||||
socket.setNoDelay(true)
|
||||
try {
|
||||
const initiator = new MiniInitiator(socket)
|
||||
await initiator.login('Normal')
|
||||
|
||||
// Dispatch the slow read (LBA 0) first, then the fast one (LBA 1),
|
||||
// without waiting for either's response — exactly what a real
|
||||
// initiator with a command window > 1 already does.
|
||||
const slowItt = await initiator.scsiCommand(rwCdb(0x28, 0, 1), 'read', BLOCK_SIZE)
|
||||
const fastItt = await initiator.scsiCommand(rwCdb(0x28, 1, 1), 'read', BLOCK_SIZE)
|
||||
|
||||
// The fast one must complete first: proof the read loop didn't block
|
||||
// on the slow one before even starting the fast one's I/O.
|
||||
const fastPdu = await initiator.recv()
|
||||
assert.equal(fastPdu.itt, fastItt)
|
||||
assert.deepEqual(fastPdu.data, Buffer.alloc(BLOCK_SIZE, 0xbb))
|
||||
const fastStatSN = fastPdu.readU32(24)
|
||||
|
||||
releaseSlow!()
|
||||
|
||||
const slowPdu = await initiator.recv()
|
||||
assert.equal(slowPdu.itt, slowItt)
|
||||
assert.deepEqual(slowPdu.data, Buffer.alloc(BLOCK_SIZE, 0xaa))
|
||||
const slowStatSN = slowPdu.readU32(24)
|
||||
|
||||
// StatSN reflects actual transmission order (fast, then slow) even
|
||||
// though the slow one's command was dispatched first.
|
||||
assert.ok(slowStatSN > fastStatSN, `expected slow StatSN (${slowStatSN}) > fast StatSN (${fastStatSN})`)
|
||||
|
||||
await initiator.logout()
|
||||
} finally {
|
||||
socket.destroy()
|
||||
await target.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('caps how many reads run at once, independently of the command window', async () => {
|
||||
const started: number[] = []
|
||||
const releases: Array<() => void> = []
|
||||
const lun: BlockDevice = {
|
||||
getSize: () => LUN_SIZE,
|
||||
getBlockSize: () => BLOCK_SIZE,
|
||||
read: async (offset, length) => {
|
||||
started.push(offset / BLOCK_SIZE)
|
||||
await new Promise<void>(resolve => releases.push(resolve))
|
||||
return Buffer.alloc(length)
|
||||
},
|
||||
write: async () => {
|
||||
throw new Error('not used by this test')
|
||||
},
|
||||
flush: async () => {},
|
||||
close: async () => {},
|
||||
}
|
||||
|
||||
const target = new IscsiTarget({ iqn: IQN, host: '127.0.0.1', port: 0, lun, readConcurrency: 2 })
|
||||
await target.listen()
|
||||
const address = target.address()
|
||||
assert.ok(address !== undefined)
|
||||
|
||||
const socket = connect(address.port, '127.0.0.1')
|
||||
await once(socket, 'connect')
|
||||
// Both IscsiTarget and IscsiInitiator disable Nagle internally; this raw
|
||||
// test socket doesn't get that for free, and these tests send several
|
||||
// commands back to back with no read in between — exactly the pattern
|
||||
// Nagle + delayed ACK stalls (~40ms observed without this).
|
||||
socket.setNoDelay(true)
|
||||
try {
|
||||
const initiator = new MiniInitiator(socket)
|
||||
await initiator.login('Normal')
|
||||
|
||||
const itts = []
|
||||
for (let lba = 0; lba < 4; lba++) {
|
||||
itts.push(await initiator.scsiCommand(rwCdb(0x28, lba, 1), 'read', BLOCK_SIZE))
|
||||
}
|
||||
|
||||
// Poll instead of a fixed number of ticks: the commands still have to
|
||||
// cross a real (loopback) socket before the LUN sees them.
|
||||
for (let attempt = 0; started.length < 2 && attempt < 100; attempt++) {
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
}
|
||||
assert.equal(started.length, 2, 'only readConcurrency reads should have started')
|
||||
|
||||
releases.shift()!()
|
||||
for (let attempt = 0; started.length < 3 && attempt < 100; attempt++) {
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
}
|
||||
assert.equal(started.length, 3, 'releasing one slot should admit exactly one more')
|
||||
|
||||
// drain the rest: releasing a slot only lets the NEXT queued read start
|
||||
// asynchronously (one microtask later), so keep releasing whatever's
|
||||
// pending and yielding until all four have started
|
||||
while (started.length < 4) {
|
||||
while (releases.length > 0) {
|
||||
releases.shift()!()
|
||||
}
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
}
|
||||
while (releases.length > 0) {
|
||||
releases.shift()!()
|
||||
}
|
||||
|
||||
for (let i = 0; i < itts.length; i++) {
|
||||
const pdu = await initiator.recv()
|
||||
assert.ok(itts.includes(pdu.itt))
|
||||
}
|
||||
|
||||
await initiator.logout()
|
||||
} finally {
|
||||
socket.destroy()
|
||||
await target.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Regression test: a failed lun.read() (backend hiccup, corrupt source block)
|
||||
// must fail only that command, not the whole connection — mirrors the write
|
||||
// path's existing per-command CHECK_CONDITION/MEDIUM_ERROR handling.
|
||||
describe('READ error isolation', () => {
|
||||
it('fails only the bad read; the connection and subsequent reads stay healthy', async () => {
|
||||
const lun: BlockDevice = {
|
||||
getSize: () => LUN_SIZE,
|
||||
getBlockSize: () => BLOCK_SIZE,
|
||||
read: async offset => {
|
||||
if (offset === 0) {
|
||||
throw new Error('simulated backend failure')
|
||||
}
|
||||
return Buffer.alloc(BLOCK_SIZE, 0xcc)
|
||||
},
|
||||
write: async () => {
|
||||
throw new Error('not used by this test')
|
||||
},
|
||||
flush: async () => {},
|
||||
close: async () => {},
|
||||
}
|
||||
|
||||
const target = new IscsiTarget({ iqn: IQN, host: '127.0.0.1', port: 0, lun })
|
||||
await target.listen()
|
||||
const address = target.address()
|
||||
assert.ok(address !== undefined)
|
||||
|
||||
const socket = connect(address.port, '127.0.0.1')
|
||||
await once(socket, 'connect')
|
||||
try {
|
||||
const initiator = new MiniInitiator(socket)
|
||||
await initiator.login('Normal')
|
||||
|
||||
// LBA 0 is the bad one: must fail with CHECK CONDITION, not drop the connection
|
||||
const failed = await initiator.read(rwCdb(0x28, 0, 1), BLOCK_SIZE)
|
||||
assert.equal(failed.status, ScsiStatus.CHECK_CONDITION)
|
||||
|
||||
// the connection must still be usable: a healthy read afterward succeeds normally
|
||||
const healthy = await initiator.read(rwCdb(0x28, 1, 1), BLOCK_SIZE)
|
||||
assert.equal(healthy.status, ScsiStatus.GOOD)
|
||||
assert.deepEqual(healthy.data, Buffer.alloc(BLOCK_SIZE, 0xcc))
|
||||
|
||||
await initiator.logout()
|
||||
} finally {
|
||||
socket.destroy()
|
||||
await target.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('connection replacement', () => {
|
||||
it('a new connection replaces an existing one instead of being refused forever', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'vates-iscsi-'))
|
||||
const backingPath = join(dir, 'lun.img')
|
||||
await writeFile(backingPath, Buffer.alloc(0))
|
||||
await truncate(backingPath, LUN_SIZE)
|
||||
|
||||
const 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)
|
||||
|
||||
try {
|
||||
// First connection: logs in, then goes idle — standing in for an
|
||||
// initiator that gave up on it (its own command timeout, against a slow
|
||||
// backend) without ever actually closing the socket.
|
||||
const socketA = connect(address.port, '127.0.0.1')
|
||||
await once(socketA, 'connect')
|
||||
const initiatorA = new MiniInitiator(socketA)
|
||||
await initiatorA.login('Normal')
|
||||
const closedA = once(socketA, 'close')
|
||||
|
||||
// A second connection must be accepted, not refused, even though the
|
||||
// first is still technically open from the target's point of view.
|
||||
const socketB = connect(address.port, '127.0.0.1')
|
||||
await once(socketB, 'connect')
|
||||
const initiatorB = new MiniInitiator(socketB)
|
||||
await initiatorB.login('Normal')
|
||||
|
||||
// The target must have torn the first connection's socket down.
|
||||
await closedA
|
||||
|
||||
// The new connection is fully usable, not left in some half-adopted state.
|
||||
const result = await initiatorB.read(rwCdb(0x28, 0, 1), BLOCK_SIZE)
|
||||
assert.equal(result.status, ScsiStatus.GOOD)
|
||||
await initiatorB.logout()
|
||||
socketB.destroy()
|
||||
} finally {
|
||||
await target.close()
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// One loopback that exercises BOTH one-way CHAP roles at once: our target is the
|
||||
// authenticator (challenges + verifies) and our initiator is the responder
|
||||
// (answers the challenge). This is the same code path each production role uses
|
||||
// (target vs XCP-ng open-iscsi; initiator vs a Pure array).
|
||||
describe('iSCSI CHAP loopback (target authenticator + initiator responder)', () => {
|
||||
const CHAP = { user: 'alice', secret: 's3cr3t' }
|
||||
const pattern = (i: number) => (i * 13 + 7) & 0xff
|
||||
|
||||
let dir: string
|
||||
let backingPath: string
|
||||
let target: IscsiTarget
|
||||
let port: number
|
||||
|
||||
before(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'vates-iscsi-chap-'))
|
||||
backingPath = join(dir, 'lun.img')
|
||||
// Pre-fill the LUN with a recognizable pattern so a plain read can verify it.
|
||||
const image = Buffer.alloc(LUN_SIZE)
|
||||
for (let i = 0; i < image.length; i++) {
|
||||
image[i] = pattern(i)
|
||||
}
|
||||
await writeFile(backingPath, image)
|
||||
|
||||
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)
|
||||
port = address.port
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await target?.close()
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('authenticates a correct secret and reads the LUN', async () => {
|
||||
const initiator = new IscsiInitiator({ host: '127.0.0.1', port, targetIqn: IQN, chap: CHAP })
|
||||
await initiator.connect()
|
||||
try {
|
||||
assert.equal(initiator.getSize(), LUN_SIZE)
|
||||
const offset = 5 * BLOCK_SIZE
|
||||
const length = 3 * BLOCK_SIZE
|
||||
const data = await initiator.read(offset, length)
|
||||
const expected = Buffer.alloc(length)
|
||||
for (let i = 0; i < length; i++) {
|
||||
expected[i] = pattern(offset + i)
|
||||
}
|
||||
assert.deepEqual(data, expected)
|
||||
} finally {
|
||||
await initiator.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('exposes the authenticated LUN as a RandomAccessDisk', async () => {
|
||||
const blockSize = 64 * 1024
|
||||
const disk = new IscsiDisk({ host: '127.0.0.1', port, targetIqn: IQN, chap: CHAP }, { blockSize })
|
||||
await disk.init()
|
||||
try {
|
||||
assert.equal(disk.getVirtualSize(), LUN_SIZE)
|
||||
assert.equal(disk.getBlockIndexes().length, LUN_SIZE / blockSize)
|
||||
assert.equal(disk.hasBlock(0), true)
|
||||
const { index, data } = await disk.readBlock(2)
|
||||
assert.equal(index, 2)
|
||||
assert.equal(data.length, blockSize)
|
||||
const expected = Buffer.alloc(blockSize)
|
||||
for (let i = 0; i < blockSize; i++) {
|
||||
expected[i] = pattern(2 * blockSize + i)
|
||||
}
|
||||
assert.deepEqual(data, expected)
|
||||
} finally {
|
||||
await disk.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a wrong secret', async () => {
|
||||
const initiator = new IscsiInitiator({
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
targetIqn: IQN,
|
||||
chap: { user: 'alice', secret: 'wrong' },
|
||||
})
|
||||
await assert.rejects(initiator.connect(), /login rejected/)
|
||||
})
|
||||
|
||||
it('rejects an initiator that offers no credentials', async () => {
|
||||
const initiator = new IscsiInitiator({ host: '127.0.0.1', port, targetIqn: IQN })
|
||||
await assert.rejects(initiator.connect(), /login rejected/)
|
||||
})
|
||||
})
|
||||
|
||||
// --- serving a RandomAccessDisk as a LUN ------------------------------------
|
||||
//
|
||||
// The shape used to expose a backup's disk chain: a sparse source disk with big
|
||||
// blocks, read through the protocol at 512-byte granularity.
|
||||
|
||||
describe('DiskBlockDevice loopback', () => {
|
||||
const DISK_BLOCK_SIZE = 64 * 1024
|
||||
const BLOCK_COUNT = 4
|
||||
const DISK_SIZE = BLOCK_COUNT * DISK_BLOCK_SIZE
|
||||
const bytePattern = (i: number) => (i * 31 + 11) & 0xff
|
||||
|
||||
// only blocks 0 and 2 are allocated, like a differencing VHD chain
|
||||
const allocated = new Map<number, Buffer>()
|
||||
for (const index of [0, 2]) {
|
||||
const data = Buffer.alloc(DISK_BLOCK_SIZE)
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
data[i] = bytePattern(index * DISK_BLOCK_SIZE + i)
|
||||
}
|
||||
allocated.set(index, data)
|
||||
}
|
||||
|
||||
const expectedAt = (offset: number, length: 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
|
||||
}
|
||||
|
||||
class SparseDisk extends RandomAccessDisk {
|
||||
getBlockSize(): number {
|
||||
return DISK_BLOCK_SIZE
|
||||
}
|
||||
getVirtualSize(): number {
|
||||
return DISK_SIZE
|
||||
}
|
||||
isDifferencing(): boolean {
|
||||
return false
|
||||
}
|
||||
getBlockIndexes(): Array<number> {
|
||||
return [...allocated.keys()]
|
||||
}
|
||||
hasBlock(index: number): boolean {
|
||||
return allocated.has(index)
|
||||
}
|
||||
async init(): Promise<void> {}
|
||||
async close(): Promise<void> {}
|
||||
async readBlock(index: number): Promise<DiskBlock> {
|
||||
const data = allocated.get(index)
|
||||
if (data === undefined) {
|
||||
// what RemoteVhdDiskChain does, and what the adapter must never trigger
|
||||
throw new Error(`Block ${index} not found in chain`)
|
||||
}
|
||||
return { index, data }
|
||||
}
|
||||
}
|
||||
|
||||
let target: IscsiTarget
|
||||
let port: number
|
||||
|
||||
before(async () => {
|
||||
target = new IscsiTarget({
|
||||
iqn: IQN,
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lun: new DiskBlockDevice({ disk: new SparseDisk(), blockSize: BLOCK_SIZE }),
|
||||
})
|
||||
await target.listen()
|
||||
const address = target.address()
|
||||
assert.ok(address !== undefined)
|
||||
port = address.port
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await target?.close()
|
||||
})
|
||||
|
||||
it('serves the disk content, unallocated blocks reading as zeroes', async () => {
|
||||
const initiator = new IscsiInitiator({ host: '127.0.0.1', port, targetIqn: IQN })
|
||||
await initiator.connect()
|
||||
try {
|
||||
assert.equal(initiator.getSize(), DISK_SIZE)
|
||||
|
||||
// one sector inside an allocated block
|
||||
const offset = DISK_BLOCK_SIZE * 2 + 3 * BLOCK_SIZE
|
||||
assert.deepEqual(await initiator.read(offset, BLOCK_SIZE), expectedAt(offset, BLOCK_SIZE))
|
||||
|
||||
// a whole unallocated block
|
||||
assert.deepEqual(await initiator.read(DISK_BLOCK_SIZE, DISK_BLOCK_SIZE), Buffer.alloc(DISK_BLOCK_SIZE))
|
||||
|
||||
// a range straddling allocated and unallocated blocks
|
||||
const straddle = DISK_BLOCK_SIZE - BLOCK_SIZE
|
||||
const length = DISK_BLOCK_SIZE + 2 * BLOCK_SIZE
|
||||
assert.deepEqual(await initiator.read(straddle, length), expectedAt(straddle, length))
|
||||
|
||||
// the whole disk, in one go
|
||||
assert.deepEqual(await initiator.read(0, DISK_SIZE), expectedAt(0, DISK_SIZE))
|
||||
} finally {
|
||||
await initiator.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('fails a write instead of corrupting the source', async () => {
|
||||
const socket = connect({ host: '127.0.0.1', port })
|
||||
await once(socket, 'connect')
|
||||
const initiator = new MiniInitiator(socket)
|
||||
try {
|
||||
await initiator.login('Normal')
|
||||
const status = await initiator.write(rwCdb(0x2a, 0, 1), Buffer.alloc(BLOCK_SIZE, 0xff))
|
||||
assert.equal(status, ScsiStatus.CHECK_CONDITION)
|
||||
// the source is untouched
|
||||
const readBack = await initiator.read(rwCdb(0x28, 0, 1), BLOCK_SIZE)
|
||||
assert.equal(readBack.status, ScsiStatus.GOOD)
|
||||
assert.deepEqual(readBack.data, expectedAt(0, BLOCK_SIZE))
|
||||
} finally {
|
||||
socket.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
// --- the same source, but cached into a local store ----------------------
|
||||
//
|
||||
// The shape used for a backup mount with a cache VDI: reads materialize into
|
||||
// the local store, and writes are accepted there.
|
||||
|
||||
describe('cached into a local store', () => {
|
||||
let cache: FileBlockDevice
|
||||
let cachedDir: string
|
||||
let cachedPath: string
|
||||
let cachedTarget: IscsiTarget
|
||||
let cachedPort: number
|
||||
|
||||
before(async () => {
|
||||
cachedDir = await mkdtemp(join(tmpdir(), 'vates-iscsi-cache-'))
|
||||
cachedPath = join(cachedDir, 'cache.img')
|
||||
await writeFile(cachedPath, '')
|
||||
await truncate(cachedPath, DISK_SIZE)
|
||||
cache = new FileBlockDevice({ path: cachedPath, blockSize: BLOCK_SIZE })
|
||||
// the cache is opened by its owner, not by the LUN
|
||||
await cache.open()
|
||||
|
||||
cachedTarget = new IscsiTarget({
|
||||
iqn: IQN,
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
lun: new CachedDiskBlockDevice({ disk: new SparseDisk(), cache, blockSize: BLOCK_SIZE }),
|
||||
})
|
||||
await cachedTarget.listen()
|
||||
const address = cachedTarget.address()
|
||||
assert.ok(address !== undefined)
|
||||
cachedPort = address.port
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await cachedTarget?.close()
|
||||
await rm(cachedDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('serves the source and materializes it into the local store', async () => {
|
||||
const initiator = new IscsiInitiator({ host: '127.0.0.1', port: cachedPort, targetIqn: IQN })
|
||||
await initiator.connect()
|
||||
try {
|
||||
assert.deepEqual(await initiator.read(0, BLOCK_SIZE), expectedAt(0, BLOCK_SIZE))
|
||||
// the whole source block landed in the store, not just the sector read
|
||||
const onDisk = await readFile(cachedPath)
|
||||
assert.deepEqual(onDisk.subarray(0, DISK_BLOCK_SIZE), expectedAt(0, DISK_BLOCK_SIZE))
|
||||
} finally {
|
||||
await initiator.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts a write and reads it back', async () => {
|
||||
const socket = connect({ host: '127.0.0.1', port: cachedPort })
|
||||
await once(socket, 'connect')
|
||||
const initiator = new MiniInitiator(socket)
|
||||
try {
|
||||
await initiator.login('Normal')
|
||||
const payload = Buffer.alloc(BLOCK_SIZE, 0xee)
|
||||
// block 1 is a hole in the source: nothing to fetch, the write stands alone
|
||||
const lba = DISK_BLOCK_SIZE / BLOCK_SIZE
|
||||
assert.equal(await initiator.write(rwCdb(0x2a, lba, 1), payload), ScsiStatus.GOOD)
|
||||
|
||||
const readBack = await initiator.read(rwCdb(0x28, lba, 1), BLOCK_SIZE)
|
||||
assert.equal(readBack.status, ScsiStatus.GOOD)
|
||||
assert.deepEqual(readBack.data, payload)
|
||||
} finally {
|
||||
socket.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
167
@vates/iscsi/src/pdu.mts
Normal file
167
@vates/iscsi/src/pdu.mts
Normal file
@@ -0,0 +1,167 @@
|
||||
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.
|
||||
*/
|
||||
export async function readPdu(stream: Readable): 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)
|
||||
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
|
||||
}
|
||||
100
@vates/iscsi/src/pdu.test.mts
Normal file
100
@vates/iscsi/src/pdu.test.mts
Normal file
@@ -0,0 +1,100 @@
|
||||
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('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/)
|
||||
})
|
||||
})
|
||||
255
@vates/iscsi/src/scsi.mts
Normal file
255
@vates/iscsi/src/scsi.mts
Normal file
@@ -0,0 +1,255 @@
|
||||
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]
|
||||
|
||||
/**
|
||||
* 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): 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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
/** Minimal MODE SENSE parameter data: a header with no block descriptor or pages. */
|
||||
export function buildModeSense(long: boolean): Buffer {
|
||||
if (long) {
|
||||
const buffer = Buffer.alloc(8)
|
||||
buffer.writeUInt16BE(6, 0) // mode data length (8 - 2)
|
||||
return buffer
|
||||
}
|
||||
const buffer = Buffer.alloc(4)
|
||||
buffer[0] = 3 // mode data length (4 - 1)
|
||||
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 }
|
||||
|
||||
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).
|
||||
*/
|
||||
export async function handleScsiCommand(
|
||||
cdb: Buffer,
|
||||
itt: number,
|
||||
ctx: CommandContext,
|
||||
identity: ScsiIdentity
|
||||
): Promise<void> {
|
||||
const { lun } = ctx
|
||||
const blockSize = lun.getBlockSize()
|
||||
const blockCount = lun.getSize() / 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)
|
||||
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)
|
||||
}
|
||||
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 (request.lba + request.blocks > blockCount) {
|
||||
return checkCondition(ctx, itt, ILLEGAL_REQUEST_LBA_OUT_OF_RANGE)
|
||||
}
|
||||
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), request.allocationLength)
|
||||
|
||||
case 'syncCache':
|
||||
await lun.flush()
|
||||
return ctx.sendScsiResponse(itt, ScsiStatus.GOOD)
|
||||
|
||||
case 'unsupported':
|
||||
return checkCondition(ctx, itt, ILLEGAL_REQUEST_INVALID_OPCODE)
|
||||
}
|
||||
}
|
||||
226
@vates/iscsi/src/scsi.test.mts
Normal file
226
@vates/iscsi/src/scsi.test.mts
Normal file
@@ -0,0 +1,226 @@
|
||||
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('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])
|
||||
assert.equal(buildVpd(0x55, IDENTITY), undefined)
|
||||
})
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
})
|
||||
33
@vates/iscsi/src/semaphore.mts
Normal file
33
@vates/iscsi/src/semaphore.mts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 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++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
70
@vates/iscsi/src/semaphore.test.mts
Normal file
70
@vates/iscsi/src/semaphore.test.mts
Normal file
@@ -0,0 +1,70 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
24
@vates/iscsi/src/sequencing.test.mts
Normal file
24
@vates/iscsi/src/sequencing.test.mts
Normal file
@@ -0,0 +1,24 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
230
@vates/iscsi/src/target.integ.mts
Normal file
230
@vates/iscsi/src/target.integ.mts
Normal file
@@ -0,0 +1,230 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { execFile, execFileSync } from 'node:child_process'
|
||||
import { mkdtemp, open as openFile, readFile, rm, stat, 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 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 `/dev/${match[1]}`
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function waitForDevice(): Promise<string> {
|
||||
for (let attempt = 0; attempt < 40; attempt++) {
|
||||
const session = await iscsiadm(['-m', 'session', '-P', '3']).catch(() => '')
|
||||
const device = parseAttachedDisk(session)
|
||||
if (device !== undefined) {
|
||||
try {
|
||||
await stat(device)
|
||||
return device
|
||||
} catch {
|
||||
// device node not yet created by udev
|
||||
}
|
||||
}
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
141
@vates/iscsi/src/types.mts
Normal file
141
@vates/iscsi/src/types.mts
Normal file
@@ -0,0 +1,141 @@
|
||||
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
|
||||
|
||||
function readLba64(cdb: Buffer, offset: number): number {
|
||||
const lba = cdb.readBigUInt64BE(offset)
|
||||
if (lba > BigInt(MAX_SAFE_LBA)) {
|
||||
throw new Error(`LBA ${lba} exceeds the supported range`)
|
||||
}
|
||||
return 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.
|
||||
*/
|
||||
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>
|
||||
}
|
||||
60
@vates/iscsi/src/writePath.mts
Normal file
60
@vates/iscsi/src/writePath.mts
Normal file
@@ -0,0 +1,60 @@
|
||||
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 received so far. */
|
||||
received: number
|
||||
/** Bytes solicited so far (one burst ahead of `received`). */
|
||||
solicited: number
|
||||
/** Next R2TSN to emit. */
|
||||
r2tSN: number
|
||||
}
|
||||
14
@vates/iscsi/tsconfig.json
Normal file
14
@vates/iscsi/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
XoProxy,
|
||||
XoSchedule,
|
||||
XoServer,
|
||||
XoSr,
|
||||
XoTask,
|
||||
XoUser,
|
||||
XoVif,
|
||||
@@ -125,6 +126,44 @@ type License = {
|
||||
bundleInfo?: { name: string; id: string }
|
||||
}
|
||||
|
||||
/** A disk of a backup archive currently served as an iSCSI LUN */
|
||||
export type BackupArchiveDiskMount = {
|
||||
/** Handle to pass to `unmountBackupArchiveDisk` / `hydrateBackupArchiveDisk` */
|
||||
id: string
|
||||
/** UUID of the SR introduced on the host */
|
||||
srUuid: string
|
||||
/** UUID of the VDI exposing the backup disk */
|
||||
vdiUuid: string
|
||||
/**
|
||||
* UUID of the disk caching what has been read from the backup, if a cache was
|
||||
* requested. Destroyed when the mount is released. Absent when the mount was
|
||||
* created without a cache: reads then always go straight to the backup, and
|
||||
* writes are refused.
|
||||
*/
|
||||
cacheVdiUuid?: string
|
||||
/** IQN of the target serving the disk */
|
||||
iqn: string
|
||||
/** Address of the portal, as advertised to the host */
|
||||
address: string
|
||||
/** Port of the portal (ephemeral, one target per mount) */
|
||||
port: number
|
||||
}
|
||||
|
||||
/** How much of a cached mount's backup has been pulled into its cache disk */
|
||||
export type BackupArchiveDiskMountProgress = { blocks: number; total: number }
|
||||
|
||||
/** A live mount, as listed by `listMountedBackupArchiveDisks` */
|
||||
export type MountedBackupArchiveDisk = BackupArchiveDiskMount & {
|
||||
/** Path of the mounted disk on its backup repository */
|
||||
diskPath: string
|
||||
/**
|
||||
* How much of the backup has been pulled into the cache disk. Once `blocks`
|
||||
* reaches `total`, that disk holds a complete copy of the backup's disk.
|
||||
* Absent for a mount created without a cache — there is nothing to track.
|
||||
*/
|
||||
materialized?: BackupArchiveDiskMountProgress
|
||||
}
|
||||
|
||||
export type XoApp = {
|
||||
hooks: EventEmitter
|
||||
_redis: {
|
||||
@@ -328,10 +367,39 @@ export type XoApp = {
|
||||
xo: Record<XoBackupRepository['id'], XoConfigBackupArchive[]>
|
||||
pool: Record<XoBackupRepository['id'], Record<XoPool['id'], XoPoolBackupArchive[]>>
|
||||
}>
|
||||
listMountedBackupArchiveDisks(): MountedBackupArchiveDisk[]
|
||||
listVmBackupsNg(
|
||||
backupRepositoryIds: XoBackupRepository['id'][],
|
||||
opts?: { _forceRefresh?: boolean; vmId: XoVm['id'] }
|
||||
): Promise<Record<XoBackupRepository['id'], Record<XoVm['id'], XoVmBackupArchive[]>>>
|
||||
/**
|
||||
* Serve one disk of a backup archive as an iSCSI LUN and attach it, as an SR,
|
||||
* to a host. Undone by `unmountBackupArchiveDisk`.
|
||||
*/
|
||||
mountBackupArchiveDisk(params: {
|
||||
archiveId: XoVmBackupArchive['id']
|
||||
/** One of the archive's `disks[].id` */
|
||||
diskId: string
|
||||
/**
|
||||
* Host the disk is attached to; defaults to the host running this appliance
|
||||
* when `srId` is given (required to plug the cache disk there), otherwise
|
||||
* required
|
||||
*/
|
||||
hostId?: XoHost['id']
|
||||
/**
|
||||
* SR for a local read/write cache; omit for a lower-performance, read-only
|
||||
* mount that needs no local storage and can target any host
|
||||
*/
|
||||
srId?: XoSr['id']
|
||||
}): Promise<BackupArchiveDiskMount>
|
||||
/**
|
||||
* Force every block of a cached mount into its cache disk, so it becomes a
|
||||
* complete copy of the backup's disk without waiting for something else to
|
||||
* read it. Rejects if the mount was created without a cache.
|
||||
*/
|
||||
hydrateBackupArchiveDisk(
|
||||
id: BackupArchiveDiskMount['id']
|
||||
): Promise<{ id: BackupArchiveDiskMount['id']; materialized: BackupArchiveDiskMountProgress }>
|
||||
pingRemote(id: XoBackupRepository['id']): Promise<{ success: true }>
|
||||
/** Allow to add a new server in the DB (XCP-ng/XenServer) */
|
||||
registerXenServer(
|
||||
@@ -357,6 +425,8 @@ export type XoApp = {
|
||||
| { success: true; readRate: number; writeRate: number }
|
||||
| { success: false; step: string; file: string; error: unknown }
|
||||
>
|
||||
/** Detach a disk mounted by `mountBackupArchiveDisk` and stop serving it */
|
||||
unmountBackupArchiveDisk(id: BackupArchiveDiskMount['id']): Promise<void>
|
||||
/** Remove a server from the DB (XCP-ng/XenServer) */
|
||||
unregisterXenServer(id: XoServer['id']): Promise<void>
|
||||
updateUser(
|
||||
|
||||
90
@xen-orchestra/mixins/live-mount/_LocalBlockDevice.mjs
Normal file
90
@xen-orchestra/mixins/live-mount/_LocalBlockDevice.mjs
Normal file
@@ -0,0 +1,90 @@
|
||||
import { open } from 'node:fs/promises'
|
||||
|
||||
/**
|
||||
* Byte-range access to a local block device (`@vates/iscsi`'s `BlockDevice`).
|
||||
* Size is passed in, not stat'ed: `fs.stat` reports 0 for a device node.
|
||||
*/
|
||||
export default class LocalBlockDevice {
|
||||
#blockSize
|
||||
#handle
|
||||
#path
|
||||
#size
|
||||
|
||||
/**
|
||||
* @param {object} params
|
||||
* @param {string} params.path - device node, e.g. `/dev/xvdb`
|
||||
* @param {number} params.size - usable size in bytes
|
||||
* @param {number} [params.blockSize] - logical block size, defaults to 512
|
||||
*/
|
||||
constructor({ path, size, blockSize = 512 }) {
|
||||
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.#blockSize = blockSize
|
||||
this.#path = path
|
||||
this.#size = size
|
||||
}
|
||||
|
||||
async open() {
|
||||
if (this.#handle === undefined) {
|
||||
this.#handle = await open(this.#path, 'r+')
|
||||
}
|
||||
}
|
||||
|
||||
getSize() {
|
||||
return this.#size
|
||||
}
|
||||
|
||||
getBlockSize() {
|
||||
return this.#blockSize
|
||||
}
|
||||
|
||||
#check(offset, length) {
|
||||
if (this.#handle === undefined) {
|
||||
throw new Error('LocalBlockDevice.open() must be called before I/O')
|
||||
}
|
||||
if (offset < 0 || length < 0 || offset + length > this.#size) {
|
||||
throw new Error(`access of ${length} bytes at ${offset} is out of range (size ${this.#size})`)
|
||||
}
|
||||
}
|
||||
|
||||
async read(offset, length) {
|
||||
this.#check(offset, length)
|
||||
const buffer = Buffer.allocUnsafe(length)
|
||||
let read = 0
|
||||
// a single read may return fewer bytes than asked for
|
||||
while (read < length) {
|
||||
const { bytesRead } = await this.#handle.read(buffer, read, length - read, offset + read)
|
||||
if (bytesRead === 0) {
|
||||
// a device that is not connected yet reports no size and ends at 0
|
||||
throw new Error(`unexpected end of ${this.#path} at ${offset + read}, expected ${this.#size} bytes`)
|
||||
}
|
||||
read += bytesRead
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
|
||||
async write(offset, data) {
|
||||
this.#check(offset, data.length)
|
||||
let written = 0
|
||||
while (written < data.length) {
|
||||
const { bytesWritten } = await this.#handle.write(data, written, data.length - written, offset + written)
|
||||
written += bytesWritten
|
||||
}
|
||||
}
|
||||
|
||||
async flush() {
|
||||
await this.#handle?.sync()
|
||||
}
|
||||
|
||||
async close() {
|
||||
const handle = this.#handle
|
||||
if (handle !== undefined) {
|
||||
this.#handle = undefined
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
82
@xen-orchestra/mixins/live-mount/_LocalBlockDevice.test.mjs
Normal file
82
@xen-orchestra/mixins/live-mount/_LocalBlockDevice.test.mjs
Normal file
@@ -0,0 +1,82 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { after, before, describe, it } from 'node:test'
|
||||
import { mkdtemp, rm, truncate, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
|
||||
import LocalBlockDevice from './_LocalBlockDevice.mjs'
|
||||
|
||||
const SIZE = 64 * 1024
|
||||
|
||||
describe('LocalBlockDevice', () => {
|
||||
let dir
|
||||
let path
|
||||
|
||||
before(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'xo-local-block-device-'))
|
||||
path = join(dir, 'device.img')
|
||||
await writeFile(path, '')
|
||||
await truncate(path, SIZE)
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const openDevice = async (opts = {}) => {
|
||||
const device = new LocalBlockDevice({ path, size: SIZE, ...opts })
|
||||
await device.open()
|
||||
return device
|
||||
}
|
||||
|
||||
it('rejects a size that is not a positive multiple of the block size', () => {
|
||||
assert.throws(() => new LocalBlockDevice({ path, size: 0 }), /positive integer/)
|
||||
assert.throws(() => new LocalBlockDevice({ path, size: SIZE + 1 }), /not a multiple/)
|
||||
})
|
||||
|
||||
it('refuses I/O before open()', async () => {
|
||||
const device = new LocalBlockDevice({ path, size: SIZE })
|
||||
await assert.rejects(device.read(0, 512), /open\(\) must be called/)
|
||||
})
|
||||
|
||||
it('round-trips a byte range', async () => {
|
||||
const device = await openDevice()
|
||||
try {
|
||||
const payload = Buffer.alloc(1024, 0xab)
|
||||
await device.write(4096, payload)
|
||||
assert.deepEqual(await device.read(4096, 1024), payload)
|
||||
// and left the surrounding bytes alone
|
||||
assert.deepEqual(await device.read(4096 - 512, 512), Buffer.alloc(512))
|
||||
} finally {
|
||||
await device.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('reports its size and block size', async () => {
|
||||
const device = await openDevice({ blockSize: 4096 })
|
||||
try {
|
||||
assert.equal(device.getSize(), SIZE)
|
||||
assert.equal(device.getBlockSize(), 4096)
|
||||
} finally {
|
||||
await device.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses to read or write past the end', async () => {
|
||||
const device = await openDevice()
|
||||
try {
|
||||
await assert.rejects(device.read(SIZE - 256, 512), /out of range/)
|
||||
await assert.rejects(device.write(SIZE - 256, Buffer.alloc(512)), /out of range/)
|
||||
await assert.rejects(device.read(-512, 512), /out of range/)
|
||||
} finally {
|
||||
await device.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('flushes and closes idempotently', async () => {
|
||||
const device = await openDevice()
|
||||
await device.flush()
|
||||
await device.close()
|
||||
await device.close()
|
||||
})
|
||||
})
|
||||
94
@xen-orchestra/mixins/live-mount/_cache.mjs
Normal file
94
@xen-orchestra/mixins/live-mount/_cache.mjs
Normal file
@@ -0,0 +1,94 @@
|
||||
import { access, constants, readFile } from 'node:fs/promises'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import { Task } from '@vates/task'
|
||||
import pRetry from 'promise-toolbox/retry'
|
||||
|
||||
import LocalBlockDevice from './_LocalBlockDevice.mjs'
|
||||
import { cacheLabel } from './_utils.mjs'
|
||||
|
||||
const { debug, info } = createLogger('xo:mixins:LiveMount')
|
||||
|
||||
// the cache device node is created by udev after the plug, so it lags a little
|
||||
const DEVICE_POLL_DELAY = 500
|
||||
const DEVICE_POLL_TRIES = 60
|
||||
|
||||
// `/sys/class/block/<name>/size` counts 512-byte sectors, always
|
||||
const SECTOR_SIZE = 512
|
||||
|
||||
/**
|
||||
* Open the block device XAPI just plugged into this appliance. Polls
|
||||
* `/sys/class/block/<name>/size` rather than the device node's own reported
|
||||
* size: until the block frontend connects it reports 0 and reads hit EOF.
|
||||
*/
|
||||
export async function openLocalDevice({ name, size }) {
|
||||
const path = `/dev/${name}`
|
||||
const actual = await pRetry(
|
||||
async () => {
|
||||
await access(path, constants.R_OK | constants.W_OK)
|
||||
const sectors = Number.parseInt(await readFile(`/sys/class/block/${name}/size`, 'utf8'), 10)
|
||||
const bytes = sectors * SECTOR_SIZE
|
||||
if (!(bytes > 0)) {
|
||||
throw new Error(`${path} is not ready yet, it still reports no size`)
|
||||
}
|
||||
return bytes
|
||||
},
|
||||
{
|
||||
delay: DEVICE_POLL_DELAY,
|
||||
tries: DEVICE_POLL_TRIES,
|
||||
onRetry: error => debug('waiting for the cache device', { error, path }),
|
||||
}
|
||||
)
|
||||
if (actual < size) {
|
||||
throw new Error(`${path} holds ${actual} bytes, less than the ${size} bytes disk it is meant to cache`)
|
||||
}
|
||||
|
||||
const device = new LocalBlockDevice({ path, size })
|
||||
await device.open()
|
||||
return device
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the disk backing the cache and plug it into this appliance, so its
|
||||
* bytes are reachable as a local block device.
|
||||
*
|
||||
* This is the only random-access write path into a VDI: XAPI's NBD export is
|
||||
* read-only, and `VDI_importContent` is a whole-stream HTTP PUT.
|
||||
*
|
||||
* @returns {Promise<{ device: object, vbdRef: string, vdiRef: string, vdiUuid: string }>}
|
||||
*/
|
||||
export async function createCache($defer, { diskPath, size, srRef, vmRef, xapi, openCache = openLocalDevice }) {
|
||||
return Task.run({ properties: { name: 'set up cache disk' } }, async () => {
|
||||
const vdiRef = await Task.run({ properties: { name: 'create cache VDI' } }, () =>
|
||||
xapi.VDI_create({
|
||||
name_description: `read cache for ${diskPath}`,
|
||||
name_label: `${cacheLabel(diskPath)}.raw`,
|
||||
SR: srRef,
|
||||
virtual_size: size,
|
||||
})
|
||||
)
|
||||
$defer.onFailure(() => xapi.VDI_destroy(vdiRef))
|
||||
|
||||
// `throwVbdPlug` is not optional here: without it VBD_create only warns when
|
||||
// the plug fails, and we would carry on with a disk that is not attached
|
||||
const { vbdRef, name } = await Task.run({ properties: { name: 'plug cache VDI' } }, async () => {
|
||||
const vbdRef = await xapi.VBD_create({ mode: 'RW', throwVbdPlug: true, type: 'Disk', VDI: vdiRef, VM: vmRef })
|
||||
$defer.onFailure(() => xapi.VBD_destroy(vbdRef))
|
||||
|
||||
// read back after the plug: the device name passed to VBD.create is
|
||||
// ignored for a running VM, XAPI assigns it
|
||||
const name = await xapi.getField('VBD', vbdRef, 'device')
|
||||
if (name === '') {
|
||||
throw new Error(`XAPI did not assign a device to the cache disk of ${diskPath}`)
|
||||
}
|
||||
return { vbdRef, name }
|
||||
})
|
||||
|
||||
const device = await Task.run({ properties: { name: 'wait for cache device' } }, () => openCache({ name, size }))
|
||||
$defer.onFailure(() => device.close())
|
||||
|
||||
const vdiUuid = await xapi.getField('VDI', vdiRef, 'uuid')
|
||||
info('cache disk plugged', { device: name, size, vdiUuid })
|
||||
|
||||
return { device, vbdRef, vdiRef, vdiUuid }
|
||||
})
|
||||
}
|
||||
126
@xen-orchestra/mixins/live-mount/_sr.mjs
Normal file
126
@xen-orchestra/mixins/live-mount/_sr.mjs
Normal file
@@ -0,0 +1,126 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { asyncEach } from '@vates/async-each'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import { Task } from '@vates/task'
|
||||
|
||||
import { cacheLabel } from './_utils.mjs'
|
||||
|
||||
const { debug, info, warn } = createLogger('xo:mixins:LiveMount')
|
||||
|
||||
// Raw "LUN per VDI" driver: the LUN becomes a VDI as-is, with no LVM written to
|
||||
// it — the only iSCSI SR type usable on a read-only LUN.
|
||||
const SR_TYPE = 'iscsi'
|
||||
|
||||
// identifies the SRs we created, to recognize leftovers
|
||||
const OC_MOUNT = 'xo:live-mount'
|
||||
|
||||
// Our target exposes exactly one LUN, numbered 0.
|
||||
const LUN_ID = '0'
|
||||
|
||||
// see introduceVdi: sm_config is StaticRO, so this module introduces the VDI
|
||||
// itself rather than letting a scan do it
|
||||
const IMAGE_FORMAT_RAW = 'raw'
|
||||
|
||||
/**
|
||||
* Introduce the SR for the LUN on `hostRef` and plug it there.
|
||||
*
|
||||
* `SR.introduce`, not `SR.create`: create would scan and introduce the VDI
|
||||
* itself (see `introduceVdi`), before we get a chance to set `sm_config`.
|
||||
*/
|
||||
export async function introduceSr($defer, { xapi, hostRef, deviceConfig, id, nameLabel, diskPath }) {
|
||||
return Task.run({ properties: { name: 'introduce SR' } }, async () => {
|
||||
const srUuid = randomUUID()
|
||||
const srRef = await Task.run({ properties: { name: 'call SR.introduce' } }, () =>
|
||||
xapi.call(
|
||||
'SR.introduce',
|
||||
srUuid,
|
||||
nameLabel ?? `[XO live mount] ${id.slice(0, 8)}`,
|
||||
`mount of ${diskPath}`,
|
||||
SR_TYPE,
|
||||
'user',
|
||||
false, // not shared: this mount serves a single host
|
||||
{}
|
||||
)
|
||||
)
|
||||
$defer.onFailure(() => xapi.call('SR.forget', srRef))
|
||||
|
||||
await Task.run({ properties: { name: 'create and plug PBD' } }, async () => {
|
||||
const pbdRef = await xapi.call('PBD.create', { host: hostRef, SR: srRef, device_config: deviceConfig })
|
||||
await xapi.call('PBD.plug', pbdRef)
|
||||
})
|
||||
$defer.onFailure(() => forgetSr(xapi, srRef))
|
||||
|
||||
await xapi.setFieldEntry('SR', srRef, 'other_config', OC_MOUNT, id)
|
||||
// disable the auto-scan that would otherwise introduce the VDI itself (see
|
||||
// introduceVdi); nothing else writes this key
|
||||
await xapi.setFieldEntry('SR', srRef, 'other_config', 'auto-scan', 'false')
|
||||
|
||||
return { srRef, srUuid }
|
||||
})
|
||||
}
|
||||
|
||||
// SR.forget rather than SR.destroy: the LUN content must not be touched
|
||||
export async function forgetSr(xapi, srRef) {
|
||||
const pbdRefs = await xapi.call('SR.get_PBDs', srRef)
|
||||
await asyncEach(pbdRefs, pbdRef => xapi.call('PBD.unplug', pbdRef), { stopOnError: false })
|
||||
await xapi.call('SR.forget', srRef)
|
||||
}
|
||||
|
||||
/**
|
||||
* Introduce the LUN as a VDI ourselves — the only way to set `sm_config`,
|
||||
* which is StaticRO and can never be amended after introduction.
|
||||
*
|
||||
* `LUNid` is what the driver needs to find the device; it fills in `SCSIid`
|
||||
* and `backend-kind` itself.
|
||||
*
|
||||
* `readOnly` only states intent: the driver ignores it (and derives its own
|
||||
* uuid too, in `_db_introduce()`), so the LUN itself enforces it instead —
|
||||
* cached mounts accept writes into the cache, uncached ones throw.
|
||||
*/
|
||||
export async function introduceVdi({ xapi, srRef, SCSIid, size, diskPath, readOnly }) {
|
||||
return Task.run({ properties: { name: 'introduce VDI' } }, async () => {
|
||||
const uuid = randomUUID()
|
||||
await Task.run({ properties: { name: 'call VDI.introduce' } }, () =>
|
||||
xapi.call(
|
||||
'VDI.introduce',
|
||||
uuid,
|
||||
`${cacheLabel(diskPath)}.raw`,
|
||||
`mount of ${diskPath}`,
|
||||
srRef,
|
||||
'user',
|
||||
false, // sharable
|
||||
readOnly,
|
||||
{}, // other_config
|
||||
uuid, // location: this driver uses the uuid
|
||||
{}, // xenstore_data
|
||||
{ LUNid: LUN_ID, SCSIid, type: IMAGE_FORMAT_RAW },
|
||||
true, // managed
|
||||
size,
|
||||
0, // physical_utilisation: nothing is allocated locally
|
||||
'OpaqueRef:NULL', // metadata_of_pool
|
||||
false, // is_a_snapshot
|
||||
'19700101T00:00:00Z', // snapshot_time
|
||||
'OpaqueRef:NULL' // snapshot_of
|
||||
)
|
||||
)
|
||||
|
||||
return Task.run({ properties: { name: 'resolve introduced VDI uuid' } }, async () => {
|
||||
// The driver derives the VDI uuid from the LUN's serial, so the record may
|
||||
// not carry the uuid we asked for: resolve it by SCSIid instead.
|
||||
const vdiRefs = await xapi.call('SR.get_VDIs', srRef)
|
||||
for (const vdiRef of vdiRefs) {
|
||||
const vdi = await xapi.getRecord('VDI', vdiRef)
|
||||
if (vdi.sm_config.SCSIid !== SCSIid) {
|
||||
continue
|
||||
}
|
||||
// the whole record, to check what the driver kept of what we asked for
|
||||
debug('introduced VDI', vdi)
|
||||
if (vdi.uuid !== uuid) {
|
||||
info('the driver renamed the introduced VDI', { asked: uuid, got: vdi.uuid })
|
||||
}
|
||||
return vdi.uuid
|
||||
}
|
||||
warn('no VDI found for the introduced LUN', { SCSIid, srRef })
|
||||
})
|
||||
})
|
||||
}
|
||||
68
@xen-orchestra/mixins/live-mount/_target.mjs
Normal file
68
@xen-orchestra/mixins/live-mount/_target.mjs
Normal file
@@ -0,0 +1,68 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import { XMLParser } from 'fast-xml-parser'
|
||||
|
||||
const { warn } = createLogger('xo:mixins:LiveMount')
|
||||
|
||||
// open-iscsi refuses a CHAP secret outside 12-16 characters
|
||||
const CHAP_SECRET_LENGTH = 16
|
||||
|
||||
// SR.probe() answers the LUN list through a fault instead of a result when the
|
||||
// device config does not name a SCSIid yet.
|
||||
const PROBE_LUN_LIST_ERROR = 'SR_BACKEND_FAILURE_107'
|
||||
const PROBE_NO_TARGET_ERROR = 'SR_BACKEND_FAILURE_141'
|
||||
|
||||
// The probe only performs iSCSI discovery, so `lvmoiscsi` is usable to enumerate
|
||||
// LUNs whatever SR type is created afterwards.
|
||||
const PROBE_SR_TYPE = 'lvmoiscsi'
|
||||
|
||||
const parseXml = (() => {
|
||||
const parser = new XMLParser({
|
||||
attributeNamePrefix: '',
|
||||
ignoreAttributes: false,
|
||||
ignoreDeclaration: true,
|
||||
parseTagValue: false,
|
||||
parseAttributeValue: false,
|
||||
})
|
||||
return xml => parser.parse(Buffer.isBuffer(xml) ? xml.toString() : xml)
|
||||
})()
|
||||
|
||||
/** Random CHAP credentials for one mount; `id` only picks a readable username. */
|
||||
export function createChapCredentials(id) {
|
||||
return {
|
||||
user: `xo-${id.slice(0, 8)}`,
|
||||
secret: randomBytes(CHAP_SECRET_LENGTH).toString('base64url').slice(0, CHAP_SECRET_LENGTH),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the host which SCSIid it computes for our LUN. Passing an incomplete
|
||||
* device config makes SR.probe() answer with a fault holding the LUN list.
|
||||
*/
|
||||
export async function probeScsiId({ xapi, hostRef, deviceConfig, address }) {
|
||||
let xml
|
||||
try {
|
||||
const probed = await xapi.call('SR.probe', hostRef, deviceConfig, PROBE_SR_TYPE, {})
|
||||
// it answers the LUN list through a fault, so a plain result is unexpected
|
||||
warn('SR.probe returned instead of reporting the LUN list', { probed })
|
||||
throw new Error('SR.probe should have reported the LUN list')
|
||||
} catch (error) {
|
||||
if (error.code === PROBE_NO_TARGET_ERROR) {
|
||||
const wrapped = new Error(`the host cannot reach the iSCSI target at ${address}, check iscsi.advertisedAddress`)
|
||||
wrapped.cause = error
|
||||
throw wrapped
|
||||
}
|
||||
if (error.code !== PROBE_LUN_LIST_ERROR) {
|
||||
throw error
|
||||
}
|
||||
xml = parseXml(error.params[2])
|
||||
}
|
||||
|
||||
const luns = xml['iscsi-target']?.LUN
|
||||
const lun = Array.isArray(luns) ? luns[0] : luns
|
||||
const SCSIid = lun?.SCSIid?.trim()
|
||||
if (SCSIid === undefined || SCSIid === '') {
|
||||
throw new Error(`no LUN reported by the host for target ${deviceConfig.targetIQN}`)
|
||||
}
|
||||
return SCSIid
|
||||
}
|
||||
68
@xen-orchestra/mixins/live-mount/_utils.mjs
Normal file
68
@xen-orchestra/mixins/live-mount/_utils.mjs
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Task } from '@vates/task'
|
||||
|
||||
// the LUN serves the *content* of the source disk, not its container format, so
|
||||
// a label must not keep a `.vhd`/`.alias.vhd` suffix: it would suggest a format
|
||||
// the storage layer would then read differently
|
||||
export const cacheLabel = diskPath =>
|
||||
diskPath
|
||||
.split('/')
|
||||
.pop()
|
||||
.replace(/(\.alias)?\.vhd$/, '')
|
||||
|
||||
const PROGRESS_THROTTLE_MS = 5e3
|
||||
const PROGRESS_THROTTLE_STEP = 0.01
|
||||
|
||||
/**
|
||||
* Reports cache-fill progress on a retained `caching` subtask rather than via
|
||||
* `Task.set`'s ambient convention: materialization can be triggered from an
|
||||
* arbitrary call stack (a raw SCSI read has no ambient task to attach to).
|
||||
*/
|
||||
class CachingProgressHandler {
|
||||
#task
|
||||
#lastDate
|
||||
#lastValue
|
||||
|
||||
constructor(task) {
|
||||
this.#task = task
|
||||
}
|
||||
|
||||
setProgress(fraction) {
|
||||
if (
|
||||
this.#lastDate !== undefined &&
|
||||
Date.now() - this.#lastDate < PROGRESS_THROTTLE_MS &&
|
||||
fraction - this.#lastValue < PROGRESS_THROTTLE_STEP
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.#report(() => this.#task.set('progress', Math.round(fraction * 100)))
|
||||
this.#lastDate = Date.now()
|
||||
this.#lastValue = fraction
|
||||
}
|
||||
|
||||
done() {
|
||||
// every block is cached: this subtask's job is over, whether that came
|
||||
// from an explicit hydration or just from enough of the disk being read
|
||||
this.#report(() => this.#task.success())
|
||||
}
|
||||
|
||||
// guards against the task having already ended — e.g. the mount was
|
||||
// unmounted (which also ends this task) while a block was in flight
|
||||
#report(fn) {
|
||||
if (this.#task.status === 'pending') {
|
||||
fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A subtask, nested under whatever `@vates/task` is ambient, tracking one
|
||||
* mount's cache-fill progress for as long as the mount lives — not just one
|
||||
* {@link hydrateDisk} call, since progress can equally come from a guest
|
||||
* reading the disk on its own. Ended by {@link CachingProgressHandler#done},
|
||||
* or by the caller if the mount is torn down first.
|
||||
*/
|
||||
export function createCachingTask() {
|
||||
const task = new Task({ properties: { name: 'caching' } })
|
||||
task.start()
|
||||
return { task, progressHandler: new CachingProgressHandler(task) }
|
||||
}
|
||||
304
@xen-orchestra/mixins/live-mount/index.mjs
Normal file
304
@xen-orchestra/mixins/live-mount/index.mjs
Normal file
@@ -0,0 +1,304 @@
|
||||
import { asyncEach } from '@vates/async-each'
|
||||
import { CachedDiskBlockDevice, DiskBlockDevice, IscsiTarget } from '@vates/iscsi'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import { defer } from 'golike-defer'
|
||||
import { openDiskChain } from '@xen-orchestra/backup-archive/disks'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { Task } from '@vates/task'
|
||||
|
||||
import { createCache, openLocalDevice } from './_cache.mjs'
|
||||
import { createChapCredentials, probeScsiId } from './_target.mjs'
|
||||
import { forgetSr, introduceSr, introduceVdi } from './_sr.mjs'
|
||||
import { createCachingTask } from './_utils.mjs'
|
||||
|
||||
const { info, warn } = createLogger('xo:mixins:LiveMount')
|
||||
|
||||
/**
|
||||
* Serve a disk as an iSCSI LUN and attach it, as an SR, to a host — so its
|
||||
* content is usable without copying it first.
|
||||
*
|
||||
* Caching is optional. With a cache, a local disk plugged into the
|
||||
* appliance's VM absorbs reads (fetched from the source once, then served
|
||||
* locally) and accepts writes, so a written-to mount no longer matches its
|
||||
* source; once fully read, the cache is a complete copy (see `hydrateDisk` to
|
||||
* force that). Without a cache, every read hits the source directly, writes
|
||||
* are refused, and the mount can target any host — nothing needs to be
|
||||
* plugged into the appliance's own VM.
|
||||
*
|
||||
* Only `app.config`/`app.hooks` are used from `app`: the source disk, XAPI
|
||||
* connection, appliance VM and cache SR are all passed in by the caller, so
|
||||
* both xo-server and xo-proxy can use this mixin.
|
||||
*/
|
||||
export default class LiveMount {
|
||||
#app
|
||||
#createTarget
|
||||
#openCache
|
||||
#openDisk
|
||||
|
||||
// mount id -> mount record
|
||||
#mounts = new Map()
|
||||
|
||||
// `openDisk`/`createTarget`/`openCache` are injectable for tests only, like
|
||||
// xo-server's crypto-credentials mixin does with xenStore/fsPromises
|
||||
constructor(
|
||||
app,
|
||||
{ openDisk = openDiskChain, createTarget = options => new IscsiTarget(options), openCache = openLocalDevice } = {}
|
||||
) {
|
||||
this.#app = app
|
||||
this.#createTarget = createTarget
|
||||
this.#openCache = openCache
|
||||
this.#openDisk = openDisk
|
||||
|
||||
app.hooks.on('stop', () =>
|
||||
asyncEach(
|
||||
[...this.#mounts.keys()],
|
||||
id =>
|
||||
this.unmountDisk(id).catch(error => {
|
||||
warn('failed to unmount on stop', { error, id })
|
||||
}),
|
||||
{ stopOnError: false }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} params
|
||||
* @param {import('@xen-orchestra/fs').RemoteHandlerAbstract} params.handler - passed to `openDisk` to open the source disk
|
||||
* @param {string} params.diskPath - path of the source disk, passed to `openDisk`
|
||||
* @param {object} params.xapi - XAPI connection of the pool owning `hostRef`
|
||||
* @param {string} params.hostRef - opaque ref of the host the disk is attached to as an SR
|
||||
* @param {object} [params.cache] - enables the read/write cache; omit for a lower-performance,
|
||||
* read-only, no-local-storage mount that can target any host
|
||||
* @param {string} params.cache.srRef - opaque ref of the SR holding the cache disk; must be writable and
|
||||
* reachable from the appliance's own host, which is the caller's job to check
|
||||
* @param {string} params.cache.vmRef - opaque ref of *this* appliance's VM, where the cache disk is plugged;
|
||||
* must be on the same XAPI connection as `hostRef` (`xapi` is used for both)
|
||||
* @param {string} [params.nameLabel] - name of the created SR
|
||||
* @param {() => Promise<void>} [params.release] - called on unmount, e.g. to dispose the remote handler
|
||||
* @returns {Promise<{ id: string, srUuid: string, vdiUuid: string, cacheVdiUuid?: string, iqn: string, address: string, port: number }>}
|
||||
*/
|
||||
async mountDisk(params) {
|
||||
const mount = await this.#createDiskMount(params)
|
||||
this.#mounts.set(mount.id, mount)
|
||||
return {
|
||||
id: mount.id,
|
||||
srUuid: mount.srUuid,
|
||||
vdiUuid: mount.vdiUuid,
|
||||
cacheVdiUuid: mount.cache?.vdiUuid,
|
||||
iqn: mount.iqn,
|
||||
address: mount.address,
|
||||
port: mount.port,
|
||||
}
|
||||
}
|
||||
|
||||
#createDiskMount = defer(
|
||||
async ($defer, { handler, diskPath, xapi, hostRef, cache: cacheParams, nameLabel, release }) => {
|
||||
const config = this.#app.config
|
||||
const address = config.get('iscsi.advertisedAddress')
|
||||
|
||||
const id = randomBytes(16).toString('hex')
|
||||
const iqn = `iqn.2026-07.tech.vates.xo:live-mount-${id}`
|
||||
const chap = createChapCredentials(id)
|
||||
|
||||
// the chain must keep its block allocation tables: they tell which blocks
|
||||
// are allocated, and reading an unallocated one throws
|
||||
const disk = await Task.run({ properties: { name: 'open source disk' } }, () =>
|
||||
this.#openDisk({ handler, path: diskPath })
|
||||
)
|
||||
$defer.onFailure(() => disk.close())
|
||||
|
||||
let cache
|
||||
let cachingTask
|
||||
let lun
|
||||
if (cacheParams !== undefined) {
|
||||
cache = await createCache($defer, {
|
||||
diskPath,
|
||||
size: disk.getVirtualSize(),
|
||||
srRef: cacheParams.srRef,
|
||||
vmRef: cacheParams.vmRef,
|
||||
xapi,
|
||||
openCache: this.#openCache,
|
||||
})
|
||||
// nested under whatever task is ambient (the caller's own, if any),
|
||||
// and kept alive for the whole mount — not just this call — since the
|
||||
// cache can keep filling long after mounting itself is done
|
||||
const caching = createCachingTask()
|
||||
cachingTask = caching.task
|
||||
$defer.onFailure(() => {
|
||||
if (cachingTask.status === 'pending') {
|
||||
cachingTask.success()
|
||||
}
|
||||
})
|
||||
// the cache owns the reads: the source is only ever touched for a block
|
||||
// that is not in it yet
|
||||
lun = new CachedDiskBlockDevice({ cache: cache.device, disk, progressHandler: caching.progressHandler })
|
||||
} else {
|
||||
lun = new DiskBlockDevice({ disk })
|
||||
}
|
||||
|
||||
const target = this.#createTarget({
|
||||
chap,
|
||||
host: config.getOptional('iscsi.bindAddress'),
|
||||
identity: { serial: `xo-live-mount-${id}` },
|
||||
iqn,
|
||||
lun,
|
||||
port: 0, // ephemeral: one target per mount
|
||||
})
|
||||
// opens the LUN, so its capacity is readable afterwards
|
||||
await Task.run({ properties: { name: 'start iSCSI target' } }, () => target.listen())
|
||||
$defer.onFailure(() => target.close())
|
||||
const { port } = target.address()
|
||||
|
||||
const deviceConfig = {
|
||||
chapuser: chap.user,
|
||||
chappassword: chap.secret,
|
||||
port: String(port),
|
||||
target: address,
|
||||
targetIQN: iqn,
|
||||
}
|
||||
|
||||
const SCSIid = await Task.run({ properties: { name: 'probe target SCSIid' } }, () =>
|
||||
probeScsiId({ xapi, hostRef, deviceConfig, address })
|
||||
)
|
||||
const fullDeviceConfig = { ...deviceConfig, SCSIid }
|
||||
|
||||
const { srRef, srUuid } = await introduceSr($defer, {
|
||||
xapi,
|
||||
hostRef,
|
||||
deviceConfig: fullDeviceConfig,
|
||||
id,
|
||||
nameLabel,
|
||||
diskPath,
|
||||
})
|
||||
|
||||
const vdiUuid = await introduceVdi({
|
||||
xapi,
|
||||
srRef,
|
||||
SCSIid,
|
||||
size: lun.getSize(),
|
||||
diskPath,
|
||||
readOnly: cache === undefined,
|
||||
})
|
||||
|
||||
info('mounted', { id, address, port, srUuid, vdiUuid, cacheVdiUuid: cache?.vdiUuid, diskPath })
|
||||
|
||||
return {
|
||||
address,
|
||||
cache,
|
||||
cachingTask,
|
||||
disk,
|
||||
diskPath,
|
||||
id,
|
||||
iqn,
|
||||
lun,
|
||||
port,
|
||||
release,
|
||||
srRef,
|
||||
srUuid,
|
||||
target,
|
||||
vdiUuid,
|
||||
xapi,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Detach a mount from its host and stop serving it.
|
||||
*
|
||||
* @param {string} id - identifier returned by {@link LiveMount#mountDisk}
|
||||
*/
|
||||
async unmountDisk(id) {
|
||||
const mount = this.#mounts.get(id)
|
||||
if (mount === undefined) {
|
||||
throw new Error(`no such live mount ${id}`)
|
||||
}
|
||||
// drop it first, so a failing teardown cannot be retried against a
|
||||
// half-released mount
|
||||
this.#mounts.delete(id)
|
||||
|
||||
const { cache, cachingTask, xapi, srRef, target, release } = mount
|
||||
|
||||
// Ordered, and each step runs even if an earlier one failed: a mount holds a
|
||||
// socket, a file descriptor, a VBD, a VDI and an SR, and giving up halfway
|
||||
// would leak whatever came after.
|
||||
const errors = []
|
||||
const step = async (what, fn) => {
|
||||
try {
|
||||
await Task.run({ properties: { name: what } }, fn)
|
||||
} catch (error) {
|
||||
warn(`failed to ${what}`, { error, id })
|
||||
errors.push(error)
|
||||
}
|
||||
}
|
||||
|
||||
await step('forget the SR', () => forgetSr(xapi, srRef))
|
||||
// stop serving first, so no I/O is left in flight
|
||||
await step('close the target', () => target.close())
|
||||
if (cache !== undefined) {
|
||||
// whatever fraction it reached: there is no more source to cache from,
|
||||
// so its job ends here rather than being left pending forever
|
||||
await step('end the caching task', () => {
|
||||
if (cachingTask.status === 'pending') {
|
||||
cachingTask.success()
|
||||
}
|
||||
})
|
||||
// the LUN closes the cache device too; doing it again is a no-op, and
|
||||
// makes sure the descriptor is gone before XAPI is asked to take the
|
||||
// disk away
|
||||
await step('close the cache device', () => cache.device.close())
|
||||
// unplugs on the way, and the VDI destroy retries on VDI_IN_USE
|
||||
await step('destroy the cache VBD', () => xapi.VBD_destroy(cache.vbdRef))
|
||||
await step('destroy the cache VDI', () => xapi.VDI_destroy(cache.vdiRef))
|
||||
}
|
||||
await step('release the caller resources', () => release?.())
|
||||
|
||||
if (errors.length !== 0) {
|
||||
const error = new Error(`failed to unmount live mount ${id}`)
|
||||
error.cause = errors[0]
|
||||
error.errors = errors
|
||||
throw error
|
||||
}
|
||||
info('unmounted', { id, srUuid: mount.srUuid, cacheVdiUuid: mount.cache?.vdiUuid })
|
||||
}
|
||||
|
||||
/**
|
||||
* Force every block of a cached mount into its cache disk, so it becomes a
|
||||
* complete copy of the source without waiting for something else to read it.
|
||||
*
|
||||
* @param {string} id - identifier returned by {@link LiveMount#mountDisk}
|
||||
* @returns {Promise<{ id: string, materialized: { blocks: number, total: number } }>}
|
||||
*/
|
||||
async hydrateDisk(id) {
|
||||
const mount = this.#mounts.get(id)
|
||||
if (mount === undefined) {
|
||||
throw new Error(`no such live mount ${id}`)
|
||||
}
|
||||
if (mount.cache === undefined) {
|
||||
throw new Error(`live mount ${id} has no cache to hydrate`)
|
||||
}
|
||||
// progress and completion are already reported on the mount's `caching`
|
||||
// subtask (built at mount time) regardless of what triggers a block's
|
||||
// materialization, so this subtask only needs to say whether *this*
|
||||
// explicit call succeeded
|
||||
await Task.run({ properties: { name: 'hydrate' } }, () => mount.lun.hydrate())
|
||||
return { id, materialized: mount.lun.getMaterialized() }
|
||||
}
|
||||
|
||||
/** Live disk mounts, in creation order. */
|
||||
listMountedDisks() {
|
||||
return [...this.#mounts.values()].map(({ id, srUuid, vdiUuid, cache, diskPath, iqn, address, port, lun }) => ({
|
||||
id,
|
||||
srUuid,
|
||||
vdiUuid,
|
||||
cacheVdiUuid: cache?.vdiUuid,
|
||||
diskPath,
|
||||
iqn,
|
||||
address,
|
||||
port,
|
||||
// how much of the source has been pulled into the cache: once every
|
||||
// block is there, the cache disk holds the whole disk. Not applicable
|
||||
// to an uncached mount, which never retains anything.
|
||||
materialized: lun.getMaterialized?.(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
555
@xen-orchestra/mixins/live-mount/index.test.mjs
Normal file
555
@xen-orchestra/mixins/live-mount/index.test.mjs
Normal file
@@ -0,0 +1,555 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { Task } from '@vates/task'
|
||||
|
||||
import LiveMount from './index.mjs'
|
||||
|
||||
const SCSI_ID = '1VATES_xo-backup-db4582d0c901d31379ac5eb5deea9a68'
|
||||
const DISK_SIZE = 2 * 1024 * 1024 * 1024
|
||||
|
||||
// as reported by a real host, trailing space included
|
||||
const LUN_LIST_XML = `<?xml version="1.0" ?>
|
||||
<iscsi-target>
|
||||
<LUN>
|
||||
<vendor>VATES</vendor>
|
||||
<LUNid>0</LUNid>
|
||||
<size>2147483648</size>
|
||||
<SCSIid>${SCSI_ID} </SCSIid>
|
||||
</LUN>
|
||||
</iscsi-target>`
|
||||
|
||||
const HOST_REF = 'OpaqueRef:host'
|
||||
const SR_REF = 'OpaqueRef:sr'
|
||||
|
||||
class XapiError extends Error {
|
||||
constructor(code, params) {
|
||||
super(code)
|
||||
this.code = code
|
||||
this.params = params
|
||||
}
|
||||
}
|
||||
|
||||
const CACHE_SR_REF = 'OpaqueRef:cache-sr'
|
||||
const CACHE_VDI_REF = 'OpaqueRef:cache-vdi'
|
||||
const CACHE_VBD_REF = 'OpaqueRef:cache-vbd'
|
||||
const VM_REF = 'OpaqueRef:vm'
|
||||
|
||||
const makeXapi = ({ probeError, vdiSmConfig, cacheDevice = 'xvdb' } = {}) => {
|
||||
const calls = []
|
||||
return {
|
||||
calls,
|
||||
async call(method, ...args) {
|
||||
calls.push([method, ...args])
|
||||
switch (method) {
|
||||
case 'SR.probe':
|
||||
throw probeError ?? new XapiError('SR_BACKEND_FAILURE_107', ['', '', LUN_LIST_XML])
|
||||
case 'SR.introduce':
|
||||
return SR_REF
|
||||
case 'PBD.create':
|
||||
return 'OpaqueRef:pbd'
|
||||
case 'SR.get_VDIs':
|
||||
return ['OpaqueRef:vdi']
|
||||
case 'SR.get_PBDs':
|
||||
return ['OpaqueRef:pbd']
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
async setFieldEntry(...args) {
|
||||
calls.push(['setFieldEntry', ...args])
|
||||
},
|
||||
async getField(type, ref, field) {
|
||||
calls.push(['getField', type, ref, field])
|
||||
if (type === 'VBD' && field === 'device') {
|
||||
return cacheDevice
|
||||
}
|
||||
if (type === 'VDI' && field === 'uuid') {
|
||||
return 'cache-vdi-uuid'
|
||||
}
|
||||
return 'sr-uuid'
|
||||
},
|
||||
async getRecord(type, ref) {
|
||||
calls.push(['getRecord', type, ref])
|
||||
// the driver derives the VDI uuid from the LUN serial, so it differs from
|
||||
// the one we asked for
|
||||
return { uuid: 'vdi-uuid', sm_config: vdiSmConfig ?? { SCSIid: SCSI_ID } }
|
||||
},
|
||||
async VDI_create(params) {
|
||||
calls.push(['VDI_create', params])
|
||||
return CACHE_VDI_REF
|
||||
},
|
||||
async VBD_create(params) {
|
||||
calls.push(['VBD_create', params])
|
||||
return CACHE_VBD_REF
|
||||
},
|
||||
async VBD_destroy(ref) {
|
||||
calls.push(['VBD_destroy', ref])
|
||||
},
|
||||
async VDI_destroy(ref) {
|
||||
calls.push(['VDI_destroy', ref])
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** In-memory stand-in for the plugged cache device. */
|
||||
const makeCacheDevice = size => {
|
||||
const content = Buffer.alloc(size)
|
||||
return {
|
||||
closed: false,
|
||||
content,
|
||||
getSize: () => size,
|
||||
getBlockSize: () => 512,
|
||||
read: async (offset, length) => Buffer.from(content.subarray(offset, offset + length)),
|
||||
write: async (offset, data) => {
|
||||
data.copy(content, offset)
|
||||
},
|
||||
flush: async () => {},
|
||||
close: async function () {
|
||||
this.closed = true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const makeMixin = ({ diskOpenError, listenError, openCacheError } = {}) => {
|
||||
const hooks = new EventEmitter()
|
||||
const app = {
|
||||
config: {
|
||||
get: path => {
|
||||
assert.equal(path, 'iscsi.advertisedAddress')
|
||||
return '192.168.1.8'
|
||||
},
|
||||
getOptional: () => undefined,
|
||||
},
|
||||
hooks,
|
||||
}
|
||||
|
||||
const disk = {
|
||||
closed: false,
|
||||
close: async () => (disk.closed = true),
|
||||
getBlockSize: () => 2 * 1024 * 1024, // as a VHD chain would report
|
||||
getVirtualSize: () => DISK_SIZE,
|
||||
}
|
||||
const target = {
|
||||
closed: false,
|
||||
options: undefined,
|
||||
address: () => ({ port: 34567 }),
|
||||
listen: async () => {
|
||||
if (listenError !== undefined) {
|
||||
throw listenError
|
||||
}
|
||||
// the real target opens the LUN, which is what exposes its capacity
|
||||
await target.options.lun.open()
|
||||
},
|
||||
close: async () => (target.closed = true),
|
||||
}
|
||||
|
||||
const cache = makeCacheDevice(DISK_SIZE)
|
||||
|
||||
const mixin = new LiveMount(app, {
|
||||
openDisk: async params => {
|
||||
if (diskOpenError !== undefined) {
|
||||
throw diskOpenError
|
||||
}
|
||||
disk.params = params
|
||||
return disk
|
||||
},
|
||||
createTarget: options => {
|
||||
target.options = options
|
||||
return target
|
||||
},
|
||||
openCache: async params => {
|
||||
if (openCacheError !== undefined) {
|
||||
throw openCacheError
|
||||
}
|
||||
cache.params = params
|
||||
return cache
|
||||
},
|
||||
})
|
||||
|
||||
return { app, cache, disk, hooks, mixin, target }
|
||||
}
|
||||
|
||||
const mount = (mixin, xapi, params) =>
|
||||
mixin.mountDisk({
|
||||
cache: { srRef: CACHE_SR_REF, vmRef: VM_REF },
|
||||
diskPath: 'xo-vm-backups/vm/vdis/job/vdi/20260731T120000Z.alias.vhd',
|
||||
hostRef: HOST_REF,
|
||||
xapi,
|
||||
...params,
|
||||
})
|
||||
|
||||
describe('mount', () => {
|
||||
it('serves the chain and attaches it as a non-shared iscsi SR on the host', async () => {
|
||||
const { mixin, target, disk, cache } = makeMixin()
|
||||
const xapi = makeXapi()
|
||||
|
||||
const result = await mount(mixin, xapi)
|
||||
|
||||
// the SR uuid is ours, since we introduce the SR instead of creating it
|
||||
assert.equal(result.srUuid, xapi.calls.find(([method]) => method === 'SR.introduce')[1])
|
||||
assert.match(result.srUuid, /^[0-9a-f-]{36}$/)
|
||||
assert.equal(result.vdiUuid, 'vdi-uuid')
|
||||
assert.equal(result.address, '192.168.1.8')
|
||||
assert.equal(result.port, 34567)
|
||||
assert.match(result.iqn, /^iqn\.2026-07\.tech\.vates\.xo:live-mount-[0-9a-f]{32}$/)
|
||||
|
||||
// the chain is opened with its block allocation tables
|
||||
assert.equal(disk.params.path, 'xo-vm-backups/vm/vdis/job/vdi/20260731T120000Z.alias.vhd')
|
||||
assert.equal(disk.params.ignoreBlockIndexes, undefined)
|
||||
|
||||
// one ephemeral target per mount, CHAP enabled, unique serial
|
||||
assert.equal(target.options.port, 0)
|
||||
assert.equal(target.options.iqn, result.iqn)
|
||||
assert.equal(target.options.chap.secret.length, 16)
|
||||
assert.equal(target.options.identity.serial, `xo-live-mount-${result.id}`)
|
||||
|
||||
// introduced, not created: SR.create would scan and introduce the VDI itself
|
||||
const srIntroduce = xapi.calls.find(([method]) => method === 'SR.introduce')
|
||||
assert.equal(srIntroduce[4], 'iscsi') // type
|
||||
assert.equal(srIntroduce[6], false) // shared
|
||||
assert.ok(!xapi.calls.some(([method]) => method === 'SR.create'))
|
||||
|
||||
// the cache disk is created on the given SR at the disk's full size, and
|
||||
// plugged into *this* appliance
|
||||
const vdiCreate = xapi.calls.find(([method]) => method === 'VDI_create')[1]
|
||||
assert.equal(vdiCreate.SR, CACHE_SR_REF)
|
||||
assert.equal(vdiCreate.virtual_size, DISK_SIZE)
|
||||
assert.equal(vdiCreate.name_label, '20260731T120000Z.raw')
|
||||
const vbdCreate = xapi.calls.find(([method]) => method === 'VBD_create')[1]
|
||||
assert.equal(vbdCreate.VM, VM_REF)
|
||||
assert.equal(vbdCreate.VDI, CACHE_VDI_REF)
|
||||
assert.equal(vbdCreate.mode, 'RW')
|
||||
// without this, VBD_create only warns when the plug fails
|
||||
assert.equal(vbdCreate.throwVbdPlug, true)
|
||||
// the device name is read back after the plug, XAPI assigns it
|
||||
assert.deepEqual(cache.params, { name: 'xvdb', size: DISK_SIZE })
|
||||
|
||||
// the PBD is created on the requested host
|
||||
const pbdCreate = xapi.calls.find(([method]) => method === 'PBD.create')[1]
|
||||
assert.equal(pbdCreate.host, HOST_REF)
|
||||
assert.equal(pbdCreate.SR, SR_REF)
|
||||
assert.deepEqual(pbdCreate.device_config, {
|
||||
SCSIid: SCSI_ID,
|
||||
chapuser: target.options.chap.user,
|
||||
chappassword: target.options.chap.secret,
|
||||
port: '34567',
|
||||
target: '192.168.1.8',
|
||||
targetIQN: result.iqn,
|
||||
})
|
||||
assert.ok(xapi.calls.some(([method, ref]) => method === 'PBD.plug' && ref === 'OpaqueRef:pbd'))
|
||||
|
||||
// the VDI is introduced by us: read_only and sm_config are settable only here
|
||||
const vdiIntroduce = xapi.calls.find(([method]) => method === 'VDI.introduce')
|
||||
assert.equal(vdiIntroduce[4], SR_REF)
|
||||
assert.equal(vdiIntroduce[5], 'user')
|
||||
assert.equal(vdiIntroduce[7], false) // read_only: writes land in the cache
|
||||
// `type` is the legacy key the sm drivers still accept:
|
||||
// `vdi_sm_config.get("image-format") or vdi_sm_config.get("type")`
|
||||
assert.deepEqual(vdiIntroduce[11], { LUNid: '0', SCSIid: SCSI_ID, type: 'raw' })
|
||||
assert.equal(vdiIntroduce[12], true) // managed
|
||||
assert.equal(vdiIntroduce[13], DISK_SIZE) // virtual size, from the LUN
|
||||
|
||||
// probe uses lvmoiscsi and does not name a SCSIid yet
|
||||
const probe = xapi.calls.find(([method]) => method === 'SR.probe')
|
||||
assert.equal(probe[3], 'lvmoiscsi')
|
||||
assert.equal(probe[2].SCSIid, undefined)
|
||||
|
||||
// ephemeral SR: tagged and not scanned at boot
|
||||
assert.deepEqual(
|
||||
xapi.calls.filter(([method]) => method === 'setFieldEntry').map(([, , , , entry, value]) => [entry, value]),
|
||||
[
|
||||
['xo:live-mount', result.id],
|
||||
['auto-scan', 'false'],
|
||||
]
|
||||
)
|
||||
// VDI.read_only is StaticRO in XAPI: nothing may try to set it
|
||||
assert.ok(!xapi.calls.some(([method]) => method === 'VDI.set_read_only'))
|
||||
|
||||
assert.deepEqual(
|
||||
mixin.listMountedDisks().map(({ id, srUuid, vdiUuid, cacheVdiUuid, materialized }) => ({
|
||||
id,
|
||||
srUuid,
|
||||
vdiUuid,
|
||||
cacheVdiUuid,
|
||||
materialized,
|
||||
})),
|
||||
[
|
||||
{
|
||||
id: result.id,
|
||||
srUuid: result.srUuid,
|
||||
vdiUuid: 'vdi-uuid',
|
||||
cacheVdiUuid: 'cache-vdi-uuid',
|
||||
// nothing read yet: 2 MiB blocks over the disk
|
||||
materialized: { blocks: 0, total: DISK_SIZE / (2 * 1024 * 1024) },
|
||||
},
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
it('serves reads from the cache once the source has been read', async () => {
|
||||
const { mixin, target, cache, disk } = makeMixin()
|
||||
const readBlocks = []
|
||||
disk.hasBlock = () => true
|
||||
disk.readBlock = async index => {
|
||||
readBlocks.push(index)
|
||||
return { index, data: Buffer.alloc(2 * 1024 * 1024, 0xab) }
|
||||
}
|
||||
|
||||
await mount(mixin, makeXapi())
|
||||
const lun = target.options.lun
|
||||
|
||||
assert.deepEqual(await lun.read(0, 512), Buffer.alloc(512, 0xab))
|
||||
assert.deepEqual(readBlocks, [0])
|
||||
// it landed in the cache disk...
|
||||
assert.deepEqual(cache.content.subarray(0, 512), Buffer.alloc(512, 0xab))
|
||||
// ... so a second read does not touch the source again
|
||||
assert.deepEqual(await lun.read(1024, 512), Buffer.alloc(512, 0xab))
|
||||
assert.deepEqual(readBlocks, [0])
|
||||
assert.deepEqual(mixin.listMountedDisks()[0].materialized, { blocks: 1, total: DISK_SIZE / (2 * 1024 * 1024) })
|
||||
})
|
||||
|
||||
it('closes the cache device when the probe fails', async () => {
|
||||
const { mixin, cache, disk, target } = makeMixin()
|
||||
const xapi = makeXapi({ probeError: new XapiError('SR_BACKEND_FAILURE_666', []) })
|
||||
|
||||
await assert.rejects(mount(mixin, xapi), { code: 'SR_BACKEND_FAILURE_666' })
|
||||
|
||||
assert.equal(cache.closed, true)
|
||||
assert.equal(target.closed, true)
|
||||
assert.equal(disk.closed, true)
|
||||
// and the cache disk is taken away again
|
||||
assert.deepEqual(
|
||||
xapi.calls.filter(([method]) => method.endsWith('_destroy')),
|
||||
[
|
||||
['VBD_destroy', CACHE_VBD_REF],
|
||||
['VDI_destroy', CACHE_VDI_REF],
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
it('destroys the cache disk when it cannot be opened', async () => {
|
||||
const { mixin, disk } = makeMixin({ openCacheError: new Error('ENOENT /dev/xvdb') })
|
||||
const xapi = makeXapi()
|
||||
|
||||
await assert.rejects(mount(mixin, xapi), /ENOENT/)
|
||||
|
||||
assert.equal(disk.closed, true)
|
||||
assert.deepEqual(
|
||||
xapi.calls.filter(([method]) => method.endsWith('_destroy')),
|
||||
[
|
||||
['VBD_destroy', CACHE_VBD_REF],
|
||||
['VDI_destroy', CACHE_VDI_REF],
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
it('fails when XAPI assigns no device to the cache disk', async () => {
|
||||
const { mixin } = makeMixin()
|
||||
await assert.rejects(mount(mixin, makeXapi({ cacheDevice: '' })), /did not assign a device/)
|
||||
})
|
||||
|
||||
it('reports an unreachable target as a configuration problem', async () => {
|
||||
const { mixin } = makeMixin()
|
||||
const xapi = makeXapi({ probeError: new XapiError('SR_BACKEND_FAILURE_141', []) })
|
||||
|
||||
await assert.rejects(mount(mixin, xapi), /cannot reach the iSCSI target at 192\.168\.1\.8/)
|
||||
})
|
||||
|
||||
it('closes the disk when the target cannot listen', async () => {
|
||||
const { mixin, disk } = makeMixin({ listenError: new Error('EADDRINUSE') })
|
||||
|
||||
await assert.rejects(mount(mixin, makeXapi()), /EADDRINUSE/)
|
||||
|
||||
assert.equal(disk.closed, true)
|
||||
})
|
||||
|
||||
it('mounts without a cache: no local disk, read-only, works on any host', async () => {
|
||||
const { mixin, target, disk } = makeMixin()
|
||||
const xapi = makeXapi()
|
||||
const otherHostRef = 'OpaqueRef:some-other-host'
|
||||
|
||||
const result = await mount(mixin, xapi, { cache: undefined, hostRef: otherHostRef })
|
||||
|
||||
// no cache disk at all
|
||||
assert.ok(!xapi.calls.some(([method]) => method === 'VDI_create'))
|
||||
assert.ok(!xapi.calls.some(([method]) => method === 'VBD_create'))
|
||||
assert.equal(result.cacheVdiUuid, undefined)
|
||||
|
||||
// the LUN is served straight from the source, on whichever host was asked
|
||||
const pbdCreate = xapi.calls.find(([method]) => method === 'PBD.create')[1]
|
||||
assert.equal(pbdCreate.host, otherHostRef)
|
||||
|
||||
// states the read-only intent, even though the driver itself ignores it
|
||||
const vdiIntroduce = xapi.calls.find(([method]) => method === 'VDI.introduce')
|
||||
assert.equal(vdiIntroduce[7], true) // read_only
|
||||
|
||||
// the LUN really is read-only: no local store to fall back on
|
||||
await assert.rejects(target.options.lun.write(0, Buffer.alloc(512)), /read-only/)
|
||||
|
||||
// an uncached mount has nothing to report progress on
|
||||
assert.equal(mixin.listMountedDisks()[0].materialized, undefined)
|
||||
|
||||
// and reads still go straight to the source
|
||||
disk.hasBlock = () => true
|
||||
disk.readBlock = async index => ({ index, data: Buffer.alloc(2 * 1024 * 1024, 0xcd) })
|
||||
assert.deepEqual(await target.options.lun.read(0, 512), Buffer.alloc(512, 0xcd))
|
||||
})
|
||||
})
|
||||
|
||||
describe('hydrate', () => {
|
||||
it('forces the whole disk into the cache', async () => {
|
||||
const { mixin, disk } = makeMixin()
|
||||
// a handful of blocks is enough to prove the point without allocating the
|
||||
// whole 2 GiB fixture disk
|
||||
const blockCount = 4
|
||||
disk.getVirtualSize = () => blockCount * 2 * 1024 * 1024
|
||||
disk.hasBlock = () => true
|
||||
disk.readBlock = async index => ({ index, data: Buffer.alloc(2 * 1024 * 1024, 0xab) })
|
||||
const { id } = await mount(mixin, makeXapi())
|
||||
|
||||
const result = await mixin.hydrateDisk(id)
|
||||
|
||||
assert.equal(result.id, id)
|
||||
assert.deepEqual(result.materialized, { blocks: blockCount, total: blockCount })
|
||||
assert.deepEqual(mixin.listMountedDisks()[0].materialized, result.materialized)
|
||||
})
|
||||
|
||||
it('refuses to hydrate an uncached mount', async () => {
|
||||
const { mixin } = makeMixin()
|
||||
const { id } = await mount(mixin, makeXapi(), { cache: undefined, hostRef: HOST_REF })
|
||||
|
||||
await assert.rejects(mixin.hydrateDisk(id), /has no cache to hydrate/)
|
||||
})
|
||||
|
||||
it('rejects an unknown mount', async () => {
|
||||
const { mixin } = makeMixin()
|
||||
await assert.rejects(mixin.hydrateDisk('nope'), /no such live mount nope/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('caching task', () => {
|
||||
it('is created at mount time, nested under whatever task is ambient, and tracks the fill regardless of trigger', async () => {
|
||||
const { mixin, disk } = makeMixin()
|
||||
const blockCount = 4
|
||||
disk.getVirtualSize = () => blockCount * 2 * 1024 * 1024
|
||||
disk.hasBlock = () => true
|
||||
disk.readBlock = async index => ({ index, data: Buffer.alloc(2 * 1024 * 1024, 0xab) })
|
||||
|
||||
const events = []
|
||||
const outerTask = new Task({ properties: { name: 'outer' }, onProgress: data => events.push(data) })
|
||||
|
||||
const { id } = await outerTask.runInside(() => mount(mixin, makeXapi()))
|
||||
|
||||
const cachingStart = events.find(e => e.type === 'start' && e.properties.name === 'caching')
|
||||
assert.notEqual(cachingStart, undefined)
|
||||
assert.equal(cachingStart.parentId, outerTask.id)
|
||||
assert.equal(
|
||||
events.some(e => e.id === cachingStart.id && e.type === 'end'),
|
||||
false
|
||||
)
|
||||
|
||||
// driven here by an explicit hydrate call, but the same handler backs
|
||||
// on-demand reads and writes too (CachedDiskBlockDevice's own tests cover
|
||||
// that) — whichever triggers materialization, it lands on this subtask
|
||||
await outerTask.runInside(() => mixin.hydrateDisk(id))
|
||||
|
||||
const progressEvents = events.filter(
|
||||
e => e.id === cachingStart.id && e.type === 'property' && e.name === 'progress'
|
||||
)
|
||||
assert.ok(progressEvents.length > 0)
|
||||
assert.equal(progressEvents.at(-1).value, 100)
|
||||
|
||||
const cachingEnd = events.find(e => e.id === cachingStart.id && e.type === 'end')
|
||||
assert.notEqual(cachingEnd, undefined)
|
||||
assert.equal(cachingEnd.status, 'success')
|
||||
})
|
||||
|
||||
it('ends the caching task at unmount, even if the disk was never fully cached', async () => {
|
||||
const { mixin } = makeMixin()
|
||||
const events = []
|
||||
const outerTask = new Task({ properties: { name: 'outer' }, onProgress: data => events.push(data) })
|
||||
const { id } = await outerTask.runInside(() => mount(mixin, makeXapi()))
|
||||
const cachingStart = events.find(e => e.type === 'start' && e.properties.name === 'caching')
|
||||
|
||||
await mixin.unmountDisk(id)
|
||||
|
||||
const cachingEnd = events.find(e => e.id === cachingStart.id && e.type === 'end')
|
||||
assert.notEqual(cachingEnd, undefined)
|
||||
assert.equal(cachingEnd.status, 'success')
|
||||
})
|
||||
|
||||
it('is not created for an uncached mount', async () => {
|
||||
const { mixin } = makeMixin()
|
||||
const events = []
|
||||
const outerTask = new Task({ properties: { name: 'outer' }, onProgress: data => events.push(data) })
|
||||
|
||||
await outerTask.runInside(() => mount(mixin, makeXapi(), { cache: undefined, hostRef: HOST_REF }))
|
||||
|
||||
assert.equal(
|
||||
events.some(e => e.type === 'start' && e.properties.name === 'caching'),
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unmount', () => {
|
||||
it('forgets the SR, releases the cache disk and the caller resources, in order', async () => {
|
||||
const { mixin, target, cache } = makeMixin()
|
||||
const xapi = makeXapi()
|
||||
let released = false
|
||||
|
||||
const { id } = await mount(mixin, xapi, { release: async () => (released = true) })
|
||||
xapi.calls.length = 0
|
||||
|
||||
await mixin.unmountDisk(id)
|
||||
|
||||
assert.deepEqual(
|
||||
xapi.calls.map(([method]) => method),
|
||||
['SR.get_PBDs', 'PBD.unplug', 'SR.forget', 'VBD_destroy', 'VDI_destroy']
|
||||
)
|
||||
assert.equal(target.closed, true)
|
||||
assert.equal(cache.closed, true)
|
||||
assert.equal(released, true)
|
||||
assert.deepEqual(mixin.listMountedDisks(), [])
|
||||
})
|
||||
|
||||
it('still tears the rest down when forgetting the SR fails', async () => {
|
||||
const { mixin, target, cache } = makeMixin()
|
||||
const xapi = makeXapi()
|
||||
let released = false
|
||||
const { id } = await mount(mixin, xapi, { release: async () => (released = true) })
|
||||
xapi.call = async () => {
|
||||
throw new Error('SR_HAS_NO_PBDS')
|
||||
}
|
||||
|
||||
await assert.rejects(mixin.unmountDisk(id), { message: `failed to unmount live mount ${id}` })
|
||||
|
||||
// a failing step must not strand the socket, the fd, the VBD or the VDI
|
||||
assert.equal(target.closed, true)
|
||||
assert.equal(cache.closed, true)
|
||||
assert.ok(xapi.calls.some(([method]) => method === 'VBD_destroy'))
|
||||
assert.ok(xapi.calls.some(([method]) => method === 'VDI_destroy'))
|
||||
// the mount is gone either way, a half-released mount must not be retried
|
||||
assert.deepEqual(mixin.listMountedDisks(), [])
|
||||
// and the caller's resources are handed back despite the failure
|
||||
assert.equal(released, true)
|
||||
})
|
||||
|
||||
it('rejects an unknown mount', async () => {
|
||||
const { mixin } = makeMixin()
|
||||
await assert.rejects(mixin.unmountDisk('nope'), /no such live mount nope/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the stop hook', () => {
|
||||
it('tears down every live mount', async () => {
|
||||
const { mixin, hooks, target } = makeMixin()
|
||||
await mount(mixin, makeXapi())
|
||||
|
||||
await Promise.all(hooks.listeners('stop').map(listener => listener()))
|
||||
|
||||
assert.equal(target.closed, true)
|
||||
assert.deepEqual(mixin.listMountedDisks(), [])
|
||||
})
|
||||
})
|
||||
@@ -16,15 +16,20 @@
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "0.20.1",
|
||||
"engines": {
|
||||
"node": ">=15.6"
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vates/async-each": "^1.0.3",
|
||||
"@vates/event-listeners-manager": "^1.0.1",
|
||||
"@vates/iscsi": "^0.1.0",
|
||||
"@vates/parse-duration": "^0.1.1",
|
||||
"@vates/task": "^0.7.0",
|
||||
"@xen-orchestra/backup-archive": "^1.1.1",
|
||||
"@xen-orchestra/log": "^0.7.2",
|
||||
"acme-client": "^5.0.0",
|
||||
"app-conf": "^3.0.0",
|
||||
"fast-xml-parser": "^4.0.0",
|
||||
"golike-defer": "^0.5.1",
|
||||
"lodash": "^4.18.0",
|
||||
"promise-toolbox": "^0.21.0",
|
||||
"xo-common": "^0.10.0"
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
|
||||
<!--packages-start-->
|
||||
|
||||
- @vates/iscsi major
|
||||
- @vates/nbd-client minor
|
||||
- @xen-orchestra/acl minor
|
||||
- @xen-orchestra/backups patch
|
||||
|
||||
@@ -205,6 +205,15 @@ requestTimeout = 0
|
||||
'/v5/netdata' = '[protocol]//localhost:[port]/netdata'
|
||||
'/v5/rest' = '[protocol]//localhost:[port]/rest'
|
||||
|
||||
[iscsi]
|
||||
# Address this appliance is reachable at *from the hosts*: it is handed to them
|
||||
# as the iSCSI portal when a backup disk is mounted, so a value they cannot
|
||||
# reach makes the mount fail. Required to mount a backup disk.
|
||||
#advertisedAddress = '192.168.0.1'
|
||||
|
||||
# Address the iSCSI targets bind to. Unset means all interfaces.
|
||||
#bindAddress = '0.0.0.0'
|
||||
|
||||
[logs]
|
||||
# Display all logs matching this filter, regardless of their level
|
||||
#filter = 'xo:load-balancer'
|
||||
|
||||
144
packages/xo-server/src/xo-mixins/_backup-disk-mounts.test.mjs
Normal file
144
packages/xo-server/src/xo-mixins/_backup-disk-mounts.test.mjs
Normal file
@@ -0,0 +1,144 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import { Task } from '@vates/task'
|
||||
|
||||
import BackupDiskMountsResolver from './backup-disk-mounts.mjs'
|
||||
|
||||
const ARCHIVE_ID = 'repo-id/xo-vm-backups/vm-uuid/archive.json'
|
||||
const DISK_ID = 'disk-id'
|
||||
const MOUNT_ID = 'mount-id'
|
||||
|
||||
const makeArchive = () => ({ id: ARCHIVE_ID, vm: { name_label: 'my-vm' }, disks: [{ id: DISK_ID }] })
|
||||
|
||||
/**
|
||||
* A fake `app` covering only the no-cache path (`srId` omitted): it never
|
||||
* calls `#getApplianceVm`/`#getCacheSr`, which depend on the real appliance's
|
||||
* XenStore and are out of reach in a unit test.
|
||||
*/
|
||||
function makeApp({ mountDisk, unmountDisk, hydrateDisk } = {}) {
|
||||
const disposed = []
|
||||
const tasks = []
|
||||
|
||||
const app = {
|
||||
listVmBackupsNg: async () => ({ 'repo-id': { 'vm-uuid': [makeArchive()] } }),
|
||||
getRemoteWithCredentials: async () => ({ id: 'remote' }),
|
||||
getBackupsRemoteAdapter: async () => ({
|
||||
value: { handler: {} },
|
||||
dispose: async () => {
|
||||
disposed.push(true)
|
||||
},
|
||||
}),
|
||||
getObject: (id, type) => {
|
||||
assert.equal(type, 'host')
|
||||
return { $pool: 'pool-uuid' }
|
||||
},
|
||||
getXapi: () => ({}),
|
||||
tasks: {
|
||||
create: properties => {
|
||||
const task = new Task({ properties })
|
||||
tasks.push({ task, properties })
|
||||
return task
|
||||
},
|
||||
},
|
||||
liveMount: {
|
||||
mountDisk: mountDisk ?? (async () => ({ id: MOUNT_ID, srUuid: 'sr-uuid', vdiUuid: 'vdi-uuid' })),
|
||||
unmountDisk: unmountDisk ?? (async () => {}),
|
||||
hydrateDisk: hydrateDisk ?? (async () => ({ id: MOUNT_ID, materialized: { blocks: 1, total: 1 } })),
|
||||
},
|
||||
}
|
||||
|
||||
return { app, disposed, tasks }
|
||||
}
|
||||
|
||||
describe('mountBackupArchiveDisk', () => {
|
||||
it('creates a task carrying the archive/disk, left pending after a successful mount', async () => {
|
||||
const { app, tasks } = makeApp()
|
||||
const resolver = new BackupDiskMountsResolver(app)
|
||||
|
||||
const result = await resolver.mountBackupArchiveDisk({ archiveId: ARCHIVE_ID, diskId: DISK_ID, hostId: 'host-id' })
|
||||
|
||||
assert.deepEqual(result, { id: MOUNT_ID, srUuid: 'sr-uuid', vdiUuid: 'vdi-uuid' })
|
||||
assert.equal(tasks.length, 1)
|
||||
const [{ task, properties }] = tasks
|
||||
assert.equal(task.status, 'pending')
|
||||
assert.equal(properties.objectId, ARCHIVE_ID)
|
||||
assert.equal(properties.diskId, DISK_ID)
|
||||
assert.equal(properties.type, 'xo:live-mount')
|
||||
})
|
||||
|
||||
it('ends the task in failure and disposes the remote adapter when the mount fails', async () => {
|
||||
const { app, disposed, tasks } = makeApp({
|
||||
mountDisk: async () => {
|
||||
throw new Error('mount failed')
|
||||
},
|
||||
})
|
||||
const resolver = new BackupDiskMountsResolver(app)
|
||||
|
||||
await assert.rejects(
|
||||
resolver.mountBackupArchiveDisk({ archiveId: ARCHIVE_ID, diskId: DISK_ID, hostId: 'host-id' }),
|
||||
/mount failed/
|
||||
)
|
||||
|
||||
assert.equal(tasks[0].task.status, 'failure')
|
||||
assert.deepEqual(disposed, [true])
|
||||
})
|
||||
})
|
||||
|
||||
describe('unmountBackupArchiveDisk', () => {
|
||||
it('succeeds the stored task and forgets it', async () => {
|
||||
const { app, tasks } = makeApp()
|
||||
const resolver = new BackupDiskMountsResolver(app)
|
||||
await resolver.mountBackupArchiveDisk({ archiveId: ARCHIVE_ID, diskId: DISK_ID, hostId: 'host-id' })
|
||||
|
||||
await resolver.unmountBackupArchiveDisk(MOUNT_ID)
|
||||
|
||||
assert.equal(tasks[0].task.status, 'success')
|
||||
// a second call falls back to a plain, task-less unmount instead of erroring
|
||||
await resolver.unmountBackupArchiveDisk(MOUNT_ID)
|
||||
})
|
||||
|
||||
it('ends the task in failure when unmounting fails', async () => {
|
||||
const { app, tasks } = makeApp({
|
||||
unmountDisk: async () => {
|
||||
throw new Error('unmount failed')
|
||||
},
|
||||
})
|
||||
const resolver = new BackupDiskMountsResolver(app)
|
||||
await resolver.mountBackupArchiveDisk({ archiveId: ARCHIVE_ID, diskId: DISK_ID, hostId: 'host-id' })
|
||||
|
||||
await assert.rejects(resolver.unmountBackupArchiveDisk(MOUNT_ID), /unmount failed/)
|
||||
|
||||
assert.equal(tasks[0].task.status, 'failure')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hydrateBackupArchiveDisk', () => {
|
||||
it('leaves the mount task pending and surfaces the error when hydration fails', async () => {
|
||||
const { app, tasks } = makeApp({
|
||||
hydrateDisk: async () => {
|
||||
throw new Error('hydration failed')
|
||||
},
|
||||
})
|
||||
const resolver = new BackupDiskMountsResolver(app)
|
||||
await resolver.mountBackupArchiveDisk({ archiveId: ARCHIVE_ID, diskId: DISK_ID, hostId: 'host-id' })
|
||||
|
||||
await assert.rejects(resolver.hydrateBackupArchiveDisk(MOUNT_ID), /hydration failed/)
|
||||
|
||||
// the disk is still mounted: its task must still be pending, not failed
|
||||
assert.equal(tasks[0].task.status, 'pending')
|
||||
// ... so a later unmount still works normally
|
||||
await resolver.unmountBackupArchiveDisk(MOUNT_ID)
|
||||
assert.equal(tasks[0].task.status, 'success')
|
||||
})
|
||||
|
||||
it('returns the result and keeps the task pending on success', async () => {
|
||||
const { app, tasks } = makeApp()
|
||||
const resolver = new BackupDiskMountsResolver(app)
|
||||
await resolver.mountBackupArchiveDisk({ archiveId: ARCHIVE_ID, diskId: DISK_ID, hostId: 'host-id' })
|
||||
|
||||
const result = await resolver.hydrateBackupArchiveDisk(MOUNT_ID)
|
||||
|
||||
assert.deepEqual(result, { id: MOUNT_ID, materialized: { blocks: 1, total: 1 } })
|
||||
assert.equal(tasks[0].task.status, 'pending')
|
||||
})
|
||||
})
|
||||
212
packages/xo-server/src/xo-mixins/backup-disk-mounts.mjs
Normal file
212
packages/xo-server/src/xo-mixins/backup-disk-mounts.mjs
Normal file
@@ -0,0 +1,212 @@
|
||||
import { invalidParameters, noSuchObject } from 'xo-common/api-errors.js'
|
||||
|
||||
import { canSrHaveNewVdiOfSize } from '../xapi/utils.mjs'
|
||||
import { getCurrentVmUuid } from '../_XenStore.mjs'
|
||||
|
||||
// a backup archive id is `<backup repository id>/<metadata path>`
|
||||
const getBackupRepositoryId = archiveId => archiveId.split('/')[0]
|
||||
|
||||
/**
|
||||
* Resolution layer between XO objects and the `LiveMount` shared mixin: turns
|
||||
* a backup archive id + disk id + SR id into a remote handler, disk path,
|
||||
* appliance VM and XAPI connection.
|
||||
*
|
||||
* Each mount gets its own long-lived XO task (created here, since the generic
|
||||
* mixin has no `app.tasks`), kept pending until `unmountBackupArchiveDisk`.
|
||||
* `LiveMount`'s steps already wrap themselves in ambient `@vates/task`
|
||||
* subtasks, so running the mount call inside this task via `runInside` nests
|
||||
* them under it for free.
|
||||
*/
|
||||
export default class BackupDiskMountsResolver {
|
||||
#app
|
||||
|
||||
// mount id -> the long-lived task representing it, from `mountBackupArchiveDisk`
|
||||
#tasks = new Map()
|
||||
|
||||
constructor(app) {
|
||||
this.#app = app
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve one disk of a backup archive as an iSCSI LUN and attach it, as an SR,
|
||||
* to a host.
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {string} params.archiveId - `<backup repository id>/<metadata path>`
|
||||
* @param {string} params.diskId - id of one of the archive's disks
|
||||
* @param {string} [params.hostId] - host the disk is attached to; defaults to the host running this
|
||||
* appliance when `srId` is given (required to plug the cache disk there), otherwise required
|
||||
* @param {string} [params.srId] - SR for a local read/write cache; omit for a lower-performance,
|
||||
* read-only mount that needs no local storage and can target any host
|
||||
*/
|
||||
async mountBackupArchiveDisk({ archiveId, diskId, hostId, srId }) {
|
||||
const app = this.#app
|
||||
|
||||
const archive = await this.#getArchive(archiveId)
|
||||
if (!archive.disks.some(disk => disk.id === diskId)) {
|
||||
// `diskId` is a path on the backup repository, an unchecked one would
|
||||
// expose any file it contains
|
||||
throw invalidParameters(`disk ${diskId} does not belong to backup archive ${archiveId}`)
|
||||
}
|
||||
|
||||
let cache
|
||||
let vm
|
||||
if (srId !== undefined) {
|
||||
vm = await this.#getApplianceVm()
|
||||
const cacheSr = this.#getCacheSr(srId, vm)
|
||||
cache = { srRef: cacheSr.$ref, vmRef: vm.$ref }
|
||||
}
|
||||
|
||||
if (hostId === undefined) {
|
||||
if (vm === undefined) {
|
||||
throw invalidParameters('host is required when no cache SR is given')
|
||||
}
|
||||
hostId = vm.$resident_on.uuid
|
||||
}
|
||||
const host = app.getObject(hostId, 'host')
|
||||
// `vm` (from getXapiObject) has no `.$pool`; `vm.$xapi.pool.uuid` is the
|
||||
// established way to get its pool, already used the same way in #getCacheSr
|
||||
if (vm !== undefined && host.$pool !== vm.$xapi.pool.uuid) {
|
||||
// the mixin uses a single XAPI connection for both the cache disk and
|
||||
// the mounted host, so they must be reachable through the same one
|
||||
throw invalidParameters(`host ${hostId} is not in the pool running this appliance, required to use a cache`)
|
||||
}
|
||||
|
||||
const remote = await app.getRemoteWithCredentials(getBackupRepositoryId(archiveId))
|
||||
const adapter = await app.getBackupsRemoteAdapter(remote)
|
||||
|
||||
// independent from the REST call's own task (which only covers this
|
||||
// method's return): this one stays pending for as long as the disk is
|
||||
// mounted, so it is created as its own root rather than nested under
|
||||
// whatever ambient task called us
|
||||
const task = app.tasks.create({
|
||||
name: `live mount of ${archive.vm.name_label} disk ${diskId}`,
|
||||
objectId: archiveId,
|
||||
type: 'xo:live-mount',
|
||||
diskId,
|
||||
})
|
||||
try {
|
||||
// `LiveMount`'s own steps wrap themselves in ambient `@vates/task`
|
||||
// subtasks, so running the call inside `task` is all it takes for them
|
||||
// to nest under it
|
||||
const result = await task.runInside(() =>
|
||||
app.liveMount.mountDisk({
|
||||
cache,
|
||||
diskPath: diskId,
|
||||
handler: adapter.value.handler,
|
||||
hostRef: host._xapiRef,
|
||||
nameLabel: `[XO backup] ${archive.vm.name_label}`,
|
||||
release: () => adapter.dispose(),
|
||||
xapi: app.getXapi(host),
|
||||
})
|
||||
)
|
||||
// left pending on purpose: `unmountBackupArchiveDisk` ends it later
|
||||
this.#tasks.set(result.id, task)
|
||||
return result
|
||||
} catch (error) {
|
||||
// `runInside` already ended `task` in failure
|
||||
await adapter.dispose()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The VM this appliance runs in: the cache disk, when there is one, is
|
||||
* plugged into it.
|
||||
*/
|
||||
async #getApplianceVm() {
|
||||
const uuid = await getCurrentVmUuid()
|
||||
try {
|
||||
return this.#app.getXapiObject(uuid, 'VM')
|
||||
} catch (error) {
|
||||
throw invalidParameters(
|
||||
`this appliance's VM (${uuid}) is not handled by this XO, maybe it is not connected to the pool running it`,
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The SR for the cache disk: it must be able to hold a new VDI and be reachable
|
||||
* from the host running this appliance.
|
||||
*
|
||||
* Free space is left to XAPI to enforce at creation time — the disk's virtual
|
||||
* size is only known once its chain has been opened, inside the mixin.
|
||||
*/
|
||||
#getCacheSr(srId, vm) {
|
||||
const app = this.#app
|
||||
const sr = app.getObject(srId, 'SR')
|
||||
const srRecord = app.getXapiObject(srId, 'SR')
|
||||
|
||||
const host = vm.$resident_on
|
||||
if (host === undefined) {
|
||||
throw invalidParameters("this appliance's VM is not running on a host of a connected pool")
|
||||
}
|
||||
if (sr.$pool !== vm.$xapi.pool.uuid) {
|
||||
throw invalidParameters(`SR ${srId} is not in the pool running this appliance`)
|
||||
}
|
||||
// covers ISO and removable SRs
|
||||
if (!canSrHaveNewVdiOfSize(srRecord, 0)) {
|
||||
throw invalidParameters(`SR ${srId} cannot hold a new disk: it is an ISO or removable SR`)
|
||||
}
|
||||
if (!sr.shared && sr.$container !== host.uuid) {
|
||||
throw invalidParameters(`SR ${srId} is local to another host than the one running this appliance`)
|
||||
}
|
||||
if (!sr.$PBDs.some(pbdId => app.getObject(pbdId, 'PBD').attached)) {
|
||||
throw invalidParameters(`SR ${srId} has no attached PBD`)
|
||||
}
|
||||
|
||||
return srRecord
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id - identifier returned by `mountBackupArchiveDisk`
|
||||
*/
|
||||
async unmountBackupArchiveDisk(id) {
|
||||
const task = this.#tasks.get(id)
|
||||
// gone whether unmounting succeeds or not: `LiveMount.unmountDisk` drops
|
||||
// its own record up front too, so a retry against this id could never
|
||||
// find a task to resume anyway
|
||||
this.#tasks.delete(id)
|
||||
if (task === undefined) {
|
||||
return this.#app.liveMount.unmountDisk(id)
|
||||
}
|
||||
await task.runInside(() => this.#app.liveMount.unmountDisk(id))
|
||||
task.success()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id - identifier returned by `mountBackupArchiveDisk`; must have been mounted with an `srId`
|
||||
*/
|
||||
async hydrateBackupArchiveDisk(id) {
|
||||
const task = this.#tasks.get(id)
|
||||
if (task === undefined) {
|
||||
return this.#app.liveMount.hydrateDisk(id)
|
||||
}
|
||||
// a failed hydration must not fail the mount's own long-lived task: the
|
||||
// disk is still mounted regardless, so the error is captured here and
|
||||
// rethrown outside `runInside`, which would otherwise end `task` in failure
|
||||
let error
|
||||
const result = await task.runInside(() => this.#app.liveMount.hydrateDisk(id).catch(e => (error = e)))
|
||||
if (error !== undefined) {
|
||||
throw error
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
listMountedBackupArchiveDisks() {
|
||||
return this.#app.liveMount.listMountedDisks()
|
||||
}
|
||||
|
||||
async #getArchive(archiveId) {
|
||||
const backupRepositoryId = getBackupRepositoryId(archiveId)
|
||||
const backupsByVm = (await this.#app.listVmBackupsNg([backupRepositoryId]))[backupRepositoryId] ?? {}
|
||||
for (const backups of Object.values(backupsByVm)) {
|
||||
const archive = backups.find(backup => backup.id === archiveId)
|
||||
if (archive !== undefined) {
|
||||
return archive
|
||||
}
|
||||
}
|
||||
throw noSuchObject(archiveId, 'backup-archive')
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import HttpProxy from '@xen-orchestra/mixins/HttpProxy.mjs'
|
||||
import includes from 'lodash/includes.js'
|
||||
import isEmpty from 'lodash/isEmpty.js'
|
||||
import iteratee from 'lodash/iteratee.js'
|
||||
import LiveMount from '@xen-orchestra/mixins/live-mount/index.mjs'
|
||||
import mixin from '@xen-orchestra/mixin'
|
||||
import mixinLegacy from '@xen-orchestra/mixin/legacy.js'
|
||||
import once from 'lodash/once.js'
|
||||
@@ -34,7 +35,7 @@ export default class Xo extends EventEmitter {
|
||||
constructor(opts) {
|
||||
super()
|
||||
|
||||
mixin(this, { Config, Hooks, HttpProxy, SslCertificate, Tasks }, [opts])
|
||||
mixin(this, { Config, Hooks, HttpProxy, LiveMount, SslCertificate, Tasks }, [opts])
|
||||
// a lot of mixins adds listener for start/stop/… events
|
||||
this.hooks.setMaxListeners(0)
|
||||
|
||||
|
||||
@@ -3851,6 +3851,13 @@
|
||||
dependencies:
|
||||
undici-types "~6.21.0"
|
||||
|
||||
"@types/node@^22.0":
|
||||
version "22.20.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.20.1.tgz#84e7cdf63cdaa20c134aa317ccc901aa21e16f0e"
|
||||
integrity sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==
|
||||
dependencies:
|
||||
undici-types "~6.21.0"
|
||||
|
||||
"@types/node@^22.3.0":
|
||||
version "22.19.20"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.19.20.tgz#243823634b177312895dab14a7fb1d0103094313"
|
||||
|
||||
Reference in New Issue
Block a user