A modern iSCSI and NVMe/TCP initiator for macOS 26/27 on Apple Silicon. macOS
ships no initiator for either; the old open-source iSCSI option is a dead kext.
This project puts the iSCSI/TCP and NVMe/TCP protocol engines in a user-space
Swift daemon and presents the LUN or namespace as a real block device. The current FSKit/hdiutil backend in this project is a bit hacky,
but it works: you can mount LUNs as APFS, with the block device provided by Apple's
DiskImages framework. A DriverKit virtual SCSI HBA is the eventual goal once Apple lifts
some very real throughput limits (see docs/architecture.md).
The vast majority of the code here is written by Claude Code using Opus 5 and Fable 5. It was closely supervised, but most of the architecture and discovery was by trial-and-error, because lots of things that should work, don't. Most of that is purposefully left in the repo to act as documentation and memory. Claude also wrote most of the documentation, although I reviewed and edited it.
There are a large number of test/integration scripts that rely on my exact setup. I used
two disposable VMs on my dev machine to test ideas and iterate. One had XCode installed,
SIP disabled, extension barriers disabled, etc. This was used mostly for initial testing
and letting Claude try out ideas harmlessly. The other was as 'virgin' as possible in
order to figure out what required user prompts, whether notarization worked, etc. You'll
see plenty of references to this in the test scripts, with logins to herko@192.168.0.x,
and even an embedded password or two. They were all left in for documentation, reference,
and history. No secrets are actually compromised.
I also manually test the initiator on a completely different bare-metal macOS 26 machine. It works. It's not just VMs.
Most of the work and testing is done against a LUN on my actual TrueNAS server in my homelab. There is also a target simulator that can be, and was, used for destructive testing.
There are some speed enhancements, but it is single-connection for simplicity and stability. It has a simple read-ahead buffer that makes the daemon achieve about 1 GB/sec against my hardware HDD-backed RAID Z1 array over 10 gbps, in synthetic tests (i.e. linear best-case reads against the raw device). So it saturates the connection in ideal conditions. Real-world tests on the entire stack are, for me, at best 300 MB/sec, which is what my 10-year-old NAS can deliver. True optimization for other conditions will need more users, more time, and detailed reports.
A write larger than one SCSI command issues its chunks together rather than one round trip at a time, which measured 2.1x faster with Force Unit Access on and 2.6x with the target caching. That helps large files; the small writes a running VM makes are a single command already and are unchanged.
Writes default to Force Unit Access on every command, because FSKit delivers no barrier signal and an acknowledged write sitting in a volatile target cache is a lie APFS will act on. That costs real throughput (measured 4.5x on my hardware). Each target can instead be set to commit its cache on a timer (1–60 s) or never, for targets whose cache is genuinely non-volatile — battery-backed, on a UPS, or write-through on the target side. The app warns loudly when you pick this: the interval bounds how stale the disk can be after a target power cut, not whether the volume survives it intact.
I have no way of testing against stacks other than TrueNAS on Intel hardware. The implementation should respect the standard so it should work. I'm currently using it to store VMs for some of my experiments, and it has worked for me so far.
Final performance warning: by the nature of the driver, connections, and general architecture, sequential access performs much, much, much better than random access, at least on HDDs.
APFS, the default filesystem on Apple devices, is latency-hostile and clearly meant for SSDs. In my experience, on HDDs, it's bad. On iSCSI hosted on hard drives it's worse. Copying large files to and from, works well. Booting a macOS VM hosted on an iSCSI volume? You might as well be swapping floppies. It works, yes. It does not work well.
CAVEAT USER.
A notarized DMG, dragged to Applications, gets you an APFS volume
in Finder without ever opening Terminal. Verified end to end on 2026-08-14 on a
clean macOS 26.6.1 machine with SIP on, no Xcode, no Apple ID and no developer
account, against a real TrueNAS target: discover → add → attach → copy files →
detach → re-attach. Full transcript in docs/acceptance-2026-08-14.md.
368 tests pass (unit + integration + real-TCP-loopback) at 87.1% line
coverage; the PDU fuzzer runs clean over 100 independent seeds
(scripts/fuzz-campaign.sh). The protocol stack is verified against real
hardware, not just the simulator.
Some of those tests measure the process rather than its output, which is deliberate: the two worst bugs found in this codebase were a receive buffer that kept every byte a connection ever read, and secrets written to a keychain the daemon cannot reach. Both ran their lines correctly and produced correct results.
What the app does today:
- Guides you through setting up. For this to actually work it must satisfy four conditions: installed in Applications, background service running, filesystem extension registered, filesystem extension enabled. This is re-checked on every launch and every return to the foreground. The screen renders the results, of checks so you can easily see what steps are needed. It also repairs: an update that drops the extension's registration shows up as an ordinary unsatisfied step with a button.
- Discover, add, attach, detach, from a menu bar item or a window, with CHAP.
- A diagnostics pane showing negotiated login parameters, session recovery count, and the target's write-cache state — everything the daemon knew and had never told anyone.
- Uninstall that removes its own daemon, secrets, and extension registration in the one order that works.
- Updates via Sparkle, which refuse to install while a volume is attached.
macOS requires the user to enable a third-party filesystem extension. On macOS 27 beta, that switch works, and the app deep-links to it. On macOS 26.x it is present but doesn't actually move. Feedback filed with Apple, will update this if they fix it. It was reproduced independently against Apple's own FSKitSample. On 26.x the app asks for consent and writes the necessary entry itself, sidestepping the bug.
Both branches are measured, on both OS versions, with a notarized build and SIP
on. docs/backend-a-fskit-notes.md has the evidence and
docs/feedback-fskit-enablement-26x.md is the Feedback report.
Future work is on a different backend that uses a DriverKit extension. It was extensively tested but it simply doesn't work, and requires Apple to fix an issue.
- APFS wedges the block device after the first access, and it is not an
iSCSI problem: built with
ISCSI_DEXT_SCRATCH_DISK 1the dext serves a RAM buffer from its own memory — no daemon, no network, no target — and APFS wedges identically, while ExFAT on the same driver is fine and APFS on anhdiutilRAM disk is fine. In other words, there's a bug somewhere in APFS' implementation... or in the interaction of these two things that I cannot figure out. - The DriverKit family entitlements are approval-gated, so their presence makes every Developer ID export fail at profile resolution.
The reconnaissance in it is expensive and correct, so it is flagged rather than
deleted. swift build -Xswiftc -DISCSI_BACKEND_B turns it back on; see the
header of scripts/vm-deploy-dext.sh for what else re-enabling needs.
There are some other delicate issues embodied in the implementation. For example,
presenting the LUN as removable media makes macOS elide every flush in-kernel,
so APFS's barriers became silent no-ops. When presented as a fixed disk, flushes
reach the wire. See "The flush gap" in docs/architecture.md.
iSCSI here is plaintext TCP on port 3260, and that is not a gap this project can close on its own. RFC 7143 defines exactly three authentication methods — Kerberos, SRP and CHAP — and no TLS binding at all; the transport protection the standard specifies is IPsec (RFC 3723, updated by RFC 7146). There is nothing to implement here that a target would understand, and the one this is developed against runs SCST, which has no TLS support either. So the transport gives you no confidentiality and no integrity.
What that means concretely, for anyone on the path between this Mac and the target:
- Every byte of the volume is readable.
Data-InPDUs carry file contents in the clear. - Every byte is modifiable, in both directions. The CRC32C header and data digests are verified, but a checksum with a published polynomial is not a MAC — anyone altering bytes simply recomputes it.
- Responses can be forged wholesale: SCSI status, sense data,
READ CAPACITY, the login exchange.
CHAP authenticates the initiator to the target. Mutual CHAP is the only control that would authenticate the target back — the thing that would catch a stand-in feeding this Mac a fabricated disk that macOS then mounts as APFS.
It is implemented, tested, and currently hidden in the UI, because the
target it was developed against cannot complete it. TrueNAS SCALE writes
OutgoingUser into /etc/scst.conf and never loads it into
/sys/kernel/scst_tgt/, including across an iSCSI service restart, so
iscsi-scstd logs "CHAP target auth.: no outgoing credentials configured" and
refuses the login. One-way and mutual logins minutes apart with the same
credentials: one-way in, mutual out. Rather than ship a switch whose only
observable effect was to break a working target, CHAP.mutualIsOffered turns
it off; flipping that constant restores it. If you have a target that does
answer a mutual challenge, that is worth hearing about.
Run this on a trusted, isolated segment, or inside a WireGuard/IPsec tunnel. FileVault on the volume protects the data at rest and over the wire; it does not stop an on-path attacker from corrupting the filesystem underneath you.
cd apps
xcodegen generate # produces iSCSIInitiator.xcodeproj
open iSCSIInitiator.xcodeprojTwo schemes. iSCSI Initiator is what ships: the app, the embedded FSKit
extension, and iscsid inside the bundle. iSCSIDext-dev builds the
DriverKit extension on its own, for a SIP-off machine.
The app builds against the macOS 26 SDK. Its one macOS 27 API,
FSClient.openFileSystemExtensionsSettings(), is reached by selector
(FSKitSettingsLink) so that the app CAN build with XCode 26 and the 26 SDK.
Bump MARKETING_VERSION and CURRENT_PROJECT_VERSION in apps/project.yml,
then push a tag:
git tag v0.4.3 && git push origin v0.4.3.github/workflows/release.yml archives, exports a Developer ID build,
notarizes and staples both the app and the DMG, signs it for Sparkle, creates
the GitHub release, and only then commits the appcast entry — because a feed
that names a file before the file exists is a failed download for every user who
checks in between. It ends by fetching the published feed and confirming the URL
it advertises really serves the bytes the signature covers.
The workflow holds no build logic of its own; it runs scripts/release.sh, the
same script that runs on a Mac:
scripts/release.sh --publish # build, notarize, publish, write the feed
scripts/release.sh # stop before publishing
scripts/release.sh --skip-notarize # for iterating on the script itself, nothing elsedocs/releasing.md covers the six repository secrets, how to produce each, and
what to do when a release fails halfway.
CI (.github/workflows/ci.yml) runs the package tests, the app build, and the
project-hygiene checks. The most valuable of those regenerates the Xcode project
from apps/project.yml and fails on any diff.
Two VMs, and which one answers a question matters:
- SIP on, no Xcode, no Apple ID — the end-user acceptance rig. The only place that can answer anything about consent, entitlements or Gatekeeper.
- SIP off — the fast loop for the daemon, XPC, and the setup machine's own
logic. It cannot answer consent questions, because self-asserted entitlements
pass there, and its FSKit view is not trustworthy (see the note in
docs/backend-a-fskit-notes.md).
Sources/
iSCSIKit/ protocol core — no policy, fully testable
PDU/ all PDU types, framer with digest verification
Negotiation/ text-key negotiation, login state machine
Auth/ CHAP (forward + mutual)
Digest/ CRC32C
Session/ ISCSIConnection + ISCSISession (recovery, keepalive)
Transport/ ConnectionTransport, NetworkTransport (TCP), MemoryPipe
SCSI/ SCSITask, CDB builders, sense parsing
XPCProtocol the daemon's surface, shared by app, extension and daemon
XPCModels Codable DTOs that cross as Data, not NSSecureCoding
ISCSIError one error domain; sense bytes and recovery text survive XPC
MountpointTag sha256(portal|target|lun) — a compatibility contract
NVMeKit/ the NVMe/TCP twin of iSCSIKit, same rules, depends on it
for the transport, CRC32C, deadline and the block seam
PDU/ the 9 NVMe/TCP PDU types, framer (data at PDO, digests)
Capsule/ SQE/SGL/CQE layouts, every command builder, the
Identify/discovery-log parsers
Session/ NVMeQueue (one connection = one queue pair),
NVMeController (admin + I/O queue, keep-alive, recovery),
NVMeBlockDevice, NVMeDiscovery
Support/ little-endian accessors, the platform-derived host NQN
MockTarget/ scriptable iSCSI target and NVMe/TCP subsystem: protocol
engines, one volatile write cache, one TCP listener
(drives both the tests and the simulator)
iscsi-target-sim/ standalone local target (+ --nvme) + loopback control socket
iscsictl/ control CLI (discover, verify, read-bench, write-bench,
wipe, and nvme discover|verify|read-bench); --debug
traces every PDU and narrates the login
iscsid/ daemon: owns sessions, vends block I/O over XPC
iSCSIDaemon/ daemon core, target store, keychain, XPC authorization
iSCSIVolume/ the volume's data path: LUNStore, the local BackingStore
and DaemonStore (RMW, chunk cache, ioLock). Split out of
the FSKit extension so the tests can reach it
pdu-fuzz/ structure-aware fuzzer
apps/
iSCSIApp/ the app: setup machine, menu bar, windows, attach path
iSCSIFSExtension/ FSKit module presenting the LUN as a file
iSCSIDext/ DriverKit virtual SCSI HBA (parked, ISCSI_BACKEND_B)
Tests/
iSCSIKitTests/ iSCSI unit tests (PDUs, negotiation, CHAP, errors)
NVMeKitTests/ NVMe/TCP unit tests (framing, PDUs, SQE/CQE, builders,
Identify and discovery-log parsing, host identity)
IntegrationTests/ happy paths, hostile scripts, recovery, TCP loopback,
crash consistency, stalled-target resilience, XPC
authorization, handle scoping, target persistence,
authentication tracing, write chunk concurrency, the
volume's read-modify-write and cache paths, keychain
query shape, resource bounds — and the same suites
again over NVMe/TCP, plus the daemon serving both
scripts/
release.sh notarized DMG + Sparkle signature + appcast
coverage-badges.sh the tests/coverage badges CI pushes to the badges branch
iscsi-attach.sh the bash-era attach path, superseded by the app
bench.py large-sequential throughput benchmark
soak.py small-file / read-modify-write soak
crash-consistency.py power-cut durability check
vm-*.sh VM deployment, fault matrix, and reproducers
fuzz-campaign.sh N-seed fuzzing campaign
docs/ architecture, measurements, and the two Feedback drafts
swift test # unit + integration suite
scripts/fuzz.sh 60 # 60s ASan fuzz of the PDU decoder
# Against a real target (e.g. your NAS):
swift run iscsictl discover 192.168.1.50
swift run iscsictl verify 192.168.1.50 --target iqn.2000-01.com.example:disk0 \
--lun 0 --write # DESTRUCTIVE — scratch LUN only
# The same over NVMe/TCP (TrueNAS SCALE 25.04+, port 4420). The host NQN it
# prints is what to add to the subsystem's allowed hosts:
swift run iscsictl nvme discover 192.168.1.50
swift run iscsictl nvme verify 192.168.1.50 \
--subsystem nqn.2011-06.com.truenas:uuid:...:disk0 --nsid 1 --write # DESTRUCTIVEOr against the local simulator, which needs no NAS and can be broken on purpose (drop connections, corrupt payloads, stall commands, cut the target's power with a volatile write cache):
swift run iscsi-target-sim --port 3260 --capacity-mib 1024 &
swift run iscsictl verify 127.0.0.1 --target iqn.2000-01.com.example:lun0 --write
printf 'crash\n' | nc 127.0.0.1 3262 # target power loss, on demand
swift run iscsi-target-sim --nvme --capacity-mib 1024 & # the same, as NVMe/TCP on 4420
swift run iscsictl nvme verify 127.0.0.1 --subsystem nqn.2026-08.me.herko.sim:disk0 --writedocs/open-questions.md lists what is known to be untested, unexplained, or
deferred — with what is known about each and how to attack it.
See docs/architecture.md for the two-backend design and the DriverKit
throughput caveat, docs/backend-a-fskit-notes.md for how the shipping backend
actually behaves on both OS versions, docs/daemon-registration.md for what
SMAppService really does, docs/resilience.md for the fault matrix, and
docs/test-playbook.md for the full test strategy.
Apache License 2.0 — see LICENSE.
Apache rather than MIT for the patent grant in section 3. This implements a standardised protocol, and a permissive licence that says nothing about patents leaves users relying on the goodwill of every contributor; Apache makes the grant explicit and terminates it for anyone who sues over it. The cost is a longer file and a requirement to note modifications.
Bundled dependencies, both permissive and neither imposing conditions on this code:
| licence | |
|---|---|
| swift-argument-parser | Apache 2.0 |
| Sparkle | MIT |
Sparkle ships inside the app bundle, so a redistributed binary carries its copyright notice; the framework includes its own license file.