mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2026-08-29 06:40:37 +02:00
Co-authored-by: konstantintieber <konstantin.tieber@n8n.io>
327 lines
10 KiB
TypeScript
327 lines
10 KiB
TypeScript
import { Logger } from '@n8n/backend-common';
|
|
import { SettingsRepository } from '@n8n/db';
|
|
import { Service } from '@n8n/di';
|
|
import type { ValidationError } from 'class-validator';
|
|
import { validate } from 'class-validator';
|
|
import { rm as fsRm } from 'fs/promises';
|
|
import { Cipher, InstanceSettings } from 'n8n-core';
|
|
import { jsonParse, UnexpectedError } from 'n8n-workflow';
|
|
import { writeFile, readFile } from 'node:fs/promises';
|
|
import path from 'path';
|
|
|
|
import {
|
|
SOURCE_CONTROL_SSH_FOLDER,
|
|
SOURCE_CONTROL_GIT_FOLDER,
|
|
SOURCE_CONTROL_SSH_KEY_NAME,
|
|
SOURCE_CONTROL_PREFERENCES_DB_KEY,
|
|
} from './constants';
|
|
import { generateSshKeyPair, isSourceControlLicensed } from './source-control-helper.ee';
|
|
import { SourceControlConfig } from './source-control.config';
|
|
import type { KeyPairType } from './types/key-pair-type';
|
|
import { SourceControlPreferences } from './types/source-control-preferences';
|
|
|
|
@Service()
|
|
export class SourceControlPreferencesService {
|
|
private _sourceControlPreferences: SourceControlPreferences = new SourceControlPreferences();
|
|
|
|
readonly sshKeyName: string;
|
|
|
|
readonly sshFolder: string;
|
|
|
|
readonly gitFolder: string;
|
|
|
|
constructor(
|
|
private readonly instanceSettings: InstanceSettings,
|
|
private readonly logger: Logger,
|
|
private readonly cipher: Cipher,
|
|
private readonly settingsRepository: SettingsRepository,
|
|
private readonly sourceControlConfig: SourceControlConfig,
|
|
) {
|
|
this.sshFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_SSH_FOLDER);
|
|
this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER);
|
|
this.sshKeyName = path.join(this.sshFolder, SOURCE_CONTROL_SSH_KEY_NAME);
|
|
}
|
|
|
|
get sourceControlPreferences(): SourceControlPreferences {
|
|
return {
|
|
...this._sourceControlPreferences,
|
|
connected: this._sourceControlPreferences.connected ?? false,
|
|
};
|
|
}
|
|
|
|
// merge the new preferences with the existing preferences when setting
|
|
set sourceControlPreferences(preferences: Partial<SourceControlPreferences>) {
|
|
this._sourceControlPreferences = SourceControlPreferences.merge(
|
|
preferences,
|
|
this._sourceControlPreferences,
|
|
);
|
|
}
|
|
|
|
isSourceControlSetup() {
|
|
return (
|
|
this.isSourceControlLicensedAndEnabled() &&
|
|
this.getPreferences().repositoryUrl &&
|
|
this.getPreferences().branchName
|
|
);
|
|
}
|
|
|
|
private async getKeyPairFromDatabase() {
|
|
const dbSetting = await this.settingsRepository.findByKey('features.sourceControl.sshKeys');
|
|
|
|
if (!dbSetting?.value) return null;
|
|
|
|
type KeyPair = { publicKey: string; encryptedPrivateKey: string };
|
|
|
|
return jsonParse<KeyPair | null>(dbSetting.value, { fallbackValue: null });
|
|
}
|
|
|
|
private async getPrivateKeyFromDatabase() {
|
|
const dbKeyPair = await this.getKeyPairFromDatabase();
|
|
|
|
if (!dbKeyPair) throw new UnexpectedError('Failed to find key pair in database');
|
|
|
|
return this.cipher.decrypt(dbKeyPair.encryptedPrivateKey);
|
|
}
|
|
|
|
private async getPublicKeyFromDatabase() {
|
|
const dbKeyPair = await this.getKeyPairFromDatabase();
|
|
|
|
if (!dbKeyPair) throw new UnexpectedError('Failed to find key pair in database');
|
|
|
|
return dbKeyPair.publicKey;
|
|
}
|
|
|
|
private async getHttpsCredentialsFromDatabase() {
|
|
const dbSetting = await this.settingsRepository.findByKey(
|
|
'features.sourceControl.httpsCredentials',
|
|
);
|
|
|
|
if (!dbSetting?.value) return null;
|
|
|
|
type HttpsCredentials = { encryptedUsername: string; encryptedPassword: string };
|
|
|
|
return jsonParse<HttpsCredentials | null>(dbSetting.value, { fallbackValue: null });
|
|
}
|
|
|
|
async getDecryptedHttpsCredentials(): Promise<{ username: string; password: string } | null> {
|
|
const credentials = await this.getHttpsCredentialsFromDatabase();
|
|
|
|
if (!credentials) return null;
|
|
|
|
return {
|
|
username: this.cipher.decrypt(credentials.encryptedUsername),
|
|
password: this.cipher.decrypt(credentials.encryptedPassword),
|
|
};
|
|
}
|
|
|
|
async saveHttpsCredentials(username: string, password: string): Promise<void> {
|
|
try {
|
|
await this.settingsRepository.save({
|
|
key: 'features.sourceControl.httpsCredentials',
|
|
value: JSON.stringify({
|
|
encryptedUsername: this.cipher.encrypt(username),
|
|
encryptedPassword: this.cipher.encrypt(password),
|
|
}),
|
|
loadOnStartup: true,
|
|
});
|
|
} catch (error) {
|
|
throw new UnexpectedError('Failed to save HTTPS credentials to database', { cause: error });
|
|
}
|
|
}
|
|
|
|
async deleteHttpsCredentials(): Promise<void> {
|
|
try {
|
|
await this.settingsRepository.delete({ key: 'features.sourceControl.httpsCredentials' });
|
|
} catch (error) {
|
|
this.logger.error('Failed to delete HTTPS credentials from database', { error });
|
|
}
|
|
}
|
|
|
|
async getPrivateKeyPath() {
|
|
const dbPrivateKey = await this.getPrivateKeyFromDatabase();
|
|
|
|
const tempFilePath = path.join(this.instanceSettings.n8nFolder, 'ssh_private_key_temp');
|
|
|
|
// Ensure proper line endings (LF only) for SSH keys, especially on Windows
|
|
const normalizedKey = dbPrivateKey.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
|
|
try {
|
|
// Remove existing file if it exists to avoid permission conflicts
|
|
// Using force: true ignores ENOENT but allows other errors to surface
|
|
await fsRm(tempFilePath, { force: true });
|
|
|
|
// Always use restrictive permissions for SSH private keys (security requirement)
|
|
await writeFile(tempFilePath, normalizedKey, { mode: 0o600 });
|
|
} catch (error) {
|
|
this.logger.error('Failed to write SSH private key to temp file', {
|
|
tempFilePath,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
throw new UnexpectedError('Failed to create SSH private key file', { cause: error });
|
|
}
|
|
|
|
return tempFilePath;
|
|
}
|
|
|
|
async getPublicKey() {
|
|
try {
|
|
const dbPublicKey = await this.getPublicKeyFromDatabase();
|
|
|
|
if (dbPublicKey) return dbPublicKey;
|
|
|
|
return await readFile(this.sshKeyName + '.pub', { encoding: 'utf8' });
|
|
} catch (e) {
|
|
const error = e instanceof Error ? e : new Error(`${e}`);
|
|
this.logger.error(`Failed to read SSH public key: ${error.message}`);
|
|
}
|
|
return '';
|
|
}
|
|
|
|
async deleteKeyPair() {
|
|
try {
|
|
await fsRm(this.sshFolder, { recursive: true });
|
|
await this.settingsRepository.delete({ key: 'features.sourceControl.sshKeys' });
|
|
} catch (e) {
|
|
const error = e instanceof Error ? e : new Error(`${e}`);
|
|
this.logger.error(`Failed to delete SSH key pair: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate an SSH key pair and write it to the database, overwriting any existing key pair.
|
|
*/
|
|
async generateAndSaveKeyPair(keyPairType?: KeyPairType): Promise<SourceControlPreferences> {
|
|
if (!keyPairType) {
|
|
keyPairType =
|
|
this.getPreferences().keyGeneratorType ?? this.sourceControlConfig.defaultKeyPairType;
|
|
}
|
|
const keyPair = await generateSshKeyPair(keyPairType);
|
|
|
|
try {
|
|
await this.settingsRepository.save({
|
|
key: 'features.sourceControl.sshKeys',
|
|
value: JSON.stringify({
|
|
encryptedPrivateKey: this.cipher.encrypt(keyPair.privateKey),
|
|
publicKey: keyPair.publicKey,
|
|
}),
|
|
loadOnStartup: true,
|
|
});
|
|
} catch (error) {
|
|
throw new UnexpectedError('Failed to write key pair to database', { cause: error });
|
|
}
|
|
|
|
// update preferences only after generating key pair to prevent endless loop
|
|
if (keyPairType !== this.getPreferences().keyGeneratorType) {
|
|
await this.setPreferences({ keyGeneratorType: keyPairType });
|
|
}
|
|
|
|
return this.getPreferences();
|
|
}
|
|
|
|
isBranchReadOnly(): boolean {
|
|
return this._sourceControlPreferences.branchReadOnly;
|
|
}
|
|
|
|
isSourceControlConnected(): boolean {
|
|
return this.sourceControlPreferences.connected;
|
|
}
|
|
|
|
isSourceControlLicensedAndEnabled(): boolean {
|
|
return this.isSourceControlConnected() && isSourceControlLicensed();
|
|
}
|
|
|
|
getBranchName(): string {
|
|
return this.sourceControlPreferences.branchName;
|
|
}
|
|
|
|
getPreferences(): SourceControlPreferences {
|
|
return this.sourceControlPreferences;
|
|
}
|
|
|
|
async validateSourceControlPreferences(
|
|
preferences: Partial<SourceControlPreferences>,
|
|
allowMissingProperties = true,
|
|
): Promise<ValidationError[]> {
|
|
const preferencesObject = new SourceControlPreferences(preferences);
|
|
const validationResult = await validate(preferencesObject, {
|
|
forbidUnknownValues: false,
|
|
skipMissingProperties: allowMissingProperties,
|
|
stopAtFirstError: false,
|
|
validationError: { target: false },
|
|
});
|
|
if (validationResult.length > 0) {
|
|
throw new UnexpectedError('Invalid source control preferences', {
|
|
extra: { preferences: validationResult },
|
|
});
|
|
}
|
|
return validationResult;
|
|
}
|
|
|
|
async setPreferences(
|
|
preferences: Partial<SourceControlPreferences>,
|
|
saveToDb = true,
|
|
): Promise<SourceControlPreferences> {
|
|
const noKeyPair = (await this.getKeyPairFromDatabase()) === null;
|
|
|
|
// Generate SSH key pair for SSH connections or when connectionType is undefined for backward compatibility
|
|
if (
|
|
noKeyPair &&
|
|
(preferences.connectionType === 'ssh' || preferences.connectionType === undefined)
|
|
) {
|
|
await this.generateAndSaveKeyPair();
|
|
}
|
|
|
|
const sanitizedPreferences = { ...preferences };
|
|
|
|
if (preferences.httpsUsername && preferences.httpsPassword) {
|
|
await this.saveHttpsCredentials(preferences.httpsUsername, preferences.httpsPassword);
|
|
}
|
|
|
|
delete sanitizedPreferences.httpsUsername;
|
|
delete sanitizedPreferences.httpsPassword;
|
|
|
|
this.sourceControlPreferences = sanitizedPreferences;
|
|
|
|
if (saveToDb) {
|
|
const settingsValue = JSON.stringify(this._sourceControlPreferences);
|
|
try {
|
|
await this.settingsRepository.save(
|
|
{
|
|
key: SOURCE_CONTROL_PREFERENCES_DB_KEY,
|
|
value: settingsValue,
|
|
loadOnStartup: true,
|
|
},
|
|
{ transaction: false },
|
|
);
|
|
} catch (error) {
|
|
throw new UnexpectedError('Failed to save source control preferences', { cause: error });
|
|
}
|
|
}
|
|
return this.sourceControlPreferences;
|
|
}
|
|
|
|
async loadFromDbAndApplySourceControlPreferences(): Promise<
|
|
SourceControlPreferences | undefined
|
|
> {
|
|
const loadedPreferences = await this.settingsRepository.findOne({
|
|
where: { key: SOURCE_CONTROL_PREFERENCES_DB_KEY },
|
|
});
|
|
if (loadedPreferences) {
|
|
try {
|
|
const preferences = jsonParse<SourceControlPreferences>(loadedPreferences.value);
|
|
if (preferences) {
|
|
// set local preferences but don't write back to db
|
|
await this.setPreferences(preferences, false);
|
|
return preferences;
|
|
}
|
|
} catch (error) {
|
|
this.logger.warn(
|
|
`Could not parse Source Control settings from database: ${(error as Error).message}`,
|
|
);
|
|
}
|
|
}
|
|
await this.setPreferences(new SourceControlPreferences(), true);
|
|
return this.sourceControlPreferences;
|
|
}
|
|
}
|