first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import { InstanceSettingsConfig } from '@n8n/config';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
jest.mock('node:fs', () => mock<typeof fs>());
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
import { InstanceSettings } from '../instance-settings';
|
||||
import { WorkerMissingEncryptionKey } from '../worker-missing-encryption-key.error';
|
||||
|
||||
describe('InstanceSettings', () => {
|
||||
const userFolder = '/test';
|
||||
|
||||
const mockFs = mock(fs);
|
||||
const logger = mock<Logger>();
|
||||
|
||||
const createInstanceSettings = (opts?: Partial<InstanceSettingsConfig>) =>
|
||||
new InstanceSettings(
|
||||
{
|
||||
...new InstanceSettingsConfig(),
|
||||
...opts,
|
||||
},
|
||||
logger,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
mockFs.statSync.mockReturnValue({ mode: 0o600 } as fs.Stats);
|
||||
|
||||
process.argv[2] = 'main';
|
||||
process.env = { N8N_USER_FOLDER: userFolder };
|
||||
});
|
||||
|
||||
describe('If the settings file exists', () => {
|
||||
beforeEach(() => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should load settings from the file', () => {
|
||||
mockFs.readFileSync.mockReturnValue(JSON.stringify({ encryptionKey: 'test_key' }));
|
||||
const settings = createInstanceSettings();
|
||||
expect(settings.encryptionKey).toEqual('test_key');
|
||||
expect(settings.instanceId).toEqual(
|
||||
'6ce26c63596f0cc4323563c529acfca0cccb0e57f6533d79a60a42c9ff862ae7',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error if settings file is not valid JSON', () => {
|
||||
mockFs.readFileSync.mockReturnValue('{"encryptionKey":"test_key"');
|
||||
expect(() => createInstanceSettings()).toThrowError();
|
||||
});
|
||||
|
||||
it('should throw if the env and file keys do not match', () => {
|
||||
mockFs.readFileSync.mockReturnValue(JSON.stringify({ encryptionKey: 'key_1' }));
|
||||
expect(() => createInstanceSettings({ encryptionKey: 'key_2' })).toThrowError();
|
||||
});
|
||||
|
||||
it('should check if the settings file has the correct permissions', () => {
|
||||
mockFs.readFileSync.mockReturnValueOnce(JSON.stringify({ encryptionKey: 'test_key' }));
|
||||
mockFs.statSync.mockReturnValueOnce({ mode: 0o600 } as fs.Stats);
|
||||
const settings = createInstanceSettings({
|
||||
encryptionKey: 'test_key',
|
||||
enforceSettingsFilePermissions: true,
|
||||
});
|
||||
expect(settings.encryptionKey).toEqual('test_key');
|
||||
expect(settings.instanceId).toEqual(
|
||||
'6ce26c63596f0cc4323563c529acfca0cccb0e57f6533d79a60a42c9ff862ae7',
|
||||
);
|
||||
expect(mockFs.statSync).toHaveBeenCalledWith('/test/.n8n/config');
|
||||
});
|
||||
|
||||
it('should check the permissions and fix them if settings file has incorrect permissions by default', () => {
|
||||
mockFs.readFileSync.mockReturnValueOnce(JSON.stringify({ encryptionKey: 'test_key' }));
|
||||
mockFs.statSync.mockReturnValueOnce({ mode: 0o644 } as fs.Stats);
|
||||
createInstanceSettings({
|
||||
enforceSettingsFilePermissions: true,
|
||||
});
|
||||
expect(mockFs.statSync).toHaveBeenCalledWith('/test/.n8n/config');
|
||||
expect(mockFs.chmodSync).toHaveBeenCalledWith('/test/.n8n/config', 0o600);
|
||||
});
|
||||
|
||||
it("should not check the permissions if 'N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS' is false", () => {
|
||||
process.env.N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS = 'false';
|
||||
mockFs.readFileSync.mockReturnValueOnce(JSON.stringify({ encryptionKey: 'test_key' }));
|
||||
createInstanceSettings({
|
||||
enforceSettingsFilePermissions: false,
|
||||
});
|
||||
expect(mockFs.statSync).not.toHaveBeenCalled();
|
||||
expect(mockFs.chmodSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should fix the permissions of the settings file if 'N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS' is true", () => {
|
||||
process.env.N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS = 'true';
|
||||
mockFs.readFileSync.mockReturnValueOnce(JSON.stringify({ encryptionKey: 'test_key' }));
|
||||
mockFs.statSync.mockReturnValueOnce({ mode: 0o644 } as fs.Stats);
|
||||
createInstanceSettings({
|
||||
enforceSettingsFilePermissions: true,
|
||||
});
|
||||
expect(mockFs.statSync).toHaveBeenCalledWith('/test/.n8n/config');
|
||||
expect(mockFs.chmodSync).toHaveBeenCalledWith('/test/.n8n/config', 0o600);
|
||||
});
|
||||
});
|
||||
|
||||
describe('If the settings file does not exist', () => {
|
||||
beforeEach(() => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
mockFs.mkdirSync.mockReturnValue('');
|
||||
mockFs.writeFileSync.mockReturnValue();
|
||||
});
|
||||
|
||||
it('should create a new settings file with explicit permissions if N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS is not set', () => {
|
||||
const settings = createInstanceSettings({
|
||||
encryptionKey: 'key_2',
|
||||
enforceSettingsFilePermissions: true,
|
||||
});
|
||||
expect(settings.encryptionKey).not.toEqual('test_key');
|
||||
expect(mockFs.mkdirSync).toHaveBeenCalledWith('/test/.n8n', { recursive: true });
|
||||
expect(mockFs.writeFileSync).toHaveBeenCalledWith(
|
||||
'/test/.n8n/config',
|
||||
expect.stringContaining('"encryptionKey":'),
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
mode: 0o600,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should create a new settings file without explicit permissions if N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=false', () => {
|
||||
process.env.N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS = 'false';
|
||||
const settings = createInstanceSettings({
|
||||
encryptionKey: 'key_2',
|
||||
enforceSettingsFilePermissions: false,
|
||||
});
|
||||
expect(settings.encryptionKey).not.toEqual('test_key');
|
||||
expect(mockFs.mkdirSync).toHaveBeenCalledWith('/test/.n8n', { recursive: true });
|
||||
expect(mockFs.writeFileSync).toHaveBeenCalledWith(
|
||||
'/test/.n8n/config',
|
||||
expect.stringContaining('"encryptionKey":'),
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
mode: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should create a new settings file with explicit permissions if N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true', () => {
|
||||
process.env.N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS = 'true';
|
||||
const settings = createInstanceSettings({
|
||||
enforceSettingsFilePermissions: true,
|
||||
encryptionKey: 'key_2',
|
||||
});
|
||||
expect(settings.encryptionKey).not.toEqual('test_key');
|
||||
expect(mockFs.mkdirSync).toHaveBeenCalledWith('/test/.n8n', { recursive: true });
|
||||
expect(mockFs.writeFileSync).toHaveBeenCalledWith(
|
||||
'/test/.n8n/config',
|
||||
expect.stringContaining('"encryptionKey":'),
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
mode: 0o600,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should pick up the encryption key from config', () => {
|
||||
const settings = createInstanceSettings({
|
||||
encryptionKey: 'env_key',
|
||||
enforceSettingsFilePermissions: true,
|
||||
});
|
||||
expect(settings.encryptionKey).toEqual('env_key');
|
||||
expect(settings.instanceId).toEqual(
|
||||
'2c70e12b7a0646f92279f427c7b38e7334d8e5389cff167a1dc30e73f826b683',
|
||||
);
|
||||
expect(settings.encryptionKey).not.toEqual('test_key');
|
||||
expect(mockFs.mkdirSync).toHaveBeenCalledWith('/test/.n8n', { recursive: true });
|
||||
expect(mockFs.writeFileSync).toHaveBeenCalledWith(
|
||||
'/test/.n8n/config',
|
||||
expect.stringContaining('"encryptionKey":'),
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
mode: 0o600,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw on a worker process, if encryption key isn't set via env", () => {
|
||||
process.argv[2] = 'worker';
|
||||
expect(() => createInstanceSettings()).toThrowError(WorkerMissingEncryptionKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should generate a `hostId`', () => {
|
||||
const encryptionKey = 'test_key';
|
||||
mockFs.existsSync.mockReturnValueOnce(true);
|
||||
mockFs.readFileSync.mockReturnValueOnce(JSON.stringify({ encryptionKey }));
|
||||
|
||||
const settings = createInstanceSettings({ encryptionKey });
|
||||
|
||||
const [instanceType, hostId] = settings.hostId.split('-');
|
||||
expect(instanceType).toEqual('main');
|
||||
expect(hostId.length).toBeGreaterThan(0); // hostname or nanoID
|
||||
});
|
||||
});
|
||||
|
||||
describe('nodeDefinitionsDir', () => {
|
||||
it('should return the path to the node definitions directory', () => {
|
||||
const encryptionKey = 'test_key';
|
||||
mockFs.existsSync.mockReturnValueOnce(true);
|
||||
mockFs.readFileSync.mockReturnValueOnce(JSON.stringify({ encryptionKey }));
|
||||
|
||||
const settings = createInstanceSettings({ encryptionKey });
|
||||
|
||||
expect(settings.nodeDefinitionsDir).toEqual('/test/.n8n/node-definitions');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDocker', () => {
|
||||
it('should return true if /.dockerenv exists', () => {
|
||||
mockFs.existsSync.mockImplementation((path) => path === '/.dockerenv');
|
||||
const settings = createInstanceSettings();
|
||||
expect(settings.isDocker).toBe(true);
|
||||
expect(mockFs.existsSync).toHaveBeenCalledWith('/.dockerenv');
|
||||
expect(mockFs.readFileSync).not.toHaveBeenCalledWith('/proc/self/cgroup', 'utf8');
|
||||
});
|
||||
|
||||
it('should return true if /run/.containerenv exists', () => {
|
||||
mockFs.existsSync.mockImplementation((path) => path === '/run/.containerenv');
|
||||
const settings = createInstanceSettings();
|
||||
expect(settings.isDocker).toBe(true);
|
||||
expect(mockFs.existsSync).toHaveBeenCalledWith('/run/.containerenv');
|
||||
expect(mockFs.readFileSync).not.toHaveBeenCalledWith('/proc/self/cgroup', 'utf8');
|
||||
});
|
||||
|
||||
test.each(['docker', 'kubepods', 'containerd'])(
|
||||
'should return true if /proc/self/cgroup contains %s',
|
||||
(str) => {
|
||||
mockFs.existsSync.mockReturnValueOnce(false);
|
||||
mockFs.readFileSync.calledWith('/proc/self/cgroup', 'utf8').mockReturnValueOnce(str);
|
||||
|
||||
const settings = createInstanceSettings();
|
||||
expect(settings.isDocker).toBe(true);
|
||||
expect(mockFs.existsSync).toHaveBeenCalledWith('/.dockerenv');
|
||||
expect(mockFs.readFileSync).toHaveBeenCalledWith('/proc/self/cgroup', 'utf8');
|
||||
},
|
||||
);
|
||||
|
||||
test.each(['docker', 'kubelet', 'containerd'])(
|
||||
'should return true if /proc/self/mountinfo contains %s',
|
||||
(str) => {
|
||||
mockFs.existsSync.mockReturnValueOnce(false);
|
||||
mockFs.readFileSync.calledWith('/proc/self/cgroup', 'utf8').mockReturnValueOnce('');
|
||||
mockFs.readFileSync.calledWith('/proc/self/mountinfo', 'utf8').mockReturnValueOnce(str);
|
||||
|
||||
const settings = createInstanceSettings();
|
||||
expect(settings.isDocker).toBe(true);
|
||||
expect(mockFs.existsSync).toHaveBeenCalledWith('/.dockerenv');
|
||||
expect(mockFs.readFileSync).toHaveBeenCalledWith('/proc/self/cgroup', 'utf8');
|
||||
expect(mockFs.readFileSync).toHaveBeenCalledWith('/proc/self/mountinfo', 'utf8');
|
||||
},
|
||||
);
|
||||
|
||||
it('should return false if no docker indicators are found', () => {
|
||||
mockFs.existsSync.calledWith('/.dockerenv').mockReturnValueOnce(false);
|
||||
mockFs.readFileSync.calledWith('/proc/self/cgroup', 'utf8').mockReturnValueOnce('');
|
||||
mockFs.readFileSync.calledWith('/proc/self/mountinfo', 'utf8').mockReturnValueOnce('');
|
||||
const settings = createInstanceSettings();
|
||||
expect(settings.isDocker).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if reading any of these files throws an error', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
mockFs.readFileSync.mockImplementation(() => {
|
||||
throw new Error('File not found');
|
||||
});
|
||||
|
||||
const settings = createInstanceSettings();
|
||||
expect(settings.isDocker).toBe(false);
|
||||
});
|
||||
|
||||
it('should cache the result of isDocker check', () => {
|
||||
mockFs.existsSync.calledWith('/.dockerenv').mockReturnValueOnce(true);
|
||||
|
||||
const settings = createInstanceSettings();
|
||||
expect(settings.isDocker).toBe(true);
|
||||
|
||||
mockFs.existsSync.mockClear();
|
||||
expect(settings.isDocker).toBe(true);
|
||||
expect(mockFs.existsSync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { InstanceSettings } from './instance-settings';
|
||||
@@ -0,0 +1,330 @@
|
||||
import { inTest, Logger } from '@n8n/backend-common';
|
||||
import { InstanceSettingsConfig } from '@n8n/config';
|
||||
import type { InstanceRole, InstanceType } from '@n8n/constants';
|
||||
import { Memoized } from '@n8n/decorators';
|
||||
import { Service } from '@n8n/di';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import { ApplicationError, jsonParse, ALPHABET, toResult } from 'n8n-workflow';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'path';
|
||||
|
||||
import { WorkerMissingEncryptionKey } from './worker-missing-encryption-key.error';
|
||||
|
||||
const nanoid = customAlphabet(ALPHABET, 16);
|
||||
|
||||
interface ReadOnlySettings {
|
||||
encryptionKey: string;
|
||||
}
|
||||
|
||||
interface WritableSettings {
|
||||
tunnelSubdomain?: string;
|
||||
|
||||
/** Whether `~/.n8n/binaryData` has been migrated to `~/.n8n/storage` */
|
||||
fsStorageMigrated?: boolean;
|
||||
}
|
||||
|
||||
type Settings = ReadOnlySettings & WritableSettings;
|
||||
|
||||
@Service()
|
||||
export class InstanceSettings {
|
||||
/** The path to the n8n folder in which all n8n related data gets saved */
|
||||
readonly n8nFolder = this.config.n8nFolder;
|
||||
|
||||
/** The path to the folder where all generated static assets are copied to */
|
||||
readonly staticCacheDir = path.join(this.config.userHome, '.cache/n8n/public');
|
||||
|
||||
/** The path to the folder containing custom nodes and credentials */
|
||||
readonly customExtensionDir = path.join(this.n8nFolder, 'custom');
|
||||
|
||||
/** The path to the folder containing installed nodes (like community nodes) */
|
||||
readonly nodesDownloadDir = path.join(this.n8nFolder, 'nodes');
|
||||
|
||||
/** The path to the folder containing generated node definitions (types + schemas) for the workflow SDK */
|
||||
readonly nodeDefinitionsDir = path.join(this.n8nFolder, 'node-definitions');
|
||||
|
||||
private readonly settingsFile = path.join(this.n8nFolder, 'config');
|
||||
|
||||
readonly enforceSettingsFilePermissions = this.loadEnforceSettingsFilePermissionsFlag();
|
||||
|
||||
private settings: Settings;
|
||||
|
||||
/**
|
||||
* Fixed ID of this n8n instance, for telemetry.
|
||||
* Derived from encryption key. Do not confuse with `hostId`.
|
||||
*
|
||||
* @example '258fce876abf5ea60eb86a2e777e5e190ff8f3e36b5b37aafec6636c31d4d1f9'
|
||||
*/
|
||||
readonly instanceId: string;
|
||||
|
||||
readonly hmacSignatureSecret: string;
|
||||
|
||||
readonly instanceType: InstanceType;
|
||||
|
||||
constructor(
|
||||
private readonly config: InstanceSettingsConfig,
|
||||
private readonly logger: Logger,
|
||||
) {
|
||||
const command = process.argv[2] as InstanceType;
|
||||
this.instanceType = ['webhook', 'worker'].includes(command) ? command : 'main';
|
||||
|
||||
this.hostId = `${this.instanceType}-${this.isDocker ? os.hostname() : nanoid()}`;
|
||||
this.settings = this.loadOrCreate();
|
||||
this.instanceId = this.generateInstanceId();
|
||||
this.hmacSignatureSecret = this.getOrGenerateHmacSignatureSecret();
|
||||
}
|
||||
|
||||
/**
|
||||
* A main is:
|
||||
* - `unset` during bootup,
|
||||
* - `leader` after bootup in single-main setup,
|
||||
* - `leader` or `follower` after bootup in multi-main setup.
|
||||
*
|
||||
* A non-main instance type (e.g. `worker`) is always `unset`.
|
||||
*/
|
||||
instanceRole: InstanceRole = 'unset';
|
||||
|
||||
/**
|
||||
* ID of this n8n instance. Hostname-based when in Docker, or nanoID-based
|
||||
* otherwise (resets on restart). Do not confuse with `instanceId`.
|
||||
*
|
||||
* @example 'main-bnxa1riryKUNHtln' (local)
|
||||
* @example 'main-6bf523178bc6' (Docker)
|
||||
*/
|
||||
readonly hostId: string;
|
||||
|
||||
private isMultiMainEnabled = false;
|
||||
|
||||
private isMultiMainLicensed = false;
|
||||
|
||||
/** Set whether multi-main mode is enabled. Does not imply licensed status. */
|
||||
setMultiMainEnabled(newState: boolean) {
|
||||
this.isMultiMainEnabled = newState;
|
||||
}
|
||||
|
||||
setMultiMainLicensed(newState: boolean) {
|
||||
this.isMultiMainLicensed = newState;
|
||||
}
|
||||
|
||||
/** Whether this `main` instance is running in multi-main mode. */
|
||||
get isMultiMain() {
|
||||
return this.instanceType === 'main' && this.isMultiMainEnabled && this.isMultiMainLicensed;
|
||||
}
|
||||
|
||||
/** Whether this `main` instance is running in single-main mode. */
|
||||
get isSingleMain() {
|
||||
return !this.isMultiMain;
|
||||
}
|
||||
|
||||
get isWorker() {
|
||||
return this.instanceType === 'worker';
|
||||
}
|
||||
|
||||
get isLeader() {
|
||||
return this.instanceRole === 'leader';
|
||||
}
|
||||
|
||||
markAsLeader() {
|
||||
this.instanceRole = 'leader';
|
||||
}
|
||||
|
||||
get isFollower() {
|
||||
return this.instanceRole === 'follower';
|
||||
}
|
||||
|
||||
markAsFollower() {
|
||||
this.instanceRole = 'follower';
|
||||
}
|
||||
|
||||
get encryptionKey() {
|
||||
return this.settings.encryptionKey;
|
||||
}
|
||||
|
||||
get tunnelSubdomain() {
|
||||
return this.settings.tunnelSubdomain;
|
||||
}
|
||||
|
||||
get fsStorageMigrated() {
|
||||
return this.settings.fsStorageMigrated === true;
|
||||
}
|
||||
|
||||
markFsStorageMigrated() {
|
||||
this.update({ fsStorageMigrated: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this instance is running inside a Docker/Podman/Kubernetes container.
|
||||
*/
|
||||
@Memoized
|
||||
get isDocker() {
|
||||
if (existsSync('/.dockerenv') || existsSync('/run/.containerenv')) return true;
|
||||
try {
|
||||
const cgroupV1 = readFileSync('/proc/self/cgroup', 'utf8');
|
||||
if (
|
||||
cgroupV1.includes('docker') ||
|
||||
cgroupV1.includes('kubepods') ||
|
||||
cgroupV1.includes('containerd')
|
||||
)
|
||||
return true;
|
||||
} catch {}
|
||||
try {
|
||||
const cgroupV2 = readFileSync('/proc/self/mountinfo', 'utf8');
|
||||
if (
|
||||
cgroupV2.includes('docker') ||
|
||||
cgroupV2.includes('kubelet') ||
|
||||
cgroupV2.includes('containerd')
|
||||
)
|
||||
return true;
|
||||
} catch {}
|
||||
return false;
|
||||
}
|
||||
|
||||
update(newSettings: WritableSettings) {
|
||||
this.save({ ...this.settings, ...newSettings });
|
||||
}
|
||||
|
||||
/**
|
||||
* Load instance settings from the settings file. If missing, create a new
|
||||
* settings file with an auto-generated encryption key.
|
||||
*/
|
||||
private loadOrCreate(): Settings {
|
||||
const encryptionKeyFromEnv = this.config.encryptionKey || undefined;
|
||||
if (existsSync(this.settingsFile)) {
|
||||
const content = readFileSync(this.settingsFile, 'utf8');
|
||||
this.ensureSettingsFilePermissions();
|
||||
|
||||
const settings = jsonParse<Settings>(content, {
|
||||
errorMessage: `Error parsing n8n-config file "${this.settingsFile}". It does not seem to be valid JSON.`,
|
||||
});
|
||||
|
||||
if (!inTest) this.logger.debug(`User settings loaded from: ${this.settingsFile}`);
|
||||
|
||||
const { encryptionKey, tunnelSubdomain, fsStorageMigrated } = settings;
|
||||
|
||||
if (encryptionKeyFromEnv && encryptionKey !== encryptionKeyFromEnv) {
|
||||
throw new ApplicationError(
|
||||
`Mismatching encryption keys. The encryption key in the settings file ${this.settingsFile} does not match the N8N_ENCRYPTION_KEY env var. Please make sure both keys match. More information: https://docs.n8n.io/hosting/environment-variables/configuration-methods/#encryption-key`,
|
||||
);
|
||||
}
|
||||
|
||||
return { encryptionKey, tunnelSubdomain, fsStorageMigrated };
|
||||
}
|
||||
|
||||
if (!encryptionKeyFromEnv) {
|
||||
if (this.instanceType === 'worker') {
|
||||
throw new WorkerMissingEncryptionKey();
|
||||
}
|
||||
|
||||
if (!inTest) {
|
||||
this.logger.info(
|
||||
`No encryption key found - Auto-generating and saving to: ${this.settingsFile}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mkdirSync(this.n8nFolder, { recursive: true });
|
||||
|
||||
const encryptionKey = encryptionKeyFromEnv ?? randomBytes(24).toString('base64');
|
||||
|
||||
const settings: Settings = { encryptionKey };
|
||||
|
||||
this.save(settings);
|
||||
this.ensureSettingsFilePermissions();
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
private generateInstanceId() {
|
||||
const { encryptionKey } = this;
|
||||
return createHash('sha256')
|
||||
.update(encryptionKey.slice(Math.round(encryptionKey.length / 2)))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
private getOrGenerateHmacSignatureSecret() {
|
||||
const hmacSignatureSecretFromEnv = process.env.N8N_HMAC_SIGNATURE_SECRET;
|
||||
if (hmacSignatureSecretFromEnv) return hmacSignatureSecretFromEnv;
|
||||
|
||||
const { encryptionKey } = this;
|
||||
return createHash('sha256').update(`hmac-signature:${encryptionKey}`).digest('hex');
|
||||
}
|
||||
|
||||
private save(settings: Settings) {
|
||||
this.settings = settings;
|
||||
writeFileSync(this.settingsFile, JSON.stringify(this.settings, null, '\t'), {
|
||||
mode: this.enforceSettingsFilePermissions.enforce ? 0o600 : undefined,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
}
|
||||
|
||||
private loadEnforceSettingsFilePermissionsFlag(): {
|
||||
isSet: boolean;
|
||||
enforce: boolean;
|
||||
} {
|
||||
const { enforceSettingsFilePermissions } = this.config;
|
||||
const isEnvVarSet = !!process.env.N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS;
|
||||
if (this.isWindows()) {
|
||||
if (isEnvVarSet) {
|
||||
console.warn(
|
||||
'Ignoring N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS as it is not supported on Windows.',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
isSet: isEnvVarSet,
|
||||
enforce: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isSet: isEnvVarSet,
|
||||
enforce: enforceSettingsFilePermissions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the settings file has the r/w permissions only for the owner.
|
||||
*/
|
||||
private ensureSettingsFilePermissions() {
|
||||
if (!this.enforceSettingsFilePermissions.enforce) return;
|
||||
|
||||
if (this.isWindows()) return; // ignore windows as it does not support chmod
|
||||
|
||||
const permissionsResult = toResult(() => {
|
||||
const stats = statSync(this.settingsFile);
|
||||
return stats?.mode & 0o777;
|
||||
});
|
||||
|
||||
// If we can't determine the permissions, log a warning and skip the check
|
||||
if (!permissionsResult.ok) {
|
||||
this.logger.warn(
|
||||
`Could not ensure settings file permissions: ${permissionsResult.error.message}. To skip this check, set N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=false.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const arePermissionsCorrect = permissionsResult.result === 0o600;
|
||||
|
||||
if (arePermissionsCorrect) return;
|
||||
|
||||
this.logger.error(
|
||||
`Permissions 0${permissionsResult.result.toString(8)} for n8n settings file ${this.settingsFile} are too wide. Changing permissions to 0600..`,
|
||||
);
|
||||
|
||||
const chmodResult = toResult(() => chmodSync(this.settingsFile, 0o600));
|
||||
|
||||
if (!chmodResult.ok) {
|
||||
// Some filesystems don't support permissions. In this case we log the
|
||||
// error and ignore it. We might want to prevent the app startup in the
|
||||
// future in this case.
|
||||
this.logger.warn(
|
||||
`Could not enforce settings file permissions: ${chmodResult.error.message}. To skip this check, set N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=false.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private isWindows() {
|
||||
return process.platform === 'win32';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { UserError } from 'n8n-workflow';
|
||||
|
||||
export class WorkerMissingEncryptionKey extends UserError {
|
||||
constructor() {
|
||||
super(
|
||||
[
|
||||
'Failed to start worker because of missing encryption key.',
|
||||
'Please set the `N8N_ENCRYPTION_KEY` env var when starting the worker.',
|
||||
'See: https://docs.n8n.io/hosting/configuration/configuration-examples/encryption-key/',
|
||||
].join(' '),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user