From 64208ae29a114ad915af401593b74f043de7f90e Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 20 Mar 2026 16:46:15 -0400 Subject: [PATCH 1/4] feat(api): support encrypted array start inputs --- api/src/core/utils/clients/emcmd.ts | 12 ++- .../graph/resolvers/array/array.model.ts | 21 +++- .../resolvers/array/array.service.spec.ts | 88 +++++++++++++++ .../graph/resolvers/array/array.service.ts | 100 +++++++++++++++++- 4 files changed, 217 insertions(+), 4 deletions(-) diff --git a/api/src/core/utils/clients/emcmd.ts b/api/src/core/utils/clients/emcmd.ts index 0cf2bee1dd..71236d230e 100644 --- a/api/src/core/utils/clients/emcmd.ts +++ b/api/src/core/utils/clients/emcmd.ts @@ -24,6 +24,16 @@ const hasErrorCode = (error: unknown): error is { code: string } => { return Boolean(error && typeof error === 'object' && 'code' in error); }; +const redactCommandsForLog = (commands: LooseObject): LooseObject => + Object.fromEntries( + Object.entries(commands).map(([key, value]) => [ + key, + ['luksKey', 'luksKeyfile', 'decryptionPassword', 'decryptionKeyfile'].includes(key) + ? '***REDACTED***' + : value, + ]) + ); + const readCsrfTokenFromVarIni = async (): Promise => { try { const iniContents = await readFile(VAR_INI_PATH, 'utf-8'); @@ -102,7 +112,7 @@ export const emcmd = async ( const stateToken = getters.emhttp().var?.csrfToken; const csrfToken = await ensureCsrfToken(stateToken, waitForToken); - appLogger.debug(`Executing emcmd with commands: ${JSON.stringify(commands)}`); + appLogger.debug({ commands: redactCommandsForLog(commands) }, 'Executing emcmd'); try { const params = new URLSearchParams(); diff --git a/api/src/unraid-api/graph/resolvers/array/array.model.ts b/api/src/unraid-api/graph/resolvers/array/array.model.ts index edb850ef44..a25b5f8b6d 100644 --- a/api/src/unraid-api/graph/resolvers/array/array.model.ts +++ b/api/src/unraid-api/graph/resolvers/array/array.model.ts @@ -2,7 +2,7 @@ import { Field, InputType, Int, ObjectType, registerEnumType } from '@nestjs/gra import { Node } from '@unraid/shared/graphql.model.js'; import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js'; -import { IsEnum, IsInt, IsOptional, IsString } from 'class-validator'; +import { IsEnum, IsInt, IsOptional, IsString, MinLength } from 'class-validator'; import { GraphQLBigInt } from 'graphql-scalars'; import { ParityCheck } from '@app/unraid-api/graph/resolvers/array/parity.model.js'; @@ -197,6 +197,25 @@ export class ArrayStateInput { @Field(() => ArrayStateInputState, { description: 'Array state' }) @IsEnum(ArrayStateInputState) desiredState!: ArrayStateInputState; + + @Field(() => String, { + nullable: true, + description: 'Optional password used to unlock encrypted array disks when starting the array', + }) + @IsOptional() + @IsString() + @MinLength(1) + decryptionPassword?: string; + + @Field(() => String, { + nullable: true, + description: + 'Optional keyfile contents used to unlock encrypted array disks when starting the array. Accepts a data URL or raw base64 payload.', + }) + @IsOptional() + @IsString() + @MinLength(1) + decryptionKeyfile?: string; } export enum ArrayState { diff --git a/api/src/unraid-api/graph/resolvers/array/array.service.spec.ts b/api/src/unraid-api/graph/resolvers/array/array.service.spec.ts index 0161458e13..1a76c0b5a0 100644 --- a/api/src/unraid-api/graph/resolvers/array/array.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/array/array.service.spec.ts @@ -1,6 +1,7 @@ import type { TestingModule } from '@nestjs/testing'; import { BadRequestException } from '@nestjs/common'; import { Test } from '@nestjs/testing'; +import { mkdir, writeFile } from 'node:fs/promises'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -25,6 +26,11 @@ vi.mock('@app/core/modules/array/get-array-data.js', () => ({ getArrayData: vi.fn(), })); +vi.mock('node:fs/promises', () => ({ + mkdir: vi.fn(), + writeFile: vi.fn(), +})); + vi.mock('@app/store/index.js', () => ({ getters: { emhttp: vi.fn(), @@ -41,6 +47,8 @@ describe('ArrayService', () => { let mockGetState: ReturnType; let mockEmcmd: ReturnType; let mockGetArrayDataUtil: ReturnType; + let mockMkdir: ReturnType; + let mockWriteFile: ReturnType; beforeEach(async () => { vi.resetAllMocks(); @@ -51,6 +59,8 @@ describe('ArrayService', () => { mockEmcmd = vi.mocked(emcmd); mockGetArrayDataUtil = vi.mocked(getArrayDataUtil); + mockMkdir = vi.mocked(mkdir); + mockWriteFile = vi.mocked(writeFile); const module: TestingModule = await Test.createTestingModule({ providers: [ArrayService], @@ -61,6 +71,7 @@ describe('ArrayService', () => { mockEmhttp.mockReturnValue({ var: { mdState: ArrayState.STOPPED, + luksKeyfile: '/tmp/unraid/keyfile', }, } as any); @@ -93,6 +104,8 @@ describe('ArrayService', () => { }, }; mockGetArrayDataUtil.mockResolvedValue(mockArrayData); + mockMkdir.mockResolvedValue(undefined); + mockWriteFile.mockResolvedValue(undefined); mockGetState.mockReturnValue({ /* mock state if needed by getArrayDataUtil */ @@ -128,6 +141,45 @@ describe('ArrayService', () => { expect(mockGetArrayDataUtil).toHaveBeenCalledTimes(1); }); + it('should START a STOPPED encrypted array with a decryption password', async () => { + const input: ArrayStateInput = { + desiredState: ArrayStateInputState.START, + decryptionPassword: 'super-secret', + }; + const expectedArrayData = { ...mockArrayData, state: ArrayState.STARTED }; + mockGetArrayDataUtil.mockResolvedValue(expectedArrayData); + + const result = await service.updateArrayState(input); + + expect(result).toEqual(expectedArrayData); + expect(mockEmcmd).toHaveBeenCalledWith({ + cmdStart: 'Start', + startState: 'STOPPED', + luksKey: 'c3VwZXItc2VjcmV0', + }); + }); + + it('should START a STOPPED encrypted array with a keyfile payload', async () => { + const input: ArrayStateInput = { + desiredState: ArrayStateInputState.START, + decryptionKeyfile: 'data:application/octet-stream;base64,QUJDRA==', + }; + const expectedArrayData = { ...mockArrayData, state: ArrayState.STARTED }; + mockGetArrayDataUtil.mockResolvedValue(expectedArrayData); + + const result = await service.updateArrayState(input); + + expect(result).toEqual(expectedArrayData); + expect(mockMkdir).toHaveBeenCalledWith('/tmp/unraid', { recursive: true }); + expect(mockWriteFile).toHaveBeenCalledWith('/tmp/unraid/keyfile', Buffer.from('ABCD'), { + mode: 0o600, + }); + expect(mockEmcmd).toHaveBeenCalledWith({ + cmdStart: 'Start', + startState: 'STOPPED', + }); + }); + it('should STOP a STARTED array', async () => { mockEmhttp.mockReturnValue({ var: { mdState: ArrayState.STARTED } } as any); const input: ArrayStateInput = { desiredState: ArrayStateInputState.STOP }; @@ -158,6 +210,42 @@ describe('ArrayService', () => { await expect(service.updateArrayState(input)).rejects.toThrow(BadRequestException); expect(mockEmcmd).not.toHaveBeenCalled(); }); + + it('should reject a decryption password that does not match webgui ASCII validation', async () => { + const input: ArrayStateInput = { + desiredState: ArrayStateInputState.START, + decryptionPassword: 'pÄssword', + }; + + await expect(service.updateArrayState(input)).rejects.toThrow(BadRequestException); + + expect(mockEmcmd).not.toHaveBeenCalled(); + }); + + it('should reject invalid decryption keyfile payloads', async () => { + const input: ArrayStateInput = { + desiredState: ArrayStateInputState.START, + decryptionKeyfile: 'not-valid-base64', + }; + + await expect(service.updateArrayState(input)).rejects.toThrow(BadRequestException); + + expect(mockWriteFile).not.toHaveBeenCalled(); + expect(mockEmcmd).not.toHaveBeenCalled(); + }); + + it('should reject providing both a decryption password and keyfile', async () => { + const input: ArrayStateInput = { + desiredState: ArrayStateInputState.START, + decryptionPassword: 'super-secret', + decryptionKeyfile: 'data:application/octet-stream;base64,QUJDRA==', + }; + + await expect(service.updateArrayState(input)).rejects.toThrow(BadRequestException); + + expect(mockWriteFile).not.toHaveBeenCalled(); + expect(mockEmcmd).not.toHaveBeenCalled(); + }); }); describe('addDiskToArray', () => { diff --git a/api/src/unraid-api/graph/resolvers/array/array.service.ts b/api/src/unraid-api/graph/resolvers/array/array.service.ts index 117e15132c..30a1d61e19 100644 --- a/api/src/unraid-api/graph/resolvers/array/array.service.ts +++ b/api/src/unraid-api/graph/resolvers/array/array.service.ts @@ -1,4 +1,6 @@ import { BadRequestException, Injectable } from '@nestjs/common'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; import { capitalCase, constantCase } from 'change-case'; import { GraphQLError } from 'graphql'; @@ -24,6 +26,82 @@ enum ArrayPendingState { export class ArrayService { private pendingState: ArrayPendingState | null = null; + private encodeDecryptionPassword = (decryptionPassword: string) => { + const printableAscii = /^[ -~]+$/; + + if (!printableAscii.test(decryptionPassword)) { + throw new BadRequestException( + new AppError( + 'Decryption password must use printable ASCII characters. Use a keyfile for UTF-8 input.' + ) + ); + } + + return Buffer.from(decryptionPassword, 'utf8').toString('base64'); + }; + + private decodeDecryptionKeyfile = (decryptionKeyfile: string): Buffer => { + const source = decryptionKeyfile.trim(); + const dataUrlMatch = /^data:[^,]*,(.+)$/i.exec(source); + + if (dataUrlMatch) { + const meta = source.slice(5, source.indexOf(',')); + const payload = dataUrlMatch[1]; + + try { + return /;base64/i.test(meta) + ? Buffer.from(payload, 'base64') + : Buffer.from(decodeURIComponent(payload), 'utf8'); + } catch { + throw new BadRequestException( + new AppError('Decryption keyfile must be a valid data URL or raw base64 payload.') + ); + } + } + + const normalized = source.replace(/\s+/g, ''); + if ( + normalized.length === 0 || + normalized.length % 4 !== 0 || + !/^[A-Za-z0-9+/=]+$/.test(normalized) + ) { + throw new BadRequestException( + new AppError('Decryption keyfile must be a valid data URL or raw base64 payload.') + ); + } + + try { + const decoded = Buffer.from(normalized, 'base64'); + if (!decoded.length) { + throw new Error('empty'); + } + const normalizedInput = normalized.replace(/=+$/g, ''); + const normalizedDecoded = decoded.toString('base64').replace(/=+$/g, ''); + if (normalizedInput !== normalizedDecoded) { + throw new Error('mismatch'); + } + return decoded; + } catch { + throw new BadRequestException( + new AppError('Decryption keyfile must be a valid data URL or raw base64 payload.') + ); + } + }; + + private writeDecryptionKeyfile = async (decryptionKeyfile: string) => { + const { getters } = await import('@app/store/index.js'); + const luksKeyfile = getters.emhttp().var?.luksKeyfile; + + if (!luksKeyfile) { + throw new BadRequestException( + new AppError('Array decryption keyfile path is not configured.') + ); + } + + await mkdir(dirname(luksKeyfile), { recursive: true }); + await writeFile(luksKeyfile, this.decodeDecryptionKeyfile(decryptionKeyfile), { mode: 0o600 }); + }; + /** * Is the array running? * @todo Refactor this to include this util in the service directly @@ -45,7 +123,11 @@ export class ArrayService { return getArrayDataUtil(store.getState); } - async updateArrayState({ desiredState }: ArrayStateInput): Promise { + async updateArrayState({ + desiredState, + decryptionPassword, + decryptionKeyfile, + }: ArrayStateInput): Promise { if (this.pendingState) { throw new BadRequestException( new AppError(`Array state is still being updated. Changing to ${this.pendingState}`) @@ -69,11 +151,25 @@ export class ArrayService { // Set lock then start/stop array this.pendingState = newPendingState; - const command = { + const command: Record = { [`cmd${capitalCase(desiredState)}`]: capitalCase(desiredState), startState: constantCase(startState), }; + if (decryptionPassword && decryptionKeyfile) { + throw new BadRequestException( + new AppError('Provide either a decryption password or a decryption keyfile, not both.') + ); + } + + if (desiredState === ArrayStateInputState.START && decryptionPassword) { + command.luksKey = this.encodeDecryptionPassword(decryptionPassword); + } + + if (desiredState === ArrayStateInputState.START && decryptionKeyfile) { + await this.writeDecryptionKeyfile(decryptionKeyfile); + } + try { await emcmd(command); } finally { From 5c2092057a1e6d6be061d8d9127fa1526a198ab2 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 20 Mar 2026 16:49:23 -0400 Subject: [PATCH 2/4] refactor(api): move emcmd redaction to logger config --- api/src/core/log.ts | 4 ++++ api/src/core/utils/clients/emcmd.ts | 12 +----------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/api/src/core/log.ts b/api/src/core/log.ts index 22433ff5b7..ca9c795b76 100644 --- a/api/src/core/log.ts +++ b/api/src/core/log.ts @@ -79,6 +79,10 @@ export const logger = pino( '*.accesstoken', '*.idtoken', '*.refreshtoken', + '*.luksKey', + '*.luksKeyfile', + '*.decryptionPassword', + '*.decryptionKeyfile', ], censor: '***REDACTED***', }, diff --git a/api/src/core/utils/clients/emcmd.ts b/api/src/core/utils/clients/emcmd.ts index 71236d230e..3e8ac14d8b 100644 --- a/api/src/core/utils/clients/emcmd.ts +++ b/api/src/core/utils/clients/emcmd.ts @@ -24,16 +24,6 @@ const hasErrorCode = (error: unknown): error is { code: string } => { return Boolean(error && typeof error === 'object' && 'code' in error); }; -const redactCommandsForLog = (commands: LooseObject): LooseObject => - Object.fromEntries( - Object.entries(commands).map(([key, value]) => [ - key, - ['luksKey', 'luksKeyfile', 'decryptionPassword', 'decryptionKeyfile'].includes(key) - ? '***REDACTED***' - : value, - ]) - ); - const readCsrfTokenFromVarIni = async (): Promise => { try { const iniContents = await readFile(VAR_INI_PATH, 'utf-8'); @@ -112,7 +102,7 @@ export const emcmd = async ( const stateToken = getters.emhttp().var?.csrfToken; const csrfToken = await ensureCsrfToken(stateToken, waitForToken); - appLogger.debug({ commands: redactCommandsForLog(commands) }, 'Executing emcmd'); + appLogger.debug({ commands }, 'Executing emcmd'); try { const params = new URLSearchParams(); From af33c1be6c7222ec0e6477bae4cfefc7f21435b5 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 20 Mar 2026 17:12:11 -0400 Subject: [PATCH 3/4] test(api): cover logger redaction for array unlock fields --- api/src/__test__/core/log.test.ts | 47 +++++++++++++++++++++++++++++++ api/src/core/log.ts | 44 +++++++++++++++-------------- 2 files changed, 70 insertions(+), 21 deletions(-) diff --git a/api/src/__test__/core/log.test.ts b/api/src/__test__/core/log.test.ts index 8bc696aa0c..cac5eb3405 100644 --- a/api/src/__test__/core/log.test.ts +++ b/api/src/__test__/core/log.test.ts @@ -1,7 +1,12 @@ +import { once } from 'node:events'; +import { Writable } from 'node:stream'; + +import pino from 'pino'; import pretty from 'pino-pretty'; import { expect, test } from 'vitest'; import { PRETTY_LOG_TIME_FORMAT } from '@app/core/log.constants.js'; +import { LOG_REDACT_PATHS } from '@app/core/log.js'; const padTime = (value: number) => value.toString().padStart(2, '0'); @@ -28,3 +33,45 @@ test('pretty log timestamps keep local minutes instead of using the month token' expect(output).toContain('test message'); expect(output).not.toContain('[16:03:34'); }); + +test('logger redacts encrypted array unlock fields', async () => { + const chunks: string[] = []; + const stream = new Writable({ + write(chunk, _encoding, callback) { + chunks.push(chunk.toString()); + callback(); + }, + }); + + const testLogger = pino( + { + redact: { + paths: [...LOG_REDACT_PATHS], + censor: '***REDACTED***', + }, + }, + stream + ); + + testLogger.info({ + commands: { + luksKey: 'secret-passphrase', + luksKeyfile: '/tmp/unraid/keyfile', + decryptionPassword: 'super-secret', + decryptionKeyfile: 'data:application/octet-stream;base64,QUJDRA==', + startState: 'STOPPED', + }, + }); + + stream.end(); + await once(stream, 'finish'); + + const output = chunks.join(''); + + expect(output).toContain('***REDACTED***'); + expect(output).toContain('"startState":"STOPPED"'); + expect(output).not.toContain('secret-passphrase'); + expect(output).not.toContain('/tmp/unraid/keyfile'); + expect(output).not.toContain('super-secret'); + expect(output).not.toContain('data:application/octet-stream;base64,QUJDRA=='); +}); diff --git a/api/src/core/log.ts b/api/src/core/log.ts index ca9c795b76..7e2edead90 100644 --- a/api/src/core/log.ts +++ b/api/src/core/log.ts @@ -19,6 +19,28 @@ const nullDestination = pino.destination({ const LOG_TRANSPORT = process.env.LOG_TRANSPORT ?? 'file'; const useConsole = LOG_TRANSPORT === 'console'; +export const LOG_REDACT_PATHS = [ + '*.password', + '*.pass', + '*.secret', + '*.token', + '*.key', + '*.Password', + '*.Pass', + '*.Secret', + '*.Token', + '*.Key', + '*.apikey', + '*.localApiKey', + '*.accesstoken', + '*.idtoken', + '*.refreshtoken', + '*.luksKey', + '*.luksKeyfile', + '*.decryptionPassword', + '*.decryptionKeyfile', +] as const; + export const logDestination = process.env.SUPPRESS_LOGS === 'true' ? nullDestination @@ -63,27 +85,7 @@ export const logger = pino( bindings: (bindings) => ({ ...bindings, apiVersion: API_VERSION }), }, redact: { - paths: [ - '*.password', - '*.pass', - '*.secret', - '*.token', - '*.key', - '*.Password', - '*.Pass', - '*.Secret', - '*.Token', - '*.Key', - '*.apikey', - '*.localApiKey', - '*.accesstoken', - '*.idtoken', - '*.refreshtoken', - '*.luksKey', - '*.luksKeyfile', - '*.decryptionPassword', - '*.decryptionKeyfile', - ], + paths: [...LOG_REDACT_PATHS], censor: '***REDACTED***', }, }, From e1726ea2e8f5999929fc7dbae14cae3ba165e70a Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 20 Mar 2026 22:28:20 -0400 Subject: [PATCH 4/4] fix(api): harden encrypted array unlock preflight --- .../resolvers/array/array.service.spec.ts | 39 ++++++++++++++++++- .../graph/resolvers/array/array.service.ts | 29 +++++++------- 2 files changed, 54 insertions(+), 14 deletions(-) diff --git a/api/src/unraid-api/graph/resolvers/array/array.service.spec.ts b/api/src/unraid-api/graph/resolvers/array/array.service.spec.ts index 1a76c0b5a0..0c28cff7b1 100644 --- a/api/src/unraid-api/graph/resolvers/array/array.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/array/array.service.spec.ts @@ -222,7 +222,7 @@ describe('ArrayService', () => { expect(mockEmcmd).not.toHaveBeenCalled(); }); - it('should reject invalid decryption keyfile payloads', async () => { + it('should reject invalid raw decryption keyfile payloads', async () => { const input: ArrayStateInput = { desiredState: ArrayStateInputState.START, decryptionKeyfile: 'not-valid-base64', @@ -234,6 +234,18 @@ describe('ArrayService', () => { expect(mockEmcmd).not.toHaveBeenCalled(); }); + it('should reject invalid data URL decryption keyfile payloads', async () => { + const input: ArrayStateInput = { + desiredState: ArrayStateInputState.START, + decryptionKeyfile: 'data:application/octet-stream;base64,not-valid-base64', + }; + + await expect(service.updateArrayState(input)).rejects.toThrow(BadRequestException); + + expect(mockWriteFile).not.toHaveBeenCalled(); + expect(mockEmcmd).not.toHaveBeenCalled(); + }); + it('should reject providing both a decryption password and keyfile', async () => { const input: ArrayStateInput = { desiredState: ArrayStateInputState.START, @@ -246,6 +258,31 @@ describe('ArrayService', () => { expect(mockWriteFile).not.toHaveBeenCalled(); expect(mockEmcmd).not.toHaveBeenCalled(); }); + + it('should allow a later start after decryption preflight validation fails', async () => { + await expect( + service.updateArrayState({ + desiredState: ArrayStateInputState.START, + decryptionPassword: 'super-secret', + decryptionKeyfile: 'data:application/octet-stream;base64,QUJDRA==', + }) + ).rejects.toThrow(BadRequestException); + + const expectedArrayData = { ...mockArrayData, state: ArrayState.STARTED }; + mockGetArrayDataUtil.mockResolvedValue(expectedArrayData); + + const result = await service.updateArrayState({ + desiredState: ArrayStateInputState.START, + decryptionPassword: 'super-secret', + }); + + expect(result).toEqual(expectedArrayData); + expect(mockEmcmd).toHaveBeenLastCalledWith({ + cmdStart: 'Start', + startState: 'STOPPED', + luksKey: 'c3VwZXItc2VjcmV0', + }); + }); }); describe('addDiskToArray', () => { diff --git a/api/src/unraid-api/graph/resolvers/array/array.service.ts b/api/src/unraid-api/graph/resolvers/array/array.service.ts index 30a1d61e19..5b6a1a0c7d 100644 --- a/api/src/unraid-api/graph/resolvers/array/array.service.ts +++ b/api/src/unraid-api/graph/resolvers/array/array.service.ts @@ -26,6 +26,11 @@ enum ArrayPendingState { export class ArrayService { private pendingState: ArrayPendingState | null = null; + private invalidDecryptionKeyfileError = () => + new BadRequestException( + new AppError('Decryption keyfile must be a valid data URL or raw base64 payload.') + ); + private encodeDecryptionPassword = (decryptionPassword: string) => { const printableAscii = /^[ -~]+$/; @@ -50,24 +55,24 @@ export class ArrayService { try { return /;base64/i.test(meta) - ? Buffer.from(payload, 'base64') + ? this.decodeStrictBase64(payload) : Buffer.from(decodeURIComponent(payload), 'utf8'); } catch { - throw new BadRequestException( - new AppError('Decryption keyfile must be a valid data URL or raw base64 payload.') - ); + throw this.invalidDecryptionKeyfileError(); } } - const normalized = source.replace(/\s+/g, ''); + return this.decodeStrictBase64(source); + }; + + private decodeStrictBase64 = (payload: string): Buffer => { + const normalized = payload.replace(/\s+/g, ''); if ( normalized.length === 0 || normalized.length % 4 !== 0 || !/^[A-Za-z0-9+/=]+$/.test(normalized) ) { - throw new BadRequestException( - new AppError('Decryption keyfile must be a valid data URL or raw base64 payload.') - ); + throw this.invalidDecryptionKeyfileError(); } try { @@ -82,9 +87,7 @@ export class ArrayService { } return decoded; } catch { - throw new BadRequestException( - new AppError('Decryption keyfile must be a valid data URL or raw base64 payload.') - ); + throw this.invalidDecryptionKeyfileError(); } }; @@ -149,8 +152,6 @@ export class ArrayService { throw new BadRequestException(new AppError(`The array is already ${startState}`)); } - // Set lock then start/stop array - this.pendingState = newPendingState; const command: Record = { [`cmd${capitalCase(desiredState)}`]: capitalCase(desiredState), startState: constantCase(startState), @@ -170,6 +171,8 @@ export class ArrayService { await this.writeDecryptionKeyfile(decryptionKeyfile); } + this.pendingState = newPendingState; + try { await emcmd(command); } finally {