feat(config): read secrets from NAME_FILE variables (#1421)

Co-authored-by: Hampus <[email protected]>
This commit is contained in:
Kai Compton
2026-10-06 21:43:08 +02:00
committed by GitHub
co-authored by Hampus
parent cc5545c333
commit 0c4f016ba2
17 changed files with 534 additions and 31 deletions
@@ -1,6 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {generateKeyPairSync} from 'node:crypto';
import {mkdtempSync, rmSync, writeFileSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {getConfig, loadConfig, resetConfig} from '@fluxer/config/src/ConfigLoader';
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
@@ -80,6 +83,24 @@ describe('ConfigLoader', () => {
expect((await loadConfig()).domain.base_domain).toBe('localhost');
});
test('loadConfig reads secrets from NAME_FILE', async () => {
const dir = mkdtempSync(join(tmpdir(), 'fluxer-config-file-'));
try {
const path = join(dir, 'postgres_password');
writeFileSync(path, 'from-secret-file\n');
stubMinimalEnv({FLUXER_POSTGRES_PASSWORD: '', FLUXER_POSTGRES_PASSWORD_FILE: path});
const config = await loadConfig();
expect(config.database.postgres.password).toBe('from-secret-file');
} finally {
rmSync(dir, {recursive: true, force: true});
}
});
test('loadConfig rejects NAME and NAME_FILE together', async () => {
stubMinimalEnv({FLUXER_SUDO_MODE_SECRET_FILE: '/run/secrets/sudo'});
await expect(loadConfig()).rejects.toThrow('FLUXER_SUDO_MODE_SECRET and FLUXER_SUDO_MODE_SECRET_FILE are both set');
});
test('getConfig throws when config is not loaded', () => {
expect(() => getConfig()).toThrow('Config not loaded');
});
@@ -1,7 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {buildNamedFluxerEnvOverrides, setNestedValue} from '@fluxer/config/src/config_loader/EnvironmentOverrides';
import {describe, expect, test} from 'vitest';
import {mkdtempSync, rmSync, writeFileSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {
buildNamedFluxerEnvOverrides,
readEnvValue,
setNestedValue,
} from '@fluxer/config/src/config_loader/EnvironmentOverrides';
import {afterAll, describe, expect, test} from 'vitest';
describe('setNestedValue', () => {
test('sets a top-level key', () => {
@@ -316,3 +323,69 @@ describe('buildNamedFluxerEnvOverrides', () => {
);
});
});
describe('readEnvValue with NAME_FILE', () => {
const dir = mkdtempSync(join(tmpdir(), 'fluxer-env-file-'));
afterAll(() => rmSync(dir, {recursive: true, force: true}));
function secretFile(name: string, contents: string): string {
const path = join(dir, name);
writeFileSync(path, contents);
return path;
}
test('reads the value from NAME_FILE when NAME is unset', () => {
const path = secretFile('plain', 'from-file\n');
expect(readEnvValue({FLUXER_SUDO_MODE_SECRET_FILE: path}, 'FLUXER_SUDO_MODE_SECRET')).toBe('from-file');
});
test('reads the value from NAME_FILE when NAME is blank', () => {
const path = secretFile('blank', 'from-file');
expect(
readEnvValue({FLUXER_SUDO_MODE_SECRET: ' ', FLUXER_SUDO_MODE_SECRET_FILE: path}, 'FLUXER_SUDO_MODE_SECRET'),
).toBe('from-file');
});
test('trims only one trailing newline', () => {
const crlf = secretFile('crlf', 'value\r\n');
const multi = secretFile('multi', '-----BEGIN-----\nabc\n-----END-----\n\n');
expect(readEnvValue({X_FILE: crlf}, 'X')).toBe('value');
expect(readEnvValue({X_FILE: multi}, 'X')).toBe('-----BEGIN-----\nabc\n-----END-----\n');
});
test('treats an empty file as unset', () => {
const path = secretFile('empty', '\n');
expect(readEnvValue({X_FILE: path}, 'X')).toBeUndefined();
});
test('keeps NAME when NAME_FILE is blank', () => {
expect(readEnvValue({X: 'direct', X_FILE: ''}, 'X')).toBe('direct');
});
test('rejects NAME and NAME_FILE together', () => {
const path = secretFile('both', 'from-file');
expect(() => readEnvValue({X: 'direct', X_FILE: path}, 'X')).toThrow('X and X_FILE are both set, set only one');
});
test('names NAME_FILE and the path when the file is missing', () => {
const path = join(dir, 'missing');
expect(() => readEnvValue({X_FILE: path}, 'X')).toThrow(`X_FILE could not read ${path} (ENOENT)`);
});
test('rejects a file that is not valid UTF-8', () => {
const path = join(dir, 'binary');
writeFileSync(path, Buffer.from([0xff, 0x61]));
expect(() => readEnvValue({X_FILE: path}, 'X')).toThrow(`X_FILE could not read ${path} (`);
});
test('feeds named overrides and aliases', () => {
const secret = secretFile('stripe', 'sk_test_file\n');
const nats = secretFile('nats', 'nats://nats:4222\n');
expect(
buildNamedFluxerEnvOverrides({FLUXER_STRIPE_SECRET_KEY_FILE: secret, FLUXER_NATS_CORE_URL_FILE: nats}),
).toMatchObject({
integrations: {stripe: {secret_key: 'sk_test_file'}},
services: {nats: {core_url: 'nats://nats:4222'}},
});
});
});
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {readFileSync} from 'node:fs';
import {type ConfigObject, isConfigObject} from '@fluxer/config/src/config_loader/ConfigObject';
type ConfigPathKey = string | number;
@@ -420,11 +421,29 @@ const NAMED_FLUXER_ENV_ALIASES: Record<string, string | undefined> = {
export const NAMED_FLUXER_ENV_NAMES = Object.keys(NAMED_FLUXER_ENV_OVERRIDES);
export function readEnvValue(env: NodeJS.ProcessEnv, name: string): string | undefined {
const value = env[name];
function nonBlank(value: string | undefined): string | undefined {
return value === undefined || value.trim().length === 0 ? undefined : value;
}
export function readEnvValue(env: NodeJS.ProcessEnv, name: string): string | undefined {
const value = nonBlank(env[name]);
const filePath = nonBlank(env[`${name}_FILE`]);
if (filePath === undefined) {
return value;
}
if (value !== undefined) {
throw new Error(`${name} and ${name}_FILE are both set, set only one`);
}
let contents: string;
try {
contents = new TextDecoder('utf-8', {fatal: true}).decode(readFileSync(filePath));
} catch (error) {
const reason = error instanceof Error && 'code' in error ? String(error.code) : 'unreadable';
throw new Error(`${name}_FILE could not read ${filePath} (${reason})`);
}
return nonBlank(contents.replace(/\r?\n$/, ''));
}
export function buildNamedFluxerEnvOverrides(env: NodeJS.ProcessEnv): ConfigObject {
const overrides: ConfigObject = {};
for (const [envKey, mapping] of Object.entries(NAMED_FLUXER_ENV_OVERRIDES)) {