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 <noreply@anthropic.com>
This commit is contained in:
Davíð Steinn Geirsson 2026-03-11 14:28:22 +00:00
parent 2ae20b8827
commit 826edc817a
11 changed files with 255 additions and 168 deletions

3
.gitignore vendored
View file

@ -1 +1,2 @@
aten-mount
/aten-mount
/result

44
cmd/aten-mount/main.go Normal file
View file

@ -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] <bmc-host> <image.iso>\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)
}
}

27
flake.lock generated Normal file
View file

@ -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
}

32
flake.nix Normal file
View file

@ -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 ];
};
});
};
}

4
go.mod
View file

@ -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

102
main.go
View file

@ -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] <bmc-host> <image.iso>\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")
}

87
mount/mount.go Normal file
View file

@ -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
}

View file

@ -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)
}

View file

@ -1,4 +1,4 @@
package main
package mount
import (
"encoding/binary"

View file

@ -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])

View file

@ -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