diff --git a/docker-compose.yml b/docker-compose.yml index 5b1c4f920..23b33417d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,8 +11,10 @@ services: ports: - 3000:3000 depends_on: - - 'vault' - - 'db' + db: + condition: service_healthy + vault: + condition: service_healthy build: context: . dockerfile: ./Dockerfile @@ -35,6 +37,11 @@ services: POSTGRES_PASSWORD: admin123 volumes: - ./data:/docker-entrypoint-initdb.d + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgresadmin -d mpsdb"] + interval: 5s + timeout: 5s + retries: 10 vault: image: hashicorp/vault:1.21 networks: @@ -44,8 +51,14 @@ services: environment: VAULT_DEV_ROOT_TOKEN_ID: myroot VAULT_DEV_LISTEN_ADDRESS: 0.0.0.0:8200 + SKIP_SETCAP: 'true' cap_add: - IPC_LOCK + healthcheck: + test: ["CMD-SHELL", "VAULT_ADDR=http://localhost:8200 vault status"] + interval: 5s + timeout: 5s + retries: 10 consul: restart: always image: hashicorp/consul diff --git a/src/amt/DeviceAction.ts b/src/amt/DeviceAction.ts index 7aad1ad5c..3922f9b11 100644 --- a/src/amt/DeviceAction.ts +++ b/src/amt/DeviceAction.ts @@ -8,6 +8,16 @@ import { type Selector } from '@device-management-toolkit/wsman-messages/WSMan.j import { logger, messages } from '../logging/index.js' import { Certificates, OCRData, type CIRASocket } from '../models/models.js' import { type CIRAHandler } from './CIRAHandler.js' +import { + BOOT_SERVICE_STATE_BOTH_OFF, + BOOT_SERVICE_STATE_RPE_ONLY +} from '../routes/amt/rpeConstants.js' + +// Per-device mutex registry. Keyed by the CIRASocket instance (one per device +// connection) so that concurrent requests to the same device are serialized for +// boot-configuration sequences that must not interleave (sendRPE, bootOptions, +// setAMTFeatures boot-state changes, and power actions). +const deviceLocks = new Map>() export class DeviceAction { ciraHandler: CIRAHandler @@ -116,7 +126,7 @@ export class DeviceAction { return result.Envelope.Body } - async forceBootMode(role: CIM.Types.BootService.Role = 1): Promise { + async forceBootMode(role: CIM.Types.BootService.Role = 1): Promise { logger.silly(`forceBootMode ${messages.REQUEST}`) const bootSource = 'Intel(r) AMT: Boot Configuration 0' const xmlRequestBody = this.cim.BootService.SetBootConfigRole(bootSource, role) @@ -190,15 +200,180 @@ export class DeviceAction { return getResponse.Envelope } - async getPowerCapabilities(): Promise> { - logger.silly(`getPowerCapabilities ${messages.REQUEST}`) + async getBootCapabilities(): Promise> { + logger.silly(`getBootCapabilities ${messages.REQUEST}`) const xmlRequestBody = this.amt.BootCapabilities.Get() const result = await this.ciraHandler.Get(this.ciraSocket, xmlRequestBody) - logger.info(JSON.stringify(result)) - logger.silly(`getPowerCapabilities ${messages.COMPLETE}`) + logger.silly(`getBootCapabilities ${messages.COMPLETE}`) return result.Envelope } + // Backward-compatible alias. Prefer getBootCapabilities for new code. + async getPowerCapabilities(): Promise> { + return await this.getBootCapabilities() + } + + /** + * Acquires a per-device mutual-exclusion lock for the duration of `fn`. + * All callers for the same device (same CIRASocket) are serialized into a + * FIFO queue so that multi-step boot-configuration sequences cannot interleave. + */ + async withDeviceLock(fn: () => Promise): Promise { + const key = this.ciraSocket as object + // Each caller chains onto the current tail of the promise queue. + // `gate` is resolved only when this caller calls `release`, which unblocks + // the next waiter in the queue. + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + const prev = deviceLocks.get(key) ?? Promise.resolve() + deviceLocks.set(key, prev.then(() => gate)) + // Wait for all previous holders to finish before proceeding. + await prev + try { + return await fn() + } finally { + release() + } + } + + async setRPE(isEnabled: boolean): Promise { + logger.silly(`setRPE ${messages.REQUEST}`) + const bootOptions = await this.getBootOptions() + const current = bootOptions.AMT_BootSettingData + // Set all known firmware variants of the RPE enable field for cross-generation compatibility. + if ('RPE' in (current as any)) (current as any).RPE = isEnabled + if ('RPEEnabled' in (current as any)) (current as any).RPEEnabled = isEnabled + if ('PlatformErase' in (current as any)) (current as any).PlatformErase = isEnabled + await this.setBootConfiguration(current) + logger.silly(`setRPE ${messages.COMPLETE}`) + } + + async sendRPE(eraseMask: number, ssdPassword?: string): Promise { + await this.withDeviceLock(async () => { + logger.silly(`sendRPE ${messages.REQUEST}`) + + // CSME sentinel bit: 0x10000 maps to ConfigurationDataReset, not a hardware erase target + const CSME_BIT = 0x10000 + const wantCSMEReset = (eraseMask & CSME_BIT) !== 0 + const tlvMask = eraseMask & ~CSME_BIT + + const xmlIdleMode = this.cim.BootService.RequestStateChange(BOOT_SERVICE_STATE_BOTH_OFF) + const idleResult = await this.ciraHandler.Send(this.ciraSocket, xmlIdleMode) + if (idleResult?.Envelope?.Body?.RequestStateChange_OUTPUT?.ReturnValue !== 0) { + throw new Error(`sendRPE RequestStateChange(${BOOT_SERVICE_STATE_BOTH_OFF}) failed: ${JSON.stringify(idleResult?.Envelope?.Body)}`) + } + + let bootOptions = await this.getBootOptions() + let current = bootOptions.AMT_BootSettingData + const rpeEnabled = (current as any).RPE ?? current.RPEEnabled ?? current.PlatformErase + if (!rpeEnabled) { + await this.setRPE(true) + bootOptions = await this.getBootOptions() + current = bootOptions.AMT_BootSettingData + } + + if (wantCSMEReset && tlvMask === 0) { + await this.changeBootOrder() + } + + const xmlRpeMode = this.cim.BootService.RequestStateChange(BOOT_SERVICE_STATE_RPE_ONLY) + const rscResult = await this.ciraHandler.Send(this.ciraSocket, xmlRpeMode) + if (rscResult?.Envelope?.Body?.RequestStateChange_OUTPUT?.ReturnValue !== 0) { + throw new Error(`sendRPE RequestStateChange(${BOOT_SERVICE_STATE_RPE_ONLY}) failed: ${JSON.stringify(rscResult?.Envelope?.Body)}`) + } + + // Step 2: Build a strict, schema-safe payload with deterministic values. + // Avoid carrying mutable values from GET (for example BIOSSetup=true) that can + // make PUT fail validation on some firmware generations. + const putBody: any = { + InstanceID: current.InstanceID, + ElementName: current.ElementName, + OwningEntity: current.OwningEntity, + BIOSPause: false, + BIOSSetup: false, + BootMediaIndex: 0, + ConfigurationDataReset: wantCSMEReset, + FirmwareVerbosity: 0, + ForcedProgressEvents: false, + IDERBootDevice: 0, + LockKeyboard: false, + LockPowerButton: false, + LockResetButton: false, + LockSleepButton: false, + PlatformErase: tlvMask !== 0, + ReflashBIOS: false, + SecureErase: false, + UseIDER: false, + UseSOL: false, + UseSafeMode: false, + UserPasswordBypass: false + } + + if (tlvMask !== 0) { + // AMT RPE expects an Intel TLV payload for erase targets. + // Parameter format: [vendor:0x8086][type:1][len:4][value:eraseMask] + const buf = Buffer.alloc(12) + buf.writeUInt16LE(0x8086, 0) + buf.writeUInt16LE(1, 2) + buf.writeUInt32LE(4, 4) + buf.writeUInt32LE(tlvMask, 8) + putBody.UefiBootParametersArray = buf.toString('base64') + putBody.UefiBootNumberOfParams = 1 + + if (ssdPassword != null && ssdPassword !== '') { + putBody.RSEPassword = ssdPassword + } + } else { + delete putBody.UefiBootParametersArray + delete putBody.UefiBootNumberOfParams + } + + // Remove conflicting schema variants and read-only fields if present. + delete putBody.UEFIBootParametersArray + delete putBody.UEFIBootNumberOfParams + delete putBody.RPEEnabled + delete putBody.OptionsCleared + delete putBody.BIOSLastStatus + + const xmlPut = this.amt.BootSettingData.Put(putBody as AMT.Models.BootSettingData) + if (process.env.MPS_RPE_LOG_REDACTED_XML != null && process.env.MPS_RPE_LOG_REDACTED_XML !== '') { + const redactedXml = xmlPut.replace(/()([\s\S]*?)(<\/h:RSEPassword>)/g, '$1***$3') + logger.info(`sendRPE BootSettingData PUT XML (redacted): ${redactedXml}`) + } + const putResult = await this.ciraHandler.Send(this.ciraSocket, xmlPut) + if (putResult?.Envelope?.Body?.Fault) { + logger.error(`sendRPE BootSettingData PUT failed: ${JSON.stringify(putResult.Envelope.Body.Fault)}`) + throw new Error(`BootSettingData PUT failed: ${JSON.stringify(putResult.Envelope.Body.Fault)}`) + } + + // Step 4: Activate boot configuration + const forceBootResult = await this.forceBootMode(1) + if (forceBootResult?.Envelope?.Body?.SetBootConfigRole_OUTPUT?.ReturnValue !== 0) { + throw new Error(`sendRPE SetBootConfigRole failed: ${JSON.stringify(forceBootResult?.Envelope?.Body)}`) + } + + // Step 5: Determine the appropriate power action by querying live power state. + // Off states (aligned with Console) → Power On (2): + // 6 = Off - Hard + // 8 = Off - Soft (S5) + // 12 = Off - Soft Graceful + // 13 = Off - Hard Graceful + // All other states (On=2, sleeping=3/4/7, unknown/null) → Master Bus Reset (10) + // so that a connected system is reliably rebooted into the erase sequence. + const powerStateResult = await this.getPowerState() + const currentState = powerStateResult?.PullResponse?.Items?.CIM_AssociatedPowerManagementService?.PowerState + const OFF_STATES = new Set([6, 8, 12, 13]) + const action: CIM.Types.PowerManagementService.PowerState = OFF_STATES.has(Number(currentState)) ? 2 : 10 + logger.info(`sendRPE: dispatching power action=${action}`) + const powerActionResult = await this.sendPowerAction(action) + if (powerActionResult?.Body?.RequestPowerStateChange_OUTPUT?.ReturnValue !== 0) { + throw new Error(`sendRPE power action ${action} failed: ${JSON.stringify(powerActionResult?.Body)}`) + } + + logger.silly(`sendRPE ${messages.COMPLETE}`) + }) // end withDeviceLock + } + async requestUserConsentCode(): Promise> { logger.silly(`requestUserConsentCode ${messages.REQUEST}`) const xmlRequestBody = this.ips.OptInService.StartOptIn() @@ -774,9 +949,7 @@ export class DeviceAction { return result?.Envelope ?? null } - async putWiFiPortConfigurationService( - data: AMT.Models.WiFiPortConfigurationService - ): Promise< + async putWiFiPortConfigurationService(data: AMT.Models.WiFiPortConfigurationService): Promise< | (Common.Models.Envelope<{ AMT_WiFiPortConfigurationService: AMT.Models.WiFiPortConfigurationService }> & { statusCode?: number }) diff --git a/src/amt/deviceAction.test.ts b/src/amt/deviceAction.test.ts index f1ecb5678..b2b200201 100644 --- a/src/amt/deviceAction.test.ts +++ b/src/amt/deviceAction.test.ts @@ -85,7 +85,7 @@ describe('Device Action Tests', () => { enumerateSpy.mockResolvedValueOnce(enumerateResponse) pullSpy.mockResolvedValue(serviceAvailableToElement) const result = await device.getPowerState() - expect(result.PullResponse.Items.CIM_AssociatedPowerManagementService.PowerState).toBe('4') + expect(result.PullResponse.Items.CIM_AssociatedPowerManagementService.PowerState).toBe(4) }) it('should send power action', async () => { getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) @@ -382,12 +382,256 @@ describe('Device Action Tests', () => { expect(result).toEqual(chip.Envelope) }) }) - describe('power capabilities', () => { - it('should get power capabilities', async () => { + + describe('boot capabilities and RPE', () => { + it('should get boot capabilities', async () => { getSpy.mockResolvedValueOnce(bootCapabilities) - const result = await device.getPowerCapabilities() + const result = await device.getBootCapabilities() expect(result).toEqual(bootCapabilities.Envelope) }) + it('should set RPE enabled', async () => { + getSpy.mockResolvedValueOnce({ + Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: false } } } + }) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) + await device.setRPE(true) + expect(getSpy).toHaveBeenCalled() + expect(sendSpy).toHaveBeenCalled() + }) + it('should send remote erase with non-zero mask', async () => { + getSpy.mockResolvedValueOnce({ + Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: true } } } + }) + enumerateSpy.mockResolvedValueOnce(enumerateResponse) + pullSpy.mockResolvedValueOnce(serviceAvailableToElement) + getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32768) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { SetBootConfigRole_OUTPUT: { ReturnValue: 0 } } } }) // forceBootMode(1) + await device.sendRPE(3) + expect(getSpy).toHaveBeenCalled() + expect(sendSpy).toHaveBeenCalled() + }) + it('should send remote erase with zero mask sets PlatformErase to false', async () => { + getSpy.mockResolvedValueOnce({ + Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', PlatformErase: true, RPE: true } } } + }) + enumerateSpy.mockResolvedValueOnce(enumerateResponse) + pullSpy.mockResolvedValueOnce(serviceAvailableToElement) + getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32768) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { SetBootConfigRole_OUTPUT: { ReturnValue: 0 } } } }) // forceBootMode(1) + await device.sendRPE(0) + expect(getSpy).toHaveBeenCalled() + expect(sendSpy).toHaveBeenCalled() + }) + it('should enable RPE before sending remote erase when it is disabled', async () => { + // Call order: + // Send#1: RequestStateChange(32768) + // Get#1: getBootOptions → RPE: false → triggers setRPE path + // Get#2: getBootOptions inside setRPE + // Send#2: setBootConfiguration inside setRPE + // Get#3: getBootOptions re-fetch after setRPE + // Send#3: RequestStateChange(32770) + // Send#4: BootSettingData.Put + // Send#5: forceBootMode(1) + // Enumerate + Pull: getPowerState + // Get#4: sendPowerAction + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32768) + getSpy.mockResolvedValueOnce({ + Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: false } } } + }) // initial getBootOptions → RPE disabled + getSpy.mockResolvedValueOnce({ + Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: false } } } + }) // getBootOptions inside setRPE + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // setBootConfiguration inside setRPE + getSpy.mockResolvedValueOnce({ + Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: true } } } + }) // getBootOptions re-fetch after setRPE + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // BootSettingData.Put + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { SetBootConfigRole_OUTPUT: { ReturnValue: 0 } } } }) // forceBootMode(1) + enumerateSpy.mockResolvedValueOnce(enumerateResponse) + pullSpy.mockResolvedValueOnce(serviceAvailableToElement) + getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) + + await device.sendRPE(3) + + expect(sendSpy).toHaveBeenCalledTimes(5) + }) + it('should clear boot order only for CSME-only remote erase requests', async () => { + const changeBootOrderSpy = vi.spyOn(device, 'changeBootOrder').mockResolvedValue({}) + getSpy.mockResolvedValueOnce({ + Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: true } } } + }) + enumerateSpy.mockResolvedValueOnce(enumerateResponse) + pullSpy.mockResolvedValueOnce(serviceAvailableToElement) + getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32768) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { SetBootConfigRole_OUTPUT: { ReturnValue: 0 } } } }) // forceBootMode(1) + + await device.sendRPE(0x10000) + + expect(changeBootOrderSpy).toHaveBeenCalledTimes(1) + }) + it('should preserve boot order for combined CSME and hardware remote erase requests', async () => { + const changeBootOrderSpy = vi.spyOn(device, 'changeBootOrder').mockResolvedValue({}) + getSpy.mockResolvedValueOnce({ + Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: true } } } + }) + enumerateSpy.mockResolvedValueOnce(enumerateResponse) + pullSpy.mockResolvedValueOnce(serviceAvailableToElement) + getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32768) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { SetBootConfigRole_OUTPUT: { ReturnValue: 0 } } } }) // forceBootMode(1) + + await device.sendRPE(0x10004) + + expect(changeBootOrderSpy).not.toHaveBeenCalled() + }) + it('should serialize hardware erase mask with UEFI boot parameter elements', async () => { + getSpy.mockResolvedValueOnce({ + Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: true } } } + }) + enumerateSpy.mockResolvedValueOnce(enumerateResponse) + pullSpy.mockResolvedValueOnce(serviceAvailableToElement) + getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32768) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { SetBootConfigRole_OUTPUT: { ReturnValue: 0 } } } }) // forceBootMode(1) + + await device.sendRPE(0x4) + + const putXml = sendSpy.mock.calls[2][1] as string + expect(putXml).toContain('UefiBootParametersArray') + expect(putXml).toContain('UefiBootNumberOfParams') + expect(putXml).not.toContain('UEFIBootParametersArray') + expect(putXml).not.toContain('UEFIBootNumberOfParams') + expect(putXml).toContain('hoABAAQAAAAEAAAA') + const paramMatches = putXml.match(//g) ?? [] + expect(paramMatches.length).toBe(1) + expect(putXml).toContain('1') + }) + it('should encode combined hardware erase bits as a single Uefi parameter mask', async () => { + getSpy.mockResolvedValueOnce({ + Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: true } } } + }) + enumerateSpy.mockResolvedValueOnce(enumerateResponse) + pullSpy.mockResolvedValueOnce(serviceAvailableToElement) + getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32768) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { SetBootConfigRole_OUTPUT: { ReturnValue: 0 } } } }) // forceBootMode(1) + + await device.sendRPE(0x04000040) + + const putXml = sendSpy.mock.calls[2][1] as string + expect(putXml).toContain('hoABAAQAAABAAAAE') + expect(putXml).toContain('1') + expect(putXml).toContain('false') + expect(putXml).toContain('false') + }) + it('should include RSEPassword when provided for encrypted SSD erase', async () => { + getSpy.mockResolvedValueOnce({ + Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: true } } } + }) + enumerateSpy.mockResolvedValueOnce(enumerateResponse) + pullSpy.mockResolvedValueOnce(serviceAvailableToElement) + getSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } }) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32768) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { SetBootConfigRole_OUTPUT: { ReturnValue: 0 } } } }) // forceBootMode(1) + + await device.sendRPE(0x4, 'mypassword') + + const putXml = sendSpy.mock.calls[2][1] as string + expect(putXml).toContain('mypassword') + }) + it('should query live power state and send Power On (2) when system is off (state 8)', async () => { + const sendPowerActionSpy = vi.spyOn(device, 'sendPowerAction').mockResolvedValue({ Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } as any) + getSpy.mockResolvedValueOnce({ + Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: true } } } + }) + // getPowerState fallback: enumerate + pull returning PowerState '8' (off) + enumerateSpy.mockResolvedValueOnce(enumerateResponse) + pullSpy.mockResolvedValueOnce({ + Envelope: { + Body: { + PullResponse: { + Items: { CIM_AssociatedPowerManagementService: { PowerState: 8 } } + } + } + } + }) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32768) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { SetBootConfigRole_OUTPUT: { ReturnValue: 0 } } } }) // forceBootMode(1) + + await device.sendRPE(0x4) + + expect(sendPowerActionSpy).toHaveBeenCalledWith(2) + }) + it('should query live power state and send Power On (2) when system is off-hard (state 6)', async () => { + const sendPowerActionSpy = vi.spyOn(device, 'sendPowerAction').mockResolvedValue({ Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } as any) + getSpy.mockResolvedValueOnce({ + Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: true } } } + }) + // getPowerState fallback: enumerate + pull returning PowerState '6' (off-hard) + enumerateSpy.mockResolvedValueOnce(enumerateResponse) + pullSpy.mockResolvedValueOnce({ + Envelope: { + Body: { + PullResponse: { + Items: { CIM_AssociatedPowerManagementService: { PowerState: 6 } } + } + } + } + }) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32768) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { SetBootConfigRole_OUTPUT: { ReturnValue: 0 } } } }) // forceBootMode(1) + + await device.sendRPE(0x4) + + expect(sendPowerActionSpy).toHaveBeenCalledWith(2) + }) + it('should query live power state and send Master Bus Reset (10) when system is on (state 2)', async () => { + const sendPowerActionSpy = vi.spyOn(device, 'sendPowerAction').mockResolvedValue({ Body: { RequestPowerStateChange_OUTPUT: { ReturnValue: 0 } } } as any) + getSpy.mockResolvedValueOnce({ + Envelope: { Body: { AMT_BootSettingData: { ElementName: 'test', RPESupported: true, RPE: true } } } + }) + // getPowerState fallback: enumerate + pull returning PowerState '2' (on) + enumerateSpy.mockResolvedValueOnce(enumerateResponse) + pullSpy.mockResolvedValueOnce({ + Envelope: { + Body: { + PullResponse: { + Items: { CIM_AssociatedPowerManagementService: { PowerState: 2 } } + } + } + } + }) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32768) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { RequestStateChange_OUTPUT: { ReturnValue: 0 } } } }) // RequestStateChange(32770) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: {} } }) // Put(putBody) + sendSpy.mockResolvedValueOnce({ Envelope: { Body: { SetBootConfigRole_OUTPUT: { ReturnValue: 0 } } } }) // forceBootMode(1) + + await device.sendRPE(0x4) + + expect(sendPowerActionSpy).toHaveBeenCalledWith(10) + }) }) describe('alarm occurrences', () => { it('should return null when enumerate call to getAlarmClockOccurrences fails', async () => { @@ -943,4 +1187,48 @@ describe('Device Action Tests', () => { expect(result).toBeNull() }) }) + + describe('withDeviceLock', () => { + it('serializes concurrent callers for the same device', async () => { + const order: number[] = [] + const p1 = device.withDeviceLock(async () => { + await new Promise((r) => setTimeout(r, 20)) + order.push(1) + }) + const p2 = device.withDeviceLock(async () => { + order.push(2) + }) + await Promise.all([p1, p2]) + expect(order).toEqual([1, 2]) + }) + + it('releases the lock when the callback throws', async () => { + await expect( + device.withDeviceLock(async () => { + throw new Error('boom') + }) + ).rejects.toThrow('boom') + + // A subsequent caller must not be blocked forever + const result = await device.withDeviceLock(async () => 'ok') + expect(result).toBe('ok') + }) + + it('allows different devices to run concurrently', async () => { + const handler2 = new CIRAHandler(new HttpHandler(), 'admin', 'P@ssw0rd') + const device2 = new DeviceAction(handler2, {} as any) + + const order: number[] = [] + const p1 = device.withDeviceLock(async () => { + await new Promise((r) => setTimeout(r, 20)) + order.push(1) + }) + const p2 = device2.withDeviceLock(async () => { + order.push(2) + }) + await Promise.all([p1, p2]) + // device2 should not be blocked by device — it runs before device1 finishes + expect(order).toEqual([2, 1]) + }) + }) }) diff --git a/src/logging/messages.ts b/src/logging/messages.ts index a1dfffa13..1caca00e0 100644 --- a/src/logging/messages.ts +++ b/src/logging/messages.ts @@ -186,6 +186,9 @@ export enum messages { POWER_CAPABILITIES_REQUESTED = 'Power Capabilities requested', POWER_CAPABILITIES_SUCCESS = 'Power Capabilities received', POWER_CAPABILITIES_EXCEPTION = 'Exception during Power Capabilities request', + BOOT_CAPABILITIES_REQUESTED = 'Boot Capabilities requested', + BOOT_CAPABILITIES_SUCCESS = 'Boot Capabilities received', + BOOT_CAPABILITIES_EXCEPTION = 'Exception during Boot Capabilities request', REDIRECT_FORWARD_DATA_EXCEPTION = 'Exception while forwarding data to client', REDIRECT_CLOSING_WEBSOCKET_EXCEPTION = 'Exception while closing client websocket connection', REDIRECT_OPENING_WEB_SOCKET = 'Opening web socket connection', diff --git a/src/models/models.ts b/src/models/models.ts index 64477b484..b464b0a74 100644 --- a/src/models/models.ts +++ b/src/models/models.ts @@ -227,3 +227,11 @@ export interface OCRProcessResult { HTTPSBootSupported: boolean OCR: boolean } + +// RPE = Remote Platform Erase +export interface RPECapabilities { + secureEraseAllSSDs: boolean + tpmClear: boolean + restoreBIOSToEOM: boolean + unconfigureCSME: boolean +} diff --git a/src/routes/amt/amtFeatureValidator.ts b/src/routes/amt/amtFeatureValidator.ts index 342edd8e9..f502c3197 100644 --- a/src/routes/amt/amtFeatureValidator.ts +++ b/src/routes/amt/amtFeatureValidator.ts @@ -16,5 +16,6 @@ export const amtFeaturesValidator = (): any => [ check('enableSOL').isBoolean().toBoolean(), check('enableIDER').isBoolean().toBoolean(), check('enableKVM').isBoolean().toBoolean(), - check('ocr').optional().isBoolean().toBoolean() + check('ocr').optional().isBoolean().toBoolean(), + check('platformEraseEnabled').optional().isBoolean().toBoolean() ] diff --git a/src/routes/amt/bootOptions.ts b/src/routes/amt/bootOptions.ts index 89891cf71..ffcd2bb29 100644 --- a/src/routes/amt/bootOptions.ts +++ b/src/routes/amt/bootOptions.ts @@ -68,29 +68,31 @@ export async function bootOptions(req: Request, res: Response): Promise { const guid = req.params?.guid || '' const bootSource = await getBootSource(guid, payload, device) - const results = await device.getBootOptions() - const bootData = setBootData(payload.action as number, payload.useSOL as boolean, results.AMT_BootSettingData) + await device.withDeviceLock(async () => { + const results = await device.getBootOptions() + const bootData = setBootData(payload.action as number, payload.useSOL as boolean, results.AMT_BootSettingData) - await determineBootDevice(payload, bootData) + await determineBootDevice(payload, bootData) - await device.changeBootOrder(null) + await device.changeBootOrder(null) - await device.setBootConfiguration(bootData) + await device.setBootConfiguration(bootData) - // set boot config role - await device.forceBootMode(1) + // set boot config role + await device.forceBootMode(1) - await device.changeBootOrder(bootSource as unknown as CIM.Types.BootConfigSetting.InstanceID) + await device.changeBootOrder(bootSource as unknown as CIM.Types.BootConfigSetting.InstanceID) - const newAction = determinePowerAction(payload.action as number) + const newAction = determinePowerAction(payload.action as number) - const powerActionResult = await device.sendPowerAction(newAction) - powerActionResult.Body.RequestPowerStateChange_OUTPUT.ReturnValueStr = AMTStatusToString( - powerActionResult.Body.RequestPowerStateChange_OUTPUT.ReturnValue as number - ) - powerActionResult.Body = powerActionResult.Body.RequestPowerStateChange_OUTPUT + const powerActionResult = await device.sendPowerAction(newAction) + powerActionResult.Body.RequestPowerStateChange_OUTPUT.ReturnValueStr = AMTStatusToString( + powerActionResult.Body.RequestPowerStateChange_OUTPUT.ReturnValue as number + ) + powerActionResult.Body = powerActionResult.Body.RequestPowerStateChange_OUTPUT - res.status(200).json(powerActionResult) + res.status(200).json(powerActionResult) + }) } catch (error) { logger.error(`${messages.BOOT_SETTING_EXCEPTION} : ${error}`) MqttProvider.publishEvent('fail', ['AMT_BootSettingData'], messages.INTERNAL_SERVICE_ERROR) diff --git a/src/routes/amt/getAMTFeatures.test.ts b/src/routes/amt/getAMTFeatures.test.ts index 4ee6e7b8a..a832b402f 100644 --- a/src/routes/amt/getAMTFeatures.test.ts +++ b/src/routes/amt/getAMTFeatures.test.ts @@ -140,7 +140,8 @@ describe('get amt features', () => { ForcedProgressEvents: true, IDER: true, InstanceID: 'Intel(r) AMT:BootCapabilities 0', - SOL: true + SOL: true, + PlatformErase: 3 } } }, @@ -155,7 +156,8 @@ describe('get amt features', () => { IDERBootDevice: 0, InstanceID: 'Intel(r) AMT:BootSettingData 0', UseIDER: false, - UseSOL: false + UseSOL: false, + RPE: true } } }) @@ -174,7 +176,8 @@ describe('get amt features', () => { httpsBootSupported: true, winREBootSupported: true, localPBABootSupported: false, - remoteErase: false + rpe: true, + rpeSupported: true }) expect(mqttSpy).toHaveBeenCalledTimes(2) }) @@ -270,7 +273,8 @@ describe('get amt features', () => { httpsBootSupported: false, winREBootSupported: false, localPBABootSupported: false, - remoteErase: false + rpe: false, + rpeSupported: false }) }) }) diff --git a/src/routes/amt/getAMTFeatures.ts b/src/routes/amt/getAMTFeatures.ts index d7cfa37bc..28487670b 100644 --- a/src/routes/amt/getAMTFeatures.ts +++ b/src/routes/amt/getAMTFeatures.ts @@ -11,6 +11,10 @@ import { MPSValidationError } from '../../utils/MPSValidationError.js' import { MqttProvider } from '../../utils/MqttProvider.js' import { type AMT, type CIM, type IPS, Common } from '@device-management-toolkit/wsman-messages' import type { BootSettingResult, OCRData, OCRProcessResult } from '../../models/models.js' +import { + BOOT_SERVICE_STATE_OCR_ONLY, + BOOT_SERVICE_STATE_BOTH_ON +} from './rpeConstants.js' export async function getAMTFeatures(req: Request, res: Response): Promise { try { @@ -28,6 +32,11 @@ export async function getAMTFeatures(req: Request, res: Response): Promise const userConsent = Object.keys(UserConsentOptions).find((key) => UserConsentOptions[key] === value) const ocrProcessResult = processOCRData(OCRData) + const rpeCaps = OCRData.capabilities?.Body?.AMT_BootCapabilities?.PlatformErase ?? 0 + const bootData = OCRData.bootData?.AMT_BootSettingData + const rpe = !!(bootData?.RPE ?? bootData?.RPEEnabled ?? bootData?.PlatformErase) + const rpeSupported = rpeCaps !== 0 + MqttProvider.publishEvent('success', ['AMT_GetFeatures'], messages.AMT_FEATURES_GET_SUCCESS, guid) res .status(200) @@ -43,7 +52,8 @@ export async function getAMTFeatures(req: Request, res: Response): Promise httpsBootSupported: ocrProcessResult.HTTPSBootSupported, winREBootSupported: ocrProcessResult.WinREBootSupported, localPBABootSupported: ocrProcessResult.LocalPBABootSupported, - remoteErase: false + rpe, + rpeSupported }) .end() } catch (error) { @@ -91,7 +101,7 @@ export function processOCRData(ocrData: OCRData): OCRProcessResult { const bootData = ocrData.bootData?.AMT_BootSettingData const bootSourceSettings = ocrData.bootSourceSettings - const isOCR = EnabledState === 32769 || EnabledState === 32771 + const isOCR = EnabledState === BOOT_SERVICE_STATE_OCR_ONLY || EnabledState === BOOT_SERVICE_STATE_BOTH_ON const bootSettings = findBootSettingInstances(bootSourceSettings) diff --git a/src/routes/amt/getBootCapabilities.test.ts b/src/routes/amt/getBootCapabilities.test.ts new file mode 100644 index 000000000..453da3038 --- /dev/null +++ b/src/routes/amt/getBootCapabilities.test.ts @@ -0,0 +1,75 @@ +/********************************************************************* + * Copyright (c) Intel Corporation 2022 + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +import { ErrorResponse } from '../../utils/amtHelper.js' +import { MqttProvider } from '../../utils/MqttProvider.js' +import { getBootCapabilities } from './getBootCapabilities.js' +import { createSpyObj } from '../../test/helper/vitest.js' +import { DeviceAction } from '../../amt/DeviceAction.js' +import { CIRAHandler } from '../../amt/CIRAHandler.js' +import { HttpHandler } from '../../amt/HttpHandler.js' +import { messages } from '../../logging/index.js' +import { vi, type MockInstance } from 'vitest' + +describe('Get Boot Capabilities', () => { + let req: any + let resSpy: any + let mqttSpy: MockInstance + let bootCapsSpy: MockInstance + let device: DeviceAction + + beforeEach(() => { + const handler = new CIRAHandler(new HttpHandler(), 'admin', 'P@ssw0rd') + device = new DeviceAction(handler, null) + req = { + params: { guid: '4c4c4544-004b-4210-8033-b6c04f504633' }, + deviceAction: device + } + resSpy = createSpyObj('Response', [ + 'status', + 'json', + 'end', + 'send' + ]) + resSpy.status.mockReturnThis() + resSpy.json.mockReturnThis() + resSpy.send.mockReturnThis() + + mqttSpy = vi.spyOn(MqttProvider, 'publishEvent') + bootCapsSpy = vi.spyOn(device, 'getBootCapabilities') + }) + + it('should return boot capabilities', async () => { + const bootCaps = { + IDER: true, + SOL: true, + BIOSSetup: true, + PlatformErase: 0x10044 + } + bootCapsSpy.mockResolvedValue({ + Body: { AMT_BootCapabilities: bootCaps } + }) + + await getBootCapabilities(req, resSpy) + expect(resSpy.status).toHaveBeenCalledWith(200) + expect(resSpy.json).toHaveBeenCalledWith({ + secureEraseAllSSDs: true, + tpmClear: true, + restoreBIOSToEOM: false, + unconfigureCSME: true + }) + expect(resSpy.end).toHaveBeenCalled() + expect(mqttSpy).toHaveBeenCalledTimes(2) + }) + + it('should return 500 on error', async () => { + bootCapsSpy.mockRejectedValue(new Error('AMT error')) + + await getBootCapabilities(req, resSpy) + expect(resSpy.status).toHaveBeenCalledWith(500) + expect(resSpy.json).toHaveBeenCalledWith(ErrorResponse(500, messages.BOOT_CAPABILITIES_EXCEPTION)) + expect(resSpy.end).toHaveBeenCalled() + }) +}) diff --git a/src/routes/amt/getBootCapabilities.ts b/src/routes/amt/getBootCapabilities.ts new file mode 100644 index 000000000..e1e24f231 --- /dev/null +++ b/src/routes/amt/getBootCapabilities.ts @@ -0,0 +1,47 @@ +/********************************************************************* + * Copyright (c) Intel Corporation 2022 + * 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 { + PLATFORM_ERASE_ALL_SSDS, + PLATFORM_ERASE_TPM_CLEAR, + PLATFORM_ERASE_CSME_UNCONFIGURE, + PLATFORM_ERASE_BIOS_TO_EOM +} from './rpeConstants.js' + +export async function getBootCapabilities(req: Request, res: Response): Promise { + try { + const guid: string = req.params.guid + + MqttProvider.publishEvent('request', ['AMT_BootCapabilities'], messages.BOOT_CAPABILITIES_REQUESTED, guid) + + const result = await req.deviceAction.getBootCapabilities() + const capabilities = parsePlatformEraseCapabilities(result.Body?.AMT_BootCapabilities?.PlatformErase ?? 0) + + MqttProvider.publishEvent('success', ['AMT_BootCapabilities'], messages.BOOT_CAPABILITIES_SUCCESS, guid) + res.status(200).json(capabilities).end() + } catch (error) { + logger.error(`${messages.BOOT_CAPABILITIES_EXCEPTION} : ${error}`) + MqttProvider.publishEvent('fail', ['AMT_BootCapabilities'], messages.INTERNAL_SERVICE_ERROR) + res.status(500).json(ErrorResponse(500, messages.BOOT_CAPABILITIES_EXCEPTION)).end() + } +} + +function parsePlatformEraseCapabilities(platformEraseMask: number): { + secureEraseAllSSDs: boolean + tpmClear: boolean + restoreBIOSToEOM: boolean + unconfigureCSME: boolean +} { + return { + secureEraseAllSSDs: (platformEraseMask & PLATFORM_ERASE_ALL_SSDS) !== 0, + tpmClear: (platformEraseMask & PLATFORM_ERASE_TPM_CLEAR) !== 0, + restoreBIOSToEOM: (platformEraseMask & PLATFORM_ERASE_BIOS_TO_EOM) !== 0, + unconfigureCSME: (platformEraseMask & PLATFORM_ERASE_CSME_UNCONFIGURE) !== 0 + } +} diff --git a/src/routes/amt/getPowerState.test.ts b/src/routes/amt/getPowerState.test.ts index ab2691ec6..5c60f7f87 100644 --- a/src/routes/amt/getPowerState.test.ts +++ b/src/routes/amt/getPowerState.test.ts @@ -40,10 +40,10 @@ describe('power state', () => { IPS_PowerManagementService: { CreationClassName: 'IPS_PowerManagementService', ElementName: 'Intel(r) AMT Power Management Service', - EnabledState: '5', + EnabledState: 5, Name: 'Intel(r) AMT Power Management Service', - OSPowerSavingState: '3', - RequestedState: '12', + OSPowerSavingState: 3, + RequestedState: 12, SystemCreationClassName: 'CIM_ComputerSystem', SystemName: 'Intel(r) AMT' } @@ -57,21 +57,21 @@ describe('power state', () => { powerStateSpy.mockResolvedValueOnce(serviceAvailableToElement.Envelope.Body) await powerState(req, resSpy) expect(resSpy.status).toHaveBeenCalledWith(200) - expect(resSpy.send).toHaveBeenCalledWith({ powerstate: '4', OSPowerSavingState: '3' }) + expect(resSpy.send).toHaveBeenCalledWith({ powerstate: 4, OSPowerSavingState: 3 }) }) it('should get power state with OSPowerSavingState as 0 when getOSPowerSavingState throws an error', async () => { osPowerStateGetSpy.mockRejectedValueOnce(new Error('OS power saving state error')) powerStateSpy.mockResolvedValueOnce(serviceAvailableToElement.Envelope.Body) await powerState(req, resSpy) expect(resSpy.status).toHaveBeenCalledWith(200) - expect(resSpy.send).toHaveBeenCalledWith({ powerstate: '4', OSPowerSavingState: 0 }) + expect(resSpy.send).toHaveBeenCalledWith({ powerstate: 4, OSPowerSavingState: 0 }) }) it('should get power state with OSPowerSavingState as 0 when getOSPowerSavingState returns null', async () => { osPowerStateGetSpy.mockResolvedValueOnce(null) powerStateSpy.mockResolvedValueOnce(serviceAvailableToElement.Envelope.Body) await powerState(req, resSpy) expect(resSpy.status).toHaveBeenCalledWith(200) - expect(resSpy.send).toHaveBeenCalledWith({ powerstate: '4', OSPowerSavingState: 0 }) + expect(resSpy.send).toHaveBeenCalledWith({ powerstate: 4, OSPowerSavingState: 0 }) }) it('should get power state with OSPowerSavingState as 0 when OSPowerSavingState is missing in response', async () => { osPowerStateGetSpy.mockResolvedValueOnce({ @@ -86,14 +86,14 @@ describe('power state', () => { powerStateSpy.mockResolvedValueOnce(serviceAvailableToElement.Envelope.Body) await powerState(req, resSpy) expect(resSpy.status).toHaveBeenCalledWith(200) - expect(resSpy.send).toHaveBeenCalledWith({ powerstate: '4', OSPowerSavingState: 0 }) + expect(resSpy.send).toHaveBeenCalledWith({ powerstate: 4, OSPowerSavingState: 0 }) }) it('should get power state with OSPowerSavingState as 0 when getOSPowerSavingState times out', async () => { osPowerStateGetSpy.mockRejectedValueOnce(new TimeoutError(TIMEOUT_MESSAGE)) powerStateSpy.mockResolvedValueOnce(serviceAvailableToElement.Envelope.Body) await powerState(req, resSpy) expect(resSpy.status).toHaveBeenCalledWith(200) - expect(resSpy.send).toHaveBeenCalledWith({ powerstate: '4', OSPowerSavingState: 0 }) + expect(resSpy.send).toHaveBeenCalledWith({ powerstate: 4, OSPowerSavingState: 0 }) }) it('should get an error with status code 400, when get power state is null', async () => { powerStateSpy.mockResolvedValueOnce(null) diff --git a/src/routes/amt/index.ts b/src/routes/amt/index.ts index c58960038..f69cddf3c 100644 --- a/src/routes/amt/index.ts +++ b/src/routes/amt/index.ts @@ -40,6 +40,9 @@ import { getScreenSettingData } from './kvm/get.js' import { setKVMRedirectionSettingData } from './kvm/set.js' import { setLinkPreference } from './setLinkPreference.js' import { linkPreferenceValidator } from './linkPreferenceValidator.js' +import { getBootCapabilities } from './getBootCapabilities.js' +import { sendRPE } from './sendRPE.js' +import { sendRPEValidator } from './sendRPEValidator.js' import { getNetworkSettings } from './networkSettings/getNetworkSettings.js' import { getWiredNetworkSettings } from './networkSettings/getWired.js' import { patchWiredNetworkSettings } from './networkSettings/patchWired.js' @@ -68,6 +71,8 @@ amtRouter.get('/power/capabilities/:guid', ciraMiddleware, powerCapabilities) amtRouter.get('/power/state/:guid', ciraMiddleware, powerState) amtRouter.get('/features/:guid', ciraMiddleware, getAMTFeatures) amtRouter.post('/features/:guid', amtFeaturesValidator(), validateMiddleware, ciraMiddleware, setAMTFeatures) +amtRouter.get('/boot/remoteErase/:guid', ciraMiddleware, getBootCapabilities) +amtRouter.post('/boot/remoteErase/:guid', sendRPEValidator(), validateMiddleware, ciraMiddleware, sendRPE) amtRouter.get('/version/:guid', ciraMiddleware, version) amtRouter.delete('/deactivate/:guid', ciraMiddleware, deactivate) amtRouter.get('/power/bootSources/:guid', ciraMiddleware, bootSources) diff --git a/src/routes/amt/powerCapabilities.test.ts b/src/routes/amt/powerCapabilities.test.ts index 04c651b07..f435d3fd0 100644 --- a/src/routes/amt/powerCapabilities.test.ts +++ b/src/routes/amt/powerCapabilities.test.ts @@ -94,7 +94,8 @@ describe('Power Capabilities', () => { 'Power on to PXE': 401 } versionResponse.CIM_SoftwareIdentity.responses = [ - { InstanceID: 'AMT', IsEntity: 'true', VersionString: '9.0.0' }] + { InstanceID: 'AMT', IsEntity: 'true', VersionString: '9.0.0' } + ] vi.spyOn(device, 'getPowerCapabilities').mockResolvedValue(powerCaps) swIdentitySpy.mockResolvedValue(softwareIdentityResponse.Envelope.Body) setupAndConfigSpy.mockResolvedValue(setupAndConfigurationServiceResponse.Envelope) @@ -129,7 +130,7 @@ describe('Power Capabilities', () => { powerCaps.Body.AMT_BootCapabilities.BIOSSetup = true powerCaps.Body.AMT_BootCapabilities.SecureErase = true powerCaps.Body.AMT_BootCapabilities.ForceDiagnosticBoot = true - vi.spyOn(device, 'getPowerCapabilities').mockResolvedValue(powerCaps) + vi.spyOn(device, 'getBootCapabilities').mockResolvedValue(powerCaps) swIdentitySpy.mockResolvedValue(softwareIdentityResponse.Envelope.Body) setupAndConfigSpy.mockResolvedValue(setupAndConfigurationServiceResponse.Envelope) await powerCapabilities(req as any, resSpy) diff --git a/src/routes/amt/rpeConstants.ts b/src/routes/amt/rpeConstants.ts new file mode 100644 index 000000000..42af088ab --- /dev/null +++ b/src/routes/amt/rpeConstants.ts @@ -0,0 +1,17 @@ +/********************************************************************* + * Copyright (c) Intel Corporation 2022 + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +export const PLATFORM_ERASE_ALL_SSDS = 0x4 +export const PLATFORM_ERASE_TPM_CLEAR = 0x40 +export const PLATFORM_ERASE_CSME_UNCONFIGURE = 0x10000 +export const PLATFORM_ERASE_BIOS_TO_EOM = 0x4000000 + +export const MAX_SSD_PASSWORD_LENGTH = 64 + +// CIM_BootService RequestedState / EnabledState values for OCR + RPE combinations +export const BOOT_SERVICE_STATE_BOTH_OFF = 32768 +export const BOOT_SERVICE_STATE_OCR_ONLY = 32769 +export const BOOT_SERVICE_STATE_RPE_ONLY = 32770 +export const BOOT_SERVICE_STATE_BOTH_ON = 32771 diff --git a/src/routes/amt/sendRPE.test.ts b/src/routes/amt/sendRPE.test.ts new file mode 100644 index 000000000..670a3a545 --- /dev/null +++ b/src/routes/amt/sendRPE.test.ts @@ -0,0 +1,122 @@ +/********************************************************************* + * Copyright (c) Intel Corporation 2022 + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +import { ErrorResponse } from '../../utils/amtHelper.js' +import { MqttProvider } from '../../utils/MqttProvider.js' +import { sendRPE } from './sendRPE.js' +import { createSpyObj } from '../../test/helper/vitest.js' +import { DeviceAction } from '../../amt/DeviceAction.js' +import { CIRAHandler } from '../../amt/CIRAHandler.js' +import { HttpHandler } from '../../amt/HttpHandler.js' +import { messages } from '../../logging/index.js' +import { vi, type MockInstance } from 'vitest' + +import { + PLATFORM_ERASE_ALL_SSDS +} from './rpeConstants.js' + +describe('Send Remote Erase', () => { + let req: any + let resSpy: any + let mqttSpy: MockInstance + let bootCapsSpy: MockInstance + let sendEraseSpy: MockInstance + let device: DeviceAction + + beforeEach(() => { + const handler = new CIRAHandler(new HttpHandler(), 'admin', 'P@ssw0rd') + device = new DeviceAction(handler, null) + req = { + params: { guid: '4c4c4544-004b-4210-8033-b6c04f504633' }, + body: { secureEraseAllSSDs: true, tpmClear: false, restoreBIOSToEOM: false, unconfigureCSME: false }, + deviceAction: device + } + resSpy = createSpyObj('Response', [ + 'status', + 'json', + 'end', + 'send' + ]) + resSpy.status.mockReturnThis() + resSpy.json.mockReturnThis() + resSpy.send.mockReturnThis() + + mqttSpy = vi.spyOn(MqttProvider, 'publishEvent') + bootCapsSpy = vi.spyOn(device, 'getBootCapabilities') + sendEraseSpy = vi.spyOn(device, 'sendRPE') + }) + + it('should send remote erase when device supports the requested mask', async () => { + req.body.ssdPassword = 'mypassword' + bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 0x44 } } }) + sendEraseSpy.mockResolvedValue(undefined) + + await sendRPE(req, resSpy) + expect(sendEraseSpy).toHaveBeenCalledWith(PLATFORM_ERASE_ALL_SSDS, 'mypassword') + expect(resSpy.status).toHaveBeenCalledWith(200) + expect(resSpy.json).toHaveBeenCalledWith({ status: 'success' }) + }) + + it('should send remote erase with zero mask (no specific capability check)', async () => { + req.body = { secureEraseAllSSDs: false, tpmClear: false, restoreBIOSToEOM: false, unconfigureCSME: false } + bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 0x44 } } }) + sendEraseSpy.mockResolvedValue(undefined) + + await sendRPE(req, resSpy) + expect(sendEraseSpy).toHaveBeenCalledWith(0, undefined) + expect(resSpy.status).toHaveBeenCalledWith(200) + }) + + it('should return 400 when device does not support platform erase', async () => { + bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 0 } } }) + + await sendRPE(req, resSpy) + expect(sendEraseSpy).not.toHaveBeenCalled() + expect(resSpy.status).toHaveBeenCalledWith(400) + expect(resSpy.json).toHaveBeenCalledWith(ErrorResponse(400, 'Device does not support Remote Platform Erase')) + }) + + it('should return 400 when requested mask is not supported by device', async () => { + req.body = { secureEraseAllSSDs: false, tpmClear: true, restoreBIOSToEOM: false, unconfigureCSME: false } + bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 0x4 } } }) + + await sendRPE(req, resSpy) + expect(sendEraseSpy).not.toHaveBeenCalled() + expect(resSpy.status).toHaveBeenCalledWith(400) + expect(resSpy.json).toHaveBeenCalledWith( + ErrorResponse(400, 'Requested erase capabilities are not supported by this device') + ) + }) + + it('should send combined CSME and hardware erase when device supports all requested bits', async () => { + req.body = { secureEraseAllSSDs: true, tpmClear: false, restoreBIOSToEOM: false, unconfigureCSME: true } + bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 0x10004 } } }) + sendEraseSpy.mockResolvedValue(undefined) + + await sendRPE(req, resSpy) + expect(sendEraseSpy).toHaveBeenCalledWith(0x10004, undefined) + expect(resSpy.status).toHaveBeenCalledWith(200) + }) + + it('should return 400 when combined request includes an unsupported capability bit', async () => { + req.body = { secureEraseAllSSDs: true, tpmClear: false, restoreBIOSToEOM: false, unconfigureCSME: true } + bootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: PLATFORM_ERASE_ALL_SSDS } } }) + + await sendRPE(req, resSpy) + expect(sendEraseSpy).not.toHaveBeenCalled() + expect(resSpy.status).toHaveBeenCalledWith(400) + expect(resSpy.json).toHaveBeenCalledWith( + ErrorResponse(400, 'Requested erase capabilities are not supported by this device') + ) + }) + + it('should return 500 on unexpected error', async () => { + bootCapsSpy.mockRejectedValue(new Error('AMT error')) + + await sendRPE(req, resSpy) + expect(resSpy.status).toHaveBeenCalledWith(500) + expect(resSpy.json).toHaveBeenCalledWith(ErrorResponse(500, messages.AMT_FEATURES_SET_EXCEPTION)) + }) +}) diff --git a/src/routes/amt/sendRPE.ts b/src/routes/amt/sendRPE.ts new file mode 100644 index 000000000..5a565a833 --- /dev/null +++ b/src/routes/amt/sendRPE.ts @@ -0,0 +1,55 @@ +/********************************************************************* + * Copyright (c) Intel Corporation 2022 + * 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 { MPSValidationError } from '../../utils/MPSValidationError.js' +import { + PLATFORM_ERASE_ALL_SSDS, + PLATFORM_ERASE_TPM_CLEAR, + PLATFORM_ERASE_CSME_UNCONFIGURE, + PLATFORM_ERASE_BIOS_TO_EOM +} from './rpeConstants.js' + +export async function sendRPE(req: Request, res: Response): Promise { + try { + const guid: string = req.params.guid + + const { secureEraseAllSSDs, ssdPassword, tpmClear, restoreBIOSToEOM, unconfigureCSME } = req.body + const mask = + (secureEraseAllSSDs ? PLATFORM_ERASE_ALL_SSDS : 0) | + (tpmClear ? PLATFORM_ERASE_TPM_CLEAR : 0) | + (restoreBIOSToEOM ? PLATFORM_ERASE_BIOS_TO_EOM : 0) | + (unconfigureCSME ? PLATFORM_ERASE_CSME_UNCONFIGURE : 0) + + MqttProvider.publishEvent('request', ['AMT_BootSettingData'], messages.AMT_FEATURES_SET_REQUESTED, guid) + + const bootCaps = await req.deviceAction.getBootCapabilities() + const platformEraseCaps = bootCaps.Body?.AMT_BootCapabilities?.PlatformErase ?? 0 + + if (platformEraseCaps === 0) { + throw new MPSValidationError('Device does not support Remote Platform Erase', 400) + } + + if (mask !== 0 && (platformEraseCaps & mask) !== mask) { + throw new MPSValidationError('Requested erase capabilities are not supported by this device', 400) + } + + await req.deviceAction.sendRPE(mask, ssdPassword) + + MqttProvider.publishEvent('success', ['AMT_BootSettingData'], messages.AMT_FEATURES_SET_SUCCESS, guid) + res.status(200).json({ status: 'success' }).end() + } catch (error) { + logger.error(`sendRPE failed: ${error}`) + if (error instanceof MPSValidationError) { + res.status(error.status ?? 400).json(ErrorResponse(error.status ?? 400, error.message)) + } else { + MqttProvider.publishEvent('fail', ['AMT_BootSettingData'], messages.INTERNAL_SERVICE_ERROR) + res.status(500).json(ErrorResponse(500, messages.AMT_FEATURES_SET_EXCEPTION)).end() + } + } +} diff --git a/src/routes/amt/sendRPEValidator.test.ts b/src/routes/amt/sendRPEValidator.test.ts new file mode 100644 index 000000000..ea170c979 --- /dev/null +++ b/src/routes/amt/sendRPEValidator.test.ts @@ -0,0 +1,133 @@ +/********************************************************************* + * Copyright (c) Intel Corporation 2022 + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +import { describe, expect, it } from 'vitest' +import { validationResult } from 'express-validator' +import { sendRPEValidator } from './sendRPEValidator.js' +import { MAX_SSD_PASSWORD_LENGTH } from './rpeConstants.js' + +async function getValidationErrors(body: any): Promise { + const req: any = { body, query: {}, params: {} } + const chains = sendRPEValidator() + for (const chain of chains) { + await chain.run(req) + } + return validationResult(req).array() +} + +describe('sendRPE validator', () => { + describe('valid requests', () => { + it('accepts a single boolean erase option', async () => { + const errors = await getValidationErrors({ secureEraseAllSSDs: true }) + expect(errors).toHaveLength(0) + }) + + it('accepts multiple erase options', async () => { + const errors = await getValidationErrors({ + secureEraseAllSSDs: true, + tpmClear: true, + restoreBIOSToEOM: false, + unconfigureCSME: false + }) + expect(errors).toHaveLength(0) + }) + + it('accepts CSME-only erase', async () => { + const errors = await getValidationErrors({ unconfigureCSME: true }) + expect(errors).toHaveLength(0) + }) + + it('accepts ssdPassword with secureEraseAllSSDs', async () => { + const errors = await getValidationErrors({ + secureEraseAllSSDs: true, + ssdPassword: 'mypassword' + }) + expect(errors).toHaveLength(0) + }) + + it('accepts ssdPassword at exactly the max byte length', async () => { + const errors = await getValidationErrors({ + secureEraseAllSSDs: true, + ssdPassword: 'a'.repeat(MAX_SSD_PASSWORD_LENGTH) + }) + expect(errors).toHaveLength(0) + }) + + it('accepts omitted ssdPassword', async () => { + const errors = await getValidationErrors({ tpmClear: true }) + expect(errors).toHaveLength(0) + }) + }) + + describe('at-least-one-option requirement', () => { + it('rejects an empty body', async () => { + const errors = await getValidationErrors({}) + expect(errors.length).toBeGreaterThan(0) + expect(errors.some((e) => e.msg === 'At least one erase option must be enabled')).toBe(true) + }) + + it('rejects when all erase options are false', async () => { + const errors = await getValidationErrors({ + secureEraseAllSSDs: false, + tpmClear: false, + restoreBIOSToEOM: false, + unconfigureCSME: false + }) + expect(errors.some((e) => e.msg === 'At least one erase option must be enabled')).toBe(true) + }) + }) + + describe('boolean type enforcement', () => { + it('rejects string "true" for secureEraseAllSSDs', async () => { + const errors = await getValidationErrors({ secureEraseAllSSDs: 'true' }) + expect(errors.some((e) => e.path === 'secureEraseAllSSDs')).toBe(true) + }) + + it('rejects string "false" for secureEraseAllSSDs', async () => { + const errors = await getValidationErrors({ secureEraseAllSSDs: 'false', tpmClear: true }) + expect(errors.some((e) => e.path === 'secureEraseAllSSDs')).toBe(true) + }) + + it('rejects number 1 for tpmClear', async () => { + const errors = await getValidationErrors({ tpmClear: 1 }) + expect(errors.some((e) => e.path === 'tpmClear')).toBe(true) + }) + + it('rejects string for restoreBIOSToEOM', async () => { + const errors = await getValidationErrors({ restoreBIOSToEOM: 'yes', secureEraseAllSSDs: true }) + expect(errors.some((e) => e.path === 'restoreBIOSToEOM')).toBe(true) + }) + + it('rejects string for unconfigureCSME', async () => { + const errors = await getValidationErrors({ unconfigureCSME: '1', secureEraseAllSSDs: true }) + expect(errors.some((e) => e.path === 'unconfigureCSME')).toBe(true) + }) + }) + + describe('ssdPassword validation', () => { + it('rejects non-string ssdPassword', async () => { + const errors = await getValidationErrors({ secureEraseAllSSDs: true, ssdPassword: 12345 }) + expect(errors.some((e) => e.path === 'ssdPassword')).toBe(true) + }) + + it('rejects ssdPassword exceeding max byte length', async () => { + const errors = await getValidationErrors({ + secureEraseAllSSDs: true, + ssdPassword: 'a'.repeat(MAX_SSD_PASSWORD_LENGTH + 1) + }) + expect(errors.some((e) => e.path === 'ssdPassword')).toBe(true) + expect(errors.some((e) => e.msg.includes(`${MAX_SSD_PASSWORD_LENGTH} bytes`))).toBe(true) + }) + + it('rejects multibyte password that exceeds 64 bytes despite being fewer characters', async () => { + // Each '€' is 3 bytes in UTF-8; 22 × 3 = 66 bytes > 64 + const errors = await getValidationErrors({ + secureEraseAllSSDs: true, + ssdPassword: '€'.repeat(22) + }) + expect(errors.some((e) => e.path === 'ssdPassword')).toBe(true) + }) + }) +}) diff --git a/src/routes/amt/sendRPEValidator.ts b/src/routes/amt/sendRPEValidator.ts new file mode 100644 index 000000000..dd9435af7 --- /dev/null +++ b/src/routes/amt/sendRPEValidator.ts @@ -0,0 +1,28 @@ +/********************************************************************* + * Copyright (c) Intel Corporation 2022 + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +import { body } from 'express-validator' +import { MAX_SSD_PASSWORD_LENGTH } from './rpeConstants.js' + +export const sendRPEValidator = (): any => [ + body('secureEraseAllSSDs').optional().isBoolean({ strict: true }).withMessage('secureEraseAllSSDs must be a boolean'), + body('tpmClear').optional().isBoolean({ strict: true }).withMessage('tpmClear must be a boolean'), + body('restoreBIOSToEOM').optional().isBoolean({ strict: true }).withMessage('restoreBIOSToEOM must be a boolean'), + body('unconfigureCSME').optional().isBoolean({ strict: true }).withMessage('unconfigureCSME must be a boolean'), + body('ssdPassword').optional().isString().withMessage('ssdPassword must be a string'), + body().custom((value) => { + const { secureEraseAllSSDs, tpmClear, restoreBIOSToEOM, unconfigureCSME } = value + if (!secureEraseAllSSDs && !tpmClear && !restoreBIOSToEOM && !unconfigureCSME) { + throw new Error('At least one erase option must be enabled') + } + return true + }), + body('ssdPassword').optional().custom((value: string) => { + if (typeof value === 'string' && new TextEncoder().encode(value).length > MAX_SSD_PASSWORD_LENGTH) { + throw new Error(`SSD password must not exceed ${MAX_SSD_PASSWORD_LENGTH} bytes`) + } + return true + }) +] diff --git a/src/routes/amt/setAMTFeatures.test.ts b/src/routes/amt/setAMTFeatures.test.ts index f1905e2dd..a9e6b0825 100644 --- a/src/routes/amt/setAMTFeatures.test.ts +++ b/src/routes/amt/setAMTFeatures.test.ts @@ -22,6 +22,7 @@ describe('set amt features', () => { let putRedirectionServiceSpy: MockInstance let putIpsOptInServiceSpy: MockInstance let bootServiceStateChangeSpy: MockInstance + let getBootOptionsSpy: MockInstance let mqttSpy: MockInstance beforeEach(() => { @@ -58,6 +59,7 @@ describe('set amt features', () => { putRedirectionServiceSpy = vi.spyOn(device, 'putRedirectionService') putIpsOptInServiceSpy = vi.spyOn(device, 'putIpsOptInService') bootServiceStateChangeSpy = vi.spyOn(device, 'BootServiceStateChange') + getBootOptionsSpy = vi.spyOn(device, 'getBootOptions') mqttSpy = vi.spyOn(MqttProvider, 'publishEvent') @@ -76,6 +78,7 @@ describe('set amt features', () => { putIpsOptInServiceSpy.mockResolvedValue({}) putRedirectionServiceSpy.mockResolvedValue({}) bootServiceStateChangeSpy.mockResolvedValue({}) + getBootOptionsSpy.mockResolvedValue({ AMT_BootSettingData: { RPE: false } }) }) it('should set amt features - no change', async () => { @@ -208,4 +211,117 @@ describe('set amt features', () => { expect(resSpy.json).toHaveBeenCalled() expect(mqttSpy).toHaveBeenCalled() }) + + describe('platformEraseEnabled without ocr — preserves current OCR state', () => { + let getBootCapsSpy: MockInstance + let setRPESpy: MockInstance + let getOCRDataSpy: MockInstance + + beforeEach(() => { + // Remove ocr from body so the RPE-only branch is exercised + delete req.body.ocr + getBootCapsSpy = vi.spyOn(req.deviceAction, 'getBootCapabilities') + setRPESpy = vi.spyOn(req.deviceAction, 'setRPE') + getOCRDataSpy = vi.spyOn(req.deviceAction, 'getOCRData') + getBootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 0x4 } } }) + setRPESpy.mockResolvedValue(undefined) + }) + + it('enables RPE and preserves OCR-on → boot state 32771 (both)', async () => { + req.body.platformEraseEnabled = true + getOCRDataSpy.mockResolvedValue({ bootService: { CIM_BootService: { EnabledState: 32769 } } }) + + await setAMTFeatures(req, resSpy) + + expect(resSpy.status).toHaveBeenCalledWith(200) + expect(setRPESpy).toHaveBeenCalledWith(true) + expect(bootServiceStateChangeSpy).toHaveBeenCalledWith(32771) + }) + + it('enables RPE and preserves OCR-off → boot state 32770 (RPE only)', async () => { + req.body.platformEraseEnabled = true + getOCRDataSpy.mockResolvedValue({ bootService: { CIM_BootService: { EnabledState: 32768 } } }) + + await setAMTFeatures(req, resSpy) + + expect(resSpy.status).toHaveBeenCalledWith(200) + expect(setRPESpy).toHaveBeenCalledWith(true) + expect(bootServiceStateChangeSpy).toHaveBeenCalledWith(32770) + }) + + it('disables RPE and preserves OCR-on → boot state 32769 (OCR only)', async () => { + req.body.platformEraseEnabled = false + getOCRDataSpy.mockResolvedValue({ bootService: { CIM_BootService: { EnabledState: 32771 } } }) + + await setAMTFeatures(req, resSpy) + + expect(resSpy.status).toHaveBeenCalledWith(200) + expect(setRPESpy).toHaveBeenCalledWith(false) + expect(bootServiceStateChangeSpy).toHaveBeenCalledWith(32769) + }) + + it('disables RPE and preserves OCR-off → boot state 32768 (both off)', async () => { + req.body.platformEraseEnabled = false + getOCRDataSpy.mockResolvedValue({ bootService: { CIM_BootService: { EnabledState: 32768 } } }) + + await setAMTFeatures(req, resSpy) + + expect(resSpy.status).toHaveBeenCalledWith(200) + expect(setRPESpy).toHaveBeenCalledWith(false) + expect(bootServiceStateChangeSpy).toHaveBeenCalledWith(32768) + }) + + it('returns 400 when device does not support RPE', async () => { + req.body.platformEraseEnabled = true + getBootCapsSpy.mockResolvedValue({ Body: { AMT_BootCapabilities: { PlatformErase: 0 } } }) + + await setAMTFeatures(req, resSpy) + + expect(resSpy.status).toHaveBeenCalledWith(400) + expect(setRPESpy).not.toHaveBeenCalled() + expect(bootServiceStateChangeSpy).not.toHaveBeenCalled() + }) + }) + + describe('ocr without platformEraseEnabled — preserves current RPE state', () => { + it('enables OCR and preserves RPE-on → boot state 32771 (both)', async () => { + req.body.ocr = true + getBootOptionsSpy.mockResolvedValue({ AMT_BootSettingData: { RPE: true } }) + + await setAMTFeatures(req, resSpy) + + expect(resSpy.status).toHaveBeenCalledWith(200) + expect(bootServiceStateChangeSpy).toHaveBeenCalledWith(32771) + }) + + it('enables OCR and preserves RPE-off → boot state 32769 (OCR only)', async () => { + req.body.ocr = true + getBootOptionsSpy.mockResolvedValue({ AMT_BootSettingData: { RPE: false } }) + + await setAMTFeatures(req, resSpy) + + expect(resSpy.status).toHaveBeenCalledWith(200) + expect(bootServiceStateChangeSpy).toHaveBeenCalledWith(32769) + }) + + it('disables OCR and preserves RPE-on → boot state 32770 (RPE only)', async () => { + req.body.ocr = false + getBootOptionsSpy.mockResolvedValue({ AMT_BootSettingData: { RPE: true } }) + + await setAMTFeatures(req, resSpy) + + expect(resSpy.status).toHaveBeenCalledWith(200) + expect(bootServiceStateChangeSpy).toHaveBeenCalledWith(32770) + }) + + it('disables OCR and preserves RPE-off → boot state 32768 (both off)', async () => { + req.body.ocr = false + getBootOptionsSpy.mockResolvedValue({ AMT_BootSettingData: { RPE: false } }) + + await setAMTFeatures(req, resSpy) + + expect(resSpy.status).toHaveBeenCalledWith(200) + expect(bootServiceStateChangeSpy).toHaveBeenCalledWith(32768) + }) + }) }) diff --git a/src/routes/amt/setAMTFeatures.ts b/src/routes/amt/setAMTFeatures.ts index 841be8c50..3467ec78b 100644 --- a/src/routes/amt/setAMTFeatures.ts +++ b/src/routes/amt/setAMTFeatures.ts @@ -8,8 +8,15 @@ import { logger, messages } from '../../logging/index.js' import { ErrorResponse } from '../../utils/amtHelper.js' import { MqttProvider } from '../../utils/MqttProvider.js' import { UserConsentOptions } from '../../utils/constants.js' +import { MPSValidationError } from '../../utils/MPSValidationError.js' import { type AMT, type IPS, Common } from '@device-management-toolkit/wsman-messages' import { type DeviceAction } from '../../amt/DeviceAction.js' +import { + BOOT_SERVICE_STATE_BOTH_OFF, + BOOT_SERVICE_STATE_OCR_ONLY, + BOOT_SERVICE_STATE_RPE_ONLY, + BOOT_SERVICE_STATE_BOTH_ON +} from './rpeConstants.js' export async function setAMTFeatures(req: Request, res: Response): Promise { try { @@ -80,24 +87,63 @@ export async function setAMTFeatures(req: Request, res: Response): Promise await setUserConsent(req.deviceAction, optServiceResponse, payload.guid as string) } - // Configure OCR settings + // Configure Remote Platform Erase (RPE) and boot service state under a device lock + // to prevent concurrent boot-configuration requests from interleaving. + await req.deviceAction.withDeviceLock(async () => { + // Configure Remote Platform Erase (RPE) — PUT must run BEFORE BootServiceStateChange + let rpeDesired: boolean | undefined + if (payload.platformEraseEnabled !== undefined) { + const bootCaps = await req.deviceAction.getBootCapabilities() + const platformEraseCaps = bootCaps.Body?.AMT_BootCapabilities?.PlatformErase ?? 0 + if (platformEraseCaps === 0) { + throw new MPSValidationError('Device does not support Remote Platform Erase', 400) + } + rpeDesired = !!payload.platformEraseEnabled + await req.deviceAction.setRPE(rpeDesired) + } + + // Configure boot service state — combines OCR and RPE + // BOTH_OFF=32768, OCR_ONLY=32769, RPE_ONLY=32770, BOTH_ON=32771 if (payload.ocr !== undefined) { - let requestedState = 0 - if (payload.ocr) { - requestedState = 32769 + const ocrOn = !!payload.ocr + // If platformEraseEnabled was not provided, read the current RPE state from the + // device so an OCR-only update does not inadvertently clear the RPE boot bit. + let rpeOn: boolean + if (rpeDesired !== undefined) { + rpeOn = rpeDesired } else { - requestedState = 32768 + const bootOptions = await req.deviceAction.getBootOptions() + const current = bootOptions.AMT_BootSettingData + rpeOn = !!((current as any).RPE ?? current.RPEEnabled ?? current.PlatformErase) } - + let requestedState = BOOT_SERVICE_STATE_BOTH_OFF + if (ocrOn && rpeOn) requestedState = BOOT_SERVICE_STATE_BOTH_ON + else if (ocrOn) requestedState = BOOT_SERVICE_STATE_OCR_ONLY + else if (rpeOn) requestedState = BOOT_SERVICE_STATE_RPE_ONLY + await req.deviceAction.BootServiceStateChange(requestedState) + } else if (rpeDesired !== undefined) { + // OCR not in request — read current OCR state so RPE-only update does not clear it. + const ocrData = await req.deviceAction.getOCRData() + const currentBootServiceState = ocrData.bootService?.CIM_BootService?.EnabledState + const ocrOn = currentBootServiceState === BOOT_SERVICE_STATE_OCR_ONLY || currentBootServiceState === BOOT_SERVICE_STATE_BOTH_ON + let requestedState = BOOT_SERVICE_STATE_BOTH_OFF + if (ocrOn && rpeDesired) requestedState = BOOT_SERVICE_STATE_BOTH_ON + else if (ocrOn) requestedState = BOOT_SERVICE_STATE_OCR_ONLY + else if (rpeDesired) requestedState = BOOT_SERVICE_STATE_RPE_ONLY await req.deviceAction.BootServiceStateChange(requestedState) } + }) // end withDeviceLock MqttProvider.publishEvent('success', ['AMT_SetFeatures'], messages.AMT_FEATURES_SET_SUCCESS, guid) res.status(200).json({ status: messages.AMT_FEATURES_SET_SUCCESS }).end() } catch (error) { logger.error(`${messages.AMT_FEATURES_SET_EXCEPTION}: ${error}`) MqttProvider.publishEvent('fail', ['AMT_SetFeatures'], messages.INTERNAL_SERVICE_ERROR) - res.status(500).json(ErrorResponse(500, messages.AMT_FEATURES_SET_EXCEPTION)).end() + if (error instanceof MPSValidationError) { + res.status(error.status ?? 400).json(ErrorResponse(error.status ?? 400, error.message)).end() + } else { + res.status(500).json(ErrorResponse(500, messages.AMT_FEATURES_SET_EXCEPTION)).end() + } } } export async function setRedirectionService( diff --git a/src/routes/index.test.ts b/src/routes/index.test.ts index 389f454ea..f8b7aeabe 100644 --- a/src/routes/index.test.ts +++ b/src/routes/index.test.ts @@ -7,7 +7,8 @@ import router from './index.js' describe('Check index from routes', () => { const routes = [ - { path: '/ciracert', method: 'get' }] + { path: '/ciracert', method: 'get' } + ] it('should have routes', () => { routes.forEach((route) => { const match = router.stack.find((s) => s.route?.path === route.path && (s.route as any)?.methods[route.method]) diff --git a/src/server/webserver.ts b/src/server/webserver.ts index a5e37cb15..a36c157ad 100644 --- a/src/server/webserver.ts +++ b/src/server/webserver.ts @@ -95,7 +95,7 @@ export class WebServer { const pathToCustomMiddleware = path.join(this.__dirname, '../middleware/custom') const middleware: RequestHandler[] = [] const doesExist = existsSync(pathToCustomMiddleware) - const isDirectory = lstatSync(pathToCustomMiddleware).isDirectory() + const isDirectory = doesExist && lstatSync(pathToCustomMiddleware).isDirectory() if (doesExist && isDirectory) { const files = readdirSync(pathToCustomMiddleware) for (const file of files) { diff --git a/src/test/collections/MPS.postman_collection.json b/src/test/collections/MPS.postman_collection.json index 6992f6d02..84c57698a 100644 --- a/src/test/collections/MPS.postman_collection.json +++ b/src/test/collections/MPS.postman_collection.json @@ -3166,7 +3166,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\r\n \"userConsent\": \"none\",\r\n \"enableSOL\": \"false\",\r\n \"enableIDER\": \"false\",\r\n \"enableKVM\": \"false\"\r\n}", + "raw": "{\r\n \"userConsent\": \"none\",\r\n \"enableSOL\": \"false\",\r\n \"enableIDER\": \"false\",\r\n \"enableKVM\": \"false\",\r\n \"platformEraseEnabled\": false\r\n}", "options": { "raw": { "language": "json" @@ -3190,6 +3190,49 @@ }, "response": [] }, + { + "name": "Send Remote Erase", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 404\", function () {\r\n pm.response.to.have.status(404);\r\n});\r\n\r\npm.test(\"Device should not be found\", function () {\r\n var jsonData = pm.response.json();\r\n pm.expect(jsonData.error).to.eq(\"Device not found/connected. Please connect again using CIRA.\")\r\n pm.expect(jsonData.errorDescription).to.eq(\"guid : 1\")\r\n});\r\n" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"secureEraseAllSSDs\": true,\r\n \"tpmClear\": false,\r\n \"restoreBIOSToEOM\": false,\r\n \"unconfigureCSME\": false\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{protocol}}://{{host}}/api/v1/amt/boot/remoteErase/1", + "protocol": "{{protocol}}", + "host": [ + "{{host}}" + ], + "path": [ + "api", + "v1", + "amt", + "boot", + "remoteErase", + "1" + ] + } + }, + "response": [] + }, { "name": "Get Version", "event": [ diff --git a/src/test/helper/wsmanResponses.ts b/src/test/helper/wsmanResponses.ts index bffbbd4bf..1c911df18 100644 --- a/src/test/helper/wsmanResponses.ts +++ b/src/test/helper/wsmanResponses.ts @@ -54,7 +54,7 @@ export const serviceAvailableToElement = { '2', '5' ], - PowerState: '4', + PowerState: 4, ServiceProvided: { Address: 'http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous', ReferenceParameters: { diff --git a/swagger.yaml b/swagger.yaml index 27b692fd4..67ef22739 100644 --- a/swagger.yaml +++ b/swagger.yaml @@ -299,6 +299,74 @@ paths: application/json: schema: $ref: '#/components/schemas/GetAMTFeaturesResponse' + /api/v1/amt/boot/remoteErase/{guid}: + get: + summary: Get Remote Platform Erase capabilities + description: Returns the Remote Platform Erase capabilities supported by the device as individual boolean flags. + 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: 'Remote Platform Erase capabilities supported by this device' + content: + application/json: + schema: + $ref: '#/components/schemas/RemoteEraseCapabilitiesResponse' + 404: + description: 'Device not found/connected' + 500: + description: 'Internal server error' + post: + summary: Trigger Remote Platform Erase + description: | + Initiates a Remote Platform Erase on the specified AMT device. + RPE must be enabled on the device first via `POST /api/v1/amt/features/{guid}`. + + Supported erase options (select one or more hardware bits, optionally combined with CSME): + - `secureEraseAllSSDs` — Secure erase all SSDs (bit 0x04) + - `tpmClear` — Clear TPM (bit 0x40) + - `restoreBIOSToEOM` — Reload BIOS golden configuration (bit 0x4000000) + - `unconfigureCSME` — CSME unconfigure; can be combined with hardware bits + - `ssdPassword` — Optional password for encrypted SSD erase (max 64 bytes) + + Returns 400 if the device does not support RPE or the requested options are not supported. + tags: + - AMT + parameters: + - name: guid + in: path + description: GUID of device + example: 123e4567-e89b-12d3-a456-426614174000 + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RemoteEraseRequest' + responses: + 200: + description: 'Remote Platform Erase initiated successfully' + content: + application/json: + schema: + $ref: '#/components/schemas/RPEStatusResponse' + 400: + description: 'Device does not support RPE, requested options not supported, or password too long' + 404: + description: 'Device not found/connected' + 500: + description: 'Internal server error' /api/v1/amt/alarmOccurrences/{guid}: post: summary: Set new Alarm Clock Occurence @@ -3329,6 +3397,9 @@ components: type: boolean ocr: type: boolean + platformEraseEnabled: + type: boolean + description: Enable or disable Remote Platform Erase (RPE). Only applied when the device reports PlatformErase capability. SetAMTFeaturesResponse: type: string example: @@ -3366,7 +3437,9 @@ components: type: boolean winREBootSupported: type: boolean - remoteErase: + rpe: + type: boolean + rpeSupported: type: boolean example: userConsent: kvm @@ -3379,7 +3452,8 @@ components: httpsBootSupported: true winREBootSupported: true localPBABootSupported: true - remoteErase: false + rpe: true + rpeSupported: true SetAlarmClockRequest: title: SetAlarmClockRequest @@ -4468,3 +4542,55 @@ components: structuredBiosBootString: type: string example: '' + RemoteEraseCapabilitiesResponse: + title: RemoteEraseCapabilitiesResponse + properties: + secureEraseAllSSDs: + type: boolean + description: 'Device supports secure erase of all SSDs (bit 0x04)' + tpmClear: + type: boolean + description: 'Device supports TPM clear (bit 0x40)' + restoreBIOSToEOM: + type: boolean + description: 'Device supports restoring BIOS to end-of-manufacturing state (bit 0x4000000)' + unconfigureCSME: + type: boolean + description: 'Device supports CSME unconfigure / ConfigurationDataReset (bit 0x10000)' + example: + secureEraseAllSSDs: true + tpmClear: true + restoreBIOSToEOM: false + unconfigureCSME: false + RemoteEraseRequest: + title: RemoteEraseRequest + properties: + secureEraseAllSSDs: + type: boolean + description: 'Bit 2 (0x04) — Secure erase all SSDs' + tpmClear: + type: boolean + description: 'Bit 6 (0x40) — Clear TPM' + restoreBIOSToEOM: + type: boolean + description: 'Bit 26 (0x4000000) — Reload BIOS golden configuration' + unconfigureCSME: + type: boolean + description: 'CSME unconfigure — sets ConfigurationDataReset; can be combined with hardware bits' + ssdPassword: + type: string + description: 'Optional password for encrypted SSD erase (max 64 bytes)' + maxLength: 64 + example: + secureEraseAllSSDs: true + tpmClear: false + restoreBIOSToEOM: false + unconfigureCSME: false + ssdPassword: '' + RPEStatusResponse: + title: RPEStatusResponse + properties: + status: + type: string + example: + status: success