diff --git a/internal/controller/httpapi/v1/export.go b/internal/controller/httpapi/v1/export.go index 753f11575..dbe02b738 100644 --- a/internal/controller/httpapi/v1/export.go +++ b/internal/controller/httpapi/v1/export.go @@ -152,8 +152,10 @@ func flatToNested(d *dto.Device) dto.DeviceExportRecord { // buildExportME returns the ME subsystem, or nil for devices without a // Management Engine func buildExportME(d *dto.Device, info *dto.DeviceInfo) *dto.ExportME { + network := buildExportMENetwork(info.MENetwork) + hasME := info.FWVersion != "" || info.FWSku != "" || info.CurrentMode != "" || - (info.AMTEnabledInBIOS != nil && *info.AMTEnabledInBIOS) + (info.AMTEnabledInBIOS != nil && *info.AMTEnabledInBIOS) || network != nil if !hasME { return nil } @@ -172,8 +174,9 @@ func buildExportME(d *dto.Device, info *dto.DeviceInfo) *dto.ExportME { UPID: info.UPID, } - if info.IPAddress != "" { - // export-gap: only the wired ME IP is stored today; wireless has no source field yet, so it stays null. + if network != nil { + me.Network = network + } else if info.IPAddress != "" { me.Network = &dto.ExportMENetwork{ Wired: &dto.ExportMEInterface{ IPAddress: info.IPAddress, @@ -185,16 +188,51 @@ func buildExportME(d *dto.Device, info *dto.DeviceInfo) *dto.ExportME { return me } +func buildExportMENetwork(network *dto.MENetworkInfo) *dto.ExportMENetwork { + if network == nil { + return nil + } + + exportNetwork := &dto.ExportMENetwork{ + Wired: buildExportMEInterface(network.Wired), + Wireless: buildExportMEInterface(network.Wireless), + } + if exportNetwork.Wired == nil && exportNetwork.Wireless == nil { + return nil + } + + return exportNetwork +} + +func buildExportMEInterface(adapter *dto.MEInterfaceInfo) *dto.ExportMEInterface { + if adapter == nil { + return nil + } + + if adapter.IPAddress == "" && adapter.DHCPEnabled == nil && adapter.DHCPMode == "" && + adapter.LinkStatus == "" && adapter.MACAddress == "" { + return nil + } + + exportAdapter := dto.ExportMEInterface(*adapter) + + return &exportAdapter +} + // buildExportOS returns the OS subsystem, or nil when no OS data was reported. func buildExportOS(info *dto.DeviceInfo) *dto.ExportOS { + network := buildExportOSNetwork(info.OSNetwork) + hasOS := info.OSName != "" || info.OSVersion != "" || info.OSDistro != "" || info.OSIPAddress != "" || info.LMSInstalled != nil || info.LMSVersion != "" || - info.MEInterfaceVersion != "" || info.MonitorConnected != nil || info.IEEE8021XEnabled != nil + info.MEInterfaceVersion != "" || info.MonitorConnected != nil || info.IEEE8021XEnabled != nil || + info.DNSSuffixOS != "" || network != nil if !hasOS { return nil } osInfo := &dto.ExportOS{ + DNSSuffix: info.DNSSuffixOS, Name: info.OSName, Version: info.OSVersion, Distro: info.OSDistro, @@ -205,8 +243,9 @@ func buildExportOS(info *dto.DeviceInfo) *dto.ExportOS { IEEE8021XEnabled: info.IEEE8021XEnabled, } - if info.OSIPAddress != "" { - // export-gap: only the wired OS IP is stored today; wireless has no source field yet, so it stays null. + if network != nil { + osInfo.Network = network + } else if info.OSIPAddress != "" { osInfo.Network = &dto.ExportOSNetwork{ Wired: []dto.ExportOSInterface{{IPAddress: info.OSIPAddress}}, } @@ -215,16 +254,63 @@ func buildExportOS(info *dto.DeviceInfo) *dto.ExportOS { return osInfo } +func buildExportOSNetwork(network *dto.OSNetworkInfo) *dto.ExportOSNetwork { + if network == nil { + return nil + } + + wired := make([]dto.ExportOSInterface, 0, len(network.Wired)) + for _, adapter := range network.Wired { + if exportAdapter := buildExportOSInterface(&adapter); exportAdapter != nil { + wired = append(wired, *exportAdapter) + } + } + + exportNetwork := &dto.ExportOSNetwork{ + Wired: wired, + Wireless: buildExportOSInterface(network.Wireless), + } + if len(exportNetwork.Wired) == 0 && exportNetwork.Wireless == nil { + return nil + } + + return exportNetwork +} + +func buildExportOSInterface(adapter *dto.OSInterfaceInfo) *dto.ExportOSInterface { + if adapter == nil { + return nil + } + + if adapter.Name == "" && adapter.IPAddress == "" && adapter.DHCPEnabled == nil && + adapter.LinkStatus == "" && adapter.MACAddress == "" { + return nil + } + + exportAdapter := dto.ExportOSInterface(*adapter) + + return &exportAdapter +} + // buildExportPlatform returns the platform subsystem, or nil when no platform // data was reported. func buildExportPlatform(info *dto.DeviceInfo) *dto.ExportPlatform { - if info.CPUModel == "" && info.EthernetAdapterCount == nil { + hasAdapters := info.PlatformAdapters != nil && + (info.PlatformAdapters.Wired != "" || info.PlatformAdapters.Wireless != "") + if info.CPUModel == "" && info.EthernetAdapterCount == nil && !hasAdapters { return nil } - // export-gap: adapters has no source fields yet, so it stays null. - return &dto.ExportPlatform{ + platform := &dto.ExportPlatform{ CPU: info.CPUModel, EthernetAdapterCount: info.EthernetAdapterCount, } + if hasAdapters { + platform.Adapters = &dto.ExportPlatformAdapters{ + Wired: info.PlatformAdapters.Wired, + Wireless: info.PlatformAdapters.Wireless, + } + } + + return platform } diff --git a/internal/controller/httpapi/v1/export_test.go b/internal/controller/httpapi/v1/export_test.go index f75e21d55..47684ac9e 100644 --- a/internal/controller/httpapi/v1/export_test.go +++ b/internal/controller/httpapi/v1/export_test.go @@ -43,6 +43,7 @@ func TestExportDevices_Success(t *testing.T) { lastSynced := time.Date(2026, 7, 20, 14, 22, 15, 0, time.UTC) amtEnabled := true dhcp := true + osDhcp := false adapters := 2 meDevice := dto.Device{ @@ -67,9 +68,32 @@ func TestExportDevices_Success(t *testing.T) { FirstDiscovered: &firstDiscovered, LastSynced: &lastSynced, OSName: "linux", + DNSSuffixOS: "vpro.demo.com", OSIPAddress: "10.49.76.163", CPUModel: "Intel(R) Core(TM) Ultra 7 165H", EthernetAdapterCount: &adapters, + MENetwork: &dto.MENetworkInfo{ + Wired: &dto.MEInterfaceInfo{ + IPAddress: "10.0.0.12", + DHCPEnabled: &dhcp, + DHCPMode: "active", + LinkStatus: "up", + MACAddress: "AA:BB:CC:DD:EE:10", + }, + }, + OSNetwork: &dto.OSNetworkInfo{ + Wired: []dto.OSInterfaceInfo{{ + Name: "eth0", + IPAddress: "10.49.76.163", + DHCPEnabled: &osDhcp, + LinkStatus: "up", + MACAddress: "AA:BB:CC:DD:EE:FF", + }}, + }, + PlatformAdapters: &dto.PlatformAdaptersInfo{ + Wired: "eth0", + Wireless: "wlan0", + }, }, } @@ -117,11 +141,23 @@ func TestExportDevices_Success(t *testing.T) { require.NotNil(t, me.DeviceInfo.ME.MEBXEnabledInBIOS) require.NotNil(t, me.DeviceInfo.ME.Network) require.Equal(t, "10.0.0.12", me.DeviceInfo.ME.Network.Wired.IPAddress) + require.Equal(t, "active", me.DeviceInfo.ME.Network.Wired.DHCPMode) + require.Equal(t, "up", me.DeviceInfo.ME.Network.Wired.LinkStatus) + require.Equal(t, "AA:BB:CC:DD:EE:10", me.DeviceInfo.ME.Network.Wired.MACAddress) require.NotNil(t, me.DeviceInfo.OS) + require.Equal(t, "vpro.demo.com", me.DeviceInfo.OS.DNSSuffix) require.Equal(t, "linux", me.DeviceInfo.OS.Name) + require.Equal(t, "eth0", me.DeviceInfo.OS.Network.Wired[0].Name) require.Equal(t, "10.49.76.163", me.DeviceInfo.OS.Network.Wired[0].IPAddress) + require.NotNil(t, me.DeviceInfo.OS.Network.Wired[0].DHCPEnabled) + require.False(t, *me.DeviceInfo.OS.Network.Wired[0].DHCPEnabled) + require.Equal(t, "up", me.DeviceInfo.OS.Network.Wired[0].LinkStatus) + require.Equal(t, "AA:BB:CC:DD:EE:FF", me.DeviceInfo.OS.Network.Wired[0].MACAddress) require.NotNil(t, me.DeviceInfo.Platform) require.Equal(t, "Intel(R) Core(TM) Ultra 7 165H", me.DeviceInfo.Platform.CPU) + require.NotNil(t, me.DeviceInfo.Platform.Adapters) + require.Equal(t, "eth0", me.DeviceInfo.Platform.Adapters.Wired) + require.Equal(t, "wlan0", me.DeviceInfo.Platform.Adapters.Wireless) require.Nil(t, me.DeviceInfo.BMC) // Non-ME device has a null me subsystem. @@ -137,6 +173,130 @@ func TestExportDevices_Success(t *testing.T) { require.NotContains(t, body, "mebxpassword") } +func TestBuildExportME_EmptyMENetworkFallsBackToLegacyIPAddress(t *testing.T) { + t.Parallel() + + dhcp := true + device := &dto.Device{} + info := &dto.DeviceInfo{ + CurrentMode: "Admin", + IPAddress: "10.0.0.12", + DHCPEnabled: &dhcp, + MENetwork: &dto.MENetworkInfo{}, + } + + me := buildExportME(device, info) + + require.NotNil(t, me) + require.NotNil(t, me.Network) + require.NotNil(t, me.Network.Wired) + require.Equal(t, "10.0.0.12", me.Network.Wired.IPAddress) +} + +func TestBuildExportME_MENetworkOnlyStillReturnsME(t *testing.T) { + t.Parallel() + + device := &dto.Device{} + info := &dto.DeviceInfo{ + MENetwork: &dto.MENetworkInfo{ + Wired: &dto.MEInterfaceInfo{MACAddress: "90:49:fa:0a:26:a3"}, + }, + } + + me := buildExportME(device, info) + + require.NotNil(t, me) + require.NotNil(t, me.Network) + require.NotNil(t, me.Network.Wired) + require.Equal(t, "90:49:fa:0a:26:a3", me.Network.Wired.MACAddress) +} + +func TestBuildExportME_EmptyMEInterfaceOnlyReturnsNil(t *testing.T) { + t.Parallel() + + device := &dto.Device{} + info := &dto.DeviceInfo{ + MENetwork: &dto.MENetworkInfo{Wired: &dto.MEInterfaceInfo{}}, + } + + me := buildExportME(device, info) + + require.Nil(t, me) +} + +func TestBuildExportOS_EmptyOSNetworkFallsBackToLegacyIPAddress(t *testing.T) { + t.Parallel() + + info := &dto.DeviceInfo{ + OSName: "linux", + OSIPAddress: "10.49.76.163", + OSNetwork: &dto.OSNetworkInfo{}, + } + + os := buildExportOS(info) + + require.NotNil(t, os) + require.NotNil(t, os.Network) + require.Len(t, os.Network.Wired, 1) + require.Equal(t, "10.49.76.163", os.Network.Wired[0].IPAddress) +} + +func TestBuildExportOS_DNSSuffixOnlyStillReturnsOS(t *testing.T) { + t.Parallel() + + info := &dto.DeviceInfo{ + DNSSuffixOS: "corp.example.com", + } + + os := buildExportOS(info) + + require.NotNil(t, os) + require.Equal(t, "corp.example.com", os.DNSSuffix) +} + +func TestBuildExportOS_OSNetworkOnlyStillReturnsOS(t *testing.T) { + t.Parallel() + + info := &dto.DeviceInfo{ + OSNetwork: &dto.OSNetworkInfo{ + Wired: []dto.OSInterfaceInfo{{IPAddress: "192.168.1.50"}}, + }, + } + + os := buildExportOS(info) + + require.NotNil(t, os) + require.NotNil(t, os.Network) + require.Len(t, os.Network.Wired, 1) + require.Equal(t, "192.168.1.50", os.Network.Wired[0].IPAddress) +} + +func TestBuildExportOS_EmptyOSNetworkOnlyReturnsNil(t *testing.T) { + t.Parallel() + + info := &dto.DeviceInfo{ + OSNetwork: &dto.OSNetworkInfo{}, + } + + os := buildExportOS(info) + + require.Nil(t, os) +} + +func TestBuildExportOS_EmptyOSInterfaceOnlyReturnsNil(t *testing.T) { + t.Parallel() + + info := &dto.DeviceInfo{ + OSNetwork: &dto.OSNetworkInfo{ + Wired: []dto.OSInterfaceInfo{{}}, + }, + } + + os := buildExportOS(info) + + require.Nil(t, os) +} + func TestExportDevices_DatabaseError(t *testing.T) { t.Parallel() diff --git a/internal/entity/dto/v1/device.go b/internal/entity/dto/v1/device.go index c759649b1..efdff6e30 100644 --- a/internal/entity/dto/v1/device.go +++ b/internal/entity/dto/v1/device.go @@ -58,11 +58,46 @@ type DeviceInfo struct { OSName string `json:"osName,omitempty"` OSVersion string `json:"osVersion,omitempty"` OSDistro string `json:"osDistro,omitempty"` + DNSSuffixOS string `json:"dnsSuffixOS,omitempty"` CPUModel string `json:"cpuModel,omitempty"` OSIPAddress string `json:"osIpAddress,omitempty"` EthernetAdapterCount *int `json:"ethernetAdapterCount,omitempty"` MonitorConnected *bool `json:"monitorConnected,omitempty"` IEEE8021XEnabled *bool `json:"ieee8021xEnabled,omitempty"` + MENetwork *MENetworkInfo `json:"meNetwork,omitempty"` + OSNetwork *OSNetworkInfo `json:"osNetwork,omitempty"` + PlatformAdapters *PlatformAdaptersInfo `json:"platformAdapters,omitempty"` +} + +type MENetworkInfo struct { + Wired *MEInterfaceInfo `json:"wired,omitempty"` + Wireless *MEInterfaceInfo `json:"wireless,omitempty"` +} + +type MEInterfaceInfo struct { + IPAddress string `json:"ipAddress,omitempty"` + DHCPEnabled *bool `json:"dhcpEnabled,omitempty"` + DHCPMode string `json:"dhcpMode,omitempty"` + LinkStatus string `json:"linkStatus,omitempty"` + MACAddress string `json:"macAddress,omitempty"` +} + +type OSNetworkInfo struct { + Wired []OSInterfaceInfo `json:"wired,omitempty"` + Wireless *OSInterfaceInfo `json:"wireless,omitempty"` +} + +type OSInterfaceInfo struct { + Name string `json:"name,omitempty"` + IPAddress string `json:"ipAddress,omitempty"` + DHCPEnabled *bool `json:"dhcpEnabled,omitempty"` + LinkStatus string `json:"linkStatus,omitempty"` + MACAddress string `json:"macAddress,omitempty"` +} + +type PlatformAdaptersInfo struct { + Wired string `json:"wired,omitempty"` + Wireless string `json:"wireless,omitempty"` } // UnmarshalJSON implements custom JSON deserialization to support backwards compatibility diff --git a/internal/usecase/devices/usecase.go b/internal/usecase/devices/usecase.go index 711a39dc4..776f29c3c 100644 --- a/internal/usecase/devices/usecase.go +++ b/internal/usecase/devices/usecase.go @@ -184,11 +184,15 @@ var deviceInfoFieldSetters = map[string]func(dst, src *dto.DeviceInfo){ "osname": func(dst, src *dto.DeviceInfo) { dst.OSName = src.OSName }, "osversion": func(dst, src *dto.DeviceInfo) { dst.OSVersion = src.OSVersion }, "osdistro": func(dst, src *dto.DeviceInfo) { dst.OSDistro = src.OSDistro }, + "dnssuffixos": func(dst, src *dto.DeviceInfo) { dst.DNSSuffixOS = src.DNSSuffixOS }, "cpumodel": func(dst, src *dto.DeviceInfo) { dst.CPUModel = src.CPUModel }, "osipaddress": func(dst, src *dto.DeviceInfo) { dst.OSIPAddress = src.OSIPAddress }, "ethernetadaptercount": func(dst, src *dto.DeviceInfo) { dst.EthernetAdapterCount = src.EthernetAdapterCount }, "monitorconnected": func(dst, src *dto.DeviceInfo) { dst.MonitorConnected = src.MonitorConnected }, "ieee8021xenabled": func(dst, src *dto.DeviceInfo) { dst.IEEE8021XEnabled = src.IEEE8021XEnabled }, + "menetwork": func(dst, src *dto.DeviceInfo) { dst.MENetwork = src.MENetwork }, + "osnetwork": func(dst, src *dto.DeviceInfo) { dst.OSNetwork = src.OSNetwork }, + "platformadapters": func(dst, src *dto.DeviceInfo) { dst.PlatformAdapters = src.PlatformAdapters }, } // firstDiscovered and discovered are set once at initial discovery and are immutable