Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions api/src/__test__/core/log.test.ts
Original file line number Diff line number Diff line change
@@ -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');

Expand All @@ -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==');
});
40 changes: 23 additions & 17 deletions api/src/core/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -63,23 +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',
],
paths: [...LOG_REDACT_PATHS],
censor: '***REDACTED***',
},
},
Expand Down
2 changes: 1 addition & 1 deletion api/src/core/utils/clients/emcmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@
const stateToken = getters.emhttp().var?.csrfToken;
const csrfToken = await ensureCsrfToken(stateToken, waitForToken);

appLogger.debug(`Executing emcmd with commands: ${JSON.stringify(commands)}`);
appLogger.debug({ commands }, 'Executing emcmd');
Comment thread
elibosley marked this conversation as resolved.
Dismissed

try {
const params = new URLSearchParams();
Expand Down
21 changes: 20 additions & 1 deletion api/src/unraid-api/graph/resolvers/array/array.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Comment thread
elibosley marked this conversation as resolved.
}

export enum ArrayState {
Expand Down
125 changes: 125 additions & 0 deletions api/src/unraid-api/graph/resolvers/array/array.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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(),
Expand All @@ -41,6 +47,8 @@ describe('ArrayService', () => {
let mockGetState: ReturnType<typeof vi.fn>;
let mockEmcmd: ReturnType<typeof vi.fn>;
let mockGetArrayDataUtil: ReturnType<typeof vi.fn>;
let mockMkdir: ReturnType<typeof vi.fn>;
let mockWriteFile: ReturnType<typeof vi.fn>;

beforeEach(async () => {
vi.resetAllMocks();
Expand All @@ -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],
Expand All @@ -61,6 +71,7 @@ describe('ArrayService', () => {
mockEmhttp.mockReturnValue({
var: {
mdState: ArrayState.STOPPED,
luksKeyfile: '/tmp/unraid/keyfile',
},
} as any);

Expand Down Expand Up @@ -93,6 +104,8 @@ describe('ArrayService', () => {
},
};
mockGetArrayDataUtil.mockResolvedValue(mockArrayData);
mockMkdir.mockResolvedValue(undefined);
mockWriteFile.mockResolvedValue(undefined);

mockGetState.mockReturnValue({
/* mock state if needed by getArrayDataUtil */
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -158,6 +210,79 @@ 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 raw 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 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,
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();
});

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', () => {
Expand Down
Loading
Loading