Skip to content
Open
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
7 changes: 7 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
### DOCKER SECRETS ###
# Any variable the panel validates on startup can be provided in a file instead of a value:
# point <VARIABLE>_FILE to a file that holds the value, e.g.
# APP_SECRET_FILE=/run/secrets/app_secret
# Handy with Docker Compose "secrets:", which mounts them to /run/secrets/<name>.
# Setting both <VARIABLE> and <VARIABLE>_FILE aborts the startup.

### APP ###
APP_PORT=3000
METRICS_PORT=3001
Expand Down
23 changes: 23 additions & 0 deletions prisma.config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,31 @@
import 'dotenv/config';
import type { PrismaConfig } from 'prisma';

import { readFileSync } from 'node:fs';
import path from 'node:path';

// Docker secrets: DATABASE_URL_FILE/DIRECT_URL_FILE point to a file holding the value.
// Inlined on purpose, only this file (not src/) is copied into the runtime image.
for (const key of ['DATABASE_URL', 'DIRECT_URL']) {
const filePath = process.env[`${key}_FILE`];

if (!filePath) {
continue;
}

if (process.env[key]) {
throw new Error(`${key} and ${key}_FILE are both set. Remove one of them.`);
}

const value = readFileSync(filePath, 'utf8').replace(/(\r?\n)+$/, '');

if (!value) {
throw new Error(`${key}_FILE points to "${filePath}", which is empty.`);
}

process.env[key] = value;
}

if (!process.env.DIRECT_URL) {
// eslint-disable-next-line no-console
console.log('DIRECT_URL is not set, using DATABASE_URL');
Expand Down
4 changes: 4 additions & 0 deletions prisma/seed/config.seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
import { Redis } from 'ioredis';

import { configSchema } from '@common/config/app-config/config.schema';
import { getRedisConnectionOptions } from '@common/utils';
import { loadSecretsFromFiles } from '@common/utils/load-secrets-from-files';

import {
checkupExternalSquads,
Expand All @@ -26,6 +28,8 @@ import {
migrateSharedLists,
} from './seeders';

loadSecretsFromFiles(process.env, Object.keys(configSchema.shape));

dayjs.extend(utc);
dayjs.extend(relativeTime);
dayjs.extend(timezone);
Expand Down
4 changes: 4 additions & 0 deletions src/bin/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,17 @@ import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
import Redis from 'ioredis';

import { configSchema } from '@common/config/app-config/config.schema';
import { getRedisConnectionOptions } from '@common/utils';
import { generateNodeCert } from '@common/utils/certs';
import { encodeCertPayload } from '@common/utils/certs/encode-node-payload';
import { loadSecretsFromFiles } from '@common/utils/load-secrets-from-files';
import { CACHE_KEYS } from '@libs/contracts/constants';

import { TResponseRuleEncryption } from '@modules/subscription-response-rules/types/response-rules.types';

loadSecretsFromFiles(process.env, Object.keys(configSchema.shape));

dayjs.extend(utc);
dayjs.extend(relativeTime);
dayjs.extend(timezone);
Expand Down
7 changes: 6 additions & 1 deletion src/common/config/common-config/common-config.module.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Global, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';

import { loadSecretsFromFiles } from '@common/utils/load-secrets-from-files';
import { validateEnvConfig } from '@common/utils/validate-env-config';

import { configSchema, Env } from '../app-config';
Expand All @@ -15,7 +16,11 @@ import { NotificationsConfigService } from './notifications-config.service';
isGlobal: true,
cache: true,
envFilePath: '.env',
validate: (config) => validateEnvConfig<Env>(configSchema, config),
validate: (config) =>
validateEnvConfig<Env>(
configSchema,
loadSecretsFromFiles(config, Object.keys(configSchema.shape)),
),
load: [notificationsConfig],
}),
],
Expand Down
60 changes: 60 additions & 0 deletions src/common/utils/load-secrets-from-files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { readFileSync } from 'node:fs';

/**
* Docker/Podman secrets support.
*
* For every known variable `X` the value can be provided in a file by setting `X_FILE`
* (e.g. `APP_SECRET_FILE=/run/secrets/app_secret`), the same convention the official
* postgres/mysql images use. Resolved values are written both to the returned config
* and to `process.env`, so consumers that read `process.env` directly (Prisma) see them too.
*
* Secret values are never included in error messages or logs.
*/
export function loadSecretsFromFiles<T extends Record<string, unknown>>(
config: T,
keys: readonly string[],
): T {
const resolvedConfig: Record<string, unknown> = { ...config };

for (const key of keys) {
const fileKey = `${key}_FILE`;
const filePath = nonEmptyString(resolvedConfig[fileKey]);

if (!filePath) {
continue;
}

if (nonEmptyString(resolvedConfig[key])) {
throw new Error(
`❌ ${key} and ${fileKey} are both set. Remove one of them and restart the application.`,
);
}

let value: string;

try {
value = readFileSync(filePath, 'utf8');
} catch (error) {
const code = (error as NodeJS.ErrnoException).code ?? 'unknown error';

throw new Error(
`❌ ${fileKey} points to "${filePath}", which can not be read: ${code}`,
);
}

value = value.replace(/(\r?\n)+$/, '');

if (!value) {
throw new Error(`❌ ${fileKey} points to "${filePath}", which is empty.`);
}

resolvedConfig[key] = value;
process.env[key] = value;
}

return resolvedConfig as T;
}

function nonEmptyString(value: unknown): string | undefined {
return typeof value === 'string' && value !== '' ? value : undefined;
}