Compare commits

..

1976 Commits

Author SHA1 Message Date
Olivier Lambert
69f2ee5013 feat: keep the objects of a disconnected pool, flagged as stale (prototype)
Demonstrator, to be split into backend and frontend PRs.

xo-server:
- after the grace period (`xapiMarkDisconnectedDelay`) or on an unexpected
  disconnection (e.g. `SESSION_INVALID` after a toolstack restart), the objects
  of the pool are kept with a `$lastSeen` property (last successful event fetch)
  instead of being removed
- on reconnection, they are replaced by the fresh ones, and those which no
  longer exist are removed
- deliberate disconnections still remove them immediately, and they are removed
  anyway after `xapiStaleObjectsMaxAge` (1 hour by default)
- actions on a stale object fail with a clear "pool disconnected, last seen"
  error instead of "no connection found for object"

Plugins: the load balancer, perf alert (perfmon configuration, RRD polling),
usage report and Netbox synchronization skip stale objects.

XO 6 and XO 5: stale objects are greyed out with a "Disconnected" tag (last seen
in tooltip), object pages show a warning banner, and XO 6 hides their actions.
2026-10-14 08:24:06 +02:00
Olivier Lambert
2e95adb6f5 chore: changelog PR number 2026-10-14 08:00:19 +02:00
Olivier Lambert
10cb696a82 fix(xo-server): cancel the disconnection grace period when the connection drops
When event fetching fails, a timer waits `xapiMarkDisconnectedDelay` before
forgetting the pool and marking the server `connecting`. This timer was not
cancelled when the XAPI connection itself was lost (e.g. `SESSION_INVALID`
after a toolstack restart), so it fired later on a state it did not own:

- if the auto-reconnection had already succeeded, it dropped the pool mapping
  of the new connection and marked it `connecting`: the server appeared as
  connecting while connected and actions on its objects failed with
  `no connection found for object`
- if the server was still disconnected, it marked it `connecting`, which stops
  the auto-reconnect loop: the pool was never reconnected automatically
- after a manual disconnection, a disabled server was marked `connecting`

The test relies on module mocking, hence the flag added to the test script.
2026-10-13 19:04:22 +02:00
Florent BEAUCHAMP
f93bb67409 fix(xen-api): really throw import error while importing VM (#10551) 2026-10-13 17:13:44 +02:00
Florent BEAUCHAMP
51bb88a1ce feat(vmware-explorer): method to list/attach/detail disk to satellites (#10449)
* feat(vmware-explorer): implement attaching and detaching disks
* feat(vmware-explorer): implement a check to see if a disk can be attached to a VM
* feat(vmware-explorer): implement a method to detect what host are candidates to deploy a satellite
* refactor: computing the datastore of a datapath
2026-10-13 13:58:23 +02:00
Pierre Donias
11335b465b fix(xo-web/self): fix removing a tag from a resource set (#10550)
Fixes Zammad#65918
2026-10-13 11:05:35 +02:00
Killian
295b8838a6 docs(backups): explain when reverse replication updates the VM in place (#10558) 2026-10-13 10:01:20 +02:00
Pierre Briffaux
e0b0c75682 feat(xo6): add copy button for multiple information (#9976) 2026-10-13 09:16:35 +02:00
Thomas Moraine
286dafa267 docs: document warm migration and fix broken link in architecture page (#10570) 2026-10-12 18:20:37 +02:00
Thierry Goettelmann
b08ad1669f feat(web-core): add UiLinkList component (#10287) 2026-10-12 17:36:54 +02:00
Thomas Moraine
4731cbd629 docs(advanced.md): document estimated per-VM power consumption metric (#10569) 2026-10-12 17:13:27 +02:00
Florent BEAUCHAMP
1c4890be10 fix(xo-cli): error when using output on a rest api query (#10541) 2026-10-12 16:59:56 +02:00
Pierre Donias
4abb9d80bc feat(xo-web/patches): warn when pool master is not up to date (#10523) 2026-10-12 16:24:18 +02:00
Mathieu
87fe4e2b53 docs(rest-api): enhance REST API guideline (#10416) 2026-10-12 15:37:17 +02:00
ayoub-el-kajji-v
6058f07813 feat(api-rest): add sr probe endpoints (#10099) 2026-10-12 14:43:07 +02:00
Sebastien Rodot
d451d5c35b feat(xo-server-sdn-controller): add unit tests
- check that objects listeners are properly attached/detached on Xapi connect/disconnect
- check that xapi instance doesn't leak on reconnect
2026-10-12 14:34:55 +02:00
Sebastien Rodot
375b9fe91b fix(xo-server-sdn-controller): avoid leaking listeners on disconnect
Objects listeners are added to Xapi objects by the sdn-controller plugin, but 
they are only removed at plugin `unload()` time.

Prefer to remove them at Xapi disconnect time.
2026-10-12 14:34:55 +02:00
Sebastien Rodot
d53294f15c fix(xo-server-sdn-controller): re-watch XAPI events after reconnect
When a pool's XAPI connection drops and reconnects, xo-server destroys the
old Xapi instance and creates a new one (same pool UUID, same XAPI object
$refs). The plugin tracked managed pools by pool UUID, so _manageXapi()
short-circuited on reconnect and never attached its add/update/remove
listeners to the new Xapi instance.

From then on the plugin was blind to XAPI events: re-applying VIF traffic
rules on VM start, handling VIF/PIF plug and star-center election all
stopped silently until the plugin was reloaded.

Track the managed Xapi instances instead of pool UUIDs, and drop a dead
instance on disconnect so its replacement is managed again.
2026-10-12 14:34:55 +02:00
Sebastien Rodot
40e5ca9a73 fix(xo-server-sdn-controller): recreate stale OpenFlow controllers
When a XAPI connection drops and reconnects, xo-server creates a new
Xapi instance while the XAPI objects (and their $refs) are kept. The
ofChannels cache still held the controllers of the previous
connection, so traffic rule add/delete calls were silently skipped
without reaching the host.

Rework _getOrCreateOfChannel() to key the cache on the Xapi instance:
entries from a previous connection are dropped and the controller is
recreated, concurrent calls share the same instantiation, and a
failed instantiation clears the cache.

Also drop the cached controller when the host is disabled or its XAPI
is not connected, so it is recreated as soon as it comes back.
2026-10-12 14:34:55 +02:00
ayoub-el-kajji-v
cf760d26cd feat(backup): support ndb through http proxy (#10544) 2026-10-12 11:23:29 +02:00
Thierry Goettelmann
d4b85421fe fix(web-core): make query builder usable inside a form (#10475) 2026-10-12 09:59:46 +02:00
Thierry Goettelmann
61a06e817b feat(web-core): add queryFilter rule and backTo to multi-step form (#10477) 2026-10-12 09:31:32 +02:00
Thierry Goettelmann
1b1ae506b6 feat(web-core): add checkbox column to host, SR and VM column sets (#10485) 2026-10-12 09:28:58 +02:00
Thierry Goettelmann
641f988781 feat(web-core): add ignoreRoute option to table pagination and query builder filter (#10476) 2026-10-12 09:27:43 +02:00
Thierry Goettelmann
b6b94a80b1 refactor(xo6): extract VM, host and SR query builder schemas into composables (#10488) 2026-10-12 09:25:17 +02:00
Florent BEAUCHAMP
70b0824dc4 feat(backups): improve full mirror backup performances (#10537)
XO-3135 and XO-3134
2026-10-09 19:04:29 +02:00
Marie Garde
4a9d434559 feat(xo6): add emergency shutdown button host (#10412) 2026-10-09 08:42:23 +02:00
Alina D
341bd0301d feat(xo6/new-vm-form): allow resizing existing template disks when a … (#10359) 2026-10-08 20:13:29 +02:00
Pierre Donias
70be55528f fix(lite): do not reuse window.origin protocol and port when overriding master host (#9955)
When overriding the pool master with `?master=<host>`, XO Lite kept the page's
protocol and port and only swapped the hostname (e.g. `https://proxy:8443` +
`?master=192.168.1.10` → `https://192.168.1.10:8443`). Requests sent directly to
hosts also always used the page's protocol, regardless of the master's.

Everything now follows the XAPI connection (one commit each):

1. **Master override** (`xen-api.store.ts`): the value is parsed as a URL,
   `https://` by default, port and protocol allowed. An invalid value falls back
   to the default host instead of crashing XO Lite.
2. **RRD stats** (`xen-api.ts`, `getResource`): use the XAPI protocol.
3. **VM and host consoles**: `wss:` if XAPI is `https:`, `ws:` otherwise.
4. **Unreachable hosts check**: pings hosts with the XAPI protocol, so the
   certificates modal checks the right URLs.
2026-10-08 16:08:03 +02:00
Joris K
71ff8b0d24 feat(xo6/lite): add shortcut to focus the treeview search (#10492)
* feat(lite): add searchbar in the treeview

* add changelog and improvement

* feedback

* review feedback

* feat(xo6/lite): add shortcut to focus the treeview search

* add changelog

* added mac shortcut

* feedback
2026-10-08 16:03:37 +02:00
Florent BEAUCHAMP
33caeacb49 fix(nbd-client): better handling of reconnection (#10456)
* fix(nbd-client): better handling of reconnection

don't let a failure of an already remplaced trasnport impact the
current transport
2026-10-08 14:19:20 +02:00
Elise-F
854e5dbd89 fix(xo6): add missing condition to the detach job (#10543) 2026-10-08 13:48:27 +02:00
Pierre Donias
c069b7e9c7 fix(lite/vm-creation): keep UEFI firmware when setting boot order (#10521)
Introduced by #8317

`VM.set_HVM_boot_params` replaces the whole map, so setting the boot order for a
network install or a VM without disk dropped the firmware set just before, and
the VM booted in BIOS mode.
2026-10-08 11:18:30 +02:00
Thierry Goettelmann
8338851c4c feat(web-core,web,lite): add UiPanelCardTitle component (#10289)
* feat(web-core,web,lite): add UiPanelCardTitle component

Figma's "Panel title v3" describes three formats — plain text, linked
title, title with an id — which the existing VtsCardObjectTitle only
covered partially, and which several cards had reimplemented inline with
their own flex/gap rules.

Rather than three components, the parts are independent and optional: a
`to` or `href` makes the title a link, an icon or an id is shown when
passed, and `size` replaces Figma's `level`, which described the
appearance in terms of hierarchy.

Adopting it in the side panel cards drops their local title styles, and
brings them back to the 0.8rem title-to-content gap the design system
specifies.

* chore(lite): add the ui-panel-card-title story route

The generated route-map entry was omitted when the story was added.

* fix(web): address review feedback

Task infos and warnings are arrays, so their counters read `length`
directly instead of going through `Object.keys` and a non-null assertion
that only held thanks to the parent's `v-if`.

Add the changelog entries for the side panel card titles.

See XO-2885

* feat(web-core,web,lite): migrate side panel cards to UiPanelCardTitle

Replace the remaining `UiCardTitle` usages in side panel cards with
`UiPanelCardTitle`, so every panel card title shares the same layout.

To cover the cases the old titles handled, `UiPanelCardTitle` gains
`label`, `action` and `more-actions` slots, and tighter spacing between
the title and its id.

See XO-2885
2026-10-08 11:01:20 +02:00
Elise-F
7edeb7a253 feat(lite): add detach action on host (#10520)
* update changelog file

* feedback one

* feat(host): add action reboot on host

* feat(lite): add forget action on host

* feedback

* feat(lite): add detach action on host

* update changelog

* fix modal translations

* add 'host is running' condition to the job and fix translations

* feedback
2026-10-08 10:16:29 +02:00
Mathieu
1a012bdd94 fix(web-core/web): handle SSE for non XAPI objects (#10450) 2026-10-08 09:15:10 +02:00
Sandrine Barrucand
493aa7f15d feat(xo6/pool): add validation to pool connection form (#10484) 2026-10-07 16:40:31 +02:00
Alina D
cb1e45b6b0 feat(xo6): allow removing existing (template) disks in the New VM form (#10292) 2026-10-07 16:02:11 +02:00
Killian
0ff6196e72 feat(xo-server/backups,rest-api): stream changes of the VM backup archives over SSE (#10472)
The cache now announces what a listing finds and what a journal replay
changes, as `add`, `update` and `remove` events carrying the archive and its
previous value, on an emitter the app exposes as `app.vmBackupArchives`. What
was last announced for a repository outlives its entry, so a repository read
again from scratch only announces what really changed. A repository which is
removed or disabled goes through the new `forgetVmBackupRepository()`, which
announces all its archives as removed. A listener which throws is logged and no
longer fails the listing.

The REST API adds `backup-archive` to the SSE collections. It is bound to
`app.vmBackupArchives` the way `task` is, and filtered by `backup-archive:read`
through the same path as every other non-XAPI collection.

Nothing polls the repositories: changes are pushed whenever any client lists a
repository. The first listing after a restart sends every archive it already
holds as `add`.

XO-2992
XO-2993
2026-10-07 11:58:55 +02:00
Bastien Nollet
2ee51a3694 fix(load-balancer): process tags in each pool separately & other fixes 2026-10-07 11:25:38 +02:00
Bastien Nollet
87aca3ff5e feat(load-balancer): smarter migration restrictions 2026-10-07 11:25:38 +02:00
Bastien Nollet
305e66becc feat(load-balancer): vm-to-host affinity 2026-10-07 11:25:38 +02:00
b-Nollet
d3fea1296d feat(load-balancer): various minor improvements 2026-10-07 11:25:38 +02:00
Elise-F
8c4e4d110a feat(lite): add action restart toolstack on host (#10480)
* update changelog file

* feedback one

* feat(host): add action reboot on host

* feat(lite): add forget action on host

* feat(host): Add restart toolstack action for hosts with HA disabled

* feat(icon status): Add a power status icon to the treeview and to the host header

* update changelog files

* fix rebase

* feedback
2026-10-06 15:29:41 +02:00
Florent BEAUCHAMP
cc8f039341 fix(backups): allow a deadlocked merge to recover (#10424)
* fix(backups): properly throw an error if a disk is missing from a chain to merge
but accept it if the merge was interrupted at the cleanup phase
* fix(backups): a merge with 0 or 1 disk is a warning
* fix(backups): don't try to merge chain that are out of retention
this fix will allow broken to recover after a while ( depending on
retention and full backup interval )
* fix: defense in depth against deletion
 every path this class compares it against is normalized (disk paths, alias targets), so
 it must be too: a raw path would e.g. make #cleanOrphanDataFiles fail to exclude the VDI
 directory from the data directories it sweeps, and delete every unclaimed file in it
* fix(backups): better handling of missing disks during a merge
* ensure we never delete ancestries of a differential disk
2026-10-06 15:05:55 +02:00
ayoub-el-kajji-v
59c8935e37 fix(backups): delete first not working in replication (#10452)
* fix(backups): delete old entries before starting a new replication
2026-10-06 14:52:50 +02:00
Thomas Moraine
779e448f21 docs(users): recommend enabling OTP authentication (#10539)
Strongly recommend OTP (TOTP) authentication to mitigate brute force attacks on passwords.
2026-10-06 14:43:39 +02:00
Elise-F
3a64afb226 feat(lite): add enable and disable actions on host (#10337)
* update changelog file

* feedback one

* feat(host): add action reboot on host

* add header state

* feat(lite): add forget action on host

* feat(lite): add enable and disable actions on host

* update changelog file

* fix rebase

* fix rebase

* feedback one

* add redirect after forget action
2026-10-06 13:55:14 +02:00
Marie Garde
cfdf0de9ee feat(xo6): Add create form backup repository (#10271)
* feat(xo6): create and edit backup repository form

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix post rebase

* add tests

* feedback

* feedback

* rebase ok

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:39:18 +02:00
Pierre Donias
d6e6e963bf feat(xo-web/ip-pools): show IP format example (#10522) 2026-10-06 11:09:50 +02:00
Pierre Donias
29c7c0e6f1 fix(web-core/remote-resource): clear error after a successful fetch (#10516)
Introduced by #8855

lastError was never reset, so one failed poll kept hasError true and
dashboard cards in error state until reload.
2026-10-06 11:07:05 +02:00
Joris K
72a9959a3c fix(xo6): update modal events to handle confirm actions correctly (#10417) 2026-10-06 11:03:16 +02:00
Sylvère
e5d4c665a4 refactor(web/core): tasks-related refactor for ongoing My Tasks work (#10265) 2026-10-06 10:53:01 +02:00
MlssFrncJrg
0070c7f709 fix(xo-lite/XO6): exclude ISO SR guest tools from selector in VM creation (#10430) 2026-10-06 10:36:47 +02:00
Mathieu
a84584d24e feat(rest-api): POST acl-roles support list of privileges (#10470) 2026-10-06 10:21:35 +02:00
Joris K
786610cba3 feat(lite): add search bar in the treeview (#10414)
* feat(lite): add searchbar in the treeview

* improve open close treeview, and a pool dashboard that displays a scrollbar when the screen is minimized

* add changelog and improvement

* feedback

* feedback

* review feedback

* feedback add story
2026-10-06 10:09:35 +02:00
Mathieu
08e6e1bf49 ci(vectura): only run on the changes of the branch (#10528)
GitHub filters a push on the diff with the previous push of the branch: after
a rebase on `master`, or a merge of it, this diff contains the vectura changes
landed on `master` meanwhile, and the workflow rebuilds vectura on branches
which do not touch it.

A pull request is filtered on the diff with its merge base instead, i.e. the
changes of the branch only. Pushes still run the workflow on `master`.
2026-10-06 09:27:15 +02:00
Elise-F
5999d53e0b feat: release 6.9.1 (#10533) 2026-10-05 17:18:51 +02:00
Elise-F
decc09117d feat: patch technical release (#10529) 2026-10-05 15:47:30 +02:00
Sylvère
d11cf2d2fc fix(xo6): prevent unwanted inherited route params (#10511) 2026-10-05 14:35:02 +02:00
Mathieu
abdddcf105 fix(web/web-core): fix invalid VM-snapshot's VDIs (#10510)
introduced by 57414dafce
2026-10-05 14:01:13 +02:00
spacotte-vates
8c2f38c004 fix(backups): fix synchronized backup snapshop showing as vm in task log (#10518)
* fix(backups): fix synchronized backup snapshop showing as vm in task log
2026-10-05 11:32:42 +02:00
Pierre Donias
5de02b6c19 docs(PULL_REQUEST_TEMPLATE): point to internal review process instead of duplicating it (#10517)
The review process was documented both here and in the internal PR process
doc, and the two had drifted apart. Keep the internal doc as the single
source of truth and only give external contributors what they need.
2026-10-05 09:59:05 +02:00
Florent BEAUCHAMP
440624a09c Fix(backups): incremental distributed replication (#10519)
`AggregatedIncrementalXapiWriter.checkBaseVdis()` didn't forward
`contentKeys` to its sub-writers, so the second run of a multi-SR
replication failed with `Cannot read properties of undefined (reading 'get')`.

Also adds QA tests for distributed replication and copyRetention.

Introduced by 680b81088
2026-10-05 09:54:47 +02:00
ayoub-el-kajji-v
718b348e85 fix(backups): use schedule.id instead of scheduleId in replica retention (#10491)
fix(backups): use schedule from props for replica retention instead of scheduleId
2026-10-02 17:07:55 +02:00
Mathieu
278f7d1706 fix(vectura): build against glibc 2.31 so V2V runs on Debian 11 XOAs (#10513)
XOA appliances still run Debian 11, whose glibc is 2.31. The vectura binary
bundled with @xen-orchestra/vmware-explorer was linked against glibc 2.34,
so it did not start there (`GLIBC_2.32 not found`): the prerequisite check
of the VMware import page failed and V2V imports could not run.

zig now links it against glibc 2.31. Below 2.34, libpthread and libdl are
libraries of their own, so check-binary.sh allows them. The bundled binary
is rebuilt with the same version, and the docs say Debian 11 and Ubuntu
20.04 instead of Debian 12 and Ubuntu 22.04.

Introduced by dc57333f5
2026-10-02 16:34:10 +02:00
ayoub-el-kajji-v
b59c8f3d20 fix(xo-web): disable restat-toolstack for HA_enabled pool hosts in home (#10497) 2026-10-01 17:33:54 +02:00
Joris K
7711b6d6f5 fix(xo6/lite): fix video RAM displayed in bytes instead of MiB (#10486) 2026-10-01 16:40:18 +02:00
Mathieu
b0c0d9361a fix(xen-api): fix HTTP/2 for server behind HTTP proxy (#10507)
Introduced by 11eed70
fix Forum#12502
2026-10-01 14:51:46 +02:00
Mathieu
83fdb2ed1f docs(changelog): vCenter is still supported by V2V (#10502)
The 6.9.0 entry for the VDDK removal said a vCenter address is no longer
supported. vectura reads the disks through a vCenter as well as directly
from an ESXi host; only ESXi 6.x is refused, because its data port does not
announce "SHA256 supported".
2026-10-01 09:47:14 +02:00
Elise-F
c9eaa7a617 feat(lite): 0.26.0 (#10501) 2026-10-01 08:54:52 +02:00
Elise-F
a2bd181d35 feat: release 6.9.0 (#10499) 2026-09-30 18:12:53 +02:00
Elise-F
989cd48559 feat: technical release (#10496) 2026-09-30 15:53:45 +02:00
ayoub-el-kajji-v
b2f0bfc907 fix(xo-web): disable restart toolstack button when HA enabled on pool (#10340) 2026-09-30 14:57:50 +02:00
spacotte-vates
ce26dc2231 fix(backups): prevent failed CR to accumulate replicas (#10404)
* fix(backups): prevent failed CR to accumulate replicas
2026-09-30 14:44:42 +02:00
ayoub-el-kajji-v
2bd4e2a747 fix(xo-server): disable restart toolstack on HA pools (#10344) 2026-09-30 11:31:37 +02:00
Pierre Donias
75098c3cda feat(xo-web/xo-cloud-config): require acknowledgment to enable backup to Vates servers
XO-3030
2026-09-30 11:25:49 +02:00
Pierre Donias
6f091b9380 feat(xo-web/xo-cloud-config): confirm before disabling backup to Vates servers
XO-3031
2026-09-30 11:25:49 +02:00
Elise-F
4b578d439f fix(xo-server): add reactivity on pif metrics (#10438) 2026-09-30 11:13:00 +02:00
amouillard-vates
93e9069614 feat(xo-lite): add network delete action for host internal networks (#10332) 2026-09-30 11:03:53 +02:00
Joris K
c17bcead07 fix(xo6): update transfer task size calculation (#10448) 2026-09-30 10:34:34 +02:00
Pierre Brunet
3dbf8738f1 fix(security): Add safeguard to v2v (#10489)
https://github.com/vatesfr/xen-orchestra/security/advisories/GHSA-mc4m-hc98-rhmv
2026-09-30 10:21:51 +02:00
ludalm13
f187b79319 fix(xo-server): trust forwarded headers from loopback so /v5 proxies … (#10461) 2026-09-30 10:03:34 +02:00
Bastien Nollet
0ae1cf917c fix(xo-web): fix log modal not closing (#10487) 2026-09-30 10:00:22 +02:00
Marie Garde
e287eaeb55 feat(xo6): create page backup repository list (#10247)
* feat(xo6): merge base master

* returns from PR

* fix build

* fix build

* add custom cards side panel

* return form url util

* fix build

* return form PR

* fix rebase

* Remove packages format and parse url

* fix from IA review

* wip rebase

* wip

* opencode review

* add tests utils

* Add changelog

* feedback

* feedback

* feedback

* add tests

* feedback

* feedback

* feedback

* feedback

* fix after rebase
2026-09-29 17:22:24 +02:00
Florent BEAUCHAMP
5a8393aa17 feat(xoa): auto open ufw port for livemount (#10468)
only open the specific route from source to xo / xo-proxy

port openned only live in memory : restarting ufw will drop them, thus making all the live-mount disk unavailable

doc updated
2026-09-29 17:08:38 +02:00
Florent BEAUCHAMP
323f69d773 feat(live-mount): detect disk removing and cleanup sr (#10432)
the live mount feature create a lot of SR , each with 1 disk
this PR introduce an automatic cleanup that will forget a SR as soon as the disk is removed, thus removing any user action needed to cleanup thins in the nominal case

crashing XO can leave SR pluggued
2026-09-29 17:03:57 +02:00
Alina D
a883cef0d6 feat(xo6): replace images with new SVG design (#10380) 2026-09-29 16:26:30 +02:00
Bastien Nollet
d3b3248937 docs: remove "XOA unreachable" troubleshooting section (#10469)
See #9526
2026-09-29 10:30:22 +02:00
Killian
45ca13ba5d feat(xo-server/backups): replay the journal of the repositories on a proxy (#10437)
Phase 4 of the journal-replayed VM backups cache: brings proxy-attached repositories onto the same fast/fresh listing path as directly-attached ones, so a proxy repository is also listed once and then kept up to date by replaying its journal, instead of being re-listed in full on every refresh window.

What changes

RemoteAdapter#readBackupJournalEvents() reduces a journal read to the last event of each backup and resolves the added/changed ones back to their current metadata, so a listing can be brought up to date from a single call. A backup rewritten several times costs one metadata read; one written then deleted costs none.
VmBackupsCache no longer knows how a repository is read: that now lives entirely behind a new VmBackupsSource, which reads a directly-attached repository through the local adapter and a proxy-attached one through backup.listVmBackups (cold build, reused as-is) and the new backup.listVmBackupsJournal RPC (journal replay).
The proxy exposes backup.listVmBackupsJournal, returning its journal events with the backups already formatted, so XO gets a listing update in one round-trip instead of one metadata read per file. A proxy too old to know the method is warned about once and falls back to a full listing, same as before this change; it starts being replayed as soon as it's upgraded, with no restart needed on the XO side.
The cache's staleness check now compares its own clock only against when this process last refreshed an entry (refreshedAt), never against the watermark returned by a journal read (cursor), which is stamped by whichever process wrote it — proxy or not. Comparing the two directly would silently over- or under-refresh a repository depending on clock skew.
An entry also rebuilds from scratch if its repository moves to or from a proxy, or if the source reports it can no longer be replayed (e.g. an old proxy), rather than being purged outright.
The old proxy-only listing path in BackupNg (_listVmBackupsOnProxy, bypassing the cache) is removed: proxy and local repositories now go through the exact same cache.
A single corrupt/unreadable metadata no longer fails a whole journal read; it's kept behind the watermark so only that entry is retried on the next call instead of the caller falling back to listing the whole repository until the file is fixed.
The backup repository cache is now also invalidated on remote update/delete (previously only handled for backup repositories proper).
No behavior change for directly-attached repositories — this phase only extends the existing cache to proxy-attached ones.

XO-2852
2026-09-29 08:58:43 +02:00
MathieuRA
2f846bf07e fix(xo-server): interruptOnDisconnect does't update server error anymore 2026-09-28 19:53:59 +02:00
MathieuRA
ae3e04fb3d fix(xo-server): server status connected only if objects are fetched 2026-09-28 19:53:59 +02:00
MathieuRA
986c6b8687 fix(rest-api): fix SSE event order 2026-09-28 19:53:59 +02:00
Thomas Moraine
386e2afe12 docs(advanced.md): remove outdated 'coming soon' mention (#10378) 2026-09-28 18:30:41 +02:00
Elise-F
d7a75b943b feat: technical release (#10467) 2026-09-28 10:43:56 +02:00
Mathieu
aea3717f81 feat(xo-server,rest-api,xo-web,types/rpu): keep RPU recovery to XCP-ng and XenServer 8.4+ (#10447) 2026-09-28 09:59:27 +02:00
Pierre Brunet
5972cf5c59 fix(vhd-lib): update changelog (#10462) 2026-09-28 09:47:03 +02:00
MathieuRA
2b466d6ac8 fix(perf-alert): fix SR dead link
introduced by 779876ede9
2026-09-28 09:42:52 +02:00
MathieuRA
8ac91f9d7d fix(perf-alert): fix test plugin
introduced by 779876ede9
2026-09-28 09:42:52 +02:00
Killian
f9cafe6a0f fix(backups): actually delete interrupted replication transfers (#10436)
* fix(backups): actually delete interrupted replication transfers on next run

IncrementalXapiWriter#_prepare's "delete previous interrupted copies" cleanup
was dead code: `vm => vm.$destroy` never called the method, and it filtered
with `srUuid: undefined`, which can never match since every replica has had
REPLICATED_TO_SR_UUID set since c610951450. So a transfer interrupted mid-way
(process crash, killed job, ...) left its VM/VDI on the target SR forever,
still carrying decorateVmMetadata's DATETIME epoch sentinel.

Fix the filter to use the real srUuid and match only entries still carrying
that sentinel, then actually call $destroy(). Exclude this._targetVmRef: when
checkBaseVdis (which runs earlier) already validated the same VM as reusable
for this run, it must survive to be chained onto, not be destroyed under it.
2026-09-28 09:33:46 +02:00
Florent BEAUCHAMP
a9caea651c fix(xo-server): ignore tests on startup (#10463)
detected on previous technical release
2026-09-28 09:04:15 +02:00
vates-bot
27882fb1dc feat(web-stack/i18n): update Xen Orchestra 6 (#10396) 2026-09-25 17:03:34 +02:00
Pierre Brunet
99af2031c4 feat(backups): improve doc (#10390)
improve backup documentation for developers
2026-09-25 16:28:16 +02:00
Elise-F
22706164d6 feat: technical release (#10458) 2026-09-25 15:03:27 +02:00
Florent BEAUCHAMP
dc57333f58 feat(v2v): remove vddk dependency (#10421)
Co-authored-by: Mathieu <mathieu.piton@vates.tech>
2026-09-25 10:38:26 +02:00
spacotte-vates
3fea79d899 feat(xo-web): add support for IS_XOA_BUILD var (#10429) 2026-09-25 10:34:55 +02:00
Florent BEAUCHAMP
f4fa020d2e feat(backups): make live mount restore works with xo-proxy (#10431) 2026-09-25 10:15:17 +02:00
Florent BEAUCHAMP
b5f119f1d4 feat(nbd-client): implement a nbd-client for stdin/stdout instead of a tcp connection (#10381) 2026-09-25 09:57:53 +02:00
Florent BEAUCHAMP
d1029ffb24 docs(live-mount): add documentation of live mount (#10445)
* docs(live-mount): add documentation of live mount

Co-authored-by: Thomas Moraine <thomas.moraine@vates.tech>
2026-09-25 09:48:11 +02:00
Mathieu
a4b17d5abb fix(rest-api): triggers updates for objects that are unmodified but computed (#10441) 2026-09-24 15:43:41 +02:00
Elise-F
b1b5097b6b feat(lite) : add action forget on host (#10315)
* update changelog file

* feedback one

* feat(lite): add forget action on host

* udpate changelog file

* fix rebase

* feedback

* rebase
2026-09-24 15:15:09 +02:00
Marie Garde
5ba393ca2b feat(xo6): fix imports vif form (#10451) 2026-09-24 14:59:32 +02:00
Marie Garde
b3b02b1fef feat(lite): Create Vif from VM Network (#10216) 2026-09-24 14:23:18 +02:00
Killian
6b4a7e5ebd fix(jobs): updates a sequence's runner user on save (#10423)
* fix(jobs): updates a sequence's runner user on save
2026-09-24 13:14:00 +02:00
MlssFrncJrg
9e38c0bac2 fix(xo-web): VDI migration now shows all pool SRs for admins, even on VMs bound to a Resource Set (#10422) 2026-09-24 12:14:16 +02:00
amouillard-vates
4d6a5ffa60 feat(web-core): add accent prop to MenuItem (#10348) 2026-09-24 09:01:20 +02:00
Mathieu
fc571c00d4 feat(xo-server,rest-api,xo-web/rpu): force-close an incomplete rolling pool update record (#10418)
* feat(xo-server,rest-api,xo-web/rpu): force-close an incomplete rolling pool update record

A pool whose previous rolling pool update left a recovery record refuses a
new run until that record is dealt with. Once the operator has reviewed the
pool, `pool.finalizeRollingUpdate` and
`POST /rest/v0/pools/{id}/actions/finalize_rolling_update` close it.

Without `force`, the finalization is refused (`incorrectState`, property
`unrestoredItems`) while the record says the run changed something the pool
still shows changed: HA, auto power-on, WLB, load balancer, backup
schedules, hosts left disabled, running VMs away from their home host,
pinned VMs shut down and not started again. A record that cannot be read
is refused too, with an unknown list.

With `force`, the record is deleted, the abandoned items are written to the
task and the trace, nothing is restored nor changed in the pool.

To tell what the run changed from what the operator did, the run now
persists, before each change, the settings it touches (strict write) and
whether each host was enabled before the run.

XO 5 shows a "Finalize" action on the incomplete update banner, which is
now also shown for a run that succeeded but whose record could not be
removed. Forcing asks for a second confirmation listing the items.
2026-09-23 16:53:43 +02:00
amouillard-vates
974c2cfee9 feat(xo6): sort networks by name on hosts > networks table (#10367) 2026-09-23 15:51:42 +02:00
Thierry Goettelmann
fc6124b1b8 feat(web-core/table): add row selection, checkbox column and controls bar (#10435) 2026-09-23 15:30:29 +02:00
Thomas Moraine
97d338710d doc(xo): remove SECURITY.md to apply global Vates security policy (#10405)
This commit removes the local `SECURITY.md` file from the Xen Orchestra repository.

The new global Vates security policy is now in place, so the local file
is no longer needed and should be replaced by the central policy:
https://github.com/vatesfr/.github/blob/main/SECURITY.md

This ensures consistent security reporting guidelines across Vates projects.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2026-09-23 13:27:17 +02:00
Grandalf
e957c2d821 test(backups): cover VM backups cache invalidation and local mutations (#10261)
test(xo-server/backups): cover VM backups cache invalidation and local mutations
2026-09-23 11:22:36 +02:00
Elise-F
a1dba1ed4e feat(lite): add start shutdown action host (#10314)
* update changelog file

* feedback one

* update changelog file

* feat(lite): add start shutdown action host

* update changelog file

* feeback 1

* feedback 2

* feedback 3
2026-09-22 15:32:35 +02:00
Marie Garde
26a71c4d4b feat(xo-remote-parser): migration JS to TS and update tests (#10399) 2026-09-22 14:50:44 +02:00
ayoub-el-kajji-v
c40741ccf4 feat(rest-api): add reclaim space method per vm or backup repository (#10262) 2026-09-21 19:02:09 +02:00
ayoub-el-kajji-v
c95dec183e Fix(backup): immediate delta vm backup deletion (#10273) 2026-09-21 16:07:58 +02:00
Thierry Goettelmann
ee0b8f0468 feat(web-core): enhance collection flags configuration and add clearFlag (#10428) 2026-09-21 14:05:57 +02:00
Florent BEAUCHAMP
75d9e4afda feat(xo-web): implement read only live mount as restore target (#10345)
following the backend PR this PR implement the xo5 ui to use live mount for restoring a VM .

    the popup is enlarged to ensure there is enough space
    it fixes a ui feature where removing a VDI make it unrestorable without closing and reopening 
    A disk row is complete when its action has the destination it needs: Live mount → a host, Restore → its own SR or the main SR, Do not restore → nothing.
    An incomplete row shows a red message under its selector, and since the rows sit in a collapsed panel, a red "Some disks have no destination yet" line is also shown outside it.
    The restore SR selector's placeholder now reads "Use main SR" once a main SR is set, so an empty field reads as a deliberate fallback rather than an oversight.
    For live moiunt the picker is prefilled with the host that can use the SR
2026-09-21 11:07:56 +02:00
Sylvère
eb7471a248 chore(web-core,web,lite): update FontAwesome, clean unused icon packages imports (#10365) 2026-09-21 09:52:34 +02:00
Grandalf
cae1b72571 feat(xo-server/backups): list the backups from a journal-replayed cache (#10257)
[XO-2850]
2026-09-21 09:36:25 +02:00
Olivier Lambert
f56150c25b Revert "feat(browser-media): prototype local ISO streaming in XO 5 and XO 6"
This reverts commit 5d1d0eba2f.
2026-09-18 18:40:28 +02:00
Olivier Lambert
5d1d0eba2f feat(browser-media): prototype local ISO streaming in XO 5 and XO 6 2026-09-18 18:37:16 +02:00
Mathieu
fe69a029e1 fix(openmetrics): don't block the scrape on stale collector caches (#10409)
Introduced by 2b7b6ba
See XO-2984
2026-09-18 17:29:46 +02:00
Patrice Ferlet
12a32dce22 fix(@xen-orchestra/mcp): use undici fetch to fix Node 26 dispatcher crash (#10413)
* fix(@xen-orchestra/mcp): use undici fetch to fix Node 26 dispatcher crash

Node's global fetch is backed by Node's bundled undici and rejects a
dispatcher from the npm undici dependency with UND_ERR_INVALID_ARG on
some Node versions (e.g. Node 26), surfacing as "fetch failed". The MCP
passed an EnvHttpProxyAgent dispatcher to the global fetch, so it crashed
at startup.

Route every request through undici's own fetch (proxyFetch) so the
dispatcher and the fetch come from the same undici copy. Add a FetchFn
injection point so unit tests can mock the fetch instead of globalThis.fetch.

Fixes #10411
2026-09-18 17:14:39 +02:00
Elise-F
cafea84fee feat(lite) : add action force reboot on host (#10311)
* update changelog file

* feedback one

* update changelog file

* feat(lite): add force reboot action on host

* update changelog file

* feedback 1

* feedback loader
2026-09-18 17:02:30 +02:00
Bastien Nollet
13480c3457 fix(xo-server): prevent incidental static_memory_min edit (#10285)
XO-3040
2026-09-18 11:26:32 +02:00
Bastien Nollet
4fc992da52 feat(users): add firstname, lastname and username properties (#10163) 2026-09-17 16:53:07 +02:00
Florent BEAUCHAMP
6f73bde276 feat(backups): backend of using live disk mount for restore (#10343)
* feat(backups): implement backend for live mounting disk

when restoring a VM, the vdi to SR map is extended to support a
'live-mount' mode , additionaly to the now explicit 'restore'
and 'ignore' mode

live-mount will need the host. no check is done on backend to
ensure if the VM is bootable
 
add e2e test
2026-09-17 16:30:27 +02:00
Florent BEAUCHAMP
70cbf5cda3 feat(xo-server): implement live mounting a backup archive disk (#10302) 2026-09-17 16:10:06 +02:00
Florent BEAUCHAMP
44b7db2fd9 feat(@vates/iscsi): extract iscsi package code (#10242)
implementation of an iscsi target and initiator in typescript

this will serve as a basis of 2 projects:

    connecting directly to 3rd party san
    exposing a disk from a backup archive to the host, effectively enabling direct mount to a running VM. Final target is to be able to boot completely the VM

@vates/iscsi/VALIDATION.md file is a journal of the additional test I run on my lab
@vates/iscsi/PROTOCOL.md describe the subset of the protocol implemented . For example we only support 1 LUN per target, not multiple connection , only one type of auth and keep alive
2026-09-17 15:07:15 +02:00
Mathieu
f56d241c18 feat(xo-server,rest-api,xo-web/rpu): safe start on a partially updated pool (#10394)
* feat(xo-server,rest-api,xo-web/rpu): safe start on a partially updated pool

A rolling pool update used to overwrite the recovery record left by the
previous run and to look only at the master to decide whether there was
anything to update. Both hid a pool left half updated: an interrupted run,
or updates applied by hand, could be silently completed or forgotten.

- `startRpuRecoveryRun` now refuses a new run while the pool still has a
  record, whatever its status, with an `incorrectState` error (property
  `rollingUpdateRecovery`). The RPU guard held by the caller makes the
  check-then-write safe. A run which fails before any host was handled
  drops its record instead of marking it failed: the orchestrator restores
  what it changed on its way out, and a refused precondition (a guidance to
  accept, a pinned VM to shut down) must not block the retry the operator
  just consented to.
- The update refuses a pool whose master is already up to date while
  another host is not (`incorrectState`, property `partiallyUpdatedPool`,
  `actual` listing the outdated hosts) unless the new
  `acceptCurrentStateAsBaseline` option is set. The option is exposed on
  `pool.rollingUpdate` and on `POST /rest/v0/pools/{id}/actions/rolling_update`.
- XO5 enables the rolling pool update button as soon as any host of the
  pool has missing patches, not only the master, disables it while a
  recovery record exists, and asks for confirmation before accepting the
  current state as the baseline.

Until the force-close operation ships, a leftover record can only be
removed by hand: it must land in the same release.

* feat(xo-server,rest-api,xo-web/rpu): changelog entry for #10394

* fix(xo-server/rpu): getRollingUpdateRecovery returns null on no record

* fix(xo-server/rpu): persist acceptCurrentStateAsBaseline, mark record succeeded on delete failure

* fix(xo-server/rpu): drop an empty record after the pool is restored, update LINSTOR only past the guards
2026-09-17 14:17:06 +02:00
MlssFrncJrg
ce7d94ec77 fix(xo-server): enhance CSP handling for Netdata (#10275)
Introduced by #10101
2026-09-16 17:01:09 +02:00
Mathieu
f91d64235a fix(xo-server): fix vm topology when editing incompatible cpu max number (#10398)
* fix(xo-server): fix vm topology when editing incompatible cpu number
2026-09-16 15:51:31 +02:00
ayoub-el-kajji-v
116f50821e fix(backups): change prefix name for health checks (#10361)
[XO-2797] This PR is for changing the displayed name during backups health check from [Importing...] to [Health Check...]
Introduced by 03865bc
2026-09-16 15:28:24 +02:00
Enée DI IORIO
43f63ee784 feat(web-core): update UiLegend component to v6 (#9509) 2026-09-16 14:58:12 +02:00
Grandalf
11ec9fd5da fix(backups): don't destroy the job's own rolling snapshots (#10395)
listReplicatedVms() stopped excluding snapshots in c1e9b83 (#9524) so that new-style replication, where each transfer is a snapshot of the target VM, would be covered. But a backup snapshot of the source VM matches the very same filter: it carries xo:backup:schedule and xo:backup:vm, and setVmOtherConfig passes srUuid = null, which removes xo:backup:sr and therefore matches the srUuid = undefined argument of the "delete previous interrupted copies" sweep in FullXapiWriter._run.

A full backup job replicating to an SR of the source pool thus destroyed both the rolling snapshot retained from the previous run and the one it had just taken, silently: the sweep is fire-and-forget under ignoreErrors, and XAPI holds the destroy of the snapshot being exported until the transfer ends instead of refusing it.

A replication snapshot is a snapshot of a replica, so a snapshot of the VM designated by xo:backup:vm can never be one.
2026-09-16 14:16:26 +02:00
spacotte-vates
ea2acac77b fix(backups): don't destroy the delta base after failed transfer (#10369)
XO-2994

Customer reported that after a failed replication transfer, the next run was a full and a VM was left orphaned on the target.

This is due to _removeUnusedSnapshots() protecting the last snapshot irrelevant of if it was successfully transferred and thus, in the case of a failed transfer, breaking the delta chain
2026-09-16 10:53:02 +02:00
Pierre Brunet
14b7115cf1 fix(openmetrics): share metrics collection on slow pools (#10401) 2026-09-16 10:19:42 +02:00
MlssFrncJrg
d0645598b9 feat(xo-server/proxy): check proxy licenses from XOA instead of blocking backups on it (#10280)
XO-2768
2026-09-15 20:42:18 +02:00
Thomas Moraine
83b2fd9056 docs(releases.md): document security support (#10379)
Document which Xen Orchestra versions receive security fixes
in the actual XO doc, not just in SECURITY.md.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2026-09-15 17:13:13 +02:00
Florent BEAUCHAMP
0c04076324 feat(v2v): use CBT to discover allocated blocks instead of vddk (#10384)
Faster (25 GiB test VM: the map goes from 2.5s to 50ms) and fewer moving
parts: the vddk map needs nbdkit, the vddk library and nbdinfo, all
version-coupled, where CBT is a handful of documented calls every backup
product has relied on for years.

Checked on ESXi 8.0.1 that enabling CBT on a disk which already holds data
backfills the tracking file, so the first query reports what was already
written and not only what is written afterwards. Against `nbdinfo --map` the
two maps differ by 960 KiB which reads back as zeros: CBT reports the blocks
that were written where the vddk reports the ones VMFS allocated.

CBT must be enabled before the migration, it cannot usefully be turned on
during the run:
- powered off VM: nothing else to do, the reconfigure writes the tracking
  file and the query answers straight away
- running VM: the query needs a snapshot, and tracking only starts at the
  next stun (snapshot create/delete, power cycle, vMotion)
2026-09-15 16:47:03 +02:00
spacotte-vates
c37c4a286d fix(xo-server): fix cache issue on vmBackupOnRemote (#10277) 2026-09-15 15:56:09 +02:00
Mathieu Lantin
21d99ee856 fix(xo-server-netbox): use numeric comparison to find the most specific prefix (#10297)
Fixes #10240
2026-09-15 14:59:32 +02:00
Bastien Nollet
fec5508ce8 chore(xo-server): remove unused files (#10397) 2026-09-15 14:46:00 +02:00
Florent BEAUCHAMP
f55c77a11a Feat: check changed packages list in CI (#10392) 2026-09-15 11:17:58 +02:00
Pierre Brunet
76225348fa fix: add error when encryption key removed from url (#10339) 2026-09-15 11:10:43 +02:00
Marie Garde
67327eb791 feat(xo6): add disabled icon host (#10188) 2026-09-15 11:09:03 +02:00
Pierre Brunet
e87f4555dd fix(backups): validate data on alias to vhd, not directly on alias (#10393) 2026-09-15 09:53:41 +02:00
vates-bot
3d0ba7ca34 feat(web-stack/i18n): update Xen Orchestra 6 (#10316) 2026-09-14 13:32:06 +02:00
Mathieu
9f337ace71 feat(xo-server,rest-api,xo-web/rpu): persist a recovery record across restarts (#10331)
* feat(xo-server,rest-api,xo-web/rpu): persist a recovery record across restarts

A rolling pool update now writes a `rpuRecovery` record (LevelDB, one per
pool) before its first side effect and keeps it updated during the run:
operator options, update variant, host order, initial VM placement, per-host
step statuses, last filtered error, and the pinned VMs shut down for their
host's reboot (previously only tracked in memory, so an xo-server crash left
them halted with nothing to restart them). A record left in a live status at
xo-server boot is flipped to `interrupted`; a successful run deletes its
record; an unreadable or unknown-version record is reported as `blocked`
without touching the stored value.

The status is exposed through `pool.getRollingUpdateRecovery` and
`GET /rest/v0/pools/{id}/rolling_update_recovery` (filtered view: never the
raw intent, reusing the existing pool permissions), and the pool Patches tab
in XO5 shows a warning with per-host progress, the last error and the pinned
VMs still halted.

First card of the RPU resume-after-failure spike: the record only reports
for now, resuming an interrupted run comes separately.

* docs(changelog): entry for the RPU recovery record (#10331)

* fix(xo-server,rest-api,xo-web/rpu): updates after review

* test(xo-server/rpu): cover the recovery record during the migrate back phase

The migrate back phase now retries the rejected migrations before giving
up, so a host is only recorded as failed once the retry passes are over,
and a pool which opted out of the phase must still close its `restoreVms`
steps, otherwise a finished run reads as still running in the record.

Both are asserted through a recorder spying on the step transitions.

* fix(xo-server/rpu): keep per-key reads in the recovery reconciliation

* test(xo-server/rpu): stub the recovery recorder in the rolling pool update tests

Since #10313, XenServers.rollingPoolUpdate runs the backup guard itself and
its tests drive it with a fake app. With the recovery record, the run also
calls app.startRpuRecoveryRun, which the fake app did not have.

The stub records its call, so the tests also check that a refusal from the
backup guard leaves no record behind.

* fix(xo-server/rpu): do not fail a successful run when its record cannot be deleted
2026-09-14 12:09:15 +02:00
Thomas Moraine
3b08f236a4 docs(xo): fix redirections (#10387)
* docs(docusaurus.config.ts): fix redirection inconsistencies

A few page redirections were missing, some were duplicates, and some pointed to the wrong URLs.

This commit cleans up redirections, updates the target URLs and adds the missing redirections.
2026-09-14 09:42:26 +02:00
Florent BEAUCHAMP
99312eca2b docs(backups): document object lock (#10373)
* docs(backups): document object lock
2026-09-11 17:47:47 +02:00
Florent BEAUCHAMP
3bf50c38ee fix(vmware-explorer): fixes omnibus (#10363)
This branch is a reliability overhaul of the ESXi/vSphere source side of V2V, plus a scope reduction of the package. The SOAP plumbing is rewritten:

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

The JSON-RPC methods pool.rollingUpdate and pool.rollingReboot refused to run
while a backup job targets the pool, but the REST actions
POST /pools/:id/actions/rolling_update and rolling_reboot called the
orchestrators directly and skipped that check.

The guard now lives in XenServers.rollingPoolUpdate and Pools.rollingPoolReboot,
so every entry point (JSON-RPC, REST, scheduled jobs) applies the same rule.
The bypass moves into backupGuard(poolId, { bypassBackupCheck, operation }),
which logs a single warning with the operation and the user, and the other
callers (host.restart, host.restartAgent, host.stop, sr.reclaimSpace and their
REST counterparts) use it instead of their own if/else and log.

The REST rolling actions accept bypassBackupCheck in their body and record the
body in the task params, like the host actions do.

* fix(xo-server,rest-api): pass the targeted object ID to the backup guard
2026-09-11 16:49:14 +02:00
Pierre Brunet
f9049754b4 fix(plugin/ldap): fix changelog (#10383) 2026-09-11 15:37:08 +02:00
Mathieu
b61e0dacba fix(xo-server): destroy proxied request when the client is gone (#10388)
Introduced by 9470349
2026-09-11 15:13:46 +02:00
Grandalf
df7b62654c feat(rest-api): migrate functions to registerRestRoutes (#9895) 2026-09-11 15:06:16 +02:00
Pierre Brunet
adecd7306e feat(backups): replace vhd-lib calls with abstract methods (#10124)
* feat: add setChainToParent in order to point header/footer to a new parent

* feat: add getCompressionType and use it in isMergeableParent in RemoteAdapter

* feat: use openDisposableDisk instead of openVhd

* fixup! feat: add setChainToParent in order to point header/footer to a new parent

* feat: add tests for setChainToParent

* fix: from review

* feat: make isMergeableParent contained in remoteDisk

* fix: remove chainToParent now useless

* feat: remove compressionType and isMergeableParent where unneeded

* feat: update test

* feat: handle compressionType in remoteAdapter and fs

* fix: use remoteHandler as config getter

* feat: remove useless isDirectory in remoteDisk

* fix: small change on mode option
2026-09-11 11:42:51 +02:00
Florent BEAUCHAMP
595687dabe docs(backups): precise the prerequisite for NBD (#10377)
* docs(backups): precise the prerequisite for NBD
Co-authored-by: Thomas Moraine <thomas.moraine@vates.tech>
Co-authored-by: Florent BEAUCHAMP <florent.beauchamp@gmail.com>

---------

Co-authored-by: Thomas Moraine <thomas.moraine@vates.tech>
2026-09-11 10:02:11 +02:00
Thomas Moraine
79118de1ad docs(xo): add redirections following doc url changes (#10385)
* docs(xo): add page redirections

Since we changed how the sidebar was generated,
many page URLs changed in the process,
but redirections weren't implemented yet.
This broke several links to the documentation.
This commit adds redirections so the links don't result in 404 errors anymore.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>

* docs(xo): update outdated URLs in the XO app

Following the recent overhaul of the documentation URLs, some links in the XO app were still pointing to obsolete pages.

This commit updates them to use the current URLs and prevents 404 errors.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>

* docs(intro.md): update link to V2V migration guide

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>

---------

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2026-09-11 09:57:39 +02:00
Thomas Moraine
12d77efe17 docs(xo): fix broken links in the xo app (#10382)
Since the XO documentation structure was changed recently,
so did the URLs to each documentation pages.
This broke multiple links in the XO app,
which pointed to the XO doc.

This commit updates the links to the current URLs,
so that the link work as intended.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2026-09-11 07:46:42 +02:00
MathieuRA
280c05ac48 feat(rest-api/SSE): SSE support now XO objects changes 2026-09-10 16:41:55 +02:00
MathieuRA
d13fad62bd fix(rest-api): use previousObj instead of object for message SSE events 2026-09-10 16:41:55 +02:00
MathieuRA
e0fb16a421 feat(xo-server): server status become a persistant property 2026-09-10 16:41:55 +02:00
Florent BEAUCHAMP
db8fe8d472 feat(rest-api): export vdi and vdi snapshot content as qcow2 + vdi export fixes (#10350) 2026-09-10 14:38:47 +02:00
Grandalf
2aa3bd602e feat/backups/journal log (#10256)
* fix fbeauchamp comments

* feat(backups): add a journal

* fix cache writing on immutable remotes

* add typing

* feat(backups): shard the journal per UTC day, one entry per time

`readBackupJournal()` listed the whole `xo-backup-log/` and filtered the names
client-side, so the cost of a read grew with the whole history of the repository.

Store the entries as `/xo-backup-log/<YYYYMMDD>/<HHMMSS.mmmZ>-<rand>-<event>-<vmUuid>-<file>`
and list only the days which can hold entries newer than `since`: a read now costs one
listing of the journal root plus one listing per day it is missing — normally a single
day, at most two, since a reader rebuilds from scratch when its watermark crosses a UTC
day. Purging old entries also becomes a directory removal instead of a per-object sweep.

The day is not repeated in the entry name, so `since` bounds the time of its own day
only; every later day is taken whole.

* fix pipeline

---------

Co-authored-by: All-Ki <killian.allegrain@vates.tech>
2026-09-10 14:26:41 +02:00
Florent BEAUCHAMP
b32fd39c19 fix(qcow2): improve qcow2 stream index computation (used for backup/replication and export) (#10333)
* feat(qcow2): improve generation speed

the idea is to build a bit index of the clusters to store them efficiently instead
of going through all the possible blocks 3 times at different phases of the generation.
The memory consumption is 2MB/TB of disk

this index generation can still be quite long , so we use setImmediate from
time to time to le the rest of the nodejs event loop run

also make this index phase abortable
2026-09-10 13:49:32 +02:00
Thomas Moraine
0115680129 doc(restapi.md): document maximum token validity (#10347) 2026-09-09 15:22:57 +02:00
Dan Pollak
e50f033361 fix(xo-server): correct spelling of "across" in migration logic and messages (#10372) 2026-09-09 14:21:10 +02:00
OlivierFL
5cf93b3c3c feat(web-core/tab-item): update UiTabItem component to v7 (#10338) 2026-09-09 12:39:43 +02:00
Mathieu
505ac6caf5 fix(xo-server): keep localhost proxy targets on wildcard binds (#10335)
* fix(xo-server): keep localhost proxy targets on wildcard binds

Since 82f047ef9 (#9572), [http.proxies] targets pointing at localhost are
rewritten to the hostname of the HTTPS listen config. With the default XOA
config (hostname = '0.0.0.0') this produced targets such as
http://0.0.0.0:9004/metrics and, worse, routed these internal requests
through the proxy-aware HTTP agent: with an httpProxy configured,
/openmetrics scrapes left the appliance for the corporate proxy and were
denied there.

Only rewrite when the bind address is a specific one (the #9500 case, where
localhost is not reachable) and pick the agent from the original target
locality, so requests to the machine itself never go through the HTTP proxy.

Seen in support ticket 7763112.

* chore(CHANGELOG.unreleased): entry for #10335

* chore(CHANGELOG.unreleased): clarify localhost targets always bypass the HTTP proxy
2026-09-09 12:00:54 +02:00
OlivierFL
e428304aea feat(xo6/web-core): implement tests on frontend (#10012) 2026-09-09 11:48:41 +02:00
Pierre Brunet
11eed70342 fix(xen-api): update undici to prevent timeout error (#10282) 2026-09-09 11:16:07 +02:00
Sandrine Barrucand
699d78b1ca fix(web-stack): use correct color for no-data stars (#10327) 2026-09-09 10:43:09 +02:00
Pierre Donias
43f81f87e3 feat(xo-web/vm/tab-backups): open edit form in same tab (#10342)
Opening the form in a new tab reloaded the whole app, so every object
was fetched again from scratch. Until that fetch finished, the form
could not be properly auto-filled. Navigating in the same tab reuses
the already loaded objects, so the form is populated right away.
2026-09-08 16:38:00 +02:00
Dan Pollak
77d07cf4ac Docs: Fix typo (#10362) 2026-09-08 15:43:02 +02:00
ayoub-el-kajji-v
dc107da91a fix(xo-server): change current_operations display for xo host and poo… (#10283) 2026-09-08 14:54:24 +02:00
Thomas Moraine
c9391b075e docs(xo): fix wrong page order and restore missing external links (#10370)
docs(xo): fix wrong page order and restore missing links

After ve changed how the sidebar
was generated (from manual declarations to automatic generation)
Some pages went out of order and didn't
have the proper sidebar title.
This commit fixes that.
Also, the external links in the sidebar disappeared.
We can't add them in an auto-generated sidebar, so we circumvent
that by creating dedicated pages for external resources
and re-adding the links there.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2026-09-08 14:33:29 +02:00
Mathieu
91d0f0fbcb feat(xo-server): make the RPU/RPR migrate back phase reliable and optional (#10295)
Fixes #10260.
Introduced by #7242.
Takes over #10274.
2026-09-08 12:00:33 +02:00
Thomas Moraine
f99d7ab761 doc(xo): sidebar refactor (#10354)
We now generate the sidebar automatically (based on the documentation folder structure and _category.json files), instead of declaring the doc structure manually in sidebars.ts.
Simpler management

This makes the sidebar easier to understand and maintain. It is now managed the exact same way as the sidebars for the Vates VMS and XCP-ng documentations.
Clickable breadcrumbs

The autogenerated sidebar also creates category index pages, which makes breadcrumb categories clickable, as they already are in the Vates VMS and XCP-ng documentations.
Preview
Capture d'écran 2026-09-08 094848
Unlisted pages

Pages that should remain accessible but be hidden from the sidebar are now hidden using the unlisted: true metadata, instead of simply being omitted from sidebars.ts.
Internal links and redirection

Finally, the PR updates internal links and redirects to reflect the new folder structure and URLs.
2026-09-08 11:40:01 +02:00
Florent BEAUCHAMP
e58587aa1d fix(xo-server): auto reconnection infinite loop when pool is already connectd (#10355)
* don't rearm after a fatal error before connection setting changed ( does not affect the normal route on non fatal error like newtork issue)
* serialize error correctly in database to ensure the "avoid a database write per auto-reconnect" corectly trigger
a lot of more cleanup to ensure that the connection is really dropped even if the host changed its id
* fix a mishnalding in the grace period before marking a pool disconnected . this will keep the ui in sync AND not redownload all the xapi object for a transient issue
* fix an error transmitted as raw, so the user was only seeing {}
2026-09-08 11:25:51 +02:00
Grandalf
067aba2bfc feat(backups): add a cache guard and migrate required to backup-archive (#10243)
[XO-2789]
* Extracts the backup migration logic into backup-archive and adds a cache guard for future performance improvements
* don't generate or read cache file on immutable backup repository
* clean cache file on immutable backup repository
2026-09-08 11:11:47 +02:00
Sandrine Barrucand
bbeb67092e fix(web-stack): use correct color for 404 (#10325) 2026-09-08 10:15:14 +02:00
Alina D
a974448339 feat(xo6): add the VM name to actions that open a modal (#10310) 2026-09-08 10:11:09 +02:00
amouillard-vates
069bb79d4a feat(xo6): sr dedicated page hosts tab (#10140)
* Update CHANGELOG.unreleased.md

* Hosts tab

* Hosts side panel

* Update CHANGELOG.unreleased.md

* Route + fix import

* The dev routes

* Simplify SrHeader

* Refactoring

* Fix type

* locale

* Simplify pbd connection status

* Refactor srs get

* New import rules

* Fixes

* Singular locale

* Refactor + sr status

* Push generated routes

* Switch order

* PR Fixes

* Comment fixes

* Changelog packages

* Cleanup changelog

* Keep unreleased
2026-09-08 10:04:07 +02:00
Sandrine Barrucand
ead446fcdb fix(web-stack): use correct color for console offline SVG sparks (#10309) 2026-09-07 17:40:44 +02:00
Sandrine Barrucand
03865bc5fe feat(xo6/host): reconfigure the management interface to another PIF (#10110) 2026-09-04 14:20:23 +02:00
Elise-F
35e8c513d8 feat(lite): add action reboot on host (#10250)
* feat(host): add action reboot on host

* update changelog file

* fix missing ts extension and clean code

* add header state

* feedback one
2026-09-04 14:20:02 +02:00
Florent BEAUCHAMP
00943b92e7 docs(xo-server): rest api guidelines (#10071)
improve the xo guidelines
2026-09-04 13:36:45 +02:00
Bastien Nollet
4416d55d7a feat: release 6.8.2 (#10360) 2026-09-04 12:00:25 +02:00
Bastien Nollet
9f72a1f900 feat: technical release (#10358)
* feat(@xen-orchestra/proxy): 0.31.10

* feat(xo-server): 5.208.3

* chore(CHANGELOG): update next
2026-09-04 11:20:47 +02:00
Florent BEAUCHAMP
003cc6f9cc fix(proxy): fetch failed (#10346)
* fix(backups): fetch failed

disable compresion between xoa on proxy on xoa side

* fix(xo-proxy): disable compression on the proxy

it was disable with hrp, replaced by #10038 since it was never
sending accept-encoding header

compression can be put back later on a route by route basis after
careful checks ( for example : file restore, backup listing )
2026-09-03 12:56:36 +02:00
Bastien Nollet
bca8f02b8a feat: release 6.8.1 (#10341) 2026-09-02 11:18:21 +02:00
Bastien Nollet
7a18762da4 feat: technical release (#10334)
* feat(@xen-orchestra/xapi): 8.10.5

* feat(@xen-orchestra/backups): 0.74.2

* feat(@xen-orchestra/proxy): 0.31.9

* feat(xo-server): 5.208.2

* chore(CHANGELOG): update next
2026-09-01 16:26:11 +02:00
Florent BEAUCHAMP
1004edb51f fix(backups): failing replication of qcow2 disks (#10319)
* fix(backups):  task label NBT->NBD

* fix: still force a list block only call for NBD + stream scenario , that was failing to transfer disk on fallback
2026-09-01 15:28:47 +02:00
Marie Garde
a67dd73304 feat(xo6/traffic rule): edit a traffic rule (#10056) 2026-09-01 11:22:03 +02:00
Mathieu
a2ce3bbc48 fix(@xen-orchestra/qa-test): downgrade engine to 22 to avoid build issue (#10323)
* fix(@xen-orchestra/qa-test): downgrade engine to 22 to avoid build issue

* fix >=
2026-09-01 09:14:56 +02:00
Bastien Nollet
3034955277 feat(lite): 0.25.0 (#10329) 2026-08-31 18:37:24 +02:00
Bastien Nollet
faf6745471 feat: release 6.8 (#10328) 2026-08-31 17:59:40 +02:00
Bastien Nollet
7e022940c3 feat: technical release (#10326)
* feat(@vates/types): 2.0.1

* feat(@xen-orchestra/web): 0.60.1

* feat(xo-server): 5.208.1

* chore(CHANGELOG): update next
2026-08-31 15:55:42 +02:00
Bastien Nollet
b33894c067 feat : technical release (#10322)
* feat(@xen-orchestra/web): 0.60.0

* feat(xo-server): 5.208.0

* feat(xo-server-openmetrics): 1.9.2

* feat(xo-web): 5.203.0

* chore(CHANGELOG): update next
2026-08-31 15:15:41 +02:00
Mathieu
2fba0ff4df fix(openmetrics): keep the Prometheus secret across xo-server restarts (#10290)
* fix(openmetrics): keep the Prometheus secret across xo-server restarts

The `secret` property of `configurationSchema` used a random `default`. That
expression is re-evaluated every time the module is loaded, and xo-server never
persists the values ajv fills in from schema defaults, so each restart handed
the metrics endpoint a brand new bearer token and Prometheus started getting
401s.

Drop the default and generate the secret in `load()` instead, saving it through
`xo.configurePlugin()` so it survives a restart.

Introduced by #9323
See https://xcp-ng.org/forum/topic/12415

* chore(changelog): add openmetrics secret entry

* fix(xo-server-openmetrics): generate a 256-bit Prometheus secret

Buffer.from(getRandomValues(new Uint32Array(8))) coerces each 32-bit
element to a single byte, so the generated secret held 64 bits of
entropy instead of 256. Use randomBytes(32) instead.

Introduced by fe29bd0d4
2026-08-31 13:45:02 +02:00
Pierre Donias
62cc3e3f5d feat(xo-web/new VM): allow admin to create VM in resource set (#10259) 2026-08-31 10:55:00 +02:00
Elise-F
d2d2d57c81 feat(lite): add action create internal network (#10235)
* feat(lite): add create network and network bonded actions

* remove vlan required and info message

* feedback number one

* feat(lite): add create network and network bonded actions

* feat(lite): add action create internal network

* update changelog file

* fix rebase

* feedback number one

* remove as const in cancelRoute

* feedback number two
2026-08-31 10:51:27 +02:00
Mathieu
c7bf0c59ef fix(@xen-orchestra/qa-test): fix node version (#10320) 2026-08-31 10:48:19 +02:00
Marie Garde
62013d4e79 feat(xo6): add detach host button (#10179)
* feat(xo6): rebase detach

* add redirect when detach ok

* fix post rebase

* remove dev route

* returns from MR

* add check master host

* use linked translation

* rebase ok
2026-08-31 09:03:33 +02:00
Bastien Nollet
6a4415ea1d feat: technical release (#10318)
* feat(@vates/nbd-client): 3.5.1

* feat(@vates/types): 2.0.0

* feat(@xen-orchestra/acl): 1.6.1

* feat(@xen-orchestra/backup-archive): 1.1.2

* feat(@xen-orchestra/xapi): 8.10.4

* feat(@xen-orchestra/backups): 0.74.1

* feat(@xen-orchestra/web-core): 0.60.0

* feat(@xen-orchestra/mcp): 1.4.2

* feat(@xen-orchestra/proxy): 0.31.8

* feat(@xen-orchestra/rest-api): 0.39.0

* feat(@xen-orchestra/web): 0.59.0

* feat(xo-server): 5.207.4

* feat(xo-server-ipmi-sensors): 2.1.1

* feat(xo-server-openmetrics): 1.9.1

* feat(xo-server-sdn-controller): 1.5.0

* chore(CHANGELOG): update next
2026-08-28 14:56:02 +02:00
Florent BEAUCHAMP
14e4c7d62c fix(backups): timeout NBD transfer omnibus fixes (#10306)
* fix(backups): timeout was not used correctly for NBD transfers
* fix(nbd-client): evict a dead client in multi instead
actual code was retrying 5 times, each time with a 60s timeout
multi also have its own retry on conneciton
since the client used is determnistic for a block, any stalled client
will be hammerred for a lot of time before giving up

this PR evict a nbd client that is failing to read a block (with its
retry logic)

* fix(nbd-client/multi): better spread read accross client
the actual code derivate the clientId from the block index
we have no information on the block distribution, only hopes that
it's random enough
this PR introduce a mechanism to use a round robin to select
the next client

* fix: correct fall back to full with /without nbd on qcow2
2026-08-28 10:57:17 +02:00
Marie Garde
d541af2c41 feat(xo6): add restart toolstack host button (#10178)
* feat(xo6): rebase from master

* add check restart toolstack on buttons

* add tooltip message

* Fix for PR

* remove loader

* add chengelog

* remove route map

* return form PR
2026-08-28 10:37:38 +02:00
Sandrine Barrucand
cdc4ddb652 feat(xo6/backups): add header title (#10303) 2026-08-27 11:49:53 +02:00
Joris K
2ebf18a636 feat(xo-lite): add vm tree actions and loader (#10304) 2026-08-27 10:56:34 +02:00
Grandalf
dfb4c925ec fix(sdn-controller): delete saved port on traffic rule patch (#10307) 2026-08-27 10:17:20 +02:00
Enée DI IORIO
c0d92f3061 feat(web-core): upgrade UiCheckbox component to v8 (#9777) 2026-08-27 10:12:31 +02:00
vates-bot
642dce30ef feat(web-stack/i18n): update Xen Orchestra 6 (#10181) 2026-08-27 10:11:02 +02:00
Thierry Goettelmann
970eca135e fix(@vates/types,@xen-orchestra/rest-api): remove ACL type parameters from XoRecord (#10308)
Introduced by 63d4334837
2026-08-26 18:12:53 +02:00
Bastien Nollet
9cdaf6bb41 feat: technical release (#10305)
* feat(@xen-orchestra/async-map): 0.1.4

* feat(xo-common): 0.11.0

* feat(xen-api): 4.9.0

* feat(@vates/types): 1.30.2

* feat(@xen-orchestra/acl): 1.6.0

* feat(@xen-orchestra/xapi): 8.10.3

* feat(@xen-orchestra/backups): 0.74.0

* feat(@xen-orchestra/backups-cli): 1.1.14

* feat(@xen-orchestra/disk-cli): 2.2.1

* feat(@xen-orchestra/immutable-backups): 3.0.5

* feat(@xen-orchestra/web-core): 0.59.0

* feat(@xen-orchestra/mixins): 0.20.2

* feat(@xen-orchestra/proxy): 0.31.7

* feat(@xen-orchestra/proxy-cli): 0.3.3

* feat(@xen-orchestra/qa-test): 1.2.0

* feat(@xen-orchestra/rest-api): 0.38.0

* feat(@xen-orchestra/upload-ova): 0.1.9

* feat(@xen-orchestra/web): 0.58.0

* feat(xo-acl-resolver): 0.5.4

* feat(xo-cli): 0.32.4

* feat(xo-server): 5.207.3

* feat(xo-server-audit): 0.15.2

* feat(xo-server-ipmi-sensors): 2.1.0

* feat(xo-server-netbox): 1.13.2

* feat(xo-server-sdn-controller): 1.4.1

* feat(xo-web): 5.202.0

* chore(CHANGELOG): update next
2026-08-25 16:19:35 +02:00
Florent BEAUCHAMP
9b4e1aee3e fix(xo-server): task controller leaking memory (#10237) 2026-08-25 15:42:12 +02:00
Thomas Moraine
ab7b2fc7a9 docs(xo): refine page titles and navigation structure (#10276)
* docs(xo): refine page titles and navigation structure

Make page titles clearer and more consistent with their content and
purpose. Reorder some pages (and sections) and align their sidebar labels with
their titles to make the documentation easier to navigate and
understand.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>

* docs(sidebars.ts): move discover xo category back to where it was

Applying the change suggested by olivierlambert.
The 'Discover Xen Orchestra' category now follows
the 'Getting started' category.
The 'architecture' page/ 'How XO 6 is organized' stays
at the end of the Getting Started category.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>

* docs(xo): specify navbar titles

Applying changes suggested by olivierlambert.

Some titles in the navigation bar were made too long,
in an attempt to make them identical to the titles in the corresponding doc pages.
Having different titles in the navbar and in the actual page
is not a defect, and can indeed help readers scan the
document structure quicker.
As a result, we're (re-)adding sidebar_label metadata
to specify a navbar title where the page title was too long.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>

* docs(xo): revert individual page renames

Applying changes suggested by olivierlambert, following,
the reasoning he explained in the pull request.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>

* docs(intro_support.md) change title to 'support'

Apply change suggested by olivierlambert.
'Support' instead of 'Support options' is shorter and
carries the same meaning overall.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>

* docs(xo): update outdated internal link labels

A few commits ago, some page titles were changed.

This did not break any internal links, but it left some link labels outdated. This commit updates those labels to match the current page titles.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>

* docs: convert page titles to sentence case

Applying changes suggested by olivierlambert

Some page titles were still using title case, even though we use sentence case throughout the XO documentation.

This commit updates those titles to follow the same convention.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>

* docs(xo): switch gerund titles to noun forms

Some page titles used gerunds (the -ing form), while others used nouns or noun phrases, resulting in an inconsistent style.

This commit changes the gerund titles to noun-based titles for consistency.

The "Troubleshooting" title is kept as-is because it is a noun, despite ending in -ing.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>

---------

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2026-08-25 14:23:34 +02:00
Thierry Goettelmann
9cdd8e9d45 chore(web-core,web,lite): enforce .ts extension in imports (#10299) 2026-08-25 11:01:00 +02:00
Sandrine Barrucand
14e5352a40 feat(xo6/vm): add guest tools status (#10077) 2026-08-25 10:40:50 +02:00
Marie Garde
d955ffc4d1 feat(xo6): add smart reboot host button (#10177)
* feat(xo6): reset smart reboot branch ok

* Last returns

* Remove route map

* rebase ok

* add check onConfirm smart reboot
2026-08-25 09:56:16 +02:00
spacotte-vates
f812eaaeb6 feat(backup): implement synchronized snapshot option back (#10136)
Adds a synchronizedSnapshot backup setting. When enabled, all of a job's VM snapshots are taken up front in a batch before any transfer begins, and each VM's backup then reuses its pre-taken snapshot instead of snapshotting again.

Settings:
synchronizedSnapshot:

    false (default)
    true (synchronize every VM in the job)
    <tag> (synchronize only VMs carrying that tag, others are backed up normally).

snapshotConcurrency: max concurrent snapshots during the batch phase (default 2).

A batch member that fails to open is silently dropped from synchronization and backed up normally.

Unit tested where applicable and e2e tests added in qa-test.

Checkbox added in xo5 backup jobs to synchronize snapshots, tags not supported yet.
2026-08-24 17:58:03 +02:00
amouillard-vates
814bc96a8b feat(xo6): sr dedicated page general tab (#10100)
* feat(xo6): sr dedicated page general tab

* Remove dev routes

* Update CHANGELOG.unreleased.md

* Rename sr usage warning

* Rename vdi allocated space warning

* Remove useless variable

* Remove useless destructuring

* Remove useless destructuring

* Use UiInfo component and normalize margins

* Change variable name

* Add pool/host breadcrumb options

* Normalize fromContext with vdi page

* SR space unwritable alert

* Side panel space card info message

* Usage warning color

* Fix PR comments

* Add sr uuid resolver

* Fix CI

* Update getters name

* Full width UiInfo

* Consistent srUsagePercentage check

* Remove superfluous destructuration

* Reuse existing SR_SCOPE_TYPE

* Remove dev routes

* CSS nesting

* Fix PR comments

* Dev routes + UiPanelCard

* Move getSrPageLocation
2026-08-24 11:13:13 +02:00
Alina D
9ef53890b8 feat(xo-lite): add vdi page (#10269) 2026-08-21 17:31:45 +02:00
spacotte-vates
8b4505a2d5 fix(xo-server): changing vif mac adress destroy traffic rules (#10284) 2026-08-21 17:15:48 +02:00
amouillard-vates
a68c9534e9 fix(lite): add hasChildren prop to xo-lite tree view elements (#10244) 2026-08-21 14:59:23 +02:00
Bastien Nollet
46b0dc67bb chore(backups): use set_memory_limits instead of convoluted code (#10286) 2026-08-21 14:36:22 +02:00
Thierry Goettelmann
249137793f fix(web-core,web,lite): align side panel card spacing with the design system (#10279) 2026-08-20 12:03:44 +02:00
amouillard-vates
96e7e23f40 feat(xo-lite): enable scan PIFs button on host network page (#10147) 2026-08-19 14:01:04 +02:00
Anupam Mediratta
6c3e3e781d fix(backups): upgrade tar to 7.5.19 (CVE-2026-59873) (#10159) 2026-08-19 10:42:23 +02:00
Marie Garde
bcb0a7041f feat(xo6): add force reboot host button (#10175)
* manual rebase

* returns from stack PR

* rebase ok

* returns from PR

* fix import

* remove unescessary params

* remove reoute map

* Last return
2026-08-18 11:48:42 +02:00
Alina D
db0d009d5b feat(xo6/pool): add forget action (#10130) 2026-08-18 11:17:18 +02:00
Elise-F
de1c231873 feat(lite): add create network and network bonded actions (#10145)
* feat(lite): add create network and network bonded actions

* update changelog file

* remove vlan required and info message

* feedback number one

* feedback number two
2026-08-18 10:49:27 +02:00
Marie Garde
4235644950 feat(xo6): add reboot host button (#10141)
* continuefeat(xo6/host): add reboot host button

* fix fr.json

* Add loader on evacuate and reboot

* add check message evacuate VMs

* Fix message

* Returns from PR

* Remove vm migrate from host operations

* Change accent modal

* Rebase master ok

* Fix rebase
2026-08-17 09:40:40 +02:00
Marie Garde
7942d6a84a feat(xo6): add disable host and evacuate VMs (#10090)
* feat(xo6): add disable and enable host button

* Add button enable disable

* remove route map file

* Refacto for PR

* Add modal

* refacto for PR

* fix i18n

* fix route map

* change modale to VtsActionModale

* refacto for MR

* fix type VtsActionModal

* refacto for MR

* add enable disable un treeview

* refacto treeview

* wip

* add shutdown and start button and job

* Update changelog and remove route map

* wip i18n

* tests ok

* fix build

* refacto for mr

* refacto for PR

* add check pending action

* add check host not halted

* feat(xo6): add disable and enable host button

* Add button enable disable

* Refacto for PR

* Add modal

* refacto for PR

* change key i18n

* change modale to VtsActionModale

* refacto for MR

* fix type VtsActionModal

* refacto for MR

* refacto treeview

* wip

* Update changelog and remove route map

* wip

* wip forget

* test and refacto for PR

* fix error rebase

* fx rebase missing

* fix rebase

* fix rebase

* wip

* add shutdown and start button and job

* tests ok

* refacto for mr

* add check host not halted

* wip

* rebase for PR

* fx rebase missing

* wip

* change order button

* wip

* wip forget

* wip

* button and job ok

* add modal disable host and evacuate vm but tests not done

* refacto for PR

* fix fr json and route map

* fix rebase

* fix PR

* remove route map

* last fix

* Returns from PR

* Returns from PR

* Add ? on confirm i18n

* Fix job disable and enable running message

* Fix enable job

* Fix enable job

* Returns from PR

* Fix post rebase

* Fix post rebase

* Fix post rebase

* fix from rebase

* Returns from PR

* Returns from PR

* rebase ok
2026-08-17 08:33:33 +02:00
amouillard-vates
c5faec4cf6 fix(lite): remove multi-selection from pool networks table (#10268) 2026-08-14 15:39:33 +02:00
Thomas Moraine
c9149618f8 docs(xo): rename security tab into traffic rules (#10267)
The Pools -> Security tab in Xen Orchestra was
recently renamed as 'Traffic rules'.
This commit updates the XO documentation (screenshots included) to reflect that change.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2026-08-14 13:42:38 +02:00
Elise-F
438415a882 feat(xo6/traffic rules): added the error message related to the method and updated UI (#10202) 2026-08-14 10:41:45 +02:00
OlivierFL
ed78cc10cf feat(xo-6/vif): add VIF name in head bar on VIF detail page (#10252) 2026-08-14 09:05:02 +02:00
Marie Garde
1c18215bd1 feat(xo6): add host action button forget (#10089)
* feat(xo6): add disable and enable host button

* Add button enable disable

* remove route map file

* Refacto for PR

* Add modal

* refacto for PR

* change key i18n

* fix i18n

* fix route map

* change modale to VtsActionModale

* refacto for MR

* fix type VtsActionModal

* refacto for MR

* add enable disable un treeview

* refacto treeview

* wip

* add shutdown and start button and job

* Update changelog and remove route map

* wip i18n

* tests ok

* fix build

* refacto for mr

* refacto for PR

* add check pending action

* add check host not halted

* feat(xo6): add disable and enable host button

* Add button enable disable

* Refacto for PR

* Add modal

* refacto for PR

* change key i18n

* change modale to VtsActionModale

* refacto for MR

* fix type VtsActionModal

* refacto for MR

* refacto treeview

* wip

* Update changelog and remove route map

* refacto for mr

* wip

* add forget button host

* update button for menuitem

* wip forget

* test and refacto for PR

* fix error rebase

* fx rebase missing

* add changelog

* fix rebase

* fix rebase

* Add ? on confirm i18n

* Returns from PR

* rebase overlay ok

* fix changelog

* Add redirect if still on host page after forget

* Add redirect after delete
2026-08-14 09:00:50 +02:00
Pierre Donias
7cdb792bfe fix(xo-web/vm/console): make collapsed header state reliable (#10007)
The console tab kept its own `minimalLayout` state to track whether the page header was collapsed, separate from the VM page's `collapsedHeader`. The two desynced because the tab remounts on navigation while the VM page stays mounted.

Fix:
- Single source of truth `collapsedHeader` in the parent index.js
- Pass it to the child tab-console.js along with `setCollapsedHeader`
- Replace the `_toggleHeader` toggle with an explicit `_setCollapsedHeader(value)` setter
- Remove the duplicate local `minimalLayout` state that could get out of sync and use `collapsedHeader` instead
- Force off on VM shutdown (console no longer displayed) and on unmount (user changed page)
2026-08-13 15:15:25 +02:00
Grandalf
0601716fa4 fix(backups): return backups repository info one by one instead of erroring on first failure (#10205) 2026-08-13 15:09:53 +02:00
spacotte-vates
d53d2fc1c2 fix(xo-server): fix target not found error for warm migration (#10210) 2026-08-13 09:56:10 +02:00
Bruno Verachten
11ff21097f docs(snapshots): note that reverting restores VM metadata too (#10196)
Surfaced via https://xcp-ng.org/forum/topic/12148.
2026-08-12 17:39:33 +02:00
Greg-hub-dev
828c15cbb4 docs(backups.md) : Delete future RAM backup feature details (#10223) 2026-08-12 15:03:33 +02:00
Grandalf
798732e62a fix(async-map): allow return type to be different than input type (#10227) 2026-08-12 10:50:44 +02:00
Mathieu
1bfcc50b23 fix(xen-api): update platform for cores-per-socket update (#10201)
XO-2814
2026-08-10 15:52:07 +02:00
Olivier Lambert
c5d791a7de docs: full documentation revamp (#10212) 2026-08-07 16:36:09 +02:00
Grandalf
3293e69cc2 refactor(xo-server): remove hrp dependency (#10038)
replace http-request-plus external depedency by a a native wrapper around fetch
2026-08-07 11:58:20 +02:00
Grandalf
a0193085f5 feat(ipmi): add get sensors route (#10003) 2026-08-07 11:38:28 +02:00
Mathieu
2e5ec8c9e2 fix(xo-server): auth provider did not wait for token update (#10233) 2026-08-06 15:24:21 +02:00
Mathieu
8054d88a88 fix(xo-server): handle 'no-bundle' as legacy licence (#10241) 2026-08-06 15:22:22 +02:00
Thierry Goettelmann
20bedca539 refactor(web-core,lite,web): migrate from modal/drawer packages to the unified overlay system (#10096) 2026-08-05 17:13:15 +02:00
Grandalf
f61019b960 fix(rest-api): coerces strings into numbers in external-router when type="number" (#10213) 2026-08-05 16:22:09 +02:00
Thomas Moraine
be960b36b9 docs(advanced.md) fix broken link to kubernetes page (#10230)
The docs on devops tools and kubernetes was moved
from the Vates VMS doc to the Xen Orchestra doc.
As a result, one link to the kubernetes page broke in the XO doc.
This commit fixes that broken link.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2026-08-05 09:40:12 +02:00
Mathieu
40dede9e11 feat: release 6.7.1 (#10229) 2026-08-04 15:42:40 +02:00
Mathieu
e9548959c6 feat: technical release (#10228) 2026-08-04 12:05:18 +02:00
Florent BEAUCHAMP
be94d0d083 fix(xo-server/v2v): incorrect uuid for running VM without snapshot (#10194)
* add more details in task log
* fix an issue with VM with more than 10 snapshot, add unit test for this parsing
* fix the real bug: vmware do not update the vmdk description file before the next VM reboot. It means that the uid of the snapshot and vm are the same, thus finishing the transfer with " nothing to transfer" . We are now using the path on source as key + add unit test + add compatibiliy with legacy VM migrated with the uid.
* add e2e test for v2v
2026-08-04 10:41:07 +02:00
Julien PIALET
9c6d10327f doc(RBAC) : added details on POST privilege example (#10168) 2026-08-03 15:20:20 +02:00
spacotte-vates
a94eba4dd1 fix(nbd-client): fix long datamap read nbddisk (#10157)
Customer imported large VM from VMWare (> 3 To), import switched to Qcow2 automatically but the process was stuck while building the qcow2 L1/L2 address tables, where the NbdDisk's hasBlock is called for each l2 table entry which iterated over the disk dataMap which contained ~113,528 ranges. This froze the process for more than 18 minutes after which it was either killed or died due to OOM error.

This fix improves the NbdDisk by:

    Updating processDatamap to throw on overlapping ranges and merge touching extents.
    Override default getBlockIndexesCount with optimized Nbd one.
    Improve hasBlock with a built in cursor to improve performance.

Test file added to cover the new function and updated code.
2026-08-03 11:24:44 +02:00
Florent BEAUCHAMP
a6970b0150 fix(backups/filerestore): restore files from proxy as tgz (#10208)
from ticket 59505

node-tar >= 7 returns a Minipass stream: it has `pipe()` but is not a `node:stream`
instance. xo-proxy serves this stream through Koa, which pipes only bodies passing
instanceof Stream` and JSON-serializes anything else — so a file restore through a
proxy answered a JSON dump of the tar object instead of the archive. Wrapping it in a
real node stream also propagates tar errors to the consumer and aborts the tar job
when the client disconnects.
2026-08-02 14:57:48 +02:00
Bastien Nollet
26cd23179d fix(load-balancer): don't get disabled hosts (#10209)
The load balancer did not take into account that hosts may be in disabled state, which means it's impossible to migrate a VM to it.
This PR fixes that by ignoring these hosts instead.

Fixes ticket 62077
2026-08-02 14:56:58 +02:00
Joris K
03d2af39ad feat(xo6): add host scan PIFs button and job (#10191)
* changelog

* feat(xo6): add host scan PIFs button and job

* add changelog

* missing translation

* review

* review

---------

Co-authored-by: MathieuRA <contact@mathieu-raisin.fr>
2026-07-31 16:10:46 +02:00
Mathieu
be868a307b feat(rest-api): add scan-pifs endpoint (#10187)
* feat(rest-api): add scan-pifs endpoint

* changelog

* add pifs tag to the route

* fix typo

* fix  typo in changelog

* fix

* missing response
2026-07-31 11:50:05 +02:00
Florent BEAUCHAMP
01c00b110e test(immutable-backups): integration test of the new process 2026-07-31 11:28:25 +02:00
Florent BEAUCHAMP
65c32014d6 feat(immutable-backups): better cleanup on start
feat: changelog
2026-07-31 11:28:25 +02:00
Florent BEAUCHAMP
69dfeeaaf6 fix(immutable-backups): ensure shell is in english 2026-07-31 11:28:25 +02:00
Florent BEAUCHAMP
4a1269b898 feat(immutable-backups): run lift on proces start 2026-07-31 11:28:25 +02:00
Marie Garde
498cc7509f feat(xo6/host): host action button start shutdown (#10088)
* feat(xo6): add disable and enable host button

* Add button enable disable

* remove route map file

* Refacto for PR

* Add modal

* refacto for PR

* change key i18n

* fix i18n

* fix route map

* change modale to VtsActionModale

* refacto for MR

* fix type VtsActionModal

* refacto for MR

* add enable disable un treeview

* refacto treeview

* wip

* add shutdown and start button and job

* Update changelog and remove route map

* add check on disable button

* fix job error type

* update button for menuitem

* wip i18n

* add vtsaction modal

* tests ok

* fix build

* refacto for mr

* fix fr.json

* refacto for PR

* add check pending action

* add check host not halted

* fix enable disable button while host shutdown

* returns from MR

* fix changestate host header

* wip

* wip

* add check pending operations

* last fix for PR

* fix menuItem in mobile vue

* remove route map

* returns from MR

* fix start button

* Add changelog
2026-07-31 10:12:52 +02:00
Florent BEAUCHAMP
f98b4c9e4a docs(s3): add ovh as a tier 1 provider (#10206) 2026-07-31 09:40:37 +02:00
Mathieu
cc7ba53b29 feat(lite): 0.24.0 (#10204) 2026-07-30 17:46:20 +02:00
Mathieu
1a795970f9 feat: release 6.7.0 (#10203) 2026-07-30 17:26:51 +02:00
Mathieu
59306dce8f feat: technical release (#10198) 2026-07-30 11:08:08 +02:00
Florent BEAUCHAMP
6ba465be04 fix(xapi): don't try to compute progress on Buffer (#10195)
* fix(xapi): don't try to compute progress on Buffer

introduced by 76b45a9b3b

* port type to vates/types
2026-07-30 10:19:37 +02:00
Mathieu
068c4cdc86 feat: technical release (#10192) 2026-07-29 15:30:14 +02:00
Mathieu
93a7c3b509 fix(docs): fix yarn.lock and broken anchor (#10190)
Introduced by ada3cf745
2026-07-29 14:52:45 +02:00
Florent BEAUCHAMP
07c42bbd1e fix(fs/s3): improve delete multiple performances (#10189) 2026-07-29 11:29:55 +02:00
Florent BEAUCHAMP
ada3cf745c feat(xo-server): serve doc from xo + add backup calculator (#10154)
* feat(xo-server): serve doc locally
* the doc is already distributed from the mono repo
* add a build phase to the global yanr build that build a doc with the
right prefix
* add a http mount doc => embed build

* doc(backups): add a space calculator
* estimate the space needed for backups
* compute the duration protected by immutability
2026-07-29 09:48:41 +02:00
Mathieu
6a0332a784 fix(xapi/host): fix async use in a sync block for smart reboot (#10180)
* fix(xapi/host): fix async use in a sync block for smart reboot
2026-07-29 09:38:38 +02:00
Mathieu
09bd5c3425 feat(openmetrics): expose SR content and UUID (#10149)
Refs XO-2503
2026-07-29 09:24:34 +02:00
Florent BEAUCHAMP
26c36d485e fix(backups): don't hide errors during backup transfers (#10183) 2026-07-28 17:08:19 +02:00
Thierry Goettelmann
087fd5d397 feat(overlay): allow manually aborting overlays (#10167) 2026-07-28 16:47:45 +02:00
Grandalf
4636fcf820 feat(types): type-register-rest-routes (#10171) 2026-07-28 14:51:36 +02:00
Marie Garde
dd58fed217 feat(xo6/xolite): add copyall button xo and lite (#10081) 2026-07-28 13:38:57 +02:00
vates-bot
4aa8a31c25 feat(web-stack/i18n): update Xen Orchestra 6 (#10033) 2026-07-28 11:00:31 +02:00
Olivier Lambert
4ca263c10a feat(xo-server,xo-web/rpu): shut down and restart pinned VMs instead of aborting (#10125)
* feat(xo-server/rpu): opt-in shutdown/restart of pinned VMs instead of aborting
2026-07-28 10:05:43 +02:00
Alina D
8d58147d66 feat(xo6): align vertically all hosts in Treeview (#10153) 2026-07-28 09:53:07 +02:00
Joris K
5849844fca fix(lite): change defer type import on vm creation (#10176) 2026-07-28 09:22:52 +02:00
Mathieu
a5648aede8 fix(web-core): fix several store issues (#10156) 2026-07-28 09:22:45 +02:00
Florent BEAUCHAMP
84ce89d199 fix(disk-transform): implement a guardrail to ensure it does not read block only in parent 2026-07-27 19:45:25 +02:00
Florent BEAUCHAMP
9140cd273b fix(disk-transform): fix getBlockIndexesCount
introduced by #10058

the current method assumes that source block are full but they can
be sparse. In the worst case getBlockIndexesCount can be the same as
the source
2026-07-27 19:45:25 +02:00
Florent BEAUCHAMP
e710135eda fix(backups): ensure replication never transmit a delta without a base
differentiate when it's expected to transmit a full , and throw an
error on any discrepency
2026-07-27 19:45:25 +02:00
Florent BEAUCHAMP
5509f62ccd fix(backups): fix aggregated backup failing instead of falling back to a full
map.foreach((value,key)=> , not map.foreach(key=>
2026-07-27 19:45:25 +02:00
Florent BEAUCHAMP
6c4f7336e1 fix(backup-archive): really run tests 2026-07-27 19:45:25 +02:00
Florent BEAUCHAMP
65cd607a49 fix(backups): force a full on continuous replication if the base could have been empty 2026-07-27 19:45:25 +02:00
Florent BEAUCHAMP
453ffc98d2 fix(backups): fall back to a full backup if it's possible that the base is broken 2026-07-27 19:45:25 +02:00
Florent BEAUCHAMP
f8df78c03d feat(backup-archive): add method to get the root of a chain 2026-07-27 19:45:25 +02:00
Florent BEAUCHAMP
3613b30aa1 fix(backups): stamp backup after the fix 2026-07-27 19:45:25 +02:00
Florent BEAUCHAMP
58332e6b16 doc(backups): more clarification on qcow2 + nbd 2026-07-27 19:45:25 +02:00
Florent BEAUCHAMP
9388fa4bb6 fix(disk-transform): erronous offset in DiskLargerBlock when copying data
the offset must be relative to the start of THIS large block, not to the
start of the disk — otherwise every large block after index 0 lands past
the end of destinationBlock and copy() silently writes nothing (zeroed block).

affect qcow2 incremental backup with NBD disable

NBD + qcow2 and Vhd backups don't go through this code path
2026-07-27 19:45:25 +02:00
Olivier Lambert
f683817dec feat(docs): working cookieless Matomo tracking on a dedicated site
The previous static/js/matomo.js snippet never loaded the tracker
script, so nothing was ever recorded; it also pointed at site 5, the
XO website. Replace it with the client module from the Vates VMS docs
on the new dedicated docs site (23): cookieless page-view tracking that
follows SPA navigations, and instrumentation of the lunr search box
reporting each settled query with its suggestion count, so zero-result
searches surface missing content. The feedback widget mirrors votes and
thumbs-down reasons as doc_feedback events.

Also fills in the Formbricks IDs for the "XO Docs page feedback"
survey, enabling the feedback widget.
2026-07-27 19:25:51 +02:00
Olivier Lambert
51fe6b8920 feat(docs): page feedback widget wired to Formbricks
Swizzles DocItem/Footer to show a 'Was this page helpful?' widget under
every doc article, ported from the Vates VMS docs. Votes post to the
self-hosted Formbricks instance via its public client API; a thumbs-down
asks why (outdated, unclear, missing info, inaccurate) plus optional
free text. The vote is recorded on click so it survives the visitor
leaving before the follow-up. Page path rides along as a hidden field
for per-page filtering.
2026-07-27 19:25:51 +02:00
Mathieu
74b952be2b feat: technical release (#10174) 2026-07-27 15:05:16 +02:00
Florent BEAUCHAMP
efbfb664f7 fix(backup-archives): regenerate cache on discrepencies (#10132)
* ensure cache is generated completely at the end of the backup job
* ensure a broken cache is regenerated even without merge during the cleanup phase
* ensure a broken cache is regenerated on request
2026-07-27 14:19:17 +02:00
Bastien Nollet
1616ec6f41 feat(backups): make fallback cause appear in the task (#10148) 2026-07-27 14:10:03 +02:00
Alina D
246e2e4eba feat(xo6/pool): add connect/disconnect actions on page site (#10075) 2026-07-27 13:26:20 +02:00
Mathieu
bcb91e8b4f feat(rest-api): add POST /srs route to create a storage repository (#9990) 2026-07-27 10:37:55 +02:00
Florent BEAUCHAMP
76b45a9b3b fix(xo-server): ensure import task progress is correctly updated (#10133) 2026-07-27 10:23:29 +02:00
Sylvère
5f42e437c7 feat(lite): add SR connect/disconnect/delete (#10006) 2026-07-27 09:44:23 +02:00
Sylvère
b76294a1b1 feat(web-core): refactor the sidebar system (#10120) 2026-07-27 09:43:16 +02:00
Grandalf
b7bce8e7e7 fix(csp): update Content-Security-Policy to allow connections to GitH… (#10162) 2026-07-27 09:03:17 +02:00
Pierre Brunet
1445f986cf feat: add azure section (#10166)
* feat: linter

* feat: add Azure doc
2026-07-24 17:59:06 +02:00
MlssFrncJrg
83b43a44f6 feat(xo5/hub): open Hub to all plans and gate recipes individually (#10117) 2026-07-24 17:30:04 +02:00
Olivier Lambert
235ff3c455 feat(xo6/new-vm): set HA restart priority at VM creation (#10072) 2026-07-24 14:15:22 +02:00
Alina D
b0194b2c5f feat(xo6): update input version (#10009) 2026-07-24 11:47:22 +02:00
Marie Garde
316122a683 feat(xo6/users): Add Users Table (#10029) 2026-07-24 10:34:04 +02:00
Sandrine Barrucand
954c0aaa9e fix(xo6): add separator between label and hint in table actions (#10143) 2026-07-23 16:19:37 +02:00
Sandrine Barrucand
377e315e02 fix(xo6/host): wrap Network name in PIF side panel (#10155) 2026-07-23 14:54:56 +02:00
Joris K
9b6f882424 fix(xo6/lite): fix some design inconsistency between pages on xo6 and lite (#10109) 2026-07-23 14:23:37 +02:00
Marie Garde
874da48125 feat(xo6): add enable/disable host button (#10074) 2026-07-23 12:02:46 +02:00
Pierre Brunet
e22ee2547b Fix: avoid default value for "private" in normalize-packages script (#10121) 2026-07-23 11:43:21 +02:00
Mathieu
43e45667ab fix(xo-server/rpu): LINSTOR packages update progress no longer exceeds 100% (#10122) 2026-07-23 11:24:23 +02:00
Florent BEAUCHAMP
c05f9bb357 feat(backup): update footer and header at once (#10123) 2026-07-23 11:16:56 +02:00
amouillard-vates
92f8ed4077 feat(lite): add copy info into JSON action to pool networks table (#10083) 2026-07-23 11:10:52 +02:00
MathieuRA
fc6415b4ec fix(xo-server): fix group deletion checking RBAC authorization 2026-07-23 10:52:29 +02:00
MathieuRA
53014d7d18 feat(rest-api): expose acl-roles/:id/groups 2026-07-23 10:52:29 +02:00
MathieuRA
5bb4ac62bf fix(xo-server): delete user don't remove the link between user and acl-role 2026-07-23 10:52:29 +02:00
MathieuRA
b4433e4ecc feat(rest-api): expose acl-roles/:id/users 2026-07-23 10:52:29 +02:00
MathieuRA
fee9bf8334 feat(xo-server): add groupIds and userIds to acl-role object 2026-07-23 10:52:29 +02:00
MathieuRA
1455801282 feat(xo-server): add aclRoleIds property to groups 2026-07-23 10:52:29 +02:00
MathieuRA
bd5937366a feat(rest-api): expose groups/:id/acl-roles 2026-07-23 10:52:29 +02:00
Joris K
d2667ac64b feat(xo6/lite): updated the modal so it can be closed using the X or by clicking outside it (#10137) 2026-07-23 09:06:19 +02:00
Pierre Brunet
de9687e2c0 fix: issue on file overwriting during vhdDirectory creation (#10139) 2026-07-22 15:04:00 +02:00
Florent BEAUCHAMP
c12bd807ed feat(qa-test): add a script to do a load test (#10119)
add new option and script to launch a load test :

    number of vm is configurable
    concurrency is configurable
    number of backup run is configurable

will do multiple run, then a restore
2026-07-22 14:36:50 +02:00
amouillard-vates
7b21690721 feat(xo6/pool): add new VM button and disconnect to pool tree actions (#10046) 2026-07-22 14:19:26 +02:00
Sandrine Barrucand
b719c0a018 refactor(xo6/vif): merge connect/disconnect buttons into a single component (#10112) 2026-07-22 13:25:45 +02:00
Grandalf
8e8afeb1b5 feat(xo-server): add content security policy (#10101) 2026-07-22 12:15:16 +02:00
Elise-F
0a5693345f feat(traffic rule type by VIF): display only the traffic rules for VMs and disable VMs without a VIF. (#9977)
- Show only traffic rules of VMs. Don't include `vm-snapshot` or `vm-template`
- For VM selection, display all VMs and disable those without a VIF, with a
message displayed
2026-07-22 11:01:17 +02:00
Bruno Verachten
ebce0e508f docs(xo): document VBD attach and note ISO mount is JSON-RPC only (#9981) 2026-07-21 16:35:13 +02:00
Olivier Lambert
64f1327f46 feat(rest-api): allow setting HA restart priority on VM creation (#10070)
See #10069
2026-07-21 16:02:08 +02:00
bleader
d2c28450d4 docs(sdn_controller.md): clarify openssl3 update instructions (#9821) 2026-07-21 11:45:09 +02:00
MlssFrncJrg
4eb43fbc36 feat(xo6): resolve objects in tasks names (#9830) 2026-07-21 10:37:36 +02:00
Bastien Nollet
e44034285b fix(backups): fix failed status on successful retry (#10129)
A VM backup log status is calculated depending on its subtask status. Previously, we made that it would be a failure if at least one of its subtask is a failure.

However, in case of successful retries, the intended behaviour is to have failed subtasks, which should not affect the parent task status. The first commit of this PR fixes that.

The second commit simplifies the getStatus function and its usage.
---------

Co-authored-by: Florent BEAUCHAMP <florent.beauchamp@vates.tech>
2026-07-21 09:50:07 +02:00
Thierry Goettelmann
9c1aa104f5 chore(deps): hoist pinia, vite and vue-router to root devDependencies (#10138) 2026-07-21 09:30:21 +02:00
Thierry Goettelmann
4ff8af838f feat(web-core): add hide-permanently package (#10108) 2026-07-21 09:28:07 +02:00
amouillard-vates
101fb46ad1 fix(xo6/site): remove site tree view new vm action (#10135) 2026-07-21 09:04:44 +02:00
Thierry Goettelmann
687dd5f456 feat(web-core): add promise-based overlay system (#10095) 2026-07-20 17:34:09 +02:00
Mathieu
ee6c6b3bbc feat(xo-server): delay load balancer re-enable after RPU (#10111)
XO-2706
2026-07-20 16:51:45 +02:00
Mathieu
54260268ba fix(xo-server): demote successful fallback logs (#10131)
XO-2734
2026-07-20 16:00:13 +02:00
Elise-F
0d53d7621a feat(lite): add the “connect/disconnect” action for a VIF on a VM (#10080) 2026-07-20 15:12:46 +02:00
Florent BEAUCHAMP
c3a2e89c1f feat(disk-cli): add option to show chain info, size, and improve peformances (#10063) 2026-07-20 14:37:20 +02:00
Enée DI IORIO
27fe714e17 feat(web-core): update UiTextArea to V5 (#9855) 2026-07-20 11:10:33 +02:00
Mathieu
5e6c4ad96d fix(xo-server): automatically reconnect to XenServer after connection (#10086)
A server left enabled but without a Xapi instance was never retried by anything in
xo-server. Three paths lead to that state:

    a failed explicit connect: xo-server boots while a pool is down, a user clicks
    "connect" during an outage, or the host-eject path exhausts its 5 attempts;
    a SESSION_INVALID teardown after a pool master restart (_sessionCall calls
    disconnect(), and nothing listens to server:disconnected);
    fixing a server's credentials/address while it is disconnected (server.set never
    triggers a connection).

Once there, the server stays disconnected forever, showing an error that can be
minutes or hours old. Support ends up chasing false "pool is dead" reports, typically
right after a rolling pool update, which is exactly when hosts reboot by design.
2026-07-20 09:42:12 +02:00
Florent BEAUCHAMP
926eb348d3 feat(xo-server): log more data to track memory leaks (#10041)
* feat(xo-server): log more data to track memory leaks

* changelog
2026-07-17 16:35:06 +02:00
Mathieu
bfb6b15cfa fix(xo-server/rpu): retry check_update while updater plugin is busy (#10115) 2026-07-17 16:17:09 +02:00
Grandalf
d05be427fb fix(xo-server): replace hasPermissions (#10093) 2026-07-17 11:48:37 +02:00
Pierre Brunet
76fa4bba51 fix(CI): do not log "ENOENT" when unlink fails after check (#10102)
* fix(CI): do not log "ENOENT" when unlink fails after check

* fix: await for mkdirp before continuing
2026-07-17 10:16:07 +02:00
Grandalf
d02098a397 fix(xo-server): don't log the admin password on server creation (#10094)
XO-2647
Introduced by #3579
2026-07-17 09:43:51 +02:00
Sandrine Barrucand
20c9ed8a41 feat(xo6/vm): add New VM button on Host view (#10048) 2026-07-16 16:18:31 +02:00
Mathieu
2f19fda3a5 feat(xo-server/rpu): assert each host can be evacuated right before its evacuation (#10097) 2026-07-16 15:16:17 +02:00
Mathieu
798bdeb973 fix(xo-server): disable auto power on during rolling pool update/reboot (#10104) 2026-07-16 14:34:50 +02:00
MlssFrncJrg
ac52f8b75e fix(perf-alert): update URL generation for alarms to support v6 routing (#10054)
* fix(perf-alert): update URL generation for alarms to support v6 routing

* changelog entry

* fix changelog

* Apply suggestions from code review

Co-authored-by: Mathieu <goore.csmoviz@gmail.com>

---------

Co-authored-by: Mathieu <goore.csmoviz@gmail.com>
2026-07-16 10:25:24 +02:00
Enée DI IORIO
691af588d3 feat(Lite): remove all coming soon (#10068) 2026-07-15 17:26:22 +02:00
Grandalf
cda3c3a724 perf(xo-server): don't wait for errored br to send data (#10025) 2026-07-15 15:32:21 +02:00
John P. Cooper
f2d6b7f0e4 Update XCP-ng version label in supported_hosts.md to bring into sync with supported status (#10092) 2026-07-15 15:20:56 +02:00
Sandrine Barrucand
1afffb8f75 feat(xo6): add possibility to export VM (#9989) 2026-07-15 14:42:36 +02:00
Mathieu
2b7b6baf02 feat(xo-server-openmetrics): estimate per-VM power consumption from CPU (#10031)
* feat(xo-server-openmetrics): estimate per-VM power consumption from CPU
usage

* refactor(xo-server-openmetrics): move hostPowerWatts undefined guard to
caller

* fix(xo-server-openmetrics): log IPMI power read failure as info
2026-07-15 14:06:49 +02:00
Florent BEAUCHAMP
0cfcb998a2 feat(disk-transform): port getBlockIndexesCount to the relevant classes (#10058) 2026-07-15 14:04:53 +02:00
Sandrine Barrucand
3ffd9b1f56 feat(xo6/vm): in the VM actions, add a separator to Delete, placeholder actions and submenu (#10036) 2026-07-15 13:18:34 +02:00
Mathieu
184d65cef1 feat(xo-server): add crash-resistant on-disk observability for RPU/RPR (#10078)
* feat(xo-server): add crash-resistant on-disk observability for RPU/RPR
(XO-2640)

* fix(xo-server): reconcile RPU heartbeats left pending at boot (XO-2640)

* chore(CHANGELOG): add RPU observability entry (XO-2640)

* docs(rpu): document on-disk traces and heartbeat for troubleshooting

* refactor(xo-server): address RPU observability review feedback
2026-07-15 12:23:36 +02:00
Elise-F
a7ba7c6a89 feat(xo6): add VM duplicate action (#9580) 2026-07-15 12:16:34 +02:00
Sandrine Barrucand
25da6d4c4b fix(xo6/xolite): switch CPU and RAM panels (#10059) 2026-07-15 11:47:20 +02:00
amouillard-vates
8b78ab985d feat(xo6/site): add new VM button and connect pool to site tree actions (#10047) 2026-07-15 11:45:19 +02:00
Joris K
2bee8c279d feat(xo6): add possibility to create VIF (#9677) 2026-07-15 11:26:42 +02:00
Bastien Nollet
5de65620f1 fix(xapi-explore-sr): fix crash on unreferenced VDI (#10113)
Co-authored-by: Florent BEAUCHAMP <florent.beauchamp@vates.tech>
2026-07-15 11:20:33 +02:00
Olivier Lambert
b078d3d42d feat(xen-api): reconnect to a surviving pool member when the master is unreachable (#10016)
* feat(xen-api): reconnect to a surviving pool member when the master is unreachable

XO follows a pool master change only through HOST_IS_SLAVE redirection, which
needs the contacted host to be up and answering. That covers a graceful master
hand-over (the old master stays up as a slave and redirects us), but not the
case where the master host itself becomes unreachable while another host takes
over as master: HA automatic failover, or a manual emergency transition. The
stored URL then points at a host that no longer responds, login fails with a
transport error instead of HOST_IS_SLAVE, the existing retry never redirects,
and the connection stays stuck on the dead master.

Maintain a set of pool-member addresses (seeded from the configured target and
optional opts.candidateHostnames, refreshed from host.get_all_records on every
session open) and, when the current target is unreachable at the transport
level, probe the other members in parallel. The first to answer wins; if it is
a slave, its login redirects us to the current master through the existing
HOST_IS_SLAVE retry. XapiError responses (including auth failures) are not
failed over, so wrong credentials still fail fast.

* feat(xo-server): persist pool-member addresses so master failover survives a restart

The xen-api failover relies on an in-memory set of pool-member addresses, which
is lost when xo-server restarts: it then only knows the configured host. That is
exactly the address that is dead when XO runs on the very pool whose master just
failed and was HA-restarted onto a survivor, leaving XO unable to reconnect.

Persist the member addresses on the server record (Redis, JSON-encoded like the
existing error field), refresh them whenever the pool object is seen via the
xen-api candidateHostnames getter, and feed them back as opts.candidateHostnames
when (re)building the connection. After a restart the connection can then probe
the surviving members and reconnect to the new master.

* chore(changelog): reconnect to a surviving pool member on master failure

Add the user-facing changelog entry and list xen-api in the packages to
release for the master-failover reconnect feature.

* fix(xen-api): prune ejected hosts from pool-member failover candidates

host.get_all_records is authoritative current membership, so replace the
candidate set on refresh instead of unioning into it. An ejected host is
typically still alive as its own standalone master with the same
credentials, so leaving it in the set could make a failover probe
silently connect to the wrong pool. The currently-connected target is
preserved since host.address may differ from the address XO used to
reach it.

* fix(xen-api): harden surviving-member failover per review

- reject failover probes on sessionId-based connections (no reusable
  credentials to open a session on another host)
- bracket bare IPv6 addresses (host.address / HOST_IS_SLAVE) before using
  them as URL hostnames
- log the probe session out even when the host answers after the probe
  timeout, to avoid leaking a session
- abort the mid-session failover loop when the connection was torn down or
  replaced while waiting (e.g. user disconnected the server)
- xo-server: drop persisted pool-member addresses when the connection
  identity (host/credentials) changes, so a stale slave list can't send a
  failover to the wrong pool

See #10016
2026-07-15 11:04:16 +02:00
Bastien Nollet
913148ec2f chore(changelog): add previously unreleased package (#10106) 2026-07-13 10:48:01 +02:00
Mathieu
44e5ba4cf5 feat(rest-api): add PATCH /rest/v0/vdis/{id} (#9945) 2026-07-10 15:40:05 +02:00
Mathieu
db795ccfe2 feat(rest-api): ability to set transient for host.disable (#10040) 2026-07-10 15:33:08 +02:00
MlssFrncJrg
519b2bd7e1 feat(rest-api): add RBAC check for pool endpoints (#9891) 2026-07-10 15:27:26 +02:00
Grandalf
b5f5e39ddc feat(rest-api): add array type to FieldDefinition (#10055) 2026-07-10 14:33:00 +02:00
Alina D
f3f8519b7a fix(xo6): repair negative other value in backup repository dashboard (#10044) 2026-07-10 09:38:42 +02:00
Bastien Nollet
1d82c373da feat: release 6.6.2 (#10105) 2026-07-09 16:50:11 +02:00
Bastien Nollet
12b380e806 feat: technical release (#10103)
* feat(@xen-orchestra/fs): 4.9.2

* feat(@xen-orchestra/backup-archive): 1.0.4

* feat(@xen-orchestra/backups): 0.73.7

* feat(@xen-orchestra/proxy): 0.31.2

* feat(xo-server): 5.205.2

* chore(CHANGELOG): update next
2026-07-09 14:31:09 +02:00
Pierre Brunet
f8c9c66c40 fix(backups): remove warning for encrypted xva (size cannot be checked for encrypted data) 2026-07-09 12:00:48 +02:00
Pierre Brunet
1afa1e52ed fix(backups): transfer length and maxStreamLength in pipeline() 2026-07-09 12:00:48 +02:00
Florent BEAUCHAMP
b2a48ffacc fix(fs): compute the output stream precisely when encrypted (#10061)
* fix(fs): compute the output stream precisely when encrypted

the current encryption add 12 byte of initialization vector at
the start + 16 bytes of authentication tag at the end

thus, the destination stream can be bigger than the source stream,
and this length is used to compute the optimal chunk size for s3/
azure

from https://xcp-ng.org/forum/topic/12328/error-mirroring-full-backups-to-backblaze-b2

---------

Co-authored-by: Pierre Brunet <pierre.brunet@vates.tech>
2026-07-09 10:35:40 +02:00
Bastien Nollet
a3077cdb62 feat: release 6.6.1 (#10087) 2026-07-08 09:56:44 +02:00
Bastien Nollet
3ba9523dfe feat: technical release (#10084) 2026-07-07 14:37:05 +02:00
spacotte-vates
377581b44a fix(backups): add warn with debug for snapshot pileup issue (#10076) 2026-07-06 14:56:03 +02:00
Thomas Moraine
090ce7e721 docs(xo): refactor navbar navigation to create a unified documentation ecosystem (#10067)
* docs(xo): overhaul navbar to create a unified doc ecosystem

- Clean up the navbar by dropping the redundant GitHub link (retained in footer).
- Relocate "Blog" to the "What's new in XO 6" sidebar.
- Introduce direct navbar links for XCP-ng and Vates VMS.
- Rename "Documentation" to "Xen Orchestra".

This aligns the navigation layout across our three documentation sites, allowing readers to smoothly transition between ecosystems with a fully unified experience.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>

* docs(xo): move home link from header to footer

To make the navbar cleaner and make space
for the links to the other documentation websites
We're moving the Home link from the header to the footer
and renaming the link to 'About Xen Orchestra'.
Also, we're displaying the 'What's new in XO 6' section again
which now contains a link to the XO blog

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2026-07-03 10:30:40 +02:00
Thomas Moraine
d53dd3e990 docs(xo): import devops tools documentation from vates vms (#10062) 2026-07-03 09:24:44 +02:00
Thomas Moraine
1b0dbc7d3b docs(xo6): hide categories that dont contain any actual pages (#10066)
Recently we've introduced new sections for the xo6 category of the XO doc.
These categories were meant to be filled later with new pages.
The pages were created as placeholders, but didn't actually contain any text, just titles.
These made readers confused, so this commit
Hides the sections from the sidebar until we update them
With actual content.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2026-07-03 08:44:14 +02:00
Florent BEAUCHAMP
bb4daac07a fix(xo-server): store rrd as Float32 instead of string (#10039) 2026-07-02 15:57:43 +02:00
spacotte-vates
63f8d624fd fix(backups): improve resume of backup merge failure (#10053)
Introduced by #9765

When a backup merge fails on a VHD file, the footer might be missing. This caused a footer1 !== footer2 error and triggered a full backup while not deleting the previous chain and resulted in all further backup jobs to be displayed as failures even though the new backup chain was working properly.

The fix allows the cleanVm to resume the merge where it stops even if the VHD file footer is missing.
2026-07-01 17:01:38 +02:00
Alina D
373a4c8c6d fix(web-core): make VDI used space in progress bar appear correctly (#10027)
Used space progress bar now shows correctly filled at 100% instead of appearing half-filled

References issue :  XO-1696
2026-07-01 10:43:37 +02:00
Warren Postdam
1ec57392fc docs: update users to add passkeys auth (#9975)
Add configuration elements to allow FIDO2 (passkeys) administrators to configure
with XO by using `auth-oidc` plugin.
2026-07-01 10:24:55 +02:00
Joris K
376bb3603f fix(xo6): fixed an issue where the selected item in the treeview would scroll to the top (#10052) 2026-07-01 09:02:12 +02:00
Bastien Nollet
3bc70f657d feat(lite): 0.23.0 (#10050) 2026-06-30 16:50:07 +02:00
Bastien Nollet
f3b2ac5983 feat: release 6.6.0 (#10049) 2026-06-30 16:20:58 +02:00
Bastien Nollet
eb4c55d82c feat: technical release (#10045)
* feat(@vates/types): 1.28.0

* feat(@xen-orchestra/acl): 1.4.0

* feat(@xen-orchestra/backups): 0.73.5

* feat(@xen-orchestra/proxy): 0.31.0

* feat(@xen-orchestra/rest-api): 0.35.0

* feat(@xen-orchestra/web): 0.55.1

* feat(xo-server): 5.205.0

* feat(xo-server-sdn-controller): 1.4.0

* chore(CHANGELOG): update next

* clear changelog
2026-06-30 12:01:18 +02:00
spacotte-vates
d43ca09b8b feat(rest-api): add 7 rest actions on hosts (#9887) 2026-06-30 11:37:17 +02:00
Grandalf
3ad3ecb244 feat(sdn-controller): add patch routes for vif and network (#9936) 2026-06-30 11:29:29 +02:00
Bastien Nollet
7bbe139b36 fix(backups): fix memory error during delta replication (#10034) 2026-06-29 17:25:17 +02:00
spacotte-vates
235fc07404 feat(rest-api): backup-repository health/bench routes (#9847) 2026-06-29 17:12:51 +02:00
Bastien Nollet
26038fdaec feat: technical release (#10037)
* feat(@xen-orchestra/backups): 0.73.4

* feat(@xen-orchestra/web-core): 0.56.0

* feat(@xen-orchestra/proxy): 0.30.5

* feat(@xen-orchestra/vmware-explorer): 0.14.0

* feat(@xen-orchestra/web): 0.55.0

* feat(xo-server): 5.204.0

* feat(xo-server-netbox): 1.13.1

* feat(xo-web): 5.199.0

* chore(CHANGELOG): update next
2026-06-29 14:38:39 +02:00
Mathieu
4fe505b229 fix(web-core): required disappear after clicking on VtsSelect (#10032) 2026-06-29 14:04:20 +02:00
Sylvère
276c4e5f7e feat(lite): add storage tabs and read-only SR views (#10005) 2026-06-29 13:31:52 +02:00
Florent BEAUCHAMP
91badc593c fix(@xen-orchestra/proxy): invalid parameter in listVmBackups (#10035)
introduced by #9143
2026-06-29 13:01:45 +02:00
Pierre Brunet
130b1612e6 fix(xo-server): add safeguards to xo-server shutdown and update changelog 2026-06-29 10:55:29 +02:00
Pierre Brunet
e8c43dafce fix(s3): stop s3 agents properly after xo-server shutdown 2026-06-29 10:55:29 +02:00
Pierre Brunet
8e0c0e05c0 feat(xo-server-auth-ldap): support LDAP multidomain (#10015) 2026-06-29 10:49:09 +02:00
Enée DI IORIO
71d71b9742 feat(lite): use uiLoader instead of uiSpinner (#10023) 2026-06-29 10:35:45 +02:00
spacotte-vates
34051927a0 fix(encryption): handle missing xen-tools (#9987)
Introduced by 6f68b04

Improve missing xen-tools handling of encryption with better logic and logs.
2026-06-29 09:41:35 +02:00
Sandrine Barrucand
9e6ad45a79 feat(xo6): add possibility to migrate VDI on SR (#9973) 2026-06-29 08:07:21 +02:00
Sandrine Barrucand
52591ec65b feat(xo6/vm): add actions available on VMs tab (#10030) 2026-06-26 17:32:23 +02:00
Mathieu
bf1e992161 feat(rbac): add Storage administrator built-in role template (#9963) 2026-06-26 16:40:52 +02:00
vates-bot
29b9bc46e4 feat(web-stack/i18n): update Xen Orchestra 6 (#9998) 2026-06-26 14:49:43 +02:00
Florent BEAUCHAMP
9d470997e8 fix(xo-server,netbox): properly close hrp stream on error (#10026)
when hrp get a 404, the stream stay ready to be consumed, for example to get
more information on the error

but most of the usage won't handle this case, and will keep the stream in memory
(for example a failing XC patches check) leaking memory

This fixes the calls from xo, and also identifies and fixes the point where we
need body content on 4xx code

relevant code in hrp is here:
https://github.com/JsCommunity/http-request-plus/blob/master/index.js#L149
2026-06-26 14:09:49 +02:00
OlivierFL
836e06843d feat(xo-core): introduce new VtsCardObjectTitle component (#9755) 2026-06-26 12:12:56 +02:00
Joris K
5160ec4c7e fix(web-core/xo6): stream infra tree collections and flatten into a virtualized list (#9986) 2026-06-26 11:33:44 +02:00
Marie Garde
cc88cc395c feat(xo6/vdi): add export VDI action (#9983) 2026-06-26 11:33:14 +02:00
Enée DI IORIO
6ea64e9413 feat(lite): Use web-core's UiLogEntryViewer instead of xo-lite UiRaw (#9995) 2026-06-26 10:28:27 +02:00
Florent BEAUCHAMP
b440f41cd5 fix(xo-server): ignore template memory_static_min is it's less than actual vm memory 2026-06-25 16:29:46 +02:00
Florent BEAUCHAMP
7ea6a5c541 feat(xo-web): show status of previous transfer to help the 2 part transfer 2026-06-25 16:29:46 +02:00
Florent BEAUCHAMP
1987008db1 fix(xo-server): v2v should create a DVDRom is source has one 2026-06-25 16:29:46 +02:00
Florent BEAUCHAMP
4e1e1510e8 fix(xo-server): v2V not updating correctly other config
setting undefined is a no ops, we need to use null to clean a value

this don't have any visible or functionnal impact, but complexify
the build of a filter to look for any non completely migrated VM
2026-06-25 16:29:46 +02:00
Grandalf
e9a8ffe315 feat(xo-server): add esxi.importDisk (#10024)
* feat(xo-server): add esxi.importDisk
2026-06-25 16:17:43 +02:00
OlivierFL
8639e86227 fix: changelog (#10028) 2026-06-25 15:43:26 +02:00
Pierre Brunet
89e1b051cb feat: add failover URIs in ldap domains (#9961) 2026-06-25 14:09:03 +02:00
Florent BEAUCHAMP
c162839d4e docs(immutable-backups): document how to lift the immutability for one VM (#9974) 2026-06-25 11:57:31 +02:00
Enée DI IORIO
d73e9d5e94 chore(lite): remove unused component (#10011) 2026-06-25 10:55:20 +02:00
Sylvère
ab472af351 feat(web-core/web/lite): updated UiPanel to v5 (#9836) 2026-06-25 10:52:35 +02:00
Sylvère
01d8e21538 feat(xo6): warn and link to doc when traffic rules are not in the expected format (#10020)
Co-authored-by: Sylvere <sylvere.armange@vates.tech>
2026-06-25 10:06:35 +02:00
Bastien Nollet
bf2954edb0 fix(backups): uncaught VM error (#10021) 2026-06-25 10:06:19 +02:00
Enée DI IORIO
0fca9b2cde feat(lite): use vtsDivider instead of UiSeparator (#10017) 2026-06-25 10:05:46 +02:00
Alina D
8b9b99b68f feat(xo6): change redirect target (#9984) 2026-06-25 09:22:05 +02:00
Mathieu
c74a83ed06 fix(web): no active job instead no job (#10013) 2026-06-24 16:39:33 +02:00
Bastien Nollet
b96a6dc1e9 feat: technical release (#10022)
* feat(@xen-orchestra/fs): 4.9.1

* feat(@vates/generator-toolbox): 1.1.3

* feat(@xen-orchestra/disk-transform): 1.3.1

* feat(@vates/fuse-vhd): 2.1.3

* feat(xo-common): 0.10.0

* feat(xen-api): 4.7.8

* feat(@vates/types): 1.27.0

* feat(@xen-orchestra/acl): 1.3.0

* feat(@xen-orchestra/backup-archive): 1.0.2

* feat(@xen-orchestra/qcow2): 1.3.1

* feat(@xen-orchestra/xapi): 8.9.0

* feat(@xen-orchestra/backups): 0.73.3

* feat(@xen-orchestra/disk-cli): 2.1.2

* feat(@xen-orchestra/web-core): 0.55.0

* feat(@xen-orchestra/mcp): 1.4.1

* feat(@xen-orchestra/mixins): 0.20.1

* feat(@xen-orchestra/openflow): 0.1.4

* feat(@xen-orchestra/proxy): 0.30.4

* feat(@xen-orchestra/qa-test): 1.1.0

* feat(@xen-orchestra/rest-api): 0.34.0

* feat(@xen-orchestra/upload-ova): 0.1.8

* feat(@xen-orchestra/web): 0.54.0

* feat(xo-acl-resolver): 0.5.3

* feat(xo-server): 5.203.0

* feat(xo-server-audit): 0.15.1

* feat(xo-server-ipmi-sensors): 2.0.1

* feat(xo-server-load-balancer): 0.13.0

* feat(xo-server-netbox): 1.13.0

* feat(xo-server-openmetrics): 1.7.0

* feat(xo-server-sdn-controller): 1.3.3

* feat(xo-web): 5.198.0

* chore(CHANGELOG): update next
2026-06-24 10:18:29 +02:00
Sebastien Marie
a6f2a25aec docs: explain traffic rules migration path (from OpenFlow Protocol to XAPI Plugin) (#9999)
* fix(configuration.md): remove trailing spaces

* feat(sdn-controller.md): explain traffic rules migration path: from OpenFlow Protocol to XAPI Plugin

- add sdn-migration.py python script
- in configuration.md, refer to sdn_controller.md for changing `useDirectChannel`
- in sdn_controller.md, provide documentation on migration path
2026-06-24 08:53:34 +02:00
Sebastien Marie
8f31ff7481 feat(xo-server-sdn-controller): traffic-rules: check the configuration matches the rules format (#10000)
XCPNG-3260

report an error if we detect that the runtime configuration doesn't match the of-rules format stored in xapi database.

the admin is expected to ran migration script by hand to fix it.
2026-06-24 08:52:48 +02:00
Enée DI IORIO
d7bfb171fd feat(lite): Use web-core's UiCardTitle instead of xo-lite version (#9982) 2026-06-23 17:10:25 +02:00
Sylvère
fc45f3e59e feat(web-stack): extract SR types/utils to web-core (#10004) 2026-06-23 17:09:22 +02:00
Florent BEAUCHAMP
cecf1cb6bc feat(xoa): add an option to take a snapshot before upgrade (#9920) 2026-06-23 16:10:29 +02:00
Thomas Moraine
bf0e40977c docs(sdn_controller.md): mention that network-wide rules can now be created in the UI with XO6 (#10019) 2026-06-23 15:33:40 +02:00
Marie Garde
caa174c69c feat(xo6): vdi dedicated page (#9949) 2026-06-23 11:37:52 +02:00
Mathieu
304e219441 fix(rest-api/dashboard): count job sequence schedules for backup computation (#9997) 2026-06-23 10:24:57 +02:00
Enée DI IORIO
9c2739b6dd feat(lite): Use web-core's UiCard instead of xo-lite version (#9980) 2026-06-23 10:16:31 +02:00
Pierre Brunet
7a24dba9c1 fix(backup/cleanVM): avoid useless warning (#9996) 2026-06-23 10:08:32 +02:00
Mathieu
a5d410e0aa feat(xo-server-openmetrics): include XO tags as tags label (#9971)
Fixes #9628
Plane : XO-2244
2026-06-23 10:07:15 +02:00
Thomas Moraine
64401c4040 docs: make a more logical doc structure for xo6 (#9972)
This commit creates a new, refreshed tree structure
for the XO 6 documentation, which we intend
to be more helpful for readers and XO 6 users.
New sections are created, to be completed later.
Additionnally, the ACLv2/RBAC page has been
moved from the Support section to the Security section.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2026-06-23 09:41:13 +02:00
Pierre Briffaux
93909d9851 feat(xo6/rbac): add admin tab with user management menu (#9947) 2026-06-22 17:25:42 +02:00
Grandalf
5604289f3b test(sdn-controller): add tests (#9957)
Adds rest api endpoints testing for the sdn controller.
Don't forget to set the required SDN_CONTROLLER_VM_ID to a vm that has both a vif and a network.
Run @xen-orchestra/qa-test yarn qa:sdn-controller for testing.
2026-06-22 17:09:39 +02:00
Mathieu
cf569efff2 feat(rest-api): add PATCH /rest/v0/vifs/{id} (#9935) 2026-06-22 15:54:05 +02:00
Sandrine Barrucand
4bde431167 feat(xo6/traffic-rules): add delete button (#9956) 2026-06-18 12:14:56 +02:00
Mathieu
37868e3a74 fix(rest-api): fix vm dashboard replica detection (#9991)
introduced by 680b810886

See https://github.com/vatesfr/xen-orchestra/pull/9806/changes#diff-6f2227ada6bc2da3fd324691bb30b604b7c61a2cdbeebcad7969a8233cd4f0c4R394
2026-06-18 10:40:13 +02:00
Florent BEAUCHAMP
443de97c57 fix(qa-test): metadata test can run so fast there is a datetime collision (#9954) 2026-06-17 17:57:02 +02:00
vates-bot
9e39d0d534 feat(web-stack/i18n): update Xen Orchestra 6 (#9914) 2026-06-17 17:01:24 +02:00
Mathieu
f166037b4d docs(rest-api): update .USAGE.md (#9967)
* docs(rest-api): update .USAGE.md

* Mathieu.P's feedback
2026-06-17 15:19:00 +02:00
Alina D
60c97d3678 feat(xo6): add policy tag colors (#9979) 2026-06-17 15:08:53 +02:00
Joris K
62ba0a5a30 feat(web-core): update table header to remove icons (#9943) 2026-06-17 11:36:50 +02:00
Pierre Brunet
2a5c350a15 fix(disk-cli): update isDisk call parameters (#9988) 2026-06-17 10:55:34 +02:00
Pierre Brunet
4a8b29530b chore: fix changelog (#9993) 2026-06-17 10:15:02 +02:00
Pierre Brunet
9a710e5bf3 feat: update packages (#9969) 2026-06-17 10:02:00 +02:00
Sylvère
8eb6543905 feat(xo6): add the SR connect feature (#9877) 2026-06-16 11:52:29 +02:00
Alina D
b99f686d0c feat(xo6): update CPU labels to vCPU in VM system resource card (#9950) 2026-06-16 10:59:19 +02:00
Stephen Boyd
07b45e157d feat(xo-server-netbox): add option to ignore private IPs (#6306)
Optionally ignore RFC 1918 VM IPs during synchronization
2026-06-16 09:33:39 +02:00
Enée DI IORIO
3e7f003060 feat(lite/xoa-deploy): add confirmation before leaving deployment (#9893)
Add a message if you try to leave the page during XOA deployment.

Unfortunately, it is not possible to have a custom info box for the
`beforeUnload` event. So for homogeneity, `onBeforeRouteLeave` is
`window.confirm` too.
2026-06-16 09:27:00 +02:00
MlssFrncJrg
cf26d3f5b2 feat(rbac): create Administrator template role (#9885) 2026-06-15 15:50:09 +02:00
Florent BEAUCHAMP
60f59509a9 fix(backups): remove inspector (#9966) 2026-06-15 14:31:31 +02:00
Bastien Nollet
801a1f70fe feat(load-balancer): wrap migrations in XO Tasks for readability (#9909)
Use XO Tasks in the load balancer so users can see on the task page what the load balancer does. Now, it creates a task (with no progress) for each VM migration.

The task has a description explaining the reason of the migration, which can be accessed by opening the task log.

XO-992
2026-06-15 13:59:48 +02:00
Enée DI IORIO
b06779655e fix(web-core/VtsStateHero): behavior on mobile (#9363) 2026-06-15 13:46:08 +02:00
Grandalf
d1b99b9e86 feat(rest-api): add forgetRepository endpoint (#9846) 2026-06-15 10:59:09 +02:00
Mathieu
04deb9e328 feat(acl): add Network administrator built-in role template (#9952) 2026-06-12 15:47:22 +02:00
Nathanaël HANNEBERT
8c51de5043 fix(docs): typo preventing building of docs 2026-06-12 11:48:50 +02:00
Florent BEAUCHAMP
de5ea8a888 fix(backups): file level restore : omnibus fixes (#9776) 2026-06-12 11:40:16 +02:00
Marie Garde
e9b66fe607 feat(xo6): add column vif to table Vm > Network (#9959) 2026-06-11 17:03:46 +02:00
Sylvère
d5d9c00c70 feat(xo6): add the SR disconnect feature (#9856) 2026-06-11 16:04:53 +02:00
Joris K
ae2ef7a7d9 feat(xo6): update actions name to be more consistent (#9968) 2026-06-11 14:25:57 +02:00
Sylvère
899d3e27bb feat(xo6): add the SR delete feature (#9853) 2026-06-11 14:11:46 +02:00
Sylvère
632532f88a docs: update glossary (#9857)
Updated glossary with more acronyms and definitions.
2026-06-11 11:42:10 +02:00
Sylvère
cbf7da417f feat(web-core): updated UiModal to v3 (#9825) 2026-06-11 11:40:11 +02:00
Florent BEAUCHAMP
1f826f7d5d feat(qa-test): add minimal runner and mail report (#9866) 2026-06-11 11:07:37 +02:00
MlssFrncJrg
ae79d51d87 feat(rest-api): add RBAC check for VM endpoints (#9910)
* feat(rest-api): add RBAC check for VM endpoints
2026-06-11 10:33:12 +02:00
MlssFrncJrg
21f191b829 feat(rest-api): add RBAC check for VDIs endpoints (#9908)
* feat(rest-api): add RBAC check for VDIs endpoints
2026-06-11 10:27:00 +02:00
Elise-F
ba5b9ba7f9 fix(xo-lite): vue-router@5 breaking changes (#9951) 2026-06-11 09:49:39 +02:00
Alina D
88045806f0 feat(web-core/xo6): update connect/disconnect icons (#9942) 2026-06-11 09:02:12 +02:00
Pierre Brunet
1ba82a9304 fix(backups): remove warning for old vdis not removed yet (#9958)
* fix(backups): remove warning for old vdis not removed yet
2026-06-10 16:31:58 +02:00
Mathieu
d6d530d616 feat(xo6/lite): add ha_reboot_vm_on_internal_shutdown in pool's system (#9962) 2026-06-10 11:55:09 +02:00
Mathieu
c61083e59e fix(rest-api): vm export correctly handle zstd and gzip compression (#9960) 2026-06-10 10:55:13 +02:00
Pierre Donias
e376a0e102 feat(netbox): support Netbox v4.6.x (#9939)
Fixes #9818
See https://github.com/netbox-community/netbox/releases
2026-06-09 17:15:02 +02:00
Bastien Nollet
13e0cd6b79 fix(xo-server): guards against reading property of undefined (#9944) 2026-06-09 16:59:35 +02:00
Mathieu
3f9c8ecce0 fix(vates/types): remove duplicate entries (#9913)
Introduced by 544590a860
2026-06-09 14:04:10 +02:00
Pierre Brunet
7b40af7e86 feat(qcow2): fix invalid range error (#9948)
Reduce memory consumption up to 700MB / TB of data ( 300-350Mb per call of getBlockIndexes().length , called twice).

Ticket: 59102
Tested in client environment. Works for a 7TB import
2026-06-09 11:54:04 +02:00
Mathieu
d04421ed54 fix(xo-server): fix authorization unit test at startup (#9965) 2026-06-09 11:52:07 +02:00
Florent BEAUCHAMP
3500d6c6c1 fix(rest-api): properly handle unauthorized error (#9938) 2026-06-08 16:04:06 +02:00
MlssFrncJrg
442e63f716 feat(rest-api): add RBAC check for VDI-snapshots endpoints (#9906) 2026-06-08 15:14:22 +02:00
Grandalf
ed670fbe36 feat(ipmi-plugin): Add a default value for outlet temp to the Dell preset (#9884)
feat(ipmi-plugin): Add a default value for outlet temp to the Dell preset
2026-06-08 15:01:36 +02:00
Florent BEAUCHAMP
59780ef834 feat(qa-test): user url instead of fs (#9898)
this enable the test to run using from and to shared storage ( nfs/samba/s3/azure)

the local path is only used for export tests

tested with a remote garage (object storage) provider, full suite is passing
2026-06-08 14:55:59 +02:00
spacotte-vates
53be744746 feat(rest-api): add add_host route to pools (#9876) 2026-06-08 11:29:31 +02:00
Mathieu
7adcf9afd3 feat(xo-server): support bundle in featureCheck (#9769) 2026-06-08 10:16:03 +02:00
MlssFrncJrg
7f693de5a5 feat(rest-api): add RBAC check for PBD endpoints (#9888)
* feat(rest-api): add RBAC check for PBD endpoints
2026-06-08 09:51:46 +02:00
MlssFrncJrg
fc67f88fcb feat(rest-api): add RBAC check for SRs endpoints (#9896)
* feat(rest-api): add RBAC check for SRs endpoints
2026-06-08 09:44:21 +02:00
Pierre Donias
81b14af429 fix: filter syntax doc link (#9929) 2026-06-08 09:30:35 +02:00
Florent BEAUCHAMP
8939a2deeb feat(backups): add progress to incremental mirror (#9901) 2026-06-05 16:00:02 +02:00
Florent BEAUCHAMP
d8094a71e2 fix: normalize packages (#9917)
* fix: normalize packages

no changelog since it does not need any release

* put back lost chanegs
2026-06-05 14:12:57 +02:00
Florent BEAUCHAMP
c070df74ed test(backups): add e2e test for metadata and pool backups (#9864)
add end to end test for pool metadata and xo config backup
2026-06-05 14:08:40 +02:00
Sebastien Marie
2b9524e8d5 fix(xo-server-sdn-controller): apply/clean network rules on VIF update (#9933)
when a VIF is unplugged, network rules applied to the VIF aren't properly removed.
2026-06-05 14:07:38 +02:00
Pierre Briffaux
199651984b feat(xo-core): create drawer component (#9555) 2026-06-05 11:32:11 +02:00
Grandalf
aa7e1bb44b perf(sdn-controller): replace xapi.objects.all with specified object types (#9886) 2026-06-05 10:54:47 +02:00
spacotte-vates
ebdc6713c0 fix(qcow2): fix init map error with large Qcow2 disks (#9940) 2026-06-05 10:54:12 +02:00
Bastien Nollet
3ba207e1f0 fix(xo-web): fix array handling in job UI (#9928) 2026-06-04 16:06:55 +02:00
Grandalf
ebb337b786 feat(rest-api): add RBAC check for vif endpoints (#9889) 2026-06-04 15:36:33 +02:00
MlssFrncJrg
f4a7b708c1 feat(rest-api): add RBAC check for VBDs endpoints (#9904) 2026-06-04 15:19:27 +02:00
spacotte-vates
5a88040318 feat(rest-api): add POST and PATCH backup repository endpoints (#9852) 2026-06-04 15:10:20 +02:00
Enée DI IORIO
f0fe294628 feat(lite): Use web-core's UiTitle instead of xo-lite version (#9869) 2026-06-04 12:59:21 +02:00
Mathieu
e6443edb6e fix(qa-test): fix NodeJS version in README (#9930) 2026-06-02 09:32:40 +02:00
Pierre Brunet
daefcab0f2 chore: move latest tag (#9932) 2026-06-01 13:48:18 +02:00
Pierre Brunet
c39b9e8760 feat: release 6.5.1 (#9931) 2026-06-01 13:37:33 +02:00
Pierre Brunet
e822276d62 feat: technical release (#9927) 2026-06-01 11:31:40 +02:00
Pierre Brunet
c792d1e1dc chore: update packages to release (#9926) 2026-06-01 11:16:51 +02:00
Florent BEAUCHAMP
4552fb9edf fix(backups): disable orphan dir cleanup
this feature can lead to more cleanup than needed, we will enable
if again when all the side effects are handled, after an extented
real world usage
2026-06-01 10:56:35 +02:00
Florent BEAUCHAMP
ecceb7c0b8 fix(backups): backup cleanup removed to many files when listing fails 2026-06-01 10:56:35 +02:00
Mathieu
f875ed5264 docs(mcp): require Xen Orchestra 6.5 or later (#9922)
* docs(mcp): require Xen Orchestra 6.5 or later

* docs(mcp): match warning/note admonition style
2026-06-01 09:29:21 +02:00
Florent BEAUCHAMP
79b87855d9 fix(xo-server): fix xo xcp ssh path and help (#9919) 2026-05-29 16:40:40 +02:00
Florent BEAUCHAMP
014fcb94b3 fix(backups): transfer size (#9900)
this is an async bug leading to the full size not really sumed, thus
depending on the async and concurrency leading to a size 0 even if another
disk has data
2026-05-29 16:04:08 +02:00
laszlobortel
16bf4400ba fix: correct 2 typos (#9873)
* Update esxi.mjs

* Update importVdi.mjs

Correct typo

* Update importDisksfromDatastore.mjs

Correct typo

* Update CHANGELOG.unreleased.md

Updated list of changed packages
2026-05-29 15:57:51 +02:00
Pierre Brunet
dec0cf7943 fix: bump lite version (#9916) 2026-05-28 16:14:36 +02:00
Pierre Brunet
315686678a feat(lite): 0.21.0 (#9915) 2026-05-28 16:02:11 +02:00
Pierre Brunet
2b887158ba feat: release 6.5.0 (#9912) 2026-05-28 15:31:56 +02:00
Florent BEAUCHAMP
51546d475f docs(immutable-backups): fix and improve the documentation (#9890)
better reflect the new implementation of immutable backup settings and troubleshooting in the doc
2026-05-28 14:53:41 +02:00
Pierre Brunet
0f29421627 feat: technical release (#9907) 2026-05-28 11:11:08 +02:00
Grandalf
781c08016f feat(acls-v2): add VMS_ADMINISTRATOR template (#9845) 2026-05-28 10:54:58 +02:00
Grandalf
5c157c1367 feat(xo-server): add date and time to exported config filename (#9859) 2026-05-28 10:32:19 +02:00
Mathieu
5089be9998 feat(rest-api/vm): add PATCH /vms/{id} to partially update a VM (#9835) 2026-05-28 10:17:15 +02:00
vates-bot
5c555170f7 feat(web-stack/i18n): update Xen Orchestra 6 (#9780) 2026-05-28 08:53:35 +02:00
Mathieu
40e944c901 fix(docs): wrap {master, satellite} in backticks on xo5/advanced page (#9902) 2026-05-28 08:42:07 +02:00
Elise-F
75b61b84c5 feat(xo6): add general tab vif page (#9831) 2026-05-27 17:10:16 +02:00
Joris K
f0aa3df923 feat(xo6): add possibility to create a VDI (#9848) 2026-05-27 16:38:06 +02:00
Elise-F
00844c66d9 feat(xo6): creating vif page with traffic rules table and form to add new traffic rule from VIF (#9837) 2026-05-27 16:31:19 +02:00
Pierre Brunet
1f243bf90e feat: update http-server-plus (#9899) 2026-05-27 16:05:43 +02:00
Pierre Brunet
b3d9dc6d0c feat: technical release (#9897) 2026-05-27 15:16:53 +02:00
Florent BEAUCHAMP
405724eacb fix(qa-test): remove rebase issue and cleanup (#9894)
introduced by #9863
also use rest api for stop
2026-05-27 14:38:12 +02:00
spacotte-vates
20222f7051 fix(encryption): fix 401 issue caused by key deletion before set (#9892) 2026-05-27 14:37:33 +02:00
Florent BEAUCHAMP
dae64be80b fix(backups): omnibus and e2e test improvement (#9863)
* fix(backups): tolerate ENOENT when deleting missing backups

* fix(xo-server): propagate content-length in VM import

* fix(qa-test): retry REST API calls on transient 401

* fix(qa-test): harden cleanup against stale cache files

* fix(qa-test): fail fast on misconfigured test environment

Assert that source and mirror repositories are distinct before running
any backup steps, giving an actionable error when both env vars resolve
to the same remote ID. Also improve the mkdir failure message in the
export test to include the exact path and the remediation command.

* feat(qa-test): make source vm runs to ensure there is content to transfer

* fix: harden job deletion

* fix(qa-test) don't use a deprecated route for authent

* fixup! feat(qa-test): make source vm runs to ensure there is content to transfer

* fixup! fix(backups): tolerate ENOENT when deleting missing backups

* fixup! fix(backups): tolerate ENOENT when deleting missing backups
2026-05-27 09:27:12 +02:00
Mathieu
fc4d34b354 feat(openmetrics): expose XOSTOR metrics for Grafana dashboards (#9849)
Implements XO-2155. The OpenMetrics plugin now ships XOSTOR-specific metrics so operators can build a Grafana dashboard for XCP-ng Hyperconverged Infrastructure.

All metrics use the xcp_xostor_* prefix:

    Cluster status: xcp_xostor_up, xcp_xostor_node_status (master / satellite roles + raw state from linstor-manager.healthCheck), xcp_xostor_resource_total.
    Storage pool health: xcp_xostor_resource_state_count — replica counts per disk-state (UpToDate, Inconsistent, Outdated, Diskless, Unknown), aggregated per cluster.
    Performance filter: a new sr_type label on every SR-tagged metric (xcp_host_disk_iops_*, xcp_host_disk_throughput_*, latency, xcp_sr_*, xcp_vdi_*, xcp_vm_disk_*). Dashboards can filter sr_type="linstor" without changing existing queries.
    Active alarms: xcp_xostor_alarms_count aggregated by alarm_name (ALARM, BOND_STATUS_CHANGED, MULTIPATH_PERIODIC_ALERT) and target_type (sr / host).
    Disk SMART: xcp_xostor_disk_smart_status per (host, device) with the overall-health string in a status label, sourced from smartctl.py.
    Pending updates: xcp_xostor_package_update_available for xcp-ng-linstor, linstor-satellite, linstor-controller, xcp-ng-release-linstor, and xcp-ng-xapi-plugins.

Each collector caches at its own TTL (60 s for cluster and alarms, 5 min for SMART, 1 h for updates) and exposes an xcp_xostor_<feature>_up gauge so silent collection failures still show up on dashboards. A TtlCache<T> helper encapsulates the cache-plus-coalescing pattern shared by the three caching collectors.
2026-05-26 17:57:40 +02:00
Mathieu
5195018361 feat(mcp): whitelist REST endpoints by x-mcp-exposure extension (#9875)
Replace the hard-coded CONFIRM_PATTERNS list in the MCP server with a per-endpoint exposure decision carried by an x-mcp-exposure OpenAPI vendor extension. Each REST endpoint now declares its MCP exposure (allow / confirm / deny) with @Extension('x-mcp-exposure', ...) next to its HTTP method decorator. A new local ESLint rule mcp/require-mcp-expose makes the decision mandatory: any new @Get/@Post/@Put/@Patch/@Delete method without an exposure tag fails lint.

The MCP parseSwagger now reads the extension and applies a default-deny policy:

    'allow' → exposed as a query/action tool
    'confirm' → exposed only when XO_MCP_ENABLE_ACTIONS=1, with confirm-token round-trip
    'deny' / absent → never exposed

Defense in depth is preserved: route-filter regex (binaries, streams, stats) is applied before the exposure check, so a binary endpoint mistakenly tagged 'allow' still stays out of the MCP surface. The confirm_token UUID + TTL machinery in risk-config.mts is untouched.

All 248 endpoints across 34 controllers have been annotated with conservative defaults (GET → allow, destructive POST/DELETE/PUT/PATCH → confirm, binary exports → deny).
2026-05-26 17:50:09 +02:00
Joris K
a9f8561cdd feat(xo6): add possibility to revert a snapshot (#9862) 2026-05-26 16:38:05 +02:00
Elise-F
8d318e8eb2 feat(xo6): add possibility to add new traffic rule from pool (#9809) 2026-05-26 16:24:44 +02:00
Mathieu
406a4aa00a feat(mcp): add global kill-switch (#9860)
Adds a server-side kill-switch for MCP. Setting [mcp] enabled = false in xo-server's config blocks every MCP connection globally
2026-05-26 16:21:22 +02:00
Mathieu
9abb66b594 fix(rest-api): post vdi other_config optional (#9883)
* fix(rest-api): post vdi other_config optional

introduced by 0bf0a46
2026-05-26 14:48:36 +02:00
Florent BEAUCHAMP
b17f43d676 fix(xo-web): ignore the number of snapshot of replicated VM in the health dashboard (#9868)
the number of snapshots will be as high as the retention selected, but this is not really
an issue since those VM will be run only when a disaster occurs. In this case, degraded
performance is an accepted tradeoff
2026-05-26 10:55:49 +02:00
Florent BEAUCHAMP
59a04d71a6 fix(xo-server): really use @vates/http-request-plus (#9842)
introduced by c6ccd987e2
2026-05-26 10:38:09 +02:00
spacotte-vates
fdcc999f66 fix(backups): fix out_of_range -4 merge error (#9782)
Introduced by #9300

Ticket #57310

Fix OUT_OF_RANGE error occurring when resuming failed merge.
2026-05-26 09:56:54 +02:00
spacotte-vates
1d9a6ac17f fix(xo-server): fix wrong boot order when hvm template has vdis (#9867)
Introduced by 7a8ca

When creating an HVM VM from a template that already has a disk attached, the boot order was being incorrectly modified to put the network first, resulting in the VM attempting a PXE boot before eventually falling through to the disk.
The boot order was modified whenever vdis and existingVdis were both empty, since no new disk needs to be provisioned. This caused network to be put first in the boot order regardless of whether a network install was actually requested.

Boot order modification now depends exclusively on installMethod:

    Network: network (n) moved to the front of the string, this is unchanged.
    CD: DVD-drive (d) moved to the front of the string, this is a fix to another issue where HVM + CD installs did not update the boot order string at all.
    Everything else: String is unchanged, preserving the template's original order

Also, previously creating a diskless HVM VM with no install repository would get the network moved to the front of the boot order, that implicit behavior is gone, now diskless VMs now inherit the template's boot order.
This means that if in the template boot order, the network is last, the BIOS will try to find the disk and the cd first before trying the network. This is acceptable as these steps are fairly short.
2026-05-26 09:44:26 +02:00
Pierre Brunet
a467f12526 fix(backup): add warning if xva is missing and update tests (#9870) 2026-05-26 09:20:37 +02:00
Joris K
5811d12ed4 feat(xo6): add possibility to attach a VDI (#9772) 2026-05-22 17:51:40 +02:00
OlivierFL
94bc7a6e03 fix(xo6): route-map incorrectly formatted by Prettier on commit (#9881) 2026-05-22 17:37:45 +02:00
Mathieu
18556901d9 fix(rest-api): rename acl tag into rbac (#9874) 2026-05-22 17:23:38 +02:00
Pierre Brunet
598bb79edb fix: bump dependants to backup-archive (#9882) 2026-05-22 15:10:16 +02:00
Pierre Brunet
bb215d1ecc chore: revert backup-archive version (#9880) 2026-05-22 11:54:20 +02:00
Pierre Brunet
082ab9cf41 feat: technical release (#9878) 2026-05-22 10:34:38 +02:00
OlivierFL
e3a58ed351 chore(xo-core/xo-6): introduce form validation (#9630) 2026-05-20 14:47:32 +02:00
Mathieu
a3d8ab8dbb feat(rest-api): add RBAC check for host endpoints (#9840)
RBAC for 
  - `/hosts/:id/actions/disable`  
  - `/hosts/:id/actions/enable` 
  - `/hosts/:id/actions/management_reconfigure`
2026-05-20 13:12:00 +02:00
Florent BEAUCHAMP
680b810886 feat(backups): bi directional replication (#9806)
following the symmetrical backup PR

reuse any snapshot in common between the source and destination if 
* using incremental replication
* only one target
 * a common snapshot exists between source and destination , even from another job, based on the CONTENT_KEY

The same rule of reuse the existing VM target / create a new one of symmetrical backups applies

if the destination don't have any data between the snapshot and the active disk 
    update an existing VM 
else create a new VM but chain disks
2026-05-20 12:33:34 +02:00
spacotte-vates
a0a302430d fix(encryption): fix orphaned entry issue (#9854)
When a Redis entry has an ID but no corresponding data key, undefined was returned which caused errors when new data was added.

Error case was the configuration of a plugin failing to save.
2026-05-20 11:16:47 +02:00
Grandalf
7c9c7efb77 feat(xo-server-ipmi-sensors) : Migrated the old ipmi feature to a new plugin and added user defined sensors (#9724)
Move ipmi sensor discovery to it's own plugin and removed old logic, ships with a default configuration with the same behaviour as the old system.
The plugin can be configured via the UI, accepts an exact match for the vendor and regexes for the ipmi matching.

Tested via unit tests.

Tested with R620-L1 host on xo-lab by adding and removing Dell from configured vendors
2026-05-20 10:58:53 +02:00
Pierre Brunet
60fbce0df9 fix(backup-archive): resolve xva path relative to backup directory (#9865)
Path needs to be relative, not absolute in metadata

introduced by #9765
2026-05-20 10:48:43 +02:00
Florent BEAUCHAMP
5e717b6e04 fix(qa-text): better logs, better env , less magic (#9833)
- Add `getRequiredEnv()`: replaces all `process.env.X || 'default'` fallbacks
  with a hard throw so a missing or typo'd .env key fails immediately with a
  diagnostic message instead of silently operating on wrong defaults
- Add `logSetup.js`: dual-transport logging — console filtered by $DEBUG,
  plus a full debug-level file in /tmp for post-mortem inspection
- Add `assertRepositoryMatchesConfig()`: fails fast when a named repository
  in XO points at a different path than the one declared in .env
- Make `getAllowedCleanupPaths()` lazy so env vars are read after .env is
  loaded, not at ESM module evaluation time
- Pass full error objects (not `.message`) to all structured log calls
- Add retry logic on flaky operations and surface more detail on failures
- Fix: only purge non-empty backup repositories
- Fix: backup jobs no longer skipped under some conditions
- Fix: missing `backuprepository.delete` function
- Add `.USAGE.md` and update `README.md`
2026-05-19 16:12:33 +02:00
Florent BEAUCHAMP
ed558e9854 fix(xo-server): ssh cli tool script name (#9861) 2026-05-19 15:49:37 +02:00
Florent BEAUCHAMP
8a51c621da feat(xo-server): ssh tool to connect to hosts (#9844)
add xo-server-ssh executable that ease a connection to the hypervisors

Without parameters it list the pool uuid/master ip
With a pool uuid parameter it opens a sshg connection to the pool master 
With a pool uuid and ip, it will reuse the pool credentials to connect to the slave host
2026-05-19 15:41:37 +02:00
Enée DI IORIO
f464803053 feat(lite): create robots.txt (#9858) 2026-05-19 14:12:05 +02:00
Mathieu
09c352244e feat(mcp): support HTTP_PROXY, HTTPS_PROXY, NO_PROXY env vars (#9820)
* feat(mcp): support HTTP_PROXY, HTTPS_PROXY, NO_PROXY env vars

* chore(changelog): mention MCP proxy env var support

* refactor(mcp): delegate proxy env handling to undici
2026-05-19 12:06:21 +02:00
Mathieu
ec365231dd fix(@vates/types): explicit XoUser permission type (#9829) 2026-05-19 11:51:13 +02:00
Laudin Alessandro Molina Troconis
e0ae3db341 fix(docs): formatting and minor grammar errors in CONTRIBUTING.md (#9827) 2026-05-19 11:45:01 +02:00
spacotte-vates
16d1c7ce9d feat(rest): add revert snapshot route (#9788) 2026-05-19 10:53:05 +02:00
MlssFrncJrg
64422066c2 feat(@xen-orchestra/rest-api): add delete SR (#9464) 2026-05-18 17:06:57 +02:00
Pierre Brunet
abbdfe6cb7 fix(xo-web): update sanitize-html (#9851)
dependabot alert : https://github.com/vatesfr/xen-orchestra/security/dependabot/484
2026-05-18 16:19:13 +02:00
Joris K
4e1c8b7188 fix(xo6/tasks): issue with task type and size when accessing backup runs (#9841) 2026-05-18 16:06:36 +02:00
Pierre Brunet
100f11308a refactor(backups): rewrite and test cleanVm (#9765)
the cleanVm script was a monolith in charge of cleaning the backup, 
detecting any corrupted data, deleting the irrecoverable one, removing
and merging older backups 

this PR do a complete rewrite, using the RemoteDisk abstraction and add
tests
2026-05-18 15:15:56 +02:00
spacotte-vates
d810ea0c46 feat(rest-api): refactor registerRestApi (#9553) 2026-05-13 17:53:15 +02:00
Mathieu
2b08359bcf fix(xo-server): fix read-only role by adding acl resources (#9839) 2026-05-13 15:00:21 +02:00
Thomas Moraine
0ede983c61 docs: add TOTP setup instructions to user documentation (#9843)
Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2026-05-13 14:56:27 +02:00
OlivierFL
8c00ccefd4 chore(xo-stack): update Vite, Typescript and Vue related libs (#9703) 2026-05-13 09:49:29 +02:00
spacotte-vates
6f68b04f7e feat(xo-server): encrypt/decrypt credentials in redis (#9735)
Implements encryption and decryption feature for Redis content with migration.
2026-05-12 16:29:34 +02:00
Pierre Brunet
ee53cd0723 fix(xo5/new-job): fix cpuMask options while using vm.set (#9823) 2026-05-12 15:25:10 +02:00
Grandalf
5985ac63f2 docs: added documentation for traffic rule REST endpoints (#9816) 2026-05-11 16:27:05 +02:00
Pierre Brunet
c89c184073 feat(fs): add custom user-agent in s3 client (#9815)
clientConfig:
  apiVersion: '2006-03-01',
  customUserAgent: [ [ 'xen-orchestra-fs-4.8.0' ] ],
2026-05-11 16:02:25 +02:00
Pierre Donias
407432b4e6 feat(xo-web/config): hide passphrase by default when exporting config (#9824)
* feat(xo-web/config): hide passphrase by default when exporting config

* PR
2026-05-11 11:05:35 +02:00
Pierre Donias
acdfcb4235 fix(xo-server/config): typo in comment of ignoreVmSnapshotResources field (#9822) 2026-05-11 11:03:41 +02:00
Enée DI IORIO
070a3da7e2 feat(web-core): Update UiChip component to V8 (#9491) 2026-05-11 10:47:35 +02:00
Enée DI IORIO
7a69b2c09a feat(web-core): Update UiTag to VtsTag and add tertiary (#9811) 2026-05-11 10:20:08 +02:00
Florent BEAUCHAMP
40eab4f4e7 feat(xo-web/xo-server): import qcow2 disk from form (#9817) 2026-05-11 08:19:01 +02:00
Julien Fontanet
c5b7e7d2cb fix(complex-matcher): avoid array allocation in isRawString (#9813) 2026-05-07 17:02:25 +02:00
Thomas Moraine
fd9e271139 docs(backup_reports.md) document the transport-nagios plugin (#9819)
The transport-nagios plugin lets users monitors backup jobs from the Nagios interface.
This was previously documented only in a Xen Orchestra blog post.
This commit completes the existing Backup reports page, by explaining how
to get backup reports in Nagios, using that plugin.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2026-05-07 16:57:47 +02:00
Grandalf
008ec6ed6a feat(rest-api): add support for txChecksumming and rateLimitting on V… (#9793) 2026-05-06 19:16:22 +02:00
Florent BEAUCHAMP
2791a8c42d feat(qa-test): console.log=>xen-orchestra/log (#9810) 2026-05-06 16:50:32 +02:00
Enée DI IORIO
d4f1798530 feat(web-core/UiInfo): upgrade UiInfo to v6 (#9773) 2026-05-06 14:43:09 +02:00
Enée DI IORIO
7ef3dd3bbe feat(web-core/UiLink): upgrade UiLink to v6 (#9784) 2026-05-06 14:21:31 +02:00
Bastien Nollet
7e144234b9 feat: release 6.4.1 (#9808) 2026-05-05 12:30:45 +02:00
Bastien Nollet
b4f0e5f64d feat: technical release (#9807) 2026-05-05 10:42:40 +02:00
Mathieu
93e38c0280 fix(rest-api): early return for admin user in acl check (#9798)
* fix(rest-api): early return for non admin user in acl check
2026-05-05 10:09:10 +02:00
Sebastien Marie
93aba2dcc7 xo-server-sdn-controller: small cleanup (#9794)
This PR contains several commits to do some code cleanup in xo-server-sdn-controller.
They doesn't belong to any specific tasks, but it was done while working in the code.
They will be better upstreamed than in my local worktree.

    unify createLogger inside xo-server-sdn-controller package : it simplifies filter usage in configuration to have debug output of the sdn-controller
    in openflow-plugin.js, avoid using log.error() for a not fatal error but use log.warn(). reorder a bit the throw code to make it more readable (the error is throw only if no plugin call successed)
2026-05-05 10:04:39 +02:00
Florent BEAUCHAMP
80ba88bb0e fix(immutable-backups): use date in filename nstead of modified time to lock/unlock + fixes
Fixes: 
* better support of localized systems
* types
* add a retry on chattr error, selinux and / or xfs seems to need a little more time before locking it
* force a complete check on startup to remove immutables files that should not be froma previous run 
* don't try to lock deleted files
2026-05-05 10:00:16 +02:00
OlivierFL
ef1fe13667 feat(xo-stack): update system pages layout (#9746) 2026-05-05 09:26:19 +02:00
Thomas Moraine
225d1699e8 docs(sdn_controller.md): openSSL 3 + sdn upgrade path: fix error in code sample (#9805)
docs(sdn_controller.md): fix error in code sample

As reported by Jon Sands, the previous version of the code sample showed the script executing line by line. It works as expected after removing the -xe

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2026-05-05 08:34:21 +02:00
Thomas Moraine
0338013220 docs(v2v migration guide): Highlight link to V2V troubleshooting doc (#9797) 2026-04-30 18:28:05 +02:00
Thomas Moraine
893b229265 docs(aclv2): fix broken links to and from the new ACLv2 page (#9796)
docs(aclv2): fix broken links

The ACLv2 page was created and merged at roughly the same time we split the documentation pages into two sections (XO6 and XO5), so the change in file structure broke some links to and from that ACLv2 page.

This commit fixes the links so that the Docusaurus build can complete without errors.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2026-04-30 10:34:47 +02:00
Thomas Moraine
598c71ca3c docs(xo): create a dedicated xo6 doc section (#9730)
XO6 has become the default interface for Xen Orchestra,
but not all features have been ported to XO5 yet
and they don't work the same way.
To make the documentation easier to maintain
and prevent confusion,
we're moving the current pages to a new section called 'XO 5'
and create a new section for XO6-specific documentation. The XO 6 section will be completed gradually.
On top of that, this commit updates internal links and redirections
to adapt to this new doc architecture.
Finally, most of the pages in the Support category
have been moved to a new section called General support,
since these pages apply the same way to XO 5 and XO 6.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2026-04-30 09:48:45 +02:00
Mathieu
a04c5d671f feat(acl-v2): ACL v2 documentation (#9795)
#9754 was merged into the wrong branch
2026-04-30 09:34:21 +02:00
Bastien Nollet
13f987ff3d feat(lite): 0.21.0 (#9792) 2026-04-29 17:41:36 +02:00
Bastien Nollet
ad85a881dd feat: release 6.4.0 (#9791) 2026-04-29 16:54:08 +02:00
Grandalf
b0c4b7c4cc chore(docs): update installation command to include libfuse2 (#9751) 2026-04-29 16:26:19 +02:00
Bastien Nollet
2f1155af78 chore(changelog): forgotten package (#9790) 2026-04-29 16:24:49 +02:00
Bastien Nollet
81f83d6f22 feat: technical release (#9789)
* feat(@xen-orchestra/web): 0.49.0

* feat(xo-server-sdn-controller): 1.3.1

* chore(CHANGELOG): update next
2026-04-29 14:50:23 +02:00
Pierre Briffaux
c388d998d8 fix(vm/console): fix VNC reconnection causing focus loss (#9727) 2026-04-29 14:21:41 +02:00
Elise-F
b6a303e02a fix(xo6): remove dev routes from prod (#9787)
Introduced by: #9747
2026-04-29 14:07:53 +02:00
Joris K
a1f660d31b feat(xo6): add possibility to connect or disconnect a VBD (#9726) 2026-04-29 13:54:16 +02:00
Pierre Briffaux
c632fd2032 feat(xo6/vm/new): removed new VM form alert (#9757) 2026-04-29 13:46:45 +02:00
spacotte-vates
cfb91c8a1c fix(sdn-controller): fix invalidParameters import (#9786)
Introduced by 1f0a3d8
2026-04-29 11:30:22 +02:00
Bastien Nollet
33b9106da9 feat: technical release (#9785)
* feat(@vates/types): 1.24.0

* feat(@xen-orchestra/acl): 1.0.0

* feat(@xen-orchestra/backups): 0.72.1

* feat(@xen-orchestra/web-core): 0.51.0

* feat(@xen-orchestra/mixins): 0.20.0

* feat(@xen-orchestra/proxy): 0.30.1

* feat(@xen-orchestra/rest-api): 0.30.0

* feat(@xen-orchestra/web): 0.48.0

* feat(xo-server): 5.200.0

* feat(xo-server-sdn-controller): 1.3.0

* chore(CHANGELOG): update next
2026-04-29 11:00:24 +02:00
Florent BEAUCHAMP
12fb701686 fix(backups): handle diskless vm snapshot retention (#9758)
* fix(backups): handle diskless vm snapshot retention
2026-04-29 10:25:23 +02:00
Joris K
b83a7f9758 feat(xo6): add possibility to connect or disconnect a VIF (#9756) 2026-04-29 10:20:42 +02:00
Grandalf
95b9d28e6a feat : Make "allow = false " accepted in the traffic rules Rest API (#9783) 2026-04-29 09:42:29 +02:00
Bastien Nollet
3d1df14373 fix(backups): missing logs (#9781)
Fix some types of backup and restore logs not being returned by getBackupNgLogs
Make log garbage collector treat restore logs as backup logs
2026-04-29 09:21:52 +02:00
Elise-F
0eb5c2fb65 feat(xo6): implement security tab on pool to display traffic rules (#9770) 2026-04-29 09:13:00 +02:00
Florent BEAUCHAMP
c998f99e9d fix(proxy): improve error to better differentiate between source error and import error (#9775) 2026-04-28 16:49:59 +02:00
MlssFrncJrg
ec1ebd1687 feat(xo-server): add DNS-01 challenge support (#9592) 2026-04-28 16:40:12 +02:00
Grandalf
c868b788fb feat(rest-api): add and remove vif rules routes (#9759) 2026-04-28 15:39:56 +02:00
Florent BEAUCHAMP
144d95aac7 fix(backups): failed cleanVm test (#9778)
- ancestor (blocks 1, 3) merged into child (blocks 1, 2) -> 3 blocks
 - cleanAncestor merged into clean -> 0 blocks
 grandchild is not merged so its size is not counted here
2026-04-28 14:40:41 +02:00
OlivierFL
781e8a357f feat(xo-6/network): implement network deletion (#9714) 2026-04-28 11:59:24 +02:00
Julien Porschen
a4382b7687 fix(xo-server): display of RHEL 10 icons in vm list (#9766)
os_version.distro changed from "rhel" to "red hat enterprise linux" since version 10
2026-04-28 11:32:55 +02:00
jhoustek
7378bde4a8 fix(backups): record actual incremental VHD size (#9637)
* fix(backups): record actual incremental VHD size
* fix(backups): record merged VHD size from merge result
* fix(backups): only rewrite metadata after actual merge
* fix(backups): record actual incremental VHD size
2026-04-28 11:28:12 +02:00
spacotte-vates
1f0a3d8115 feat(rest-api): add and remove network rules routes (#9448) 2026-04-28 11:27:22 +02:00
vates-bot
7f1b8949cb feat(web-stack/i18n): update Xen Orchestra 6 (#9729) 2026-04-28 11:23:59 +02:00
Mathieu
71b2e373ab feat(mcp) : openapi bootstrap (#9641)
* refactor(mcp): split monolithic index.mts into modular file structure

* feat(mcp): add markdown formatters for LLM-optimized responses

* feat(mcp): dynamic tool generation from OpenAPI spec at startup

* fix(mcp): remove orphan tests and fix mock client after rebase

* refactor(mcp): clean up dead code, consolidate formatters, use
human-format

* docs(mcp): reflect dynamic tool generation + actions flag

* refactor(mcp): generate tools directly from OpenAPI spec

* refactor(mcp): drop redundant tool annotation hints
2026-04-28 11:12:58 +02:00
Mathieu
63d4334837 feat(rest-api): ACL V2 integration (#9774) 2026-04-28 11:03:49 +02:00
Sebastien Marie
00560dd418 feat(xo-server-sdn-controller): add the openflow controller method used to pool:other-config
it will be used by xapi to know if the OpenFlow channel is used and so if port 6653 needs to be opened or not.
it might also be used later to know how to interprete of-rules in the migration path from channel to xapi-plugin, if needed.

XCPNG-2470
XO-2282
2026-04-28 09:42:57 +02:00
Sebastien Marie
d56b68d49a fix(xo-server-sdn-controller): allow pool.certificate_uninstall to fail with CERTIFICATE_DOES_NOT_EXIST
It could happens if xapi database is out-of-sync with filesystem.

As the purpose of this block is to decide if the certificate should be installed,
assume that on CERTIFICATE_DOES_NOT_EXIST you need to install the new certificate.
2026-04-28 09:42:03 +02:00
Sebastien Marie
954a611399 fix(xo-server-sdn-controller): generate psk password using cryptographically secure pseudorandom number generator
For strong passwords, you need a good source of entropy (true randomness).  
lodash.sample's randomness relies on the JavaScript runtime's PRNG, which might 
not have enough entropy.
2026-04-28 09:42:03 +02:00
Sebastien Marie
c5c3cc23a2 fix(xo-server-sdn-controller): fix VM events to propagate Network Rules on changes
on topology changes (VM started/stopped/migrated/...), network rules needs to be 
refreshed as they are installed on each port of virtual switch.

add the same logic than VIF rules to refresh them.
2026-04-28 09:42:03 +02:00
Sebastien Marie
657c639de2 fix(xo-server-sdn-controller): fix function name in error message (copy/paste error) 2026-04-28 09:42:03 +02:00
Sebastien Marie
b57765231f fix(xo-server-sdn-controller): manage network rules deletion with cookie
Enable the usage of `cookie` parameter exposed by sdn-controller xapi plugin
- https://github.com/xcp-ng/xcp-ng-xapi-plugins/pull/58

It fixes several cases where OF rules where leaked and left behind.

Upgrade notes (apply to Network Rules only, VIF Rules are not impacted):
- a new xapi-plugin with old XO is compatible (and no behaviour change)
- an old xapi-plugin with new XO will fail to communicate
  XO will try to use `cookie` but xapi-plugin will complain about invalid parameter
- a new xapi-plugin with new XO will use new behaviour
  and existing OF rules will migrate smoothly from old to new behaviour
2026-04-28 09:42:03 +02:00
Pierre Briffaux
20dfa98575 feat(xo6/host): show management IP first in hosts table IP column (#9747) 2026-04-27 16:38:42 +02:00
Elise-F
3bb0850626 fix(xo6): remove dev pages in prod (#9700) 2026-04-27 16:10:54 +02:00
Joris K
699fb196fb feat(xo6): add possibility to remove a snapshot (#9749) 2026-04-27 10:25:14 +02:00
Mathieu
00e0bee372 fix(xo-server): fix default-template still in xo after deletion (#9760)
When VM_destroy is called on a default template, it is first converted to a simple template (and then to a VM).

After converting a default template to a simple template, the object received after the xapiObjectToXo transformation has a different ID (the default template's ID = pool.uuid + template.uuid, and non default template ID = template.uuid).

Since this object does not already exist in the XO server cache(because the ID is new), XO treats it as a new object and not as an update, so the old object with ID pool.uuid + template.uuid stay in the cache
2026-04-27 08:39:51 +02:00
Florent BEAUCHAMP
da045e0de1 fix(backups): use a delta transfer even if the VM is modified (#9709)
* fix(backups): use a delta transfer even if the VM is modified
* test(backup): fix date test without timezone
* refactor(backups): extract method that check which snapshot can be reused for replica target
* reset before launching the replication
2026-04-24 19:30:51 +02:00
Bastien Nollet
fdb82ed68a feat: technical release (#9768) 2026-04-24 14:38:15 +02:00
Pierre Brunet
112c22e748 fix: add chainPhysicalUsage property in vdi object (#9708)
*add  chainPhysicalUsage property to vdi
2026-04-23 16:29:55 +02:00
Thomas Moraine
c540a49643 doc(users.md): recommend against using local users in prod environments (#9761) 2026-04-23 15:13:14 +02:00
Joris K
f6484a0695 feat(xo6): add possibility to delete a VBD (#9698) 2026-04-21 16:34:13 +02:00
Joris K
5593f27110 feat(xo6): add possibility to remove VDIs (#9689) 2026-04-21 15:41:15 +02:00
Joris K
81813c7e5b feat(xo6): add possibility to remove VIFs (#9601) 2026-04-21 15:19:06 +02:00
Mathieu
0bf0a468b5 fix(rest-api): fix other_config being ignored on VDI creation (#9695) 2026-04-21 12:35:33 +02:00
Pierre Brunet
e78275f046 docs: fix typo (#9739) 2026-04-21 11:39:49 +02:00
spacotte-vates
89c7551293 feat(rest-api): add connect/disconnect vif actions (#9643) 2026-04-21 10:25:24 +02:00
Mathieu
ed30291993 fix(rest-api): fix double slash in the href property (#9741)
Introduced by dd5f0cdaa9
2026-04-21 10:07:52 +02:00
Pierre Donias
9765435471 feat(xo-web/servers): remind users to only add pool masters (#9742) 2026-04-20 15:43:02 +02:00
spacotte-vates
e2a4bd2231 fix(changelog): add version 6.2.3 (#9743) 2026-04-20 15:10:02 +02:00
Florent BEAUCHAMP
c6ccd987e2 fix(xo-server): TLSSocket memory leak — stoppable + http-server-plus incompatibility (#9725)
xo-server uses two libraries for its web server:

    http-server-plus — a multi-protocol server facade that multiplexes HTTP and HTTPS listeners behind a single EventEmitter interface ( introduced here JsCommunity/http-server-plus@ec63f3f )
    stoppable — wraps the server to track open connections for graceful shutdown, using an internal reqsPerSocket Map (server._pendingSockets) (introduced here : 782505b )


Fix

@vates/http-server-plus (fork of JsCommunity/http-server-plus): add 'secureConnection' to the forwardedEvents list so that webServer.on('secureConnection', handler) is propagated to underlying https.Server instances at listen() time.

xo-server/src/index.mjs: register the missing handler that stoppable was supposed to add but never did:
2026-04-20 14:38:37 +02:00
Mathieu
45f810c044 feat(openmetrics): add VM status and uptime metrics (#9684)
* feat(openmetrics): add VM status and uptime metrics

* refactor(openmetrics): address review feedback on VM status/uptime
2026-04-20 14:09:28 +02:00
Bastien Nollet
fbba05bbde feat(backups): use XO Tasks (#9734)
Change the backups so they stop using their own task system, and use the same system as XO Tasks.

For the moment, it shouldn't affect the users, but when the previous backup logs will be old enough to be erased, this will allow us to have shorter loading times for backup logs.

XO-52
2026-04-17 17:49:18 +02:00
OlivierFL
9dc52f2e5b fix(xo-6/network-form): toRef causes TS error on build (#9736) 2026-04-17 17:33:06 +02:00
Mathieu
271d123395 fix(rest-api): SSE MiB limit instead percent (#9731) 2026-04-17 16:58:26 +02:00
Enée DI IORIO
88a89cc3a9 fix(changelog): fix PR link formatting in changelogs (#9732) 2026-04-17 11:46:47 +02:00
Florent BEAUCHAMP
5f7fab9d34 test(backups): replication and full mirror backups
* test(replication): test the new snapshot based replication

* fix(tests): use mergeBackupsSynchronously to ensure the backups don't fails with lock already heled error

* fix: ensure the repository that will be purged start empty

* test(backup): add test for full mirror backups
2026-04-17 11:25:38 +02:00
spacotte-vates
8df8ef0af8 feat(backups): log merge size (#9679)
XO-2050

Add the merged size in the cleanVm task log
2026-04-17 09:58:12 +02:00
Mathieu
6fbb961e7f fix(xo-server): fix 408 timeout when copying a VM with compression (#9699)
* fix compression not used on front whe copying a VM
* ensure the first bytes are ready before starting to import the VM 

---------

Co-authored-by: Florent BEAUCHAMP <florent.beauchamp@vates.tech>
2026-04-17 09:56:39 +02:00
Mathieu
6b1a2afc9c docs(@vates/types): document vm.videoram (#9694) 2026-04-16 16:05:28 +02:00
Pierre Donias
3ed8c7b689 feat(netbox): introduce sub-platforms with major version number (#9644)
Fixes #7773
See Zammad#53857

On Netbox >= 4.4 (which introduced platform hierarchies):
- If the major version number is available: create a versioned child platform (e.g. "Debian 12") with the distro-only platform (e.g. "Debian") as its parent, and assign the child to the VM
- Otherwise: keep previous behaviour and assign the VM to the distro-only platform
2026-04-16 10:31:32 +02:00
Mathieu
db9c4815b1 feat(openmetrics): add 9 missing host RRD metrics (#9696) 2026-04-15 19:27:03 +02:00
Mathieu
3e21edad4e chore: upgrade dependencies with known security vulnerabilities (#9640)
* chore: upgrade dependencies with known security vulnerabilities

* Update Node.js version in CI workflow

* chore: upgrade additional security dependencies

* chore: stabilize yarn.lock path-to-regexp entry

* chore: bump minimum version ranges for security-patched dependencies

* chore: update CHANGELOG.unreleased.md with security upgrades

* chore: revert vite upgrade to 6.4.1 (6.4.2 breaks vue-tsc)

* chore: fix vite upgrade and pin vue to 3.5.28
2026-04-15 16:56:43 +02:00
vates-bot
1b12aebb47 feat(web-stack/i18n): update Xen Orchestra 6 (#9649) 2026-04-15 14:43:26 +02:00
p-bo
2e009535aa fix(i18n): shutdown -> reboot in force reboot dialog (as in French) (#9683)
99d19012ad/%40xen-orchestra/web-core/lib/locales/fr.json (L485)
2026-04-15 14:36:13 +02:00
Elise-F
5af32683ce feat(web-core): update default tab functionality (#9651) 2026-04-15 12:40:20 +02:00
Mathieu
0fef765ce6 fix(rest-api): fix memory-leak on subscription (#9707) 2026-04-15 12:18:06 +02:00
Pierre Donias
4508e01644 fix(CHANGELOG): markdown, URLs, labels, ... (#9723) 2026-04-15 11:56:25 +02:00
Pierre Briffaux
f60203a927 fix(web): fix lost focus on vdi name input (#9685) 2026-04-15 11:29:11 +02:00
Mathieu
5ebefb0af2 feat(openmetrics): add VDI disk size and physical usage metrics (#9680)
Fixes XO-1995
2026-04-15 09:52:26 +02:00
Thomas Moraine
fa8844037f docs(xen-orchestra): upgrade docusaurus to v3.10.0 (#9682) 2026-04-15 09:40:19 +02:00
Bastien Nollet
2098c02e7f chore(xo-web): remove unused function (#9691) 2026-04-15 09:37:11 +02:00
Mathieu
c4763d5eec feat(rest-api,mcp): add ?markdown=true output format to REST API (#9624) 2026-04-15 09:30:20 +02:00
Elise-F
0b78cab0b3 fix(web-core): match npm package version (#9721)
web-core 0.49.0 was accidentally published to npm.
This commit bumps the version to match what is already on npm.
2026-04-14 18:18:38 +02:00
Elise-F
bbf3e1e446 feat: release 6.3.3 (#9720) 2026-04-14 17:19:55 +02:00
Elise-F
d94de6090b feat: technical release (#9716) 2026-04-14 15:31:22 +02:00
Bastien Nollet
816b60f11b docs(backups): extra details (#9693) 2026-04-14 14:20:07 +02:00
Florent BEAUCHAMP
6b36b41d68 fix(xo-web): toggle distributed replication (#9715)
introduced by #9433
2026-04-14 11:44:26 +02:00
spacotte-vates
e9caeafd7a fix(bakups): fix merge resume issue when child is disk chain (#9668)
* fix(bakups): fix merge resume issue when child is disk chain
2026-04-14 11:04:23 +02:00
Pierre Brunet
06c3933756 test(backups): add unit test to check #9667 (#9670) 2026-04-13 18:30:17 +02:00
Mathieu
30ff04d88a docs(mcp): add multi-instance configuration (#9705) 2026-04-13 18:25:27 +02:00
Florent BEAUCHAMP
43d86038b1 fix(backups): Fix "Storage_error ([S(Illegal_transition);[[S(Activated);S(RO)];[S(Activated);S(RW)]]])" (#9702)
* don't connect NBD server if we only want the block list
* wait for the disk to be disconnected from dom0 before trying to write into it
2026-04-13 16:46:23 +02:00
Thomas Moraine
415207d1ba docs(intro_backup.md): fix broken youtube embed (#9701) 2026-04-13 10:41:57 +02:00
spacotte-vates
b21e5e8c53 fix(backups): fix vhd directory merge speed regression (#9692)
* fix(backups): fix vhd directory merge speed regression

* add changelog
2026-04-13 09:35:40 +02:00
OlivierFL
aa605639e4 feat(xo-6): network creation forms (#9629) 2026-04-10 17:35:21 +02:00
Elise-F
8ecaa6d817 fix(xo6): increase SSE ping timeout (#9681)
Introduced by #9375
2026-04-09 10:14:54 +02:00
Mathieu
76655a9e05 fix(@xen-orchestra/immutable-backups): fix missing prepublishOnly entry (#9687)
See zammad#51952
2026-04-08 16:12:20 +02:00
Elise-F
58b56a0749 fix(XO6): 'please wait' page not displayed on dev pages (#9686) 2026-04-08 14:41:50 +02:00
Thomas Moraine
99d19012ad docs(xo): replace some XO5 screenshots with the XO6 versions (#9678) 2026-04-07 09:24:38 +02:00
Elise-F
c1e5f2e8d7 feat: release XO 6.3.2 (#9676) 2026-04-03 15:21:41 +02:00
Elise-F
372a2be21d feat: technical release (#9673) 2026-04-03 10:47:59 +02:00
Florent BEAUCHAMP
3acd35142c fix(backups): don't fail incremental replication when the VM has VTPM (#9671) 2026-04-03 10:06:44 +02:00
Florent BEAUCHAMP
28c5e5f138 fix(backups): schedule reference is missing (#9667)
introduced by #9635
2026-04-02 14:47:00 +02:00
Elise-F
a6c508be46 feat(lite): 0.20.0 (#9666) 2026-04-01 16:27:01 +02:00
Elise-F
875ceb7cab feat: release XO 6.3.1 (#9665) 2026-04-01 15:08:02 +02:00
Thomas Moraine
d98dd78168 docs(manage-infrastructure.md): fix formatting errors in info panels (#9664)
Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2026-04-01 13:07:04 +02:00
Elise-F
df0c69c023 feat: technical release (#9663) 2026-04-01 10:37:53 +02:00
Mathieu
df7ffbb90c fix(web-core): fix objects store not updated (#9660)
* fix(web-core): fix objects store not updated
2026-04-01 10:10:23 +02:00
Mathieu
8553f1cd08 fix(rest-api): fix no such VDI for /vms/:id/(dashboard|vdis) (#9662)
* fix(rest-api): fix no such VDI for /vms/:id/(dashboard|vdis)
2026-04-01 10:07:10 +02:00
Florent BEAUCHAMP
41de7528bf fix(backups): full when going from 6.2 to 6.3 (#9661) 2026-04-01 09:48:55 +02:00
Elise-F
bfb0a052ef feat: release XO 6.3.0 (#9658) 2026-03-31 16:37:31 +02:00
Elise-F
5bd04b0736 feat: technical release (#9656) 2026-03-31 14:52:50 +02:00
Mathieu
4015987b03 fix(web-core): fix performance issue when lot of new events (#9627) 2026-03-31 12:05:00 +02:00
Elise-F
25db113349 feat: technical release (#9654) 2026-03-31 10:00:56 +02:00
spacotte-vates
b8637097c2 fix(backups): fix double close on merge (#9636)
* fix(backups): fix double close on merge

* set dispose to promise instead of empty func
2026-03-31 09:20:06 +02:00
Mathieu
9226c51df7 fix(rest-api): cannot read properties of undefined for vms/:id/dashboard (#9650)
Fix forum#12031
2026-03-30 20:07:45 +02:00
Joris K
14328ad62a feat(web-core): add stepper component (#9569) 2026-03-30 17:19:29 +02:00
Florent BEAUCHAMP
ac8dfeb7f5 feat(backups): improve incremental replication (#9635) 2026-03-30 17:04:16 +02:00
OlivierFL
bce3c88915 fix(xo-core): wrong translation key on PT locale (#9652)
Introduced by 658de40
2026-03-30 15:55:53 +02:00
DustyArmstrong
658de40f9e fix(web-core): fix some grammar issues on the XO 6 Dashboard (#9647) 2026-03-30 15:31:16 +02:00
Pierre Donias
b413db14e7 fix(xo-server-netbox): avoid mutating TYPES_WITH_UUID constant (#9607) 2026-03-30 15:24:58 +02:00
Enée DI IORIO
e6e5031964 feat(xo6/lite): add bug tools download button (#9419) 2026-03-30 14:35:45 +02:00
Florent BEAUCHAMP
484b9a02a3 docs(openmetrics): add documentation for xo metrics (#9631)
introduced by 9535
2026-03-30 14:34:32 +02:00
OlivierFL
a7829414b0 chore(web-core/query-builder): add query builder documentation (#9541) 2026-03-30 10:56:05 +02:00
vates-bot
39acc6b3a0 feat(web-stack/i18n): update Xen Orchestra 6 (#9554) 2026-03-30 10:55:00 +02:00
Mathieu
465c00b71d fix(xo-stack): return null instead of 0 for every NaN stats (#9634) 2026-03-27 17:26:55 +01:00
Mathieu
6814f6b2ef feat(qa-test): migrate xo-qatest into monorepo as @xen-orchestra/qa-test (#9626)
* feat(qa-test): migrate xo-qatest into monorepo as @xen-orchestra/qa-test
2026-03-27 14:35:03 +01:00
Elise-F
cc4cc1796b feat: technical release (#9646) 2026-03-27 14:22:03 +01:00
Florent BEAUCHAMP
1d81c0b26e fix(xapi): add missing package to changelog (#9645)
introduced by #9596
2026-03-27 13:46:38 +01:00
Elise-F
04a6fb3805 feat: technical release (#9642) 2026-03-27 10:23:50 +01:00
Florent BEAUCHAMP
1302618f90 fix(backup): compute the restore size for incremental restore (#9632)
introduced by #8432
2026-03-26 11:48:44 +01:00
Elise-F
7a39e45ffb feat(xo6): add please wait page for first connection in xo server (#9568) 2026-03-26 11:27:16 +01:00
Florent BEAUCHAMP
4bbfa77ec8 feat(xo5/sr): add preferred-image-format field (#9596) 2026-03-26 10:43:52 +01:00
Elise-F
57414dafce feat(xo6): implement vm snapshot tab page (#9521) 2026-03-26 10:03:19 +01:00
Elise-F
e28e768c72 Fix(xo6): remove scrollbar from console tab (#9609) 2026-03-26 09:14:24 +01:00
Joris K
8e5809a675 feat(xo6): add backup replication on vm dashboard (#9545) 2026-03-25 14:55:54 +01:00
Mathieu
56b17ce7f6 feat(rest-api): expose create internal and bonded network (#9611) 2026-03-25 11:25:06 +01:00
Mathieu
27a119f037 refactor(mcp): split monolithic index.mts into modular file structure (#9621)
XO-2221
2026-03-25 11:14:48 +01:00
Elise-F
60ba5070c9 fix(XO6): update watch to avoid reset template select in new VM (#9603) 2026-03-24 17:33:01 +01:00
Florent BEAUCHAMP
0efa078190 fix(backups): vhd directory have empty names (#9633) 2026-03-24 17:05:49 +01:00
Mathieu
c3dcb8fd50 feat(mcp): add list_srs and get_sr_details tools for SR (#9599)
* feat(mcp): add list_srs and get_sr_details tools for storage
repositories

* docs(mcp): add list_srs and get_sr_details to documentation

* fix(mcp): extract expected tool names into a constant in tests

* docs(mcp): add link to live filter syntax documentation for all filter
parameters
2026-03-24 16:39:58 +01:00
Florent BEAUCHAMP
0286f7786a fix(immutable-backups): decrease resource consumption, add tests and typing (#9548) 2026-03-24 16:00:01 +01:00
Florent BEAUCHAMP
f2b1931408 feat(disk-cli): implement disk cli tooling (#9551)
* feat(disk-cli): implement disk cli tooling

* changelog

* fix test path

* fix following review

* fix following rebase

* ci

* fix following rebase

* more tests
2026-03-24 14:35:05 +01:00
Florent BEAUCHAMP
f546849494 feat(xo-server): use index for getAllUnhealthyVdiChainsLength (#9622) 2026-03-24 10:37:18 +01:00
Florent BEAUCHAMP
6ae70a424a fix(backups): correctly rename replicated VMs (#9606) 2026-03-24 09:16:43 +01:00
Florent BEAUCHAMP
41751a8599 fix(backups): use the snapshot of the source (#9612)
* fix(backups): use the snapshot of the source

introduced by 9524

use only the vdi from the source VM as the source on backup
this bug could only occurs if the replication was intra pool, if
not the different xapi was filtering the results
2026-03-24 09:16:16 +01:00
Joris K
7ea8640ad0 fix(xo6/lite): fixed duplicated ip addresses in vm networks tab (#9547) 2026-03-23 16:29:41 +01:00
Enée DI IORIO
2994761178 fix(xo-lite/header): Fix menu button on large screen (#9558) 2026-03-23 10:12:56 +01:00
Mathieu
d1736e01f7 feat(mcp): add list_networks and get_network_details tools (#9595) 2026-03-20 09:38:59 +01:00
Mathieu
a2e3343b6e fix(rest-api/dashboard): exlude ISO SRs from dashboard (#9608) 2026-03-19 14:07:54 +01:00
Bastien Nollet
e325c6af51 docs(load-balancer): add info on migration cooldown (#9604) 2026-03-18 10:09:40 +01:00
Florent BEAUCHAMP
598ab85446 feat(vmware-explorer): make V2V import cancellable (#9552) 2026-03-17 18:17:08 +01:00
Pierre Brunet
1591749a23 fix(fs/s3): add check before using DeleteObjectsCommand (#9598)
fallback to delete object by object on multiple delete error  to handle backends that can't handle this command
2026-03-16 16:56:33 +01:00
Mathieu
800fd97996 feat(openmetrics): add missing VBD and DCMI metrics (#9563)
XO-1491
2026-03-16 11:37:19 +01:00
Pierre Brunet
45aee36ae7 typescript: Add types in several packages (#9583)
* feat: add types for fs (#9515)

* feat: add types for fs

* fix: update from review

* fix: update devDep

* feat: add types from @xen-orchestra/fs/path

* feat: export types based on async-each

* fix: remove ts-expect-error because async-each is now typed

* fix: export backups types and packages

* fix: update tsconfig async-each and fix types in MergeRemoteDisk

* fix: extractIdsFromSimplePattern from backups

* fix: package.json

* feat: export vhd-lib types

* feat(disks): export types

* fix(backups/disks): make params optional

* fix(backups/disks): small update in jsdoc

* bump: update yarn.lock

* fix: update vhd-lib exports

* fix(vhd-lib): update package exports

* fix: revert to previous import

* fix: new approach without exports

* fix: revert types except for asyncEach, openDisk

* fix(disks): import in vhd-lib

* fix(remoteVhdDisk): update typedef
2026-03-16 10:55:18 +01:00
Mathieu
2aff87d6d6 feat(mcp): add list_vdis tool to list virtual disks (#9559) 2026-03-13 15:54:40 +01:00
Mathieu
449e79de21 feat(rest-api): add disable/enable action endpoints for hosts (#9532) 2026-03-13 10:03:56 +01:00
MlssFrncJrg
9d657ac7cc feat(@xen-orchestra/rest-api): expose forget SR (#9505) 2026-03-12 16:12:43 +01:00
Enée DI IORIO
f4cc40233c fix(xo6/backups): backupLog transfer size calculation (#9496) 2026-03-12 15:09:19 +01:00
MlssFrncJrg
2cd5ba0319 feat(@xen-orchestra/rest-api): expose vms/:id/actions/clone (#9453) 2026-03-11 15:58:18 +01:00
Mathieu
2444861082 fix(rest-api/xo6): cpu at VM creation was not working (#9591)
* fix(rest-api/xo6): cpu at VM creation was not working

* changelog
2026-03-11 15:07:56 +01:00
Florent BEAUCHAMP
7ddff121de feat(fs): make part size configurable for s3 (#9561)
from https://xcp-ng.org/forum/topic/10587/s3-backup-maximum-number-of-parts/
2026-03-11 09:54:08 +01:00
Florent BEAUCHAMP
c1e9b83adc feat(backups): symetrical backups (#9524) 2026-03-11 09:34:09 +01:00
Mathieu
18d9b33c87 feat(mcp): support token authentication via XO_TOKEN (#9577) 2026-03-11 09:20:40 +01:00
Bastien Nollet
673e729afe fix(xo-server): incorrect import (#9589) 2026-03-10 19:25:55 +01:00
Florent BEAUCHAMP
4e0655dc3d fix(backups): better handling of generator cleanup (#9557) 2026-03-10 15:55:59 +01:00
bleader
a78870b473 docs(sdn-controller): mention xapi plugin is in BETA (#9556) 2026-03-10 15:42:16 +01:00
Thomas Moraine
0bf309a4ad docs(sdn_controller.md): clarify requirements for regenerating certificates (#9584) 2026-03-10 09:54:01 +01:00
spacotte-vates
5bdd76a955 feat: release 6.2.2 (#9579) 2026-03-09 14:41:09 +01:00
Pierre Brunet
e11530ea7c feat: technical release (#9576) 2026-03-09 10:10:36 +01:00
Florent BEAUCHAMP
c70f911b5e fix(changelog): remove package from release list (#9575)
introduced during rebase by #9572
2026-03-09 09:56:22 +01:00
Mathieu
82f047ef9d fix(xo-server): proxy issue if hostname defined (#9572)
fix #9500
2026-03-09 09:49:24 +01:00
Mathieu
57aec1ab2d fix(xo-server): race condition on XOA WebSocket during plugin loading (#9402) 2026-03-09 09:42:53 +01:00
Pierre Donias
385fcf3810 fix(xo-server/vm): allocate resource set quotas when not using DMC (#9566)
When editing VM RAM, the high-level `memory` field dispatches to `memoryMax` or
`_memory` depending on whether the VM uses DMC. Both paths need `limitName:
'memory'` on their spec so the resource set quota counter is correctly updated,
but `_memory` was missing it, causing memory resizes on non-DMC VMs to silently
create a stale `_memory` counter instead of updating the actual memory quota.
2026-03-09 09:37:24 +01:00
Florent BEAUCHAMP
6699bd1f5d fix(backups): remove snapshot of vm with cdrommounted (#9571) 2026-03-06 17:28:50 +01:00
Pierre Brunet
844c07e166 feat: technical release (#9570) 2026-03-06 14:49:29 +01:00
spacotte-vates
7676978c78 fix(backups): fix out of range error on merge (#9567)
Fixes out of range error during merge that was happening on delta backups when the child disk was bigger than the parent disk but no blocks where written in the "new" space.

Also added a test for this error.
2026-03-06 14:17:17 +01:00
Elise-F
7054fefdbd Style(xo6): update color for status paused VM icon (#9562) 2026-03-05 11:21:06 +01:00
Enée DI IORIO
acd84649ed feat(xo6): add backupRuns and BackupArchives card on vm dashboard (#9303) 2026-03-05 10:32:07 +01:00
Florent BEAUCHAMP
7460bf93d1 feat(openmetrics): add XO related metrics (#9535) 2026-03-04 10:30:20 +01:00
Mathieu
61e4a77cab fix(openmetrics): convert latency from µs to seconds instead of ms (#9550) 2026-03-03 10:01:56 +01:00
vates-bot
55130311f3 feat(web-stack/i18n): update Xen Orchestra 6 (#9531) 2026-03-03 09:18:35 +01:00
Florent BEAUCHAMP
f0cbd1bd72 docs(backup): document distributed backups (#9528)
* docs(backup): document distributed backups

* Apply suggestions from MPT code review

Co-authored-by: Mathieu <goore.csmoviz@gmail.com>

---------

Co-authored-by: Mathieu <goore.csmoviz@gmail.com>
2026-03-03 09:11:36 +01:00
Enée DI IORIO
561e2349e9 feat(xo6-lite/dashboard): add reactivity on resize (#9378) 2026-03-02 14:59:51 +01:00
Enée DI IORIO
7c83ed63f1 fix(web-core/treeItemLabel): fix treeview tooltip overlap (#9319) 2026-03-02 10:41:26 +01:00
Enée DI IORIO
9525abdee5 feat(xo6/Lite): add secureBoot support (#9423) 2026-02-27 15:09:49 +01:00
spacotte-vates
c6172b7b74 feat: release 6.2.1 (#9546) 2026-02-27 14:42:27 +01:00
Pierre Brunet
1b865bbf89 feat: technical release (#9544)
* feat(@xen-orchestra/backups): 0.69.2

* feat(@xen-orchestra/proxy): 0.29.49

* feat(xo-server): 5.197.3

* chore(CHANGELOG): update next

* fix: update changelog
2026-02-27 11:56:10 +01:00
spacotte-vates
815f4be76c fix(backups): changelog (#9543) 2026-02-27 11:44:13 +01:00
spacotte-vates
53f5c63fa2 fix(backups): race condition on merge (#9542) 2026-02-27 11:29:42 +01:00
spacotte-vates
4ffd1b4e13 feat(lite): 0.19.0 (#9540) 2026-02-26 18:29:55 +01:00
spacotte-vates
d25d15efd7 feat: release 6.2.0 (#9539) 2026-02-26 17:46:07 +01:00
Pierre Brunet
322205d203 feat: technical release (#9537) 2026-02-26 14:57:42 +01:00
Mathieu
89494457a1 fix(mcp): use bin wrapper to fix npx permission denied (#9536)
Fix npx @xen-orchestra/mcp failing with "Permission denied".

tsc does not preserve the executable bit on compiled files, so bin pointing to ./dist/index.mjs (mode 644) caused the error. This adds a bin/xo-mcp.mjs wrapper tracked by git with mode 755, following the same pattern as other packages (@xen-orchestra/xapi, @xen-orchestra/proxy, etc.).
2026-02-26 13:34:08 +01:00
Pierre Briffaux
6bc2844092 feat(xo6/new-vm): add custom user-config to new VM creation (#9427) 2026-02-26 13:15:30 +01:00
Mathieu
53c15bc9e4 docs(mcp) : documentation (#9529)
docs(mcp): add README and Docusaurus documentation page
2026-02-25 17:19:38 +01:00
Pierre Brunet
1dd8c1603f Release (#9534) 2026-02-25 16:19:54 +01:00
Mathieu
52513fc898 feat(xo-server): add transient option to host.disable (#9503)
Add optional transient parameter (boolean) to host.disable API endpoint.

When transient=true, passes auto_enable=false to XAPI, keeping the host disabled across reboots and toolstack restarts (requires XAPI >= 25.31.0). Default behavior unchanged (backward compatible).

XAPI 25.31.0 introduced a new auto_enable parameter to Host.disable (xen-api#6652). When set to false, the host stays disabled regardless of toolstack restarts, host reboots, or consider_enabling_host calls. Only an explicit Host.enable can re-activate it.
2026-02-25 15:54:26 +01:00
spacotte-vates
ed508ec04f feat: technical release (#9533)
* feat(@xen-orchestra/backups): 0.69.1

* feat(complex-matcher): 1.1.0

* feat(@xen-orchestra/web-core): 0.44.0

* feat(@xen-orchestra/mcp): 1.0.0

* feat(@xen-orchestra/proxy): 0.29.48

* feat(@xen-orchestra/rest-api): 0.26.0

* feat(@xen-orchestra/web): 0.42.0

* feat(xo-server): 5.197.1

* feat(xo-web): 5.194.0

* chore(CHANGELOG): update next

---------

Co-authored-by: Pierre Brunet <pierre.brunet@vates.tech>
Co-authored-by: Pierre Donias <pierre.donias@gmail.com>
2026-02-25 15:39:25 +01:00
MathieuRA
21823bf430 feat: add rest-api debug 2026-02-25 14:13:30 +01:00
Florent BEAUCHAMP
e36e1012e2 fix(xo-web): reducing polling for patches and license 2026-02-25 14:13:30 +01:00
Elise-F
3fcf582087 feat(new VM): add ssh key to vm creation form (#9413) 2026-02-25 10:24:29 +01:00
vates-bot
f1389cff6e feat(web-stack/i18n): update Xen Orchestra 6 (#9462) 2026-02-25 09:39:32 +01:00
Mathieu
438a7cc746 feat(mcp): add @xen-orchestra/mcp package (#9519)
This PR adds the new @xen-orchestra/mcp package — an MCP (Model Context Protocol) server that enables AI assistants (Claude Desktop, Claude Code, or any MCP-compatible client) to query a Xen Orchestra infrastructure in read-only mode.
2026-02-24 17:10:32 +01:00
Florent BEAUCHAMP
31e2ff1b02 feat(backups): only check size for full backups (#9527)
The size is computed from the blocksize * nb allocated blocks during the backups
which is not aligned with the real size on disk
Checking the file on disk is too costly for vhd directory
This will be eventually reimplemented by the next cleanVm rework
2026-02-24 17:04:58 +01:00
Thomas Moraine
44a23fb33f docs(sdn_controller.md): document openssl 3 + sdn upgrade path (#9525)
This pull request documents the manual steps that will be needed for the SDN controller, with the incoming March 2026 update of XCP-ng that will include OpenSSL3.
2026-02-24 13:21:26 +01:00
Pierre Donias
f8501c1a1e feat(xo-web/patches): warn about updating XOSTOR before installing patches (#9517) 2026-02-24 12:07:44 +01:00
spacotte-vates
6cab502260 feat(rest-api): sr scan route (#9514) 2026-02-24 11:05:18 +01:00
Pierre Donias
157e7fe757 feat(xo-web/licenses): display bundle name next to license name (#9512) 2026-02-23 16:20:46 +01:00
OlivierFL
0866a5db81 feat(xo-stack): implement Query Builder (#9488) 2026-02-23 15:35:23 +01:00
Pierre Donias
1c245acca5 feat(xo-web/network): reconfigure management PIF (#9510) 2026-02-23 14:36:18 +01:00
spacotte-vates
58c2c518a3 feat: technical release (#9523)
* feat(@xen-orchestra/fs): 4.6.7

* feat(@vates/types): 1.20.0

* feat(@xen-orchestra/backups): 0.69.0

* feat(@xen-orchestra/backups-cli): 1.1.8

* feat(@xen-orchestra/immutable-backups): 1.0.30

* feat(@xen-orchestra/web-core): 0.43.0

* feat(@xen-orchestra/proxy): 0.29.47

* feat(@xen-orchestra/rest-api): 0.25.0

* feat(@xen-orchestra/vmware-explorer): 0.11.0

* feat(@xen-orchestra/web): 0.41.0

* feat(xo-server): 5.197.0

* feat(xo-server-load-balancer): 0.12.0

* feat(xo-server-netbox): 1.11.0

* feat(xo-server-openmetrics): 1.2.0

* feat(xo-server-sdn-controller): 1.2.1

* feat(xo-web): 5.193.0

* chore(CHANGELOG): update next

---------

Co-authored-by: Pierre Brunet <pierre.brunet@vates.tech>
2026-02-23 10:23:57 +01:00
Elise-F
755ac108f2 feat(xo6): adds a banner when access to the xo server fails (#9375) 2026-02-20 17:12:29 +01:00
Bastien Nollet
f88c0e8be2 feat(rest-api): expose POST vdis (#9492) 2026-02-20 16:24:18 +01:00
Philippe Coval
109e37695c fix(sdn-controller): Support openssl-3 for ca/cert generation (#9507)
OpenSSL-3 (on xcp-ng) requires CA set to true in basicConstraints.

"All CAs should have the CA flag set to true."

For context, here are additional resources:

- https://docs.openssl.org/3.0/man1/openssl-verification-options/#basic-constraints
- https://docs.openssl.org/1.0.2/man1/x509v3_config/#basic-constraints
- https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.9

To connect to OpenSSL 3, certificates should be re-generated if they
already exist (just remove xo-server/data/sdn-controller).

Renewed certs were tested on XCP-ng host on "OpenSSL 1.0.2k-fips"
without regression, so it is safe to assume those ext are designed to
be backward compatible.

Observed issue:

    openssl verify \
      -CAfile .../xo-server/data/sdn-controller/ca-cert.pem \
      .../xo-server/data/sdn-controller/client-cert.pem

    C=XX, L=Default City, O=Default Company LTD
    error 79 at 1 depth lookup: invalid CA certificate
    error .../xo-server/data/sdn-controller/client-cert.pem: \
      verification failed

The expected output is now:

    .../xo-server/data/sdn-controller/client-cert.pem: OK

The expected output is now:

    .../xo-server/data/sdn-controller/client-cert.pem: OK

It was tested along ovsdb-server (Open vSwitch) 2.17.7 :

    openssl s_client \
      -connect  10.1.75.62:6640 \
      -cert .../xo-server/data/sdn-controller/client-cert.pem \
      -key .../xo-server/data/sdn-controller/client-key.pem

    (...)
    CONNECTED(00000003)
    Can't use SSL_get_servername
    (...)
    verify error:num=18:self-signed certificate
    (...)
    SSL handshake has read 2713 bytes and written 3799 bytes
    Verification error: self-signed certificate
    (...)
    New, TLSv1.2, Cipher is AES128-GCM-SHA256
    (...)
    {"id":"echo","method":"echo","params":[]}

Extra info at:

- https://xapi-project.github.io/new-docs/design/pool-certificates/

Origin: https://github.com/vatesfr/xen-orchestra/pull/9507
Relate-to: XCPNG-2710
Relate-to: XO-1986
Signed-off-by: Philippe Coval <philippe.coval@vates.tech>
2026-02-20 09:34:13 +01:00
spacotte-vates
a4b480de4a feat(rest-api): add relaim_space action to sr (#9486) 2026-02-19 17:26:12 +01:00
spacotte-vates
db8addbc45 fix(backups): fix RemoteVhdDisk & DiskChain parent and directory issue (#9508)
* fix(backups): fix RemoteVhdDisk & DiskChain parent and directory issue
2026-02-18 14:06:05 +01:00
Sebastien Marie
9dbc090116 fix(xo-server-sdn-controller): communicate with enabled hosts only (#9442)
permit to have working traffic rules when a pool is in a 'degraded' mode (for
example, one host temporary down or unreachable).

currently, exceptions occurs and nothing happens (no rules added or deleted).
with it, unreachable hosts are ignored and reachable hosts properly updated.
2026-02-17 13:31:44 +01:00
OlivierFL
353678e754 feat(xo-6/internal-links): normalize internal links (#9276) 2026-02-17 08:20:01 +01:00
Florent BEAUCHAMP
a313986694 feat(backups): implements distributed backup and replications (#9433)
The deletion of old backups is done globally
For Full backups/replication:
 * retention is computed on the total of the target
 * choose the storage with the most free size ( if possible, since some backup repositories don't have any size used/ free )
 * one vm is written on the same target ( xva, index, checksum )
 * healthcheck ( if enabled ) will run only on the target that received the data
 * the cleanup of old backups/replication is done on the right target ( but considering the full lenght across all the targets )

For incremental: 
 * delta chain are continued on the same target 
 
For mirror : 
if a vm has multiple backups to transfer during the same run, then
they will all go to teh same remote
 
UX : 
* show a toggle on remote/sr selectors
* toggle is disabled if 1 or less targets are selected
* for backup and mirror backup forms
* show the details in the job list
  

More details on the PR page
2026-02-16 18:17:03 +01:00
Florent BEAUCHAMP
983ac2d2c9 docs(config): explain the config files priorities (#9463) 2026-02-16 18:11:50 +01:00
spacotte-vates
4e50858526 feat(@xen-orchestra/backups): refactor backup merge code (#9300)
this merge code leverage the new classes introduced to handle qcow2, improving our flexibility, and future uses.

this also improve the performance on "block mode" backups (s3/ azure and other storage with the option), since it reliably updates the block allocation table only at the end of the merge instead of each time a new block is detected
2026-02-16 18:04:12 +01:00
Mathieu
c497ae3d38 fix(web): use the store's object type and not type from vates/types (#9421) 2026-02-16 17:01:26 +01:00
spacotte-vates
9d9b5d5e33 feat(rest-api): add plug and unplug pbd routes to rest api (#9477) 2026-02-16 16:52:04 +01:00
OlivierFL
5834070e77 fix(xo-6/xo-lite): missing required prop for UiRadioButtonGroup in new VM creation form (#9504) 2026-02-16 16:50:40 +01:00
Enée DI IORIO
5c5f27f92b fix(xo-stack): fix VM creation on small screen (#9344) 2026-02-16 16:16:11 +01:00
Cyrille G.
8fc04bc03b fix(hub/recipe): remove DHCP option for the Kubernetes recipe (#9454) 2026-02-16 10:20:11 +01:00
Pierre Brunet
87135c7469 feat: add qcow2 support in vdi creation (#9493)
Add qcow2 image-format when size is higher than 2TB. It will still be a VHD if PBD.preferred-image-formats do not list qcow2
2026-02-16 09:56:08 +01:00
Mathieu
fb030a590e docs(openmetrics): update documentation for host uptime, host status and is_control_domain (#9497)
docs(openmetrics): update documentation for host uptime, host status and
is_control_domain
2026-02-16 09:55:02 +01:00
Florent BEAUCHAMP
0be2392519 feat(xo-server/V2V): automatically take a snapshot if needed (#9471) 2026-02-12 17:07:56 +01:00
Florent BEAUCHAMP
546d42427c feat(xo-web): remember V2V connections settings (#9490) 2026-02-12 15:42:47 +01:00
Mathieu
fc86fda13d fix(rest-api): exclude removable storage from top 5 SRs usage (#9495) 2026-02-12 14:04:12 +01:00
Elise-F
b44bed6d10 feat(web-core): new ui rich radio button component created (#9198) 2026-02-12 11:53:35 +01:00
Mathieu
4e66889233 fix(xo-server-openmetrics): bind to localhost instead of 127.0.0.1 (#9489) 2026-02-12 09:51:42 +01:00
Pierre Donias
10bb1741e7 chore: split changelog file (#9390) 2026-02-11 14:57:35 +01:00
Mathieu
a0f890f301 feat(openmetrics): add host uptime metric (#9449) 2026-02-11 11:08:43 +01:00
Pierre Donias
839a484017 fix(xo-web/host/patches): remove obsolete yum install command instruction (#9451) 2026-02-11 10:34:02 +01:00
MlssFrncJrg
dc9e029bdb feat(netbox): support Netbox v4.5.x (#9445)
Fixes #9379
See https://github.com/netbox-community/netbox/releases
2026-02-11 10:31:47 +01:00
Mathieu
f203b1f1e1 feat(rest-api): expose vms/:id/actions/migrate (#9414) 2026-02-11 09:55:51 +01:00
Mathieu
7399a9fec5 feat(openmetrics): add xcp_host_status metric (#9457) 2026-02-10 16:39:40 +01:00
Olivier Lambert
c69b2336f6 feat(xo-server-load-balancer): add migration cooldown to prevent VM flapping (#9388) 2026-02-10 16:11:27 +01:00
Florent BEAUCHAMP
80ab29de29 feat(xo-server): warm migration works with non running VM 2026-02-10 16:08:10 +01:00
Florent BEAUCHAMP
1449a8482e feat(xo-server): vm.warmMigration now return the uuid 2026-02-10 16:08:10 +01:00
Mathieu
ec9806e081 feat(rest-api): add objectType to tasks from actions (#9429) 2026-02-10 15:22:03 +01:00
Pierre Brunet
c6c31524e1 build: bump all dependencies (#9481) 2026-02-10 14:42:37 +01:00
Mathieu
49fce9b61f feat(openmetrics): add is_control_domain label to differentiate dom0 VMs (#9474) 2026-02-10 14:41:42 +01:00
Thomas Moraine
f52a35c093 docs(metadata_backup): clarify the two types of metadata backups (#9487) 2026-02-10 14:11:42 +01:00
Mathieu
9d74585bd4 fix(@xen-orchestra/web): fix build issue (#9482)
Introduced by #7851
2026-02-10 10:59:37 +01:00
Mathieu
12f4360100 feat: release 6.1.2 (#9485) 2026-02-10 10:01:17 +01:00
Pierre Brunet
771ce8c6d5 fix(backups/s3): add fallback if deleteObjects unsupported (#9450) 2026-02-10 09:29:53 +01:00
Mathieu
41c9a6c7c2 feat: release 6.1.2 (#9480) 2026-02-09 09:43:51 +01:00
Florent BEAUCHAMP
7e08a016bd fix(backups): ensure vm created by backups are removable by backups : halthcheck/replication (#9473) 2026-02-06 15:15:37 +01:00
Mathieu
91c5d98489 feat: release 6.1.1 (#9470) 2026-02-05 16:59:00 +01:00
Mathieu
b895f48a4f feat: technical release (#9469) 2026-02-05 16:01:47 +01:00
Mathieu
d68d878747 fix(changelog): add missing package to release (#9468) 2026-02-05 15:46:55 +01:00
Mathieu
5155a9d5f1 feat: technical release (#9467) 2026-02-05 14:58:41 +01:00
Florent BEAUCHAMP
7437427fae fix(backups): refresh the snapshot name after removing the temp name (#9466)
introduced by #9434
2026-02-05 13:13:39 +01:00
spacotte-vates
a16b4cb540 feat(rest-api): add post vifs and delete vifs/:id routes to rest api (#9393) 2026-02-05 12:05:26 +01:00
Florent BEAUCHAMP
21508c97bc fix(immutable-backups): really protect pool metadata
Apply suggestion from @MathieuRA

Co-authored-by: Mathieu <70369997+MathieuRA@users.noreply.github.com>

Apply suggestion from @MathieuRA

Co-authored-by: Mathieu <70369997+MathieuRA@users.noreply.github.com>
2026-02-05 10:16:03 +01:00
Florent BEAUCHAMP
e8ff60c8bc docs(immutable-backups): fix app name in documentation
alos changelog that wasn't in previous PR
2026-02-05 10:16:03 +01:00
Mathieu
153efccb85 fix(rest-api): pools/:id/actions/create_vm install.repository was ignored (#9461) 2026-02-05 10:10:39 +01:00
Joris K
a99e1aa72d fix(xo6/lite): only count running vms for cpu provisioning in host dashboard (#9459) 2026-02-05 09:27:20 +01:00
Mathieu
dd5f0cdaa9 fix(rest-api): fix backup-archives href (#9460)
* fix(rest-api): fix backup-archives href

* changelog

---------
2026-02-04 17:10:37 +01:00
Mathieu
ea142b50a4 fix(rest-api): only count running vms for cpu provisioning in pool dashboard (#9456)
See Forum#101359
2026-02-04 17:05:16 +01:00
Pierre Briffaux
5b5a0a72cc fix(web-core): enable legacy clipboard support for http (#9426) 2026-02-04 16:41:16 +01:00
Joris K
84a16ef85f fix(xo6): fix issue with alarms display (#9458) 2026-02-04 11:18:38 +01:00
vates-bot
b983e6d4fa feat(web-stack/i18n): update Xen Orchestra 6 (#9440) 2026-02-04 10:40:27 +01:00
Joris K
a0e771f09c feat(xo6): update site dashboard (#8964) 2026-02-03 17:51:06 +01:00
Florent BEAUCHAMP
9bd91aea43 feat(fs): autodetect object lock/immutability on s3 (#9431)
* add a setting to declare remote immutable ?immutable=true in the URL 
* autodetect object lock/immutabiltiy on s3
2026-02-03 17:12:15 +01:00
Florent BEAUCHAMP
0a042d2f54 fix(xapi): correctly block operations
blocked operation on vm creation does not seem to work

also importIncrementalVm was not unlocking it at the end
2026-02-03 15:59:24 +01:00
Florent BEAUCHAMP
c610951450 fix(backups): set other_config on incremental replication vm creation
this will ensure that any partial transfer will ben cleaned on next run
2026-02-03 15:59:24 +01:00
Florent BEAUCHAMP
a831ac7b21 chore(backups): remove old todo 2026-02-03 15:59:24 +01:00
Florent BEAUCHAMP
d0f71d4ec7 fix(backups): don't leave dangling snapshots (#9434)
when the connection to the host break between the snapshot() and
the update of other config
this could leave snapshot that will never be cleaned by any backup job
The fix is that the snapshot will be named with a specific name, it
will cause an issue if a user reuse this name
2026-02-03 15:55:11 +01:00
Joris K
c1a9bc82fb feat(xo6/vm): add more actions button to delete VM and take snapshot (#9410) 2026-02-03 13:05:49 +01:00
Dan Pollak
601cc3e9fd fix(immutable-backups): Fix typo in sample config file (#9444)
* fix(immutable-backups): default config.toml missing quotes

Signed-off-by: Dan Pollak <danpollak2@gmail.com>

* fix(immutable-backups): Locate config file

Signed-off-by: Dan Pollak <danpollak2@gmail.com>

* Update CHANGELOG

Signed-off-by: Dan Pollak <danpollak2@gmail.com>

* Revert "fix(immutable-backups): Locate config file"

This reverts commit e9e367474d.

* Update CHANGELOG.unreleased.md

Co-authored-by: Florent BEAUCHAMP <florent.beauchamp@gmail.com>

---------

Signed-off-by: Dan Pollak <danpollak2@gmail.com>
Co-authored-by: Florent BEAUCHAMP <florent.beauchamp@gmail.com>
2026-02-03 13:03:09 +01:00
MathieuRA
79d1f42dd7 fix(rest-api): do not create auth task when using auth token 2026-02-03 09:20:25 +01:00
MathieuRA
64c366838a fix(rest-api): don't send message updates when subscribing to alarm 2026-02-03 09:20:25 +01:00
MathieuRA
85b3162be6 fix(rest-api): set memory limit per sse client 2026-02-03 09:20:25 +01:00
Mathieu
eb020e250b docs(openmetrics): add SR capacity metrics and complete VM metrics (#9438)
documentation
2026-01-30 16:50:55 +01:00
Olivier Lambert
56142085e5 feat(xo6/settings): add various themes (#9387) 2026-01-30 15:21:00 +01:00
Mathieu
2838bde875 feat(lite): 0.18.0 (#9437) 2026-01-29 17:56:53 +01:00
Mathieu
fa110ed9c9 feat: release 6.1.0 (#9436) 2026-01-29 17:31:28 +01:00
Mathieu
d3187c06b5 feat: technical release (#9432) 2026-01-29 10:57:33 +01:00
Florent BEAUCHAMP
2f41ccffe8 feat(xapi): try to disconnect vdi if a vm_destroy fail (#9415)
happenned during a backup for HK
only solve case where the disk can be manually disconnected from DOM0
2026-01-29 10:02:59 +01:00
Elise-F
a9e45fcb8e feat(xo6/Lite): add VTPM support (#9389) 2026-01-29 09:46:41 +01:00
Pierre Briffaux
1e69c25ba5 fix(xo6/new-vm): fixed disk naming from template (#9294) 2026-01-28 16:13:58 +01:00
Joris K
9f852365fc feat(xo6/vm): add VMs actions to manage lifecycle (#9403) 2026-01-28 16:01:57 +01:00
Enée DI IORIO
05824cb8a6 feat(web-core): normalize icons creation from design system (#8989) 2026-01-28 15:29:40 +01:00
vates-bot
638ee77aff feat(web-stack/i18n): update Xen Orchestra 6 (#9330) 2026-01-28 10:36:59 +01:00
Bastien Nollet
08a560a8da fix(xo-server): fix NFSv4 ISO SR creation (#9425) 2026-01-28 10:02:44 +01:00
Elise-F
e77a0e43f1 feat(xo6): remove text ellipsis on sides panel (#9238) 2026-01-27 18:29:35 +01:00
Florent BEAUCHAMP
8069adf26d feat(xo-server): expose an api method to export a disk from vmware 2026-01-27 16:04:05 +01:00
Florent BEAUCHAMP
1241977599 fix(vhd-cli): raw need to load BAT before working 2026-01-27 16:04:05 +01:00
Florent BEAUCHAMP
4ca314fbc7 fix(nbd-client): discrepency when using hasblock/getblockindexes 2026-01-27 16:04:05 +01:00
Mathieu
13ab1cce86 feat(rest-api): add POST /vdis/{id}/actions/migrate endpoint (#9408) 2026-01-27 10:58:28 +01:00
Mathieu
9f7797fdda feat(rest-api): add VBD connect/disconnect endpoints (#9399) 2026-01-27 10:25:04 +01:00
Mathieu
21767145bd feat: technical release (#9422) 2026-01-23 14:44:23 +01:00
OlivierFL
6805e16108 refactor(xo6): project architecture (#9371) 2026-01-23 12:02:43 +01:00
Mathieu
ecd49e69a7 fix(xo-server-openmetrics): add sr_name label to VM disk metrics (#9353)
Introduced by 0005a4f4cc1990e9a20eb48becb5943b5bf15c43
2026-01-23 10:24:29 +01:00
Florent BEAUCHAMP
a5f5a0273e fix(boackups): reverting a VM from a backup shouldn't leave dangling other_config (#9398) 2026-01-23 10:18:52 +01:00
Mathieu
ddc9bf3211 feat(rest-api): add DELETE /vbds/{id} endpoint (#9394) 2026-01-22 14:31:25 +01:00
Mathieu
75db97f9ea feat(@vates/types,xo-server-openmetrics): use centralized XoApp type (#9404)
XO-1810
2026-01-22 11:19:49 +01:00
Mathieu
07c1c02f03 feat(rest-api): add secureBoot param for vm creation (#9417) 2026-01-22 11:13:42 +01:00
Mathieu
dbe8e0ce26 fix(rest-api): vms/:id/start fix request body being ignored (#9416)
introduced by 0202b56d33
2026-01-21 17:42:20 +01:00
Florent BEAUCHAMP
82cd861479 feat(backups): expose LTR tags to VM backups (#9364) 2026-01-21 17:34:50 +01:00
Pierre Briffaux
32f8c1a25f feat(xo-lite): scroll to selected item in treeview (#9332) 2026-01-21 16:52:06 +01:00
Florent BEAUCHAMP
9e7813319a fix(vhd-lib/qcow2): better handling of unaligned blocks (#9407) 2026-01-21 15:52:33 +01:00
Mathieu
caba00a2b9 feat(rest-api): add createVtpm parameter to VM creation endpoint (#9412) 2026-01-21 15:25:03 +01:00
Pierre Briffaux
927561123a feat(xo6/treeview): scroll to selected item in treeview (#9268) 2026-01-21 11:51:25 +01:00
Mathieu
097c2bd1d9 feat(rest-api): add POST /vbds endpoint to create VBDs (#9391) 2026-01-20 14:36:24 +01:00
MlssFrncJrg
5288b6d848 feat(xo6/vm): add 'Change State' button on VM page (#9317) 2026-01-20 11:54:01 +01:00
Pierre Brunet
263059be22 fix: update node-tar to handle CVE-2026-23745 (#9406)
* fix: update node-tar to handle https://github.com/advisories/GHSA-8qq5-rm4j-mr97

* chore: update changelog
2026-01-20 11:42:57 +01:00
Florent BEAUCHAMP
c83df8a342 fix(backups): more filtering on disk snapshot before starting (#9405)
from ticket 33058
2026-01-19 11:20:09 +01:00
Bastien Nollet
4f959e2d22 fix(backup-reports): prevent succesful backups being sometimes repor… (#9400)
* fix(backup-reports): prevent succesfull backups being sometimes reported as interrupted

* increase delay after user feedback

* change comment according to delay change

---------

Co-authored-by: Florent BEAUCHAMP <florent.beauchamp@vates.tech>
2026-01-19 11:06:36 +01:00
Florent BEAUCHAMP
ee6df8c979 chore(backups): remove unused and broken code (#9367)
note the missing await for the adapter._handler.readFile
this method is correctly implemented in MixinRemoteWriter
2026-01-19 10:58:16 +01:00
Mathieu
0a0ae5dc7e feat(xo-server/rest-api): add management interface reconfigure API (#9369) 2026-01-14 16:29:43 +01:00
Mathieu
58b6215c41 fix(@xen-orchestra/rest-api): check length instead of undefined (#9401)
Introduced by #9395
2026-01-14 15:17:59 +01:00
Mathieu
0e56c5e2bd fix(@xen-orchestra/rest-api): add isEmpty prop for xoa dashboard (#9395) 2026-01-14 11:27:28 +01:00
Florent BEAUCHAMP
0b44b1b52b fix(backups): size estimation of full backups (#9396) 2026-01-14 10:38:52 +01:00
Bastien Nollet
4c7b8b194f feat(vhd-cli): prevent using invalid options (#9386) 2026-01-13 16:52:36 +01:00
Mathieu
ac2ed8880a feat(@vates/types): expose XoApp (#9322) 2026-01-12 15:27:53 +01:00
Raven
6fc30c899d docs(xo): Add instructions for fetching deps on RHEL 10 distros (#9383)
RHEL 10 and co. (AlmaLinux, Rocky, CentOS) stopped shipping redis and
moved some packages around. Update the docs to reflect that.
2026-01-12 11:56:48 +01:00
Florent BEAUCHAMP
05e1298eec fix(backups): rework snapshot listing (#9377) 2026-01-12 08:17:54 +01:00
Mathieu
d20561fadc fix(xo-server-openmetrics): use correct timestamp property name in computeVmCpuUsageFallback (#9384)
Introduced by 29fa4b2
2026-01-09 14:12:28 +01:00
Mathieu
53a3e2a70f feat(xo-server-openmetrics): add SR capacity metrics (#9360)
* feat(xo-server-openmetrics): add SR capacity metrics

* fix(xo-server-openmetrics): refactor SR types and add host labels for
local SRs

---------

Co-authored-by: Florent BEAUCHAMP <florent.beauchamp@vates.tech>
2026-01-09 10:25:10 +01:00
Mathieu
2c90eb5ba8 fix(rest-api): string and not 'string' (#9382) 2026-01-09 10:24:37 +01:00
Mathieu
c3e78ebdfd fix(rest-api): fix getVmVdis and enhance the type (#9381) 2026-01-09 10:24:20 +01:00
Mathieu
264b93d502 feat(open-metrics): compute vm_cpu_usage from per-core metrics (#9331)
* feat(open-metrics): compute vm_cpu_usage from per-core metrics

* refactor(xo-server-openmetrics): combine conditional checks in
computeVmCpuUsageFallback
2026-01-09 10:23:26 +01:00
Elise-F
d0abf8810c feat(web-core): update UI title component (#9292) 2026-01-09 10:09:25 +01:00
Mathieu
bdd9b26651 fix(rest-api): various vm dashboard fixes (#9380) 2026-01-08 17:30:12 +01:00
Elise-F
66d5014755 feat(xo6): removal borders of sidebar in mobile (#9366) 2026-01-08 16:49:22 +01:00
mathieuRA
8e2d9f1022 feat(xo6): use define-job for server creation 2026-01-07 16:04:21 +01:00
mathieuRA
566bdeda63 feat(xo6): use define-job for VM creation 2026-01-07 16:04:21 +01:00
mathieuRA
e86d71fc31 feat(xo6): implement async action monitoring 2026-01-07 16:04:21 +01:00
Mathieu
4827f33305 feat(@xen-orchestra/rest-api): update xoa dashboard endpoint (#9207)
XO-1462
2026-01-07 14:39:48 +01:00
Elise-F
9b6d1089f4 feat(web-core): update text logo with custom font (#9298) 2026-01-06 16:01:34 +01:00
Elise-F
d1f8f61791 feat: release 6.0.3 (#9374) 2026-01-06 15:20:32 +01:00
Elise-F
dbf00ba0cd feat: technical patch release (#9373) 2026-01-06 14:33:41 +01:00
Pierre Brunet
44a0d14e88 fix (XO Hub): read getResourceCatalog removed during refactor (#9362) 2026-01-06 14:08:48 +01:00
Elise-F
0b52a05bb3 feat: technical patch release (#9372) 2026-01-06 11:51:13 +01:00
Florent BEAUCHAMP
407591d74d fix(vhd-lib): more logs on backup edge cases (#9326)
* more logs for vdi attached to multiples VMs failing _removeUnusedSnapshots
* more logs for incomplete reading of a disk
2026-01-06 10:57:41 +01:00
Enée DI IORIO
0b29586626 fix(xo6/site-dashboard): replace raw text by translated text in Pools Status (#9329) 2026-01-05 16:38:07 +01:00
Florent BEAUCHAMP
42eb8664be fix(xo-server/v2v): more block alignment fixes (#9293)
from #45336
2026-01-05 16:01:59 +01:00
Florent BEAUCHAMP
1d8c429532 fix(backups): cleanup data on restore (#9361) 2026-01-05 12:10:44 +01:00
Joris K
c51df2239f fix(xo6/host): correction of a translation key (#9359) 2026-01-05 10:34:06 +01:00
Mathieu
b89c26459c feat: release 6.0.2 (#9356) 2025-12-23 15:41:32 +01:00
Mathieu
a1333c07d7 feat: technical release (#9355) 2025-12-23 14:45:33 +01:00
Mathieu
5e8e5beba1 fix(xo-server): fix netdata url (#9354) 2025-12-23 14:04:08 +01:00
Mathieu
9169e11f35 fix(xo-server): fix don't call the updater with secure protocol (#9349) 2025-12-23 11:46:28 +01:00
Mathieu
6908001bd8 fix(xo-server): bypass session auth for /openmetrics routes 2025-12-22 18:02:29 +01:00
Mathieu
53ec9a62a8 fix(xo-server-openmetrics): comply with OpenMetrics 1.0 specification 2025-12-22 18:02:29 +01:00
Joris K
4a0590b7e9 fix(web core): fix task item tree (#9352)
Introduced by 3eb9efe

Fix an issue on taskitem tree only on firefox
2025-12-22 11:22:10 +01:00
Thierry Goettelmann
d358aed897 fix(web-core/lite): UiInput v-model modifiers and reactivity (#9282)
Fixes randomly blank input
2025-12-22 11:03:15 +01:00
Florent BEAUCHAMP
5fcb6db32e docs(xo5): use absolute path (#9347) 2025-12-19 17:29:54 +01:00
Mathieu
1de60c52b4 docs(openmetrics): add user documentation for Prometheus integration (#9333) 2025-12-19 14:17:38 +01:00
Elise-F
55243b65a2 feat: release XO 6.0.1 (#9345) 2025-12-19 14:03:18 +01:00
Elise-F
0247b3b9b3 feat: technical release (#9343) 2025-12-19 11:48:25 +01:00
Mathieu
6dae628e0d Fix/openmetrics hardcode server config (#9342)
* docs(changelog): add entry for xo-server-openmetrics security fix

* Apply suggestion from @fbeauchamp

---------

Co-authored-by: Florent BEAUCHAMP <florent.beauchamp@vates.tech>
2025-12-19 11:28:51 +01:00
Mathieu
7827b27d63 fix(xo-server): fix http.proxies when redirectToHttps=true (#9339)
introduced by 7c1764a39e
fix [forum#11681](https://xcp-ng.org/forum/post/100852)
2025-12-19 11:11:35 +01:00
Mathieu
86ddff46a9 fix(xo-server-openmetrics): hardcode port and bind address for security (#9337) 2025-12-19 10:35:50 +01:00
Pierre Donias
476f4e5f1f fix(xo-server/signin-otp): pass xo5Mount param to Pug template (#9340) 2025-12-19 10:12:24 +01:00
Elise-F
8448b4d5a6 feat: release 6.0.0 (#9336) 2025-12-18 16:36:07 +01:00
Elise-F
28952a0a4a feat: technical release (#9334) 2025-12-18 12:21:33 +01:00
Pierre Brunet
19f973fcd3 fix: replace deprecated endpoint call (#9316)
Call to deprecated route restore/logs replaced by restore-logs/
2025-12-18 09:20:48 +01:00
OlivierFL
d9fe9b603f fix(xo-6/account-menu): license url was not redirecting properly to XO 5 (#9321) 2025-12-17 17:49:46 +01:00
OlivierFL
13a462429e fix(xo-6/xo-lite/new-vm-form): change "memory" section to "resource management" for better clarity (#9309) 2025-12-17 17:45:08 +01:00
Mathieu
bfe788e096 fix(@xen-orchestra/web-core): fix race condition on reactive store (#9307)
Introduced by 5d28710ab3
2025-12-17 17:37:15 +01:00
Mathieu
980e4b5887 Feature openmetrics (#9323) 2025-12-17 17:17:53 +01:00
Florent BEAUCHAMP
0d8cb990c6 fix(xen-api): don't eat relevant error during import (#9327)
don't eat relevant error during import

SR contains the full list of the VDI on the SR
host contains the full list of the VM on the HOST
2025-12-17 16:40:30 +01:00
vates-bot
7794adfb89 feat(web-stack/i18n): update Xen Orchestra 6 (#9305) 2025-12-17 16:14:27 +01:00
Mathieu
fb67e8eea7 fix(xo-server): report 0 RAM usage for halted hosts (#9272) 2025-12-17 16:01:17 +01:00
Pierre Brunet
1316a811f5 feat(plugins/backup-report): add custom subject in plugin settings (#8973) 2025-12-17 15:41:11 +01:00
Joris K
c6f47f9ef3 feat(xo6/vm): implement tasks view and side panel information (#9313) 2025-12-17 15:31:45 +01:00
Joris K
c2a95467f8 feat(xo6/host): implement tasks view and side panel information (#9311) 2025-12-17 15:25:45 +01:00
Joris K
4d58ff885a feat(xo6/pool) implement tasks view and side panel information (#9312) 2025-12-17 15:15:08 +01:00
Florent BEAUCHAMP
3892eee7a0 fix(xo-server): changelog after xosan removing (#9302) 2025-12-17 15:01:33 +01:00
Joris K
15b9e13ed2 feat(xo6/site): implement tasks view and side panel information (#9063) 2025-12-17 14:50:34 +01:00
Mathieu
e504b8af0f fix(@xen-orchestra/rest-api): fix duplicated entries with ndjson (#9320) 2025-12-17 12:02:22 +01:00
Mathieu
efc751c54e fix(xo-server): check certificate instead port to define secure protocol (#9324) 2025-12-17 11:35:50 +01:00
Mathieu
8b4c953ab0 docs(configuration): update doc for XO5 by default (#9296) 2025-12-17 09:44:50 +01:00
OlivierFL
32b3c0b5fc refactor(web-stack/translations): remove duplicates, improve pluralization, improve actions, clean translations (#9295) 2025-12-16 15:51:57 +01:00
Mathieu
7c1764a39e fix(xo-server): fix proxied urls when using ssl (#9314)
introduced by 03cea8b24d

Fixed [forum#11681](https://xcp-ng.org/forum/topic/11681/xo5-breaks-after-defaulting-to-xo6-from-source/16?_=1765875445829)
2025-12-16 11:23:54 +01:00
Pierre Brunet
b248cddece fix: perf-alert unload (#9306) 2025-12-16 10:19:50 +01:00
Florent BEAUCHAMP
df2dc3a710 fix(xapi): don't fail detecting image format on suspend disk (#9308)
from #48758

suspend_vdi  disk created for snapshot with memory are snapshot
but don't have a parent, thus failing the check 

introduced by c870bb653a
2025-12-15 17:08:26 +01:00
Elise-F
227063341e feat: technical release (#9310) 2025-12-15 16:00:02 +01:00
Mathieu
a4ab2e8b06 feat(@xen-orchestra/rest-api): add reactivity on task store (#9271) 2025-12-15 10:51:58 +01:00
Mathieu
9f3872a5f1 feat(@xen-orchestra/rest-api): ability to subscribe to task changes (#9269) 2025-12-12 18:07:39 +01:00
mathieuRA
e2d727abe5 fix(@xen-orchestra/rest-api): fix cannot read property of undefined on VM dashboard 2025-12-12 17:27:35 +01:00
mathieuRA
11720d96f1 fix(@xen-orchestra/rest-api): fix cannot convert undefined or null to object 2025-12-12 17:27:35 +01:00
vates-bot
859ebb892d feat(web-stack/i18n): update Xen Orchestra 6 (#9243) 2025-12-12 13:06:18 +01:00
Pierre Brunet
035ee8fb8e refactor: remove all xosan components (#9248)
XOSAN is not used anymore by clients. We can safely remove it from code base to clean it up.
2025-12-11 17:14:04 +01:00
Mathieu
bc69046bdd feat: technical release (#9301) 2025-12-11 15:22:37 +01:00
spacotte-vates
debde3f16e fix(xo-server): fix guiRoute generation (#9299) 2025-12-11 15:09:08 +01:00
Joris K
3eb9efe1f2 feat(web-core): add UiTaskItem and UiTaskList components (#9009) 2025-12-10 17:18:57 +01:00
Mathieu
7cf9726876 feat: technical release (#9297) 2025-12-10 15:08:46 +01:00
Pierre Briffaux
64d9f8c39e feat(xo6): update links to XO-5 (#9214) 2025-12-10 14:38:02 +01:00
Mathieu
03cea8b24d feat(xo-server): set XO6 the default page (#9212) 2025-12-10 14:29:49 +01:00
Bastien Nollet
4b4c9b91f3 fix(xo-web): allow offline delta backups (#9228) 2025-12-10 13:19:09 +01:00
Mathieu
907ee7d88c feat(@xen-orchestra/web): display modal of the first connection (#9230) 2025-12-10 12:52:01 +01:00
Enée DI IORIO
dbdc4cac4a fix(web-core): fix pagination placement (#9264) 2025-12-10 10:16:33 +01:00
Bastien Nollet
19c0b958fc fix(backups): fix incorrect info on mirror backups (#9286)
From forum#11623
2025-12-10 09:39:03 +01:00
Florent BEAUCHAMP
e5d2e83de6 fix(backups): cleanup .vhd.checksum files (#9291) 2025-12-10 09:19:58 +01:00
spacotte-vates
85596da792 fix(xo-server): improve handling of xapi snapshots (#9231)
In some cases, when converting an xapi object into an xo object, the xapi.is_a_snapshot param may be set to false even tho the xapi.snapshotOf refers to another xapi object. Handle this case better.
2025-12-10 09:16:18 +01:00
Pierre Brunet
230749a3f3 feat(fs/s3): DeleteObjectsCommand (#9281) 2025-12-10 09:08:35 +01:00
Bastien Nollet
b667bc8a23 fix(@xen-orchestra/fs): avoid retrying on some statuses (#9188) 2025-12-09 17:50:32 +01:00
Mathieu
76abf1ed73 docs(restapi.md): update REST API docs (#9284) 2025-12-09 14:18:51 +01:00
Mathieu
54930ee07f fix(@xen-orchestra/rest-api): vmProtected -> vmProtection (#9288) 2025-12-09 11:53:18 +01:00
Mathieu
90295ca924 feat(@xen-orchestra/web): redirect non admin to xo5 (#9219) 2025-12-09 11:48:04 +01:00
Mathieu
e281c536d3 feat: release 5.113.2 (#9290) 2025-12-09 11:32:27 +01:00
Mathieu
efa5b1a71e feat(@xen-orchestra/rest-api): display URL of the openApi spec (#9285) 2025-12-09 11:10:10 +01:00
Mathieu
62aaa21ced feat: technical release (#9289) 2025-12-09 10:19:09 +01:00
Florent BEAUCHAMP
dfbf654159 fix(xo-server-perf-alert): improve email alert subject (#9283) 2025-12-08 18:03:26 +01:00
spacotte-vates
4bc614ffd1 fix(xo-server-auth-oidc): fix groups import on string (#9280)
Introduced in 6cd0710

For some OIDC auth provider, when the user only has 1 group, the idToken groups param is a string and not an array.
2025-12-08 15:17:09 +01:00
Thierry Goettelmann
1640a6ae00 feat(web-stack): enhance tree lib and add persistence to XO6 navigation tree (#9277) 2025-12-05 15:56:05 +01:00
Thierry Goettelmann
66b677af28 feat: migrate XO Lite tables. introduces new tables. clean code. (#9265) 2025-12-03 17:31:24 +01:00
Thierry Goettelmann
655bda4789 feat(web-stack): table system iteration (composition over config) (#9247) 2025-12-02 17:57:54 +01:00
Thierry Goettelmann
07e605162a refactor(web-core): merge busy prop into type in VtsStateHero (#9273) 2025-12-02 17:36:29 +01:00
Pierre Donias
ce98c3deba fix(web/vdis): xo 5 fallback URL query should start with a "?" (#9267)
Introduced by #9232
See https://xcp-ng.org/forum/post/100170
2025-12-02 10:24:44 +01:00
MlssFrncJrg
71fa8924d7 feat: release 5.113.1 (#9263) 2025-11-28 13:01:09 +01:00
MlssFrncJrg
9c78018662 feat: technical release (#9262)
* feat(xo-server-perf-alert): 1.0.3

* chore(CHANGELOG): update next
2025-11-28 12:16:37 +01:00
Florent BEAUCHAMP
72f31d412d fix(xo-server-perf-alert): compute core average if cpu_usage is missing (#9258) 2025-11-28 11:51:55 +01:00
MlssFrncJrg
ef60450dc8 feat(lite): 0.17.0 (#9261) 2025-11-28 10:11:57 +01:00
MlssFrncJrg
f4988c00af feat(lite): 0.17.0 (#9260) 2025-11-27 18:06:40 +01:00
MlssFrncJrg
43c9f574fd feat: release 5.113.0 (#9259) 2025-11-27 17:36:38 +01:00
MlssFrncJrg
a15eeeed5b feat: technical release (#9257) 2025-11-27 16:29:04 +01:00
Florent BEAUCHAMP
14a72a2612 fix(xo-server-perf-alert): better handling of VM not loaded in xapi and undefined (#9256) 2025-11-27 16:16:35 +01:00
Mathieu
68554b3e0a fix(@xen-orchestra/core): predicate early return if undefined (#9255)
introduced by 5d28710ab3
2025-11-27 16:05:35 +01:00
MlssFrncJrg
91d5107e1d feat: technical release (#9254)
* feat(@xen-orchestra/web): 0.34.1

* feat(xo-server-perf-alert): 1.0.1

* chore(CHANGELOG): update next
2025-11-27 15:03:51 +01:00
Florent BEAUCHAMP
8e46b83d3e fix(xo-server-perf-alert): copy template to dist and fix imports (#9253) 2025-11-27 14:47:08 +01:00
Mathieu
6105681df2 fix(@xen-orchestra/web): add missing bios_strings field (#9252)
introduced by 0c92bf559a
2025-11-27 14:41:32 +01:00
Thomas Moraine
05713d16c0 docs(proxy.md): explain error message for incompatible proxies (#9245)
Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-11-27 13:24:06 +01:00
MlssFrncJrg
1c6bc86794 Release (#9251)
* feat(vhd-lib): 4.14.5

* feat(@vates/nbd-client): 3.2.2

* feat(@xen-orchestra/web-core): 0.35.0

* feat(@xen-orchestra/rest-api): 0.21.1

* feat(@xen-orchestra/vmware-explorer): 0.10.6

* feat(@xen-orchestra/web): 0.34.0

* feat(xo-server): 5.193.1

* feat(xo-server-auth-oidc): 0.4.0

* feat(xo-server-perf-alert): 1.0.0

* feat(xo-web): 5.191.0

* chore(CHANGELOG): update next

* feat(vhd-cli): 1.0.0

* add missing lib
2025-11-27 13:23:09 +01:00
Florent BEAUCHAMP
21b4d1ee34 docs(xo-server): guidelines (#8583)
Co-authored-by: Mathieu <70369997+MathieuRA@users.noreply.github.com>

Co-authored-by: Bastien Nollet <150058367+b-Nollet@users.noreply.github.com>

Co-authored-by: Mathieu <goore.csmoviz@gmail.com>

---------

Co-authored-by: Mathieu <70369997+MathieuRA@users.noreply.github.com>
Co-authored-by: Bastien Nollet <150058367+b-Nollet@users.noreply.github.com>
Co-authored-by: Mathieu <goore.csmoviz@gmail.com>
2025-11-27 11:45:22 +01:00
MlssFrncJrg
0c92bf559a feat(xo6/web): add boot firmware at vm creation (#9158) 2025-11-27 10:51:36 +01:00
Mathieu
5d28710ab3 feat(@xen-orchestra/web): implement reactivity for sub-stores (#9250) 2025-11-27 09:42:29 +01:00
Thierry Goettelmann
3126688d5e fix(web): charts reload every 30 seconds (#8939) 2025-11-27 09:24:51 +01:00
Elise-F
e70fe39934 feat(xo6): implement vdis tab page (#9232) 2025-11-26 18:33:41 +01:00
Enée DI IORIO
0af02941ed feat(xo6/pool/hosts): refactoring pool hosts page (#9218) 2025-11-26 17:54:10 +01:00
Florent BEAUCHAMP
779876ede9 feat(xo-server-perf-alert): improve performances (#9070) 2025-11-26 17:25:55 +01:00
Enée DI IORIO
dd8bb763e5 fix(xo-stack/resourceManagement): fix resourceManagement card if controllerMemory is undefined (#9197) 2025-11-26 17:04:42 +01:00
Enée DI IORIO
5e6c19b39f fix(web-core): add tooltip on UiTag (#9184) 2025-11-26 16:05:40 +01:00
Mathieu
dbb76cce6e fix(@xen-orchestra/rest-api): status code 400 instead of 500 for CM parse error (#9244)
See https://team.vates.fr/vates/pl/uu1c3r4bzbnx7gjon8xf6wmzsc
XO-1569
2025-11-26 15:15:53 +01:00
Mathieu
5cf1de5356 feat(xo6): implement reactivity for XAPI objects (#9183) 2025-11-26 14:20:50 +01:00
OlivierFL
bed27e5ffa fix(xo-6/dashboard): prevent double scrollbar in alarms lists (#9246) 2025-11-26 12:00:42 +01:00
Mathieu
bff4e4d4e2 fix(@xen-orchestra/rest-api/backup-archive): fix cannot convert undefined or null to object (#9240)
introduced by 544590a
fix zammad#48178
2025-11-26 09:47:45 +01:00
Enée DI IORIO
97d202dae0 feat(web-core): update UiLabel from V1 to V3 (#9201) 2025-11-26 09:40:20 +01:00
spacotte-vates
460bbc4569 fix(nbd-client): fix esxi 6 warm migration (#9223) 2025-11-26 09:21:19 +01:00
Bastien Nollet
708089622d feat(vhd-cli): accept all types of remotes (#9235) 2025-11-26 09:02:08 +01:00
OlivierFL
1818a031af feat(xo6/header): add DC Scope and DC NetScope buttons (#9242) 2025-11-26 08:23:56 +01:00
Mathieu
5cc6154bd2 fix(@xen-orchestra/rest-api): remove unused import (#9241) 2025-11-25 16:14:15 +01:00
spacotte-vates
6cd0710f89 feat(xo-server-auth-oidc): import user groups from OIDC (#9206) 2025-11-25 16:11:47 +01:00
Cyrille G.
65858c9ce7 feat(hub/recipes): add support for XO CCM and custom CIDR configurations in Pyrgos form (#9236) 2025-11-25 12:10:27 +01:00
Florent BEAUCHAMP
6277a12c4a fix(nbd-client): nbddisk must emit full size block (#9233) 2025-11-25 11:57:41 +01:00
Thomas Moraine
16de2e9fca docs(configuration.md): document changing the default XO interface (#9225) 2025-11-25 10:56:59 +01:00
Pierre Donias
39fa0f041f feat(xo-web/disk): (dis)connectVbd → (dis)connectVbds (#9211)
See Zammad#47676

Always use bulk methods for connecting or disconnecting a VBD so that
confirmation modals behaviours are mutualized.
2025-11-25 10:51:52 +01:00
vates-bot
ca3a7540f4 feat(web-stack/i18n): update Xen Orchestra 6 (#9165) 2025-11-25 10:40:59 +01:00
MlssFrncJrg
0bac1d3e0e feat: technical release (#9237) 2025-11-24 17:30:39 +01:00
MlssFrncJrg
99f221ccbf feat(xo-6): inform user about VM creation limitations (#9203) 2025-11-24 14:51:52 +01:00
Pierre Brunet
f24952f967 chore(xo-web): update marked to more recent version (#9227)
Fix https://github.com/vatesfr/xen-orchestra/security/dependabot/27
2025-11-24 14:36:08 +01:00
Pierre Brunet
566b210d1e chore(xo-web): update ansi_up (#9226) 2025-11-24 13:55:45 +01:00
Florent BEAUCHAMP
5fc2e4e8e5 fix(nbd-client): wrong comment leads to bad code (#9222) 2025-11-24 10:44:58 +01:00
Mathieu
65de89c7ee fix(@xen-orchestra/rest-api): don't return job for /backup-jobs endpoint (#9210)
* fix(@xen-orchestra/rest-api): don't return job for /backup-jobs endpoint
2025-11-24 10:37:06 +01:00
Bastien Nollet
1c55ca61e2 feat(xo-server-load-balancer): affinity tags (#9116)
Add Affinity rule option to the load balancer plugin.
This options ensures some VMs are positioned on the same host, when it's possible.

It's important to be careful while configuring the Affinity tags and Anti-affinity tags fields, otherwise it may lead to conflicts between the two behaviours. In such case, affinity takes precedence over anti-affinity.
2025-11-24 10:29:29 +01:00
Mathieu
ed5bd4d42e Feat/use vif move for network change (#9221)
* feat(vif): use VIF.move for network changes instead of delete+create
2025-11-24 09:56:00 +01:00
Julien Porschen
488eec9d1c fix(backups): handle LVM on GPT partitions #7029 (#9182)
* fix(backups): handle LVM on GPT partitions on file level restore #7029
* fix(backups): ignore swap partition of file level restore
2025-11-24 09:51:28 +01:00
Elise-F
89d45caac9 feat(xo6): fetch xo gui routes (#9138) 2025-11-24 08:51:44 +01:00
Mathieu
2592f2fc2c feat(xo-server-usage-report): add RAM/CPU allocation to reports (#9224) 2025-11-20 17:28:35 +01:00
spacotte-vates
87547a2130 fix(@xen-orchestra/rest-api): async actions return json (#9209) 2025-11-20 16:12:21 +01:00
Enée DI IORIO
5349f2ad09 fix(web-core): remove secondary cancel button on search input (#9189)
fix(web-core/uiInput): remove secondary cancel button on search input
2025-11-19 17:02:19 +01:00
Joris K
d34025fd25 feat(xo6/host): update vms view and implement new page vms and sidepanel (#9193) 2025-11-19 08:59:56 +01:00
Joris K
c53758ddc5 feat(xo6/pool) update vms view and implement new page vms and sidepanel (#9196) 2025-11-19 08:39:22 +01:00
Elise-F
90d1207a3c feat(xo6): creating setting page (#9175) 2025-11-18 17:16:15 +01:00
Joris K
9f478f97a2 feat(xo6/site): implement vms page (#9145) 2025-11-18 17:05:59 +01:00
Pierre Briffaux
e86bd71819 fix(web-core/MenuList): add overflow scroll to prevent content clipping (#9215) 2025-11-18 17:04:08 +01:00
Pierre Brunet
53c1a33106 security: update nodemailer (#9217)
Fix Nodemailer: Email to an unintended domain can occur due to Interpretation Conflict: https://github.com/vatesfr/xen-orchestra/security/dependabot/246
2025-11-18 09:23:27 +01:00
Pierre Brunet
7c06abc99f chore(saml): update GW tutorial (#9119)
Changes thanks to the forum posts here:
https://xcp-ng.org/forum/topic/6174/saml-auth-with-azure-ad/29?_=1760599896992
2025-11-18 09:06:23 +01:00
Pierre Brunet
2a5b0ffd1a security: update package xml2js in xo-web and xo-vmdk-to-vhd (#9216)
Update xml2js in xo-web and xo-vmdk-to-vhd for security purpose
Fix https://github.com/vatesfr/xen-orchestra/security/dependabot/84
2025-11-18 09:04:54 +01:00
Pierre Brunet
e494007b2a security: update cookie package (#9220)
Update cookie package in xo-server for security
Fix https://github.com/vatesfr/xen-orchestra/security/dependabot/167

Proxy check tested
2025-11-18 09:03:48 +01:00
OlivierFL
c2fb0470fc feat(xo-6/host): add storage repositories page and table (#9137) 2025-11-17 15:46:42 +01:00
OlivierFL
5fb5e639cd feat(xo-6/pool): add storage repositories page (#9134)
* fix reactivity issue in progress.util

* update VtsProgressBar component to display progress bar with no ruler

* create and use ProgressCell in Table
2025-11-17 15:01:42 +01:00
OlivierFL
da3733a3a9 feat(xo-6/backups): update wordings for backups modes (#9199) 2025-11-17 11:10:57 +01:00
Florent BEAUCHAMP
bd7ba64b68 fix(vmware-explorer): force vddk library path (#9208)
fix ticket 47466
2025-11-14 16:29:34 +01:00
Enée DI IORIO
436e2ea168 feat(xo6/site) implement hosts page (#9128) 2025-11-14 16:09:32 +01:00
Mathieu
dcbd550961 fix(@xen-orchestra/rest-api): fix eventListener not reattached (#9191) 2025-11-14 11:56:50 +01:00
Thierry Goettelmann
8811bb4bac feat(xo6): migrate typing to @vates/types (#9186) 2025-11-14 09:09:33 +01:00
Joris K
2d06677887 fix(xo6/host/vm): fix issues with condition on dashboards, and chart translation (#9204) 2025-11-13 09:23:37 +01:00
Mathieu
6cfefc91e4 feat(@xen-orchestra/rest-api): expose vms/:id/dashboard (#9143) 2025-11-12 18:01:41 +01:00
Florent BEAUCHAMP
8dc7254128 fix(backups): getOldEntries must accept entries without id or timestamp (#9202)
introduced by #9180 https://github.com/vatesfr/xen-orchestra/pull/9180
2025-11-12 17:42:18 +01:00
Pierre Donias
18a41cd055 feat(xo-web/menu): add link to XO 6 (#9187) 2025-11-12 15:31:06 +01:00
spacotte-vates
a7ad70bbed feat(rest-api): add vm.tags to backups-archives (#9190) 2025-11-12 11:10:17 +01:00
Mathieu
fa0202f5b3 docs(xo): add Gmail App Password and Microsoft 365 auth guidance (#9194)
* docs(xo): add Gmail App Password and Microsoft 365 auth guidance

* fix(docs): update email authentication guidance and improve clarity

* fix(docs): clarify backup-reports plugin configuration step

Co-authored-by: Thomas Moraine <thomas.moraine@vates.tech>

---------

Co-authored-by: Mathieu Piton <mathieu.piton@vates.tech>
Co-authored-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-11-10 09:32:40 +01:00
Elise-F
88b88187e9 feat(xo6): updated ui of input search (#9156)
* feat(xO6): udpate ui of input search

* updated changelog file

* feat(web-core): updated story of ui input component

* feedbacks on PR
2025-11-07 17:21:02 +01:00
Mathieu
ade825df55 fix(@xen-orchestra/xapi): await VBD.plug (#9192)
introduced by d353dc622c
2025-11-07 15:07:21 +01:00
Thierry Goettelmann
042c9c645b chore(types): complete exiting xo type definitions (#9174) 2025-11-06 10:05:51 +01:00
Dan Pollak
6bf5f4b7d4 feat(xo-server/recover-account): supply password via CLI (#9173)
Allow password to be passed as a parameter
2025-11-05 16:40:41 +01:00
Bastien Nollet
063f499ee0 fix(docs): fix long-term retention doc (#9178) 2025-11-05 15:39:03 +01:00
Mathieu
e4d484e84f feat(xo-server-usage-report): add OS information to usage reports (#9179)
---------

Co-authored-by: Mathieu Piton <mathieu.piton@vates.tech>
2025-11-05 14:24:29 +01:00
Florent BEAUCHAMP
05c6f87a12 fix(backups): long term retention taking the most recent values (#9180) 2025-11-05 11:01:43 +01:00
Pierre Donias
8a390057b6 fix(backups): typos and spelling (#9154)
See #8593

Co-authored-by: Florent BEAUCHAMP <florent.beauchamp@vates.tech>
2025-11-05 10:20:58 +01:00
Alexander Sennhauser
82d65e3c68 describe correct tag for backup health check (#9010) 2025-11-05 10:17:17 +01:00
Pierre Donias
58f029dcbe feat(xo-web/VM/backup): warn that backup job affects all VMs (#9155)
See Zammad#45581
2025-11-04 15:58:32 +01:00
Mathieu
7112c33142 feat(@xen-orchestra/rest-api): expose events (#9130) 2025-11-04 15:05:06 +01:00
Thomas Moraine
08c2923386 docs(xo): document immutability (#9135)
Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-11-04 11:50:41 +01:00
Thomas Moraine
ed5be40099 docs(manage_infrastructure.md): update container/docker information (#9140)
This commit renames and updates the **Docker management** section in the Xen Orchestra documentation. We explain a but better how containers are supported in Xen Orchestra, and remove links and references to the now-removed Docker support section, which was triggering a Docusaurus warning at build time.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-11-04 09:40:28 +01:00
Bastien Nollet
8c038bac34 feat: release 5.112.1 (#9177) 2025-11-03 14:37:36 +01:00
Bastien Nollet
961b505cfb feat: technical release (#9176) 2025-11-03 12:14:53 +01:00
Florent BEAUCHAMP
32556029cd feat(backups): export qcow2 disk as qcow2 (#9170)
fix full backups for delta
2025-11-03 10:54:11 +01:00
Mathieu
65013190e3 fix(xo-server/rpu): update linstor-satellite and linstor-controller (#9171) 2025-11-03 10:29:33 +01:00
OlivierFL
2dd451a7d9 fix(xo-6/backup-settings): reactivity issue when using useXoBackupJobSettingsUtils (#9169)
Introduced by e547447
2025-10-31 14:27:16 +01:00
Pierre Brunet
73a8ba59ba fix(xo-web): update easyvirt forms for DC Net Scope (#9107) 2025-10-31 11:07:33 +01:00
Bastien Nollet
285d1da361 feat(lite): 0.16.0 (#9167) 2025-10-30 18:24:47 +01:00
Bastien Nollet
4d77b79ce9 feat: release 5.112.0 (#9166) 2025-10-30 17:55:30 +01:00
Bastien Nollet
268033439b feat: technical release (#9164) 2025-10-30 15:25:41 +01:00
Florent BEAUCHAMP
df3a365eb7 fix(xapi): use qcow2 exports only for bigger disks (#9163) 2025-10-30 15:01:05 +01:00
MlssFrncJrg
61ee81ea3a fix(@vates/types): add packages to release in changelog (#9161)
introduced by cfb64f8
2025-10-30 12:50:37 +01:00
vates-bot
2789b339cb feat(web-stack/i18n): update Xen Orchestra 6 (#9095) 2025-10-30 11:59:16 +01:00
Olivier Floch
c895f05486 feat(xo6): introduce constants.ts file to avoid duplications 2025-10-30 11:17:43 +01:00
Olivier Floch
ed3f50cb1a feat(xo6/backups): add breadcrumb in backups pages header 2025-10-30 11:17:43 +01:00
Olivier Floch
6bb90082b9 feat(xo-core): add UiBreadCrumb component 2025-10-30 11:17:43 +01:00
Florent BEAUCHAMP
c870bb653a fix(backups): use qcow2 for export of qcow2 disks (#9160) 2025-10-30 09:20:35 +01:00
Florent BEAUCHAMP
b11484ce6d fix(xo-web): image format in disk tab of vm (#9157) 2025-10-29 16:04:02 +01:00
MlssFrncJrg
cfb64f8e0d feat(@xen-orchestra/rest-api): add boot firmware for VM creation (#9147) 2025-10-29 15:51:11 +01:00
Enée DI IORIO
d6e0f70510 feat(xo-6/backups) add backup targets page (#9048) 2025-10-29 15:33:56 +01:00
Pierre Donias
b47a7055a2 feat(netbox): support Netbox v4.4.x (#9153)
Fixes #8958
2025-10-29 15:26:19 +01:00
MlssFrncJrg
a101e9e02d feat(@xen-orchestra/rest-api): expose gui routes (#9133) 2025-10-29 15:04:11 +01:00
Enée DI IORIO
e547447bb4 feat(xo-6/backups): add backup configuration page (#9008) 2025-10-29 14:49:44 +01:00
Florent BEAUCHAMP
5d92f813f6 feat(xo-server): support import of disk > 2TB toward qcow2 SR (#9148) 2025-10-29 10:13:44 +01:00
Florent BEAUCHAMP
ab4e20b0a5 fix(vhd-lib): error while handling open error (#9151)
fix an error Cannot read properties of undefined while handling an error raised during a vhd file error.
2025-10-29 10:04:25 +01:00
Mathieu
ff4b087be8 feat(xo-cli): add system status download functionality for hosts and pools (#9141)
Add two new API methods to download XCP-ng/XenServer system status (bug reports) via xo-cli:

    host.getSystemStatus - Download system status from a single host
    pool.getSystemStatuses - Download system status from all hosts in a pool in parallel


---------

Co-authored-by: Mathieu Piton <mathieu.piton@vates.tech>
2025-10-29 09:29:20 +01:00
Elise-F
09208ba0fb feat(xo6): added loader in treeview when search is triggered (#9142)
* feat(xo6): added loader in treeview when search is triggered

* update changelog file

* fix(xo6): code cleanup following PR feedback

* fix order packages in changelog file
2025-10-29 09:23:54 +01:00
Elise-F
896e9386a9 feat(xo6): added new links in the user menu and customized it (#9126)
* feat(xo6): added new links in the user menu and customized it

* udpate Changelog file

* fix(xo6): code cleanup following PR feedback

* fix order packages in changelog file
2025-10-29 09:15:10 +01:00
Olivier Lambert
87471d90c4 feat(@xen-orchestra/xapi): expose IPMI sensors for Lenovo hardware (#9149)
---------

Signed-off-by: Olivier Lambert <olivier.lambert@vates.fr>
Co-authored-by: Mathieu <70369997+MathieuRA@users.noreply.github.com>
Co-authored-by: Florent BEAUCHAMP <florent.beauchamp@vates.tech>
2025-10-28 19:15:37 +01:00
Enée DI IORIO
740fc03a04 feat(xo-6/backups): add backed up vms page (#9018)
Co-authored-by: Olivier Floch <olivier.floch@vates.fr>
2025-10-28 17:08:08 +01:00
Florent BEAUCHAMP
45ef65b251 fix(qcow2): number/bigint comparison (#9150) 2025-10-28 15:47:27 +01:00
Florent BEAUCHAMP
bf705e0a98 feat(xo-web): add merge synchronously option to mirror backups (#9118) 2025-10-28 14:15:30 +01:00
Thomas Moraine
989864314f docs: upgrade docusaurus from version 3.8 to 3.9.2 (#9073)
docs: upgrade docusaurus from version 3.8 to 3.9

The onBrokenMarkdownLinks option is deprecated and has been replaced by siteConfig.hooks.onBrokenMarkdownLinks. However, since the option was configured to 'warn' and is the default behavior for this option, deleting the onBrokenMarkdownLinks seems like easier

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-10-28 12:20:53 +01:00
MlssFrncJrg
0a377f9568 feat(@xen-orchestra/rest-api): expose ping (#9129) 2025-10-28 10:10:27 +01:00
Anthoine Bourgeois
cb96de6d11 feat: Qcow2Disk: support sub-cluster feature
Signed-off-by: Anthoine Bourgeois <anthoine.bourgeois@vates.tech>
2025-10-28 09:43:37 +01:00
Anthoine Bourgeois
7e032e9f84 fix: Qcow2Disk: fix host cluster offset mask
Signed-off-by: Anthoine Bourgeois <anthoine.bourgeois@vates.tech>
2025-10-28 09:43:37 +01:00
Bastien Nollet
51855c22d4 feat: technical release (#9146) 2025-10-27 12:13:55 +01:00
MathieuRA
676b059485 fix(xo-lite): set default cores-per-socket value 2025-10-27 11:41:38 +01:00
MathieuRA
81d0d28819 fix(xo-server): set default cores-per-socket value 2025-10-27 11:41:38 +01:00
Pierre Brunet
9123921676 Chore: update rimraf packages to fix glob ending support (#9046)
feat: update rimraf
2025-10-27 09:48:17 +01:00
Mathieu
544590a860 feat(@xen-orchestra/rest-api): expose backup-archives (#8982)
XO-1468
2025-10-24 17:29:10 +02:00
OlivierFL
63e7eb881c feat(xo-6/backups): add backup runs page (#9007) 2025-10-24 11:09:35 +02:00
Bastien Nollet
030d697392 fix(xo-server-load-balancer): correctly avoid migrating tagged VMs (#9139)
Introduced by 9ef05b8afe
2025-10-23 15:45:32 +02:00
Florent BEAUCHAMP
af56933587 fix(backups): wrong error on close before init (#9125)
this happens if the disk init fails, for example when trying
to read qcow2 disk from a non qcow2 SR
2025-10-22 15:56:21 +02:00
bleader
ec0bdb3286 docs(sdn-controller): document OpenFlow XAPI plugin usage (#9114)
- State to use a single XOA instance with SDN Controller plugin enabled
- Cleanup existing OpenFlow documentation
- Document XAPI plugin usage, including xo-cli commands
- Add configuration to toggle between direct channel and XAPI plugin
2025-10-21 16:46:14 +02:00
OlivierFL
70055960d0 feat(xo-core): update logical properties wording in CSS guidelines (#9132) 2025-10-21 11:12:52 +02:00
Pierre Donias
a9ab882b75 fix(fatfs): typos and spelling (#9131)
See #8593
2025-10-20 16:49:04 +02:00
Elise-F
bce7408f1f feat(xo6): add loader on input search treeview when is triggered (#9122)
* feat(xo6): add loader on input search treeview when is triggered

* udpate Changelog file

* fix(xo6): removed condition on props icon in UiInput

* fix(xo6): removed debounced on clear
2025-10-20 15:20:12 +02:00
Mathieu
c8abbb259e fix(@xen-orchestra/rest-api): add bad request response documentation (#9108) 2025-10-20 14:07:38 +02:00
Thomas Moraine
35cc755bf0 docs(xo): specify configuration file path (#9127)
Several sections of the Xen Orchestra Appliance documentation mention modifying the config file, without specifying the actual file path. This commit updates those references to explicitly include /etc/xo-server/config.toml, which helps users locate and edit the correct configuration file more easily.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-10-20 12:10:09 +02:00
Bastien Nollet
5750b58362 fix(xo-web/Size): fix saving Size component (#9121)
Changing the value of a `Size` component by editing the field and pressing
'Enter' was executing twice the `_save()` function in parallel (which calls the
`onChange` function).

In some cases like editing a running VM memory limit, this caused errors (trying
to open two modals at the same time, and trying to restart the VM twice)
2025-10-20 10:28:28 +02:00
Joris K
8a95941297 fix(xo6): fix formatted date in backup jobs (#9124) 2025-10-20 08:45:17 +02:00
Florent BEAUCHAMP
8289150460 feat(vmware-explore): add more debug when failing to get thumbprint (#9012)
from ticket 45117
2025-10-17 14:25:44 +02:00
Florent BEAUCHAMP
87df162944 chores(all): remove jest (#8956)
jest test are unmaintained and flacky, they increase the dependencies surface
2025-10-17 14:24:05 +02:00
Thierry Goettelmann
e9eda42d7d feat(web-stack): new VtsStatus component (#9052) 2025-10-17 10:14:59 +02:00
Thierry Goettelmann
d942d144ca feat(web-core): add objectIcon helper (#9053) 2025-10-17 09:40:50 +02:00
Elise-F
d829816f62 fix(xo6): display user menu above tree structure (#9115)
* fix(xo6): display user menu above tree structure

* add CHANGELOG
2025-10-16 16:47:01 +02:00
Florent BEAUCHAMP
8b3b23e995 fix(backups): throw an error on non unique target for replication (#9117)
from ticket 40151
2025-10-16 15:49:00 +02:00
Cécile Morange
52807ed15f docs(xo): change link to the xcp-ng docs, add some infos in HA section (#9112)
* docs(xo): change link to the xcp-ng docs, add some infos in HA section

* Update docs/docs/manage_infrastructure.md

Co-authored-by: Thomas Moraine <thomas.moraine@vates.tech>

* Update docs/docs/manage_infrastructure.md

Co-authored-by: Thomas Moraine <thomas.moraine@vates.tech>

* Update docs/docs/manage_infrastructure.md

Co-authored-by: Thomas Moraine <thomas.moraine@vates.tech>

---------

Co-authored-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-10-16 14:20:58 +02:00
Florent BEAUCHAMP
596f9ac536 fix(vhd-lib): better error handling
ensure disk is really closed, and that error are caught
2025-10-16 11:30:25 +02:00
Florent BEAUCHAMP
d58efc7b2d fix(xapi): implement Throttling on Xapi/Remote read 2025-10-16 11:30:25 +02:00
Florent BEAUCHAMP
73aa66a219 fix(xapi): implement Timeout on Xapi read 2025-10-16 11:30:25 +02:00
Florent BEAUCHAMP
93c8903953 fix(backups): put back rate speed to full backup, replication and mirror 2025-10-16 11:30:25 +02:00
Florent BEAUCHAMP
dc8c455a0b feat(generator-toolbox): handle unlimited throttle (speed at 0) 2025-10-16 11:30:25 +02:00
Thierry Goettelmann
d601558bf4 feat(web-core): new table system (#9054) 2025-10-16 10:39:22 +02:00
Mathieu
d5cee13e25 fix(@xen-orchestra/rest-api): use middleware instead of route for users/me redirection (#9103)
Introduced by #8985
2025-10-16 09:41:36 +02:00
Florent BEAUCHAMP
151d7ed22a fix(immutable-backups): double delete that can block immut lifting (#9104) 2025-10-15 17:39:11 +02:00
OlivierFL
3d29348337 feat(xo-6/collections): implement virtual lists for Treeview, QuickTasks and Alarms collections in dashboards (#9077) 2025-10-15 17:17:32 +02:00
Thomas Moraine
62ab5a2f5b docs(xo): remove mentions of xscontainer from the xo doc (#9113)
docs(manage_infrastructure.md): remove content related to xscontainer, which is deprecated

Since XSContainer is no longer supported on XCP-ng 8.3 or XenServer and
we currently have no alternative, instructions related to XScontainer in
the XO doc are removed

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-10-15 14:33:31 +02:00
Pierre Brunet
c2d1c66a59 fix(plugin/auth-saml): add default config in SAML (#9093) 2025-10-14 16:30:58 +02:00
Bastien Nollet
730d0ff41d fix(xo-web/Toggle): swallow undefined errors thrown by confirm (#8953)
Updating the `Toggle` component to avoid throwing an error coming from a
`confirm` modal, which ends up appearing in the console.

This also changes a bit the behaviour of the `Toggle` component, which now
awaits its `onChange` function.
2025-10-14 15:11:18 +02:00
Mathieu
64d164300b feat(@xen-orchestra/rest-api): expose pbds (#9106) 2025-10-14 14:44:26 +02:00
Mathieu
04eeb78df7 fix(@vates/types): fix XoSr allocationStrategy (#9105) 2025-10-14 10:44:33 +02:00
Mathieu
6bdfbfb953 feat(@xen-orchestra/rest-api): expose users/authentication_tokens (#9102) 2025-10-14 09:48:31 +02:00
Mathieu
e6573e26a9 feat(xo-server/rest-api): remove legacy code (#9101) 2025-10-14 09:41:28 +02:00
Mathieu
5f5cb9b45d feat(@xen-orchestra/rest-api): expose vdis/:id.(vhd|raw) (#9038)
* feat(@xen-orchestra/rest-api): expose vdi(s|-snapshots)/:id.(vhd|raw)
2025-10-10 14:39:31 +02:00
MlssFrncJrg
940e879a1c feat(@xen-orchestra/rest-api): expose vm-controllers/:id/tags/:tag (#9097) 2025-10-10 11:36:11 +02:00
MlssFrncJrg
861567d0db feat(@xen-orchestra/rest-api): expose vm-templates/:id/tags/:tag (#9099) 2025-10-10 11:23:57 +02:00
MlssFrncJrg
8e536a4c32 feat(@xen-orchestra/rest-api): expose vm-snapshots/:id/tags/:tag (#9098) 2025-10-10 11:10:08 +02:00
MlssFrncJrg
e100736349 feat(@xen-orchestra/rest-api): expose srs/:id/tags/:tag (#9089) 2025-10-10 11:03:17 +02:00
MlssFrncJrg
98e4bcccfa feat(@xen-orchestra/rest-api): expose vdis/:id/tags/:tag (#9094) 2025-10-10 10:43:35 +02:00
MlssFrncJrg
938b34343d feat(@xen-orchestra/rest-api): expose vdi-snapshots/:id/tags/:tag (#9091) 2025-10-10 10:31:03 +02:00
MlssFrncJrg
e1ecc2d6d4 feat(@xen-orchestra/rest-api): remove vifs/:id/tags/:tag (#9096) 2025-10-10 10:22:12 +02:00
Pierre Donias
afabf35c66 fix(xo-web/Copiable): remove gap in hover box (#9059)
See Zammad#45442

Wrap "Copy to clipboard" button with a span to make the hoverable zone
continuous between the copiable element and the button. Without this, the 1ex
gap between the 2 was sometimes enough to make the button disappear before the
user had a chance to click it.
2025-10-10 10:19:50 +02:00
MlssFrncJrg
8e0f48bddd feat(@xen-orchestra/rest-api): remove vbds/:id/tags/:tag (#9090) 2025-10-10 10:13:34 +02:00
MlssFrncJrg
70dc82e621 feat(@xen-orchestra/rest-api): expose srs/:id/tasks (#9086) 2025-10-10 10:03:18 +02:00
Mathieu
2928813613 feat(@xen-orchestra/rest-api): expose vms/:id/tags/:tag (#9092) 2025-10-10 09:54:57 +02:00
MlssFrncJrg
a86d07adea feat(@xen-orchestra/rest-api): expose vbds/:id/tasks (#9085) 2025-10-10 09:48:00 +02:00
Mathieu
a282e10069 feat(@xen-orchestra/rest-api): expose pools/:id/tasks (#9080) 2025-10-09 16:39:13 +02:00
MlssFrncJrg
8a70196fa9 feat(@xen-orchestra/rest-api): expose pools/:id/tags/:tag (#9088) 2025-10-09 14:40:04 +02:00
MlssFrncJrg
1d8ae5eea8 feat(@xen-orchestra/rest-api): expose networks/:id/tags/:tag (#9087) 2025-10-09 12:54:03 +02:00
Mathieu
055bce7d91 feat(@xen-orchestra/rest-api): expose hosts/:id/tags/:tag (#9037) 2025-10-09 10:47:54 +02:00
Thomas Moraine
1ee07eb06f docs(xo): document object storage support list (#9068)
Adds a page to the Xen Orchestra documentation to list the compatible S3-based
storage support providers for Xen orchestra, with the different support tiers
and appropriate caveats.
2025-10-09 09:53:50 +02:00
Joris K
6a5eb12051 fix(xo6/site): issue in backups tab (#9064)
* fix(xo6/site): issue in backups tab

* add changelog
2025-10-08 17:50:05 +02:00
Pierre Brunet
fbf3f1bc7f chore: update doc for SAML due to breaking changes in config (#9084) 2025-10-08 17:13:31 +02:00
MlssFrncJrg
dedfa91669 feat(@xen-orchestra/rest-api): expose vdis-snapshots/:id/tasks (#9082) 2025-10-08 15:47:16 +02:00
MlssFrncJrg
f3480f6939 feat(@xen-orchestra/rest-api): expose vdis/:id/tasks (#9079) 2025-10-08 15:38:35 +02:00
Mathieu
e48166846c feat(@xen-orchestra/rest-api): expose vbds/:id/messages (#9029) 2025-10-08 15:23:05 +02:00
Mathieu
f7a79a0be3 feat(@xen-orchestra/rest-api): expose hosts/:id/tasks (#9074) 2025-10-08 15:02:42 +02:00
Mathieu
f0f1fd1715 feat(@xen-orchestra/rest-api): expose networks/:id/tasks (#9076) 2025-10-08 14:56:47 +02:00
MlssFrncJrg
2e4c45b207 fix(xo-server/rest-api): fix indentation (#9081) 2025-10-08 14:49:44 +02:00
Mathieu
72a3f0a9da feat(@xen-orchestra/rest-api): expose pifs/:id/tasks (#9078) 2025-10-08 14:45:05 +02:00
MlssFrncJrg
2d6f784f0a feat(@xen-orchestra/rest-api): expose vifs/:id/tasks (#9075) 2025-10-08 14:19:48 +02:00
MlssFrncJrg
614449d540 feat(@xen-orchestra/rest-api): migrate vm-controllers tasks (#9069) 2025-10-08 14:12:57 +02:00
Thierry Goettelmann
53ff333778 feat(web-core): introducing toComputed helper (+ migrate codebase) (#8960) 2025-10-08 11:49:12 +02:00
Thierry Goettelmann
4207954982 feat(web-stack): migrate modal system to web-core + enhancements + new UiModal (#8887) 2025-10-08 11:48:46 +02:00
Mathieu
6af64c7095 feat(@xen-orchestra/rest-api): expose groups/:id/tasks (#9072)
XO-1187
2025-10-08 11:05:28 +02:00
Mathieu
7d03560a3e feat(@xen-orchestra/rest-api): expose users/:id/tasks (#9066)
XO-1188
2025-10-08 10:26:06 +02:00
Julien Fontanet
9f9519ef15 docs(xoa): use HTTP for alternative install (#9071)
Introduced by 51b06b1177

Fixes #9067
2025-10-07 20:29:29 +02:00
MlssFrncJrg
0838b112de feat(@xen-orchestra/rest-api): migrate vm-templates tasks (#9004) 2025-10-07 17:29:48 +02:00
Pierre Donias
d6e4456538 fix(xo-web/VM/ActionBar): do not hide actions if user is admin (#9061)
See Zammad#44366

Admin users should always be able to perform actions on VMs even if the VM was
created with Self Service.
2025-10-07 15:57:06 +02:00
Mathieu
022dc0bf2e feat(@xen-orchestra/rest-api): expose servers/:id/tasks (#9065) 2025-10-07 14:55:51 +02:00
MlssFrncJrg
49909df1d2 feat(@xen-orchestra/rest-api): migrate vm-snapshots tasks (#9005) 2025-10-07 14:35:05 +02:00
Mathieu
7c7d7a938e feat(@xen-orchestra/rest-api): expose vm-controllers/:id/messages (#9050) 2025-10-07 14:16:14 +02:00
Mathieu
a67ad48012 feat(@xen-orchestra/rest-api): expose vifs/:id/messages (#9049) 2025-10-06 16:29:27 +02:00
Mathieu
6c6d7a377f feat(@xen-orchestra/rest-api): expose vdis/:id/messages (#9044) 2025-10-06 16:24:11 +02:00
Mathieu
d3006026ca feat(@xen-orchestra/rest-api): expose vdi-snapshots/:id/messages (#9043) 2025-10-06 16:04:41 +02:00
Mathieu
62cdd56691 feat(@xen-orchestra/rest-api): expose networks/:id/messages (#9023) 2025-10-06 15:58:25 +02:00
Mathieu
775e8a8b12 feat(@xen-orchestra/rest-api): expose pifs/:id/messages (#9021) 2025-10-06 15:49:44 +02:00
Mathieu
f5676705cc fix(@xen-orchestra/rest-api): move getTasksObject out of a service (#9058)
Introduced #8955
2025-10-06 15:40:29 +02:00
Pierre Donias
0bad96418b feat: release 5.111.1 (#9062) 2025-10-06 15:26:01 +02:00
Pierre Donias
3222025088 feat: technical release (#9060) 2025-10-06 14:15:47 +02:00
Pierre Brunet
86f3096c1b chore: update packages (#9042)
- passport-saml -> @node-saml/passport-saml
- connect-flash -> typed-flash
2025-10-06 10:42:44 +02:00
Florent BEAUCHAMP
d7fe4f1729 fix(vmware-explorer): thumbprint is case insensitive (#9045)
* fix(vmware-explorer): thumbprint is case insensitive

depending on version ? it can answer a text starting woth sha1 or SHA1
2025-10-03 13:47:27 +02:00
Florent BEAUCHAMP
64a2666645 fix(asyncEach): handle iterator error
this was the cause of  xo:backups:worker WARN possibly unhandled rejection
and the backup error code not triggerring on timeout and various
edge cases
2025-10-03 11:19:55 +02:00
Florent BEAUCHAMP
3b7f623f9e fix(fs): retry writing a file should not throw EEXIST on previous failed write
also removing the retry on outputfile, since outputfile call _writeFile which
is already retried
2025-10-03 11:19:55 +02:00
Florent BEAUCHAMP
16cdbc16fa fix(v2v): do not lock stopped VM (#9047)
imported VM are locked to prepare for a future resume
stopped VM are completly transferred and won't need a future resume
2025-10-03 10:23:26 +02:00
Mathieu
52510ed03c fix(xo-server/users): name property was not present in getAllUsers (#9019)
* fix(xo-server/users): name property was not present in getAllUsers

* Update packages/xo-server/src/xo-mixins/subjects.mjs

Co-authored-by: Florent BEAUCHAMP <florent.beauchamp@vates.tech>

---------

Co-authored-by: Florent BEAUCHAMP <florent.beauchamp@vates.tech>
2025-10-03 10:05:27 +02:00
Mathieu
bbec7d4532 feat(@xen-orchestra/rest-api): expose pools/:id/messages (#9022)
* feat(@xen-orchestra/rest-api): expose pools/:id/messages
2025-10-02 15:13:45 +02:00
Mathieu
cf0449c034 fix(@xen-orchestra/rest-api): fix messages tag (#9024) 2025-09-30 17:00:38 +02:00
Mathieu
9e93ba14ae feat(@xen-orchestra/rest-api): expose hosts/:id/messages (#9027) 2025-09-30 17:00:28 +02:00
Mathieu
bf330311a1 feat(@xen-orchestra/rest-api): expose srs/:id/messages (#9028) 2025-09-30 16:53:56 +02:00
Pierre Donias
481ef5e585 feat(lite): 0.15.0 (#9036) 2025-09-30 16:21:33 +02:00
Pierre Donias
a5debf3241 feat: release 5.111.0 (#9035) 2025-09-30 16:07:25 +02:00
Pierre Donias
d8c106fc5d feat: technical release (#9034) 2025-09-30 15:46:07 +02:00
Enée DI IORIO
8d2af27896 fix(xo-lite/settings): fix key-value alignment (#8981)
* feat(xo-lite/settings): fix key-value alignment

Refactor page for use of more regular components and match better to Figma

* review

* review
2025-09-30 15:40:21 +02:00
Joris K
893cdcbc86 feat(xo6): add alarms in host and vm dashboard, add patches in host dashboard (#8937)
* feat(xo6): add alarms in host and vm dashboard, add patches in host dashboard

* feedback
2025-09-30 15:00:33 +02:00
Pierre Donias
241c37b35c feat: technical release (#9033) 2025-09-30 13:54:47 +02:00
Thierry Goettelmann
57e1b94115 fix(web): failing build (#9030)
Introduced by #8966
2025-09-30 13:25:52 +02:00
Thierry Goettelmann
7c670b479e fix(web): unable to change tab in tasks quick panel (#8930)
* fix(web): unable to change tab in quick task panel

* update CHANGELOG.unreleased.md
2025-09-30 12:33:02 +02:00
Pierre Donias
a1103f6da9 feat: technical release (#9026)
* feat(@xen-orchestra/web-core): 0.31.0

* feat(@xen-orchestra/rest-api): 0.17.0

* feat(@xen-orchestra/web): 0.29.0

* feat(xo-server): 5.190.0

* feat(xo-server-sdn-controller): 1.2.0

* feat(xo-web): 5.187.0

* chore(CHANGELOG): update next
2025-09-30 09:52:59 +02:00
Pierre Brunet
4c5b5f603b feature(hub/recipes): add RGPD compliance in EasyVirt form and bootAfterCreate option (#9016)
Add RGPD compliance in easyvirt creation form and bootAfterCreate option
2025-09-30 09:08:46 +02:00
Pierre Brunet
d77d6d0d9f fix(new-vm): prevent error in case of empty usersshkeys (#9015)
Introduced by #8968 
Reported in #9014
2025-09-29 11:11:05 +02:00
Mathieu
93be967459 feat(@xen-orchestra/xapi): expose vm-templates messages (#8995) 2025-09-29 10:22:59 +02:00
Florent BEAUCHAMP
a62ac1ab82 feat(sdn-controller): use host plugin (#8488)
* feat(sdn-controller): use XCP plugin to manage open switch

try to instantiate the plugin and fall back to direct query
if it fails
The plugin allow to define and target multiple bridges in parallel

* feat(sdn-controller): implement network wide rules

this introduce 2 new API method sdnController.addNetworkRule
and sdnController.deleteNetworkRule
Theses methods will only work with the plugin, and can't work with
the legacy ( direct channel) approach
2025-09-26 17:46:45 +02:00
OlivierFL
55f51184c2 feat(xo-6/vm): add side panel in VM backup jobs page (#8978) 2025-09-26 17:30:37 +02:00
OlivierFL
f7ae461337 feat(xo-6/backups): add backup jobs side panel (#8966) 2025-09-26 17:21:42 +02:00
vates-bot
3f57f2b8ec feat(web-stack/i18n): update Xen Orchestra 6 (#8901) 2025-09-26 13:50:23 +02:00
Pierre Donias
2786c9c392 feat(xo-web/pool): dc scope/netscope button (access or deploy) (#8951)
Try to detect a DC Scope and/or DC NetScope VM and add buttons to access the DC
Scope/NetScope instances.
2025-09-26 10:20:04 +02:00
Joris K
332b4d6197 fix(xo6/vm): fix issue due to incorrect value naming (#8998) 2025-09-26 09:39:58 +02:00
Lux_
8fa7a77fab feat(xo-core/i18n): update french translations (#8983) 2025-09-26 09:34:45 +02:00
Pierre Donias
a0046fa19f feat: technical release (#9013) 2025-09-25 16:15:05 +02:00
Pierre Brunet
692f60c323 feat(xo-web/recipes): add recipe to create EasyVirt VMs (DC Scope and DC NetScope) (#8797)
See xoa#244
2025-09-25 14:45:04 +02:00
Florent BEAUCHAMP
9e4fe7a2a3 fix(xo-web/xostor): resource without volumes should be shown (diskless/tiebreaker) (#8944)
Some tie breaker and diskless volume can be useful for support and advanced usages
2025-09-25 14:09:43 +02:00
Pierre Donias
bcee503a77 chore(CHANGELOG): update LDAP security fix entry (#9011)
See #8639
2025-09-25 11:01:56 +02:00
Mathieu
29af04b8f2 fix(sdn-controller/addHost): fix no PIF found in center (#9000) 2025-09-24 15:19:43 +02:00
Mathieu
c00659ba61 fix(@vates/types): fix typo in warning (#9006) 2025-09-24 12:15:34 +02:00
OlivierFL
d7afda4c52 feat(xo-6/treeview): add debounce to search input (#8992) 2025-09-23 17:05:27 +02:00
Mathieu
d07732119b feat(@xen-orchestra/rest-api): expose vm-snapshots/:id/messages (#8997) 2025-09-23 16:25:35 +02:00
MlssFrncJrg
396ba3868f feat(@xen-orchestra/rest-api): migrate vms tasks (#8955) 2025-09-23 16:15:05 +02:00
MlssFrncJrg
4453bbc16c feat(@xen-orchestra/rest-api): expose users authentication_tokens (#8865) 2025-09-23 15:51:09 +02:00
Mathieu
b01adbe2e1 feat(@xen-orchestra/rest-api): expose (backup|restore)-logs (#8987) 2025-09-23 14:30:42 +02:00
Thomas Moraine
46f5e7bc0d docs(xo): add backup strategy guide (#8881)
Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
Co-authored-by: Florent BEAUCHAMP <florent.beauchamp@vates.tech>
2025-09-23 14:08:19 +02:00
Thomas Moraine
d76e7744e2 docs(xo): document the new migration process (#8934)
Since the 5.110 release of Xen Orchestra, the Vmware to Vates (V2V) process has been updated, with a whole new backend. This commit documents this new process, in the XO documentation

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-09-22 16:52:49 +02:00
Mathieu
b6100980fc feat(@xen-orchestra/rest-api): expose vms/:id/messages (#8988) 2025-09-22 16:23:24 +02:00
Pierre Donias
b8a839068e fix(xo-web/getLicenseNearExpiration): properly check if trial is running (#8991)
Pass the license object to isTrialRunning instead of passing the parent object.

See Zammad#44878 Zammad#44755 Zammad#44431 Zammad#43526 Zammad#44556 ...
Introduced by #8557
See #8523 → #8551 → #8557
2025-09-22 16:22:01 +02:00
Pierre Donias
d2fd68df74 feat(xo-web/PIFs): natural sort by device (#8967) 2025-09-22 09:55:33 +02:00
Mathieu
5d03dc3c10 fix(@xen-orchestra/backups): fix exportIncrementalVm VTPMs (#8990) 2025-09-22 09:55:13 +02:00
Bastien Nollet
1c01f1c7fe fix(xo-server-load-balancer): prevent mass migration when receiving incorrect CPU data (#8965) 2025-09-19 19:20:30 +02:00
Enée DI IORIO
8135148217 feat(xo-lite/settings): add icon for external link (#8980) 2025-09-19 11:02:08 +02:00
Mathieu
067ee26715 feat(@xen-orchestra/rest-api): expose POST srs/:id/vdis (#8984) 2025-09-19 10:13:19 +02:00
Mathieu
c8827ab076 feat(@xen-orchestra/rest-api): expose /users/me* (#8985) 2025-09-19 09:40:30 +02:00
Mathieu
ba4b9b6cb3 feat(@xen-orchestra/rest-api): expose backup-jobs (#8970)
XO-643
2025-09-17 15:19:25 +02:00
Pierre Donias
44d93b90b3 fix(xo-server/resource-sets): only edit limits if limits is passed (#8979)
See https://xcp-ng.org/forum/topic/11241/why-changing-objects-in-resource-set-wipe-limits
Introduced by #7353
2025-09-17 14:02:58 +02:00
Pierre Brunet
7bb6592be4 feat(xo-web/new-vm): add sshkeys in cloudconfig (#8968) 2025-09-17 10:10:20 +02:00
Julien Fontanet
8f2b8791cc feat(complex-matcher): 1.0.0 2025-09-16 14:57:12 +02:00
Julien Fontanet
b56ffbdb8d feat(complex-matcher/Comparison): can match with non numbers
It will use implicit conversion.
2025-09-16 14:53:20 +02:00
Julien Fontanet
b4e05b3183 chore(complex-matcher): add tests for Comparison 2025-09-16 14:52:54 +02:00
Julien Fontanet
c2c8d1b1ea chore(complex-matcher): move number check to Comparison constructor 2025-09-16 14:52:54 +02:00
Joris K
857d038316 feat(xo6/vm): add backup jobs view (#8976) 2025-09-15 17:15:42 +02:00
Joris K
b16d568b7a feat(xo6/lite): update vtsStateHero component (#8910) 2025-09-15 14:33:37 +02:00
Florent BEAUCHAMP
1471ab0c7c fix(backups): replication doing always a full (#8971)
introduced by 8935
sr.$VDIs are xen api objects not xo objects
2025-09-15 14:29:37 +02:00
Mathieu
ee74a640b4 feat(@xen-orchestra/rest-api): expose delete vdi*/:id (#8961) 2025-09-15 14:16:40 +02:00
Mathieu
44bfc28a44 fix(@vates/types,@xen-orchestra/rest-api): fix eslint config (#8974) 2025-09-15 12:25:31 +02:00
Joris K
de0c535976 feat(xo6/lite): update quick info in dashboard VM to match figma (#8952) 2025-09-15 11:05:57 +02:00
Joris K
3fdc97b690 fix(CHANGELOG): add missing packages (#8975)
introduced by ec042d1
2025-09-15 10:05:12 +02:00
Joris K
ec042d1089 feat(web-core): update ui collapsible list component (#8954) 2025-09-12 16:41:54 +02:00
Florent BEAUCHAMP
a459015ca9 Fix(replication): VDI_NOT_MANAGED error (#8935)
from ticket #40151
2025-09-11 15:42:34 +02:00
Florent BEAUCHAMP
4944ea902f fix(schedule): mark sequence progress as completly done on success (#8527) 2025-09-10 17:49:56 +02:00
Mathieu
4c93609bfe feat(xo-server/rpu): add more XO task (#8950) 2025-09-10 17:24:30 +02:00
Bastien Nollet
c2144947c9 feat(xo-server): add a backup guard to reclaiming space on SR (#8947) 2025-09-10 11:06:36 +02:00
Pierre Donias
0d481ffb18 chore: update dependencies (#8888) 2025-09-09 16:48:09 +02:00
Mathieu
b4c22f623d feat(@xen-orchestra/rest-api): expose vms/:id/backup-jobs (#8948)
* feat(@xen-orchestra/rest-api): expose vms/:id/backup-jobs
2025-09-09 15:10:20 +02:00
Pierre Donias
e84e34efa5 fix(xo-web/pool-item): handle undefined license info in render() (#8946)
See Zammad#44148
Introduced by #6655
2025-09-09 15:06:21 +02:00
Mathieu
878e8ceea7 feat(@xen-orchestra/rest-api): expose users/:id/groups (#8936) 2025-09-09 14:58:07 +02:00
Mathieu
876c28d392 feat(@xen-orchestra/rest-api): expose groups/:id/users (#8932) 2025-09-09 14:19:13 +02:00
Bastien Nollet
6a08b82d86 chore(packages): remove unused xo-server-test (#8945)
remove unused xo-server-test
2025-09-09 10:18:06 +02:00
Mathieu
9dda9be6f0 feat(@xen-orchestra/rest-api): expose delete /vms*/:id (#8938) 2025-09-08 17:22:53 +02:00
Pierre Brunet
98ddd115fd feat(xo-web): change vif device cast to number (#8877) 2025-09-08 17:14:54 +02:00
Mathieu
39cb465941 feat(@xen-orchestra/rest-api): expose (vms/vm-snapshots/vm-templates)/:id.(xva|ova) (#8929) 2025-09-08 16:51:48 +02:00
Mathieu
f28181b07c feat(@xen-orchestra/rest-api): expose tasks/:id/actions/abort (#8908) 2025-09-08 16:42:37 +02:00
Mathieu
31bbf0bdff feat(@xen-orchestra/rest-api): expose (vdis|vdi-snapshots)/:id.(raw|vhd) (#8923) 2025-09-08 12:28:13 +02:00
Mathieu
a509a7c6b7 feat(@xen-orchestra/rest-api): expose delete tasks and tasks/:id (#8905) 2025-09-08 12:19:52 +02:00
Bastien Nollet
02b732b9da fix(xo-web/pool/advanced): only suggest SRs from pool when enabling HA (#8924)
The modal to activate HA used to suggest SRs from all pools, but selecting a SR from another pool would result to an error. This change makes that modal only suggest SRs from the correct pool.
2025-09-08 11:16:39 +02:00
Florent BEAUCHAMP
89e4f051fc feat(xo-web/home): add copiable id on templates (#8949) 2025-09-08 11:01:49 +02:00
Bastien Nollet
d36d64380d feat(lite): 0.14.1 (#8943) 2025-09-04 16:02:07 +02:00
Bastien Nollet
1dc2d2b6fa feat: release 5.110.1 (#8941) 2025-09-04 14:55:50 +02:00
Bastien Nollet
348dfab021 feat: technical release (#8940)
* feat(@xen-orchestra/xapi): 8.4.1

* feat(@xen-orchestra/backups): 0.64.2

* feat(@xen-orchestra/proxy): 0.29.30

* feat(@xen-orchestra/vmware-explorer): 0.10.2

* feat(@xen-orchestra/web): 0.27.1

* feat(xo-server): 5.188.0

* feat(xo-web): 5.185.0

* chore(CHANGELOG): update next
2025-09-04 09:59:47 +02:00
Bastien Nollet
fbc5e3e071 fix(@xen-orchestra/xapi): better fallback messages for backups (#8926)
better fallback messages for backups. 
This will give the user more information on mixed delta/base backups disk per disk
2025-09-04 09:28:11 +02:00
Florent BEAUCHAMP
f7feaaa4f9 feat(xo-server): Make timeout for import xva from url configurable (#8931)
Make timeout for import xva from url configurable

the entry in the config file is

[jsonrpc-api]
xvaImportFromUrlTimeout = '6s'
2025-09-04 09:11:35 +02:00
Florent BEAUCHAMP
a96865198d feat(xo-server/v2v): UI fixes (#8927) 2025-09-03 17:31:59 +02:00
Florent BEAUCHAMP
114df4e18e fix(xo-server/v2v): transfer resume retransfer the full VM (#8928)
current code was looking for a snapshot, but the import should write
on the VM, and its disk
2025-09-03 16:02:45 +02:00
Mathieu
3676ce9764 fix(CHANGELOG): fix changelog URL (#8933) 2025-09-03 10:33:53 +02:00
Thomas Moraine
3512a2f768 docs(xo): document recipes (#8919)
Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-09-03 09:55:56 +02:00
Dan Pollak
d68c8c7ee0 fix(v2v): messages typos (#8914) 2025-09-02 14:02:06 +02:00
Mathieu
0c2c948712 fix(xo-lite/vm/system): correctly display secure boot (#8925) 2025-09-02 13:53:15 +02:00
Joris K
c9d053e70d fix(xo6): display only ISO VDIs for the ISO input list in the VM creation form (#8922) 2025-09-02 13:45:49 +02:00
Mathieu
c3905bc10d feat(@xen-orchestra/rest-api): expose proxies (#8920) 2025-09-01 14:51:38 +02:00
Thierry Goettelmann
ba30ca08d9 fix(web): typo host dashboard ram provisioning legend (#8915) 2025-09-01 10:00:45 +02:00
Bastien Nollet
4dfd4829ad feat(lite): 0.14.0 (#8918) 2025-08-28 16:33:57 +02:00
Bastien Nollet
e0fccacc2f feat(lite): 0.14.0 (#8917) 2025-08-28 16:10:41 +02:00
Bastien Nollet
3937eadbce feat: release 5.110.0 (#8916) 2025-08-28 15:34:56 +02:00
Bastien Nollet
043387a913 feat: technical release (#8913) 2025-08-28 11:41:28 +02:00
Pierre Brunet
88f2e3b7eb fix(backup): set transfer tag only on AbstractRemote (#8907)
* fix(backup): add transfer tag in FullRemote
* fix: move tag where it is really useful
2025-08-28 11:03:34 +02:00
Florent BEAUCHAMP
ea6410e711 fix(backup) error footer1 !== footer2 (#8882)
* fix(vhd-lib): remove unused parent locator
* fix(vhd-lig): improve error messages one wrong end footer
2025-08-28 10:58:27 +02:00
Florent BEAUCHAMP
e1ce9eca7b fix(v2v): improve vddk import (#8911)
* fix(nbd-client): better handling of error during getMap
* fix(xo-server): esxi check errors are objects instead of text
* fix(xo-web): V2v/check improve alignment
* feat(xo-web): add button to install nbdinfo/nbdkit if needed
2025-08-28 10:46:54 +02:00
Thierry Goettelmann
b0cfd9e7f9 feat(web-stack): VtsProgressBar + VtsProgressBarGroup + migration (#8899) 2025-08-28 10:16:39 +02:00
OlivierFL
4f44af6763 fix(xo-6/backup-jobs): correctly display "pending" jobs (#8909) 2025-08-28 09:41:53 +02:00
Bastien Nollet
384bb37dd4 feat: technical release (#8906) 2025-08-27 11:54:12 +02:00
Mathieu
a4410a3c82 fix(@xen-orchestra/rest-api): fix watch tasks (#8900) 2025-08-27 10:18:22 +02:00
Florent BEAUCHAMP
e900f61ed6 fix(vmware-explorer): ignore incomplete vm 2025-08-26 18:36:13 +02:00
Florent BEAUCHAMP
a65f6cd42d feat(xo-server): implement vddk + v2V 2025-08-26 18:36:13 +02:00
Florent BEAUCHAMP
aee55234ea feat(xo-server): expose api to check vddk install 2025-08-26 18:36:13 +02:00
Florent BEAUCHAMP
5ddb78fdf6 task(read-chunk): improve error message and type 2025-08-26 18:36:13 +02:00
Florent BEAUCHAMP
f8cb07596b feat(nbd-client): implement a Disk adapter based on NBD connection 2025-08-26 18:36:13 +02:00
Florent BEAUCHAMP
6c32155327 feat(nbd-client): implement getMap based on nbdinfos 2025-08-26 18:36:13 +02:00
Thierry Goettelmann
c6d8ccf85a feat(web-stack): migration all icons to new icon library (#8736) 2025-08-26 17:51:05 +02:00
Pierre Brunet
659e636657 fix(fs): update timeout for b2 listing (#8903) 2025-08-26 17:28:08 +02:00
Pierre Brunet
d15776e44a fix(backups): prevent Health check error about tags if no transfer during backups (#8879)
add flag to prevent checking for tags if no transfer was executed in incremental backups
2025-08-26 17:06:48 +02:00
Bastien Nollet
e9ba0bd494 feat(xo-server): implement unregisterRestApi (#8898) 2025-08-26 16:41:55 +02:00
OlivierFL
f1e3e6968a feat(xo-6/account-menu): add link to REST API documentation (#8904) 2025-08-26 16:38:27 +02:00
Florent BEAUCHAMP
354284f6ca feat(xo-web): add link to rest api doc (#8902) 2025-08-26 15:16:50 +02:00
Pierre Brunet
f53b78bef1 feat(@xen-orchestra/rest-api): add docs/swagger.json route (#8892) 2025-08-26 14:57:42 +02:00
OlivierFL
769dc19baa feat(xo-6/site): add backup jobs view (#8889) 2025-08-26 14:41:46 +02:00
vates-bot
9d3cffd211 feat(web-stack/i18n): update Xen Orchestra 6 (#8837) 2025-08-26 10:01:50 +02:00
Mathieu
c1c3b56441 feat(@xen-orchestra/rest-api): expose host missing_patches (#8862) 2025-08-25 17:44:23 +02:00
Thierry Goettelmann
e0748796eb feat(web-core): new useProgress and useProgressGroup composables (#8897) 2025-08-25 17:15:13 +02:00
Thierry Goettelmann
4ac481e311 feat(web-core): new useThreshold composable (#8896) 2025-08-25 16:47:33 +02:00
Thierry Goettelmann
05847ca1fc feat(lite): add i18n engage link on settings page (#8884) 2025-08-25 16:31:21 +02:00
OlivierFL
dd7538b82b feat(xo-core): update UiTablePagination component (#8891) 2025-08-25 15:37:45 +02:00
Bastien Nollet
b665bb3e6e fix(xo-server): don't end sequence on skipped backup (#8859) 2025-08-25 15:01:53 +02:00
Mathieu
a324da30bc feat(@xen-orchestra/rest-api): expose pools/:id/missing_patches (#8871) 2025-08-25 14:45:25 +02:00
Mathieu
5911d266f9 feat(@xen-orchestra/xapi): expose IPMI sensors for Dell hardware (#8861) 2025-08-25 14:42:52 +02:00
Thomas Moraine
2090898ce2 docs(xo): explain causes of large incremental backups (#8860)
Added a new section to the Incremental backups documentation to explain why some deltas can be unexpectedly large. Covers common causes and includes tips for investigation and mitigation.

This helps clarify expected behavior and avoids confusion when backup sizes seem too large.

add tips for 2tb+ disks and qcow disks

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>

---------

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-08-25 11:47:07 +02:00
Mathieu
5d66e6de5f feat(@xen-orchestra/rest-api): expose xo tasks (#8843) 2025-08-25 09:17:00 +02:00
Bastien Nollet
4eeae48ade feat: technical release (#8894) 2025-08-21 15:17:07 +02:00
Thomas Moraine
6a2d3e848b docs(xo): minor improvements to the load balancing page (#8893)
Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-08-21 13:58:54 +02:00
Thomas Moraine
e4bf576efa docs(xo): explain the usage reports plugin more explicitly (#8890)
* docs(xo): explain the usage reports plugin more explicitly

Previously, it wasn't clear what the toggle called All did. This commit makes the documentation more clear about how it works.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>

* Update docs/docs/manage_infrastructure.md

Co-authored-by: Pierre Donias <pierre.donias@gmail.com>

---------

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
Co-authored-by: Pierre Donias <pierre.donias@gmail.com>
2025-08-20 11:27:22 +02:00
Thomas Moraine
76071b0732 docs(xo): restructure and expand load balancing doc (#8886)
Make the load balancing documentation clearer, better structured, with more illustrations, so that users can follow the process of creating or editing a load balancing plan without friction or hassle

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-08-20 10:54:27 +02:00
OlivierFL
6eaeab56a7 feat(xo-core): create UiCollapsibleList component (#8857) 2025-08-19 16:30:13 +02:00
OlivierFL
17640142ee feat(xo-core): update UiLogEntryViewer component (#8880) 2025-08-19 16:27:11 +02:00
Thomas Moraine
ff85cae993 docs(xo): mention automatic daily deployment of documentation (#8885)
The README now specifies that Xen Orchestra documentation updates are deployed automatically once per day. This makes it clearer for users that the online docs are always up to date without requiring manual intervention.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-08-18 09:49:23 +02:00
Thomas Moraine
70d59eb5f9 docs(xo): document network bonding (#8874)
Network bonding was absent from the Xen Orchestra documentation. Information about this feature was previously only available in a blog article on XCP-ng.

This commit updates the XO doc so that it properly documents this feature, and remains the single source of truth in this topic. XO users won't have to dig through blog posts to find the relevant information, making the documentation more useful and trustworthy.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-08-13 15:18:26 +02:00
Thierry Goettelmann
e0b9bc2004 feat(web): introducing defineRemoteResource (Web Core) and useXoCollectionState (XO 6) to handle XO API resources (#8855) 2025-08-13 10:28:40 +02:00
Pierre Donias
c1c88c6797 feat(lite/release): check that version matches build version (#8870)
* feat(lite/release): check that version matches build version

1. Let Vite's build get the last commit hash instead of passing an env variable
2. Emit a build file containing the current commit hash and package version
3. Before releasing a new version, compare hash and version with current git commit and version passed by user

* readJson
2025-08-12 12:04:20 +02:00
Mathieu
7ab857f5a7 feat(@xen-orchestra/rest-api): enhance getCurrentUser (#8875)
In the REST API, we generally work on authenticated requests. getCurrentUser can therefore be a bit tedious, as it can return undefined | XoUser. So, whenever we need the current user in an authenticated request, we need to add a check:
if user === undefined throw for typescript to be happy

So now: getCurrentUser returns XoUser (throw if no user) and getCurrentUser({throwUnauthenticated: false}) returns undefined | XoUser
2025-08-12 12:03:15 +02:00
Mathieu
199bf00df7 feat(@xen-orchestra/rest-api): expose vm(*)/:id/vdis (#8876)
Enhancements:

    Fixed @vates/types
    Added limitAndFilterArray in utils.helper to easily handle user filter/limit everywhere
    Enhance getObject type in rest-api
    makeObjectMapper can take a function to define the path (for path based on the object type)
    add getVmVdis in vmService to get VDIs from a VM

Endpoints:

    /vms/:id/vdis
    /vm-snapshots/:id/vdis
    /vm-controllers/:id/vdis
    /vm-templates/:id/vdis
2025-08-12 12:01:49 +02:00
Thierry Goettelmann
909cfd844a feat(web-stack): migrate FormSelect / select to VtsSelect + enhance useFormSelect (#8681) 2025-08-11 17:27:35 +02:00
Thierry Goettelmann
63d5de04fa feat(web): implement default tab handling (#8873) 2025-08-11 16:52:39 +02:00
Thierry Goettelmann
c19eabadc0 feat(lite): migrate the project to file-based navigation + implement default tab handling (#8872) 2025-08-11 16:40:49 +02:00
Thierry Goettelmann
6f9c5fb46e feat(web-core): add useDefaultTab to handle redirection to last visited tab (#8868) 2025-08-11 16:16:12 +02:00
Mathieu
935034517a feat(@xen-orchestra/rest-api): expose hosts/:id/smt (#8863) 2025-08-07 17:07:04 +02:00
Mathieu
4e3c1a82f4 feat(lite): 0.13.1 (#8867) 2025-08-06 17:09:27 +02:00
Mathieu
0c3ae5a560 feat: release 5.109.1 (#8866) 2025-08-06 16:55:27 +02:00
Mathieu
9e484e011f feat: technical release (#8864)
* feat(@xen-orchestra/web-core): 0.26.1

* feat(@xen-orchestra/rest-api): 0.13.1

* feat(@xen-orchestra/web): 0.24.1

* feat(xo-server): 5.184.2

* feat(xo-web): 5.182.0

* chore(CHANGELOG): update next

* fix identation
2025-08-06 15:12:00 +02:00
MlssFrncJrg
b30b897ad9 fix(xo-web): remove image format column on ISO SRs (#8852) 2025-08-06 14:43:27 +02:00
Thomas Moraine
fb69373264 doc(xo): deduplicate content across multiple pages (#8851)
* docs(xo): deduplicate acls content

The ACLs page in the XO documentation wasn't listed in the navigation bar and had content that duplicated the ACLs section of the Users page.

This commit removes the standalone page and updates the valid section with minor improvements (presentation, some missing info from the lone ACLs page) so that it becomes the only source of information on the topic.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>

* docs(xo): remove obsolete administration page

The Administration page duplicates content in the VMs section of the Infrastructure management page.

Although the Administration page seemingly refers to itself as a 'section', it contains no subsections or extra content, so this might as been a draft for a bigger page, and was never elaborated upon.

In any case, this page isn't accessible from the navigation bar and doesn't seem to add much value to the doc, so I suggest we remove it from the tree view.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>

* docs(xo): deduplicate backups and disaster recovery content

The 'Backups and Disaster Recovery' page in the XO documentation wasn't listed in the navigation bar and had content that was already present in other pages in the doc.

The last section of that page dealt with scheduled snapshots and completes the existing content on 'rolling snapshots'. That content has been added to the rolling snapshots section, so that everything can be grouped in a single location.

Finally, since the Backups and disaster recovery page is now obsolete, it has been deleted.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>

* Update docs/docs/users.md

Co-authored-by: Laure S. <laure.souai@vates.tech>

---------

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
Co-authored-by: Laure S. <laure.souai@vates.tech>
2025-08-05 14:46:31 +02:00
Mathieu
f5cadfa3bc fix(xo-rest-api/xo-web/xo-lite): fix cpu provisioning computation (#8858)
* fix(xo-rest-api/xo-web/xo-lite): fix cpu provisioning computation

* changelogs

---------

Co-authored-by: debian <debian@XOA-from-source.lyon.vates.team>
2025-08-05 14:44:26 +02:00
Mathieu
bf44455250 fix(xo-server): fix incorrect state when remove server (#8854) 2025-08-05 11:35:23 +02:00
Mathieu
31a3f23fce feat(lite): 0.13.0 (#8849) 2025-07-31 18:39:06 +02:00
Mathieu
bc0720b9fc feat: release 5.109.0 (#8848) 2025-07-31 18:23:22 +02:00
Mathieu
497754ef4f feat: technical release (#8846) 2025-07-31 14:54:07 +02:00
Florent BEAUCHAMP
85e936f22f fix(vhd-lib): ensure vhd block read are always complete 2025-07-31 14:40:29 +02:00
Florent BEAUCHAMP
5d84f147ce fix(fs): better handling of incomplete read
from ticket 42001
do not retry an incomplete _read (for example read after the end of the file)
2025-07-31 14:40:29 +02:00
Florent BEAUCHAMP
2cec1aa44a fix(fs/test): fix read after the end of the file 2025-07-31 14:40:29 +02:00
Pierre Brunet
822a6f6e77 fix(xo-web/vdi): replace unknown image format by VHD (#8845)
* fix(xo-web/vdi): Replace unknown by vhd

* feat: update changelog packages

* Update CHANGELOG.unreleased.md

---------

Co-authored-by: Mathieu <70369997+MathieuRA@users.noreply.github.com>
2025-07-31 14:33:54 +02:00
Mathieu
4c229cf4a1 feat: technical release (#8844)
* feat(@xen-orchestra/web): 0.24.0

* feat(xo-server): 5.184.0

* feat(xo-web): 5.181.0

* chore(CHANGELOG): update next
2025-07-31 12:34:24 +02:00
Pierre Brunet
665a68b7fe feat(xo-cli/xo-web): add supported_image_format in xo-cli and xo-web (#8834) 2025-07-31 12:20:45 +02:00
Joris K
a7922f23aa feat(xo6): add alarms component on site dashboard (#8838)
* feat(xo6): add alarms component on site dashboard

* update

* add date sorter

* feedback and changelog

* feedback
2025-07-31 10:26:03 +02:00
Mathieu
2f39f37698 feat: technical release (#8842) 2025-07-30 16:25:08 +02:00
MlssFrncJrg
deedc5e7bf feat(@xen-orchestra/rest-api): migrate update user (#8784) 2025-07-30 16:02:50 +02:00
MlssFrncJrg
d45e4cb50b feat(@xen-orchestra/rest-api): expose patch groups (#8790) 2025-07-30 14:47:39 +02:00
MlssFrncJrg
50f5759d2e feat(@xen-orchestra/rest-api): expose delete user from group (#8773) 2025-07-30 14:06:31 +02:00
Joris K
d03c2b62fe fix(xo6): fix issue with infinite loading on site dashboard (#8841) 2025-07-30 12:07:35 +02:00
MlssFrncJrg
4465108333 feat(@xen-orchestra/rest-api): migrate add user to group (#8705) 2025-07-30 11:18:41 +02:00
Mathieu
0ce7b564ea feat(@xen-orchestra/rest-api): expose VM snapshot alarms (#8827) 2025-07-30 09:51:38 +02:00
Joris K
16f07463e1 feat(xo6/pool): Add dashboard view (#8791) 2025-07-30 09:45:08 +02:00
Mathieu
af1a8f2e2c feat(@xen-orchestra/rest-api): expose VM alarms (#8829) 2025-07-30 08:38:46 +02:00
Mathieu
95b5ee95eb fix(@xen-orchestra/rest-api/hosts): count host disabled only if running (#8833) 2025-07-30 08:37:44 +02:00
Thierry Goettelmann
13f406fe70 feat(web-stack): introducing defineRequest + convert site dashboard (#8839) 2025-07-29 17:59:27 +02:00
Pierre Donias
1045016545 feat(xo-web/backups): add strong confirm on single VM backup deletion (#8813) 2025-07-29 10:19:50 +02:00
MlssFrncJrg
51e8cf94f8 feat(@xen-orchestra/rest-api): expose post groups (#8703) 2025-07-29 09:51:27 +02:00
Enée DI IORIO
fb0e1b5f8d feat(xo-6): add connect pool view (#8763) 2025-07-28 17:21:33 +02:00
Mathieu
61e119fd55 feat(@xen-orchestra/rest-api): expose VBD alarms (#8822) 2025-07-28 17:09:37 +02:00
Mathieu
e465d6b9b1 feat(@xen-orchestra/rest-api): expose VIF alarms (#8825) 2025-07-28 17:02:46 +02:00
Mathieu
39509b5a24 feat(@xen-orchestra/rest-api): expose VM controller alarms (#8826) 2025-07-28 16:51:12 +02:00
Mathieu
6d5a1ccdd2 feat: technical release (#8836) 2025-07-28 14:53:35 +02:00
Mathieu
0faf25b050 feat(@xen-orchestra/rest-api): expose pool alarms (#8805) 2025-07-28 14:22:39 +02:00
Mathieu
df4949d5ca feat(@xen-orchestra/rest-api): expose SR alarms (#8810) 2025-07-28 14:21:32 +02:00
Pierre Brunet
ca4e4e272f feat(@xen-orchestra/rest-api): expose backup-repositories (#8831) 2025-07-28 14:21:04 +02:00
Pierre Brunet
36d1e883e2 feat(xo-web): display image_format in VDI tabs (#8835) 2025-07-28 11:23:09 +02:00
vates-bot
8535271fb3 feat(web-stack/i18n): update Xen Orchestra 6 (#8765) 2025-07-28 10:53:23 +02:00
Pierre Brunet
4ca170746d fix(backups): mustDoSnapshot now checks if a vdi has a tag NOBAK (#8820) 2025-07-28 10:24:38 +02:00
Mathieu
6277af813f fix(@xen-orchestra/rest-api): escape unsafe complex matcher input (#8832) 2025-07-25 11:54:00 +02:00
Mathieu
bd83ea3ffc feat(@xen-orchestra/rest-api): expose host logs.tgz (#8830) 2025-07-25 09:41:12 +02:00
Mathieu
afc5518e1d feat(@xen-orchestra/rest-api): expose VM template alarms (#8828) 2025-07-24 15:11:41 +02:00
Mathieu
c702433370 feat(@xen-orchestra/rest-api): expose VDI alarms (#8824) 2025-07-24 14:05:17 +02:00
Joris K
91e4003ca3 feat(web-core): update progress bar component to add possibility to display GB instead of percent depending on the case (#8815)
* feat(web-core): update progress bar component to add possibility to display prefix in case of value

* feedback
2025-07-24 10:29:05 +02:00
Mathieu
359a27bffe fix(xo-server/rest-api): unauthenticated request no longer creates xo task (#8821) 2025-07-23 17:09:36 +02:00
Mathieu
46b0a4b91f feat(@xen-orchestra/rest-api): expose VDI-snapshot alarms (#8823) 2025-07-23 17:09:19 +02:00
Julien Fontanet
aab0f75fb2 feat(xo-server): check build is up-to-date 2025-07-23 11:50:46 +02:00
Julien Fontanet
9373909581 feat(xo-server): show buid commit 2025-07-23 11:50:46 +02:00
Florent BEAUCHAMP
c922b62217 fix(backups): better handling of retrying an upload (#8776) 2025-07-23 11:44:27 +02:00
Joris K
0c914704db feat(web-core): added data ruler component (#8812) 2025-07-23 10:29:32 +02:00
Pierre Donias
e77a474614 fix(xapi): spelling and typos (#8817)
Co-authored-by: Josh Soref <2119212+jsoref@users.noreply.github.com>
See #8593
2025-07-22 17:29:07 +02:00
Pierre Donias
17a65e35e7 fix(openflow): spelling and typos (#8816)
Co-authored-by: Josh Soref <2119212+jsoref@users.noreply.github.com>
See #8593
2025-07-22 17:16:11 +02:00
Mathieu
01f22b8a66 feat(@xen-orchestra/rest-api): expose pif alarms (#8804) 2025-07-22 16:53:38 +02:00
Pierre Donias
adebc5961f fix(generator-toolbox): typo (#8814)
Co-authored-by: Josh Soref <2119212+jsoref@users.noreply.github.com>
See #8593
2025-07-22 16:06:13 +02:00
Thomas Moraine
6b172f7047 doc(xo): update links to new xo doc domain name (#8811)
The Xen Orchestra documentation moved to a new domain name a while ago. This commit updates the last remaining links in the XO doc and app that pointed to the old domain name

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-07-22 11:19:12 +02:00
Mathieu
a9789ba630 fix(@xen-orchestra/rest-api): identify errors case for some computed objects (#8806) 2025-07-22 10:09:54 +02:00
Mathieu
b86af049c1 feat(@xen-orchestra/rest-api): expose network alarms (#8801) 2025-07-22 09:39:46 +02:00
Mathieu
ef5a711bb6 feat(@xen-orchestra/rest-api): expose host alarms (#8800) 2025-07-22 08:52:47 +02:00
Mathieu
be454d8643 feat(@vates/types): enhance createVm type (#8798) 2025-07-22 08:46:58 +02:00
Mathieu
e5702e407c fix(@xen-orchestra/rest-api): include ISO SR in top 5 dashboard (#8803) 2025-07-18 16:21:14 +02:00
Mathieu
73ce910f34 fix(@xen-orchestra/rest-api): alarm time in ms and alarm value in percent (#8802) 2025-07-18 16:20:50 +02:00
Enée DI IORIO
fee7bd5813 feat(web-core): create UI alarm item and UI alarm list components (#8516)
* feat(web-core/alarm-item): creating component

* feat(web-core/alarm-item): add style

* feat(web-core/alarm-item): add description and togle button with vueuse

* feat(web-core/alarm-item): add size variant on alarm-item

* feat(web-core/alarm-item): create story for alarm-item

* feat(web-core/alarm-item): create story for alarm-item

* feat(web-core/alarm-item): compliance with the standard

* feat(web-core/alarm-item): fix responsive on ui alarm item

* feat(web-core/alarm-item): fix spacing

* feat(web-core/alarm-item): add ui-alarm-list on story

* fix(web-core/alarm-item): fix border if item is on a list

* fix: revert unwanted npmignore change

* review

* review

* add changelog

* feedback

* feedback

* feedback

* feedback

* feedback

* feedback & improve translate

* feedback & improve translate

---------

Co-authored-by: Olivier Floch <olivier.floch@vates.fr>
Co-authored-by: Joris-K <joris.kosacki@vates.tech>
2025-07-16 17:16:35 +02:00
Joris K
d4444df15a feat(xo6/lite): Updated sidebar to auto close when the screen is small (#8760) 2025-07-16 16:16:15 +02:00
Joris K
153ff4c641 fix(lite): error in changelog (#8799)
introduced by #8761
2025-07-16 15:11:56 +02:00
Pierre Donias
aa68b7432b fix(@xen-orchestra/mixins): typo (#8789)
See #8593
2025-07-15 11:38:29 +02:00
Joris K
c5ba756618 feat(web-core): updated query search bar to work in responsive (#8761) 2025-07-11 16:59:07 +02:00
Pierre Donias
0e69b3fdba feat(xo-web/acls): always use removeAcls to delete even a single entry (#8774)
See [MM](https://team.vates.fr/vates/pl/r7khs54wiidw8ehbf5kfm9enow)
2025-07-10 15:32:07 +02:00
Joris K
d4263d8408 fix(xo6/lite): fixed display error due to inversion of upload and download (#8793) 2025-07-10 15:19:28 +02:00
Mathieu
a403cd3711 feat(@xen-orchestra/rest-api): expose pool dashboard (#8768) 2025-07-10 14:51:53 +02:00
MlssFrncJrg
e47cf032e6 feat(@xen-orchestra/rest-api): expose delete group (#8704) 2025-07-07 16:51:59 +02:00
MlssFrncJrg
81fa8701c9 feat(xo-server/rest-api): migrate delete user (#8698) 2025-07-07 16:38:02 +02:00
MlssFrncJrg
424f303ade feat(@xen-orchestra/rest-api): expose create user (#8697) 2025-07-07 16:09:31 +02:00
Pierre Donias
8093cb461b fix(xo-web/health): correctly use snapshot functions/messages in snapshot tables (#8775)
See Zammad#41131
Introduced by #3274
2025-07-07 15:00:43 +02:00
Pierre Brunet
c8f9d81b8f fix(xo-lite): bump version 0.12.1 (#8788) 2025-07-03 15:40:40 +02:00
Pierre Brunet
895fd7f2d3 feat(lite): 0.12.1 (#8787) 2025-07-03 15:16:52 +02:00
Pierre Brunet
975c538229 feat: release 5.108.1 (#8786) 2025-07-03 14:50:38 +02:00
Pierre Brunet
2827c2a4ef feat: technical release (#8785)
* feat(xen-api): 4.7.3

* feat(@vates/nbd-client): 3.1.4

* feat(@vates/types): 1.8.0

* feat(@xen-orchestra/xapi): 8.3.2

* feat(@xen-orchestra/web-core): 0.24.0

* feat(@xen-orchestra/proxy): 0.29.25

* feat(@xen-orchestra/rest-api): 0.11.0

* feat(@xen-orchestra/web): 0.21.1

* feat(xo-server): 5.181.0

* chore(CHANGELOG): update next

* chore(CHANGELOG): pass changelogs to next release

* chore(CHANGELOG): remove changelog in wrong release

* chore(CHANGELOG): add packages
2025-07-03 11:56:28 +02:00
Thomas Moraine
797906283a docs(backups.md): explain how to schedule backups (#8783)
Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-07-03 11:46:59 +02:00
Thomas Moraine
0ef9ed97e4 docs(xo): deduplicate incremental backups and full replication content (#8777)
* docs: deduplicate delta_backups page

Previously, the content of the Incremental Backups page (filename: delta_backups.md) was duplicated by the page titled "Incremental Backups (formerly: Continuous Delta backups)", which was not referenced in the sidebar and had the filename incremental_backups.md.

We don't want duplicate content, as it is troublesome to maintain and may cause confusion for the reader.

Also, since Delta backup is outdated terminology and we now talk about Incremental backups, the lone incremental backups.md page has been deleted, and the current "Incremental Backups" page with the filename delta_backups.md has been renamed as the new incremental_backups.md

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>

* docs: deduplicate full_replication page

Previously, the content of the Full Replication page (filename: disaster_recovery.md) was duplicated by the page titled "Full Replication (formerly: Disaster recovery)", which was not referenced in the sidebar and had the filename full_replication.md.

We don't want duplicate content, as it is troublesome to maintain and may cause confusion for the reader.

Also, since Disaster recovery is outdated terminology and we now talk about Full Replication, the lone full_replication.md page has been deleted, and the current "Full Replication" page with the filename disaster_recovery.md has been renamed as the new full_replication.md

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>

---------

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-07-03 11:45:41 +02:00
Florent BEAUCHAMP
3bc90981af fix(xapi): respect the config file setting for preferNbd (#8780)
* fix(nbd-client): handle unreachable VM through nbd

when the user select nbd but don't have any way to reach
the storage/host of the VM (for example no NBD enabled network,
or a NBD enabled network unreachable by xo/proxy it was timing out
and blocking the start of the backup
This error was blocking the fallback to export stream mechanism

* fix(xapi): respect the config file setting for preferNbd

* fix(xapi): don't close export stream if nbd failed to connect

* fix(xapi):respect vhd/qcow2 when faling back to a full if delta exports fails
2025-07-03 10:53:59 +02:00
Mathieu
6123c82d40 fix(api-error): api-error return an error instead of throwing (#8782) 2025-07-03 09:38:32 +02:00
Joris K
193ca26ca3 fix(xo-lite): change timestamp calculation (#8778)
Reported by https://xcp-ng.org/forum/topic/11012/graph-in-v0.12.0-48bf9
introduced by #8529 and #8504
2025-07-02 15:24:21 +02:00
Joris K
e2edac3875 fix(web-core): fix tooltip overflow when too close to the edge (#8779)
Reported by https://xcp-ng.org/forum/topic/11012/graph-in-v0.12.0-48bf9/2
Introduced by #6376
2025-07-02 15:19:24 +02:00
Mathieu
3c54363b09 feat(@xen-orchestra/rest-api): expose host/:id/audit.txt (#8757) 2025-07-01 10:15:57 +02:00
Mathieu
42ddf02831 feat(@xen-orchestra/rest-api): expose pool stats (#8764) 2025-07-01 09:52:44 +02:00
Pierre Brunet
48bf9c95c9 feat(lite): version 0.12.0 (#8771) 2025-06-30 16:56:04 +02:00
Pierre Brunet
850bd41fe7 feat: release 5.108 (#8770) 2025-06-30 16:38:28 +02:00
Pierre Brunet
fcefaaea85 feat: technical release (#8767) 2025-06-30 10:43:41 +02:00
Florent BEAUCHAMP
3c8efb4dc3 fix(backups): non NBD backups stalls (#8766) 2025-06-30 09:57:44 +02:00
Thomas Moraine
ac04f0aa46 doc(configuration): document let's encrypt support in xoa (#8657)
doc(configuration) document let's encrypt support in xoa

Add documentation explaining how to  configure Let's Encrypt in Xen Orchestra Appliance (XOA). This feature was mentioned in the XO 5.73 release blog post but was not included in the technical documentation for Xen Orchestra.
2025-06-30 09:54:36 +02:00
Enée DI IORIO
d96ffde7f5 feat(xo-web/uiCounter): fix radius (#8732)
* feat(xo-web/uiCounter): fix radius

* review
2025-06-30 09:39:03 +02:00
Enée DI IORIO
d89c861ea2 fix(lite,web/Host): tag list color (#8731)
Fixing the color of the tag on all UI tags, On all UiTagsList, we always have info for the UiTag variant.
2025-06-30 09:37:29 +02:00
Enée DI IORIO
2effd8520a feat(web-core/xo-6/xo-lite): add pagination in data tables (#8573) 2025-06-27 17:50:18 +02:00
Enée DI IORIO
b2e61a146d feat(xo-6/host): add name of pGpu in system view (#8740) 2025-06-27 17:43:51 +02:00
vates-bot
9212cd2559 feat(web-stack/i18n): update Xen Orchestra 6 (#8643)
* feat(web-stack/i18n): update Czech

* feat(web-stack/i18n): update German

* feat(web-stack/i18n): update Russian

* feat(web-stack/i18n): update Russian

* feat(web-stack/i18n): update Dutch

* feat(web-stack/i18n): update Russian

* feat(web-stack/i18n): update Czech

* feat(web-stack/i18n): update Czech

* feat(web-stack/i18n): update Spanish

* feat(web-stack/i18n): update Czech

* feat(web-stack/i18n): update Spanish

* feat(web-stack/i18n): update Spanish

* feat(web-stack/i18n): update Spanish

* feat(web-stack/i18n): Update German

* add changelog

---------

Co-authored-by: OlivierFL <olivier.floch@vates.tech>
Co-authored-by: Olini <oliver.elsner@oribu.net>
Co-authored-by: Aleksey Kruglov <apkrugru@gmail.com>
Co-authored-by: Tristis Oris <faust-dourden@ya.ru>
Co-authored-by: Mitchel Nijkamp <IxFail@users.noreply.translate.vates.tech>
Co-authored-by: Ниджат <nnr@ndb24.ru>
Co-authored-by: Pavel Borecki <pavel.borecki@gmail.com>
Co-authored-by: Francisco Nacher <xiscoj@gmail.com>
Co-authored-by: Xavier Esteban <xest@hotmail.com>
Co-authored-by: Joris-K <joris.kosacki@vates.tech>
2025-06-27 17:21:47 +02:00
Mathieu
0778c6c53c fix(@xen-orchestra/web&lite): handle graphs with null value (#8722) 2025-06-27 16:54:36 +02:00
Pierre Brunet
541411586b feat: technical release (#8762) 2025-06-27 14:35:39 +02:00
OlivierFL
8527fd96ee fix(xo-6/vite-config): links in dev pages break prod build (#8759) 2025-06-27 14:03:41 +02:00
Pierre Brunet
1cb9c5e633 feat: technical release (#8758)
* feat(vhd-lib): 4.14.0

* feat(@xen-orchestra/disk-transform): 1.1.0

* feat(@xen-orchestra/qcow2): 1.0.0

* feat(@xen-orchestra/xapi): 8.3.0

* feat(@xen-orchestra/backups): 0.62.0

* feat(@xen-orchestra/backups-cli): 1.1.1

* feat(@xen-orchestra/immutable-backups): 1.0.22

* feat(@xen-orchestra/web-core): 0.22.0

* feat(@xen-orchestra/proxy): 0.29.23

* feat(@xen-orchestra/rest-api): 0.10.0

* feat(@xen-orchestra/web): 0.20.0

* feat(xo-acl-resolver): 0.5.1

* feat(xo-server): 5.180.0

* feat(xo-web): 5.178.0

* chore(CHANGELOG): update next

* feat(@vates/types): 1.6.0

* fix(chore): changelog package order
2025-06-27 10:19:25 +02:00
Florent BEAUCHAMP
d837056eac chore: update READMEs (#8734) 2025-06-26 16:07:03 +02:00
MlssFrncJrg
fe92e206eb feat(xo-server/rest-api): migrate create_vm (#8658) 2025-06-26 15:58:28 +02:00
Julien Fontanet
5950c09016 fix(xo-server,xo-web): ability to clear remote syslog (#8746)
See https://team.vates.fr/vates/pl/rmh9o5mgnfg9ueey8y7b4grsjw
2025-06-26 15:34:48 +02:00
Enée DI IORIO
b952dd1f8f feat(web-core/size): fix type of formatSizeRaw (#8723) 2025-06-26 15:28:56 +02:00
Florent BEAUCHAMP
d499f3ec9e fix(xo-web): clarify when CBT is enabled automatically (#8754) 2025-06-26 15:19:18 +02:00
mathieuRA
8b44f95de5 feat(@xen-orchestra/rest-api): expose vm unpause 2025-06-26 14:28:40 +02:00
mathieuRA
cd013b8f82 feat(@xen-orchestra/rest-api): expose vm resume 2025-06-26 14:28:40 +02:00
mathieuRA
0202b56d33 feat(@xen-orchestra/rest-api): ability to start a vm on a specific host 2025-06-26 14:28:40 +02:00
mathieuRA
80d0624409 feat(@xen-orchestra/rest-api): expose vm suspend 2025-06-26 14:28:40 +02:00
mathieuRA
36889b3841 feat(@xen-orchestra/rest-api): expose vm pause 2025-06-26 14:28:40 +02:00
mathieuRA
fd5db5bf06 chore(@xen-orchestra/rest-api): add getXapi in BaseController 2025-06-26 14:28:40 +02:00
Florent BEAUCHAMP
f0d893d29a feat(xo-web/tasks): show only failed tasks by default in xo-task view (#8676) 2025-06-26 14:20:50 +02:00
Pierre Donias
d6a702b5fa fix(xo-web/Link): fix middle-click behaviour on FF (#8756) 2025-06-26 14:09:26 +02:00
Pierre Donias
bda87c9023 feat(xo-web/remotes): creation/edition Azure/Azurite remotes (#8753)
Implement UI for creation and edition of Azure and Azurite remotes.
2025-06-26 11:19:13 +02:00
Florent BEAUCHAMP
aae93d0103 docs(backups-cli): document the new remote option (#8752) 2025-06-26 10:09:25 +02:00
Enée DI IORIO
8fb3cf3c6c feat(xo-6/site): add page to list servers (#8664) 2025-06-25 16:51:47 +02:00
Thomas Moraine
eca4cb8ba3 docs(backups.md): explain how to send logs to a syslog server (#8747)
Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-06-25 16:39:11 +02:00
Pierre Brunet
d967cc2efb feat(@xen-orchestra/rest-api): expose SM in the REST API (#8696) 2025-06-25 15:20:19 +02:00
Mathieu
4f9efb5e49 feat(@xen-orchestra/rest-api): expose VM import (#8748) 2025-06-25 14:46:32 +02:00
Florent BEAUCHAMP
bb4e14b6dd fix(backups): use the right format (qcow2/vhd) (#8750)
introduced by cbc07b319f
2025-06-25 12:15:56 +02:00
Thomas Moraine
a348ce07d8 docs(tab-advanced-js): fix wrong link to guest uefi secure boot guide (#8749)
Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-06-25 10:21:03 +02:00
Enée DI IORIO
a53297bd5a feat(web-core): create UiQuoteCode component (#8701) 2025-06-25 10:20:45 +02:00
Joris K
e4959045df feat(xo6/lite): update side panel behavior for mobile view (#8688)
* feat(xo6/lite): update side panel behavior for mobile view

* feedback & improvement

* feedback

* feedback

* fix issue with sticky side panel when datatable has a lot of data

* feedback and fix issue

* add changelog

* update icon

* review feedback

* update vts copy button icon
2025-06-24 17:03:47 +02:00
Mathieu
aa707b386a feat(@xen-orchestra/rest-api): dashboard enpoint can return a stream (#8695) 2025-06-24 16:35:11 +02:00
Florent BEAUCHAMP
cbc07b319f feat(xapi): support import and export of qcow2 disks (#8506)
This PR implement a limited support of generating and reading qcow2 . The support is limited since it only handles qcow2 with 64KB cluster size, with this structure :

    header
    refcount table
    refcount blocks
    L1 table
    L1 tables
    data clusters

generated by a9cd5bc639/scripts/qcow2-to-stdout.py
2025-06-24 15:32:20 +02:00
Pierre Brunet
7994fc52c3 feat: technical release (#8745)
* feat(xo-remote-parser): 0.10.0

* feat(@xen-orchestra/fs): 4.6.0

* feat(vhd-lib): 4.13.0

* feat(xen-api): 4.7.2

* feat(@vates/task): 0.6.2

* feat(@vates/types): 1.5.0

* feat(@xen-orchestra/backups): 0.61.2

* feat(@xen-orchestra/backups-cli): 1.1.0

* feat(@xen-orchestra/web-core): 0.21.0

* feat(@xen-orchestra/rest-api): 0.9.0

* feat(@xen-orchestra/web): 0.19.0

* feat(vhd-cli): 0.9.4

* feat(xo-web): 5.177.0

* feat(xo-server): 5.179.0

* feat(xo-server-auth-oidc): 0.3.3

* feat(xo-server-backup-reports): 1.6.0

* feat(xo-server-load-balancer): 0.10.2

* feat(xo-server-transport-nagios): 1.0.3

* chore(changelog): update next

* feat(@xen-orchestra/proxy): 0.29.22

* feat(xo-server-perf-alert): 0.6.3

* fix(chore): update changelog

* fix: fix yarn.lock
2025-06-23 17:27:36 +02:00
Florent BEAUCHAMP
1a7b56a4fe fix(fs): fix 'The last argument to .catch() must be a function, got […object Object]' (#8739)
introduced by afb110c473
2025-06-23 14:19:59 +02:00
Olivier Lambert
040ae74079 docs(restapi): improve the REST API page, explain the versioning and … (#8742)
docs(restapi): improve the REST API page, explain the versioning and the current status

Signed-off-by: Olivier Lambert <olivier.lambert@vates.tech>
2025-06-23 12:02:36 +02:00
Pierre Brunet
695b0c4d90 fix: re-add await for createOutputStream (#8741)
Fix issue in metadata backup which was broken due to forgotten async introduced by #8718
2025-06-23 11:41:45 +02:00
Florent BEAUCHAMP
858681ecc1 feat(backups): enable CBT only when using "purge snapshot data" (#8735) 2025-06-23 10:35:38 +02:00
Thomas Moraine
035f5b7651 docs(restapi): clean up the rest api page (#8683) 2025-06-23 10:00:04 +02:00
Josh Soref
be3677d0bb fix(rest-api): typo
See #8593
2025-06-23 09:52:46 +02:00
Pierre Donias
6b26353be8 fix(vhd-lib,vhd-cli): typos and spelling (#8730)
Co-authored-by: Josh Soref <2119212+jsoref@users.noreply.github.com>
See #8593
2025-06-20 16:30:13 +02:00
Pierre Brunet
0776fc1616 Feat fs safeguards (#8718)
Add safety functions in abstract instead of s3 and azure. A new function "withTimeout" allows to handle timeout and success/failure after timeout.
It should help for better debug and it can be used in several situations.
2025-06-20 14:55:51 +02:00
Josh Soref
1d6ea0a2b9 fix(xo-acl-resolver): typo
See #8593
2025-06-20 10:13:53 +02:00
Thierry Goettelmann
19412728cb feat(web-core): create initial icon packs + icon dev page (#8724) 2025-06-19 16:08:39 +02:00
Thierry Goettelmann
5772bbd005 feat(lite): remove old lite components usage in favor of web-core components (#8685)
* feat(lite): remove old lite components usage in favor of web-core components

* feedback
2025-06-19 15:27:55 +02:00
Pierre Donias
31ef709b30 fix(plugins): typos and spelling (#8728)
Co-authored-by: Josh Soref <2119212+jsoref@users.noreply.github.com>
See #8593
2025-06-19 14:15:41 +02:00
Pierre Donias
8d498151d9 fix(lite,web-core): typos and spelling (#8727)
Co-authored-by: Josh Soref <2119212+jsoref@users.noreply.github.com>
See #8593
2025-06-19 14:12:13 +02:00
Pierre Donias
fb9bd06dfd fix(@vates/task): typos (#8726)
Co-authored-by: Josh Soref <2119212+jsoref@users.noreply.github.com>
See #8593
2025-06-19 11:11:49 +02:00
Pierre Donias
c6ee83bcad fix(workflows): typo (#8725)
See #8593
2025-06-19 11:04:48 +02:00
Joris K
a8723e3619 fix(xo6/site): fix issue with prefix in site dashboard (#8721)
Introduced by #8585
2025-06-19 09:48:22 +02:00
Mathieu
607e2ef67e feat(xo-server): ability to add/remove data source for VMs (#8699)
XO-846

Methods are also added in the JSON-RPC API because our partner do not use the REST API for now.
2025-06-18 10:27:53 +02:00
Florent BEAUCHAMP
4da43ff805 fix(fs): ensure configuration is really passed to remotes (#8707)
remoteOpiions of config file wasn't properly passed to the remotes

especially the timout settings
from ticket 36083
2025-06-18 10:04:28 +02:00
Florent BEAUCHAMP
c8f0a5a583 feat(backups-cli): handle cleanup of remotes (#8712)
zammad#39640
2025-06-17 16:47:35 +02:00
Florent BEAUCHAMP
d863696a17 fix(vhd-lib): handle double close of vhd file (#8706)
zammad#39533
2025-06-17 16:32:51 +02:00
p-bo
daec3dc89f fix(xo-web/intl): use correct ISO code for Swedish (#8719)
Introduced by #7844
See #8711
2025-06-17 15:16:32 +02:00
p-bo
f235925eed feat(xo-web/user): add XO6-only languages to be able to switch to them (#8711)
XO6 is translated into languages, not existing for XO5. And as it is not
possible to select language in XO6 yet (is inherited from XO5 settings), interim
solution is to add these new languages into XO5 user settings, to make them
actually accessible in XO6. It will not affect XO5, because it will simply
fallback into English in case of not existing language.
2025-06-17 14:14:41 +02:00
dinhngtu
0e02282f30 Apply suggestions from code review
Co-authored-by: Thomas Moraine <thomas.moraine@vates.tech>
Signed-off-by: Tu Dinh <ngoc-tu.dinh@vates.tech>
2025-06-17 10:36:27 +02:00
Tu Dinh
09f19ef22d docs(XO): add Windows cloudbase-init template guide
Add a step-by-step guide for creating VM templates with cloudbase-init
support, including master VM creation, template branching, customization,
reseal and VM instantiation.

Include best practices, tips and visual aids for each step.

Signed-off-by: Tu Dinh <ngoc-tu.dinh@vates.tech>
2025-06-17 10:36:27 +02:00
Joris K
6adb304e5d feat(xo-lite/vm): add dashboard view (#8529) 2025-06-17 10:29:35 +02:00
Thierry Goettelmann
b6083b214c feat(web-core): new icon management library (#8607) 2025-06-17 09:07:32 +02:00
OlivierFL
beeef40eda feat(xo-6/treeview): introduce site level in treeview (#8694) 2025-06-17 08:59:41 +02:00
Thomas Moraine
486034d32b docs(xo): upgrade docusaurus from 3.7 to 3.8.1
The new version of Docusaurus introduces performance improvements, see the announcement here: https://docusaurus.io/blog/releases/3.8

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-06-16 17:18:51 +02:00
Mathieu
bd64758e5b fix(xo-server): fix no such object when fetching servers (#8709) 2025-06-16 16:36:26 +02:00
Bastien Nollet
b71101feaf feat(xo-server-backup-reports): avoid making report content depend on reportWhen (#8670)
remove a value that was only used for backup reports, and was no longer used since we use the template system

avoid the backup report content to differ when the backup job has reportWhen = false, which also caused differences between regular backup reports and backup reports generated with the "test plugin" button. To make sure the users can still have the previous behavior, we add parameter in the backup job configuration that allows short failure reports by not displaying all successful VMs in error reports.

rewording "successful VMs" to "successful items" so it also makes sense for metadata backups
2025-06-16 16:34:18 +02:00
mathieuRA
dc2247d739 feat(@xen-orchestra/rest-api): expose delete servers/<id> 2025-06-16 16:00:34 +02:00
mathieuRA
6bd79394bd fix(xo-server): fix unregister server only disconnect 2025-06-16 16:00:34 +02:00
Pierre Donias
bb68d3495a fix(auth-oidc): require advanced prop when sub-props are required (#8662)
Introduced by #6684

If sub-properties are required but the parent property is not, then omitting
the parent property passes the validation, which is not what we want.
2025-06-16 09:45:09 +02:00
Joris K
36e594a3ca feat(xo-6/vm): add dashboard view (#8585) 2025-06-16 09:24:43 +02:00
Mathieu
7be702ef0d feat(@xen-orchestra/rest-api): expose pgpus (#8684) 2025-06-13 14:54:29 +02:00
Pierre Brunet
64eac04446 feat: add image_format to vdi object (#8654) 2025-06-12 17:02:18 +02:00
NadineYasser1
8add75a41d feat(fs): add azure blob storage (#8415)
implement azure backup storage on the backend
2025-06-11 11:58:58 +02:00
Thierry Goettelmann
1bda61e2a0 feat(web-core): simplify logic of VtsCopyButton (#8693) 2025-06-11 11:07:18 +02:00
Joris K
919cf12134 feat(web-core): create composable to translate, formatted and use pluralization with useTimeAgo (#8578) 2025-06-11 10:54:44 +02:00
Joris K
ee2bb3a1c7 feat(xo-6/dashboard): update components BackupIssues and VtsBackupState (#8674) 2025-06-11 10:53:44 +02:00
Mathieu
0c1947aba2 feat(@xen-orchestra/rest-api): expose dashboard endpoint (#8580) 2025-06-10 14:41:05 +02:00
Mathieu
b3145b1034 REST API: expose get pcis and get pci id (#8686) 2025-06-10 14:01:04 +02:00
Thierry Goettelmann
033e241a82 feat(web): improve sidebar (#8691) 2025-06-10 13:34:10 +02:00
OlivierFL
bd62d22c52 feat(web-stack/i18n): migrate $t to t (#8682) 2025-06-10 10:02:29 +02:00
Julien Fontanet
a504efb2ee fix: add missing rimraf dev dependency 2025-06-06 15:03:52 +02:00
Mathieu
9066e43f51 feat(@xen-orchestra/rest-api): ability to use ndjson for collections (#8628) 2025-06-06 15:02:18 +02:00
mathieuRA
5e733c9e69 feat(@xen-orchestra/rest-api): expose pool rolling_update 2025-06-06 14:51:10 +02:00
mathieuRA
35e8d960e2 feat(@xen-orchestra/rest-api): expose pool rolling_reboot 2025-06-06 14:51:10 +02:00
mathieuRA
1667e0f573 feat(@xen-orchestra/rest-api): expose pool emergency_shutdown 2025-06-06 14:51:10 +02:00
mathieuRA
815dad114c feat(@xen-orchestra/rest-api): expose delete network 2025-06-06 14:10:56 +02:00
mathieuRA
94db22f7b0 feat(@xen-orchestra/rest-api): expose create network 2025-06-06 14:10:56 +02:00
mathieuRA
d47fa03ec8 fix(xen-api): getObject* throw nosuchObject 2025-06-06 14:10:56 +02:00
Mathieu
83f52c25cb fix(@xen-orchestra/rest-api): fix typo in actionAsyncrone (#8675) 2025-06-06 11:44:56 +02:00
Thomas Moraine
bb0650d258 docs(vm-templates): document viridian extensions (#8525)
The Viridian setting in Xen Orchestra was mentioned in a release blog (XO 5.79) but not in the XO technical documentation.

This improvement explain what Viridian is and how to enable it for VMs and templates, from within XO.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-06-06 11:32:23 +02:00
Pierre Donias
f817d4a813 fix(xo-server/plugins): disable strictRequired in JSON compilation (#8669)
See https://ajv.js.org/strict-mode.html#defined-required-properties
2025-06-06 10:04:25 +02:00
Florent BEAUCHAMP
45394609d2 fix(xo-web/VM/Advanced): fix CPU mask computation (#8661) 2025-06-06 10:03:13 +02:00
Mathieu
c7167bcc49 fix: order of packages to fix the ci (#8680)
fix: order packages order for fix the CI
2025-06-05 18:41:56 +02:00
Mathieu
92932dd546 feat(xo-server): inject master in server properties (#8637) 2025-06-05 16:35:27 +02:00
Mathieu
a6ed74a8ec feat: release 5.107.2 (#8679) 2025-06-05 16:31:43 +02:00
Mathieu
529e6bd7d1 feat: technical release (#8677)
* feat(@vates/generator-toolbox): 1.0.3

* feat(@xen-orchestra/backups): 0.61.1

* feat(@xen-orchestra/proxy): 0.29.21

* feat(xo-server): 5.178.2

* feat(xo-web): 5.176.0

* chore(CHANGELOG): update next
2025-06-05 15:13:41 +02:00
Julien Fontanet
ceaf3ee2da fix(CHANGELOG): fix Markdown 2025-06-05 09:52:22 +02:00
OlivierFL
b21f49e804 chore(web-core/docs): update guidelines (#8640) 2025-06-05 09:32:55 +02:00
Enée DI IORIO
392352e324 feat(xo-lite/pool): add system page (#8660) 2025-06-05 08:47:53 +02:00
Thomas Moraine
e64c434d0c docs(plugins): fix links to various plugins
The documentation for mutliple plugins was updated or moved outside the general Plugins page over time.

As a result, the links contained in various plugin's Readme or Usage files got obsolete, as they all point to the general Plugins page, instead of the specific plugin documentations.

This commit updates those links with the relevant location.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-06-04 16:40:43 +02:00
Florent BEAUCHAMP
4253ad6823 fix(backups): properly clone data on incremental backup (#8668) 2025-06-04 15:29:07 +02:00
Josh Soref
0a378391a9 chore: fix spelling errors and typos in docs and comments (#8619) 2025-06-04 14:37:58 +02:00
Thomas Moraine
699f4e08a2 docs(usage-reports): fix link to the usage report plugin documentation
Previously, the Readme and Usage files related to the server-usage-report plugin contained links pointing to a general page on plugins.

The server-usage-report plugin has since been documented in the Management → Manage infrastructure management page of the Xen Orchestra documentation. As a result, these links have become obsolete.

This commit make the obsolete links point to the actual, new documentation on the server-usage-report plugin.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-06-04 13:48:02 +02:00
Enée DI IORIO
77cb05d861 feat(xo-6/pool): add system page (#8581)
* feat(xo-web): create system page

* feat(xo-web): creat poolGeneralInfo component

* feat(xo-web): creat poolLicensing component

* feat(xo-web): create poolNetworking component

* feat(xo-web): create poolManagement component

* feat(xo-web): create poolStorageConfiguration component

* feat(xo-web): create poolConnection and poolResourceManagement component

* feat(xo-web): create server.store for adding data on poolConnection

* feat(xo-web) update changelog

* fix(xo-web) add loading, fix server.store

* feat(xo-web) add VtsEnabledState from [#8521](https://github.com/vatesfr/xen-orchestra/pull/8521/files#diff-38e34d645fd18a178735268961d29214f9e9b683e3849d7766bc35ba7da64427)

* fix(xo-web) fix VtsEnabledState

* feat(xo-web): create system page

* feat(xo-web): creat poolGeneralInfo component

* feat(xo-web): creat poolLicensing component

* feat(xo-web): create poolNetworking component

* feat(xo-web): create poolManagement component

* feat(xo-web): create poolStorageConfiguration component

* feat(xo-web): create poolConnection and poolResourceManagement component

* feat(xo-web): create server.store for adding data on poolConnection

* feat(xo-web) update changelog

* fix(xo-web) add loading, fix server.store

* feat(xo-web) add VtsEnabledState from [#8521](https://github.com/vatesfr/xen-orchestra/pull/8521/files#diff-38e34d645fd18a178735268961d29214f9e9b683e3849d7766bc35ba7da64427)

* fix(xo-web) fix VtsEnabledState

* fix(xo-web) use vtsColumn on system page

* review

* fix(xo6/poolSystem): add vts loading hero

* review
2025-06-04 09:14:00 +02:00
Thierry Goettelmann
a54b233ea2 feat(web-core): enhance form select library (#8618) 2025-06-03 17:54:30 +02:00
Pierre Donias
e1ee2d11ac feat(xo-web/OTP): "password" → "OTP code" when enabling OTP (#8666)
See Zammad#39728
2025-06-03 15:53:39 +02:00
Pierre Donias
3603ac1c43 feat(xo-web/VM): add "HIDE_XSA468" tag on VM to hide all XSA-468 warnings (#8665)
See #8638
2025-06-03 15:44:55 +02:00
OlivierFL
3c24064bf3 fix(xo-6/changelog): add missing packages to release (#8667)
Introduced by 5c0ee7e
2025-06-03 15:10:31 +02:00
Pierre Donias
f6c9809c1f fix(perf-alert): add missing type "array" in JSON schema (#8663)
Introduced by #7911
2025-06-03 15:09:42 +02:00
Thierry Goettelmann
77389f91eb feat(web-core): enhance collection library (#8614) 2025-06-03 15:00:38 +02:00
Thierry Goettelmann
a82af251c5 feat(web-core): enhance mapper library (#8609) 2025-06-03 11:54:03 +02:00
OlivierFL
5c0ee7e269 feat(xo-6/xo-lite/host): rename RAM usage components and update CPU provisioning logic in dashboard (#8648) 2025-06-03 11:53:20 +02:00
Mathieu
6ecabc3d9a feat: release 5.107.1 (#8656) 2025-05-28 15:16:24 +02:00
Mathieu
009f791d84 feat: technical release (#8655) 2025-05-28 14:21:52 +02:00
Pierre Donias
2fd5759489 fix(xo-server/xapi-object-to-xo): do not check vulnerabilities on templates and snapshots (#8652)
Introduced by #8638
See https://xcp-ng.org/forum/topic/10888/xsa-468-multiple-windows-pv-driver-vulnerabilities-update-now/19
2025-05-28 10:24:45 +02:00
Mathieu
6ed35551ee feat(lite): 0.11.0 (#8650) 2025-05-27 18:15:29 +02:00
Mathieu
47ec82ded4 feat: release 5.107 (#8649) 2025-05-27 18:03:47 +02:00
Mathieu
f7b3e7da92 feat: technical release (#8647)
* feat(xo-web): 5.175.1

* chore(CHANGELOG): update next
2025-05-27 17:35:21 +02:00
Pierre Donias
e931c51af0 fix(xo-web): prevent subscribeSelfLicenses from returning an object (#8646)
Introduced by #8523
2025-05-27 17:26:10 +02:00
Mathieu
b5eaf53ba9 feat: technical release (#8645) 2025-05-27 16:13:45 +02:00
OlivierFL
00466fde65 fix(xo-6/vm): add required comment in vm index.vue file to prevent build fail (#8644) 2025-05-27 16:07:01 +02:00
Mathieu
a7346871c3 feat: technical release (#8642) 2025-05-27 15:47:25 +02:00
Pierre Donias
19db81f7d8 fix(CHANGELOG): fix XSA-468 links (#8641) 2025-05-27 15:19:52 +02:00
Pierre Donias
d48e537bd2 fix(xo-server,auth-ldap): do not pass undefined ID to registerUser2 (#8639) 2025-05-27 14:31:46 +02:00
Pierre Donias
bfc39542aa feat(xo-server,xo-web): show alert on VMs that are vulnerable to XSA-468 (#8638)
See https://xenbits.xen.org/xsa/advisory-468.html
See https://docs.xcp-ng.org/vms/#xsa-468-multiple-windows-pv-driver-vulnerabilities
See https://xcp-ng.org/blog/xsa-468-windows-pv-driver-vulnerabilities/
2025-05-27 14:25:39 +02:00
Mathieu
b8f9be6553 feat(@xen-orchestra/rest-api): expose vm snapshot action (#8622) 2025-05-27 13:50:51 +02:00
Florent BEAUCHAMP
dfbc578dfa fix(disk-transform): backup omnibus fix (#8635) 2025-05-27 13:50:27 +02:00
Enée DI IORIO
2f84a07066 feat(web-core/UiCardNumbers): fix if unit is undefined (#8636) 2025-05-27 11:37:44 +02:00
Bastien Nollet
55ac7f0035 fix(xo-web): hub ensure template is a template (#8634)
See xoa#234

Prevent importing hub templates that are actually not templates.
Importing an incorrect template previously created a VM without any way to know it.
2025-05-27 11:30:40 +02:00
Pierre Donias
2e1fe1dd34 fix(xo-web/VM/Advanced): handle vm.VTPMs containing null values (#8601)
Fixes Zammad#39180
Introduced by #7085
2025-05-27 10:43:08 +02:00
Nathanaël
bf7518c98b feat(web-stack/i18n): update Xen Orchestra 6 (#8534) 2025-05-27 09:54:49 +02:00
Enée DI IORIO
88a2144339 feat(xo-6/xo-lite): redirect /vm/UUID to /vm/UUID/console (#8553) 2025-05-26 17:38:17 +02:00
Joris K
e1dab25045 feat(xo-lite): add possibility to edit SR on existing vdis (#8507) 2025-05-26 17:31:35 +02:00
Bastien Nollet
c5a268277f fix(xo-web): avoid unrelevant errors on isPubKeyTooShort (#8605) 2025-05-26 14:50:19 +02:00
Enée DI IORIO
bff0e37fbd feat(web-core): update UI card numbers component (#8626) 2025-05-26 11:00:17 +02:00
Joris K
8d0151fcb5 feat(web-core): update svg with new illustrations, and add new vtsState components (#8591) 2025-05-26 10:59:39 +02:00
mathieuRA
3628066154 feat(@xen-orchestra/rest-api): expose get pifs and get pifs/id 2025-05-26 09:49:55 +02:00
mathieuRA
a55d1c4a00 feat(@vates/types): add XoPif 2025-05-26 09:49:55 +02:00
Mathieu
6e273b3328 feat: technical release (#8632)
* feat(@xen-orchestra/proxy): 0.29.19

* chore(CHANGELOG): update next
2025-05-26 08:52:40 +02:00
Mathieu
ca227536aa feat: technical release (#8631) 2025-05-23 15:47:32 +02:00
Florent BEAUCHAMP
a8753530bd fix(vhd-lib): add to changelog (#8630) 2025-05-23 15:37:57 +02:00
Mathieu
b674e0d13f feat: technical release (#8629)
* feat(@xen-orchestra/fs): 4.5.1

* feat(@vates/generator-toolbox): 1.0.2

* feat(@vates/nbd-client): 3.1.3

* feat(@vates/types): 1.3.0

* feat(@xen-orchestra/disk-transform): 1.0.0

* feat(@xen-orchestra/xapi): 8.2.0

* feat(@xen-orchestra/backups): 0.60.0

* feat(@xen-orchestra/backups-cli): 1.0.31

* feat(@xen-orchestra/immutable-backups): 1.0.20

* feat(@xen-orchestra/web-core): 0.20.0

* feat(@xen-orchestra/proxy): 0.29.18

* feat(@xen-orchestra/rest-api): 0.7.0

* feat(@xen-orchestra/web): 0.18.0

* feat(xo-server): 5.177.1

* feat(xo-server-auth-oidc): 0.3.2

* feat(xo-server-netbox): 1.9.0

* feat(xo-server-perf-alert): 0.6.2

* chore(CHANGELOG): update next
2025-05-23 15:06:25 +02:00
Mathieu
0dec1299f1 fix(web/lite): ability to create VM without VDIs (#8617) 2025-05-23 13:57:12 +02:00
Enée DI IORIO
2cff92641e feat(xo6/xo-lite): update vm/system (#8586) 2025-05-23 13:45:17 +02:00
Sebastian CZECH
9d9158266d feat(xo-lite/host): add system page (#8536) 2025-05-23 11:57:59 +02:00
Mathieu
dc1ffedb0a fix(xo-server/xen-server): fix disabling an enabled but disconnected server (#8599) 2025-05-23 11:39:00 +02:00
Bastien Nollet
c9a8083b12 feat: release 5.106.4 (#8627) 2025-05-23 11:37:40 +02:00
Bastien Nollet
2519284521 feat: technical release (#8625) 2025-05-23 10:21:55 +02:00
Pierre Donias
cb70114aa4 fix(xo-web/update): explicitly check trial when disabling email input (#8624)
Introduced by #8595
2025-05-23 10:08:02 +02:00
Sebastian CZECH
9140b0b9bb feat(xo-6/host): add system page (#8521) 2025-05-23 09:43:12 +02:00
Enée DI IORIO
958b5b4f03 fix(xo-6/header): Safari force rendering header on size change (#8574) 2025-05-23 09:42:37 +02:00
Bastien Nollet
160d7f7712 fix(@xen-orchestra/xapi): catch error on mdadm status for offline hosts (#8597) 2025-05-23 09:32:19 +02:00
OlivierFL
ae2361da1f feat(xo-6/host): update dashboard quick info section (#8606) 2025-05-23 09:04:09 +02:00
Mathieu
aeffcd32b9 fix(@xen-orchestra/rest-api): move networks into good folder (#8610) 2025-05-22 17:30:46 +02:00
Mathieu
9af1a729bc fix(xo-server): cpuCap and cpuWeight can no longer be null (#8577) 2025-05-22 16:53:24 +02:00
Mathieu
1404fda662 feat(@xen-orchestra/rest-api): expose vm (clean/hard)_reboot (#8611) 2025-05-22 16:45:04 +02:00
Florent BEAUCHAMP
f2a38591d3 fix(backups): error "a differential VHD must have a parent" (#8616) 2025-05-22 16:44:52 +02:00
Pierre Donias
4601306b23 feat(netbox): support Netbox v4.3.x (#8588)
Fixes #8587
2025-05-22 16:43:53 +02:00
Bastien Nollet
1615e0eb77 feat: release 5.106.3 (#8621) 2025-05-22 16:27:59 +02:00
Bastien Nollet
6d129a4ec4 feat: technical release (#8620) 2025-05-22 14:46:09 +02:00
Mathieu
87b5cacf52 feat(@xen-orchestra/rest-api): expose (clean/hard)_shutdown (#8612) 2025-05-22 12:21:02 +02:00
Pierre Donias
cbfcf9b736 docs(PULL_REQUEST_TEMPLATE): instruction for external contributors (#8600)
Fixes #8594
2025-05-21 15:45:21 +02:00
Thomas Moraine
c29a682d40 docs(advanced, manage_infrastructure): deduplicate alerts content
The Alerts page was not listed in the sidebar and duplicated content from the Alerts section in the Advanced page. This commit removes the standalone Alerts page and redirects all links pointing to it to the Alerts section of the Advanced page. This change also fixes a broken anchor warning in Docusaurus, which could prevent new documentation builds from succeeding.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-05-21 15:23:34 +02:00
Thomas Moraine
ad27a5d390 docs(manage_infrastructure): explain VIFs and network locking mode (#8584)
* doc(XO): Explain VIFs and Network locking mode

VIFs and the Network locking mode were previously missing in the Xen Orchestra technical documentation, with details scattered across XO blog posts, CLI references in the XCP-ng doc, and other sources.

This commit adds information on VIFs and the network working modes to the XO documentation, making it a more complete and reliable reference for users.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>

* Update manage_infrastructure.md

---------

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
Co-authored-by: Julien Fontanet <julien.fontanet@vates.tech>
2025-05-21 15:23:03 +02:00
Pierre Donias
bb59801c0f fix(xo-server/VM.setAndRestart): explicitly pass forceShutdownDelay: 0 to VM.stop (#8592)
Fixes Zammad#38602
Introduced by #7247

Avoid immediately falling back to a hard shutdown when calling `VM.stop`,
especially in cases where a clean shutdown was requested explicitly by the user
like in `VM.setAndRestart`.
2025-05-21 15:20:58 +02:00
Pierre Donias
4f65a85989 feat(xo-web/update): disable registration email input when XOA is in trial (#8595) 2025-05-21 14:54:28 +02:00
Thomas Moraine
802a5ceb90 docs(manage-infrastructure): explain rolling pool update scheduling (#8549)
Rolling pool update (RPU) scheduling was introduced in Xen Orchestra 5.102 and mentioned in the corresponding blog post, but it was not included in the XO technical documentation.

This commit explains how to schedule RPUs, so users no longer need to refer to blog posts for instructions.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-05-21 14:06:02 +02:00
Thomas Moraine
0e43512383 docs(restapi): add Swagger API documentation details (#8540)
As mentioned in the XO 5.104 release blog post, we're gradually documenting the Xen Orchestra REST API using Swagger.

The REST API page in our technical documentation already has a 'Future Improvements' section, which seems like a good place to mention Swagger, even though the documentation process is still ongoing.

This way, users won't need to check the XO blog to learn about this feature.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-05-21 13:55:24 +02:00
Thomas Moraine
3d2d6c8248 docs(footer): correct wrong GitHub link (#8598)
The GitHub link in the footer was the default one used by the Docusaurus template.

This commit changes the link so that it points to the Xen Orchestra GitHub repository.
2025-05-21 10:02:01 +02:00
Thomas Moraine
a49b27bff7 docs(airgap): fix broken link to xoa check (#8602)
Correct a malformed link to the Support -> XOA Support -> XOA check section in the XO documentation, ensuring Docusaurus builds the doc properly.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-05-20 16:42:02 +02:00
Thomas Moraine
4432e64b6c docs(troubleshooting): integrate general_troubleshooting (#8560)
The General Troubleshooting page is not listed in the sidebar and contains minimal content, making it suitable to be merged into the existing Troubleshooting page, which is already accessible. Consequently, the standalone General Troubleshooting page is no longer needed and is being removed.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-05-20 14:28:05 +02:00
Enée DI IORIO
adb490db41 fix(xo-server/signin): logo size on signin screen on Safari (#8572)
Fixes #8301
2025-05-20 14:27:25 +02:00
OlivierFL
e83f90937d fix(xo-core/vts-quick-info-row): handle long values in slot, add tooltip for key, add missing mobile styles (#8596) 2025-05-20 11:09:46 +02:00
Enée DI IORIO
14ce24fa8b feat(xo-lite/vm): create system page (#8547) 2025-05-20 10:40:17 +02:00
Thomas Moraine
21d2379462 docs(manage_infrastructure): document software RAID health check (#8576)
While Software RAID is provided as-is, Xen Orchestra allows users to monitor the health of their software RAID arrays.

This includes a dedicated status interface and alert messages for any issues.

This information was previously documented in the Xen Orchestra 5.102 release blog post, and is now integrated into the XO technical documentation.

This update ensures users have a single reference point for technical information, eliminating the need to search through blog posts.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-05-20 10:23:48 +02:00
mathieuRA
9a833ba15a feat(@xen-orchestra/rest-api): connect/disconnect a server 2025-05-19 16:46:08 +02:00
mathieuRA
4c8c34caab fix(xo-server/server): harmonize status checks in connect/disconnect 2025-05-19 16:46:08 +02:00
Florent BEAUCHAMP
15538b4f6b fix(@xen-orchestra/xapi): really stop xapi task 2025-05-19 16:37:57 +02:00
Florent BEAUCHAMP
75bdd0c4ab fix(@xen-orchestra/xapi): race condition on xapi task start 2025-05-19 16:37:57 +02:00
Florent BEAUCHAMP
b149e233ab fix(@xen-orchestra/xapi): update progress every percent 2025-05-19 16:37:57 +02:00
OlivierFL
d9087118cb feat(xo-core): add VtsEnabledState component (#8582) 2025-05-19 15:28:27 +02:00
p-bo
e3bda4488f docs(contributing): add Translations section (#8531) 2025-05-19 08:52:48 +02:00
Florent BEAUCHAMP
41e5a33cfe feat(backups): use generator instead of streams for backup and replication (#8432)
use generators instead of stream for backup and replication
2025-05-16 14:24:09 +02:00
Sebastian CZECH
5cadece9b1 fix(xo-6/vm-creation): better vifs handling (#8499) 2025-05-16 11:36:53 +02:00
Enée DI IORIO
a43292642b feat(web-core): create VtsCopyButton component (#8532) 2025-05-16 10:02:19 +02:00
OlivierFL
85b18e1d54 feat(xo-core): add VtsColumns component (#8579) 2025-05-15 10:46:43 +02:00
Thomas Moraine
95e72a8482 docs: fix links to docs and pricing thoughout the code (#8558) 2025-05-14 09:20:53 +02:00
Thomas Moraine
e652c5b341 doc(XO): Explain Differential restores (#8520)
Differential restores were mentioned in the XO blog (release 5.89)
but not in the XO technical documentation.
This commit explains in the technical doc what differential restores are
and how to perform one from the XO interface.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-05-14 08:55:17 +02:00
Enée DI IORIO
af03cfdb3c feat(xo-6/vm): create system page (#8522) 2025-05-13 13:57:55 +02:00
Enée DI IORIO
85c65a9ea0 feat(xo-6/treeview): collapse treeview when pool/host/vm is clicked (#8556) 2025-05-13 12:58:32 +02:00
Sebastian CZECH
bd1eb1fbb9 feat(web-core): update vtsLoadingHero Component (#8559) 2025-05-13 12:35:47 +02:00
b-Nollet
47bdf40e23 fix(xo-server-perf-alert): don't monitor non-running VMs and hosts 2025-05-13 11:36:14 +02:00
b-Nollet
b341f91bbb fix(xo-server-perf-alert): fixing configuration schema 2025-05-13 11:36:14 +02:00
Bastien Nollet
b9fc6cbbee feat(xo-server-auth-oidc): update dependency (#8555)
* feat(xo-server-auth-oidc): update dependency

* changelog
2025-05-12 17:25:42 +02:00
Pierre Donias
26ee92b2a7 fix(xo-server/Redis): properly delete IDs from Redis when multiple are removed (#8571)
Fixes Zammad#37604
Introduced by 36b94f745d

Since version 4, Redis' Node library doesn't support passing multiple flat
arguments to its methods to perform an action on multiple entries. It expects
an array instead.
2025-05-12 16:54:10 +02:00
Florent BEAUCHAMP
1fc52d73e7 fix(fs/s3): mirror full backup bigger han 50GB from encrypted source (#8570)
we must compute a relevant partSize when uploading a file bigger than 50G
bigger partSize means more memory consumption, but we are limited to
upload 10K chunk of partSize bytes

this commit will ensure there is an estimate of the size from encrpted remote
and pass this estimate to the FullRemoteWriter

from ticket 38031
2025-05-12 16:48:18 +02:00
Mathieu
7d116c1b76 feat(@xen-orchestra/rest-api): ability to add a server (#8564)
* feat(@xen-orchestra/rest-api): ability to add a server

* changelog
2025-05-12 16:36:04 +02:00
Thierry Goettelmann
a61450c2d7 feat(web-core): create VtsSelect, VtsOption and composables (#8438) 2025-05-12 15:18:00 +02:00
Thierry Goettelmann
0d65ae1568 fix(web-core): sidebar z-index (#8568) 2025-05-12 10:48:01 +02:00
Bastien Nollet
255ef06acc feat(lite): 0.10.1 (#8567) 2025-05-07 10:32:29 +02:00
Bastien Nollet
7fa77a3e75 feat: release 5.106.2 (#8566) 2025-05-07 10:01:13 +02:00
Bastien Nollet
ab56924b1d feat: technical release (#8563) 2025-05-06 12:39:57 +02:00
Florent BEAUCHAMP
14fce18f97 fix(xo-web): better handling of free and trial users (#8557)
- don't check licence for users with a running trial
- don't check licence for users with a free of from source plan
2025-05-06 11:25:22 +02:00
Joris K
14411866ac fix(xo-lite): add default value when the value of firmware is undefined (#8562) 2025-05-06 10:46:23 +02:00
Joris K
8fdd68addc feat(xo-lite): add possibility to add some tags (#8515) 2025-05-06 10:45:09 +02:00
Joris K
8d39fb654a feat(xo-lite): add affinity host field in vm creation, added pbd store, type and translation (#8486) 2025-05-05 17:09:07 +02:00
Thierry Goettelmann
b0099a98f8 feat(web-core): enhance useCollection composable (#8526) 2025-05-05 16:16:25 +02:00
Bastien Nollet
80e7273994 feat: release 5.106.1 (#8554) 2025-04-30 17:29:25 +02:00
Bastien Nollet
f9e58a6918 feat: technical release (#8552) 2025-04-30 16:05:08 +02:00
Florent BEAUCHAMP
c0ae2d7d87 fix(xo-web): better handling of strange licences (#8551) 2025-04-30 15:37:00 +02:00
Bastien Nollet
4b5a273e22 feat(lite): 0.10.0 (#8550) 2025-04-30 15:01:16 +02:00
Bastien Nollet
3bb59002c6 feat: release 5.106.0 (#8548) 2025-04-30 14:17:57 +02:00
Bastien Nollet
57ae9e7566 feat: technical release (#8546) 2025-04-30 10:12:56 +02:00
Pierre Donias
aa99af22d4 fix(CHANGELOG): add missing @xen-orchestra/web to packages to release (#8545)
Introduced by #8542
2025-04-30 09:38:40 +02:00
Joris K
1613293f46 feat(xo-lite): add topology field in vm creation (#8491) 2025-04-29 16:50:16 +02:00
Pierre Donias
2ebffdf9e2 fix(lite,web): use VtsInputWrapper to show input labels (#8542)
Introduced by #8436
2025-04-29 15:45:36 +02:00
Mathieu
2ea30b1899 feat(xo-server-web-hooks): handle office 365 connector format (#8498)
See Zammad#36742

Microsoft teams webhook requires a payload in the office 365 connector format.
2025-04-29 12:04:57 +02:00
Florent BEAUCHAMP
9c92f3a561 feat(license): better handling of expiring licenses (#8523) 2025-04-29 11:39:14 +02:00
Mathieu
374bd22f7b fix(xo-server): fix possible race condition at VIF level on VM creation (#8541) 2025-04-29 11:00:36 +02:00
Thomas Moraine
eae00400b8 doc(XO): Remove orphan Plugins page (#8539)
The page https://docs.xen-orchestra.com/plugins is not referenced in the navigation bar
 and duplicates the content in the https://docs.xen-orchestra.com/architecture#plugins page.
As a result, this commit deletes the Plugins page.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-04-29 10:55:54 +02:00
MlssFrncJrg
18c9259982 feat(@xen-orchestra/rest-api): expose get group and get groups (#8496) 2025-04-29 10:27:59 +02:00
Florent BEAUCHAMP
11050ca6d0 feat(v2v): resume import 2025-04-29 10:17:28 +02:00
Florent BEAUCHAMP
8756d12b8b fix(vmware-explorer): correctly handle stream and buffer
following dependence update 34e8ff6bae
2025-04-29 10:17:28 +02:00
MlssFrncJrg
93394508de feat(@xen-orchestra/rest-api): expose get networks and get network (#8497) 2025-04-29 09:42:19 +02:00
Bastien Nollet
ba8516d2d3 feat: technical release (#8537) 2025-04-28 16:20:12 +02:00
MlssFrncJrg
ae44845c09 feat(@xen-orchestra/rest-api): expose get user and get users (#8494) 2025-04-28 15:11:12 +02:00
Cyrille G.
88ccb60b60 feat(hub/recipes): update kubernetes version list to use Microk8s version (#8530)
feat(hub/recipes): update kubernetes version list to use Microk8s versions
2025-04-28 12:14:22 +02:00
Thomas Moraine
f879728ee9 docs(manage_infrastructure): document usage-report plugin (#8533)
The usage-reports plugin was not covered at all in the Xen Orchestra technical documentation.

This improvement explains how to get usage reports from your resources, by enabling and setting up the plugin.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-04-28 10:30:48 +02:00
OlivierFL
47b2a45548 chore(xo-core/docs): update guidelines (#8519) 2025-04-25 17:25:41 +02:00
Enée DI IORIO
404f5a3d88 feat(web-stack/host-header): add host master icon in host header (#8512) 2025-04-25 16:12:58 +02:00
OlivierFL
c3a5fefa02 feat(xo-lite/host): add dashboard view (#8504) 2025-04-25 15:35:37 +02:00
Florent BEAUCHAMP
34e8ff6bae fix: update following dependabot (#8509) 2025-04-25 15:05:11 +02:00
Mathieu
f9838c51d9 fix(xo-server): fix videoram not correctly parsed if defined in platform (#8524)
fix videoram not correctly parsed if defined in platform
2025-04-25 08:32:48 +02:00
Bastien Nollet
9ed551f87d feat: technical release (#8528)
* feat(@xen-orchestra/fs): 4.5.0

* feat(@vates/types): 1.2.0

* feat(@xen-orchestra/audit-core): 0.4.0

* feat(@xen-orchestra/backups): 0.59.0

* feat(@xen-orchestra/backups-cli): 1.0.30

* feat(@xen-orchestra/immutable-backups): 1.0.18

* feat(@xen-orchestra/web-core): 0.19.0

* feat(@xen-orchestra/proxy): 0.29.17

* feat(@xen-orchestra/rest-api): 0.4.0

* feat(@xen-orchestra/web): 0.16.0

* feat(xo-server): 5.176.1

* feat(xo-server-audit): 0.14.0

* feat(xo-web): 5.172.0

* chore(CHANGELOG): update next
2025-04-24 15:16:31 +02:00
Nathanaël
b5dd90f731 feat(web-stack/i18n): update Xen Orchestra 6 (#8465) 2025-04-24 10:24:04 +02:00
Bastien Nollet
d7e64028ec feat(xo-server-audit): audit import (#8474) 2025-04-23 11:03:29 +02:00
Pierre Donias
f17dde1021 fix(xo-web): do not show VM creator when user is not admin (#8503)
Fixes #8463
Introduced by #7276
2025-04-23 09:28:27 +02:00
Julien Fontanet
5c386c1987 feat(xo-web/host/networks): PIFs can be filtered by network names
Internal request: https://team.vates.fr/vates/pl/fgh4irbsi3f65xpzpyh4xe16za
2025-04-22 16:58:20 +02:00
Julien Fontanet
6f0f506d31 fix(CHANGELOG.unreleased): fix Markdown 2025-04-22 16:58:20 +02:00
Enée DI IORIO
058e43c692 feat(web-core): update UiInfo component (#8513) 2025-04-22 14:01:57 +02:00
mathieuRA
355d582965 feat(@xen-orchestra/rest-api): expose get messages and get message 2025-04-22 10:27:46 +02:00
mathieuRA
bc4df1872e feat(@xen-orchestra/rest-api): expose get alarms and get alarm 2025-04-22 10:27:46 +02:00
mathieuRA
d2e5f4213e feat(@vates/types): add XoMessage and XoAlarm 2025-04-22 10:27:46 +02:00
Mathieu
41b56a7fa8 fix(xo-server/createVm): fix DEVICE_ALREADY_EXISTS (#8473)
Introduced by 04dd9e9c40
See [mm](https://team.vates.fr/vates/pl/xn49jke9sfb7byt3k94ngjgq8a)
zammad#36543
2025-04-22 09:56:31 +02:00
Thomas Moraine
8a7d7f5748 docs(continuous_replication): rename to incremental_replication (#8514)
Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-04-22 09:27:54 +02:00
Thomas Moraine
48ca9597ff docs(load_balancing): explain how to find the load balancer plugin (#8511)
Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-04-22 08:49:38 +02:00
Thomas Moraine
9d24901300 doc(manage_infrastructure): add link to the V2V section of the XCP-ng doc (#8502)
The XCP-ng doc has been recently updated with more details on how to migrate from VMware to a Vates environment,
with a choice of methods including V2V within Xen Orchestra.

As a result, a mention of V2V is added to the XO doc, with a link to the relevant section in the XCP-ng doc.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-04-18 11:52:18 +02:00
Florent BEAUCHAMP
72f3c9f755 Update CHANGELOG.unreleased.md
Co-authored-by: Bastien Nollet <150058367+b-Nollet@users.noreply.github.com>
2025-04-16 08:45:06 +02:00
Florent BEAUCHAMP
44658110e1 fix(fs): better handling of rmtree EPERM errors
from ticket 37120
an EPERM error during a rmtree , called by the cleanup at the end
of a metdatadat backup, will trigger a ENOTDIR error
this can be caused by a misconfigured/failing immutable backup
2025-04-16 08:45:06 +02:00
Florent BEAUCHAMP
f461eff69a fix(fs): better handling of remote without VMs
remote without any VM ( either empty remote of config only remote)
are flooding the logs with errors like

Error: ENOENT: no such file or directory, scandir '/run/xo-server/mounts/f0952d37-294a-4d5e-8ba5-565e9932b4a7/xo-vm-backups'
2025-04-16 08:45:06 +02:00
Thierry Goettelmann
c609c58398 feat(web-core): don't force collection properties to be computed (#8510) 2025-04-15 16:24:19 +02:00
Florent BEAUCHAMP
e71c17bde3 chore: update dependencies (#8508)
chore: update dependencies
2025-04-15 13:49:15 +02:00
Thierry Goettelmann
06d62e9c28 feat(web-core): create useCollection composable (#8447) 2025-04-14 17:28:38 +02:00
mathieuRA
8e90ad487c feat(@xen-orchestra/rest-api): expose schedule/run 2025-04-14 13:37:09 +02:00
mathieuRA
17b95a8a8f feat(@xen-orchestra/rest-api): expose get schedules and get schedule 2025-04-14 13:37:09 +02:00
mathieuRA
11d78db88d feat(@vates/types): expose XoSchedule type 2025-04-14 13:37:09 +02:00
MlssFrncJrg
748a2ff860 feat(xo-web/dashboard): display snapshots older than 30 days (#8487)
Fixes #8258
2025-04-14 09:39:01 +02:00
MlssFrncJrg
83a69fd00f feat(@xen-orchestra/rest-api): expose get pools and get pool (#8490) 2025-04-11 13:22:27 +02:00
Thomas Moraine
6b89d30e29 Doc: Explain what 'Prevent migration' does (#8222) 2025-04-10 16:04:22 +02:00
Mathieu
5c88f9ee82 feat(xo-web/vm): rename 'disable migration' to 'prevent migration' (#8500)
introduced by dba08f0f
see https://xcp-ng.org/forum/topic/10206/block-migraton-option-on-the-vm-s-advanced-tab/14
related to #8222
2025-04-10 16:00:36 +02:00
René Paw Christensen
0ed5b7c788 docs(backups): exclude disks (#8493)
I have checked the source code, and it uses a "Contains" and not some kind of "StartsWith" to check whether the disk name should be excluded.

I think it is important to clarify that it is possible to place the "[NOBAK]" label after the disk name, which might help some to maintain their naming convention.
2025-04-09 16:59:38 +02:00
Mathieu
dba08f0fac feat(xo-web/vm): rename 'block migration' to 'disable migration' (#8492)
Introduced by 11a801c
Related to #8222
2025-04-09 10:46:30 +02:00
OlivierFL
18d8a29175 fix(xo-6/vm-creation): remove TS errors from type-check (#8472)
- Updated VM creation code to match the TS typing and remove errors in `type-check`
- Refactored code
- Simplified and improved business logic
- Improved naming
2025-04-08 16:56:16 +02:00
Thierry Goettelmann
838564b570 feat(web-core): update UiInput (#8436) 2025-04-08 16:26:08 +02:00
Mathieu
f04a469797 fix(@xen-orchestra/rest-api): fix unbrand type (#8480) 2025-04-08 11:52:28 +02:00
Mathieu
b32dcc2dcb feat(@xen-orchestra/rest-api): swagger interface qol (#8484) 2025-04-08 11:40:17 +02:00
mathieuRA
8128846ea4 feat(@xen-orchestra/rest-api): expose get vifs and get vif 2025-04-07 12:09:11 +02:00
mathieuRA
0dac0c212a feat(@vates/types): add XoVif type 2025-04-07 12:09:11 +02:00
Pierre Donias
dac9aab269 fix(xo-web/acls): disable ACL feature on plans <Enterprise (#8470)
Introduced by #3774
Introduced by #8412

ACLs feature is only available for XOA plans Enterprise and Premium. The
feature is properly disabled on the main ACL config page but was still
available from a VM's Advanced page and from the VM creation form.
2025-04-07 11:32:52 +02:00
Joris K
cf8065e56e feat(web-core): add alert component (#8459) 2025-04-07 11:29:04 +02:00
Olivier Lambert
e896ce6182 chore(docs): update Docusaurus to 3.7 (#8479)
Signed-off-by: Olivier Lambert <olivier.lambert@vates.fr>
2025-04-05 20:15:49 +02:00
Mathieu
224c840147 feat(lite): 0.9.1 (#8475) 2025-04-02 14:32:26 +02:00
Mathieu
ec78209f65 fix(web-core): fix _s is undefined (#8471)
Introduced by ac2f4e9
2025-04-01 16:48:35 +02:00
Sebastian CZECH
fd67016657 feat(web-core): circle progress bar component (#8402) 2025-04-01 14:01:22 +02:00
Mathieu
a420a95c09 feat(lite): 0.9.0 (#8468)
* feat(lite): 0.9.0
2025-03-31 16:44:41 +02:00
Mathieu
1b23812ddc feat: release 5.105 (#8467) 2025-03-31 16:31:53 +02:00
Mathieu
5c390d76e9 feat: technical release (#8466)
* feat(@vates/types): 1.1.1

* feat(@xen-orchestra/web-core): 0.18.0

* feat(@xen-orchestra/rest-api): 0.3.0

* feat(@xen-orchestra/web): 0.15.0

* feat(xo-server): 5.176.0

* feat(xo-server-backup-reports): 1.5.1

* feat(xo-web): 5.171.0

* chore(CHANGELOG): update next
2025-03-31 15:10:21 +02:00
Sebastian CZECH
82ed2bbf8c feat(xo-6): VM creation (#8324) 2025-03-31 14:49:03 +02:00
Joris K
3f565ff4a8 feat(xo-lite): VM creation (#8317) 2025-03-31 11:12:18 +02:00
Nathanaël
6d99331f26 feat(web-stack/i18n): update Xen Orchestra 6 (#8396) 2025-03-28 17:26:42 +01:00
MlssFrncJrg
b88e5c8e8f feat(@xen-orchestra/rest-api): expose get VM snapshots and get VM snapshot (#8453) 2025-03-28 16:36:31 +01:00
MlssFrncJrg
edc2591ce5 feat(@xen-orchestra/rest-api): expose get VM templates and get VM template (#8450) 2025-03-28 16:06:37 +01:00
MlssFrncJrg
2ff482ccad feat(@xen-orchestra/rest-api): expose get VM controllers and get VM controller (#8454) 2025-03-28 15:02:41 +01:00
OlivierFL
ad8f8fe0a4 fix(xo-6/host): ram usage progress bar not handling values properly (#8461) 2025-03-28 10:10:37 +01:00
MlssFrncJrg
873fe76d26 feat(xo-server): set new XAPI platform flag for nested virtualization (#8395)
set new XAPI platform flag for nested virtualization
2025-03-28 09:15:47 +01:00
Mathieu
782a93cdf8 feat(xo-server/xostor): handle XOSTOR on RPU process (#8455) 2025-03-27 12:25:06 +01:00
Mathieu
890766636a fix(xo-server/rest-api): return 404 if backup log do not exist (#8457) 2025-03-27 12:19:22 +01:00
OlivierFL
d77b195213 feat(xo-6/host): add dashboard view (#8398) 2025-03-27 09:52:51 +01:00
Mathieu
60b0b35eaa feat: technical release (#8458) 2025-03-26 15:50:59 +01:00
Joris K
6f578c0302 feat(xo-6/vm): add VIFs information in side panel (#8443) 2025-03-25 16:53:55 +01:00
Florent BEAUCHAMP
a6265fba0f fix(xo-web): remove spec message (#8452)
introduced by dca3f39
2025-03-25 16:50:32 +01:00
Mathieu
85fbdb1cd5 fix(@xen-orchestra/rest-api): remove unused return type (#8449) 2025-03-25 14:12:41 +01:00
Mathieu
d3c655bd49 feat(@xen-orchestra/rest-api): expose get servers and get server (#8385)
Implement the mecanism to expose non XAPI object endpoints:

    Added a new type, NonXapiXoRecord, which represents all XO objects that are not XAPI objects (e.g., users, servers, groups, etc.).
    Split XapiXoController to create BaseController, allowing code reuse between XapiXoController and the new XoController.
    BaseController exposes sendObjects and createAction (unchanged) and enforces subclasses to define the getObject and getObjects methods.
    XoController requires subclasses to implement getAllCollectionObjects and getCollectionObject, as these are used in its getObjects and getObject methods.
    filter and limit are handled by xo-server for XAPI objects. XoController implements its own filter and limit mechanisms for all non XAPI object in getObjects

And finally expose /rest/v0/servers and /rest/v0/servers/<server-id>🥳
2025-03-25 09:38:28 +01:00
Thierry Goettelmann
05d1552c7f feat(web-core): create usePagination composable (#8267) 2025-03-24 17:58:21 +01:00
Thierry Goettelmann
3a80bdfb36 feat(web-core): update VtsLoadingHero logic (#8437) 2025-03-24 17:35:48 +01:00
mathieuRA
39f4ad494d feat(@xen-orchestra/rest-api): expose get vdi-snapshots and get vdi-snapshot 2025-03-24 16:37:17 +01:00
mathieuRA
3aeab1585b feat(@xen-orchestra/rest-api): expose get vdis and get vdi 2025-03-24 16:37:17 +01:00
mathieuRA
2851bcbc5c feat(@vates/types): add XoVdi(-snapshot|unmanaged) types 2025-03-24 16:37:17 +01:00
Florent BEAUCHAMP
bb554a0ddd fix(@xen-orchestra/xapi): remove automatic disabling of cbt chain on export failure (#8446)
introduced by 2a2c966
zammad#35683
2025-03-24 14:34:05 +01:00
Julien Fontanet
f9de5b7e3a feat(README): add translation status and link 2025-03-24 11:59:48 +01:00
Florent BEAUCHAMP
86961aa782 fix(fs): improve compatibility with backblaze (#8393)
Fix `Unsupported header 'x-amz-checksum-mode' received for this API call.`
on backblaze/cloudlfare object storage
from version 3.729, aws sdk send headers that aren't supported yet by backblaze
2025-03-24 11:06:45 +01:00
Joris K
3aa36f907c feat(xo-6/vm): display VIFs in VM network tab (#8442) 2025-03-21 17:43:28 +01:00
Joris K
be44d75f43 feat(xo-lite/vm): display VIFs in VM network tab (#8441) 2025-03-21 16:00:41 +01:00
Sebastian CZECH
a04e58d0a5 feat(xo-6): add sidebar to host networks view (#8287) 2025-03-20 15:48:44 +01:00
OlivierFL
54e00924ed feat(xo-core): add UiProgressBar component (#8410) 2025-03-20 11:05:18 +01:00
Mathieu
f830315a3e fix(@xen-orchestra/rest-api): remove mutualization of query params (#8424)
Mutualiztion of query params require to also mutualize examples.
Introduced by 0951d56
2025-03-19 09:15:18 +01:00
Mathieu
8cc6b33ca1 fix(@vates/types): enhance xen-api and xo types (#8434) 2025-03-19 09:05:50 +01:00
Bastien Nollet
4f969adbe8 feat(xo-server-audit): log warning when audit log chain is broken (#8439) 2025-03-19 09:04:03 +01:00
Thierry Goettelmann
4f46c68aaa feat(web-core): create VtsInputWrapper component (#8435) 2025-03-17 12:03:32 +01:00
Thierry Goettelmann
9b56d93073 feat(web-core): create useRanked composable (#8431) 2025-03-14 17:23:45 +01:00
Thierry Goettelmann
9b93a7d9b3 feat(web-core): create useMapper composable (#8430) 2025-03-14 17:11:45 +01:00
Mathieu
2d062a301e fix(@xen-orchestra/lite): fix JSONRPC request type (#8421) 2025-03-14 15:23:44 +01:00
Thierry Goettelmann
7fd4528eae feat(web-core): recreate UiDropdown component from DS (#8418) 2025-03-13 12:09:39 +01:00
Thierry Goettelmann
ac2f4e9f32 chore(web-stack): update deps (#8428) 2025-03-13 11:54:36 +01:00
Mathieu
1d4b04c777 feat(@xen-orchestra/rest-api): expose get vbd and get vbds (#8404) 2025-03-13 09:41:52 +01:00
Florent BEAUCHAMP
e16f9bfa91 fix(v2v): assert error on import delta from esxi < 7.5 (#8422)
it happens when the source disk is not aligned with the vhd block size ( 2MB)
this PR will read more data into the grain directory / grain tables, but
this data won't be used since we use the virtual size to compute which
grain to read

from ticket 33018
2025-03-12 17:47:11 +01:00
Pierre Donias
9c27b53dfd fix(netbox/name-dedup): perform a case-insensitive name comparison (#8413)
Netbox doesn't allow 2 VM names to be equal within the same cluster even if they
don't have the same case.
2025-03-11 17:03:20 +01:00
Thomas Moraine
763e5f082d docs(backups): Microsoft ReFS is unsupported (#8423)
Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-03-11 16:16:09 +01:00
Pierre Donias
8a0604c274 feat(netbox): support Netbox v4.2 (#8417)
Fixes #8280
Fixes #7645
See Zammad#34699
See https://xcp-ng.org/forum/topic/10236/netbox-version-4-2-1-not-supported
See https://github.com/netbox-community/netbox/releases/tag/v4.2.0
2025-03-11 15:35:53 +01:00
Mathieu
ca84af4eda feat(@xen-orchestra/rest-api): migrate vms/:id/actions/start (#8379) 2025-03-11 12:50:42 +01:00
Mathieu
57056b2298 Add XoSr type + expose /rest/v0/srs and /rest/v0/srs/:id (#8419)
* feat(@vates/types): add XoSr type

* feat(@xen-orchestra/rest-api): expose get srs and get sr
2025-03-11 11:18:38 +01:00
Florent BEAUCHAMP
517a0c7b6b fix(backups): handle case when a new disk is added to a replicated VM (#8400)
* fix(backups): handle case when a new disk is added to a replicated VM

* changelog

* changelog
2025-03-11 10:31:56 +01:00
Mathieu
0951d5606d feat(@xen-orchestra/rest-api): mutualize code for better qol (#8401)
* feat(@xen-orchestra/rest-api): mutualize code for better qol

* fix after rebase

* use queries instead of query(s)
2025-03-11 09:16:37 +01:00
Thomas Moraine
d74828017a doc(XO): Add Amazon S3 to the list of supported remotes (#8407)
doc(XO): Add Amazon S3 to list of supported remotes

Amazon S3 remotes have been supported for a while,
but there was no section mentioning it in the Backups → Remotes doc.
This is now fixed

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-03-11 09:00:31 +01:00
Pierre Donias
1bd7bd6661 feat(xo-web,xo-server/createVm): ability to assign ACLs to VM during creation (#8412)
Fixes #6996
2025-03-10 17:10:46 +01:00
Mathieu
6816d6e759 fix(CHANGELOG.unreleased.md): fix extra space (#8420) 2025-03-10 11:29:34 +01:00
Pierre Donias
8fd8e3dff5 fix(netbox): fetch Netbox version even if version check is disabled (#8416)
See Zammad#34699
2025-03-10 09:52:59 +01:00
Bastien Nollet
37fbb7edb8 docs(perf-alert): functional description (#8414) 2025-03-07 16:04:53 +01:00
Bastien Nollet
9e7e4298ab fix(perf-alert): preventing alerts sent endlessly every minute (#8408)
Introduced by b1ce62f
2025-03-07 15:38:58 +01:00
Mathieu
f310575d0c fix(xo-server/createVm): fix OTHER_OPERATION_IN_PROGRESS (#8399)
Introduced by 04dd9e9
2025-03-07 15:27:57 +01:00
Julien Fontanet
5a501d9ee2 fix(xo-server): clarify session duration validity (#8411)
Related to zammad#35588
2025-03-07 14:04:15 +01:00
mathieuRA
37444afa12 fix(xo-server/rest-api): on VM import SR ID is no longer ignored 2025-03-07 12:10:30 +01:00
mathieuRA
31b049963f fix(@xen-orchestra/xapi/VM): throws a nice error when migrating VM without SR or default SR 2025-03-07 12:10:30 +01:00
mathieuRA
76e23ecdcc feat(@xen-orchestra/rest-api): expose hosts/:id/stats 2025-03-06 16:56:38 +01:00
mathieuRA
93706d70c9 feat(@xen-orchestra/rest-api): expose get hosts and get host 2025-03-06 16:56:38 +01:00
mathieuRA
a5866bbb6a feat(@vates/types): add XoHost type 2025-03-06 16:56:38 +01:00
Mathieu
034d9b5eca feat(@xen-orchestra/rest-api): expose VM stats (#8359) 2025-03-06 15:06:28 +01:00
Mathieu
a415c452ef fix(@xen-orchestra/rest-api): fix null values not displayed as nullable (#8406) 2025-03-06 09:23:51 +01:00
OlivierFL
adc5bea153 feat: release 5.104.1 (#8405) 2025-03-04 16:49:47 +01:00
OlivierFL
600d8d9e85 feat: technical release (#8403) 2025-03-04 15:37:15 +01:00
Florent BEAUCHAMP
82f0894fec fix(xapi): add missing changelog entry (#8397) 2025-03-04 13:19:52 +01:00
Florent BEAUCHAMP
90f8f9cff4 feat(vhd-lib): handle broken vhd directory named as aliases (#8292) 2025-03-04 09:55:04 +01:00
Florent BEAUCHAMP
cf0331391b fix(backups): handle undefined remote name in restore description (#8367) 2025-03-04 08:39:11 +01:00
Florent BEAUCHAMP
2a2c966126 fix(backups): disable CBT when CBT is unusable (#8313) 2025-03-03 11:41:39 +01:00
Julien Fontanet
39613ff2fa feat(xo-server/vdi.migrate): returns the new UUID
See https://team.vates.fr/vates/pl/169jwdqkeiys8boftay745iony
2025-02-28 11:18:39 +01:00
Sebastian CZECH
9033577406 feat(xo-6): add sidebar to pool networks view (#8286) 2025-02-28 10:18:46 +01:00
OlivierFL
6125348529 feat(lite): 0.8.0 (#8392) 2025-02-27 14:51:44 +01:00
OlivierFL
b59125cf5a feat: release 5.104.0 (#8391) 2025-02-27 14:26:02 +01:00
OlivierFL
73c9f74a45 feat: technical release (#8389) 2025-02-27 11:48:59 +01:00
OlivierFL
6a21e5525a feat(xo-core): update UiInput and UiTextArea components (#8349) 2025-02-27 11:03:55 +01:00
Sebastian CZECH
e0338c33d7 fix(xo-server): add pifs bond master of (#8368) 2025-02-27 10:49:59 +01:00
Joris K
e905817ea5 fix(xo-lite): add changelog (#8386) 2025-02-27 10:49:13 +01:00
Thomas Moraine
b0ab26b77e docs(backups): Microsoft Data Deduplication and Logical Disk Manager are unsupported (#8382)
Also, replace the screenshot of the file restore feature with an example that uses Linux instead of a Microsoft/NTFS resource

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-02-27 10:32:38 +01:00
Pierre Donias
82daa3550f fix(xo-web/tab-disks): wrong PropTypes on AttachDisk's vbds (#8387) 2025-02-27 10:27:18 +01:00
Pierre Donias
db4d394449 feat(xo-web/vm/disks): allow Self Service users to attach disks to their VMs (#8384)
See Zammad#30630
2025-02-27 10:19:51 +01:00
Joris K
8063f438d9 feat(xo-lite/host): display pif information in side panel / add pif metrics store (#8186) 2025-02-27 09:33:05 +01:00
OlivierFL
b3d66497b9 feat: technical release (#8383) 2025-02-26 16:34:07 +01:00
Mathieu
e567a3b358 feat(xo-server/rest-api): expose vms/hosts stats (#8378)
`GET /rest/v0/vms/f26eb0a5-1d91-4682-ae58-96df405a3af6/stats`
`GET /rest/v0/hosts/f26eb0a5-1d91-4682-ae58-96df405a3af6/stats`
2025-02-26 12:01:03 +01:00
Mathieu
468732cdba fix(xo-server/rest-api): do not show route twice (#8377) 2025-02-26 11:55:55 +01:00
MlssFrncJrg
13bda9812e feat(xo-server/rest-api): ability to update a user (#8343) 2025-02-26 10:03:29 +01:00
OlivierFL
ff8a020fa2 feat(web-stack): update typography (#8320) 2025-02-26 09:30:44 +01:00
OlivierFL
d1c65756be feat(web-stack/i18n): merge translation files into single file (#8380) 2025-02-26 09:00:18 +01:00
Nathanaël
905e3cc943 feat(web-stack/i18n): update translations (#8294) 2025-02-25 17:57:35 +01:00
Joris K
5a21bdfe10 feat(xo-lite/network): add side panel informations and some trad (#8150) 2025-02-25 16:16:30 +01:00
Dan Pollak
a7446ae4af fix(xo-server-audit): ignore additional API calls (#8353) 2025-02-25 15:58:46 +01:00
Julien Fontanet
d46c871b06 fix(xo-server/joinPool): remove useless and bogus obfuscation
Username was obfuscated instead of password.

Obfuscation is now properly done in `xen-api`.
2025-02-25 13:44:14 +01:00
Julien Fontanet
7cc89012d7 fix(xen-api): obfuscate password for pool.join{,_force}
Fixes zammad#35148
2025-02-25 13:44:14 +01:00
Julien Fontanet
bd8a1b25ed fix(xo-cli): timeouts with legacy JSON-RPC API
Fixes zammad#34473
2025-02-25 10:30:01 +01:00
OlivierFL
f18b010588 feat(web-stack): update "info" and "brand" colors usages (#8345) 2025-02-24 16:30:18 +01:00
Mathieu
ac2ff9b38d fix(@vates/types): add missing VM-template in XapiXoRecord (#8376)
Introduced by 0ef95d9
2025-02-24 15:54:19 +01:00
MlssFrncJrg
1a3cd3e2b9 feat(xo-server/rest-api): ability to delete user by its ID (#8283) 2025-02-24 14:46:26 +01:00
OlivierFL
f25c67d5a8 feat: technical release (#8375) 2025-02-24 10:48:54 +01:00
Pierre Donias
821c25f864 fix(generator-toolbox): normalize (#8374) 2025-02-24 10:12:19 +01:00
Mathieu
5910a47780 fix(@xen-orchestra/rest-api): rename example files to not be ignored by npm (#8373)
Introduced by 780e0bb

`.npmignore` ignores all `examples/` folders and all `*.example.*` files
Rename Open API examples as `*.oa-example.mts`
2025-02-24 09:51:05 +01:00
OlivierFL
749f063dc6 fix: bump versions of packages with wrong dependencies (#8371)
Introduced by 4eed730a09

Due to an issue with bump-pkg script, some packages were previously published on
NPM before their dependencies' versions were updated. This commit simply bumps
the versions of those packages to publish the correct list of dependencies.
2025-02-21 17:04:25 +01:00
OlivierFL
4eed730a09 feat: technical release (#8370) 2025-02-21 15:29:00 +01:00
Stephane
42ca53f80c feat(@xen-orchestra/xapi): smart reboot improvements (#8333)
feat(xapi): smart reboot improvements
2025-02-21 11:21:41 +01:00
Julien Fontanet
6b8167a2fc docs(installation): add missing openssl dependency 2025-02-21 10:50:08 +01:00
Mathieu
780e0bbefa feat(@xen-orchestra/rest-api): expose get vms and get vm (#8356)
@vates/types Added an export to the root of the lib because TSOA requires importing the type from the root library to generate the OpenAPI specification
    @vates/types Tuples ([number, number]) throw errors when generating the OpenAPI specification. They have been updated to number[]
    @vates/types Created a XapiXoRecord to be able to differentiate between XO types that come from xapi-object-to-xo and XO types that come from XO collections
    xo-server Added getObjectsByType to avoid iterating over all XAPI objects when we know what type of object we want
    Branded is not a valid type for the OpenAPI spec. I created a Unbrand type, that convert all Branded to string . (Use Unbrand only for OpenAPI specification definition to maintain Branded type safety)
    Added another error middleware to catch TSOA errors and throw XO errors
    Added a README.md to have consistency of order in the use of decorators
2025-02-21 10:31:57 +01:00
Bastien Nollet
b2ba5abacf docs(fuse-vhd): document cli file restoration (#8355)
Document how to restore a file from a VHD with fuse-vhd CLI.
This will allow users to restore by themselves large files that make the restore fail when done from the UI.
2025-02-21 10:26:12 +01:00
Pierre Donias
695b3d76e8 fix(xo-web): empty list of options in selects (#8363)
Introduced by a386680d90
Fixes #8361
See https://github.com/bvaughn/react-virtualized/issues/1871

react-virtualized@9.22.6 seems to be breaking old versions of React
Error in browser console: `TypeError: p.createRef is not a function`
2025-02-20 17:08:52 +01:00
Mathieu
6f5b97f7f3 feat(@xen-orchestra/rest-api): setup simple auth middleware (#8327)
Configure dependency injection + configure authentication middleware for openapi (copy of what actually exists in the current REST API. Will be improved later when implementing ACL/resource set)
2025-02-20 16:07:09 +01:00
OlivierFL
db3f702235 fix(web-stack): rollback vue-tsc version (#8362)
Rollback `vue-tsc` version to `2.1.10`, to fix broken `yarn type-check` and CI.
2025-02-20 13:04:43 +01:00
Bastien Nollet
fb02bf07f6 fix(@vates/task): ensure compatibility between library versions (#8347) 2025-02-19 17:44:34 +01:00
Florent BEAUCHAMP
9723075253 feat(generator): create a toolbox (#8295) 2025-02-18 17:23:51 +01:00
MlssFrncJrg
4b8cdf8043 feat(xo-server/rest-api): ability to create user (#8282) 2025-02-18 16:54:02 +01:00
Florent BEAUCHAMP
397e8b6ed7 fix(xo-server-test): normalize package 2025-02-18 16:45:23 +01:00
Florent BEAUCHAMP
da332715f6 doc(sdn-controller): update readme 2025-02-18 16:45:23 +01:00
Florent BEAUCHAMP
08c47da2b6 fix(rest-api): normalize package 2025-02-18 16:45:23 +01:00
Florent BEAUCHAMP
aa27e3ba46 fix(fatfs): normalize package 2025-02-18 16:45:23 +01:00
Julien Fontanet
a386680d90 chore: update dev deps 2025-02-18 11:05:22 +01:00
Julien Fontanet
6e508b03d8 fix(xo-server-audit): ignore host.getBiosInfo
Introduced by d5217f92f

Related to zammad#34890
2025-02-18 09:19:32 +01:00
Julien Fontanet
2aa7c012f1 fix(xo-server-audit): ignore host.getMdadmHealth
Introduced by c41ae0527

Related to zammad#34890
2025-02-18 09:13:47 +01:00
Julien Fontanet
18fec2a0af fix(CHANGELOG.unreleased): fix Markdown
Introduced by 137837cda
2025-02-18 09:10:56 +01:00
David Johnston
be6b73dcb5 docs: fix links to cloud-init (#8354)
Introduced by a89c31c75

Co-authored-by: David Johnston <david@johnstons.net>
2025-02-18 08:58:37 +01:00
Thomas Moraine
a89c31c75b docs: add page to explain VM templates (#8340)
A forum user suggested changes for better understanding of VM templates.
A new page has been created just for templates,
with Create, Read, Update, Delete (CRUD) sections.

Also, the Cloud-init/Cloudbase-init section of the Advanced page has been moved
to this new page.

This should make it easier for users to get all useful information on templates
in a single document.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-02-17 10:06:30 +01:00
Julien Fontanet
51b06b1177 docs(xoa): always use HTTPS and wget --no-verbose
`--no-verbose-` is better than `--quite` because it does not hide errors.
2025-02-17 09:36:04 +01:00
Florent BEAUCHAMP
c0d434b612 fix(v2v): Cannot read properties of undefined (reading 'map') with empty datastore (#8311) 2025-02-17 09:35:42 +01:00
Thomas Moraine
b390f36deb doc(XO): Update encryption algorithm transition guidance (#8350)
Update the Xen Orchestra documentation to guide users
on transitioning from AES-256-GCM to ChaCha20-Poly1305 encryption,
solving an issue where users could encounter warnings due to mixed
encryption algorithms in remote storage.
Fix [this]((https://xcp-ng.org/forum/topic/10453/remote-encryption-algorithm)
2025-02-17 09:32:10 +01:00
Mathieu
1983025403 fix(@vates/types): normalize package (#8332) 2025-02-17 09:22:07 +01:00
OlivierFL
9e29c98735 feat(xo-core): update treeview components colors (#8326) 2025-02-17 09:18:00 +01:00
Florent BEAUCHAMP
66e677d7cb feat(V2V/powerOff): handle queued state when stopping the VM (#8328)
zammad#34451
2025-02-14 17:16:50 +01:00
OlivierFL
ecd7d624a0 feat(xo-core): update UiTablePagination component (#8344) 2025-02-14 16:20:24 +01:00
Florent BEAUCHAMP
5219d47fd0 test(vhd-lib): improve ci (#8335) 2025-02-14 15:05:34 +01:00
Mathieu
b7a056dfaf feat(@xen-orchestra/rest-api): add error middlware (#8323) 2025-02-14 14:54:44 +01:00
Mathieu
a5831f8a58 fix(xo-server/rest-api): restore legacy rest-api doc (#8346) 2025-02-14 11:43:41 +01:00
Thomas Moraine
ef636a9821 docs(users): add troubleshooting steps for LDAP group sync issues (#8312)
Some users weren't able to sync all LDAP groups with XO.
Adding what to check to troubleshoot the issue.
Also, refreshing the style and presentation of the LDAP section in the Users page
2025-02-14 09:20:35 +01:00
Nathanaël
987e969103 docs(airgap): replace curl by wget to avoid download errors (#8227)
cURL configuration in old XCP-ng/XenServer hosts is not compatible with https://xoa.io, use wget instead.
2025-02-13 14:40:39 +01:00
MlssFrncJrg
7615a6c5f8 feat(xo-server/rest-api): ability to remove user from a group (#8336) 2025-02-13 12:51:29 +01:00
MlssFrncJrg
a0b1ac9388 feat(xo-server/rest-api): ability to add user in a group (#8334) 2025-02-13 12:36:46 +01:00
MlssFrncJrg
4c6797d278 feat(xo-server/rest-api): ability to update group's name (#8278) 2025-02-13 12:25:02 +01:00
MlssFrncJrg
4b244daebd feat(xo-server/rest-api): ability to delete group (#8277) 2025-02-13 11:45:21 +01:00
MlssFrncJrg
2994b9a327 feat(xo-server/rest-api): ability to create group (#8276) 2025-02-13 11:08:10 +01:00
Mathieu
137837cdab fix(sdn-controller): fix no PIF found when creating private network (#8319)
Fix #8027 zammad#34140
2025-02-12 17:07:40 +01:00
OlivierFL
1fd74a29b9 feat(xo-core): update UiCheckbox and UiRadioButton components (#8342) 2025-02-12 15:26:51 +01:00
Mathieu
877ca82600 feat(@xen-orchestra/rest-api): initial setup for swagger (#8316) 2025-02-12 14:09:22 +01:00
OlivierFL
5dbb529126 feat(xo-core): update UiCounter component (#8337) 2025-02-12 11:55:18 +01:00
OlivierFL
78c3b40762 feat(xo-core): update Button components (#8329) 2025-02-12 11:25:07 +01:00
OlivierFL
47ee8799f3 feat(xo-core): update UiCardTitle and UiCardSubtitle components (#8338) 2025-02-12 11:07:00 +01:00
OlivierFL
8f877962f3 feat(xo-core): update VtsIcon component brand color (#8325) 2025-02-11 17:06:55 +01:00
b-Nollet
832634f284 chore(xo-server): upgrade hashy version (#8304) 2025-02-11 15:50:07 +01:00
Sebastian CZECH
6b6a9a4942 feat(xo-6): dashboard mobile layout (#8268) 2025-02-11 15:11:58 +01:00
OlivierFL
87551ca7b8 feat(xo-core): update UiLink component (#8331) 2025-02-11 13:57:10 +01:00
Julien Fontanet
decf1ebf3e feat(normalize-packages): Node defaults to >=18
See https://github.com/vatesfr/xen-orchestra/pull/8332/files#r1950603611
2025-02-11 11:37:27 +01:00
OlivierFL
aeb22636c7 feat(xo-core): update scrollbars color (#8330) 2025-02-11 09:39:31 +01:00
Thomas Moraine
82473fdf37 docs(sdn_controller): remove XCP-ng 8.0 and prior (obsolete) (#8322)
XCP-ng 8.0 and 7.6 are references in multiple sections.

These versions are obsolete, nobody should be using SDN Controller on anything older than XCP-ng 8.2.

As a result, references to XCP-ng 8.0 and older can be removed safely.

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-02-10 17:19:25 +01:00
Truong
f692c25638 VM Description in backup report (#8253)
Sometime, the name does not provide enough information about the VM, adding more with Description, we will know more which VM has issue with backup.

Co-authored-by: b-Nollet <150058367+b-Nollet@users.noreply.github.com>
2025-02-10 17:05:06 +01:00
OlivierFL
6b30fb50c4 feat(xo-core): update color "info" values (#8314) 2025-02-10 15:10:38 +01:00
Mathieu
0ef95d9911 feat(@vates/types): create @vates/types lib (#8308) 2025-02-10 10:16:39 +01:00
Thomas Moraine
37aa941796 docs(sdn-controller): various improvements (#8318) 2025-02-09 00:33:40 +01:00
Jonas
d907129e82 docs(users): using Google for SAML auth (#8296) 2025-02-07 12:46:57 +01:00
Thomas Moraine
57fe8aec51 docs(users): authentication with auth-oidc plugin (#8230)
Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-02-07 12:44:24 +01:00
Sebastian CZECH
90188726e3 feat(lite/release): add changelog to GitHub release description (#8265) 2025-02-07 11:18:09 +01:00
Sebastian CZECH
564a2af879 feat(web-core): migrate UiResource(s) to VtsResource(s) (#8315) 2025-02-06 11:08:37 +01:00
b-Nollet
13488b41b1 feat(lite): 0.7.1 (#8310) 2025-02-04 16:37:53 +01:00
b-Nollet
3e335a3741 feat: release 5.103.1 (#8309) 2025-02-04 16:09:19 +01:00
b-Nollet
ac015305d7 feat: technical release (#8307)
* feat(@xen-orchestra/web-core): 0.13.0

* feat(@xen-orchestra/web): 0.9.0

* feat(xo-server): 5.170.0

* chore(CHANGELOG): update next
2025-02-04 14:48:46 +01:00
Mathieu
936bdaac45 fix(web-core): fix UiStackedBar percent calculation (#8306)
Introduced by 14da422
2025-02-04 13:30:46 +01:00
OlivierFL
0960f8d8d3 fix(lite/host): improve console retrieval (#8305) 2025-02-04 10:17:18 +01:00
Mathieu
6ae05398d7 feat(XO6/dashboard): display size used by replications (#8300) 2025-02-04 10:15:11 +01:00
Stephane
e1b1fcd948 fix(xo-server): fix calculate size replication in sr (#8302)
Introduced by 90a33ea
2025-02-03 17:35:57 +01:00
Florent BEAUCHAMP
7579b3359c docs(async-each): improve the js doc (#8293) 2025-02-03 13:00:25 +01:00
Thomas Moraine
dfbdcd23f2 docs: update XO logo & require Debian 12 (#8263)
Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-02-03 12:48:49 +01:00
Thomas Moraine
683f5f61d3 docs: remove references to XOSAN (legacy) (#8299)
Since XOSAN is now legacy and XOSTOR has taken its place, we are removing XOSAN from the Xen Orchestra doc.

Links to XOSAN docs are replaced with links to XOSTOR (from the XCP-ng doc).

Signed-off-by: Thomas Moraine <thomas.moraine@vates.tech>
2025-02-03 09:28:53 +01:00
b-Nollet
c8c9f77d45 feat(xo-server): remember server's pool name and description (#8206)
Remember server's pool name and description, and display the pool name as server default label. The server label can be deleted (since 81e7e07) to get back to the default pool name value.

We did multiple commits to keep the original changes around, but I would advise not to review by commit.

XO-437
2025-02-03 08:41:38 +01:00
Mathieu
7aa51f20c9 fix(xo-server/VM): do not try to resize vdi if vdi.size is undefined (#8291) 2025-02-03 08:35:13 +01:00
b-Nollet
494ea4509f feat(lite): 0.7.0 (#8290) 2025-01-30 16:39:46 +01:00
b-Nollet
4296ca7e9b feat: release 5.103.0 (#8289) 2025-01-30 13:58:52 +01:00
b-Nollet
437e438672 feat: technical release (#8288) 2025-01-30 11:54:42 +01:00
Julien Fontanet
81e7e07312 fix(xo-server,xo-web): allow deleting server's label 2025-01-30 10:27:37 +01:00
Mathieu
05dd9238b8 fix(xo-server/test): use mock.timers for getFromAsyncCache test (#8279)
introduced by 1eeff13

use mock.timers to avoid false negative.
https://nodejs.org/dist/v23.6.1/docs/api/test.html#timers
2025-01-30 08:21:56 +01:00
b-Nollet
aae8b6b1d8 fix(@xen-orchestra/backups): avoid undefined target (#8275) 2025-01-29 17:43:26 +01:00
Sebastian CZECH
097aa5a584 feat(xo-6): add host network view (#8198) 2025-01-29 16:29:41 +01:00
Nathanaël
062dd7561f feat(web-stack/i18n): translations update from Weblate (#8178) 2025-01-29 14:41:03 +01:00
Joris K
2fe2ef49db fix(xo-lite,xo-6): issue with status in pool network table (#8284)
Fix issue with how status is calculated in pool table
2025-01-29 12:23:35 +01:00
Joris K
7862952cf6 fix(xo-lite): issue with wording in host pif table (#8285) 2025-01-29 12:18:02 +01:00
Sebastian CZECH
76c3f7472f feat(xo-6): add pool network view (#8182) 2025-01-29 09:54:06 +01:00
Thomas Moraine
35e8af6313 docs(advanced): disk health monitoring (#8264)
Disk health monitoring from XO was available since XO v5.87.
It's now documented as part of the Management -> Advanced features section.
2025-01-29 09:53:01 +01:00
Joris K
3e69d4c84b feat(xo-lite/host): add host network tab and pif table / status component … (#8180) 2025-01-28 16:22:58 +01:00
Stephane
7e3b7367f1 fix(backups): backup worker inspect failed: address already in use (#8197) 2025-01-28 15:50:45 +01:00
Pierre Donias
42b32eb079 feat: technical release (#8274) 2025-01-28 11:43:52 +01:00
Florent BEAUCHAMP
bf874bce7c fix(xo-server/getAllUnhealthyVdiChainsLength): better handling of vdi not in cache (#8273) 2025-01-28 10:32:22 +01:00
Florent BEAUCHAMP
989b3888ee feat(backups): don't use checksum if not needed (#8270)
* feat(backups): don't use checksum if not needed

encrypted backups are already authenticated, this guarantee that
a readble file has not been modified
thus, no need to compute a relatively slow hash is encryption is already done
2025-01-28 10:30:15 +01:00
MlssFrncJrg
b519e25601 fix(xo-web): in storage creation, display error when selecting SR type (#8250) 2025-01-28 10:02:03 +01:00
Florent BEAUCHAMP
2677b8ca38 fix(backups): weekly ltr retention last week of year (#8269)
the year computation use the locale year computation, whereas the week 
was using the iso week computation
2025-01-28 08:19:16 +01:00
Florent BEAUCHAMP
5024bd765b test: fix ci (#8271)
* test(xo-server/asynccache): more time to let the test run
* test(vhd-lib/openvhd): concurrency on openvhd tests
2025-01-27 16:27:52 +01:00
Joris K
6761274a16 feat(xo-lite/network): implementation of network tab and add network/pif store (#8147) 2025-01-27 16:21:46 +01:00
Thomas Moraine
512af62f7f docs(backups): long term retention (GFS) (#8223) 2025-01-27 15:38:05 +01:00
Thomas Moraine
4123ae731f docs(backups): encryption algorithms (#8266)
Explain how backups are encrypted by Xen Orchestra,
what each encryption algorithm does,
and how it works with Amazon S3
2025-01-27 11:52:39 +01:00
Stephane
90a33ead6d feat(xo-server): calculate size replication in sr (#8262) 2025-01-27 11:49:37 +01:00
Florent BEAUCHAMP
a49864abfb fix(V2V/Vsan): better handling of multiple VSAN (#8243) 2025-01-24 11:34:50 +01:00
Sebastian CZECH
1f4ec8d0e1 feat(xo-6): add console fullscreen action (#8238) 2025-01-23 15:00:22 +01:00
Mathieu
3e615f725b fix(xapi): VDI_destroyCloudInitConfig wait 5 minutes before removing the VDI (#8247) 2025-01-23 13:09:43 +01:00
Mathieu
9af58185cf fix(xo-server, xo-web): fix install patches and RPU on XS8 (#8241) 2025-01-22 15:58:18 +01:00
Sebastian CZECH
2b63829e91 feat(web-core): inverse the logic of border props in MenuList.vue (#8246) 2025-01-22 15:34:56 +01:00
Sebastian CZECH
300bb18a64 feat(web-core): focus on console (#8235) 2025-01-22 15:14:09 +01:00
Sebastian CZECH
42f205e473 feat(xo-6): switch to language based on xo5 cookie (#8242) 2025-01-22 14:55:01 +01:00
Thierry Goettelmann
fe5276217f fix(web-core): useRouteQuery composable type issue (#8261) 2025-01-22 09:04:59 +01:00
Julien Fontanet
1765600437 fix(xo-server-audit): ignore host.getIpmiSensors
Introduced by e30f28907
2025-01-21 09:07:09 +01:00
Stephane
f0680df1e1 feat(backups): add encryption chacha20-poly1305, allow file larger than 64GB on encrypted remote (#8237)
This will also incite users to create new remote with the chacha20-poly1305 instead of aes-256-gcm that will continue to work on exusting remote
2025-01-20 20:10:34 +01:00
Sebastian CZECH
6d34cf67fb feat(web-core): move chart lib from xo-lite to web-core (#8248) 2025-01-20 15:15:46 +01:00
Sebastian CZECH
9c97ac903f feat(xo-6): add Ctrl+Alt+Del (#8239)
functionality to console
2025-01-20 14:56:46 +01:00
OlivierFL
a96d5e64de docs(xo-core): update guidelines (#8194) 2025-01-20 11:44:49 +01:00
b-Nollet
4744071098 fix(usage-report): avoid report failure when previous stats are invalid (#8240) 2025-01-20 11:43:02 +01:00
b-Nollet
dc9dbd9018 fix(xo-web): display mirror backup transfer size (#8224) 2025-01-20 11:40:31 +01:00
b-Nollet
6405eeb1b5 fix(xo-server): throw when remote uses encryption without data blocks (#8244) 2025-01-20 11:38:42 +01:00
Stephane
7c666d1156 fix(backups): fix metadata.isVhdDifferencing key computation (#8216)
This PR fix metadata.isVhdDifferencing where an object was used instead of a string to set the filename.
2025-01-20 10:55:56 +01:00
Sebastian CZECH
7ca6829c27 feat(web-core): standardize console component between xo-lite and xo-6 (#8245) 2025-01-20 10:25:30 +01:00
Florent BEAUCHAMP
97cd50af44 test(backups): fix parallel test (#8257) 2025-01-20 10:16:43 +01:00
David Johnston
5bfd462616 feat(web-stack): add Spanish translation (#8220) 2025-01-20 09:03:41 +01:00
Florent BEAUCHAMP
2b964576f2 test(vhd-lib): fix ci (#8255)
Assertion messages can now contain extra info since Node 20.18.1
2025-01-17 11:50:59 +01:00
MlssFrncJrg
d044dda59a fix(xo-web/SelectTag): list SR tags (#8251)
See https://xcp-ng.org/forum/topic/9953
2025-01-16 15:22:42 +01:00
Julien Fontanet
11a4ceddce chore: update dev deps 2025-01-15 14:46:20 +01:00
Thierry Goettelmann
b97268727c feat(web-core,lite,xo6): add useDisabled composable and replace DisabledContext usage (#8133) 2025-01-13 16:30:24 +01:00
Sebastian CZECH
3a21290fde feat(web-core): add loading state on console (#8226) 2025-01-09 14:43:09 +01:00
Julien Fontanet
299a109eb6 fix(CHANGELOG): fix date
Introduced by 4235e9816
2025-01-09 14:28:28 +01:00
Julien Fontanet
4235e98166 fix(CHANGELOG): add missing date
Introduced by 281ccbb8b
2025-01-09 12:03:04 +01:00
Julien Fontanet
281ccbb8ba feat: release 5.102.1 2025-01-09 11:42:32 +01:00
Julien Fontanet
7053fc4a08 feat(xo-server-backup-reports): 1.4.4 2025-01-09 10:31:12 +01:00
b-Nollet
cd2f74cd63 fix(xo-server-backup-reports): fix undefined function in partial (#8229)
In some failure cases, backup reports were not sent because of an error during handlebars compilation.
The `formatDate` function was incorrectly passed from partial to partial causing the `executeFunction` helper to execute `undefined` as a function (the `vmFailure` helper used default `formatDate=./formatDate` instead of `formatDate=../formatDate`, the `..` being necessary because of the `#each` loop)


Introduced by aa9c49ce6a
2025-01-08 17:25:01 +01:00
Thomas Moraine
6d836df6bf docs(xoa): NTP synchronization with systemd-timesyncd (#8217)
On recent versions of Xen Orchestra, we switched to systemd-timesyncd for NTP management.
XOA documentation is updated to reflect this change.
2025-01-08 09:07:50 +01:00
3970 changed files with 304636 additions and 70061 deletions

View File

@@ -85,6 +85,21 @@ module.exports = {
XO_LITE_GIT_HEAD: true,
},
rules: {
'import/extensions': [
'error',
'ignorePackages',
{
pattern: { ts: 'always', vue: 'always' },
checkTypeImports: true,
// '@core/*' and '@/*' look like scoped packages to the plugin, which 'ignorePackages' would skip
pathGroupOverrides: [
{ pattern: '@core/packages/*', action: 'ignore' }, // barrel files
{ pattern: '@core/icons', action: 'ignore' },
{ pattern: '@core/**', action: 'enforce' },
{ pattern: '@/**', action: 'enforce' },
],
},
],
'import/order': [
'error',
{
@@ -171,7 +186,6 @@ module.exports = {
'vue/no-empty-component-block': 'error',
'vue/no-multiple-objects-in-class': 'error',
'vue/no-ref-object-reactivity-loss': 'error',
'vue/no-required-prop-with-default': 'error',
'vue/no-static-inline-styles': 'error',
'vue/no-template-target-blank': 'error',
'vue/no-undef-components': ['error', { ignorePatterns: ['RouterLink', 'RouterView', 'I18nT'] }],
@@ -192,18 +206,14 @@ module.exports = {
// Specific rules for XO i18n-related files
{
files: [
'@xen-orchestra/lite/**/*.{vue,ts,json}',
'@xen-orchestra/web/**/*.{vue,ts,json}',
'@xen-orchestra/lite/**/*.{vue,ts}',
'@xen-orchestra/web/**/*.{vue,ts}',
'@xen-orchestra/web-core/**/*.{vue,ts,json}',
],
extends: ['plugin:@intlify/vue-i18n/base'],
settings: {
'vue-i18n': {
localeDir: [
'@xen-orchestra/lite/src/locales/*.json',
'@xen-orchestra/web/src/locales/*.json',
'@xen-orchestra/web-core/lib/locales/*.json',
],
localeDir: ['@xen-orchestra/web-core/lib/locales/*.json'],
messageSyntaxVersion: '^9.9.0',
},
},
@@ -221,13 +231,49 @@ module.exports = {
'@intlify/vue-i18n/no-raw-text': [
'error',
{
ignoreText: ['Xen Orchestra Lite', 'Xen Orchestra', 'XO Lite', 'XCP-ng', 'XO 5', '404', '⚠️'],
ignoreText: [
'Xen Orchestra Lite',
'Xen Orchestra',
'Xen Orchestra Appliance',
'XO Lite',
'XCP-ng',
'XO 5',
'404',
'⚠️',
],
},
],
'@intlify/vue-i18n/no-v-html': 'error',
'@intlify/vue-i18n/valid-message-syntax': 'error',
'@intlify/vue-i18n/no-duplicate-keys-in-locale': 'error',
'@intlify/vue-i18n/no-missing-keys-in-other-locales': ['error', { ignoreLocales: ['de', 'fa', 'cs'] }],
'@intlify/vue-i18n/no-missing-keys-in-other-locales': [
'error',
{
ignoreLocales: [
'de',
'fa',
'cs',
'es',
'sv',
'it',
'ru',
'uk',
'nl',
'pt-BR',
'da',
'ko',
'nb-NO',
'ja',
'fi',
'pl',
'zh-Hans',
'sk',
'pt',
'ar',
'tr',
],
},
],
},
},
// Specific rules for XO dev-related files
@@ -252,7 +298,7 @@ module.exports = {
},
},
{
files: ['@xen-orchestra/{web-core,lite,web}/typed-router.d.ts'],
files: ['@xen-orchestra/{web-core,lite,web}/src/route-map.d.ts'],
parserOptions: {
sourceType: 'module',
},
@@ -261,6 +307,17 @@ module.exports = {
'eslint-comments/no-unlimited-disable': 'off',
},
},
{
files: ['*.d.ts'],
parser: '@typescript-eslint/parser',
parserOptions: {
sourceType: 'module',
},
rules: {
'n/no-unsupported-features/es-syntax': 'off',
'no-unused-vars': 'off',
},
},
],
ignorePatterns: ['@vates/fatfs/'],

View File

@@ -40,8 +40,8 @@ body:
- type: markdown
attributes:
value: |
- Don't forget to [read this first](https://xen-orchestra.com/docs/community.html)
- As well as follow [this guide](https://xen-orchestra.com/docs/community.html#report-a-bug)
- Don't forget to [read this first](https://docs.xen-orchestra.com/community)
- As well as follow [this guide](https://docs.xen-orchestra.com/community#report-a-bug)
- type: input
id: xo-sources-commit-number
attributes:

36
.github/workflows/changelog-check.yml vendored Normal file
View File

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

View File

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

68
.github/workflows/vectura.yml vendored Normal file
View File

@@ -0,0 +1,68 @@
name: Vectura
# only when vectura changes: the rest of the repository does not build it
#
# pull requests rather than every push: GitHub filters a push on the diff with
# the previous push, which after a rebase on `master` (or a merge of it)
# contains the vectura changes of `master`, whereas it filters a pull request
# on the diff with the merge base, i.e. the changes of the branch only
on:
pull_request:
paths:
- '@xen-orchestra/vmware-explorer/vectura/**'
- .github/workflows/vectura.yml
push:
branches:
- master
paths:
- '@xen-orchestra/vmware-explorer/vectura/**'
- .github/workflows/vectura.yml
defaults:
run:
working-directory: '@xen-orchestra/vmware-explorer/vectura'
jobs:
checks:
runs-on: ubuntu-latest
steps:
# https://github.com/actions/checkout
- uses: actions/checkout@v4
# rustup, on the runner image, installs the toolchain rust-toolchain.toml
# names on the first cargo command
- run: cargo fmt --all --check
- run: cargo clippy --all-targets --locked -- -D warnings
- run: cargo test --locked
# https://github.com/taiki-e/install-action
- uses: taiki-e/install-action@v2
with:
tool: cargo-deny@0.20.2,cargo-llvm-cov@0.8.5
- run: cargo deny --locked check
- run: cargo llvm-cov --locked --json --summary-only --output-path target/coverage.json
- name: Hold the coverage floor
run: cargo test --locked --test coverage -- --ignored
# builds the binary as a release does, then checks it and the one in git,
# which xo-server runs
binary:
runs-on: ubuntu-latest
steps:
# https://github.com/actions/checkout
- uses: actions/checkout@v4
# https://github.com/mlugg/setup-zig
- uses: mlugg/setup-zig@v2
with:
version: 0.16.0
# https://github.com/taiki-e/install-action
- uses: taiki-e/install-action@v2
with:
tool: cargo-zigbuild@0.23.4
- run: scripts/build.sh "$RUNNER_TEMP/build"
- run: scripts/check-binary.sh "$RUNNER_TEMP/build/vectura"
- run: scripts/check-binary.sh vectura

3
.gitignore vendored
View File

@@ -5,8 +5,11 @@
/@vates/*/dist/
/@vates/*/node_modules/
/@vates/node-vsphere-soap/test/config-test.mjs
/@xen-orchestra/*/dist/
/@xen-orchestra/*/node_modules/
/@xen-orchestra/rest-api/open-api/spec/swagger.json
/@xen-orchestra/rest-api/src/open-api/routes/routes.ts
/packages/*/dist/
/packages/*/node_modules/

View File

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

View File

@@ -16,7 +16,7 @@ Returns a promise wich rejects as soon as a call to `iteratee` throws or a promi
- `concurrency`: a number which indicates the maximum number of parallel call to `iteratee`, defaults to `10`. The value `0` means no concurrency limit.
- `signal`: an abort signal to stop the iteration
- `stopOnError`: wether to stop iteration of first error, or wait for all calls to finish and throw an `AggregateError`, defaults to `true`
- `stopOnError`: whether to stop iteration of first error, or wait for all calls to finish and throw an `AggregateError`, defaults to `true`
```js
import { asyncEach } from '@vates/async-each'

View File

@@ -34,7 +34,7 @@ Returns a promise wich rejects as soon as a call to `iteratee` throws or a promi
- `concurrency`: a number which indicates the maximum number of parallel call to `iteratee`, defaults to `10`. The value `0` means no concurrency limit.
- `signal`: an abort signal to stop the iteration
- `stopOnError`: wether to stop iteration of first error, or wait for all calls to finish and throw an `AggregateError`, defaults to `true`
- `stopOnError`: whether to stop iteration of first error, or wait for all calls to finish and throw an `AggregateError`, defaults to `true`
```js
import { asyncEach } from '@vates/async-each'

5
@vates/async-each/index.d.ts vendored Normal file
View File

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

View File

@@ -11,8 +11,8 @@ class AggregateError extends Error {
/**
* @template Item
* @param {Iterable<Item>} iterable
* @param {(item: Item, index: number, iterable: Iterable<Item>) => Promise<void>} iteratee
* @param {Iterable<Item>|AsyncIterable<Item>} iterable
* @param {(item: Item, index: number, iterable: Iterable<Item>|AsyncIterable<Item>) => Promise<void>} iteratee
* @returns {Promise<void>}
*/
exports.asyncEach = function asyncEach(iterable, iteratee, { concurrency = 10, signal, stopOnError = true } = {}) {
@@ -73,20 +73,20 @@ exports.asyncEach = function asyncEach(iterable, iteratee, { concurrency = 10, s
}
nextIsRunning = true
if (running < concurrency) {
const cursor = await it.next()
if (cursor.done) {
next = () => {
if (running === 0) {
if (errors.length === 0) {
resolve()
} else {
reject(new AggregateError(errors))
try {
const cursor = await it.next()
if (cursor.done) {
next = () => {
if (running === 0) {
if (errors.length === 0) {
resolve()
} else {
reject(new AggregateError(errors))
}
}
}
}
} else {
++running
try {
} else {
++running
const result = iteratee.call(this, cursor.value, index++, iterable)
let then
if (result != null && typeof result === 'object' && typeof (then = result.then) === 'function') {
@@ -94,9 +94,9 @@ exports.asyncEach = function asyncEach(iterable, iteratee, { concurrency = 10, s
} else {
onFulfilled(result)
}
} catch (error) {
onRejected(error)
}
} catch (error) {
onRejected(error)
}
nextIsRunning = false
return next()

View File

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

View File

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

View File

@@ -7,7 +7,7 @@
* @param {Array|Buffer|string} data1 - The first data array or buffer to compare.
* @param {T} data2 - The second data array or buffer to compare.
* @param {(index: number, diff: T) => void} [cb] - The callback function to invoke for each difference. If not provided, an array of differences will be returned.
* @returns {Array<number|T>|undefined} - An array of differences if no callback is provided, otherwise undefined.
* @returns {Array<number|T>|undefined} - An array of differences if no callback is provided; otherwise, undefined.
*/
module.exports = function diff(data1, data2, cb) {
let result

View File

@@ -14,7 +14,7 @@ import { deduped } from '@vates/disposable/deduped'
// the connection with the passed host will be established once at the first call, then, it will be shared with the next calls
const getConnection = deduped(async function (host)) {
const connection = new Connection(host)
return new Disposabe(connection, () => connection.close())
return new Disposable(connection, () => connection.close())
}, host => [host])
```

View File

@@ -32,7 +32,7 @@ import { deduped } from '@vates/disposable/deduped'
// the connection with the passed host will be established once at the first call, then, it will be shared with the next calls
const getConnection = deduped(async function (host)) {
const connection = new Connection(host)
return new Disposabe(connection, () => connection.close())
return new Disposable(connection, () => connection.close())
}, host => [host])
```

View File

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

View File

@@ -1,3 +0,0 @@
.DS_Store
.vscode/
node_modules/

View File

@@ -28,7 +28,7 @@ fs.stat('autoexec.bat', function (e, stats) {
## API
- `fs = fatfs.createFileSystem(vol, [opts], [cb])` — Simply pass in a block driver (see below) mapped to a FAT partition somewhere, and get back the API documented [here](http://nodejs.org/api/fs.html). An options dictionary can be provided, details are in the next section. You may also optionally provide a callback `cb(err)` which will be automatically registered for the on `'ready'` or `'error'` event.
- `fs = fatfs.createFileSystem(vol, [opts], [cb])` — Simply pass in a block driver (see below) mapped to a FAT partition somewhere, and get back an instance of an [`fs`](http://nodejs.org/api/fs.html). An options dictionary can be provided, details are in the next section. You may also optionally provide a callback `cb(err)` which will be automatically registered for the on `'ready'` or `'error'` event.
- `'ready'` event — fired on `fs` when initial volume information has been determined and the API is ready to use. It is safe to call other `fs` methods before this fires **only if** you are sure the first sector will be readable and represents a valid FAT volume.
- `'error'` event — fired if initialization fails for whatever reason.
@@ -36,7 +36,7 @@ fs.stat('autoexec.bat', function (e, stats) {
The `opts` dictionary you pass to `fatfs.createFileSystem` can contain any of the following options:
- `ro` — Enables readonly mode if `true`. It defaults to `false`, but if your volume driver does not provide a `writeSectors` method it will be overriden to `true`.
- `ro` — Enables readonly mode if `true`. It defaults to `false`, but if your volume driver does not provide a `writeSectors` method it will be overridden to `true`.
- `noatime` — The FAT filesystem can track the last access time (just a date, actually) but this means every read operation would also incur some write overhead. Defaults to `true`, meaning by default access times will **not** be updated on reads. Set this to `false` to track access times.
- `modmode` — chooses how `fs.chmod` (and the mode field from `fs.stat`–family calls) should map FAT attributes to POSIX permissions. Set to the number `0111` to map the readonly flag to the user's write bit being unset, and the archive/system/hidden flags to the user/group/other executable bits respectively. Set to the number `07000` to map the readonly flag to _all_ write bits being unset, and the archive/system/hidden flags to the sticky/setgid/setuid bits respectively. Set to `null` for readonly mapping. Defaults to `0111`.
- `umask` — any bits _set_ in this octal number will be _unset_ in the 'mode' field from `fs.stat`–family calls. It does not affect anything else. Defaults to `process.umask()`, or `0022` if that is unavailable.
@@ -53,7 +53,7 @@ Well, sort of…
### Temporary
- **BETA** **BETA** **BETA**. Seriously, this is a _brand new_, _from scratch_, _completely unproven_ filesystem implementation. It does not have full automated test coverage, and it has not been manually tested very much either. Please please please **make sure you have a backup** of any important drive/image/card you unleash this upon.
- **BETA** **BETA** **BETA**. Seriously, this is a _brand new_, _from scratch_, _completely unproven_ filesystem implementation. It does not have full automated test coverage, and it has not been manually tested very much either. Please, please, please, **make sure you have a backup** of any important drive/image/card you unleash this upon.
- A few methods are not quite implemented, either: `fs.rename`, `fs.unlink` and `fs.rmdir`, as well as `fs.watchFile`/`fs.unwatchFile` and `fs.watch`. These are Coming Soon™.
- There are several internal housekeeping items (redundant FAT tables, extra FAT32 information, etc.) that are not done. These do not seem to affect interop, but you may see warnings when repairing a filesystem written by this module.
- Oh, and not to scare you, but if an IO error happens while writing, the library usually just bails — bubbling an error up to your callback as if it were a hot potato. Although some attempt has been made to do separate writes in the safest order (e.g. allocating an additional file cluster, then appending data into it, and then finally updating the file's size), but this behavior has not been thoroughly audited for all operations. There's certainly no attempt to retry/cleanup/rollback if a multi-step change runs into trouble partway through.

View File

@@ -62,7 +62,7 @@ exports.parseFlags = function (flags) {
case 'ax': info = {read:false, write:true, create:true, append:true, exclusive:true}; break;
case 'a+': info = {read:true, write:true, create:true, append:true}; break;
case 'ax+': info = {read:true, write:true, create:true, append:true, exclusive:true}; break;
default: throw Error("Uknown mode: "+flags); // TODO: throw as `S.err.INVAL`
default: throw Error("Unknown mode: "+flags); // TODO: throw as `S.err.INVAL`
}
if (info.sync) throw Error("Mode not implemented."); // TODO: what would this require of us?
if (_dir) info._openDir = true;

View File

@@ -1,15 +1,16 @@
{
"private": false,
"name": "@vates/fatfs",
"version": "0.11.0",
"version": "0.11.1",
"description": "fs implementation on top of raw FAT16/FAT32 block source",
"main": "index.js",
"scripts": {
"postversion": "npm publish --access public"
},
"repository": {
"directory": "@vates/fatfs",
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra"
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"keywords": [
"filesystem",
@@ -20,13 +21,14 @@
],
"author": "Vates SAS",
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/vatesfr/xen-orchestra/issues"
},
"homepage": "https://github.com/vatesfr/xen-orchestra",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/fatfs",
"dependencies": {
"fifolock": "^1.0.0",
"struct-fu": "^1.2.1",
"xok": "^1.0.0"
},
"engines": {
"node": ">=18"
}
}

View File

@@ -90,7 +90,7 @@ function startTests(vol, waitTime) {
assert(path === file, "Fixed fluffy path matches normal one.");
});
fs.realpath([BASE_DIR,"non","existent","path"].join('/'), function (e) {
assert(e, "Expected error calling fs.realpath on non-existent file.");
assert(e, "Expected error calling fs.realpath on nonexistent file.");
});
fs.readdir(BASE_DIR, function (e, arr) {

View File

@@ -13,7 +13,7 @@ await mount(handler, diskId, mountPoint)
### cli
From the install folder :
From the install folder:
```
cli.mjs <remoteUrl> <vhdPathInRemote> <mountPoint>
@@ -25,4 +25,25 @@ After installing the package
xo-fuse-vhd <remoteUrl> <vhdPathInRemote> <mountPoint>
```
remoteUrl can be found by using cli in `@xen-orchestra/fs` , for example a local remote will have a url like `file:///path/to/remote/root`
The `remoteUrl` can be found using the following command from the XOA CLI, and then picking the right `url` property:
`xo-server-db ls remote`
The `vhdPathInRemote` follows a file structure described in [VM backups](https://github.com/vatesfr/xen-orchestra/blob/master/%40xen-orchestra/backups/docs/VM%20backups/README.md).
## Restore a file from a VHD using `fuse-vhd` CLI
1. Mount a VHD to the Filesystem (see **Usage**).
2. Verify the VHD is Correctly Mounted:
`mount | grep fuse`
3. Get the `START` and `SIZE` of the disk whose file you want to restore. Multiply `START` by 512 (block size) to get the offset value. (`mountedVhdPath` is the `mountPoint` value from earlier, followed by `/vhd0`, for example). Depending on the partition type, you may need to do some additional setup to discover the partitions.
`partx --bytes --output=NR,START,SIZE,NAME,UUID,TYPE --pairs <mountedVhdPath>`
4. Mount the disk. Depending on the partition type, you may need to add some additional options. The `norecovery` option is used for ext3/ext4/xfs file systems; otherwise, remove this option.
`mount --options=loop,ro,norecovery,sizelimit=<SIZE>,offset=<START*512> --source=<mountedVhdPath> --target=<diskMountPoint>`
5. Copy your files from the mounted partition, then unmount the partition.

View File

@@ -29,7 +29,7 @@ await mount(handler, diskId, mountPoint)
### cli
From the install folder :
From the install folder:
```
cli.mjs <remoteUrl> <vhdPathInRemote> <mountPoint>
@@ -41,7 +41,28 @@ After installing the package
xo-fuse-vhd <remoteUrl> <vhdPathInRemote> <mountPoint>
```
remoteUrl can be found by using cli in `@xen-orchestra/fs` , for example a local remote will have a url like `file:///path/to/remote/root`
The `remoteUrl` can be found using the following command from the XOA CLI, and then picking the right `url` property:
`xo-server-db ls remote`
The `vhdPathInRemote` follows a file structure described in [VM backups](https://github.com/vatesfr/xen-orchestra/blob/master/%40xen-orchestra/backups/docs/VM%20backups/README.md).
## Restore a file from a VHD using `fuse-vhd` CLI
1. Mount a VHD to the Filesystem (see **Usage**).
2. Verify the VHD is Correctly Mounted:
`mount | grep fuse`
3. Get the `START` and `SIZE` of the disk whose file you want to restore. Multiply `START` by 512 (block size) to get the offset value. (`mountedVhdPath` is the `mountPoint` value from earlier, followed by `/vhd0`, for example). Depending on the partition type, you may need to do some additional setup to discover the partitions.
`partx --bytes --output=NR,START,SIZE,NAME,UUID,TYPE --pairs <mountedVhdPath>`
4. Mount the disk. Depending on the partition type, you may need to add some additional options. The `norecovery` option is used for ext3/ext4/xfs file systems; otherwise, remove this option.
`mount --options=loop,ro,norecovery,sizelimit=<SIZE>,offset=<START*512> --source=<mountedVhdPath> --target=<diskMountPoint>`
5. Copy your files from the mounted partition, then unmount the partition.
## Contributions

View File

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

View File

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

View File

@@ -0,0 +1,58 @@
A toolbox to ease the use of generator
### Timeout
wrap a source async generator to have it throw an error when timeout is reached
timeout is a positive number in milliseconds
```js
import { Timeout } from '@vates/generator-toolbox'
const wrappedGenerator = new Timeout(sourceGenerator, timeout)
```
### Throttle
wrap a source async generator to have it respect a max speed ( in bytes per seconds).
speed is either a strictly positive number or a function returning a strictly positive number. A speed change will be used for the next emitted packet.
The source generator must yield object with a length property.
Optimized for small yields regarding to the speed, since it won't split incoming packet.
If the generator reached the max speed it will be paused, limiting memory consumption.
```js
import { Throttle } from '@vates/generator-toolbox'
const wrappedGenerator = new Throttle(sourceGenerator, speed)
```
### Synchronized
Fork a generator. The rules are:
- if the source returns, all the forks return
- if the forks error, all the forks error with the same error
- if a fork returns, it is stopped, but the generator continue with the other
- if a fork errors, it is stopped, but the generator continue with the other
- if all the forks return, the source is stopped
- if all the forks error, the source is errored with the last error
- the source start producing a packet when the fastest forked ask for it
- the source forks get the packet only when all the forks asked for it, no buffer stores in memory
```ts
import { Synchronized } from '@vates/generator-toolbox'
async function consume(generator: AsyncGenerator) {
for await (const val of generator) {
console.log({ val })
}
}
const forker = new Synchronized(generator)
const first = forker.fork('first')
const second = forker.fork('second')
await Promise.all([consume(first), consume(second)])
```
Note: you can stop early a generator by calling `generator.return()`, and you can stop in in error by calling `generator.throw(error)`

View File

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

View File

@@ -0,0 +1,89 @@
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
# @vates/generator-toolbox
[![Package Version](https://badgen.net/npm/v/@vates/generator-toolbox)](https://npmjs.org/package/@vates/generator-toolbox) ![License](https://badgen.net/npm/license/@vates/generator-toolbox) [![PackagePhobia](https://badgen.net/bundlephobia/minzip/@vates/generator-toolbox)](https://bundlephobia.com/result?p=@vates/generator-toolbox) [![Node compatibility](https://badgen.net/npm/node/@vates/generator-toolbox)](https://npmjs.org/package/@vates/generator-toolbox)
## Install
Installation of the [npm package](https://npmjs.org/package/@vates/generator-toolbox):
```sh
npm install --save @vates/generator-toolbox
```
## Usage
A toolbox to ease the use of generator
### Timeout
wrap a source async generator to have it throw an error when timeout is reached
timeout is a positive number in milliseconds
```js
import { Timeout } from '@vates/generator-toolbox'
const wrappedGenerator = new Timeout(sourceGenerator, timeout)
```
### Throttle
wrap a source async generator to have it respect a max speed ( in bytes per seconds).
speed is either a strictly positive number or a function returning a strictly positive number. A speed change will be used for the next emitted packet.
The source generator must yield object with a length property.
Optimized for small yields regarding to the speed, since it won't split incoming packet.
If the generator reached the max speed it will be paused, limiting memory consumption.
```js
import { Throttle } from '@vates/generator-toolbox'
const wrappedGenerator = new Throttle(sourceGenerator, speed)
```
### Synchronized
Fork a generator. The rules are:
- if the source returns, all the forks return
- if the forks error, all the forks error with the same error
- if a fork returns, it is stopped, but the generator continue with the other
- if a fork errors, it is stopped, but the generator continue with the other
- if all the forks return, the source is stopped
- if all the forks error, the source is errored with the last error
- the source start producing a packet when the fastest forked ask for it
- the source forks get the packet only when all the forks asked for it, no buffer stores in memory
```ts
import { Synchronized } from '@vates/generator-toolbox'
async function consume(generator: AsyncGenerator) {
for await (const val of generator) {
console.log({ val })
}
}
const forker = new Synchronized(generator)
const first = forker.fork('first')
const second = forker.fork('second')
await Promise.all([consume(first), consume(second)])
```
Note: you can stop early a generator by calling `generator.return()`, and you can stop in in error by calling `generator.throw(error)`
## 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
[MIT](https://spdx.org/licenses/MIT) © [Vates SAS](https://vates.fr)

View 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)

View File

@@ -0,0 +1,38 @@
{
"name": "@vates/generator-toolbox",
"version": "1.1.3",
"main": "dist/index.mjs",
"license": "MIT",
"private": false,
"type": "module",
"devDependencies": {
"@eslint/js": "^9.19.0",
"rimraf": "^6.0.1",
"@types/node": "^20.6",
"typescript": "~5.6",
"typescript-eslint": "^8.61.0"
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"postversion": "npm publish --access public",
"prebuild": "rimraf dist/",
"predev": "yarn run prebuild",
"test": "tsc && node --test **/*.test.mjs",
"clean": "rimraf dist/"
},
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/generator-toolbox",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {
"directory": "@vates/generator-toolbox",
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"author": {
"name": "Vates SAS",
"url": "https://vates.fr"
},
"engines": {
"node": ">=20.18"
}
}

View File

@@ -0,0 +1,3 @@
export { Synchronized } from './synchronized.mjs'
export { Throttle } from './throttle.mjs'
export { Timeout } from './timeout.mjs'

View File

@@ -0,0 +1,150 @@
import assert from 'node:assert'
export class Synchronized<T, TReturn, TNext> {
#source: AsyncGenerator<T, TReturn, TNext>
#forks = new Map<string, Forked<T, TReturn, TNext>>()
#removedForks = new Set<string>()
#waitingForks = new Set<string>()
#started = false
#nextValueForksReady?: {
promise: Promise<IteratorResult<T>>
forksWaitingReject: (error: Error) => void
forksWaitingResolve: () => void
}
constructor(source: AsyncGenerator<T, TReturn, TNext>) {
this.#source = source
}
fork(uid: string): AsyncGenerator<T, TReturn, TNext> {
assert.strictEqual(this.#started, false, `can't create a fork after consuming the data`)
assert.strictEqual(this.#forks.has(uid), false, `a fork with id ${uid} already exists`)
const fork = new Forked<T, TReturn, TNext>(this, uid)
this.#forks.set(uid, fork)
return fork
}
async #resolveWhenAllForksReady(): Promise<IteratorResult<T>> {
if (!this.#nextValueForksReady) {
throw new Error('Can t wait forks if there are noone waiting')
}
const { promise, forksWaitingResolve } = this.#nextValueForksReady
if (this.#waitingForks.size === this.#forks.size) {
// reset value
this.#waitingForks.clear()
this.#nextValueForksReady = undefined
forksWaitingResolve() // for the other forks waiting
}
return promise
}
async next(uid: string): Promise<IteratorResult<T>> {
if (this.#removedForks.has(uid)) {
return { done: true, value: undefined }
}
if (!this.#forks.has(uid)) {
throw new Error(`trying to advance fork ${uid} that is not a fork of this one`)
}
if (this.#waitingForks.has(uid)) {
throw new Error(`Fork ${uid} is already waiting`)
}
this.#started = true
if (this.#nextValueForksReady === undefined) {
let forksWaitingResolve = () => {}
let forksWaitingReject: (reason?: Error) => void = () => {}
const next = this.#source.next().catch(async error => {
const e = new Error(`Error in the source generator ${error.message}`, { cause: error })
forksWaitingReject(e)
// source has failed, kill everything, and stop the forks
for (const uid of [...this.#forks.keys()]) {
await this.remove(uid, error)
}
})
const promise = Promise.all([
next,
new Promise((_resolve, _reject) => {
forksWaitingResolve = () => _resolve(undefined)
forksWaitingReject = _reject
}),
]).then(([_]) => _ as IteratorResult<T>)
this.#nextValueForksReady = { promise, forksWaitingResolve, forksWaitingReject }
}
this.#waitingForks.add(uid)
return this.#resolveWhenAllForksReady()
}
async remove(uid: string, error?: Error): Promise<IteratorResult<T>> {
const fork = this.#forks.get(uid)
if (fork === undefined) {
if (this.#removedForks.has(uid)) {
// already removed
return { done: true, value: undefined }
}
throw new Error(`trying to remove fork with uid ${uid} that is not a fork of this one`)
}
this.#forks.delete(uid)
this.#waitingForks.delete(uid)
this.#removedForks.add(uid)
try {
if (error === undefined) {
await fork.return()
} else {
await fork.throw(error)
}
} catch (cleaningError) {
console.error('Error while cleaning the forked', {
cleaningError,
sourceError: error,
})
}
if (this.#forks.size === 0) {
if (error === undefined) {
await this.#source.return(undefined as TReturn)
} else {
await this.#source.throw(error)
}
// Reject any pending forks waiting for the next value
if (this.#nextValueForksReady) {
this.#nextValueForksReady.forksWaitingReject(new Error('Source generator terminated.', { cause: error }))
this.#nextValueForksReady = undefined
}
// clear state
this.#removedForks.clear()
this.#waitingForks.clear()
} else {
// this fork was maybe blocking the others
if (this.#nextValueForksReady) {
await this.#resolveWhenAllForksReady()
}
}
return { done: true, value: undefined }
}
}
class Forked<T, TReturn, TNext> implements AsyncGenerator<T, TReturn, TNext> {
#parent: Synchronized<T, TReturn, TNext>
#uid: string
constructor(parent: Synchronized<T, TReturn, TNext>, uid: string) {
this.#parent = parent
this.#uid = uid
}
next(): Promise<IteratorResult<T>> {
return this.#parent.next(this.#uid)
}
async return(): Promise<IteratorResult<T>> {
return this.#parent.remove(this.#uid)
}
async throw(e: Error): Promise<IteratorResult<T>> {
return this.#parent.remove(this.#uid, e)
}
[Symbol.asyncIterator](): AsyncGenerator<T, TReturn, TNext> {
return this
}
}

View File

@@ -0,0 +1,173 @@
import assert from 'node:assert'
import { suite, test } from 'node:test'
import { Synchronized } from './synchronized.mjs'
async function* makeRangeGenerator(end = Infinity, progress = { yielded: 0 }, onYielded = (_val: unknown) => {}) {
for (let i = 0; i < end; i++) {
await new Promise(resolve => setTimeout(resolve, 10))
yield i
onYielded(i)
progress.yielded = i
}
return progress
}
async function consume(
iterable: AsyncGenerator,
delay = 2500,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onConsumed = (_val: unknown, _iterable: AsyncGenerator) => Promise.resolve(false)
) {
for await (const val of iterable) {
await new Promise(resolve => setTimeout(resolve, delay))
if (await onConsumed(val, iterable)) {
iterable.return(undefined)
}
}
}
suite('success', () => {
test('for-await-of break stops the source generator', async () => {
let sourceClosed = false
const source = (async function* () {
try {
for (let i = 0; i < 10; i++) {
await new Promise(resolve => setTimeout(resolve, 10))
yield i
}
} finally {
sourceClosed = true
}
})()
const forker = new Synchronized(source)
const fork = forker.fork('first')
// for-await-of must call fork.return() on break, which propagates to the source
for await (const val of fork) {
if (val === 2) break
}
assert.strictEqual(sourceClosed, true)
})
test('if works with multiple consumer', async () => {
const progress = { yielded: 0 }
const generator = makeRangeGenerator(3, progress)
const forker = new Synchronized(generator)
const first = forker.fork('first')
const second = forker.fork('second')
await Promise.all([consume(first, 50), consume(second, 500)])
assert.strictEqual(progress.yielded, 2)
})
test('if works with one consumer returning', async () => {
const progress = { yielded: 0 }
const generator = makeRangeGenerator(10, progress)
const forker = new Synchronized(generator)
const first = forker.fork('first')
const second = forker.fork('second')
await new Promise(resolve => setTimeout(resolve, 500))
await Promise.all([
consume(first, 1),
consume(second, 1, async (val: unknown) => {
return Promise.resolve(val === 2)
}),
])
assert.strictEqual(progress.yielded, 9)
})
test('if works with all consumers returning', async () => {
const progress = { yielded: 0 }
const generator = makeRangeGenerator(10, progress)
const forker = new Synchronized(generator)
const first = forker.fork('first')
const second = forker.fork('second')
await new Promise(resolve => setTimeout(resolve, 500))
await Promise.all([
consume(first, 1, async (val: unknown) => {
return Promise.resolve(val === 5)
}),
consume(second, 1, async (val: unknown) => {
return Promise.resolve(val === 2)
}),
])
assert.strictEqual(progress.yielded, 4)
})
})
suite('error', () => {
test('it fails all if error on source', async () => {
const progress = { yielded: 0 }
const generator = makeRangeGenerator(5, progress, (val: unknown) => {
if (val === 2) {
throw new Error('source stop')
}
})
const forker = new Synchronized(generator)
const first = forker.fork('first')
const second = forker.fork('second')
await assert.rejects(Promise.all([consume(first, 1), consume(second, 1)]))
assert.strictEqual(progress.yielded, 1)
})
test('it flows until the end if one consumer throws', async () => {
const progress = { yielded: 0 }
const generator = makeRangeGenerator(10, progress)
const forker = new Synchronized(generator)
const first = forker.fork('first')
const second = forker.fork('second')
const firstConsume = consume(first, 1)
const secondConsume = assert.rejects(() =>
consume(second, 1, async (val: unknown, iterable) => {
if (val === 1) {
await iterable.throw(new Error('error on second'))
}
return false
})
)
await Promise.allSettled([firstConsume, secondConsume])
assert.strictEqual(progress.yielded, 9)
})
test('it throw if ALL consumer throws', async () => {
const progress = { yielded: 0 }
const generator = makeRangeGenerator(5, progress)
const forker = new Synchronized(generator)
const first = forker.fork('first')
const second = forker.fork('second')
const firstConsume = consume(first, 1, async (val: unknown, iterable) => {
if (val === 3) {
await iterable.throw(new Error('error on first'))
}
return false
})
const secondConsume = consume(second, 1, async (val: unknown, iterable) => {
if (val === 1) {
await iterable.throw(new Error('error on second'))
}
return false
})
await assert.rejects(Promise.all([firstConsume, secondConsume]))
assert.strictEqual(progress.yielded, 2)
})
test('It should not allow a new fork after the start', async () => {
const progress = { yielded: 0 }
const generator = makeRangeGenerator(5, progress)
const forker = new Synchronized(generator)
const first = forker.fork('first')
await first.next()
assert.throws(() => forker.fork('second'))
})
test('It should not allow fork with the same uid', async () => {
const progress = { yielded: 0 }
const generator = makeRangeGenerator(5, progress)
const forker = new Synchronized(generator)
forker.fork('first')
forker.fork('second')
assert.throws(() => forker.fork('first'))
})
})

View File

@@ -0,0 +1,68 @@
import assert from 'node:assert'
/**
*
* This class will throttle the production of a group of generators
*
* limits: depends on the event loop, so don't expect it to be very precise
* works better with more small blocks
* The source generator must yield object with a length property
* Changing the speed will only be takin into account fot the next packets asked
*/
export class Throttle {
#previousSlot = 0
#bytesPerSecond: number | (() => number)
get speed(): number {
let speed: number
if (typeof this.#bytesPerSecond === 'function') {
speed = this.#bytesPerSecond()
} else {
speed = this.#bytesPerSecond
}
assert.ok(speed >= 0, `speed must be positive, ${speed} computed`)
return speed
}
constructor(speed: number | (() => number)) {
this.#bytesPerSecond = speed
}
getNextSlot(length: number): { timeout?: ReturnType<typeof setTimeout>; promise?: Promise<unknown> } {
assert.notStrictEqual(length, undefined, `throttled stream need to expose a length property }`)
assert.ok(length > 0, `throttled stream must expose a positive length property , ${length} given }`)
if (this.speed === 0) {
return {}
}
const previous = this.#previousSlot
const nextSlot = Math.round(previous + (length * 1000) / this.speed)
if (nextSlot < Date.now()) {
// we're above the limit, go now
this.#previousSlot = Date.now()
return {}
}
this.#previousSlot = nextSlot
// wait till the next slot
// it won't be extremely precise since the event loop is not
let timeout: ReturnType<typeof setTimeout> | undefined = undefined
const promise = new Promise(function (resolve) {
timeout = setTimeout(resolve, nextSlot - Date.now())
})
return { promise, timeout }
}
async *createThrottledGenerator(source: AsyncGenerator<{ length: number }>): AsyncGenerator<{ length: number }> {
let timeout: ReturnType<typeof setTimeout> | undefined
try {
for await (const value of source) {
const res = this.getNextSlot(value.length)
timeout = res.timeout
// wait for the time slot before yielding the data
if (res.promise !== undefined) {
await res.promise
}
yield value
}
} finally {
clearTimeout(timeout)
}
}
}

View File

@@ -0,0 +1,63 @@
import { Throttle } from './throttle.mjs'
import { suite, test } from 'node:test'
import assert from 'node:assert'
async function* makeGenerator(chunkSize = 1024 * 1024, nbChunks = 100) {
for (let i = 0; i < nbChunks; i++) {
yield Buffer.allocUnsafe(chunkSize)
}
}
async function consumes(generator: AsyncGenerator) {
// eslint-disable-next-line no-empty,@typescript-eslint/no-unused-vars
for await (const data of generator) {
}
}
suite('it throttle one', () => {
test('base test', async () => {
// 10MB/s
const throttler = new Throttle(10 * 1024 * 1024)
const throttled = throttler.createThrottledGenerator(makeGenerator(1024 * 1024, 20))
const start = Date.now()
await consumes(throttled)
const end = Date.now()
assert.strictEqual(Math.round((end - start) / 1000), 2)
})
test('variable speed test', async () => {
// 10MB/s
const throttler = new Throttle(() => 10 * 1024 * 1024)
const throttled = throttler.createThrottledGenerator(makeGenerator(1024 * 1024, 20))
const start = Date.now()
await consumes(throttled)
const end = Date.now()
assert.strictEqual(Math.round((end - start) / 1000), 2)
})
test('multiple generator test', async () => {
// 10MB/s
const throttler = new Throttle(10 * 1024 * 1024)
const first = throttler.createThrottledGenerator(makeGenerator(1024 * 1024, 10))
const second = throttler.createThrottledGenerator(makeGenerator(1024 * 1024, 10))
const start = Date.now()
await Promise.all([consumes(first), consumes(second)])
const end = Date.now()
assert.strictEqual(Math.round((end - start) / 1000), 2)
})
test(`it doesn't accept negative value`, async () => {
// 10MB/s
let throttler = new Throttle(-10 * 1024 * 1024)
let generator = throttler.createThrottledGenerator(makeGenerator(1024 * 1024, 10))
await assert.rejects(() => consumes(generator))
let index = 5
throttler = new Throttle(() => {
index--
return index > 0 ? 10 * 1024 * 1024 : -10 * 1024 * 1024
})
generator = throttler.createThrottledGenerator(makeGenerator(1024 * 1024, 10))
await assert.rejects(() => consumes(generator))
})
})

View File

@@ -0,0 +1,49 @@
import assert from 'node:assert'
const TIMEOUT = Symbol('timeout')
export class Timeout<T> implements AsyncGenerator {
#source: AsyncGenerator<T>
#timeout: number
constructor(source: AsyncGenerator<T>, timeout: number) {
assert.ok(timeout > 0, `Timeout value must be positive, ${timeout} received`)
this.#source = source
this.#timeout = timeout
}
async next(): Promise<IteratorResult<T>> {
let timeoutHandle: ReturnType<typeof setTimeout>
const sourceNext = this.#source.next()
const result = await Promise.race([
sourceNext.then(res => {
clearTimeout(timeoutHandle)
return res
}),
new Promise<typeof TIMEOUT>(resolve => {
timeoutHandle = setTimeout(() => resolve(TIMEOUT), this.#timeout)
}),
])
if (result === TIMEOUT) {
// stop the source once the in-flight next settles to avoid data loss on the next call
// we don't want to await for the end of timeouted code
sourceNext.then(
() => this.#source.return(undefined).catch(() => {}),
() => {} // source already errored, nothing to clean up
)
throw new Error('Timeout reached')
}
return result
}
return(): Promise<IteratorResult<T>> {
return this.#source.return(undefined)
}
throw(e: Error): Promise<IteratorResult<T>> {
return this.#source.throw(e)
}
[Symbol.asyncIterator](): AsyncGenerator<T> {
return this
}
}

View File

@@ -0,0 +1,87 @@
import { describe, it } from 'node:test'
import assert from 'node:assert'
import { Timeout } from './timeout.mjs'
describe('Timeout class', () => {
it('should reject a timeout with a negative or zero value ', async () => {
// Create a mock async generator that never resolves
const mockGenerator = (async function* () {
yield 1
})()
assert.throws(() => new Timeout(mockGenerator, 0))
assert.throws(() => new Timeout(mockGenerator, -10))
})
it('should resolve with the value from the source generator if it completes before the timeout', async () => {
// Create a mock async generator that resolves immediately
const mockGenerator = (async function* () {
yield 1
})()
const timeout = new Timeout(mockGenerator, 100) // 100ms timeout
const result = await timeout.next()
assert.strictEqual(result.value, 1)
assert.strictEqual(result.done, false)
})
it('should reject with a timeout error if the source generator takes too long', async () => {
// Create a mock async generator that never resolves
// eslint-disable-next-line require-yield
const mockGenerator = (async function* () {
await new Promise(() => {}) // Never resolves
})()
const timeout = new Timeout(mockGenerator, 10) // 10ms timeout
await assert.rejects(timeout.next(), 'Expected timeout error was not thrown')
})
it('should allow iteration using for-await-of', async () => {
// Create a mock async generator that yields values
const mockGenerator: AsyncGenerator<number> = (async function* () {
yield 1
yield 2
yield 3
})()
const timeout = new Timeout(mockGenerator, 100) // 100ms timeout
const results: Array<number> = []
for await (const value of timeout) {
results.push(value as number)
}
assert.deepStrictEqual(results, [1, 2, 3])
})
it('should handle the throw method correctly', async () => {
// Create a mock async generator with a throw method
const mockGenerator = (async function* () {
yield 1
})()
const timeout = new Timeout(mockGenerator, 100) // 100ms timeout
await assert.rejects(timeout.throw(new Error('Test error')), 'Expected error was not thrown')
})
it('should close the source generator after a timeout', async () => {
let sourceClosed = false
const slowSource = (async function* () {
try {
// resolves after 100ms so the in-flight next() eventually settles
await new Promise(resolve => setTimeout(resolve, 100))
yield 1
} finally {
sourceClosed = true
}
})()
const timeout = new Timeout(slowSource, 10) // fires before source yields
await assert.rejects(() => timeout.next(), /Timeout reached/)
// wait for the in-flight next() to settle so source.return() can be called
await new Promise(resolve => setTimeout(resolve, 200))
assert.strictEqual(sourceClosed, true)
})
})

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "es2023",
"module": "ESNext",
"types": ["node"], // Only use types from Node.js and packages that are explicitly imported
"moduleResolution": "bundler",
"strict": true,
"declaration": true, // Generates `.d.ts` files for type safety
"outDir": "dist" // Outputs compiled code to the `dist` directory
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

246
@vates/iscsi/.USAGE.md Normal file
View 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
View File

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

124
@vates/iscsi/PROTOCOL.md Normal file
View File

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

279
@vates/iscsi/README.md Normal file
View File

@@ -0,0 +1,279 @@
<!-- DO NOT EDIT MANUALLY, THIS FILE HAS BEEN GENERATED -->
# @vates/iscsi
[![Package Version](https://badgen.net/npm/v/@vates/iscsi)](https://npmjs.org/package/@vates/iscsi) ![License](https://badgen.net/npm/license/@vates/iscsi) [![PackagePhobia](https://badgen.net/bundlephobia/minzip/@vates/iscsi)](https://bundlephobia.com/result?p=@vates/iscsi) [![Node compatibility](https://badgen.net/npm/node/@vates/iscsi)](https://npmjs.org/package/@vates/iscsi)
> Minimal iSCSI target and initiator: expose or read a single LUN, with one-way CHAP
## Install
Installation of the [npm package](https://npmjs.org/package/@vates/iscsi):
```sh
npm install --save @vates/iscsi
```
## Usage
A small, dependency-light iSCSI **target** (server) written in TypeScript. It
exposes exactly one read/write LUN backed by a pluggable byte-range
`BlockDevice`, and interoperates with the standard Linux **open-iscsi**
initiator (the same one XCP-ng/XAPI uses to attach an `lvmoiscsi` SR).
### Scope
The target drives login negotiation onto a single, fixed code path:
- single initiator, **`MaxConnections=1`**, **`ErrorRecoveryLevel=0`**
- no header/data digests (`HeaderDigest=None`, `DataDigest=None`)
- all writes are R2T-solicited (`InitialR2T=Yes`, `ImmediateData=No`)
- one LUN (LUN 0)
It implements SendTargets discovery, login (`AuthMethod=None` or one-way
**CHAP** — see [Authentication](#authentication-chap)), the ~10 SCSI
commands a Linux initiator issues to attach and use a block device
(`INQUIRY` + VPD `0x00`/`0x80`/`0x83`, `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)

165
@vates/iscsi/VALIDATION.md Normal file
View File

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

View File

@@ -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
View File

@@ -0,0 +1,61 @@
{
"name": "@vates/iscsi",
"version": "0.2.0",
"main": "dist/index.mjs",
"types": "dist/index.d.mts",
"bin": {
"vates-iscsi": "dist/cli.mjs"
},
"license": "ISC",
"private": false,
"type": "module",
"description": "Minimal iSCSI target and initiator: expose or read a single LUN, with one-way CHAP",
"keywords": [
"iscsi",
"target",
"initiator",
"chap",
"scsi",
"lun",
"block",
"storage"
],
"dependencies": {
"@vates/async-each": "^1.0.3",
"@vates/read-chunk": "^1.2.1",
"@xen-orchestra/disk-transform": "^1.3.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"
}
}

View File

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

View File

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

View File

@@ -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()
}
}

View 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)
})
})

View 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 }
}
}

View 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.alloc(length, 0)
let read = 0
// A single fh.read may return fewer bytes than requested; loop to fill.
while (read < length) {
const { bytesRead } = await handle.read(buffer, read, length - read, offset + read)
if (bytesRead === 0) {
// 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
View 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)
}

View 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
View 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)

View File

@@ -0,0 +1,582 @@
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, receiveDataOut, type PendingWrite } from './writePath.mjs'
import { Semaphore } from './semaphore.mjs'
const log: Logger = createLogger('vates:iscsi')
/** 2^32: the modulus for all iSCSI sequence-number arithmetic. */
export const SERIAL_MODULO = 0x1_0000_0000
/** Increment an iSCSI serial number with 32-bit wraparound. */
export function incrementSerial(value: number): number {
return (value + 1) % SERIAL_MODULO
}
/** Add `delta` to an iSCSI serial number with 32-bit wraparound. */
export function addSerial(value: number, delta: number): number {
return (value + delta) % SERIAL_MODULO
}
/**
* Initiator opcodes whose BHS bytes 24-27 are CmdSN. The two that are missing —
* SCSI Data-Out and SNACK — have those bytes reserved instead; see the comment
* on `Connection`'s `#updateCommandWindow` for what reading them would cost.
*/
const CARRIES_CMD_SN: ReadonlySet<number> = new Set([
InitiatorOpcode.NOP_OUT,
InitiatorOpcode.SCSI_COMMAND,
InitiatorOpcode.SCSI_TASK_MGMT_REQUEST,
InitiatorOpcode.LOGIN_REQUEST,
InitiatorOpcode.TEXT_REQUEST,
InitiatorOpcode.LOGOUT_REQUEST,
])
/** Everything a {@link Connection} needs from the owning target. */
export interface ConnectionDeps {
readonly iqn: string
readonly identity: ScsiIdentity
readonly lun: BlockDevice
/** Drain timeout for outbound PDUs, in ms (0 disables). */
readonly writeTimeoutMs: number
/** Size of the CmdSN command window advertised to the initiator. */
readonly cmdWindow: number
/** Max number of READs served concurrently (see {@link Connection}). */
readonly readConcurrency: number
/** Allocate a fresh, non-zero Target Session Identifying Handle. */
allocateTsih(): number
/** When set, require one-way CHAP: the target challenges and verifies the initiator. */
readonly chap?: ChapCredentials
}
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 — but only from one
* that actually carries a CmdSN.
*
* BHS bytes 24-27 hold CmdSN on command PDUs only; on a SCSI Data-Out (RFC
* 7143 §11.7) and a SNACK they are reserved, as is the immediate bit. An
* initiator zeroes them, so treating such a PDU as a command reads CmdSN=0,
* takes the non-immediate branch, and rewinds the window to ExpCmdSN=1 /
* MaxCmdSN=1+window — then advertises it on the very next R2T, SCSI Response,
* or concurrent READ's Data-In. Initiators discard the rewind as stale only
* while the session's CmdSN stays below 2^31; past that it compares as newer
* under serial arithmetic, is adopted, and the window collapses into a stall.
*
* Since every WRITE is R2T-solicited (InitialR2T=Yes), Data-Out is the common
* case, not an edge one. Allowlisting the opcodes that do carry CmdSN, rather
* than skipping the two that don't, keeps a newly handled opcode from
* silently inheriting the bug.
*/
#updateCommandWindow(pdu: IncomingPdu): void {
if (!CARRIES_CMD_SN.has(pdu.opcode)) {
return
}
const cmdSN = pdu.cmdSN
if (!this.#cmdSNInitialized) {
this.#expCmdSN = cmdSN
this.#cmdSNInitialized = true
}
// Immediate PDUs (Login, NOP pings, aborts) do not consume a CmdSN slot.
if (!pdu.immediate) {
this.#expCmdSN = incrementSerial(cmdSN)
}
this.#maxCmdSN = addSerial(this.#expCmdSN, this.#deps.cmdWindow)
}
/** Current StatSN without advancing (for R2T, NOP-In, Reject, non-final Data-In). */
#currentStatSN(): number {
return this.#statSN
}
/** Return the current StatSN and advance it (for status-bearing responses). */
#nextStatSN(): number {
const value = this.#statSN
this.#statSN = incrementSerial(value)
return value
}
#send(buffer: Buffer): Promise<void> {
return writePdu(this.#socket, buffer, this.#deps.writeTimeoutMs)
}
// --- dispatch -------------------------------------------------------------
/** Returns true when the connection should be closed (after a logout). */
async #dispatch(pdu: IncomingPdu): Promise<boolean> {
this.#updateCommandWindow(pdu)
if (this.#phase === 'login') {
if (pdu.opcode === InitiatorOpcode.LOGIN_REQUEST) {
return await this.#handleLogin(pdu)
}
await this.#sendReject(pdu, RejectReason.PROTOCOL_ERROR)
return false
}
switch (pdu.opcode) {
case InitiatorOpcode.SCSI_COMMAND:
if (this.#sessionType === 'Discovery') {
await this.#sendReject(pdu, RejectReason.PROTOCOL_ERROR)
} else {
await this.#handleScsiCommand(pdu)
}
return false
case InitiatorOpcode.TEXT_REQUEST:
await this.#handleText(pdu)
return false
case InitiatorOpcode.NOP_OUT:
await this.#handleNopOut(pdu)
return false
case InitiatorOpcode.LOGOUT_REQUEST:
await this.#handleLogout(pdu)
return true
case InitiatorOpcode.SCSI_DATA_OUT:
await this.#handleDataOut(pdu)
return false
default:
await this.#sendReject(pdu, RejectReason.COMMAND_NOT_SUPPORTED)
return false
}
}
/** Returns true when the login failed and the connection must be closed. */
async #handleLogin(pdu: IncomingPdu): Promise<boolean> {
const { flagsByte, data, statusClass = LoginStatusClass.SUCCESS, statusDetail = 0 } = this.#login.process(pdu)
if (this.#login.complete && this.#tsih === 0) {
this.#tsih = this.#deps.allocateTsih()
}
const bhs = allocBhs(TargetOpcode.LOGIN_RESPONSE)
bhs[1] = flagsByte
bhs[2] = ISCSI_VERSION // Version-max
bhs[3] = ISCSI_VERSION // Version-active
pdu.bhs.copy(bhs, 8, 8, 14) // echo ISID (6 bytes)
bhs.writeUInt16BE(this.#tsih, 14)
bhs.writeUInt32BE(pdu.itt, 16)
bhs.writeUInt32BE(this.#nextStatSN(), 24)
bhs.writeUInt32BE(this.#expCmdSN, 28)
bhs.writeUInt32BE(this.#maxCmdSN, 32)
bhs[36] = statusClass
bhs[37] = statusDetail
await this.#send(assemblePdu(bhs, data))
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> {
// Login declares ImmediateData=No unconditionally, so a data segment here is
// write data we never solicited and have no path to apply. Ignoring it would
// acknowledge a write missing its first bytes — exactly the silent loss the
// solicited path is built to avoid — so fail the command loudly instead and
// let the initiator retry or give up.
if (pdu.data.length > 0) {
log.warn('immediate data is not supported', { itt: pdu.itt, length: pdu.data.length })
const sense = buildFixedSense({ key: SenseKey.ILLEGAL_REQUEST, ...Asc.INVALID_FIELD_IN_CDB })
await this.sendScsiResponse(pdu.itt, ScsiStatus.CHECK_CONDITION, { sense })
return
}
const cdb = pdu.bhs.subarray(32, 48)
if (decodeCdb(cdb).kind === 'read') {
// Not awaited: the read loop must move on immediately rather than block on
// this `lun.read()`. See the class doc for why no extra ordering is needed.
void this.#readGate
.run(() => handleScsiCommand(cdb, pdu.itt, this, this.#deps.identity))
.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,
// zero-filled, not `allocUnsafe`: belt to `receiveDataOut`'s braces, so a
// buffer that somehow reaches the LUN partly filled writes zeroes rather
// than recycled heap (which the initiator could then read straight back)
buffer: Buffer.alloc(totalLength),
totalLength,
contiguousReceived: 0,
solicited: 0,
r2tSN: 0,
}
this.#pendingWrites.set(itt, pending)
log.debug('write begin', { itt, lunOffset, totalLength })
await this.#sendNextR2t(pending)
}
/** Solicit the next burst (≤ MaxBurstLength) of a pending write via an R2T. */
async #sendNextR2t(pending: PendingWrite): Promise<void> {
const desiredLength = Math.min(pending.totalLength - pending.solicited, this.#params.maxBurstLength)
const bufferOffset = pending.solicited
const r2tSN = pending.r2tSN
pending.solicited += desiredLength
pending.r2tSN++
await this.#send(
buildR2t(
{ itt: pending.itt, r2tSN, targetTransferTag: pending.targetTransferTag, bufferOffset, desiredLength },
{ statSN: this.#currentStatSN(), expCmdSN: this.#expCmdSN, maxCmdSN: this.#maxCmdSN }
)
)
}
async #handleDataOut(pdu: IncomingPdu): Promise<void> {
const pending = this.#pendingWrites.get(pdu.itt)
if (pending === undefined) {
// Data-Out for an unknown command: protocol desync.
await this.#sendReject(pdu, RejectReason.PROTOCOL_ERROR)
return
}
const bufferOffset = pdu.readU32(40) // BufferOffset is relative to the command's data
const outcome = receiveDataOut(pending, {
targetTransferTag: pdu.readU32(20),
bufferOffset,
data: pdu.data,
})
log.debug('write data-out', {
itt: pending.itt,
offset: bufferOffset,
length: pdu.data.length,
received: pending.contiguousReceived,
total: pending.totalLength,
})
if (!outcome.ok) {
// The command is dropped, not just the PDU: leaving it pending would hang
// the initiator on an R2T that never comes, until its command timeout.
this.#pendingWrites.delete(pdu.itt)
log.warn('unplaceable write data-out', { itt: pending.itt, reason: outcome.reason })
await this.#sendReject(pdu, RejectReason.PROTOCOL_ERROR)
return
}
if (outcome.complete) {
this.#pendingWrites.delete(pdu.itt)
try {
await this.lun.write(pending.lunOffset, pending.buffer)
} catch (error) {
log.warn('LUN write failed', error instanceof Error ? error : new Error(String(error)))
const sense = buildFixedSense({ key: SenseKey.MEDIUM_ERROR, ...Asc.NO_ADDITIONAL_SENSE })
await this.sendScsiResponse(pending.itt, ScsiStatus.CHECK_CONDITION, { sense })
return
}
await this.sendScsiResponse(pending.itt, ScsiStatus.GOOD)
} else if (outcome.burstComplete) {
// Current burst complete and data remains: solicit the next one.
await this.#sendNextR2t(pending)
}
}
async sendScsiResponse(itt: number, status: number, options: ScsiResponseOptions = {}): Promise<void> {
const { sense, residualOverflow = 0, residualUnderflow = 0 } = options
const bhs = allocBhs(TargetOpcode.SCSI_RESPONSE)
let flags = FLAG_FINAL // bit 7 is always set on a SCSI Response
if (residualOverflow > 0) {
flags |= SCSI_RESP_FLAG_OVERFLOW
}
if (residualUnderflow > 0) {
flags |= SCSI_RESP_FLAG_UNDERFLOW
}
bhs[1] = flags
bhs[2] = SCSI_RESPONSE_COMMAND_COMPLETED
bhs[3] = status
bhs.writeUInt32BE(itt, 16)
bhs.writeUInt32BE(this.#nextStatSN(), 24)
bhs.writeUInt32BE(this.#expCmdSN, 28)
bhs.writeUInt32BE(this.#maxCmdSN, 32)
bhs.writeUInt32BE(residualOverflow || residualUnderflow, 44)
let data: Buffer | undefined
if (sense !== undefined) {
// Sense data in a SCSI Response is prefixed with a 2-byte length.
data = Buffer.alloc(2 + sense.length)
data.writeUInt16BE(sense.length, 0)
sense.copy(data, 2)
}
await this.#send(assemblePdu(bhs, data))
}
}

View File

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

View File

@@ -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')}`
}

View 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
View 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()
}
}

View File

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

View File

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

369
@vates/iscsi/src/login.mts Normal file
View File

@@ -0,0 +1,369 @@
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 {
// Only answer what the initiator actually offered: for every key below, the
// RFC 7143 default of an unoffered key is already the value we want, so
// staying silent and letting the default stand is equivalent.
const answer = (key: string, value: string) => {
if (keys.has(key)) {
responseKeys.push([key, value])
}
}
answer('HeaderDigest', 'None')
answer('DataDigest', 'None')
answer('MaxConnections', '1')
answer('InitialR2T', 'Yes')
answer('ErrorRecoveryLevel', '0')
// ImmediateData is the one exception: its RFC 7143 default is *Yes*, so an
// initiator that never offers the key may legally put write data in the SCSI
// Command PDU itself — which this target does not implement and would
// otherwise silently drop, acknowledging a write it never applied. Declare
// No whether or not it was offered. Costs nothing: there is no immediate
// data path to lose, and every mainstream initiator offers the key anyway,
// so for them this is byte-identical to before.
responseKeys.push(['ImmediateData', 'No'])
answer('DataPDUInOrder', 'Yes')
answer('DataSequenceInOrder', 'Yes')
answer('DefaultTime2Wait', '2')
answer('DefaultTime2Retain', '0')
answer('MaxOutstandingR2T', '1')
// MaxRecvDataSegmentLength is declarative per direction: capture the
// initiator's (caps our Data-In) and always declare ours (caps its Data-Out).
this.params.initiatorMaxRecvDataSegmentLength = parsePositiveInt(
keys.get('MaxRecvDataSegmentLength'),
this.params.initiatorMaxRecvDataSegmentLength
)
responseKeys.push(['MaxRecvDataSegmentLength', String(OUR_MAX_RECV_DATA_SEGMENT_LENGTH)])
// MaxBurstLength / FirstBurstLength negotiate to the minimum of both offers.
const maxBurst = Math.min(parsePositiveInt(keys.get('MaxBurstLength'), OUR_MAX_BURST_LENGTH), OUR_MAX_BURST_LENGTH)
this.params.maxBurstLength = maxBurst
if (keys.has('MaxBurstLength')) {
responseKeys.push(['MaxBurstLength', String(maxBurst)])
}
if (keys.has('FirstBurstLength')) {
const firstBurst = Math.min(parsePositiveInt(keys.get('FirstBurstLength'), maxBurst), maxBurst)
responseKeys.push(['FirstBurstLength', String(firstBurst)])
}
}
}

View File

@@ -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')
})
})

View 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
View 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
}

View 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
View 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)
}
}

View File

@@ -0,0 +1,237 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import type { BlockDevice } from './backend.mjs'
import { ScsiStatus, SenseKey } from './constants.mjs'
import {
buildReadCapacity10,
buildReadCapacity16,
buildReportLuns,
buildStandardInquiry,
buildVpd,
handleScsiCommand,
type ScsiIdentity,
} from './scsi.mjs'
import { decodeCdb, type CommandContext, type ScsiResponseOptions } from './types.mjs'
const IDENTITY: ScsiIdentity = { vendor: 'VATES', product: 'ISCSI LUN', revision: '0001', serial: 'unit-serial-1' }
// --- a trivial in-memory LUN ------------------------------------------------
class MemoryBlockDevice implements BlockDevice {
readonly #buffer: Buffer
readonly #blockSize: number
constructor(sizeBytes: number, blockSize = 512) {
this.#buffer = Buffer.alloc(sizeBytes)
this.#blockSize = blockSize
}
get buffer(): Buffer {
return this.#buffer
}
getSize(): number {
return this.#buffer.length
}
getBlockSize(): number {
return this.#blockSize
}
async read(offset: number, length: number): Promise<Buffer> {
return Buffer.from(this.#buffer.subarray(offset, offset + length))
}
async write(offset: number, data: Buffer): Promise<void> {
data.copy(this.#buffer, offset)
}
async flush(): Promise<void> {}
async close(): Promise<void> {}
}
class FakeContext implements CommandContext {
readonly reads: Array<{ data: Buffer; allocationLength: number }> = []
readonly responses: Array<{ status: number; options?: ScsiResponseOptions }> = []
readonly writes: Array<{ itt: number; lunOffset: number; totalLength: number }> = []
constructor(readonly lun: BlockDevice) {}
async sendReadData(_itt: number, data: Buffer, allocationLength: number): Promise<void> {
this.reads.push({ data, allocationLength })
}
async beginWrite(itt: number, lunOffset: number, totalLength: number): Promise<void> {
this.writes.push({ itt, lunOffset, totalLength })
}
async sendScsiResponse(_itt: number, status: number, options?: ScsiResponseOptions): Promise<void> {
this.responses.push({ status, options })
}
}
function cdb(...bytes: number[]): Buffer {
const buffer = Buffer.alloc(16)
Buffer.from(bytes).copy(buffer)
return buffer
}
describe('decodeCdb', () => {
it('decodes READ(10) LBA and transfer length', () => {
const buffer = Buffer.alloc(16)
buffer[0] = 0x28
buffer.writeUInt32BE(0x01020304, 2)
buffer.writeUInt16BE(8, 7)
assert.deepEqual(decodeCdb(buffer), { kind: 'read', lba: 0x01020304, blocks: 8 })
})
it('decodes WRITE(16) LBA and transfer length', () => {
const buffer = Buffer.alloc(16)
buffer[0] = 0x8a
buffer.writeBigUInt64BE(123456789n, 2)
buffer.writeUInt32BE(16, 10)
assert.deepEqual(decodeCdb(buffer), { kind: 'write', lba: 123456789, blocks: 16 })
})
it('decodes READ CAPACITY(16) only for the right service action', () => {
assert.equal(decodeCdb(cdb(0x9e, 0x10)).kind, 'readCapacity16')
assert.equal(decodeCdb(cdb(0x9e, 0x12)).kind, 'unsupported')
})
it('clamps an out-of-range 64-bit LBA instead of throwing', () => {
// Past 2^53 an LBA no longer fits a JS number — and is past any capacity we
// can serve, so it must come back as an unservable LBA the range check
// rejects, not as an error that would tear the connection down.
const buffer = Buffer.alloc(16)
buffer[0] = 0x88
buffer.writeBigUInt64BE(0xffffffffffffffffn, 2)
buffer.writeUInt32BE(1, 10)
assert.deepEqual(decodeCdb(buffer), { kind: 'read', lba: Number.MAX_SAFE_INTEGER, blocks: 1 })
})
it('maps unknown opcodes to unsupported', () => {
assert.deepEqual(decodeCdb(cdb(0xff)), { kind: 'unsupported', opcode: 0xff })
})
})
describe('response encoders', () => {
it('builds 36-byte standard INQUIRY data for a block device', () => {
const data = buildStandardInquiry(IDENTITY)
assert.equal(data.length, 36)
assert.equal(data[0], 0x00) // direct-access block device
assert.equal(data[4], 31) // additional length
assert.equal(data.subarray(8, 16).toString('ascii').trim(), 'VATES')
assert.equal(data.subarray(16, 32).toString('ascii').trim(), 'ISCSI LUN')
})
it('builds READ CAPACITY(10) as (maxLba, blockSize)', () => {
const data = buildReadCapacity10(2048, 512)
assert.equal(data.readUInt32BE(0), 2047)
assert.equal(data.readUInt32BE(4), 512)
})
it('clamps READ CAPACITY(10) max LBA to force RC16 on large disks', () => {
const data = buildReadCapacity10(0x1_0000_0001, 512)
assert.equal(data.readUInt32BE(0), 0xffffffff)
})
it('builds READ CAPACITY(16) with a 64-bit max LBA', () => {
const data = buildReadCapacity16(0x1_0000_0001, 512)
assert.equal(data.readBigUInt64BE(0), 0x1_0000_0000n)
assert.equal(data.readUInt32BE(8), 512)
})
it('reports a single LUN 0', () => {
const data = buildReportLuns()
assert.equal(data.readUInt32BE(0), 8) // one 8-byte LUN entry
assert.deepEqual(data.subarray(8, 16), Buffer.alloc(8))
})
it('advertises supported VPD pages and rejects unknown ones', () => {
const page00 = buildVpd(0x00, IDENTITY)
assert.ok(page00 !== undefined)
assert.deepEqual([...page00.subarray(4)], [0x00, 0x80, 0x83])
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
})
})

View 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++
}
}
}
}

View 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)
})
})

View 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)
})
})

View File

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

149
@vates/iscsi/src/types.mts Normal file
View File

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

View File

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

View File

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

View File

@@ -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"]
}

View File

@@ -1,4 +1,4 @@
The use for this librariy is to create a composite key from a JSON value, ignoring objects identity and properties order.
The use for this library is to create a composite key from a JSON value, ignoring objects identity and properties order.
The hash algorithm used is intended to be fast and with low collisions and is not guaranteed to be be secure.

View File

@@ -16,7 +16,7 @@ npm install --save @vates/json-hash
## Usage
The use for this librariy is to create a composite key from a JSON value, ignoring objects identity and properties order.
The use for this library is to create a composite key from a JSON value, ignoring objects identity and properties order.
The hash algorithm used is intended to be fast and with low collisions and is not guaranteed to be be secure.

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,234 @@
import { RandomAccessDisk } from '@xen-orchestra/disk-transform'
import NbdClient from '@vates/nbd-client'
/**
* @typedef {{ offset: number, length: number }} DataRange
*/
/**
* any NBD client, whatever transport it uses
*
* @typedef {import('@vates/nbd-client').AbstractNbdClient} AbstractNbdClient
*/
/**
* @extends {RandomAccessDisk}
*/
export class NbdDisk extends RandomAccessDisk {
#nbdInfos
/** @type {AbstractNbdClient|undefined} */
#nbdClient
/** @type {Array<DataRange> | undefined} */
#dataMap
/** @type {number} */
#blockSize
/** @type {number | undefined} */
#hasBlockCursor
/** @type {number | undefined} */
#hasBlockPreviousIndex
/** @type {typeof AbstractNbdClient} */
#ClientClass
/**
* @param {object} nbdInfos - the settings of the client, depends on `ClientClass`
* @param {number} blockSize
* @param {object} [options]
* @param {Array<{offset: number, length: number, type: number}>} [options.dataMap] - computed through `getMap()` when not provided
* @param {typeof AbstractNbdClient} [options.ClientClass] - to talk to a NBD server through another transport
*/
constructor(nbdInfos, blockSize, { dataMap, ClientClass = NbdClient } = {}) {
super()
this.#blockSize = blockSize
this.#nbdInfos = nbdInfos
this.#ClientClass = ClientClass
this.#dataMap = dataMap && this.#processDatamap(dataMap)
}
#processDatamap(rawDataMap) {
const ranges = rawDataMap
.filter(({ type, length }) => type === 0 && length > 0)
.map(({ offset, length }) => ({ offset, length }))
.sort(({ offset: offset1 }, { offset: offset2 }) => offset1 - offset2)
// hasBlock()'s forward-only cursor is only correct if the extents are
// sorted AND disjoint: an earlier extent must never reach into a block
// after a later one, otherwise the cursor could skip it and wrongly report
// a block as empty, dropping data and corrupting the output.
// Touching extents (cur.offset === prevEnd) are merged
const merged = []
for (const range of ranges) {
const last = merged[merged.length - 1]
if (last !== undefined) {
const lastEnd = last.offset + last.length
if (range.offset < lastEnd) {
throw new Error(
`overlapping ranges in data map: [${last.offset}, ${lastEnd}) and [${range.offset}, ${range.offset + range.length})`
)
}
if (range.offset === lastEnd) {
// touching: extend the previous extent
last.length += range.length
continue
}
}
// gap (or first range): new extent
merged.push(range)
}
return merged
}
/**
* @param {number} index
* @returns {Promise<DiskBlock>}
*/
async readBlock(index) {
if (!this.#nbdClient) {
throw new Error("can't readBlock before init")
}
let data = await this.#nbdClient.readBlock(index, this.getBlockSize())
if (data.length !== this.getBlockSize()) {
// this condition only work on non aligned disks
// we won't accept truncated block from unaligned disks
if (index === Math.ceil(this.getVirtualSize() / this.getBlockSize()) - 1) {
// last block add zeros at the end
data = Buffer.concat([data], this.getBlockSize())
} else {
throw new Error(`Block ${index} is not at the right size, expecting ${this.getBlockSize()}, got ${data.length}`)
}
}
return {
index,
data,
}
}
/**
* @returns {number}
*/
getVirtualSize() {
if (!this.#nbdClient) {
throw new Error("can't get size before init")
}
return Number(this.#nbdClient.exportSize)
}
/**
* @returns {number}
*/
getBlockSize() {
return this.#blockSize
}
/**
* @returns {Promise<void>}
*/
async init() {
this.#nbdClient = new this.#ClientClass(this.#nbdInfos)
await this.#nbdClient.connect()
if (this.#dataMap === undefined) {
this.#dataMap = this.#processDatamap(await this.#nbdClient.getMap())
}
}
/**
* @returns {Promise<void>}
*/
async close() {
await this.#nbdClient?.disconnect()
}
/**
* @returns {boolean}
*/
isDifferencing() {
return false
}
/**
* @returns {Array<number>}
*/
getBlockIndexes() {
const indexes = new Set()
this.#dataMap?.forEach(({ offset, length }) => {
if (length === 0) return
const firstBlockIndex = Math.floor(offset / this.getBlockSize())
const lastBlockIndex = Math.floor((offset + length - 1) / this.getBlockSize())
for (let blockIndex = firstBlockIndex; blockIndex <= lastBlockIndex; blockIndex++) {
indexes.add(blockIndex)
}
})
return [...indexes].sort((a, b) => a - b)
}
/**
* Counts the allocated blocks without materializing the index list.
*
* @returns {number}
*/
getBlockIndexesCount() {
if (!this.#dataMap) {
throw new Error("can't getBlockIndexesCount before init")
}
const blockSize = this.getBlockSize()
let count = 0
let lastCountedBlock = -1
for (const { offset, length } of this.#dataMap) {
const firstBlockIndex = Math.floor(offset / blockSize)
const lastBlockIndex = Math.floor((offset + length - 1) / blockSize)
const from = Math.max(firstBlockIndex, lastCountedBlock + 1)
if (lastBlockIndex >= from) {
count += lastBlockIndex - from + 1
lastCountedBlock = lastBlockIndex
}
}
return count
}
/**
* @param {number} index
* @returns {boolean}
*/
hasBlock(index) {
if (!this.#dataMap) {
throw new Error("can't hasBlock before init")
}
const blockSize = this.getBlockSize()
const blockStart = index * blockSize
const blockEnd = blockStart + blockSize
let startExtentIndex = 0
if (this.#hasBlockCursor !== undefined && index >= this.#hasBlockPreviousIndex) {
startExtentIndex = this.#hasBlockCursor
} else {
this.#hasBlockCursor = undefined
}
this.#hasBlockPreviousIndex = index
const dataMap = this.#dataMap
const l = dataMap.length
for (let i = startExtentIndex; i < l; i++) {
const { offset, length } = dataMap[i]
if (offset >= blockEnd) {
// extents are sorted: nothing from here on can overlap this block
break
}
if (offset + length > blockStart) {
this.#hasBlockCursor = i
return true
}
}
return false
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
export const INIT_PASSWD = Buffer.from('NBDMAGIC') // "NBDMAGIC" ensure we're connected to a nbd server
export const OPTS_MAGIC = Buffer.from('IHAVEOPT') // "IHAVEOPT" start an option block
export const NBD_OPT_REPLY_MAGIC = 1100100111001001n // magic received during negociation
export const NBD_OPT_REPLY_MAGIC = 1100100111001001n // magic received during negotiation
export const NBD_OPT_EXPORT_NAME = 1
export const NBD_OPT_ABORT = 2
export const NBD_OPT_LIST = 3

View File

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

View File

@@ -7,6 +7,7 @@ const { warn } = createLogger('vates:nbd-client:multi')
export default class MultiNbdClient {
#clients = []
#nbdConcurrency
#nextClient = 0
#options
#readAhead
#settings
@@ -30,6 +31,8 @@ export default class MultiNbdClient {
* open nbdConcurrency connections to NBD servers
* it must obtain at least one connection to succeed
* it tries to spread connections on multiple host
*
* @returns {Promise<void>}
*/
async connect() {
const candidates = [...this.#settings]
@@ -68,7 +71,9 @@ export default class MultiNbdClient {
await _connect()
}
if (this.#clients.length === 0) {
throw new Error(`Fail to connect to any Nbd client`)
const error = new Error(`Fail to connect to any Nbd client`, { nbdInfos: this.#settings })
error.code = 'NO_NBD_AVAILABLE'
throw error
}
if (this.#clients.length < this.#nbdConcurrency) {
warn(
@@ -77,17 +82,53 @@ export default class MultiNbdClient {
}
}
/**
* @returns {Promise<void>}
*/
async disconnect() {
await asyncEach(this.#clients, client => client.disconnect(), {
stopOnError: false,
})
}
/**
*
* @param {number} index
* @param {number} size
* @returns {Promise<Buffer>}
*/
async readBlock(index, size = NBD_DEFAULT_BLOCK_SIZE) {
const clientId = index % this.#clients.length
return this.#clients[clientId].readBlock(index, size)
const clientId = this.#nextClient++ % this.#clients.length
const client = this.#clients[clientId]
try {
return await client.readBlock(index, size)
} catch (err) {
// client.readBlock() already exhausted its own retries/reconnects: this connection is dead.
// Evict it so future reads stop being routed to it, and retry this read on a surviving
// client, since the data is usually still reachable through the others.
this.#evict(client)
if (this.#clients.length === 0) {
throw err
}
warn(`evicted a dead nbd client, retrying block ${index} on a remaining client`, { err })
return this.readBlock(index, size)
}
}
// no-op if `client` was already evicted by a concurrent failed read on the same client
#evict(client) {
const i = this.#clients.indexOf(client)
if (i === -1) {
return
}
this.#clients.splice(i, 1)
client.disconnect().catch(() => {})
}
/**
*
* @param {AsyncGenerator<Buffer>} indexGenerator
*/
async *readBlocks(indexGenerator) {
// default : read all blocks
const readAhead = []
@@ -112,4 +153,17 @@ export default class MultiNbdClient {
yield readAhead.shift()
}
}
/**
* returns the map of the file with holes, zeros and data, useful to handle efficiently sparse source *
*
* @returns {Promise<{ offset: number, length: number, type: number }[]>}
* A promise that resolves to an array where each object represents a segment:
* - `offset` — The byte offset from the start.
* - `length` — The size of the segment in bytes.
* - `type` — A numeric code indicating the segment type (0 means no data).
*/
async getMap(signal) {
// ask the map from one of the connected client
return this.#clients[Math.floor(this.#clients.length * Math.random())].getMap(signal)
}
}

View File

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

View File

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

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