Files
seaweedfs/weed/storage/blockvol/iscsi/session.go
T
Ping QiuandClaude Opus 4.6 5a9a52f2d0 feat: Phase 6 CP6-2 -- CSI control-plane integration + csi-sanity/k3s validation
CP6-2 wires the CSI driver to SeaweedFS master/volume-server control plane:
- Proto: block volume messages in master.proto/volume_server.proto, codegen
- Master registry: in-memory BlockVolumeRegistry with Pending->Active status,
  full/delta heartbeat, inflight lock, placement (fewest volumes)
- VS gRPC: AllocateBlockVolume/DeleteBlockVolume handlers, shared naming
- Master RPCs: CreateBlockVolume (retry up to 3 servers), Delete, Lookup
- Heartbeat: block volume fields wired into bidirectional stream
- CSI Controller: VolumeBackend interface (Local + Master), returns volume_context
- CSI Node: reads volume_context for remote targets, staged map + IQN derivation
- Mode flag: --mode=controller/node/all, --master for control-plane
- K8s manifests: csi-driver.yaml, csi-controller.yaml, csi-node.yaml

csi-sanity conformance (33 pass, 58 skip) found 6 bugs:
- BUG-SANITY-1/2/3: missing VolumeCapabilities/VolumeCapability validation
- BUG-SANITY-4: NodePublish used mount instead of bind mount
- BUG-SANITY-5: NodeUnpublish didn't remove target path
- BUG-SANITY-6: NodeUnpublish failed on unmounted path

k3s Level 4 (PVC->Pod data persistence) found 1 bug:
- BUG-K3S-1: IsLoggedIn didn't handle iscsiadm exit code 21

226 CSI tests + 54 server tests = 280 new tests, all passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 11:01:08 -08:00

