From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [IPv6:2a0f:8001:1:32::40]) by lore.proxmox.com (Postfix) with ESMTPS id 666DC1FF0ED for ; Fri, 31 Jul 2026 12:24:57 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id BDBB221817; Fri, 31 Jul 2026 12:22:10 +0200 (CEST) From: Dietmar Maurer To: pve-devel@lists.proxmox.com Subject: [RFC pve-storage 14/27] api: add node-level iSCSI initiator target and session API Date: Fri, 31 Jul 2026 12:21:43 +0200 Message-ID: <20260731102156.3947857-15-dietmar@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260731102156.3947857-1-dietmar@proxmox.com> References: <20260731102156.3947857-1-dietmar@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 2 AWL -0.326 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) KAM_LAZY_DOMAIN_SECURITY 1 Sending domain does not have any anti-forgery methods POISEN_SPAM_PILL 0.1 Meta: its spam POISEN_SPAM_PILL_1 0.1 random spam to be learned in bayes POISEN_SPAM_PILL_3 0.1 random spam to be learned in bayes RDNS_NONE 1.274 Delivered to internal network by a host with no rDNS SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_NONE 0.001 SPF: sender does not publish an SPF Record Message-ID-Hash: NVT3F5TS7K6TPOF6J246GE5FREURKING X-Message-ID-Hash: NVT3F5TS7K6TPOF6J246GE5FREURKING X-MailFrom: dietmar@zilli.proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: The iSCSI storage plugin manages session logins automatically on storage activation, but nothing surfaces the resulting state: admins cannot see which targets a node knows or is logged in to, how healthy the sessions are, or which parameters were negotiated. Sessions and node records also linger after their storage is removed from the configuration, with no way to clean them up short of iscsiadm on the shell. Add a small observability-focused API at /nodes/{node}/iscsi: report the initiator identity, list the known targets per portal with their session state and the configured storage using them, read the negotiated parameters of a session, rescan a session for new LUNs, log out of a single session and remove the node records of a target or of a single portal. The target list merges the initiator's node records with the active sessions, so a target stays visible as not-connected after a logout or while its portal is unreachable, instead of silently disappearing. Portals need special handling in two places, since iscsiadm reports and accepts them in several representations: the list normalizes them before merging (bracketed IPv6 in session listings, bare in node listings, v4-mapped addresses for IPv4 portals reached over IPv6 sockets), and record removal passes v4-mapped addresses back with their IPv4 tail rewritten in hex groups, the only spelling iscsiadm's portal parser accepts with a port. Reads require Datastore.Allocate on /storage like the related scan/iscsi and scan/san-luns endpoints, so the SAN views work with a single privilege; logout, rescan and node record removal mutate node state and require Sys.Modify. Signed-off-by: Dietmar Maurer --- src/PVE/API2/ISCSI.pm | 586 +++++++++++++++++++++++++++++++++ src/PVE/API2/Makefile | 1 + src/PVE/Storage/ISCSIPlugin.pm | 23 ++ 3 files changed, 610 insertions(+) create mode 100644 src/PVE/API2/ISCSI.pm diff --git a/src/PVE/API2/ISCSI.pm b/src/PVE/API2/ISCSI.pm new file mode 100644 index 0000000..6ce5e2a --- /dev/null +++ b/src/PVE/API2/ISCSI.pm @@ -0,0 +1,586 @@ +package PVE::API2::ISCSI; + +use strict; +use warnings; + +use PVE::JSONSchema qw(get_standard_option); +use PVE::RESTHandler; +use PVE::Storage; +use PVE::Storage::ISCSIPlugin; +use PVE::Tools qw(run_command file_get_contents file_read_firstline); + +use base qw(PVE::RESTHandler); + +# Node-level view of the local iSCSI initiator: report the initiator identity, +# inspect the known targets with their session state and negotiated parameters, +# rescan a session for new LUNs, log out of stale sessions and remove leftover +# node records. Session logins are managed by the iSCSI storage plugin on +# storage activation; this API only observes them and cleans up what no +# configured storage owns anymore. + +my $ISCSIADM = '/usr/bin/iscsiadm'; +my $INITIATOR_NAME_FILE = '/etc/iscsi/initiatorname.iscsi'; + +my $session_id_schema = { + description => "Kernel iSCSI session id.", + type => 'integer', + minimum => 0, +}; + +my $target_schema = { + description => "iSCSI target name (IQN or EUI).", + type => 'string', + minLength => 1, + maxLength => 255, +}; + +my $initiator_return = { + type => 'object', + properties => { + name => { + description => "The initiator IQN (InitiatorName).", + type => 'string', + }, + alias => { + description => "The initiator alias (InitiatorAlias), if set.", + type => 'string', + optional => 1, + }, + }, +}; + +my $status_return = { + type => 'object', + properties => { + supported => { + description => + "Whether iSCSI is supported, that is the open-iscsi package is installed.", + type => 'boolean', + }, + initiator => { + %$initiator_return, + description => "The local initiator identity, if available.", + optional => 1, + }, + }, +}; + +my $target_return = { + type => 'object', + properties => { + target => { + description => "The target IQN.", + type => 'string', + }, + portal => { + description => "The portal ('host:port') this entry refers to.", + type => 'string', + }, + 'session-id' => { + description => "The kernel session id; absent when there is no active session" + . " on this portal.", + type => 'integer', + optional => 1, + }, + transport => { + description => "The transport the session runs over (for example 'tcp' or" + . " 'iser'); only set for active sessions.", + type => 'string', + optional => 1, + }, + health => { + description => "The session health derived from the kernel session state:" + . " 'recovering' means the connection is down and iscsid is retrying the" + . " login, 'not-connected' means only a node record exists, without an" + . " active session.", + type => 'string', + enum => ['logged-in', 'recovering', 'logging-out', 'not-connected', 'unknown'], + }, + 'kernel-state' => { + description => "The raw kernel session state read from sysfs (for example" + . " 'LOGGED_IN'), if available.", + type => 'string', + optional => 1, + }, + 'used-by-storage' => { + description => "Storage identifier of a configured iSCSI-type storage using" + . " this target.", + type => 'string', + optional => 1, + }, + }, +}; + +my $session_params_return = { + type => 'object', + properties => { + 'header-digest' => { + description => "The negotiated header digest.", + type => 'string', + optional => 1, + }, + 'data-digest' => { + description => "The negotiated data digest.", + type => 'string', + optional => 1, + }, + 'max-recv-data-segment-length' => { + description => "Max data segment length (bytes) the initiator accepts in a PDU.", + type => 'integer', + optional => 1, + }, + 'max-xmit-data-segment-length' => { + description => "Max data segment length (bytes) the target accepts in a PDU.", + type => 'integer', + optional => 1, + }, + 'first-burst-length' => { + description => "Max unsolicited data (bytes) sent with a command (first burst).", + type => 'integer', + optional => 1, + }, + 'max-burst-length' => { + description => "Max data (bytes) in one solicited burst.", + type => 'integer', + optional => 1, + }, + 'max-outstanding-r2t' => { + description => "Max number of outstanding Ready-To-Transfer PDUs.", + type => 'integer', + optional => 1, + }, + 'immediate-data' => { + description => "Whether unsolicited immediate data is allowed ('Yes'/'No').", + type => 'string', + optional => 1, + }, + 'initial-r2t' => { + description => + "Whether the target requires an R2T before any data is sent ('Yes'/'No').", + type => 'string', + optional => 1, + }, + 'recovery-timeout' => { + description => + "Seconds the session waits for path recovery before failing its commands.", + type => 'integer', + optional => 1, + }, + 'abort-timeout' => { + description => "Seconds to wait for a SCSI abort task to complete.", + type => 'integer', + optional => 1, + }, + 'lu-reset-timeout' => { + description => "Seconds to wait for a LUN reset to complete.", + type => 'integer', + optional => 1, + }, + 'tgt-reset-timeout' => { + description => "Seconds to wait for a target reset to complete.", + type => 'integer', + optional => 1, + }, + }, +}; + +my sub read_initiator_info { + my $content = eval { file_get_contents($INITIATOR_NAME_FILE) }; + return undef if !defined($content); + + my $res = {}; + for my $line (split(/\n/, $content)) { + if ($line =~ m/^\s*InitiatorName\s*=\s*(\S+)\s*$/) { + $res->{name} = $1; + } elsif ($line =~ m/^\s*InitiatorAlias\s*=\s*(.+?)\s*$/) { + $res->{alias} = $1; + } + } + return defined($res->{name}) ? $res : undef; +} + +my sub session_health { + my ($kernel_state) = @_; + + return 'unknown' if !defined($kernel_state); + return 'logged-in' if $kernel_state eq 'LOGGED_IN'; + return 'recovering' if $kernel_state eq 'FAILED'; + return 'not-connected' if $kernel_state eq 'FREE'; + return 'unknown'; +} + +# iscsiadm reports portals in several representations: session listings print +# IPv6 addresses in brackets while node record listings do not, and connections +# to IPv4 portals made over IPv6 sockets appear with a v4-mapped address +# (::ffff:a.b.c.d). Normalize them so session and node record entries merge on +# the same key, unmapping v4-mapped addresses and bracketing IPv6 addresses +# like the storage configuration does. +my sub normalize_portal { + my ($portal) = @_; + return $portal if $portal !~ m/^(.+):(\d+)$/; + my ($addr, $port) = ($1, $2); + $addr =~ s/^\[(.*)\]$/$1/; + $addr =~ s/^::ffff:(\d+\.\d+\.\d+\.\d+)$/$1/i; + $addr = "[$addr]" if $addr =~ m/:/; + return "$addr:$port"; +} + +# iscsiadm's --portal parser only accepts a port on a bracketed address, and +# only takes the bracket path when the address contains no dots, so a stored +# v4-mapped address (::ffff:a.b.c.d) cannot be passed back as printed in any +# form. Rewrite an embedded IPv4 tail in hex groups: that spelling parses, +# and iscsiadm resolves it back to the same address when matching records. +my sub iscsiadm_portal_arg { + my ($portal) = @_; + my ($addr, $port) = $portal =~ m/^(.+):(\d+)$/; + return $portal if !defined($addr) || $addr !~ m/:/; + if ($addr =~ m/^(.*:)(\d+)\.(\d+)\.(\d+)\.(\d+)$/) { + $addr = sprintf("%s%x:%x", $1, ($2 << 8) + $3, ($4 << 8) + $5); + } + return "[$addr]:$port"; +} + +# One entry per known target and portal: the active sessions, merged with the +# node records so a target stays visible as 'not-connected' after a logout or +# while its portal is unreachable. +my sub list_targets { + my $session_map = PVE::Storage::ISCSIPlugin::iscsi_session_list(); + my $node_list = PVE::Storage::ISCSIPlugin::iscsi_node_list(); + + # map each target IQN to the first configured iSCSI-type storage using it + my $target_storage = {}; + my $cfg = PVE::Storage::config(); + for my $storeid (sort keys %{ $cfg->{ids} }) { + my $scfg = $cfg->{ids}->{$storeid}; + next if $scfg->{type} ne 'iscsi' || !defined($scfg->{target}); + $target_storage->{ $scfg->{target} } //= $storeid; + } + + my $res = []; + my $have_session = {}; + for my $target (sort keys %$session_map) { + for my $session (@{ $session_map->{$target} }) { + my $sid = int($session->{session_id}); + my $state = file_read_firstline("/sys/class/iscsi_session/session${sid}/state"); + my $portal = normalize_portal($session->{portal}); + my $entry = { + target => $target, + 'session-id' => $sid, + portal => $portal, + transport => $session->{transport}, + health => session_health($state), + }; + $entry->{'kernel-state'} = $state if defined($state); + $entry->{'used-by-storage'} = $target_storage->{$target} + if defined($target_storage->{$target}); + push @$res, $entry; + $have_session->{"$target\0$portal"} = 1; + } + } + + for my $node (@$node_list) { + my $portal = normalize_portal($node->{portal}); + next if $have_session->{"$node->{target}\0$portal"}; + my $entry = { + target => $node->{target}, + portal => $portal, + health => 'not-connected', + }; + $entry->{'used-by-storage'} = $target_storage->{ $node->{target} } + if defined($target_storage->{ $node->{target} }); + push @$res, $entry; + } + + return [sort { $a->{target} cmp $b->{target} || $a->{portal} cmp $b->{portal} } @$res]; +} + +# Map the interesting lines of 'iscsiadm -m session -P 2' (the 'Timeouts' and +# 'Negotiated iSCSI params' sections) to the API field names. +my $session_param_map = { + 'HeaderDigest' => ['header-digest', 'string'], + 'DataDigest' => ['data-digest', 'string'], + 'MaxRecvDataSegmentLength' => ['max-recv-data-segment-length', 'integer'], + 'MaxXmitDataSegmentLength' => ['max-xmit-data-segment-length', 'integer'], + 'FirstBurstLength' => ['first-burst-length', 'integer'], + 'MaxBurstLength' => ['max-burst-length', 'integer'], + 'ImmediateData' => ['immediate-data', 'string'], + 'InitialR2T' => ['initial-r2t', 'string'], + 'MaxOutstandingR2T' => ['max-outstanding-r2t', 'integer'], + 'Recovery Timeout' => ['recovery-timeout', 'integer'], + 'Abort Timeout' => ['abort-timeout', 'integer'], + 'LUN Reset Timeout' => ['lu-reset-timeout', 'integer'], + 'Target Reset Timeout' => ['tgt-reset-timeout', 'integer'], +}; + +my sub read_session_params { + my ($session_id) = @_; + + my $res = {}; + run_command( + [$ISCSIADM, '--mode', 'session', '--sid', $session_id, '-P', '2'], + errmsg => "reading parameters of iSCSI session $session_id failed", + outfunc => sub { + my ($line) = @_; + return if $line !~ m/^\s*([^:]+?)\s*:\s*(.+?)\s*$/; + my ($key, $value) = ($1, $2); + my $field = $session_param_map->{$key} or return; + my ($name, $type) = @$field; + if ($type eq 'integer') { + return if $value !~ m/^\d+$/; + $value = int($value); + } + $res->{$name} = $value; + }, + ); + return $res; +} + +__PACKAGE__->register_method({ + name => 'index', + path => '', + method => 'GET', + proxyto => 'node', + permissions => { user => 'all' }, + description => "Node iSCSI initiator index.", + parameters => { + additionalProperties => 0, + properties => { + node => get_standard_option('pve-node'), + }, + }, + returns => { + type => 'array', + items => { + type => "object", + properties => {}, + }, + links => [{ rel => 'child', href => "{name}" }], + }, + code => sub { + return [ + { name => 'rescan' }, + { name => 'session-logout' }, + { name => 'session-params' }, + { name => 'status' }, + { name => 'target' }, + ]; + }, +}); + +# Reads require Datastore.Allocate on /storage like the related scan/iscsi and +# scan/san-luns endpoints, so the SAN views work with a single privilege. +# Session logout and rescan mutate node state and require Sys.Modify. +__PACKAGE__->register_method({ + name => 'status', + path => 'status', + method => 'GET', + protected => 1, + proxyto => 'node', + description => "Report iSCSI support and, when open-iscsi is installed, the" + . " initiator identity.", + permissions => { + check => ['perm', '/storage', ['Datastore.Allocate']], + }, + parameters => { + additionalProperties => 0, + properties => { + node => get_standard_option('pve-node'), + }, + }, + returns => $status_return, + code => sub { + my $res = { supported => -x $ISCSIADM ? 1 : 0 }; + if ($res->{supported}) { + my $initiator = read_initiator_info(); + $res->{initiator} = $initiator if defined($initiator); + } + return $res; + }, +}); + +__PACKAGE__->register_method({ + name => 'targets', + path => 'target', + method => 'GET', + protected => 1, + proxyto => 'node', + description => "List the iSCSI targets known to the initiator, together with the state" + . " of their sessions.", + permissions => { + check => ['perm', '/storage', ['Datastore.Allocate']], + }, + parameters => { + additionalProperties => 0, + properties => { + node => get_standard_option('pve-node'), + }, + }, + returns => { + type => 'array', + items => $target_return, + }, + code => sub { + return list_targets(); + }, +}); + +__PACKAGE__->register_method({ + name => 'target_delete', + path => 'target', + method => 'DELETE', + protected => 1, + proxyto => 'node', + description => "Remove the node records of a target from the initiator database, either" + . " of a single portal or of the whole target. Requires the affected records to be" + . " logged out.", + permissions => { + check => ['perm', '/', ['Sys.Modify']], + }, + parameters => { + additionalProperties => 0, + properties => { + node => get_standard_option('pve-node'), + target => $target_schema, + portal => { + description => "Only remove the node record of this portal.", + type => 'string', + minLength => 2, + maxLength => 256, + optional => 1, + }, + }, + }, + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + my ($target, $portal) = ($param->{target}, $param->{portal}); + + my $sessions = PVE::Storage::ISCSIPlugin::iscsi_session_list()->{$target} // []; + for my $session (@$sessions) { + next if defined($portal) && normalize_portal($session->{portal}) ne $portal; + die "target '$target' still has an active session - log out first\n"; + } + + if (!defined($portal)) { + run_command( + [$ISCSIADM, '--mode', 'node', '--targetname', $target, '--op', 'delete'], + errmsg => "removing the node records of target '$target' failed", + outfunc => sub { }, + ); + return undef; + } + + # look up the records by their normalized portal and address each one + # in the representation it is actually stored under + my @records = + grep { $_->{target} eq $target && normalize_portal($_->{portal}) eq $portal } + @{ PVE::Storage::ISCSIPlugin::iscsi_node_list() }; + die "no node record for target '$target', portal '$portal' found\n" + if !scalar(@records); + + for my $record (@records) { + run_command( + [ + $ISCSIADM, + '--mode', + 'node', + '--targetname', + $target, + '--portal', + iscsiadm_portal_arg($record->{portal}), + '--op', + 'delete', + ], + errmsg => "removing the node record of target '$target' failed", + outfunc => sub { }, + ); + } + return undef; + }, +}); + +__PACKAGE__->register_method({ + name => 'session_params', + path => 'session-params', + method => 'GET', + protected => 1, + proxyto => 'node', + description => "Read the parameters an active iSCSI session negotiated at login.", + permissions => { + check => ['perm', '/storage', ['Datastore.Allocate']], + }, + parameters => { + additionalProperties => 0, + properties => { + node => get_standard_option('pve-node'), + 'session-id' => $session_id_schema, + }, + }, + returns => $session_params_return, + code => sub { + my ($param) = @_; + return read_session_params($param->{'session-id'}); + }, +}); + +__PACKAGE__->register_method({ + name => 'session_logout', + path => 'session-logout', + method => 'POST', + protected => 1, + proxyto => 'node', + description => "Log out of a single iSCSI session, tearing down one path of a target.", + permissions => { + check => ['perm', '/', ['Sys.Modify']], + }, + parameters => { + additionalProperties => 0, + properties => { + node => get_standard_option('pve-node'), + 'session-id' => $session_id_schema, + }, + }, + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + my $sid = $param->{'session-id'}; + run_command( + [$ISCSIADM, '--mode', 'session', '--sid', $sid, '--logout'], + errmsg => "logout of iSCSI session $sid failed", + outfunc => sub { }, + ); + return undef; + }, +}); + +__PACKAGE__->register_method({ + name => 'rescan', + path => 'rescan', + method => 'POST', + protected => 1, + proxyto => 'node', + description => "Rescan an iSCSI session to pick up newly attached LUNs.", + permissions => { + check => ['perm', '/', ['Sys.Modify']], + }, + parameters => { + additionalProperties => 0, + properties => { + node => get_standard_option('pve-node'), + 'session-id' => $session_id_schema, + }, + }, + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + my $sid = $param->{'session-id'}; + run_command( + [$ISCSIADM, '--mode', 'session', '--sid', $sid, '--rescan'], + errmsg => "rescan of iSCSI session $sid failed", + outfunc => sub { }, + ); + return undef; + }, +}); + +1; diff --git a/src/PVE/API2/Makefile b/src/PVE/API2/Makefile index fe316c5..10e18f9 100644 --- a/src/PVE/API2/Makefile +++ b/src/PVE/API2/Makefile @@ -3,5 +3,6 @@ .PHONY: install install: install -D -m 0644 Disks.pm ${DESTDIR}${PERLDIR}/PVE/API2/Disks.pm + install -D -m 0644 ISCSI.pm ${DESTDIR}${PERLDIR}/PVE/API2/ISCSI.pm make -C Storage install make -C Disks install diff --git a/src/PVE/Storage/ISCSIPlugin.pm b/src/PVE/Storage/ISCSIPlugin.pm index 4fbc5ad..e9a585f 100644 --- a/src/PVE/Storage/ISCSIPlugin.pm +++ b/src/PVE/Storage/ISCSIPlugin.pm @@ -68,6 +68,29 @@ sub iscsi_session_list { return $res; } +sub iscsi_node_list { + assert_iscsi_support(); + + my $res = []; + eval { + run_command( + [$ISCSIADM, '--mode', 'node'], + errmsg => 'iscsi node record scan failed', + outfunc => sub { + my $line = shift; + if ($line =~ $ISCSI_TARGET_RE) { + push @$res, { portal => $1, target => $2 }; + } + }, + ); + }; + if (my $err = $@) { + die $err if $err !~ m/: No records found/i; + } + + return $res; +} + sub iscsi_test_session { my ($sid) = @_; -- 2.47.3