From 826edc817a09ba0ce93e0ea9f9795fd53462dcee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dav=C3=AD=C3=B0=20Steinn=20Geirsson?= Date: Wed, 11 Mar 2026 14:28:22 +0000 Subject: [PATCH] refactor: extract mount library package, add flake.nix Move protocol and SCSI code into importable mount/ package with a single public API (Config + Run). Restructure as multi-binary repo with cmd/aten-mount/ CLI wrapper using context-based cancellation. Add Nix flake with go_1_26 for builds and devshell. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 +- cmd/aten-mount/main.go | 44 +++++++++ flake.lock | 27 ++++++ flake.nix | 32 +++++++ go.mod | 4 +- main.go | 102 --------------------- mount/mount.go | 87 ++++++++++++++++++ protocol.go => mount/protocol.go | 22 ++--- protocol_test.go => mount/protocol_test.go | 2 +- scsi.go => mount/scsi.go | 74 +++++++-------- scsi_test.go => mount/scsi_test.go | 26 +++--- 11 files changed, 255 insertions(+), 168 deletions(-) create mode 100644 cmd/aten-mount/main.go create mode 100644 flake.lock create mode 100644 flake.nix delete mode 100644 main.go create mode 100644 mount/mount.go rename protocol.go => mount/protocol.go (92%) rename protocol_test.go => mount/protocol_test.go (96%) rename scsi.go => mount/scsi.go (83%) rename scsi_test.go => mount/scsi_test.go (83%) diff --git a/.gitignore b/.gitignore index f7b4367..4e6f5a3 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -aten-mount +/aten-mount +/result diff --git a/cmd/aten-mount/main.go b/cmd/aten-mount/main.go new file mode 100644 index 0000000..1662b9c --- /dev/null +++ b/cmd/aten-mount/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "os/signal" + "syscall" + + "git.dsg.is/dsg/aten-ipmi-tools/mount" +) + +func main() { + user := flag.String("u", "admin", "BMC username") + pass := flag.String("p", "admin", "BMC password") + port := flag.Int("port", 623, "BMC virtual media TCP port") + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage: %s [flags] \n\nFlags:\n", os.Args[0]) + flag.PrintDefaults() + } + flag.Parse() + + if flag.NArg() != 2 { + flag.Usage() + os.Exit(1) + } + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + cfg := mount.Config{ + Host: flag.Arg(0), + Port: *port, + Username: *user, + Password: *pass, + ISOPath: flag.Arg(1), + } + + if err := mount.Run(ctx, cfg); err != nil { + log.Fatal(err) + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..51ec438 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1773110118, + "narHash": "sha256-mPAG8phMbCReKSiKAijjjd3v7uVcJOQ75gSjGJjt/Rk=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e607cb5360ff1234862ac9f8839522becb853bb9", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..84c750f --- /dev/null +++ b/flake.nix @@ -0,0 +1,32 @@ +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + }; + + outputs = { self, nixpkgs }: + let + systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; + forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f { + pkgs = nixpkgs.legacyPackages.${system}; + }); + in + { + packages = forAllSystems ({ pkgs }: { + aten-mount = pkgs.buildGoModule.override { go = pkgs.go_1_26; } { + pname = "aten-mount"; + version = "0.1.0"; + src = ./.; + vendorHash = null; + subPackages = [ "cmd/aten-mount" ]; + }; + + default = self.packages.${pkgs.system}.aten-mount; + }); + + devShells = forAllSystems ({ pkgs }: { + default = pkgs.mkShell { + packages = [ pkgs.go_1_26 ]; + }; + }); + }; +} diff --git a/go.mod b/go.mod index e9aaddf..ad27c9f 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module github.com/example/aten-mount +module git.dsg.is/dsg/aten-ipmi-tools -go 1.25.7 +go 1.26 diff --git a/main.go b/main.go deleted file mode 100644 index b4ed560..0000000 --- a/main.go +++ /dev/null @@ -1,102 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "log" - "net" - "os" - "os/signal" - "syscall" - "time" -) - -func main() { - user := flag.String("u", "admin", "BMC username") - pass := flag.String("p", "admin", "BMC password") - port := flag.Int("port", 623, "BMC virtual media TCP port") - flag.Usage = func() { - fmt.Fprintf(os.Stderr, "Usage: %s [flags] \n\nFlags:\n", os.Args[0]) - flag.PrintDefaults() - } - flag.Parse() - - if flag.NArg() != 2 { - flag.Usage() - os.Exit(1) - } - host := flag.Arg(0) - isoPath := flag.Arg(1) - - // Open and validate ISO file - isoFile, err := os.Open(isoPath) - if err != nil { - log.Fatalf("open ISO: %v", err) - } - defer isoFile.Close() - - isoInfo, err := isoFile.Stat() - if err != nil { - log.Fatalf("stat ISO: %v", err) - } - isoSize := isoInfo.Size() - if isoSize < 0x8054 { - log.Fatalf("ISO file too small (%d bytes)", isoSize) - } - totalSectors := uint32(isoSize / 2048) - log.Printf("ISO: %s (%d bytes, %d sectors)", isoPath, isoSize, totalSectors) - - // TCP connect - addr := net.JoinHostPort(host, fmt.Sprintf("%d", *port)) - log.Printf("Connecting to %s...", addr) - conn, err := net.DialTimeout("tcp", addr, 10*time.Second) - if err != nil { - log.Fatalf("connect: %v", err) - } - defer conn.Close() - log.Printf("Connected") - - // Send PlugIn packet - pluginPkt := buildPlugInPacket(*user, *pass) - if _, err := conn.Write(pluginPkt); err != nil { - log.Fatalf("send plugin: %v", err) - } - log.Printf("Sent PlugIn packet (%d bytes)", len(pluginPkt)) - - // Read mount status - status, err := readMountStatus(conn) - if err != nil { - log.Fatalf("mount status: %v", err) - } - if status != 0x00 { - log.Fatalf("mount failed: %s", mountStatusString(status)) - } - log.Printf("Mount OK") - - // Send SetEP command - if _, err := conn.Write(buildSetEPPacket()); err != nil { - log.Fatalf("send setep: %v", err) - } - log.Printf("Sent SetEP, entering SCSI command loop") - - // Signal handling for clean unmount - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - - // SCSI command loop - dev := &SCSIDevice{ - file: isoFile, - totalSectors: totalSectors, - sense: initialSenseData(), - } - - err = commandLoop(conn, dev, sigCh) - - // Clean unmount - log.Printf("Sending unmount...") - conn.Write(buildUnmountPacket()) - if err != nil { - log.Fatalf("command loop: %v", err) - } - log.Printf("Unmounted cleanly") -} diff --git a/mount/mount.go b/mount/mount.go new file mode 100644 index 0000000..dc0de2b --- /dev/null +++ b/mount/mount.go @@ -0,0 +1,87 @@ +package mount + +import ( + "context" + "fmt" + "log" + "net" + "os" + "time" +) + +// Config holds the parameters for a virtual media mount session. +type Config struct { + Host string + Port int + Username string + Password string + ISOPath string +} + +// Run connects to the BMC, mounts the ISO, and serves SCSI commands +// until ctx is cancelled or an error occurs. Sends unmount on return. +func Run(ctx context.Context, cfg Config) error { + isoFile, err := os.Open(cfg.ISOPath) + if err != nil { + return fmt.Errorf("open ISO: %w", err) + } + defer isoFile.Close() + + isoInfo, err := isoFile.Stat() + if err != nil { + return fmt.Errorf("stat ISO: %w", err) + } + isoSize := isoInfo.Size() + if isoSize < 0x8054 { + return fmt.Errorf("ISO file too small (%d bytes)", isoSize) + } + totalSectors := uint32(isoSize / 2048) + log.Printf("ISO: %s (%d bytes, %d sectors)", cfg.ISOPath, isoSize, totalSectors) + + addr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port)) + log.Printf("Connecting to %s...", addr) + + var d net.Dialer + d.Timeout = 10 * time.Second + conn, err := d.DialContext(ctx, "tcp", addr) + if err != nil { + return fmt.Errorf("connect: %w", err) + } + defer conn.Close() + log.Printf("Connected") + + if _, err := conn.Write(buildPlugInPacket(cfg.Username, cfg.Password)); err != nil { + return fmt.Errorf("send plugin: %w", err) + } + log.Printf("Sent PlugIn packet") + + status, err := readMountStatus(conn) + if err != nil { + return fmt.Errorf("mount status: %w", err) + } + if status != 0x00 { + return fmt.Errorf("mount failed: %s", mountStatusString(status)) + } + log.Printf("Mount OK") + + if _, err := conn.Write(buildSetEPPacket()); err != nil { + return fmt.Errorf("send setep: %w", err) + } + log.Printf("Sent SetEP, entering SCSI command loop") + + dev := &scsiDevice{ + file: isoFile, + totalSectors: totalSectors, + sense: initialSenseData(), + } + + err = commandLoop(ctx, conn, dev) + + log.Printf("Sending unmount...") + conn.Write(buildUnmountPacket()) + if err != nil { + return fmt.Errorf("command loop: %w", err) + } + log.Printf("Unmounted cleanly") + return nil +} diff --git a/protocol.go b/mount/protocol.go similarity index 92% rename from protocol.go rename to mount/protocol.go index 259fea8..6b2bad9 100644 --- a/protocol.go +++ b/mount/protocol.go @@ -1,13 +1,13 @@ -package main +package mount import ( + "context" "crypto/rc4" "encoding/binary" "fmt" "io" "log" "net" - "os" "time" ) @@ -272,7 +272,7 @@ func buildCSW(ep byte, tag [4]byte, dataTransferLen uint32, actualLen uint32, st pdu[3] = 0xFF binary.LittleEndian.PutUint32(pdu[4:8], 13) // CSW body - pdu[8] = 'U' // dCSWSignature "USBS" + pdu[8] = 'U' // dCSWSignature "USBS" pdu[9] = 'S' pdu[10] = 'B' pdu[11] = 'S' @@ -282,7 +282,7 @@ func buildCSW(ep byte, tag [4]byte, dataTransferLen uint32, actualLen uint32, st residue = dataTransferLen - actualLen } binary.LittleEndian.PutUint32(pdu[16:20], residue) // dCSWDataResidue - pdu[20] = status // bCSWStatus + pdu[20] = status // bCSWStatus return pdu } @@ -307,22 +307,20 @@ func (h pduHeader) pduType() uint32 { return binary.BigEndian.Uint32(h.typeOrTag[:]) } -// commandLoop reads PDU headers and dispatches commands until signal or error. -func commandLoop(conn net.Conn, dev *SCSIDevice, sigCh <-chan os.Signal) error { +// commandLoop reads PDU headers and dispatches commands until context +// cancellation or error. +func commandLoop(ctx context.Context, conn net.Conn, dev *scsiDevice) error { for { - // Check for signal (non-blocking) - select { - case <-sigCh: + if err := ctx.Err(); err != nil { return nil - default: } - // Set read deadline so we can check signals periodically + // Set read deadline so we can check context periodically conn.SetReadDeadline(time.Now().Add(1 * time.Second)) hdr, err := readPDUHeader(conn) if err != nil { if netErr, ok := err.(net.Error); ok && netErr.Timeout() { - continue // timeout, loop back to check signal + continue // timeout, loop back to check context } return fmt.Errorf("read PDU header: %w", err) } diff --git a/protocol_test.go b/mount/protocol_test.go similarity index 96% rename from protocol_test.go rename to mount/protocol_test.go index f5097f6..1913289 100644 --- a/protocol_test.go +++ b/mount/protocol_test.go @@ -1,4 +1,4 @@ -package main +package mount import ( "encoding/binary" diff --git a/scsi.go b/mount/scsi.go similarity index 83% rename from scsi.go rename to mount/scsi.go index dda3411..44d9660 100644 --- a/scsi.go +++ b/mount/scsi.go @@ -1,4 +1,4 @@ -package main +package mount import ( "encoding/binary" @@ -8,25 +8,25 @@ import ( // SCSI opcodes const ( - scsiTestUnitReady = 0x00 - scsiRequestSense = 0x03 - scsiInquiry = 0x12 - scsiStartStopUnit = 0x1B - scsiMediumRemoval = 0x1E - scsiReadFormatCapacities = 0x23 - scsiReadCapacity = 0x25 - scsiRead10 = 0x28 - scsiReadToc = 0x43 - scsiGetConfiguration = 0x46 - scsiGetEventStatusNotif = 0x4A - scsiModeSense10 = 0x5A - scsiRead12 = 0xA8 + scsiTestUnitReady = 0x00 + scsiRequestSense = 0x03 + scsiInquiry = 0x12 + scsiStartStopUnit = 0x1B + scsiMediumRemoval = 0x1E + scsiReadFormatCapacities = 0x23 + scsiReadCapacity = 0x25 + scsiRead10 = 0x28 + scsiReadToc = 0x43 + scsiGetConfiguration = 0x46 + scsiGetEventStatusNotif = 0x4A + scsiModeSense10 = 0x5A + scsiRead12 = 0xA8 ) // CSW status const ( - cswStatusPassed = 0x00 - cswStatusFailed = 0x01 + cswStatusPassed = 0x00 + cswStatusFailed = 0x01 ) // Sense keys @@ -37,8 +37,8 @@ const ( senseIllegalRequest = 0x05 ) -// SCSIDevice holds state for the virtual CD-ROM. -type SCSIDevice struct { +// scsiDevice holds state for the virtual CD-ROM. +type scsiDevice struct { file *os.File totalSectors uint32 sense [18]byte @@ -46,25 +46,25 @@ type SCSIDevice struct { func initialSenseData() [18]byte { var s [18]byte - s[0] = 0x70 // Response code (current errors) - s[7] = 0x0A // Additional sense length + s[0] = 0x70 // Response code (current errors) + s[7] = 0x0A // Additional sense length return s } -func (d *SCSIDevice) setSense(key, asc, ascq byte) { +func (d *scsiDevice) setSense(key, asc, ascq byte) { d.sense = initialSenseData() d.sense[2] = key d.sense[12] = asc d.sense[13] = ascq } -func (d *SCSIDevice) setOK() { +func (d *scsiDevice) setOK() { d.sense = initialSenseData() } // handleCBW parses a 31-byte CBW and returns the complete TCP response // (data PDU + CSW, or just CSW). -func (d *SCSIDevice) handleCBW(ep byte, cbw []byte) []byte { +func (d *scsiDevice) handleCBW(ep byte, cbw []byte) []byte { // Parse CBW fields (offsets relative to 31-byte CBW body) // CBW signature at 0-3 ("USBC") var tag [4]byte @@ -96,7 +96,7 @@ func (d *SCSIDevice) handleCBW(ep byte, cbw []byte) []byte { return resp } -func (d *SCSIDevice) executeSCSI(cdb []byte, transferLen uint32) (data []byte, status byte) { +func (d *scsiDevice) executeSCSI(cdb []byte, transferLen uint32) (data []byte, status byte) { if len(cdb) == 0 { d.setSense(senseIllegalRequest, 0x20, 0x00) // Invalid command return nil, cswStatusFailed @@ -137,12 +137,12 @@ func (d *SCSIDevice) executeSCSI(cdb []byte, transferLen uint32) (data []byte, s } } -func (d *SCSIDevice) cmdTestUnitReady() ([]byte, byte) { +func (d *scsiDevice) cmdTestUnitReady() ([]byte, byte) { d.setOK() return nil, cswStatusPassed } -func (d *SCSIDevice) cmdRequestSense(cdb []byte) ([]byte, byte) { +func (d *scsiDevice) cmdRequestSense(cdb []byte) ([]byte, byte) { allocLen := int(cdb[4]) resp := make([]byte, 18) copy(resp, d.sense[:]) @@ -153,7 +153,7 @@ func (d *SCSIDevice) cmdRequestSense(cdb []byte) ([]byte, byte) { return resp, cswStatusPassed } -func (d *SCSIDevice) cmdInquiry(cdb []byte) ([]byte, byte) { +func (d *scsiDevice) cmdInquiry(cdb []byte) ([]byte, byte) { evpd := cdb[1] & 0x01 pageCode := cdb[2] allocLen := int(cdb[3])<<8 | int(cdb[4]) @@ -216,17 +216,17 @@ func (d *SCSIDevice) cmdInquiry(cdb []byte) ([]byte, byte) { return nil, cswStatusFailed } -func (d *SCSIDevice) cmdStartStopUnit() ([]byte, byte) { +func (d *scsiDevice) cmdStartStopUnit() ([]byte, byte) { d.setOK() return nil, cswStatusPassed } -func (d *SCSIDevice) cmdMediumRemoval() ([]byte, byte) { +func (d *scsiDevice) cmdMediumRemoval() ([]byte, byte) { d.setOK() return nil, cswStatusPassed } -func (d *SCSIDevice) cmdReadCapacity(cdb []byte) ([]byte, byte) { +func (d *scsiDevice) cmdReadCapacity(cdb []byte) ([]byte, byte) { // Validate reserved bytes 1-8 are zero (except byte 2 which is OK) for i := 1; i <= 8; i++ { if i >= 2 && i <= 5 { @@ -246,7 +246,7 @@ func (d *SCSIDevice) cmdReadCapacity(cdb []byte) ([]byte, byte) { return resp, cswStatusPassed } -func (d *SCSIDevice) cmdReadFormatCapacities(cdb []byte) ([]byte, byte) { +func (d *scsiDevice) cmdReadFormatCapacities(cdb []byte) ([]byte, byte) { allocLen := int(cdb[7])<<8 | int(cdb[8]) resp := make([]byte, 12) resp[3] = 0x08 // Capacity list length @@ -263,7 +263,7 @@ func (d *SCSIDevice) cmdReadFormatCapacities(cdb []byte) ([]byte, byte) { return resp, cswStatusPassed } -func (d *SCSIDevice) cmdRead10(cdb []byte) ([]byte, byte) { +func (d *scsiDevice) cmdRead10(cdb []byte) ([]byte, byte) { lba := binary.BigEndian.Uint32(cdb[2:6]) count := uint32(cdb[7])<<8 | uint32(cdb[8]) @@ -286,7 +286,7 @@ func (d *SCSIDevice) cmdRead10(cdb []byte) ([]byte, byte) { return data[:n], cswStatusPassed } -func (d *SCSIDevice) cmdRead12(cdb []byte) ([]byte, byte) { +func (d *scsiDevice) cmdRead12(cdb []byte) ([]byte, byte) { lba := binary.BigEndian.Uint32(cdb[2:6]) count := binary.BigEndian.Uint32(cdb[6:10]) @@ -309,7 +309,7 @@ func (d *SCSIDevice) cmdRead12(cdb []byte) ([]byte, byte) { return data[:n], cswStatusPassed } -func (d *SCSIDevice) cmdReadToc(cdb []byte) ([]byte, byte) { +func (d *scsiDevice) cmdReadToc(cdb []byte) ([]byte, byte) { format := cdb[2] & 0x0F if format != 0 { d.setSense(senseIllegalRequest, 0x24, 0x00) @@ -354,7 +354,7 @@ func (d *SCSIDevice) cmdReadToc(cdb []byte) ([]byte, byte) { return resp, cswStatusPassed } -func (d *SCSIDevice) cmdGetConfiguration(cdb []byte) ([]byte, byte) { +func (d *scsiDevice) cmdGetConfiguration(cdb []byte) ([]byte, byte) { allocLen := int(cdb[7])<<8 | int(cdb[8]) if allocLen == 0 { d.setSense(senseIllegalRequest, 0x24, 0x00) @@ -398,7 +398,7 @@ func (d *SCSIDevice) cmdGetConfiguration(cdb []byte) ([]byte, byte) { return resp, cswStatusPassed } -func (d *SCSIDevice) cmdGetEventStatus(cdb []byte) ([]byte, byte) { +func (d *scsiDevice) cmdGetEventStatus(cdb []byte) ([]byte, byte) { if cdb[1]&0x01 == 0 { // Polled bit not set - async not supported d.setSense(senseIllegalRequest, 0x24, 0x00) @@ -424,7 +424,7 @@ func (d *SCSIDevice) cmdGetEventStatus(cdb []byte) ([]byte, byte) { return resp, cswStatusPassed } -func (d *SCSIDevice) cmdModeSense10(cdb []byte) ([]byte, byte) { +func (d *scsiDevice) cmdModeSense10(cdb []byte) ([]byte, byte) { pageCode := cdb[2] & 0x3F allocLen := int(cdb[7])<<8 | int(cdb[8]) diff --git a/scsi_test.go b/mount/scsi_test.go similarity index 83% rename from scsi_test.go rename to mount/scsi_test.go index 7ccd450..4ec4445 100644 --- a/scsi_test.go +++ b/mount/scsi_test.go @@ -1,4 +1,4 @@ -package main +package mount import ( "encoding/binary" @@ -6,16 +6,16 @@ import ( "testing" ) -func newTestDevice(t *testing.T) *SCSIDevice { +func newTestDevice(t *testing.T) *scsiDevice { t.Helper() - return &SCSIDevice{ + return &scsiDevice{ file: nil, // No file for most tests totalSectors: 1000, sense: initialSenseData(), } } -func newTestDeviceWithFile(t *testing.T) (*SCSIDevice, func()) { +func newTestDeviceWithFile(t *testing.T) (*scsiDevice, func()) { t.Helper() // Create a minimal fake ISO (just enough for reads) f, err := os.CreateTemp("", "test-*.iso") @@ -30,7 +30,7 @@ func newTestDeviceWithFile(t *testing.T) (*SCSIDevice, func()) { f.Write(data) f.Sync() - dev := &SCSIDevice{ + dev := &scsiDevice{ file: f, totalSectors: 10, sense: initialSenseData(), @@ -173,14 +173,14 @@ func TestHandleCBW(t *testing.T) { dev := newTestDevice(t) // Build a CBW for INQUIRY cbw := make([]byte, 31) - copy(cbw[0:4], []byte("USBC")) // Signature - copy(cbw[4:8], []byte{1, 2, 3, 4}) // Tag - binary.LittleEndian.PutUint32(cbw[8:12], 36) // Transfer length - cbw[12] = 0x80 // Flags: IN - cbw[14] = 6 // CDB length - cbw[15] = 0x12 // INQUIRY opcode - cbw[18] = 0x00 // Alloc len MSB (CDB byte 3) - cbw[19] = 0x24 // Alloc len LSB (CDB byte 4) = 36 + copy(cbw[0:4], []byte("USBC")) // Signature + copy(cbw[4:8], []byte{1, 2, 3, 4}) // Tag + binary.LittleEndian.PutUint32(cbw[8:12], 36) // Transfer length + cbw[12] = 0x80 // Flags: IN + cbw[14] = 6 // CDB length + cbw[15] = 0x12 // INQUIRY opcode + cbw[18] = 0x00 // Alloc len MSB (CDB byte 3) + cbw[19] = 0x24 // Alloc len LSB (CDB byte 4) = 36 resp := dev.handleCBW(0x20, cbw) // Should have: 8 data PDU tag + 36 data + 8 CSW tag + 13 CSW body = 65