606 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package iscsi
import (
"errors"
"fmt"
"io"
"log"
"net"
"sync"
"sync/atomic"
"time"
)
var (
ErrSessionClosed = errors.New("iscsi: session closed")
ErrCmdSNOutOfWindow = errors.New("iscsi: CmdSN out of window")
)
// maxPendingQueue limits the number of non-Data-Out PDUs that can be
// queued during a Data-Out collection phase. 2× the CmdSN window (32).
const maxPendingQueue = 64
// SessionState tracks the lifecycle of an iSCSI session.
type SessionState int
const (
SessionLogin SessionState = iota // login phase
SessionLoggedIn // full feature phase
SessionLogout // logout requested
SessionClosed // terminated
)
// Session manages a single iSCSI session (one initiator connection).
type Session struct {
mu sync.Mutex
state SessionState
conn net.Conn
scsi *SCSIHandler
config TargetConfig
resolver TargetResolver
devices DeviceLookup
targetIQN string // negotiated target name (set after login)
// Sequence numbers
expCmdSN atomic.Uint32 // expected CmdSN from initiator
maxCmdSN atomic.Uint32 // max CmdSN we allow
statSN uint32 // target status sequence number (txLoop only after login)
// Login state
negotiator *LoginNegotiator
loginDone bool
// Negotiated session parameters
negImmediateData bool
negInitialR2T bool
// Data sequencing
dataInWriter *DataInWriter
// PDU queue for commands received during Data-Out collection.
pending []*PDU
// TX goroutine channel for response PDUs.
// Buffered; txLoop reads and writes to conn.
respCh chan *PDU
txDone chan struct{}
// Shutdown
closed atomic.Bool
closeErr error
// Logging
logger *log.Logger
}
// NewSession creates a new iSCSI session on the given connection.
func NewSession(conn net.Conn, config TargetConfig, resolver TargetResolver, devices DeviceLookup, logger *log.Logger) *Session {
if logger == nil {
logger = log.Default()
}
s := &Session{
state: SessionLogin,
conn: conn,
config: config,
resolver: resolver,
devices: devices,
negotiator: NewLoginNegotiator(config),
respCh: make(chan *PDU, 64),
txDone: make(chan struct{}),
logger: logger,
}
// Per RFC 7143 Section 4.2.2: Login CmdSN is not used for ordering.
// The first post-login SCSI command from the Linux initiator uses CmdSN=0.
s.expCmdSN.Store(0)
s.maxCmdSN.Store(31) // window of 32 commands [0, 31]
return s
}
// HandleConnection processes PDUs until the connection is closed or an error occurs.
// Login phase runs inline (single goroutine). After login completes, a txLoop
// goroutine is started for pipelined response writing.
func (s *Session) HandleConnection() error {
defer s.close()
// Login phase: handle login PDUs inline (no txLoop yet).
if err := s.loginPhase(); err != nil {
return err
}
if !s.loginDone {
// Connection closed during login phase.
return nil
}
// Start TX goroutine for full-feature phase.
go s.txLoop()
// RX loop: read PDUs and dispatch serially.
err := s.rxLoop()
// Shutdown: close respCh so txLoop exits, then wait for it.
close(s.respCh)
<-s.txDone
return err
}
// loginPhase handles login PDUs inline until login is complete or connection closes.
func (s *Session) loginPhase() error {
for !s.closed.Load() && !s.loginDone {
pdu, err := ReadPDU(s.conn)
if err != nil {
if s.closed.Load() || errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
return nil
}
return fmt.Errorf("read PDU: %w", err)
}
if err := s.dispatch(pdu); err != nil {
if s.closed.Load() {
return nil
}
return fmt.Errorf("dispatch %s: %w", OpcodeName(pdu.Opcode()), err)
}
}
return nil
}
// rxLoop reads PDUs from the connection and dispatches them serially.
// Response PDUs are enqueued on respCh by handlers.
func (s *Session) rxLoop() error {
for !s.closed.Load() {
pdu, err := s.nextPDU()
if err != nil {
if s.closed.Load() || errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
return nil
}
return fmt.Errorf("read PDU: %w", err)
}
if err := s.dispatch(pdu); err != nil {
if s.closed.Load() {
return nil
}
return fmt.Errorf("dispatch %s: %w", OpcodeName(pdu.Opcode()), err)
}
}
return nil
}
// txLoop reads response PDUs from respCh, assigns StatSN, and writes to conn.
// Runs as a goroutine during full-feature phase.
func (s *Session) txLoop() {
defer close(s.txDone)
for pdu := range s.respCh {
if pdu == nil {
continue
}
// Assign StatSN based on PDU type.
switch s.pduStatSNMode(pdu) {
case statSNAssign:
s.mu.Lock()
pdu.SetStatSN(s.statSN)
s.statSN++
pdu.SetExpCmdSN(s.expCmdSN.Load())
pdu.SetMaxCmdSN(s.maxCmdSN.Load())
s.mu.Unlock()
case statSNCopy:
s.mu.Lock()
pdu.SetStatSN(s.statSN)
pdu.SetExpCmdSN(s.expCmdSN.Load())
pdu.SetMaxCmdSN(s.maxCmdSN.Load())
s.mu.Unlock()
}
if err := WritePDU(s.conn, pdu); err != nil {
if !s.closed.Load() {
s.logger.Printf("txLoop write error: %v", err)
}
// Close connection so rxLoop exits, which lets HandleConnection
// close respCh, which lets the drain loop below finish.
s.closed.Store(true)
s.conn.Close()
for range s.respCh {
}
return
}
}
}
// statSNMode controls how txLoop handles StatSN for a PDU.
type statSNMode int
const (
statSNNone statSNMode = iota // don't touch (intermediate Data-In)
statSNAssign // assign current StatSN, increment
statSNCopy // assign current StatSN, do NOT increment (R2T)
)
// pduStatSNMode returns the StatSN handling mode for the given PDU.
func (s *Session) pduStatSNMode(pdu *PDU) statSNMode {
op := pdu.Opcode()
switch op {
case OpSCSIDataIn:
// Only the final Data-In PDU (with S-bit) gets StatSN.
if pdu.OpSpecific1()&FlagS != 0 {
return statSNAssign
}
return statSNNone
case OpR2T:
// R2T carries StatSN but does NOT increment it (RFC 7143).
return statSNCopy
default:
return statSNAssign
}
}
// enqueue sends a response PDU to the TX goroutine.
func (s *Session) enqueue(pdu *PDU) {
select {
case s.respCh <- pdu:
case <-s.txDone:
// TX loop exited, discard response.
}
}
// nextPDU returns the next PDU to process, draining the pending queue
// (populated during Data-Out collection) before reading from the connection.
func (s *Session) nextPDU() (*PDU, error) {
if len(s.pending) > 0 {
pdu := s.pending[0]
s.pending = s.pending[1:]
return pdu, nil
}
return ReadPDU(s.conn)
}
// Close terminates the session.
func (s *Session) Close() error {
s.closed.Store(true)
return s.conn.Close()
}
func (s *Session) close() {
s.closed.Store(true)
s.mu.Lock()
s.state = SessionClosed
s.mu.Unlock()
s.conn.Close()
}
func (s *Session) dispatch(pdu *PDU) error {
op := pdu.Opcode()
if op != OpSCSICmd && op != OpSCSIDataOut {
s.logger.Printf("dispatch: opcode=%s(0x%02x)", OpcodeName(op), op)
}
switch op {
case OpLoginReq:
return s.handleLogin(pdu)
case OpTextReq:
return s.handleText(pdu)
case OpSCSICmd:
return s.handleSCSICmd(pdu)
case OpSCSIDataOut:
// Handled inline during write command processing
return nil
case OpNOPOut:
return s.handleNOPOut(pdu)
case OpLogoutReq:
return s.handleLogout(pdu)
case OpSCSITaskMgmt:
return s.handleTaskMgmt(pdu)
default:
s.logger.Printf("unhandled opcode: %s", OpcodeName(op))
return s.sendReject(pdu, 0x04) // command not supported
}
}
func (s *Session) handleLogin(pdu *PDU) error {
s.mu.Lock()
defer s.mu.Unlock()
resp := s.negotiator.HandleLoginPDU(pdu, s.resolver)
// During login phase, StatSN is assigned inline (no txLoop yet).
resp.SetStatSN(s.statSN)
s.statSN++
resp.SetExpCmdSN(s.expCmdSN.Load())
resp.SetMaxCmdSN(s.maxCmdSN.Load())
if err := WritePDU(s.conn, resp); err != nil {
return err
}
if s.negotiator.Done() {
s.loginDone = true
s.state = SessionLoggedIn
result := s.negotiator.Result()
s.targetIQN = result.TargetName
s.dataInWriter = NewDataInWriter(uint32(result.MaxRecvDataSegLen))
s.negImmediateData = result.ImmediateData
s.negInitialR2T = result.InitialR2T
if s.devices != nil && result.TargetName != "" {
dev := s.devices.LookupDevice(result.TargetName)
s.scsi = NewSCSIHandler(dev)
}
s.logger.Printf("login complete: initiator=%s target=%s session=%s",
result.InitiatorName, result.TargetName, result.SessionType)
}
return nil
}
func (s *Session) handleText(pdu *PDU) error {
if !s.loginDone {
return s.sendReject(pdu, 0x0b) // protocol error
}
var targets []DiscoveryTarget
if lister, ok := s.resolver.(TargetLister); ok {
targets = lister.ListTargets()
}
s.logger.Printf("text request: params=%q targets=%d", string(pdu.DataSegment), len(targets))
resp := HandleTextRequest(pdu, targets)
// ExpCmdSN/MaxCmdSN are set by txLoop via pduNeedsStatSN.
s.enqueue(resp)
return nil
}
func (s *Session) handleSCSICmd(pdu *PDU) error {
if !s.loginDone {
return s.sendReject(pdu, 0x0b) // protocol error
}
if s.scsi == nil {
return s.sendReject(pdu, 0x04) // command not supported
}
cdb := pdu.CDB()
itt := pdu.InitiatorTaskTag()
flags := pdu.OpSpecific1()
s.logger.Printf("SCSI CDB: opcode=0x%02x cdb=%x itt=0x%08x flags=0x%02x edtl=%d",
cdb[0], cdb[:], itt, flags, pdu.ExpectedDataTransferLength())
// CmdSN validation for non-immediate commands
if !pdu.Immediate() {
cmdSN := pdu.CmdSN()
expCmdSN := s.expCmdSN.Load()
maxCmdSN := s.maxCmdSN.Load()
if !cmdSNInWindow(cmdSN, expCmdSN, maxCmdSN) {
s.logger.Printf("CmdSN %d out of window [%d, %d], dropping", cmdSN, expCmdSN, maxCmdSN)
return nil
}
s.advanceCmdSN()
}
isWrite := flags&FlagW != 0
isRead := flags&FlagR != 0
expectedLen := pdu.ExpectedDataTransferLength()
// Handle write commands -- collect data
var dataOut []byte
if isWrite && expectedLen > 0 {
collector := NewDataOutCollector(expectedLen)
if len(pdu.DataSegment) > 0 {
if !s.negImmediateData {
return s.sendCheckCondition(itt, SenseIllegalRequest, ASCInvalidFieldInCDB, ASCQLuk)
}
if err := collector.AddImmediateData(pdu.DataSegment); err != nil {
return s.sendCheckCondition(itt, SenseIllegalRequest, ASCInvalidFieldInCDB, ASCQLuk)
}
}
if !collector.Done() {
if err := s.collectDataOut(collector, itt); err != nil {
return err
}
}
dataOut = collector.Data()
}
// Execute SCSI command
result := s.scsi.HandleCommand(cdb, dataOut)
if isRead && result.Status == SCSIStatusGood && len(result.Data) > 0 {
// Build Data-In PDUs and enqueue them all.
pdus := s.dataInWriter.BuildDataInPDUs(result.Data, itt, expectedLen, s.expCmdSN.Load(), s.maxCmdSN.Load())
for _, p := range pdus {
s.enqueue(p)
}
return nil
}
// Build SCSI Response PDU and enqueue.
resp := BuildSCSIResponse(result, itt, s.expCmdSN.Load(), s.maxCmdSN.Load())
s.enqueue(resp)
return nil
}
func (s *Session) collectDataOut(collector *DataOutCollector, itt uint32) error {
var r2tSN uint32
ttt := itt
// Clear any read deadline on exit (success or error).
defer s.conn.SetReadDeadline(time.Time{})
for !collector.Done() {
// Build R2T and enqueue (txLoop assigns StatSN before writing).
r2t := BuildR2T(itt, ttt, r2tSN, s.totalReceived(collector), collector.Remaining(),
s.expCmdSN.Load(), s.maxCmdSN.Load())
s.enqueue(r2t)
r2tSN++
// Set read deadline for Data-Out collection.
if s.config.DataOutTimeout > 0 {
s.conn.SetReadDeadline(time.Now().Add(s.config.DataOutTimeout))
}
// Read Data-Out PDUs until F-bit.
for {
doPDU, err := ReadPDU(s.conn)
if err != nil {
return err
}
if doPDU.Opcode() != OpSCSIDataOut {
if len(s.pending) >= maxPendingQueue {
return fmt.Errorf("pending queue overflow (%d PDUs)", maxPendingQueue)
}
s.pending = append(s.pending, doPDU)
continue
}
if err := collector.AddDataOut(doPDU); err != nil {
return err
}
if doPDU.OpSpecific1()&FlagF != 0 {
break
}
}
}
return nil
}
func (s *Session) totalReceived(c *DataOutCollector) uint32 {
return c.expectedLen - c.Remaining()
}
func (s *Session) handleNOPOut(pdu *PDU) error {
if !s.loginDone {
return s.sendReject(pdu, 0x0b) // protocol error
}
resp := &PDU{}
resp.SetOpcode(OpNOPIn)
resp.SetOpSpecific1(FlagF)
resp.SetInitiatorTaskTag(pdu.InitiatorTaskTag())
resp.SetTargetTransferTag(0xFFFFFFFF)
if len(pdu.DataSegment) > 0 {
resp.DataSegment = pdu.DataSegment
}
s.enqueue(resp)
return nil
}
func (s *Session) handleLogout(pdu *PDU) error {
if !s.loginDone {
return s.sendReject(pdu, 0x0b) // protocol error
}
resp := &PDU{}
resp.SetOpcode(OpLogoutResp)
resp.SetOpSpecific1(FlagF)
resp.SetInitiatorTaskTag(pdu.InitiatorTaskTag())
resp.BHS[2] = 0x00 // response: connection/session closed successfully
s.enqueue(resp)
// Give txLoop a moment to write the response before closing.
// We signal close after enqueue so the logout response is sent.
s.mu.Lock()
s.state = SessionLogout
s.mu.Unlock()
s.closed.Store(true)
return nil
}
func (s *Session) handleTaskMgmt(pdu *PDU) error {
if !s.loginDone {
return s.sendReject(pdu, 0x0b) // protocol error
}
resp := &PDU{}
resp.SetOpcode(OpSCSITaskResp)
resp.SetOpSpecific1(FlagF)
resp.SetInitiatorTaskTag(pdu.InitiatorTaskTag())
resp.BHS[2] = 0x00 // function complete
s.enqueue(resp)
return nil
}
func (s *Session) advanceCmdSN() {
s.expCmdSN.Add(1)
s.maxCmdSN.Add(1)
}
// cmdSNInWindow checks if cmdSN is within [expCmdSN, maxCmdSN] using
// serial number arithmetic (RFC 7143 section 4.2.2.1). Handles uint32 wrap.
func cmdSNInWindow(cmdSN, expCmdSN, maxCmdSN uint32) bool {
return serialLE(expCmdSN, cmdSN) && serialLE(cmdSN, maxCmdSN)
}
func serialLE(a, b uint32) bool {
return a == b || int32(b-a) > 0
}
func (s *Session) sendReject(origPDU *PDU, reason uint8) error {
resp := &PDU{}
resp.SetOpcode(OpReject)
resp.SetOpSpecific1(FlagF)
resp.BHS[2] = reason
resp.SetInitiatorTaskTag(0xFFFFFFFF)
resp.DataSegment = origPDU.BHS[:]
if s.loginDone {
s.enqueue(resp)
} else {
// During login phase, write inline (no txLoop).
s.mu.Lock()
resp.SetStatSN(s.statSN)
s.statSN++
resp.SetExpCmdSN(s.expCmdSN.Load())
resp.SetMaxCmdSN(s.maxCmdSN.Load())
s.mu.Unlock()
return WritePDU(s.conn, resp)
}
return nil
}
func (s *Session) sendCheckCondition(itt uint32, senseKey, asc, ascq uint8) error {
result := SCSIResult{
Status: SCSIStatusCheckCond,
SenseKey: senseKey,
SenseASC: asc,
SenseASCQ: ascq,
}
resp := BuildSCSIResponse(result, itt, s.expCmdSN.Load(), s.maxCmdSN.Load())
s.enqueue(resp)
return nil
}
// TargetLister is an optional interface that TargetResolver can implement
// to support SendTargets discovery.
type TargetLister interface {
ListTargets() []DiscoveryTarget
}
// DeviceLookup resolves a target IQN to a BlockDevice.
type DeviceLookup interface {
LookupDevice(iqn string) BlockDevice
}
// State returns the current session state.
func (s *Session) State() SessionState {
s.mu.Lock()
defer s.mu.Unlock()
return s.state
}
// TargetIQN returns the negotiated target name for this session.
// Returns empty string if login has not completed.
func (s *Session) TargetIQN() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.targetIQN
}