diff --git a/src/amt/DeviceAction.ts b/src/amt/DeviceAction.ts index bbfcff5ae..43dc1f4fa 100644 --- a/src/amt/DeviceAction.ts +++ b/src/amt/DeviceAction.ts @@ -660,4 +660,183 @@ export class DeviceAction { logger.silly(`putKVMRedirectionSettingData ${messages.COMPLETE}`) return result.Envelope.Body } + + async getEthernetPortSettings( + instanceID: string = 'Intel(r) AMT Ethernet Port Settings 0' + ): Promise { + logger.silly(`getEthernetPortSettings ${messages.REQUEST}`) + const selector = { name: 'InstanceID', value: instanceID } + const xmlRequestBody = this.amt.EthernetPortSettings.Get(selector) + const result = await this.ciraHandler.Get(this.ciraSocket, xmlRequestBody) + logger.silly(`getEthernetPortSettings ${messages.COMPLETE}`) + return result.Envelope.Body + } + + async enumerateEthernetPortSettings(): Promise< + Common.Models.Envelope> + > { + logger.silly(`enumerateEthernetPortSettings ${messages.REQUEST}`) + let xmlRequestBody = this.amt.EthernetPortSettings.Enumerate() + const enumResponse = await this.ciraHandler.Enumerate(this.ciraSocket, xmlRequestBody) + if (enumResponse == null) { + logger.error(`enumerateEthernetPortSettings failed. Reason: ${messages.ENUMERATION_RESPONSE_NULL}`) + return null + } + xmlRequestBody = this.amt.EthernetPortSettings.Pull(enumResponse.Envelope.Body.EnumerateResponse.EnumerationContext) + const pullResponse = await this.ciraHandler.Pull(this.ciraSocket, xmlRequestBody) + logger.silly(`enumerateEthernetPortSettings ${messages.COMPLETE}`) + return pullResponse.Envelope + } + + /** + * Finds the first WiFi port by checking PhysicalConnectionType + * @returns InstanceID of the first WiFi port found, or null if none found + */ + async findWiFiPort(): Promise { + logger.silly('findWiFiPort: searching for WiFi port') + try { + const enumResult = await this.enumerateEthernetPortSettings() + if (enumResult?.Body?.PullResponse?.Items == null) { + logger.error('findWiFiPort: No ethernet port settings found') + return null + } + + const settings: any = (enumResult.Body.PullResponse.Items as any).AMT_EthernetPortSettings + if (settings == null) { + logger.error('findWiFiPort: AMT_EthernetPortSettings not found in response') + return null + } + + const ports = Array.isArray(settings) ? settings : [settings] + + // Find the first port with PhysicalConnectionType = 3 (Wireless LAN) + const wifiPort = ports.find((port: any) => { + const connectionType = parseInt(port.PhysicalConnectionType, 10) + return connectionType === 3 + }) + + if (wifiPort == null) { + logger.error('findWiFiPort: No WiFi port found') + return null + } + + logger.silly(`findWiFiPort: Found WiFi port ${wifiPort.InstanceID}`) + return wifiPort.InstanceID + } catch (err) { + logger.error(`findWiFiPort error: ${(err as Error).message}`) + return null + } + } + + /** + * Validates if a given instance is a WiFi port by checking PhysicalConnectionType + * @returns Object with isWiFi boolean and connectionType number, or null if not found + */ + async validateWiFiPort(instanceID: string): Promise<{ isWiFi: boolean; connectionType: number; instanceID: string } | null> { + logger.silly(`validateWiFiPort for ${instanceID}`) + try { + // Enumerate all ethernet port settings + const enumResult = await this.enumerateEthernetPortSettings() + if (enumResult?.Body?.PullResponse?.Items == null) { + logger.error('validateWiFiPort: No ethernet port settings found') + return null + } + + // Extract AMT_EthernetPortSettings from Items + const settings: any = (enumResult.Body.PullResponse.Items as any).AMT_EthernetPortSettings + if (settings == null) { + logger.error('validateWiFiPort: AMT_EthernetPortSettings not found in response') + return null + } + + // Ensure ports is always an array + const ports = Array.isArray(settings) ? settings : [settings] + + // Find the port matching the instanceID + const targetPort = ports.find((port: any) => port.InstanceID === instanceID) + if (targetPort == null) { + logger.error(`validateWiFiPort: InstanceID ${instanceID} not found`) + return null + } + + const connectionType = parseInt(targetPort.PhysicalConnectionType, 10) + // PhysicalConnectionType: 0=Integrated LAN, 1=Discrete LAN, 2=Thunderbolt, 3=Wireless LAN + // PhysicalConnectionType 3 (Wireless LAN) is WiFi + const isWiFi = connectionType === 3 + logger.silly(`validateWiFiPort: ${instanceID} connectionType=${connectionType} isWiFi=${isWiFi}`) + return { isWiFi, connectionType, instanceID } + } catch (err) { + logger.error(`validateWiFiPort error: ${(err as Error).message}`) + return null + } + } + + async setEthernetLinkPreference( + linkPreference: AMT.Types.EthernetPortSettings.LinkPreference, + timeoutSeconds: number, + instanceID?: string + ): Promise> { + logger.silly(`setEthernetLinkPreference ${messages.REQUEST}`) + + // If no instanceID provided, auto-detect the WiFi port + let targetInstanceID = instanceID + if (targetInstanceID == null || targetInstanceID.trim() === '') { + logger.silly('setEthernetLinkPreference: No instanceID provided, auto-detecting WiFi port') + targetInstanceID = await this.findWiFiPort() + if (targetInstanceID == null) { + const errorMsg = 'No WiFi port found on this device. SetLinkPreference requires a WiFi interface (PhysicalConnectionType=3).' + logger.error(`setEthernetLinkPreference: ${errorMsg}`) + return { + Header: {}, + Body: { + Fault: { + Code: { Value: 'NoWiFiPort' }, + Reason: { Text: errorMsg } + } + } + } as any + } + logger.info(`setEthernetLinkPreference: Auto-detected WiFi port: ${targetInstanceID}`) + } + + // Validate that the target instance is a WiFi port + const validation = await this.validateWiFiPort(targetInstanceID) + if (validation == null) { + logger.error('setEthernetLinkPreference: Failed to validate port') + return null + } + + if (!validation.isWiFi) { + const errorMsg = `SetLinkPreference is only applicable for WiFi ports. InstanceID "${targetInstanceID}" has PhysicalConnectionType=${validation.connectionType} (0=Integrated LAN, 1=Discrete LAN, 2=Thunderbolt). WiFi ports have type 3 (Wireless LAN).` + logger.error(`setEthernetLinkPreference: ${errorMsg}`) + // Return an error envelope structure that handlers can recognize + return { + Header: {}, + Body: { + Fault: { + Code: { Value: 'ValidationError' }, + Reason: { Text: errorMsg } + } + } + } as any + } + + const xmlRequestBody = this.amt.EthernetPortSettings.SetLinkPreference(linkPreference, timeoutSeconds, targetInstanceID) + const result = await this.ciraHandler.Get(this.ciraSocket, xmlRequestBody) + logger.silly(`setEthernetLinkPreference ${messages.COMPLETE}`) + // Add the detected instanceID to the result for the handler to use + if (result?.Envelope != null) { + (result.Envelope as any)._detectedInstanceID = targetInstanceID + } + return result.Envelope + } + + async setLinkPreferenceME(timeoutSeconds: number, instanceID?: string): Promise> { + return await this.setEthernetLinkPreference(1, timeoutSeconds, instanceID) + } + + async cancelLinkPreference(instanceID?: string): Promise> { + // Set preference back to HOST with timeout 0 + return await this.setEthernetLinkPreference(2, 0, instanceID) + } } diff --git a/src/amt/deviceAction.test.ts b/src/amt/deviceAction.test.ts index 78ae1b5be..61246a2f3 100644 --- a/src/amt/deviceAction.test.ts +++ b/src/amt/deviceAction.test.ts @@ -627,4 +627,258 @@ describe('Device Action Tests', () => { expect(result).toEqual(putKVMRedirectionSettingDataResponse.Envelope.Body) }) }) + + describe('WiFi port validation and link preference', () => { + let enumerateEthernetPortSettingsSpy: SpyInstance + + beforeEach(() => { + enumerateEthernetPortSettingsSpy = spyOn(device, 'enumerateEthernetPortSettings') + }) + + it('should find WiFi port automatically', async () => { + const mockEnumResponse = { + Body: { + PullResponse: { + Items: { + AMT_EthernetPortSettings: [ + { + InstanceID: 'Intel(r) AMT Ethernet Port Settings 0', + PhysicalConnectionType: '0', // Integrated LAN + ElementName: 'LAN Port' + }, + { + InstanceID: 'Intel(r) AMT Ethernet Port Settings 1', + PhysicalConnectionType: '3', // Wireless LAN + ElementName: 'WiFi Port' + } + ] + } + } + } + } + enumerateEthernetPortSettingsSpy.mockResolvedValue(mockEnumResponse as any) + + const result = await device.findWiFiPort() + + expect(result).toBe('Intel(r) AMT Ethernet Port Settings 1') + }) + + it('should return null when no WiFi port exists', async () => { + const mockEnumResponse = { + Body: { + PullResponse: { + Items: { + AMT_EthernetPortSettings: [ + { + InstanceID: 'Intel(r) AMT Ethernet Port Settings 0', + PhysicalConnectionType: '0' // Only LAN + }, + { + InstanceID: 'Intel(r) AMT Ethernet Port Settings 1', + PhysicalConnectionType: '1' // Only LAN + } + ] + } + } + } + } + enumerateEthernetPortSettingsSpy.mockResolvedValue(mockEnumResponse as any) + + const result = await device.findWiFiPort() + + expect(result).toBeNull() + }) + + it('should auto-detect WiFi port when instanceID not provided', async () => { + const mockEnumResponse = { + Body: { + PullResponse: { + Items: { + AMT_EthernetPortSettings: [ + { + InstanceID: 'Intel(r) AMT Ethernet Port Settings 0', + PhysicalConnectionType: '0' + }, + { + InstanceID: 'Intel(r) AMT Ethernet Port Settings 1', + PhysicalConnectionType: '3' // WiFi + } + ] + } + } + } + } + enumerateEthernetPortSettingsSpy.mockResolvedValue(mockEnumResponse as any) + getSpy.mockResolvedValue({ Envelope: { Body: { SetLinkPreference_OUTPUT: { ReturnValue: '0' } } } }) + + const result = await device.setEthernetLinkPreference(1, 300) + + expect(result?.Body?.Fault).toBeUndefined() + expect(getSpy).toHaveBeenCalled() + expect((result as any)._detectedInstanceID).toBe('Intel(r) AMT Ethernet Port Settings 1') + }) + + it('should return error when no WiFi port found and instanceID not provided', async () => { + const mockEnumResponse = { + Body: { + PullResponse: { + Items: { + AMT_EthernetPortSettings: { + InstanceID: 'Intel(r) AMT Ethernet Port Settings 0', + PhysicalConnectionType: '0' // Only LAN + } + } + } + } + } + enumerateEthernetPortSettingsSpy.mockResolvedValue(mockEnumResponse as any) + + const result = await device.setEthernetLinkPreference(1, 300) + + expect(result?.Body?.Fault).toBeDefined() + expect(result?.Body?.Fault?.Code?.Value).toBe('NoWiFiPort') + expect(result?.Body?.Fault?.Reason?.Text).toContain('No WiFi port found') + }) + + it('should validate WiFi port (PhysicalConnectionType=3)', async () => { + const mockEnumResponse = { + Body: { + PullResponse: { + Items: { + AMT_EthernetPortSettings: [ + { + InstanceID: 'Intel(r) AMT Ethernet Port Settings 0', + PhysicalConnectionType: '0', // Integrated LAN + ElementName: 'LAN Port' + }, + { + InstanceID: 'Intel(r) AMT Ethernet Port Settings 1', + PhysicalConnectionType: '3', // Wireless LAN + ElementName: 'WiFi Port' + } + ] + } + } + } + } + enumerateEthernetPortSettingsSpy.mockResolvedValue(mockEnumResponse as any) + + const result = await device.validateWiFiPort('Intel(r) AMT Ethernet Port Settings 1') + + expect(result).not.toBeNull() + expect(result?.isWiFi).toBe(true) + expect(result?.connectionType).toBe(3) + expect(result?.instanceID).toBe('Intel(r) AMT Ethernet Port Settings 1') + }) + + it('should identify non-WiFi port (PhysicalConnectionType=0)', async () => { + const mockEnumResponse = { + Body: { + PullResponse: { + Items: { + AMT_EthernetPortSettings: { + InstanceID: 'Intel(r) AMT Ethernet Port Settings 0', + PhysicalConnectionType: '0', // Integrated LAN + ElementName: 'LAN Port' + } + } + } + } + } + enumerateEthernetPortSettingsSpy.mockResolvedValue(mockEnumResponse as any) + + const result = await device.validateWiFiPort('Intel(r) AMT Ethernet Port Settings 0') + + expect(result).not.toBeNull() + expect(result?.isWiFi).toBe(false) + expect(result?.connectionType).toBe(0) + }) + + it('should identify Thunderbolt as non-WiFi (PhysicalConnectionType=2)', async () => { + const mockEnumResponse = { + Body: { + PullResponse: { + Items: { + AMT_EthernetPortSettings: { + InstanceID: 'Intel(r) AMT Ethernet Port Settings 2', + PhysicalConnectionType: '2', // Thunderbolt + ElementName: 'Thunderbolt' + } + } + } + } + } + enumerateEthernetPortSettingsSpy.mockResolvedValue(mockEnumResponse as any) + + const result = await device.validateWiFiPort('Intel(r) AMT Ethernet Port Settings 2') + + expect(result?.isWiFi).toBe(false) + expect(result?.connectionType).toBe(2) + }) + + it('should return null when instanceID not found', async () => { + const mockEnumResponse = { + Body: { + PullResponse: { + Items: { + AMT_EthernetPortSettings: { + InstanceID: 'Intel(r) AMT Ethernet Port Settings 0', + PhysicalConnectionType: '0' + } + } + } + } + } + enumerateEthernetPortSettingsSpy.mockResolvedValue(mockEnumResponse as any) + + const result = await device.validateWiFiPort('NonExistent Instance') + + expect(result).toBeNull() + }) + + it('should reject setLinkPreference on non-WiFi port', async () => { + const mockEnumResponse = { + Body: { + PullResponse: { + Items: { + AMT_EthernetPortSettings: { + InstanceID: 'Intel(r) AMT Ethernet Port Settings 0', + PhysicalConnectionType: '0' // Integrated LAN + } + } + } + } + } + enumerateEthernetPortSettingsSpy.mockResolvedValue(mockEnumResponse as any) + + const result = await device.setEthernetLinkPreference(1, 300, 'Intel(r) AMT Ethernet Port Settings 0') + + expect(result?.Body?.Fault).toBeDefined() + expect(result?.Body?.Fault?.Code?.Value).toBe('ValidationError') + expect(result?.Body?.Fault?.Reason?.Text).toContain('only applicable for WiFi ports') + expect(result?.Body?.Fault?.Reason?.Text).toContain('PhysicalConnectionType=0') + }) + + it('should allow setLinkPreference on WiFi port', async () => { + const mockEnumResponse = { + Body: { + PullResponse: { + Items: { + AMT_EthernetPortSettings: { + InstanceID: 'Intel(r) AMT Ethernet Port Settings 1', + PhysicalConnectionType: '3' // Wireless LAN + } + } + } + } + } + enumerateEthernetPortSettingsSpy.mockResolvedValue(mockEnumResponse as any) + getSpy.mockResolvedValue({ Envelope: { Body: { SetLinkPreference_OUTPUT: { ReturnValue: '0' } } } }) + + const result = await device.setEthernetLinkPreference(1, 300, 'Intel(r) AMT Ethernet Port Settings 1') + + expect(result?.Body?.Fault).toBeUndefined() + expect(getSpy).toHaveBeenCalled() + }) + }) }) diff --git a/src/routes/amt/enumerateEthernetPortSettings.test.ts b/src/routes/amt/enumerateEthernetPortSettings.test.ts new file mode 100644 index 000000000..fc2d909f6 --- /dev/null +++ b/src/routes/amt/enumerateEthernetPortSettings.test.ts @@ -0,0 +1,118 @@ +/********************************************************************* + * Copyright (c) Intel Corporation 2025 + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +import { type SpyInstance, spyOn } from 'jest-mock' +import { CIRAHandler } from '../../amt/CIRAHandler.js' +import { DeviceAction } from '../../amt/DeviceAction.js' +import { HttpHandler } from '../../amt/HttpHandler.js' +import { messages } from '../../logging/index.js' +import { createSpyObj } from '../../test/helper/jest.js' +import { ErrorResponse } from '../../utils/amtHelper.js' +import { MqttProvider } from '../../utils/MqttProvider.js' +import { enumerateEthernetPortSettings } from './enumerateEthernetPortSettings.js' + +describe('Enumerate Ethernet Port Settings', () => { + let req: Express.Request + let resSpy + let mqttSpy: SpyInstance + let enumerateEthernetPortSettingsSpy: SpyInstance + let device: DeviceAction + let mockEnumerateResponse + + beforeEach(() => { + const handler = new CIRAHandler(new HttpHandler(), 'admin', 'P@ssw0rd') + device = new DeviceAction(handler, null) + + mockEnumerateResponse = { + Body: { + PullResponse: { + Items: { + AMT_EthernetPortSettings: [ + { + InstanceID: 'Intel(r) AMT Ethernet Port Settings 0', + ElementName: 'Intel(r) AMT Ethernet Port Settings', + MACAddress: 'a4-ae-11-1c-02-4d', + LinkIsUp: true, + LinkPolicy: [1, 14, 16], + LinkPreference: 2, + LinkControl: 2 + }, + { + InstanceID: 'Intel(r) AMT Ethernet Port Settings 1', + ElementName: 'Intel(r) AMT Ethernet Port Settings', + MACAddress: 'a4-ae-11-1c-02-4e', + LinkIsUp: false, + LinkPolicy: [1, 14], + LinkPreference: 1, + LinkControl: 1 + } + ] + } + } + } + } + + req = { + params: { + guid: '123456' + }, + deviceAction: device + } + resSpy = createSpyObj('Response', [ + 'status', + 'json', + 'end', + 'send' + ]) + resSpy.status.mockReturnThis() + resSpy.json.mockReturnThis() + resSpy.send.mockReturnThis() + mqttSpy = spyOn(MqttProvider, 'publishEvent') + enumerateEthernetPortSettingsSpy = spyOn(device, 'enumerateEthernetPortSettings').mockResolvedValue(mockEnumerateResponse as any) + }) + + it('should enumerate all ethernet port settings', async () => { + await enumerateEthernetPortSettings(req as any, resSpy) + + expect(enumerateEthernetPortSettingsSpy).toHaveBeenCalled() + expect(mqttSpy).toHaveBeenCalledWith('success', ['AMT_EthernetPortSettings'], 'Ethernet Port Settings enumerated') + expect(resSpy.status).toHaveBeenCalledWith(200) + expect(resSpy.json).toHaveBeenCalledWith(mockEnumerateResponse.Body.PullResponse.Items.AMT_EthernetPortSettings) + }) + + it('should handle single port in enumerate response', async () => { + const singlePortResponse = { + Body: { + PullResponse: { + Items: { + AMT_EthernetPortSettings: { + InstanceID: 'Intel(r) AMT Ethernet Port Settings 0', + ElementName: 'Intel(r) AMT Ethernet Port Settings', + MACAddress: 'a4-ae-11-1c-02-4d' + } + } + } + } + } + enumerateEthernetPortSettingsSpy.mockResolvedValue(singlePortResponse as any) + + await enumerateEthernetPortSettings(req as any, resSpy) + + expect(resSpy.status).toHaveBeenCalledWith(200) + // Should wrap single object in array + expect(resSpy.json).toHaveBeenCalled() + }) + + it('should handle errors gracefully', async () => { + const errorMessage = 'Failed to enumerate ethernet port settings' + enumerateEthernetPortSettingsSpy.mockRejectedValue(new Error(errorMessage)) + + await enumerateEthernetPortSettings(req as any, resSpy) + + expect(mqttSpy).toHaveBeenCalledWith('fail', ['AMT_EthernetPortSettings'], messages.INTERNAL_SERVICE_ERROR) + expect(resSpy.status).toHaveBeenCalledWith(500) + expect(resSpy.json).toHaveBeenCalledWith(ErrorResponse(500, 'Exception during Enumerate Ethernet Port Settings')) + }) +}) diff --git a/src/routes/amt/enumerateEthernetPortSettings.ts b/src/routes/amt/enumerateEthernetPortSettings.ts new file mode 100644 index 000000000..06a00f4b2 --- /dev/null +++ b/src/routes/amt/enumerateEthernetPortSettings.ts @@ -0,0 +1,32 @@ +/********************************************************************* + * Copyright (c) Intel Corporation 2025 + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +import { type Response, type Request } from 'express' +import { logger, messages } from '../../logging/index.js' +import { ErrorResponse } from '../../utils/amtHelper.js' +import { MqttProvider } from '../../utils/MqttProvider.js' +import { type DeviceAction } from '../../amt/DeviceAction.js' + +export async function enumerateEthernetPortSettings(req: Request, res: Response): Promise { + try { + const guid: string = req.params.guid + const deviceAction: DeviceAction = req.deviceAction as DeviceAction + + logger.debug(`Enumerate Ethernet Port Settings for ${guid}`) + const result = await deviceAction.enumerateEthernetPortSettings() + + // Extract the AMT_EthernetPortSettings from the response + const settings = (result?.Body?.PullResponse?.Items as any)?.AMT_EthernetPortSettings + // Ensure settings is always an array + const settingsArray = Array.isArray(settings) ? settings : (settings ? [settings] : []) + + MqttProvider.publishEvent('success', ['AMT_EthernetPortSettings'], 'Ethernet Port Settings enumerated') + res.status(200).json(settingsArray).end() + } catch (error) { + logger.error(`Exception during Enumerate Ethernet Port Settings: ${error}`) + MqttProvider.publishEvent('fail', ['AMT_EthernetPortSettings'], messages.INTERNAL_SERVICE_ERROR) + res.status(500).json(ErrorResponse(500, 'Exception during Enumerate Ethernet Port Settings')).end() + } +} diff --git a/src/routes/amt/getEthernetPortSettings.test.ts b/src/routes/amt/getEthernetPortSettings.test.ts new file mode 100644 index 000000000..5742a1a4b --- /dev/null +++ b/src/routes/amt/getEthernetPortSettings.test.ts @@ -0,0 +1,94 @@ +/********************************************************************* + * Copyright (c) Intel Corporation 2025 + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +import { type SpyInstance, spyOn } from 'jest-mock' +import { CIRAHandler } from '../../amt/CIRAHandler.js' +import { DeviceAction } from '../../amt/DeviceAction.js' +import { HttpHandler } from '../../amt/HttpHandler.js' +import { messages } from '../../logging/index.js' +import { createSpyObj } from '../../test/helper/jest.js' +import { ErrorResponse } from '../../utils/amtHelper.js' +import { MqttProvider } from '../../utils/MqttProvider.js' +import { getEthernetPortSettings } from './getEthernetPortSettings.js' + +describe('Get Ethernet Port Settings', () => { + let req + let resSpy + let mqttSpy: SpyInstance + let getEthernetPortSettingsSpy: SpyInstance + let device: DeviceAction + let mockEthernetPortSettings + + beforeEach(() => { + const handler = new CIRAHandler(new HttpHandler(), 'admin', 'P@ssw0rd') + device = new DeviceAction(handler, null) + + mockEthernetPortSettings = { + AMT_EthernetPortSettings: { + InstanceID: 'Intel(r) AMT Ethernet Port Settings 0', + ElementName: 'Intel(r) AMT Ethernet Port Settings', + MACAddress: 'a4-ae-11-1c-02-4d', + LinkIsUp: true, + LinkPolicy: [1, 14, 16], + LinkPreference: 2, + LinkControl: 2, + SharedMAC: true, + SharedStaticIp: false, + SharedDynamicIP: true, + IpSyncEnabled: true, + DHCPEnabled: true, + PhysicalConnectionType: 0 + } + } + + req = { + params: { + guid: '123456' + }, + query: {}, + deviceAction: device + } + resSpy = createSpyObj('Response', [ + 'status', + 'json', + 'end', + 'send' + ]) + resSpy.status.mockReturnThis() + resSpy.json.mockReturnThis() + resSpy.send.mockReturnThis() + mqttSpy = spyOn(MqttProvider, 'publishEvent') + getEthernetPortSettingsSpy = spyOn(device, 'getEthernetPortSettings').mockResolvedValue(mockEthernetPortSettings as any) + }) + + it('should get ethernet port settings with default instanceID', async () => { + await getEthernetPortSettings(req as any, resSpy) + + expect(getEthernetPortSettingsSpy).toHaveBeenCalledWith(undefined) + expect(mqttSpy).toHaveBeenCalledWith('success', ['AMT_EthernetPortSettings'], 'Ethernet Port Settings retrieved') + expect(resSpy.status).toHaveBeenCalledWith(200) + expect(resSpy.json).toHaveBeenCalledWith(mockEthernetPortSettings) + }) + + it('should get ethernet port settings with custom instanceID', async () => { + req.query.instanceID = 'Intel(r) AMT Ethernet Port Settings 1' + + await getEthernetPortSettings(req as any, resSpy) + + expect(getEthernetPortSettingsSpy).toHaveBeenCalledWith('Intel(r) AMT Ethernet Port Settings 1') + expect(resSpy.status).toHaveBeenCalledWith(200) + }) + + it('should handle errors gracefully', async () => { + const errorMessage = 'Failed to get ethernet port settings' + getEthernetPortSettingsSpy.mockRejectedValue(new Error(errorMessage)) + + await getEthernetPortSettings(req as any, resSpy) + + expect(mqttSpy).toHaveBeenCalledWith('fail', ['AMT_EthernetPortSettings'], messages.INTERNAL_SERVICE_ERROR) + expect(resSpy.status).toHaveBeenCalledWith(500) + expect(resSpy.json).toHaveBeenCalledWith(ErrorResponse(500, 'Exception during Get Ethernet Port Settings')) + }) +}) diff --git a/src/routes/amt/getEthernetPortSettings.ts b/src/routes/amt/getEthernetPortSettings.ts new file mode 100644 index 000000000..25c2fa8ac --- /dev/null +++ b/src/routes/amt/getEthernetPortSettings.ts @@ -0,0 +1,27 @@ +/********************************************************************* + * Copyright (c) Intel Corporation 2025 + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +import { type Response, type Request } from 'express' +import { logger, messages } from '../../logging/index.js' +import { ErrorResponse } from '../../utils/amtHelper.js' +import { MqttProvider } from '../../utils/MqttProvider.js' +import { type DeviceAction } from '../../amt/DeviceAction.js' + +export async function getEthernetPortSettings(req: Request, res: Response): Promise { + try { + const guid: string = req.params.guid + const instanceID: string | undefined = req.query.instanceID as string + const deviceAction: DeviceAction = req.deviceAction as DeviceAction + + logger.debug(`Get Ethernet Port Settings for ${guid}, instanceID: ${instanceID ?? 'default'}`) + const result = await deviceAction.getEthernetPortSettings(instanceID) + MqttProvider.publishEvent('success', ['AMT_EthernetPortSettings'], 'Ethernet Port Settings retrieved') + res.status(200).json(result).end() + } catch (error) { + logger.error(`Exception during Get Ethernet Port Settings: ${error}`) + MqttProvider.publishEvent('fail', ['AMT_EthernetPortSettings'], messages.INTERNAL_SERVICE_ERROR) + res.status(500).json(ErrorResponse(500, 'Exception during Get Ethernet Port Settings')).end() + } +} diff --git a/src/routes/amt/index.ts b/src/routes/amt/index.ts index 3fb6e78bf..f80cc46c7 100644 --- a/src/routes/amt/index.ts +++ b/src/routes/amt/index.ts @@ -38,6 +38,10 @@ import { validator } from './kvm/validator.js' import { get } from 'http' import { getScreenSettingData } from './kvm/get.js' import { setKVMRedirectionSettingData } from './kvm/set.js' +import { setLinkPreference } from './linkPreference.js' +import { linkPreferenceValidator } from './linkPreferenceValidator.js' +import { getEthernetPortSettings } from './getEthernetPortSettings.js' +import { enumerateEthernetPortSettings } from './enumerateEthernetPortSettings.js' const amtRouter: Router = Router() @@ -68,4 +72,11 @@ amtRouter.post('/certificates/:guid', certValidator(), validateMiddleware, ciraM amtRouter.get('/kvm/displays/:guid', ciraMiddleware, getScreenSettingData) amtRouter.put('/kvm/displays/:guid', validator(), ciraMiddleware, setKVMRedirectionSettingData) +// Ethernet Port Settings +amtRouter.get('/network/ethernetPortSettings/:guid', ciraMiddleware, getEthernetPortSettings) +amtRouter.get('/network/ethernetPortSettings/enumerate/:guid', ciraMiddleware, enumerateEthernetPortSettings) + +// Link Preference (ME/HOST) +amtRouter.post('/network/linkPreference/:guid', linkPreferenceValidator(), validateMiddleware, ciraMiddleware, setLinkPreference) + export default amtRouter diff --git a/src/routes/amt/linkPreference.test.ts b/src/routes/amt/linkPreference.test.ts new file mode 100644 index 000000000..c3d653295 --- /dev/null +++ b/src/routes/amt/linkPreference.test.ts @@ -0,0 +1,219 @@ +/********************************************************************* + * Copyright (c) Intel Corporation 2025 + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +import { type SpyInstance, spyOn } from 'jest-mock' +import { CIRAHandler } from '../../amt/CIRAHandler.js' +import { DeviceAction } from '../../amt/DeviceAction.js' +import { HttpHandler } from '../../amt/HttpHandler.js' +import { messages } from '../../logging/index.js' +import { createSpyObj } from '../../test/helper/jest.js' +import { ErrorResponse } from '../../utils/amtHelper.js' +import { MqttProvider } from '../../utils/MqttProvider.js' +import { setLinkPreference } from './linkPreference.js' + +describe('Link Preference', () => { + let req + let resSpy + let mqttSpy: SpyInstance + let setEthernetLinkPreferenceSpy: SpyInstance + let device: DeviceAction + + beforeEach(() => { + const handler = new CIRAHandler(new HttpHandler(), 'admin', 'P@ssw0rd') + device = new DeviceAction(handler, null) + req = { + params: { + guid: '123456' + }, + body: { + linkPreference: 1, + timeout: 300 + }, + query: {}, + deviceAction: device + } + resSpy = createSpyObj('Response', [ + 'status', + 'json', + 'end', + 'send' + ]) + resSpy.status.mockReturnThis() + resSpy.json.mockReturnThis() + resSpy.send.mockReturnThis() + mqttSpy = spyOn(MqttProvider, 'publishEvent') + // Mock with _detectedInstanceID to simulate auto-detection fallback + setEthernetLinkPreferenceSpy = spyOn(device, 'setEthernetLinkPreference').mockResolvedValue({ + _detectedInstanceID: 'Intel(r) AMT Ethernet Port Settings 0' + } as any) + }) + + it('should set link preference to ME (1) with timeout', async () => { + req.body.linkPreference = 1 + req.body.timeout = 300 + + await setLinkPreference(req as any, resSpy) + + expect(setEthernetLinkPreferenceSpy).toHaveBeenCalledWith(1, 300, undefined) + expect(mqttSpy).toHaveBeenCalledWith('success', ['AMT_LinkPreference'], 'Link Preference set to ME') + expect(resSpy.status).toHaveBeenCalledWith(200) + expect(resSpy.json).toHaveBeenCalledWith({ + status: 'Link Preference set to ME', + linkPreference: 1, + timeout: 300, + instanceID: 'Intel(r) AMT Ethernet Port Settings 0' + }) + }) + + it('should set link preference to HOST (2) with timeout', async () => { + req.body.linkPreference = 2 + req.body.timeout = 600 + + await setLinkPreference(req as any, resSpy) + + expect(setEthernetLinkPreferenceSpy).toHaveBeenCalledWith(2, 600, undefined) + expect(mqttSpy).toHaveBeenCalledWith('success', ['AMT_LinkPreference'], 'Link Preference set to HOST') + expect(resSpy.status).toHaveBeenCalledWith(200) + expect(resSpy.json).toHaveBeenCalledWith({ + status: 'Link Preference set to HOST', + linkPreference: 2, + timeout: 600, + instanceID: 'Intel(r) AMT Ethernet Port Settings 0' + }) + }) + + it('should set link preference with custom instanceID', async () => { + req.body.linkPreference = 1 + req.body.timeout = 120 + req.query.instanceID = 'Intel(r) AMT Ethernet Port Settings 1' + + // Mock response with the custom instanceID + setEthernetLinkPreferenceSpy.mockResolvedValue({ + _detectedInstanceID: 'Intel(r) AMT Ethernet Port Settings 1' + } as any) + + await setLinkPreference(req as any, resSpy) + + expect(setEthernetLinkPreferenceSpy).toHaveBeenCalledWith(1, 120, 'Intel(r) AMT Ethernet Port Settings 1') + expect(resSpy.json).toHaveBeenCalledWith({ + status: 'Link Preference set to ME', + linkPreference: 1, + timeout: 120, + instanceID: 'Intel(r) AMT Ethernet Port Settings 1' + }) + }) + + it('should set link preference to ME with timeout 0', async () => { + req.body.linkPreference = 1 + req.body.timeout = 0 + + await setLinkPreference(req as any, resSpy) + + expect(setEthernetLinkPreferenceSpy).toHaveBeenCalledWith(1, 0, undefined) + expect(resSpy.json).toHaveBeenCalledWith({ + status: 'Link Preference set to ME', + linkPreference: 1, + timeout: 0, + instanceID: 'Intel(r) AMT Ethernet Port Settings 0' + }) + }) + + it('should set link preference to HOST with timeout 0', async () => { + req.body.linkPreference = 2 + req.body.timeout = 0 + + await setLinkPreference(req as any, resSpy) + + expect(setEthernetLinkPreferenceSpy).toHaveBeenCalledWith(2, 0, undefined) + expect(resSpy.json).toHaveBeenCalledWith({ + status: 'Link Preference set to HOST', + linkPreference: 2, + timeout: 0, + instanceID: 'Intel(r) AMT Ethernet Port Settings 0' + }) + }) + + it('should handle errors gracefully', async () => { + const errorMessage = 'Failed to set link preference' + setEthernetLinkPreferenceSpy.mockRejectedValue(new Error(errorMessage)) + + await setLinkPreference(req as any, resSpy) + + expect(mqttSpy).toHaveBeenCalledWith('fail', ['AMT_LinkPreference'], messages.INTERNAL_SERVICE_ERROR) + expect(resSpy.status).toHaveBeenCalledWith(500) + expect(resSpy.json).toHaveBeenCalledWith(ErrorResponse(500, 'Exception during Set Link Preference')) + }) + + it('should return 400 when attempting to set link preference on non-WiFi port', async () => { + // Mock a validation failure response - the exact message depends on actual port type + const mockFaultResponse = { + Header: {}, + Body: { + Fault: { + Code: { Value: 'ValidationError' }, + Reason: { Text: 'SetLinkPreference is only applicable for WiFi ports' } + } + } + } + setEthernetLinkPreferenceSpy.mockResolvedValue(mockFaultResponse) + + await setLinkPreference(req as any, resSpy) + + // Verify 400 response with the fault message + expect(resSpy.status).toHaveBeenCalledWith(400) + expect(resSpy.json).toHaveBeenCalledWith({ error: mockFaultResponse.Body.Fault.Reason.Text }) + expect(mqttSpy).toHaveBeenCalledWith('fail', ['AMT_LinkPreference'], mockFaultResponse.Body.Fault.Reason.Text) + }) + + it('should return 500 when port validation returns null', async () => { + setEthernetLinkPreferenceSpy.mockResolvedValue(null) + + await setLinkPreference(req as any, resSpy) + + expect(mqttSpy).toHaveBeenCalledWith('fail', ['AMT_LinkPreference'], 'Port validation failed') + expect(resSpy.status).toHaveBeenCalledWith(500) + expect(resSpy.json).toHaveBeenCalledWith(ErrorResponse(500, 'Failed to validate ethernet port')) + }) + + it('should auto-detect WiFi port when instanceID not provided', async () => { + req.query.instanceID = undefined + const mockResponse = { + Body: { SetLinkPreference_OUTPUT: { ReturnValue: '0' } }, + _detectedInstanceID: 'Intel(r) AMT Ethernet Port Settings 1' + } + setEthernetLinkPreferenceSpy.mockResolvedValue(mockResponse) + + await setLinkPreference(req as any, resSpy) + + expect(setEthernetLinkPreferenceSpy).toHaveBeenCalledWith(1, 300, undefined) + expect(resSpy.status).toHaveBeenCalledWith(200) + expect(resSpy.json).toHaveBeenCalledWith({ + status: 'Link Preference set to ME', + linkPreference: 1, + timeout: 300, + instanceID: 'Intel(r) AMT Ethernet Port Settings 1' + }) + }) + + it('should return 400 when no WiFi port found during auto-detection', async () => { + req.query.instanceID = undefined + const mockFaultResponse = { + Body: { + Fault: { + Code: { Value: 'NoWiFiPort' }, + Reason: { Text: 'No WiFi port found on this device. SetLinkPreference requires a WiFi interface (PhysicalConnectionType=3).' } + } + } + } + setEthernetLinkPreferenceSpy.mockResolvedValue(mockFaultResponse) + + await setLinkPreference(req as any, resSpy) + + expect(resSpy.status).toHaveBeenCalledWith(400) + expect(resSpy.json).toHaveBeenCalledWith({ + error: 'No WiFi port found on this device. SetLinkPreference requires a WiFi interface (PhysicalConnectionType=3).' + }) + }) +}) diff --git a/src/routes/amt/linkPreference.ts b/src/routes/amt/linkPreference.ts new file mode 100644 index 000000000..7245d65d0 --- /dev/null +++ b/src/routes/amt/linkPreference.ts @@ -0,0 +1,58 @@ +/********************************************************************* + * Copyright (c) Intel Corporation 2025 + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +import { type Response, type Request } from 'express' +import { logger, messages } from '../../logging/index.js' +import { ErrorResponse } from '../../utils/amtHelper.js' +import { MqttProvider } from '../../utils/MqttProvider.js' +import { type DeviceAction } from '../../amt/DeviceAction.js' + +export async function setLinkPreference(req: Request, res: Response): Promise { + try { + const guid: string = req.params.guid + const linkPreference: number = Number(req.body.linkPreference) + const timeout: number = Number(req.body.timeout) + const instanceID: string | undefined = req.query.instanceID as string + const deviceAction: DeviceAction = req.deviceAction as DeviceAction + + const linkPrefName = linkPreference === 1 ? 'ME' : 'HOST' + const instanceIDParam = instanceID?.trim() === '' ? undefined : instanceID + logger.debug(`Set Link Preference to ${linkPrefName} for ${guid} with timeout ${timeout}s, instanceID: ${instanceIDParam ?? 'auto-detect'}`) + + const result = await deviceAction.setEthernetLinkPreference(linkPreference as 1 | 2, timeout, instanceIDParam) + + // Check if validation failed (non-WiFi port or no WiFi port found) + if (result?.Body?.Fault != null) { + const errorMsg = result.Body.Fault.Reason?.Text ?? 'Validation failed' + logger.error(`Set Link Preference validation failed: ${errorMsg}`) + MqttProvider.publishEvent('fail', ['AMT_LinkPreference'], errorMsg) + res.status(400).json({ error: errorMsg }).end() + return + } + + // Check if result is null (port not found or other error) + if (result == null) { + logger.error('Set Link Preference failed: port validation failed') + MqttProvider.publishEvent('fail', ['AMT_LinkPreference'], 'Port validation failed') + res.status(500).json(ErrorResponse(500, 'Failed to validate ethernet port')).end() + return + } + + // Extract the detected instanceID (if auto-detected) + const detectedInstanceID = (result as any)._detectedInstanceID ?? instanceIDParam ?? 'Unknown' + + MqttProvider.publishEvent('success', ['AMT_LinkPreference'], `Link Preference set to ${linkPrefName}`) + res.status(200).json({ + status: `Link Preference set to ${linkPrefName}`, + linkPreference, + timeout, + instanceID: detectedInstanceID + }).end() + } catch (error) { + logger.error(`Exception during Set Link Preference: ${error}`) + MqttProvider.publishEvent('fail', ['AMT_LinkPreference'], messages.INTERNAL_SERVICE_ERROR) + res.status(500).json(ErrorResponse(500, 'Exception during Set Link Preference')).end() + } +} diff --git a/src/routes/amt/linkPreferenceValidator.ts b/src/routes/amt/linkPreferenceValidator.ts new file mode 100644 index 000000000..4fcf872e0 --- /dev/null +++ b/src/routes/amt/linkPreferenceValidator.ts @@ -0,0 +1,13 @@ +/********************************************************************* + * Copyright (c) Intel Corporation 2025 + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +import { check, query } from 'express-validator' + +export const linkPreferenceValidator = (): any => [ + check('linkPreference').isInt({ min: 1, max: 2 }).withMessage('linkPreference must be 1 (ME) or 2 (HOST)'), + check('timeout').isInt({ min: 0 }).withMessage('timeout must be a non-negative integer'), + query('instanceID').optional().isString() +] + diff --git a/src/utils/tlsConfiguration.test.ts b/src/utils/tlsConfiguration.test.ts index f7e4f5e2c..62bec9d46 100644 --- a/src/utils/tlsConfiguration.test.ts +++ b/src/utils/tlsConfiguration.test.ts @@ -5,6 +5,7 @@ import { web, mps } from './tlsConfiguration.js' import path from 'node:path' +import { fileURLToPath } from 'node:url' import fs from 'node:fs' import { logger } from '../logging/index.js' import { type mpsConfigType, type webConfigType } from '../models/Config.js' @@ -12,6 +13,9 @@ import { constants } from 'node:crypto' import { jest } from '@jest/globals' import { type SpyInstance, spyOn } from 'jest-mock' +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + let existsSyncSpy: SpyInstance let readFileSyncSpy: SpyInstance let jsonParseSpy: SpyInstance diff --git a/swagger.yaml b/swagger.yaml index 788de350b..8034d89bc 100644 --- a/swagger.yaml +++ b/swagger.yaml @@ -739,6 +739,245 @@ paths: $ref: '#/components/schemas/DisconnectErrorResponse' 500: description: 'Internal server error' + /api/v1/amt/network/ethernetPortSettings/{guid}: + get: + summary: Get Specific Ethernet Port Settings + description: | + Retrieve detailed Ethernet Port Settings for a specific port by InstanceID. + + Includes link preference, link policy, MAC address, and other network configurations. + Use `/enumerate/{guid}` first to discover available port InstanceIDs. + tags: + - AMT + parameters: + - name: guid + in: path + description: GUID of device + example: 123e4567-e89b-12d3-a456-426614174000 + required: true + schema: + type: string + - name: instanceID + in: query + description: InstanceID of the Ethernet port (e.g., "Intel(r) AMT Ethernet Port Settings 0" for wired, "Intel(r) AMT Ethernet Port Settings 1" for WiFi) + required: true + schema: + type: string + example: Intel(r) AMT Ethernet Port Settings 1 + responses: + 200: + description: 'Ethernet Port Settings retrieved successfully' + content: + application/json: + schema: + type: object + properties: + InstanceID: + type: string + example: Intel(r) AMT Ethernet Port Settings 0 + ElementName: + type: string + example: Intel(r) AMT Ethernet Port Settings + LinkPreference: + type: integer + description: 1 = ME, 2 = HOST + example: 2 + LinkControl: + type: integer + description: 1 = ME, 2 = HOST + example: 2 + LinkPolicy: + type: array + items: + type: integer + description: Array of link policy values + example: [1, 14, 16] + LinkProtection: + type: integer + example: 1 + SharedMAC: + type: boolean + example: true + MACAddress: + type: string + example: "00:11:22:33:44:55" + 404: + description: 'Device not found' + content: + application/json: + schema: + $ref: '#/components/schemas/DisconnectErrorResponse' + 500: + description: 'Internal server error' + /api/v1/amt/network/ethernetPortSettings/enumerate/{guid}: + get: + summary: Enumerate All Ethernet Port Settings + description: | + Retrieve all Ethernet Port Settings instances for all network ports (wired and wireless). + + **PhysicalConnectionType values**: + - 0 = Integrated LAN + - 1 = Discrete LAN + - 2 = Thunderbolt + - 3 = Wireless LAN (WiFi) + + Use this endpoint to discover available ports and their types before setting link preferences. + tags: + - AMT + parameters: + - name: guid + in: path + description: GUID of device + example: 123e4567-e89b-12d3-a456-426614174000 + required: true + schema: + type: string + responses: + 200: + description: 'All Ethernet Port Settings retrieved successfully' + content: + application/json: + schema: + type: object + properties: + Body: + type: object + properties: + PullResponse: + type: object + properties: + Items: + type: array + items: + type: object + properties: + InstanceID: + type: string + example: Intel(r) AMT Ethernet Port Settings 0 + ElementName: + type: string + example: Intel(r) AMT Ethernet Port Settings + LinkPreference: + type: integer + description: 1 = ME, 2 = HOST + example: 2 + LinkControl: + type: integer + description: 1 = ME, 2 = HOST + example: 2 + MACAddress: + type: string + example: "00:11:22:33:44:55" + PhysicalConnectionType: + type: integer + description: 0=Integrated LAN, 1=Discrete LAN, 2=Thunderbolt, 3=Wireless LAN + example: 3 + LinkIsUp: + type: boolean + example: true + SharedMAC: + type: boolean + example: true + 404: + description: 'Device not found' + content: + application/json: + schema: + $ref: '#/components/schemas/DisconnectErrorResponse' + 500: + description: 'Internal server error' + /api/v1/amt/network/linkPreference/{guid}: + post: + summary: Set WiFi Link Preference + description: | + Set Ethernet Link Preference for WiFi ports only. + - **LinkPreference 1 (ME)**: Routes traffic through Management Engine for the specified timeout duration + - **LinkPreference 2 (HOST)**: Routes traffic through host OS (timeout should be 0) + + **WiFi Port Validation**: This API only works on WiFi ports (PhysicalConnectionType = 3). + It will return a 400 error if attempting to set link preference on wired LAN ports (types 0, 1, or 2). + + **Auto-Detection**: If `instanceID` is not provided, the API will automatically detect and use the first available WiFi port. + tags: + - AMT + parameters: + - name: guid + in: path + description: GUID of device + example: 123e4567-e89b-12d3-a456-426614174000 + required: true + schema: + type: string + - name: instanceID + in: query + description: | + InstanceID of the WiFi Ethernet port (e.g., "Intel(r) AMT Ethernet Port Settings 1"). + If not provided, the API will automatically detect the WiFi port. + Must be a WiFi port (PhysicalConnectionType = 3). + required: false + schema: + type: string + example: Intel(r) AMT Ethernet Port Settings 1 + requestBody: + description: Link preference and timeout settings + required: true + content: + application/json: + schema: + type: object + properties: + linkPreference: + type: integer + description: Link preference - 1 = ME, 2 = HOST + enum: [1, 2] + example: 1 + timeout: + type: integer + description: Timeout in seconds + example: 300 + required: + - linkPreference + - timeout + responses: + 200: + description: 'Link preference set successfully' + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: Link Preference set to ME + linkPreference: + type: integer + example: 1 + timeout: + type: integer + example: 300 + instanceID: + type: string + description: The WiFi port InstanceID that was used (auto-detected or specified) + example: Intel(r) AMT Ethernet Port Settings 1 + 400: + description: 'Validation error - port is not a WiFi port or no WiFi port found' + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: "SetLinkPreference is only applicable for WiFi ports. InstanceID \"Intel(r) AMT Ethernet Port Settings 0\" has PhysicalConnectionType=0 (0=Integrated LAN, 1=Discrete LAN, 2=Thunderbolt). WiFi ports have type 3 (Wireless LAN)." + 404: + description: 'Device not found' + content: + application/json: + schema: + $ref: '#/components/schemas/DisconnectErrorResponse' + 500: + description: 'Internal server error' + /api/v1/amt/screen/{guid}: put: summary: Put the changed settings for KVM in AMT description: Modify screen settings for KVM in AMT device