mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
feat(core): Add Git connections module and service (no-changelog) (#36270)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3741de521b
commit
17567043bd
|
|
@ -0,0 +1,57 @@
|
|||
import { CreateGitConnectionDto, UpdateGitConnectionDto } from '../git-connections.dto';
|
||||
|
||||
describe('Git connection DTOs', () => {
|
||||
it('accepts SSH and HTTPS create payloads', () => {
|
||||
expect(
|
||||
CreateGitConnectionDto.safeParse({
|
||||
name: 'Deployments',
|
||||
repositoryUrl: 'git@example.com:org/repo.git',
|
||||
connectionType: 'ssh',
|
||||
keyGeneratorType: 'ed25519',
|
||||
}),
|
||||
).toMatchObject({ success: true });
|
||||
expect(
|
||||
CreateGitConnectionDto.safeParse({
|
||||
name: 'Deployments',
|
||||
repositoryUrl: 'https://example.com/org/repo.git',
|
||||
connectionType: 'https',
|
||||
username: 'git-user',
|
||||
password: 'secret',
|
||||
}),
|
||||
).toMatchObject({ success: true });
|
||||
});
|
||||
|
||||
it('rejects empty names, branch names, and credentials', () => {
|
||||
const base = {
|
||||
name: 'Deployments',
|
||||
repositoryUrl: 'https://example.com/org/repo.git',
|
||||
connectionType: 'https' as const,
|
||||
username: 'git-user',
|
||||
password: 'secret',
|
||||
};
|
||||
expect(CreateGitConnectionDto.safeParse({ ...base, name: ' ' }).success).toBe(false);
|
||||
expect(CreateGitConnectionDto.safeParse({ ...base, branchName: '' }).success).toBe(false);
|
||||
expect(CreateGitConnectionDto.safeParse({ ...base, password: '' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('caps branchName at the 255-char column width', () => {
|
||||
const base = {
|
||||
name: 'Deployments',
|
||||
repositoryUrl: 'https://example.com/org/repo.git',
|
||||
connectionType: 'https' as const,
|
||||
username: 'git-user',
|
||||
password: 'secret',
|
||||
};
|
||||
expect(CreateGitConnectionDto.safeParse({ ...base, branchName: 'a'.repeat(255) }).success).toBe(
|
||||
true,
|
||||
);
|
||||
expect(CreateGitConnectionDto.safeParse({ ...base, branchName: 'a'.repeat(256) }).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('allows partial updates and rejects null branches', () => {
|
||||
expect(UpdateGitConnectionDto.safeParse({ name: 'Renamed' }).success).toBe(true);
|
||||
expect(UpdateGitConnectionDto.safeParse({ branchName: null }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
import { publicApiPaginationSchema } from '../pagination/pagination.dto';
|
||||
|
||||
export const gitConnectionTypeSchema = z.enum(['ssh', 'https']);
|
||||
export const gitKeyGeneratorTypeSchema = z.enum(['ed25519', 'rsa']);
|
||||
export type GitConnectionType = z.infer<typeof gitConnectionTypeSchema>;
|
||||
export type GitKeyGeneratorType = z.infer<typeof gitKeyGeneratorTypeSchema>;
|
||||
|
||||
const nameSchema = z.string().trim().min(1).max(128);
|
||||
const repositoryUrlSchema = z.string().trim().min(1);
|
||||
// branchName maps to a `varchar(255)` column — cap it so over-long input is a
|
||||
// 400 at validation time rather than a 500 at insert.
|
||||
const branchNameSchema = z.string().trim().min(1).max(255);
|
||||
|
||||
export class CreateGitConnectionDto extends Z.class({
|
||||
name: nameSchema,
|
||||
repositoryUrl: repositoryUrlSchema,
|
||||
branchName: branchNameSchema.optional(),
|
||||
connectionType: gitConnectionTypeSchema,
|
||||
keyGeneratorType: gitKeyGeneratorTypeSchema.optional(),
|
||||
username: z.string().min(1).optional(),
|
||||
password: z.string().min(1).optional(),
|
||||
}) {}
|
||||
|
||||
export class UpdateGitConnectionDto extends Z.class({
|
||||
name: nameSchema.optional(),
|
||||
repositoryUrl: repositoryUrlSchema.optional(),
|
||||
branchName: branchNameSchema.optional(),
|
||||
connectionType: gitConnectionTypeSchema.optional(),
|
||||
keyGeneratorType: gitKeyGeneratorTypeSchema.optional(),
|
||||
username: z.string().min(1).optional(),
|
||||
password: z.string().min(1).optional(),
|
||||
}) {}
|
||||
|
||||
export class ConnectGitConnectionDto extends Z.class({
|
||||
branchName: branchNameSchema.optional(),
|
||||
}) {}
|
||||
|
||||
export class ListGitConnectionsQueryDto extends Z.class({
|
||||
limit: publicApiPaginationSchema.limit,
|
||||
cursor: z.string().optional(),
|
||||
}) {}
|
||||
|
||||
export const gitConnectionPublicSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
repositoryUrl: z.string(),
|
||||
branchName: z.string().nullable(),
|
||||
connectionType: gitConnectionTypeSchema,
|
||||
publicKey: z.string().nullable(),
|
||||
keyGeneratorType: gitKeyGeneratorTypeSchema.nullable(),
|
||||
baseCommit: z.string().nullable(),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export class GitConnectionPublicDto extends Z.class(gitConnectionPublicSchema.shape) {}
|
||||
|
||||
export const gitConnectionSummarySchema = gitConnectionPublicSchema.omit({ publicKey: true });
|
||||
|
||||
export class GitConnectionListPublicDto extends Z.class({
|
||||
data: z.array(gitConnectionSummarySchema),
|
||||
nextCursor: z.string().nullable(),
|
||||
}) {}
|
||||
|
|
@ -98,6 +98,18 @@ export {
|
|||
} from './source-control/pull-work-folder-request.dto';
|
||||
export { PushWorkFolderRequestDto } from './source-control/push-work-folder-request.dto';
|
||||
export { type GitCommitInfo } from './source-control/push-work-folder-response.dto';
|
||||
export {
|
||||
ConnectGitConnectionDto,
|
||||
CreateGitConnectionDto,
|
||||
GitConnectionListPublicDto,
|
||||
GitConnectionPublicDto,
|
||||
ListGitConnectionsQueryDto,
|
||||
UpdateGitConnectionDto,
|
||||
gitConnectionTypeSchema,
|
||||
gitKeyGeneratorTypeSchema,
|
||||
type GitConnectionType,
|
||||
type GitKeyGeneratorType,
|
||||
} from './git-connections/git-connections.dto';
|
||||
|
||||
export { CreateCredentialDto } from './credentials/create-credential.dto';
|
||||
export { VariableListRequestDto } from './variables/variables-list-request.dto';
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export const MODULE_NAMES = [
|
|||
'provisioning',
|
||||
'breaking-changes',
|
||||
'source-control',
|
||||
'git-connections',
|
||||
'dynamic-credentials',
|
||||
'chat-hub',
|
||||
'sso-oidc',
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export const LOG_SCOPES = [
|
|||
'breaking-changes',
|
||||
'circuit-breaker',
|
||||
'source-control',
|
||||
'git-connections',
|
||||
'dynamic-credentials',
|
||||
'workflow-history-compaction',
|
||||
'data-table-csv-import',
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export const LICENSE_FEATURES = {
|
|||
ADVANCED_EXECUTION_FILTERS: 'feat:advancedExecutionFilters',
|
||||
VARIABLES: 'feat:variables',
|
||||
SOURCE_CONTROL: 'feat:sourceControl',
|
||||
GIT_CONNECTIONS: 'feat:gitConnections',
|
||||
API_DISABLED: 'feat:apiDisabled',
|
||||
EXTERNAL_SECRETS: 'feat:externalSecrets',
|
||||
SHOW_NON_PROD_BANNER: 'feat:showNonProdBanner',
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ export class E2EController {
|
|||
[LICENSE_FEATURES.LOG_STREAMING]: false,
|
||||
[LICENSE_FEATURES.ADVANCED_EXECUTION_FILTERS]: false,
|
||||
[LICENSE_FEATURES.SOURCE_CONTROL]: false,
|
||||
[LICENSE_FEATURES.GIT_CONNECTIONS]: false,
|
||||
[LICENSE_FEATURES.VARIABLES]: false,
|
||||
[LICENSE_FEATURES.API_DISABLED]: false,
|
||||
[LICENSE_FEATURES.EXTERNAL_SECRETS]: false,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
import { mockLogger } from '@n8n/backend-test-utils';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
|
||||
import { GitConnectionsGitService } from '../git-connections-git.service';
|
||||
|
||||
describe('GitConnectionsGitService', () => {
|
||||
const service = new GitConnectionsGitService(mockLogger());
|
||||
|
||||
describe('validateRepositoryUrl (https)', () => {
|
||||
it('accepts an https URL without embedded credentials', () => {
|
||||
expect(() =>
|
||||
service.validateRepositoryUrl('https://github.com/org/repo.git', 'https'),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts http URLs (internal Git / Gitea on a trusted network)', () => {
|
||||
expect(() =>
|
||||
service.validateRepositoryUrl('http://gitea.internal/org/repo.git', 'https'),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a URL that embeds credentials', () => {
|
||||
expect(() =>
|
||||
service.validateRepositoryUrl('https://user:pass@github.com/org/repo.git', 'https'),
|
||||
).toThrow(BadRequestError);
|
||||
});
|
||||
|
||||
it('rejects a non-URL string', () => {
|
||||
expect(() => service.validateRepositoryUrl('not a url', 'https')).toThrow(BadRequestError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('control characters', () => {
|
||||
it.each([
|
||||
['tab', 'https://github.com/org/re\tpo.git'],
|
||||
['carriage return', 'https://github.com/org/re\rpo.git'],
|
||||
['newline', 'https://github.com/org/re\npo.git'],
|
||||
])('rejects an https URL containing a %s', (_label, url) => {
|
||||
expect(() => service.validateRepositoryUrl(url, 'https')).toThrow(BadRequestError);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['tab', 'git@github.com:org/re\tpo.git'],
|
||||
['carriage return', 'git@github.com:org/re\rpo.git'],
|
||||
['newline', 'git@github.com:org/re\npo.git'],
|
||||
])('rejects an ssh URL containing a %s', (_label, url) => {
|
||||
expect(() => service.validateRepositoryUrl(url, 'ssh')).toThrow(BadRequestError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateRepositoryUrl (ssh)', () => {
|
||||
it.each([
|
||||
'ssh://git@github.com/org/repo.git',
|
||||
'ssh://git@github.com:22/org/repo.git',
|
||||
'git@github.com:org/repo.git',
|
||||
'github.com:org/repo.git',
|
||||
])('accepts %s', (url) => {
|
||||
expect(() => service.validateRepositoryUrl(url, 'ssh')).not.toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ext:: transport (command execution)', 'ext::sh -c "id"'],
|
||||
['file:// scheme (local repo disclosure)', 'file:///srv/private-repo'],
|
||||
['http:// scheme', 'http://github.com/org/repo.git'],
|
||||
['git:// scheme', 'git://github.com/org/repo.git'],
|
||||
['leading dash (option injection)', '--upload-pack=/tmp/x'],
|
||||
['host starting with a dash', 'user@-oProxyCommand=evil:path'],
|
||||
['bare ssh:// with no path', 'ssh://github.com'],
|
||||
['ssh:// with a password in the userinfo', 'ssh://git:secret@github.com/org/repo.git'],
|
||||
])('rejects %s', (_label, url) => {
|
||||
expect(() => service.validateRepositoryUrl(url, 'ssh')).toThrow(BadRequestError);
|
||||
});
|
||||
|
||||
describe('drive-letter prefixes', () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
const setPlatform = (platform: NodeJS.Platform) => {
|
||||
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
setPlatform(originalPlatform);
|
||||
});
|
||||
|
||||
// git reads `C:\path` / `C:/path` as a local filesystem path only on
|
||||
// Windows, where allowing it would clone off the host's disk.
|
||||
it.each(['C:/Users/n8n/repo', 'C:\\Users\\n8n\\repo', 'c:repo'])(
|
||||
'rejects %s on Windows',
|
||||
(url) => {
|
||||
setPlatform('win32');
|
||||
expect(() => service.validateRepositoryUrl(url, 'ssh')).toThrow(BadRequestError);
|
||||
},
|
||||
);
|
||||
|
||||
// On other platforms git treats `c:path` as a scp-like remote to a
|
||||
// one-character host, which is a legitimate SSH alias.
|
||||
it.each(['C:/Users/n8n/repo', 'c:repo'])('accepts %s on non-Windows', (url) => {
|
||||
setPlatform('linux');
|
||||
expect(() => service.validateRepositoryUrl(url, 'ssh')).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
import {
|
||||
buildHttpsGitConfig,
|
||||
buildSshCommand,
|
||||
generateSshKeyPair,
|
||||
} from '../git-connections-git.utils';
|
||||
|
||||
describe('git-connections-git.utils', () => {
|
||||
describe('buildHttpsGitConfig', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
for (const key of [
|
||||
'HTTP_PROXY',
|
||||
'HTTPS_PROXY',
|
||||
'http_proxy',
|
||||
'https_proxy',
|
||||
'NO_PROXY',
|
||||
'no_proxy',
|
||||
'ALL_PROXY',
|
||||
'all_proxy',
|
||||
]) {
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('should build a path-scoped credential helper for plain credentials', () => {
|
||||
const config = buildHttpsGitConfig('https://github.com/user/repo.git', {
|
||||
username: 'testuser',
|
||||
password: 'testpass',
|
||||
});
|
||||
|
||||
expect(config).toEqual([
|
||||
"credential.helper=!f() { echo username='testuser'; echo password='testpass'; }; f",
|
||||
'credential.useHttpPath=true',
|
||||
'http.lowSpeedLimit=1000',
|
||||
'http.lowSpeedTime=30',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should escape single quotes so credentials cannot break out of the helper', () => {
|
||||
const config = buildHttpsGitConfig('https://github.com/user/repo.git', {
|
||||
username: "user'; rm -rf /",
|
||||
password: "pass'; rm -rf /",
|
||||
});
|
||||
|
||||
expect(config[0]).toBe(
|
||||
"credential.helper=!f() { echo username='user'\"'\"'; rm -rf /'; echo password='pass'\"'\"'; rm -rf /'; }; f",
|
||||
);
|
||||
});
|
||||
|
||||
it('should append http.proxy when a proxy resolves for the repository URL', () => {
|
||||
process.env.HTTPS_PROXY = 'http://proxy.company.com:8080';
|
||||
|
||||
const config = buildHttpsGitConfig('https://github.com/user/repo.git', {
|
||||
username: 'testuser',
|
||||
password: 'testpass',
|
||||
});
|
||||
|
||||
expect(config).toContain('http.proxy=http://proxy.company.com:8080');
|
||||
});
|
||||
|
||||
it('should not append http.proxy when no proxy is set', () => {
|
||||
const config = buildHttpsGitConfig('https://github.com/user/repo.git', {
|
||||
username: 'testuser',
|
||||
password: 'testpass',
|
||||
});
|
||||
|
||||
expect(config.some((entry) => entry.includes('proxy='))).toBe(false);
|
||||
});
|
||||
|
||||
it('should bound stalled transfers with a low-speed limit', () => {
|
||||
const config = buildHttpsGitConfig('https://github.com/user/repo.git', {
|
||||
username: 'testuser',
|
||||
password: 'testpass',
|
||||
});
|
||||
|
||||
expect(config).toContain('http.lowSpeedLimit=1000');
|
||||
expect(config).toContain('http.lowSpeedTime=30');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSshCommand', () => {
|
||||
it('should build an ssh command pinning host keys with accept-new', () => {
|
||||
const command = buildSshCommand({
|
||||
privateKeyPath: '/data/.ssh/private-key',
|
||||
knownHostsPath: '/data/.ssh/known_hosts',
|
||||
});
|
||||
|
||||
expect(command).toBe(
|
||||
"ssh -o ConnectTimeout=30 -o ServerAliveInterval=15 -o ServerAliveCountMax=3 -o UserKnownHostsFile='/data/.ssh/known_hosts' -o StrictHostKeyChecking=accept-new -i '/data/.ssh/private-key'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should normalize Windows-style backslash paths to POSIX', () => {
|
||||
const command = buildSshCommand({
|
||||
privateKeyPath: 'C:\\n8n\\.ssh\\private-key',
|
||||
knownHostsPath: 'C:\\n8n\\.ssh\\known_hosts',
|
||||
});
|
||||
|
||||
expect(command).toBe(
|
||||
"ssh -o ConnectTimeout=30 -o ServerAliveInterval=15 -o ServerAliveCountMax=3 -o UserKnownHostsFile='C:/n8n/.ssh/known_hosts' -o StrictHostKeyChecking=accept-new -i 'C:/n8n/.ssh/private-key'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should bound a stalled connection with connect and keepalive timeouts', () => {
|
||||
const command = buildSshCommand({
|
||||
privateKeyPath: '/data/.ssh/private-key',
|
||||
knownHostsPath: '/data/.ssh/known_hosts',
|
||||
});
|
||||
|
||||
expect(command).toContain('-o ConnectTimeout=30');
|
||||
expect(command).toContain('-o ServerAliveInterval=15');
|
||||
expect(command).toContain('-o ServerAliveCountMax=3');
|
||||
});
|
||||
|
||||
it('should quote shell metacharacters in paths', () => {
|
||||
const command = buildSshCommand({
|
||||
privateKeyPath: "/data/$(archive)/owner's/private-key",
|
||||
knownHostsPath: '/data/`archive`/known_hosts',
|
||||
});
|
||||
|
||||
expect(command).toContain("-i '/data/$(archive)/owner'\"'\"'s/private-key'");
|
||||
expect(command).toContain("-o UserKnownHostsFile='/data/`archive`/known_hosts'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateSshKeyPair', () => {
|
||||
it('should generate a parseable ed25519 key pair carrying the comment', async () => {
|
||||
const keyPair = await generateSshKeyPair('ed25519', 'n8n git connection');
|
||||
|
||||
expect(keyPair.privateKey).toContain('BEGIN OPENSSH PRIVATE KEY');
|
||||
expect(keyPair.publicKey).toContain('ssh-ed25519');
|
||||
expect(keyPair.publicKey).toContain('n8n git connection');
|
||||
});
|
||||
|
||||
it('should generate a parseable rsa key pair', async () => {
|
||||
const keyPair = await generateSshKeyPair('rsa', 'n8n git connection');
|
||||
|
||||
expect(keyPair.privateKey).toContain('BEGIN OPENSSH PRIVATE KEY');
|
||||
expect(keyPair.publicKey).toContain('ssh-rsa');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
import type { CreateGitConnectionDto, UpdateGitConnectionDto } from '@n8n/api-types';
|
||||
import type { Cipher, InstanceSettings } from 'n8n-core';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
|
||||
import type { GitConnection } from '../database/entities/git-connection.entity';
|
||||
import type { GitConnectionRepository } from '../database/repositories/git-connection.repository';
|
||||
import type { GitConnectionsGitService } from '../git-connections-git.service';
|
||||
import { GitConnectionsService } from '../git-connections.service';
|
||||
|
||||
describe('GitConnectionsService (credential state machine)', () => {
|
||||
const repository = mock<GitConnectionRepository>();
|
||||
const gitService = mock<GitConnectionsGitService>();
|
||||
const cipher = mock<Cipher>();
|
||||
const instanceSettings = mock<InstanceSettings>({ n8nFolder: '/tmp/n8n' });
|
||||
|
||||
const service = new GitConnectionsService(repository, gitService, cipher, instanceSettings);
|
||||
|
||||
const baseEntity = () => ({
|
||||
id: '1',
|
||||
name: 'c',
|
||||
repositoryUrl: 'git@github.com:o/r.git',
|
||||
branchName: 'main',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const sshEntity = (): GitConnection =>
|
||||
({
|
||||
...baseEntity(),
|
||||
connectionType: 'ssh',
|
||||
keyGeneratorType: 'ed25519',
|
||||
publicKey: 'PUB',
|
||||
encryptedPrivateKey: 'enc:PRIV',
|
||||
encryptedUsername: null,
|
||||
encryptedPassword: null,
|
||||
}) as GitConnection;
|
||||
|
||||
const httpsEntity = (): GitConnection =>
|
||||
({
|
||||
...baseEntity(),
|
||||
repositoryUrl: 'https://github.com/o/r.git',
|
||||
connectionType: 'https',
|
||||
keyGeneratorType: null,
|
||||
publicKey: null,
|
||||
encryptedPrivateKey: null,
|
||||
encryptedUsername: 'enc:user',
|
||||
encryptedPassword: 'enc:pass',
|
||||
}) as GitConnection;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repository.create.mockImplementation((input) => input as GitConnection);
|
||||
repository.save.mockImplementation(async (input) => {
|
||||
const entity = input as GitConnection;
|
||||
entity.createdAt ??= new Date();
|
||||
entity.updatedAt ??= new Date();
|
||||
return entity;
|
||||
});
|
||||
cipher.encryptV2.mockImplementation(async (value) => `enc:${value as string}`);
|
||||
gitService.generateSshKeyPair.mockResolvedValue({ publicKey: 'PUB', privateKey: 'PRIV' });
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('generates and encrypts an SSH key pair for ssh connections', async () => {
|
||||
await service.create({
|
||||
name: 'c',
|
||||
repositoryUrl: 'git@github.com:o/r.git',
|
||||
connectionType: 'ssh',
|
||||
} as CreateGitConnectionDto);
|
||||
|
||||
const saved = repository.save.mock.calls[0][0] as GitConnection;
|
||||
expect(gitService.generateSshKeyPair).toHaveBeenCalledWith('ed25519');
|
||||
expect(saved.publicKey).toBe('PUB');
|
||||
expect(saved.encryptedPrivateKey).toBe('enc:PRIV');
|
||||
expect(saved.encryptedUsername).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects username/password on an ssh connection', async () => {
|
||||
await expect(
|
||||
service.create({
|
||||
name: 'c',
|
||||
repositoryUrl: 'git@github.com:o/r.git',
|
||||
connectionType: 'ssh',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
} as CreateGitConnectionDto),
|
||||
).rejects.toThrow(BadRequestError);
|
||||
});
|
||||
|
||||
it('encrypts username and password for https connections', async () => {
|
||||
await service.create({
|
||||
name: 'c',
|
||||
repositoryUrl: 'https://github.com/o/r.git',
|
||||
connectionType: 'https',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
} as CreateGitConnectionDto);
|
||||
|
||||
const saved = repository.save.mock.calls[0][0] as GitConnection;
|
||||
expect(saved.encryptedUsername).toBe('enc:u');
|
||||
expect(saved.encryptedPassword).toBe('enc:p');
|
||||
expect(saved.encryptedPrivateKey).toBeNull();
|
||||
});
|
||||
|
||||
it('requires username and password together for https connections', async () => {
|
||||
await expect(
|
||||
service.create({
|
||||
name: 'c',
|
||||
repositoryUrl: 'https://github.com/o/r.git',
|
||||
connectionType: 'https',
|
||||
username: 'u',
|
||||
} as CreateGitConnectionDto),
|
||||
).rejects.toThrow(BadRequestError);
|
||||
});
|
||||
|
||||
it('rejects keyGeneratorType on an https connection', async () => {
|
||||
await expect(
|
||||
service.create({
|
||||
name: 'c',
|
||||
repositoryUrl: 'https://github.com/o/r.git',
|
||||
connectionType: 'https',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
keyGeneratorType: 'rsa',
|
||||
} as CreateGitConnectionDto),
|
||||
).rejects.toThrow(BadRequestError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('rejects changing the SSH key type after creation', async () => {
|
||||
repository.findOneBy.mockResolvedValue(sshEntity());
|
||||
await expect(
|
||||
service.update('1', { keyGeneratorType: 'rsa' } as UpdateGitConnectionDto),
|
||||
).rejects.toThrow(BadRequestError);
|
||||
});
|
||||
|
||||
it('keeps existing keys when an ssh connection is updated without key changes', async () => {
|
||||
repository.findOneBy.mockResolvedValue(sshEntity());
|
||||
await service.update('1', { name: 'renamed' } as UpdateGitConnectionDto);
|
||||
|
||||
expect(gitService.generateSshKeyPair).not.toHaveBeenCalled();
|
||||
const saved = repository.save.mock.calls[0][0] as GitConnection;
|
||||
expect(saved.encryptedPrivateKey).toBe('enc:PRIV');
|
||||
});
|
||||
|
||||
it('clears the opposite credential set when switching https -> ssh', async () => {
|
||||
repository.findOneBy.mockResolvedValue(httpsEntity());
|
||||
await service.update('1', { connectionType: 'ssh' } as UpdateGitConnectionDto);
|
||||
|
||||
const saved = repository.save.mock.calls[0][0] as GitConnection;
|
||||
expect(saved.encryptedPrivateKey).toBe('enc:PRIV');
|
||||
expect(saved.encryptedUsername).toBeNull();
|
||||
expect(saved.encryptedPassword).toBeNull();
|
||||
});
|
||||
|
||||
it('requires credentials and clears keys when switching ssh -> https', async () => {
|
||||
repository.findOneBy.mockResolvedValue(sshEntity());
|
||||
await expect(
|
||||
service.update('1', { connectionType: 'https' } as UpdateGitConnectionDto),
|
||||
).rejects.toThrow(BadRequestError);
|
||||
|
||||
repository.findOneBy.mockResolvedValue(sshEntity());
|
||||
await service.update('1', {
|
||||
connectionType: 'https',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
} as UpdateGitConnectionDto);
|
||||
|
||||
const saved = repository.save.mock.calls.at(-1)![0] as GitConnection;
|
||||
expect(saved.encryptedUsername).toBe('enc:u');
|
||||
expect(saved.encryptedPassword).toBe('enc:p');
|
||||
expect(saved.encryptedPrivateKey).toBeNull();
|
||||
expect(saved.publicKey).toBeNull();
|
||||
expect(saved.keyGeneratorType).toBeNull();
|
||||
});
|
||||
|
||||
it('invalidates the cached working copy on a branch-only change', async () => {
|
||||
repository.findOneBy.mockResolvedValue(sshEntity());
|
||||
await service.update('1', { branchName: 'release' } as UpdateGitConnectionDto);
|
||||
|
||||
expect(gitService.resetWorkingCopy).toHaveBeenCalledWith('/tmp/n8n/git-connections/1');
|
||||
});
|
||||
|
||||
it('does not touch the working copy on a name-only change', async () => {
|
||||
repository.findOneBy.mockResolvedValue(sshEntity());
|
||||
await service.update('1', { name: 'renamed' } as UpdateGitConnectionDto);
|
||||
|
||||
expect(gitService.resetWorkingCopy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
23
packages/cli/src/modules/git-connections.ee/constants.ts
Normal file
23
packages/cli/src/modules/git-connections.ee/constants.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/** Comment embedded in generated SSH keys (visible when added as a deploy key). */
|
||||
export const GIT_KEY_COMMENT = 'n8n git connection';
|
||||
|
||||
// Timeouts that bound how long a stalled Git operation can hang. Transport-level
|
||||
// limits (SSH, HTTP) fire first based on real network inactivity; the process
|
||||
// stall timeout is a transport-agnostic backstop.
|
||||
|
||||
// Bound SSH: fail the initial connect after CONNECT_TIMEOUT, and drop an
|
||||
// established-but-silent session after INTERVAL * COUNT_MAX seconds of unanswered
|
||||
// keepalives.
|
||||
export const SSH_CONNECT_TIMEOUT_SECONDS = 30;
|
||||
export const SSH_SERVER_ALIVE_INTERVAL_SECONDS = 15;
|
||||
export const SSH_SERVER_ALIVE_COUNT_MAX = 3;
|
||||
|
||||
// Abort an HTTP(S) transfer that drops below ~1 KB/s for this long, so a stalled
|
||||
// server can't leave the request pending indefinitely.
|
||||
export const HTTP_LOW_SPEED_LIMIT_BYTES = 1000;
|
||||
export const HTTP_LOW_SPEED_TIME_SECONDS = 30;
|
||||
|
||||
// Transport-agnostic backstop: kill the git process if it emits no output for
|
||||
// this long, so clone() always rejects and its cleanup path runs. Clone is run
|
||||
// with --progress so a healthy transfer keeps feeding this timer.
|
||||
export const GIT_COMMAND_STALL_TIMEOUT_MS = 60_000;
|
||||
|
|
@ -1,11 +1,7 @@
|
|||
import type { GitConnectionType, GitKeyGeneratorType } from '@n8n/api-types';
|
||||
import { WithTimestampsAndStringId } from '@n8n/db';
|
||||
import { Column, Entity } from '@n8n/typeorm';
|
||||
|
||||
// Defined inline so this DB-layer entity stays self-contained; the shared
|
||||
// @n8n/api-types DTOs that own these unions arrive in a later PR.
|
||||
type GitConnectionType = 'ssh' | 'https';
|
||||
type GitKeyGeneratorType = 'ed25519' | 'rsa';
|
||||
|
||||
@Entity('git_connection')
|
||||
export class GitConnection extends WithTimestampsAndStringId {
|
||||
@Column({ type: 'varchar', length: 128 })
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
import { Service } from '@n8n/di';
|
||||
import { DataSource, Repository } from '@n8n/typeorm';
|
||||
|
||||
import { GitConnection } from '../entities/git-connection.entity';
|
||||
|
||||
@Service()
|
||||
export class GitConnectionRepository extends Repository<GitConnection> {
|
||||
constructor(dataSource: DataSource) {
|
||||
super(GitConnection, dataSource.manager);
|
||||
}
|
||||
|
||||
async getManyAndCount(options: { skip: number; take: number }) {
|
||||
// TypeORM omits the LIMIT clause when `take` is falsy, so `take: 0` would
|
||||
// return every row instead of none — short-circuit to honor the limit.
|
||||
if (options.take <= 0) {
|
||||
return { count: await this.count(), data: [] };
|
||||
}
|
||||
|
||||
const [data, count] = await this.findAndCount({
|
||||
order: { id: 'ASC' },
|
||||
skip: options.skip,
|
||||
take: options.take,
|
||||
});
|
||||
return { count, data };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
import type { GitConnectionType, GitKeyGeneratorType } from '@n8n/api-types';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { Service } from '@n8n/di';
|
||||
import { chmod, mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { SimpleGit, SimpleGitOptions } from 'simple-git';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
|
||||
import { GIT_COMMAND_STALL_TIMEOUT_MS, GIT_KEY_COMMENT } from './constants';
|
||||
import type { GitConnection } from './database/entities/git-connection.entity';
|
||||
import {
|
||||
buildHttpsGitConfig,
|
||||
buildSshCommand,
|
||||
generateSshKeyPair,
|
||||
} from './git-connections-git.utils';
|
||||
|
||||
type PlainCredentials =
|
||||
| { type: 'ssh'; privateKey: string }
|
||||
| { type: 'https'; username: string; password: string };
|
||||
|
||||
@Service()
|
||||
export class GitConnectionsGitService {
|
||||
constructor(private readonly logger: Logger) {
|
||||
this.logger = this.logger.scoped('git-connections');
|
||||
}
|
||||
|
||||
validateRepositoryUrl(repositoryUrl: string, connectionType: GitConnectionType) {
|
||||
// `new URL()` silently strips control characters, so reject them up front to
|
||||
// keep the persisted/git-handed URL identical to what we validated.
|
||||
for (const char of repositoryUrl) {
|
||||
const code = char.charCodeAt(0);
|
||||
if (code < 32 || code === 127) {
|
||||
throw new BadRequestError('Repository URL must not contain control characters');
|
||||
}
|
||||
}
|
||||
if (connectionType === 'https') {
|
||||
this.validateHttpsRepositoryUrl(repositoryUrl);
|
||||
} else {
|
||||
this.validateSshRepositoryUrl(repositoryUrl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept only `http(s)://` URLs without embedded credentials. Credentials must
|
||||
* come through the encrypted fields, not the stored/returned `repositoryUrl`.
|
||||
*/
|
||||
private validateHttpsRepositoryUrl(repositoryUrl: string) {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(repositoryUrl);
|
||||
} catch {
|
||||
throw new BadRequestError('Repository URL must be a valid HTTP or HTTPS URL');
|
||||
}
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
throw new BadRequestError('HTTPS connections require an HTTP or HTTPS repository URL');
|
||||
}
|
||||
if (url.username || url.password) {
|
||||
throw new BadRequestError('Repository URL must not contain credentials');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Only accept `ssh://host/path` and scp-like `user@host:path`. Reject a
|
||||
* leading `-` (option injection), the `::` transport-helper form (e.g.
|
||||
* `ext::<cmd>`, which runs arbitrary commands), and any non-ssh scheme (e.g.
|
||||
* `file://`, `http://`) — git's remote transports would otherwise let a URL
|
||||
* clone local paths off the host or execute commands.
|
||||
*/
|
||||
private validateSshRepositoryUrl(repositoryUrl: string) {
|
||||
const error = new BadRequestError(
|
||||
'SSH connections require an ssh:// or user@host:path repository URL',
|
||||
);
|
||||
|
||||
if (repositoryUrl.startsWith('-') || repositoryUrl.includes('::')) throw error;
|
||||
|
||||
const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//.exec(repositoryUrl);
|
||||
if (scheme) {
|
||||
if (scheme[1].toLowerCase() !== 'ssh') throw error;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(repositoryUrl);
|
||||
} catch {
|
||||
throw error;
|
||||
}
|
||||
// A bare username is the legitimate SSH user (e.g. `git@`); a password in
|
||||
// the userinfo would be stored/returned in `repositoryUrl` and treated by
|
||||
// git as part of the SSH user. Credentials must come through the encrypted fields.
|
||||
if (url.password) throw new BadRequestError('Repository URL must not contain credentials');
|
||||
if (!url.hostname || !url.pathname || url.pathname === '/') throw error;
|
||||
return;
|
||||
}
|
||||
|
||||
// On Windows, git reads a drive-letter prefix (`C:\path`, `C:/path`) as a
|
||||
// local filesystem path rather than a `host:path` remote, so it would clone
|
||||
// off the host's disk. Reject it before the scp-like check treats the drive
|
||||
// letter as a hostname. This is a Windows-only interpretation; on other
|
||||
// platforms git treats `c:path` as a scp-like remote to host `c`, which is a
|
||||
// legitimate one-character SSH alias, so only guard when running on Windows.
|
||||
if (process.platform === 'win32' && /^[a-zA-Z]:/.test(repositoryUrl)) throw error;
|
||||
|
||||
// scp-like: [user@]host:path, host must not start with `-`.
|
||||
const isScpLike = /^(?:[a-zA-Z0-9_.-]+@)?[a-zA-Z0-9._][a-zA-Z0-9._-]*:[^\s]+$/.test(
|
||||
repositoryUrl,
|
||||
);
|
||||
if (!isScpLike) throw error;
|
||||
}
|
||||
|
||||
async validateBranchName(branchName: string) {
|
||||
const { simpleGit } = await import('simple-git');
|
||||
try {
|
||||
await simpleGit().raw(['check-ref-format', '--branch', branchName]);
|
||||
} catch {
|
||||
throw new BadRequestError(`Invalid Git branch name: ${branchName}`);
|
||||
}
|
||||
}
|
||||
|
||||
async generateSshKeyPair(keyType: GitKeyGeneratorType) {
|
||||
return await generateSshKeyPair(keyType, GIT_KEY_COMMENT);
|
||||
}
|
||||
|
||||
async clone({
|
||||
connection,
|
||||
credentials,
|
||||
branchName,
|
||||
rootFolder,
|
||||
}: {
|
||||
connection: GitConnection;
|
||||
credentials: PlainCredentials;
|
||||
branchName: string;
|
||||
rootFolder: string;
|
||||
}) {
|
||||
await this.validateBranchName(branchName);
|
||||
await mkdir(rootFolder, { recursive: true });
|
||||
const repositoryFolder = path.join(rootFolder, 'repository');
|
||||
const nextRepositoryFolder = path.join(rootFolder, 'repository-next');
|
||||
await rm(nextRepositoryFolder, { recursive: true, force: true });
|
||||
|
||||
try {
|
||||
await this.withGit({ connection, credentials, baseDir: rootFolder }, async (git) => {
|
||||
const remoteRefs = await git.listRemote([
|
||||
'--heads',
|
||||
connection.repositoryUrl,
|
||||
`refs/heads/${branchName}`,
|
||||
]);
|
||||
if (!remoteRefs.trim()) {
|
||||
throw new BadRequestError(`Remote branch does not exist: ${branchName}`);
|
||||
}
|
||||
await git.clone(connection.repositoryUrl, nextRepositoryFolder, [
|
||||
'--branch',
|
||||
branchName,
|
||||
'--single-branch',
|
||||
'--no-tags',
|
||||
// Emit progress so the stall-timeout backstop keeps resetting during a
|
||||
// healthy transfer and only fires when the connection genuinely stalls.
|
||||
'--progress',
|
||||
]);
|
||||
await rm(repositoryFolder, { recursive: true, force: true });
|
||||
await rename(nextRepositoryFolder, repositoryFolder);
|
||||
});
|
||||
} catch (error) {
|
||||
await rm(nextRepositoryFolder, { recursive: true, force: true });
|
||||
if (error instanceof BadRequestError) throw error;
|
||||
// Withhold the raw git output (it can echo the credential-helper config);
|
||||
// log only a redacted subset that lets an operator correlate the failure.
|
||||
this.logger.warn('Failed to connect to Git repository', {
|
||||
connectionId: connection.id,
|
||||
branchName,
|
||||
});
|
||||
throw new BadRequestError('Could not connect to the Git repository');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the cached working copy but keep `.ssh/known_hosts`, so a pinned
|
||||
* host key survives credential rotation and reconnects (preserving MITM
|
||||
* protection). Used when auth/target changes invalidate the clone.
|
||||
*/
|
||||
async resetWorkingCopy(rootFolder: string) {
|
||||
await rm(path.join(rootFolder, 'repository'), { recursive: true, force: true });
|
||||
await rm(path.join(rootFolder, 'repository-next'), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/** Remove everything for a connection, including the pinned host key. */
|
||||
async cleanup(rootFolder: string) {
|
||||
await rm(rootFolder, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a git operation with credentials wired in and torn down afterwards.
|
||||
* Auth is configured per connection type: HTTPS goes through a credential
|
||||
* helper, SSH writes the private key to a temporary file referenced via
|
||||
* `GIT_SSH_COMMAND`. The `finally` block removes that temporary key material
|
||||
* so it never outlives the operation, regardless of success or failure.
|
||||
*/
|
||||
private async withGit<T>(
|
||||
{
|
||||
connection,
|
||||
credentials,
|
||||
baseDir,
|
||||
}: {
|
||||
connection: GitConnection;
|
||||
credentials: PlainCredentials;
|
||||
baseDir: string;
|
||||
},
|
||||
operation: (git: SimpleGit) => Promise<T>,
|
||||
) {
|
||||
await mkdir(baseDir, { recursive: true });
|
||||
const options: Partial<SimpleGitOptions> = {
|
||||
baseDir,
|
||||
binary: 'git',
|
||||
maxConcurrentProcesses: 1,
|
||||
trimmed: false,
|
||||
timeout: { block: GIT_COMMAND_STALL_TIMEOUT_MS },
|
||||
};
|
||||
const { simpleGit } = await import('simple-git');
|
||||
let temporaryFolder: string | undefined;
|
||||
|
||||
try {
|
||||
let git: SimpleGit;
|
||||
if (credentials.type === 'https') {
|
||||
const config = buildHttpsGitConfig(connection.repositoryUrl, credentials);
|
||||
|
||||
git = simpleGit({
|
||||
...options,
|
||||
config,
|
||||
unsafe: { allowUnsafeCredentialHelper: true },
|
||||
}).env('GIT_TERMINAL_PROMPT', '0');
|
||||
} else {
|
||||
temporaryFolder = await mkdtemp(path.join(tmpdir(), 'n8n-git-connection-'));
|
||||
const privateKeyPath = path.join(temporaryFolder, 'private-key');
|
||||
await writeFile(privateKeyPath, credentials.privateKey, { mode: 0o600 });
|
||||
await chmod(privateKeyPath, 0o600);
|
||||
const sshFolder = path.join(baseDir, '.ssh');
|
||||
await mkdir(sshFolder, { recursive: true });
|
||||
const sshCommand = buildSshCommand({
|
||||
privateKeyPath,
|
||||
knownHostsPath: path.join(sshFolder, 'known_hosts'),
|
||||
});
|
||||
git = simpleGit({ ...options, unsafe: { allowUnsafeSshCommand: true } })
|
||||
.env('GIT_SSH_COMMAND', sshCommand)
|
||||
.env('GIT_TERMINAL_PROMPT', '0');
|
||||
}
|
||||
return await operation(git);
|
||||
} finally {
|
||||
if (temporaryFolder) await rm(temporaryFolder, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import type { GitKeyGeneratorType } from '@n8n/api-types';
|
||||
import { resolveProxyUrl } from '@n8n/backend-network';
|
||||
import { generateKeyPairSync } from 'node:crypto';
|
||||
|
||||
import {
|
||||
HTTP_LOW_SPEED_LIMIT_BYTES,
|
||||
HTTP_LOW_SPEED_TIME_SECONDS,
|
||||
SSH_CONNECT_TIMEOUT_SECONDS,
|
||||
SSH_SERVER_ALIVE_COUNT_MAX,
|
||||
SSH_SERVER_ALIVE_INTERVAL_SECONDS,
|
||||
} from './constants';
|
||||
|
||||
/** Quote a value for use as one POSIX shell argument. */
|
||||
const quoteShellArg = (value: string) => `'${value.replace(/'/g, "'\"'\"'")}'`;
|
||||
|
||||
/**
|
||||
* Build the Git configuration for an HTTPS connection.
|
||||
*
|
||||
* The credential helper serves every request from this Git process. Each
|
||||
* connection uses a separate process and configuration.
|
||||
*/
|
||||
export function buildHttpsGitConfig(
|
||||
repositoryUrl: string,
|
||||
credentials: { username: string; password: string },
|
||||
): string[] {
|
||||
const helper = `!f() { echo username=${quoteShellArg(credentials.username)}; echo password=${quoteShellArg(credentials.password)}; }; f`;
|
||||
const config = [
|
||||
`credential.helper=${helper}`,
|
||||
'credential.useHttpPath=true',
|
||||
`http.lowSpeedLimit=${HTTP_LOW_SPEED_LIMIT_BYTES}`,
|
||||
`http.lowSpeedTime=${HTTP_LOW_SPEED_TIME_SECONDS}`,
|
||||
];
|
||||
// Git uses http.proxy for both HTTP and HTTPS URLs.
|
||||
const proxyUrl = resolveProxyUrl(repositoryUrl);
|
||||
if (proxyUrl) config.push(`http.proxy=${proxyUrl}`);
|
||||
return config;
|
||||
}
|
||||
|
||||
/** Build the shell-safe SSH command used for Git connections. */
|
||||
export function buildSshCommand(paths: { privateKeyPath: string; knownHostsPath: string }): string {
|
||||
const normalizeAndQuote = (value: string) => quoteShellArg(value.split(/[/\\]/).join('/'));
|
||||
const timeouts = `-o ConnectTimeout=${SSH_CONNECT_TIMEOUT_SECONDS} -o ServerAliveInterval=${SSH_SERVER_ALIVE_INTERVAL_SECONDS} -o ServerAliveCountMax=${SSH_SERVER_ALIVE_COUNT_MAX}`;
|
||||
return `ssh ${timeouts} -o UserKnownHostsFile=${normalizeAndQuote(paths.knownHostsPath)} -o StrictHostKeyChecking=accept-new -i ${normalizeAndQuote(paths.privateKeyPath)}`;
|
||||
}
|
||||
|
||||
/** Generate an OpenSSH key pair with the supplied key comment. */
|
||||
export async function generateSshKeyPair(keyType: GitKeyGeneratorType, comment: string) {
|
||||
// Read the CommonJS default export because parsePrivateKey is not a named export.
|
||||
const { default: sshpk } = await import('sshpk');
|
||||
const generated =
|
||||
keyType === 'ed25519'
|
||||
? generateKeyPairSync('ed25519', {
|
||||
privateKeyEncoding: { format: 'pem', type: 'pkcs8' },
|
||||
publicKeyEncoding: { format: 'pem', type: 'spki' },
|
||||
})
|
||||
: generateKeyPairSync('rsa', {
|
||||
modulusLength: 4096,
|
||||
privateKeyEncoding: { format: 'pem', type: 'pkcs8' },
|
||||
publicKeyEncoding: { format: 'pem', type: 'spki' },
|
||||
});
|
||||
const publicKey = sshpk.parseKey(generated.publicKey, 'pem');
|
||||
publicKey.comment = comment;
|
||||
const privateKey = sshpk.parsePrivateKey(generated.privateKey, 'pem');
|
||||
privateKey.comment = comment;
|
||||
return {
|
||||
publicKey: publicKey.toString('ssh'),
|
||||
privateKey: privateKey.toString('ssh-private'),
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { LICENSE_FEATURES } from '@n8n/constants';
|
||||
import type { ModuleInterface } from '@n8n/decorators';
|
||||
import { BackendModule } from '@n8n/decorators';
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
@BackendModule({
|
||||
name: 'git-connections',
|
||||
licenseFlag: LICENSE_FEATURES.GIT_CONNECTIONS,
|
||||
instanceTypes: ['main'],
|
||||
})
|
||||
export class GitConnectionsModule implements ModuleInterface {
|
||||
async init() {
|
||||
const { GitConnectionsService } = await import('./git-connections.service.js');
|
||||
Container.get(GitConnectionsService);
|
||||
}
|
||||
|
||||
async entities() {
|
||||
const { GitConnection } = await import('./database/entities/git-connection.entity.js');
|
||||
return [GitConnection];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
import type {
|
||||
CreateGitConnectionDto,
|
||||
GitConnectionPublicDto,
|
||||
UpdateGitConnectionDto,
|
||||
} from '@n8n/api-types';
|
||||
import { Service } from '@n8n/di';
|
||||
import { Cipher, InstanceSettings } from 'n8n-core';
|
||||
import path from 'node:path';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
|
||||
import { GitConnection } from './database/entities/git-connection.entity';
|
||||
import { GitConnectionRepository } from './database/repositories/git-connection.repository';
|
||||
import { GitConnectionsGitService } from './git-connections-git.service';
|
||||
|
||||
@Service()
|
||||
export class GitConnectionsService {
|
||||
constructor(
|
||||
private readonly repository: GitConnectionRepository,
|
||||
private readonly gitService: GitConnectionsGitService,
|
||||
private readonly cipher: Cipher,
|
||||
private readonly instanceSettings: InstanceSettings,
|
||||
) {}
|
||||
|
||||
async create(input: CreateGitConnectionDto) {
|
||||
this.gitService.validateRepositoryUrl(input.repositoryUrl, input.connectionType);
|
||||
if (input.branchName) await this.gitService.validateBranchName(input.branchName);
|
||||
|
||||
const connection = this.repository.create({
|
||||
name: input.name,
|
||||
repositoryUrl: input.repositoryUrl,
|
||||
branchName: input.branchName ?? null,
|
||||
connectionType: input.connectionType,
|
||||
publicKey: null,
|
||||
encryptedPrivateKey: null,
|
||||
encryptedUsername: null,
|
||||
encryptedPassword: null,
|
||||
keyGeneratorType: null,
|
||||
baseCommit: null,
|
||||
});
|
||||
await this.applyNewAuthentication(connection, input);
|
||||
return this.toPublic(await this.repository.save(connection));
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
return this.toPublic(await this.getEntity(id));
|
||||
}
|
||||
|
||||
async list(offset: number, limit: number) {
|
||||
const result = await this.repository.getManyAndCount({ skip: offset, take: limit });
|
||||
return { ...result, data: result.data.map((connection) => this.toSummary(connection)) };
|
||||
}
|
||||
|
||||
async update(id: string, input: UpdateGitConnectionDto) {
|
||||
if (Object.keys(input).length === 0)
|
||||
throw new BadRequestError('At least one field is required');
|
||||
const current = await this.getEntity(id);
|
||||
const targetType = input.connectionType ?? current.connectionType;
|
||||
const targetUrl = input.repositoryUrl ?? current.repositoryUrl;
|
||||
this.gitService.validateRepositoryUrl(targetUrl, targetType);
|
||||
if (input.branchName) await this.gitService.validateBranchName(input.branchName);
|
||||
|
||||
// Any auth or target (url/branch) change invalidates the cached working
|
||||
// copy on disk — the branch is part of the target, so a branch-only change
|
||||
// must invalidate too, otherwise the clone stays on the old branch.
|
||||
const changesTarget =
|
||||
input.connectionType !== undefined ||
|
||||
input.repositoryUrl !== undefined ||
|
||||
input.branchName !== undefined ||
|
||||
input.username !== undefined ||
|
||||
input.password !== undefined ||
|
||||
input.keyGeneratorType !== undefined;
|
||||
|
||||
const updated = this.repository.create({ ...current });
|
||||
if (input.name !== undefined) updated.name = input.name;
|
||||
if (input.repositoryUrl !== undefined) updated.repositoryUrl = input.repositoryUrl;
|
||||
if (input.branchName !== undefined) updated.branchName = input.branchName;
|
||||
|
||||
await this.applyUpdatedAuthentication(updated, current, input);
|
||||
|
||||
const saved = await this.repository.save(updated);
|
||||
if (changesTarget) await this.resetWorkingCopy(id);
|
||||
return this.toPublic(saved);
|
||||
}
|
||||
|
||||
async connect(id: string, branchName?: string) {
|
||||
const connection = await this.getEntity(id);
|
||||
const effectiveBranch = branchName ?? connection.branchName;
|
||||
if (!effectiveBranch) throw new BadRequestError('A branch name is required to connect');
|
||||
const credentials = await this.decryptCredentials(connection);
|
||||
await this.gitService.clone({
|
||||
connection,
|
||||
credentials,
|
||||
branchName: effectiveBranch,
|
||||
rootFolder: this.rootFolder(id),
|
||||
});
|
||||
connection.branchName = effectiveBranch;
|
||||
return this.toPublic(await this.repository.save(connection));
|
||||
}
|
||||
|
||||
async disconnect(id: string) {
|
||||
const connection = await this.getEntity(id);
|
||||
await this.resetWorkingCopy(id);
|
||||
return this.toPublic(connection);
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
const connection = await this.getEntity(id);
|
||||
await this.purge(id);
|
||||
await this.repository.remove(connection);
|
||||
}
|
||||
|
||||
private async applyNewAuthentication(
|
||||
connection: GitConnection,
|
||||
input: Pick<
|
||||
CreateGitConnectionDto,
|
||||
'connectionType' | 'keyGeneratorType' | 'username' | 'password'
|
||||
>,
|
||||
) {
|
||||
if (input.connectionType === 'ssh') {
|
||||
if (input.username !== undefined || input.password !== undefined) {
|
||||
throw new BadRequestError('Username and password are only valid for HTTPS connections');
|
||||
}
|
||||
const keyType = input.keyGeneratorType ?? 'ed25519';
|
||||
const keyPair = await this.gitService.generateSshKeyPair(keyType);
|
||||
connection.publicKey = keyPair.publicKey;
|
||||
connection.encryptedPrivateKey = await this.cipher.encryptV2(keyPair.privateKey);
|
||||
connection.keyGeneratorType = keyType;
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.keyGeneratorType !== undefined) {
|
||||
throw new BadRequestError('Key generator type is only valid for SSH connections');
|
||||
}
|
||||
this.validateHttpsCredentials(input.username, input.password, true);
|
||||
connection.encryptedUsername = await this.cipher.encryptV2(input.username!);
|
||||
connection.encryptedPassword = await this.cipher.encryptV2(input.password!);
|
||||
}
|
||||
|
||||
private async applyUpdatedAuthentication(
|
||||
updated: GitConnection,
|
||||
current: GitConnection,
|
||||
input: UpdateGitConnectionDto,
|
||||
) {
|
||||
const targetType = input.connectionType ?? current.connectionType;
|
||||
if (targetType === 'ssh') {
|
||||
if (input.username !== undefined || input.password !== undefined) {
|
||||
throw new BadRequestError('Username and password are only valid for HTTPS connections');
|
||||
}
|
||||
if (current.connectionType === 'ssh') {
|
||||
if (input.keyGeneratorType && input.keyGeneratorType !== current.keyGeneratorType) {
|
||||
throw new BadRequestError('SSH key type cannot be changed after creation');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const keyType = input.keyGeneratorType ?? 'ed25519';
|
||||
const pair = await this.gitService.generateSshKeyPair(keyType);
|
||||
updated.connectionType = 'ssh';
|
||||
updated.publicKey = pair.publicKey;
|
||||
updated.encryptedPrivateKey = await this.cipher.encryptV2(pair.privateKey);
|
||||
updated.keyGeneratorType = keyType;
|
||||
updated.encryptedUsername = null;
|
||||
updated.encryptedPassword = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.keyGeneratorType !== undefined) {
|
||||
throw new BadRequestError('Key generator type is only valid for SSH connections');
|
||||
}
|
||||
const switching = current.connectionType === 'ssh';
|
||||
this.validateHttpsCredentials(input.username, input.password, switching);
|
||||
updated.connectionType = 'https';
|
||||
if (input.username !== undefined && input.password !== undefined) {
|
||||
updated.encryptedUsername = await this.cipher.encryptV2(input.username);
|
||||
updated.encryptedPassword = await this.cipher.encryptV2(input.password);
|
||||
}
|
||||
updated.publicKey = null;
|
||||
updated.encryptedPrivateKey = null;
|
||||
updated.keyGeneratorType = null;
|
||||
}
|
||||
|
||||
private validateHttpsCredentials(username?: string, password?: string, required = false) {
|
||||
if ((username === undefined) !== (password === undefined) || (required && !username)) {
|
||||
throw new BadRequestError('HTTPS username and password must be provided together');
|
||||
}
|
||||
if ([username, password].some((value) => value && /[\r\n\0]/.test(value))) {
|
||||
throw new BadRequestError('HTTPS credentials contain unsupported characters');
|
||||
}
|
||||
}
|
||||
|
||||
private async decryptCredentials(connection: GitConnection) {
|
||||
if (connection.connectionType === 'ssh') {
|
||||
if (!connection.encryptedPrivateKey) throw new BadRequestError('SSH private key is missing');
|
||||
return {
|
||||
type: 'ssh' as const,
|
||||
privateKey: await this.cipher.decryptV2(connection.encryptedPrivateKey),
|
||||
};
|
||||
}
|
||||
if (!connection.encryptedUsername || !connection.encryptedPassword) {
|
||||
throw new BadRequestError('HTTPS credentials are missing');
|
||||
}
|
||||
return {
|
||||
type: 'https' as const,
|
||||
username: await this.cipher.decryptV2(connection.encryptedUsername),
|
||||
password: await this.cipher.decryptV2(connection.encryptedPassword),
|
||||
};
|
||||
}
|
||||
|
||||
private async getEntity(id: string) {
|
||||
const connection = await this.repository.findOneBy({ id });
|
||||
if (!connection) throw new NotFoundError('Git connection not found');
|
||||
return connection;
|
||||
}
|
||||
|
||||
private rootFolder(id: string) {
|
||||
return path.join(this.instanceSettings.n8nFolder, 'git-connections', id);
|
||||
}
|
||||
|
||||
/** Drop the cached working copy but keep the pinned host key. */
|
||||
private async resetWorkingCopy(id: string) {
|
||||
await this.gitService.resetWorkingCopy(this.rootFolder(id));
|
||||
}
|
||||
|
||||
/** Remove everything on disk for a connection, including the pinned host key. */
|
||||
private async purge(id: string) {
|
||||
await this.gitService.cleanup(this.rootFolder(id));
|
||||
}
|
||||
|
||||
private toPublic(connection: GitConnection): GitConnectionPublicDto {
|
||||
return {
|
||||
id: connection.id,
|
||||
name: connection.name,
|
||||
repositoryUrl: connection.repositoryUrl,
|
||||
branchName: connection.branchName,
|
||||
connectionType: connection.connectionType,
|
||||
publicKey: connection.publicKey,
|
||||
keyGeneratorType: connection.keyGeneratorType,
|
||||
baseCommit: connection.baseCommit,
|
||||
createdAt: connection.createdAt.toISOString(),
|
||||
updatedAt: connection.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private toSummary(connection: GitConnection) {
|
||||
const { publicKey: _, ...summary } = this.toPublic(connection);
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
|
|
@ -332,7 +332,12 @@ describe('SourceControlGitService', () => {
|
|||
binary: 'git',
|
||||
maxConcurrentProcesses: 6,
|
||||
trimmed: false,
|
||||
config: [`credential.helper=${expectedCredentialScript}`, 'credential.useHttpPath=true'],
|
||||
config: [
|
||||
`credential.helper=${expectedCredentialScript}`,
|
||||
'credential.useHttpPath=true',
|
||||
'http.lowSpeedLimit=1000',
|
||||
'http.lowSpeedTime=30',
|
||||
],
|
||||
unsafe: { allowUnsafeCredentialHelper: true },
|
||||
}),
|
||||
);
|
||||
|
|
@ -359,7 +364,12 @@ describe('SourceControlGitService', () => {
|
|||
"!f() { echo username='user'\"'\"'; rm -rf /'; echo password='pass'\"'\"'; rm -rf /'; }; f";
|
||||
expect(simpleGit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: [`credential.helper=${expectedCredentialScript}`, 'credential.useHttpPath=true'],
|
||||
config: [
|
||||
`credential.helper=${expectedCredentialScript}`,
|
||||
'credential.useHttpPath=true',
|
||||
'http.lowSpeedLimit=1000',
|
||||
'http.lowSpeedTime=30',
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -382,7 +392,7 @@ describe('SourceControlGitService', () => {
|
|||
);
|
||||
expect(mockGitInstance.env).toHaveBeenCalledWith(
|
||||
'GIT_SSH_COMMAND',
|
||||
'ssh -o UserKnownHostsFile=".ssh/known_hosts" -o StrictHostKeyChecking=accept-new -i "private-key"',
|
||||
"ssh -o ConnectTimeout=30 -o ServerAliveInterval=15 -o ServerAliveCountMax=3 -o UserKnownHostsFile='.ssh/known_hosts' -o StrictHostKeyChecking=accept-new -i 'private-key'",
|
||||
);
|
||||
expect(mockGitInstance.env).toHaveBeenCalledWith('GIT_TERMINAL_PROMPT', '0');
|
||||
});
|
||||
|
|
@ -614,11 +624,11 @@ describe('SourceControlGitService', () => {
|
|||
// Assert - verify paths with spaces are properly quoted
|
||||
expect(mockGitInstance.env).toHaveBeenCalledWith(
|
||||
'GIT_SSH_COMMAND',
|
||||
expect.stringContaining('"C:/Users/Test User/.n8n/ssh_private_key_temp"'), // Quoted path with spaces
|
||||
expect.stringContaining("'C:/Users/Test User/.n8n/ssh_private_key_temp'"), // Quoted path with spaces
|
||||
);
|
||||
expect(mockGitInstance.env).toHaveBeenCalledWith(
|
||||
'GIT_SSH_COMMAND',
|
||||
expect.stringContaining('"C:/Users/Test User/.n8n/.ssh/known_hosts"'), // Quoted known_hosts path
|
||||
expect.stringContaining("'C:/Users/Test User/.n8n/.ssh/known_hosts'"), // Quoted known_hosts path
|
||||
);
|
||||
expect(mockGitInstance.env).toHaveBeenCalledWith(
|
||||
'GIT_SSH_COMMAND',
|
||||
|
|
@ -630,7 +640,7 @@ describe('SourceControlGitService', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should escape double quotes in paths to prevent command injection', async () => {
|
||||
it('should single-quote paths to prevent command injection', async () => {
|
||||
// Arrange
|
||||
const mockPreferencesService = mock<SourceControlPreferencesService>();
|
||||
const pathWithQuotes = 'C:/Users/Test"User/.n8n/ssh_private_key_temp';
|
||||
|
|
@ -655,14 +665,56 @@ describe('SourceControlGitService', () => {
|
|||
// Act
|
||||
await gitService.setGitCommand('/git/folder', sshFolder);
|
||||
|
||||
// Assert - verify the SSH command was properly escaped
|
||||
// Assert - the double quote is kept literal inside single quotes, so it
|
||||
// cannot terminate the argument and inject a command.
|
||||
expect(mockGitInstance.env).toHaveBeenCalledWith(
|
||||
'GIT_SSH_COMMAND',
|
||||
expect.stringContaining('Test\\"User'), // Escaped quote
|
||||
expect.stringContaining("-i 'C:/Users/Test\"User/.n8n/ssh_private_key_temp'"),
|
||||
);
|
||||
expect(mockGitInstance.env).toHaveBeenCalledWith(
|
||||
'GIT_SSH_COMMAND',
|
||||
expect.not.stringContaining('Test"User'), // No unescaped quote in final command
|
||||
expect.stringContaining(
|
||||
"-o UserKnownHostsFile='C:/Users/Test\"User/.n8n/.ssh/known_hosts'",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should escape single quotes in paths to prevent command injection', async () => {
|
||||
// Arrange - a path containing a single quote (e.g. a Windows user
|
||||
// folder like "John's") would otherwise terminate the quoted argument.
|
||||
const mockPreferencesService = mock<SourceControlPreferencesService>();
|
||||
const pathWithSingleQuote = "C:/Users/John's/.n8n/ssh_private_key_temp";
|
||||
const sshFolder = "C:/Users/John's/.n8n/.ssh";
|
||||
|
||||
mockPreferencesService.getPrivateKeyPath.mockResolvedValue(pathWithSingleQuote);
|
||||
mockPreferencesService.getPreferences.mockReturnValue({
|
||||
connectionType: 'ssh',
|
||||
connected: true,
|
||||
repositoryUrl: 'git@github.com:user/repo.git',
|
||||
branchName: 'main',
|
||||
branchReadOnly: false,
|
||||
branchColor: '#5296D6',
|
||||
initRepo: false,
|
||||
keyGeneratorType: 'ed25519',
|
||||
});
|
||||
|
||||
const gitService = new SourceControlGitService(mock(), mock(), mockPreferencesService);
|
||||
|
||||
// Act
|
||||
await gitService.setGitCommand('/git/folder', sshFolder);
|
||||
|
||||
// Assert - the single quote is emitted as the POSIX '\'' sequence
|
||||
// (close-quote, escaped quote, reopen-quote), keeping it literal so
|
||||
// it cannot break out of the argument and inject a command.
|
||||
expect(mockGitInstance.env).toHaveBeenCalledWith(
|
||||
'GIT_SSH_COMMAND',
|
||||
expect.stringContaining("-i 'C:/Users/John'\"'\"'s/.n8n/ssh_private_key_temp'"),
|
||||
);
|
||||
expect(mockGitInstance.env).toHaveBeenCalledWith(
|
||||
'GIT_SSH_COMMAND',
|
||||
expect.stringContaining(
|
||||
"-o UserKnownHostsFile='C:/Users/John'\"'\"'s/.n8n/.ssh/known_hosts'",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { Logger } from '@n8n/backend-common';
|
||||
import { resolveProxyUrl } from '@n8n/backend-network';
|
||||
import type { User } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { execSync } from 'child_process';
|
||||
|
|
@ -16,6 +15,10 @@ import type {
|
|||
StatusResult,
|
||||
} from 'simple-git';
|
||||
|
||||
import {
|
||||
buildHttpsGitConfig,
|
||||
buildSshCommand,
|
||||
} from '@/modules/git-connections.ee/git-connections-git.utils';
|
||||
import { OwnershipService } from '@/services/ownership.service';
|
||||
|
||||
import {
|
||||
|
|
@ -130,47 +133,20 @@ export class SourceControlGitService {
|
|||
|
||||
if (preferences.connectionType === 'https') {
|
||||
const credentials = await this.sourceControlPreferencesService.getDecryptedHttpsCredentials();
|
||||
const escapeShellArg = (arg: string) => `'${arg.replace(/'/g, "'\"'\"'")}'`;
|
||||
const credentialScript = `!f() { echo username=${escapeShellArg(credentials.username)}; echo password=${escapeShellArg(credentials.password)}; }; f`;
|
||||
|
||||
const config = buildHttpsGitConfig(preferences.repositoryUrl, credentials);
|
||||
const httpsGitOptions = {
|
||||
...this.gitOptions,
|
||||
config: [
|
||||
'credential.helper=' + credentialScript,
|
||||
// ensures that the credentials are only used for the configured repositoryUrl of the environment
|
||||
'credential.useHttpPath=true',
|
||||
],
|
||||
config,
|
||||
unsafe: { allowUnsafeCredentialHelper: true },
|
||||
};
|
||||
|
||||
// Add proxy configuration if proxy environment variables are set
|
||||
const repositoryUrl = preferences.repositoryUrl;
|
||||
const proxyUrl = resolveProxyUrl(repositoryUrl);
|
||||
if (proxyUrl) {
|
||||
// Git uses http.proxy for both HTTP and HTTPS URLs
|
||||
this.logger.debug('Proxy configuration added', { proxyUrl });
|
||||
httpsGitOptions.config.push(`http.proxy=${proxyUrl}`);
|
||||
}
|
||||
|
||||
this.git = simpleGit(httpsGitOptions).env('GIT_TERMINAL_PROMPT', '0');
|
||||
} else if (preferences.connectionType === 'ssh') {
|
||||
const privateKeyPath = await this.sourceControlPreferencesService.getPrivateKeyPath();
|
||||
const sshKnownHosts = path.join(sshFolder, 'known_hosts');
|
||||
|
||||
// Convert paths to POSIX format for SSH command (works cross-platform)
|
||||
// Use regex to handle both Windows (\) and POSIX (/) separators regardless of current platform
|
||||
const normalizedPrivateKeyPath = privateKeyPath.split(/[/\\]/).join('/');
|
||||
const normalizedKnownHostsPath = sshKnownHosts.split(/[/\\]/).join('/');
|
||||
|
||||
// Escape double quotes to prevent command injection
|
||||
const escapedPrivateKeyPath = normalizedPrivateKeyPath.replace(/"/g, '\\"');
|
||||
const escapedKnownHostsPath = normalizedKnownHostsPath.replace(/"/g, '\\"');
|
||||
|
||||
// Quote paths to handle spaces and special characters
|
||||
// Use StrictHostKeyChecking=accept-new to protect against MITM attacks:
|
||||
// - First connection: accepts and saves host key to known_hosts
|
||||
// - Subsequent connections: verifies against saved key
|
||||
const sshCommand = `ssh -o UserKnownHostsFile="${escapedKnownHostsPath}" -o StrictHostKeyChecking=accept-new -i "${escapedPrivateKeyPath}"`;
|
||||
const sshCommand = buildSshCommand({
|
||||
privateKeyPath,
|
||||
knownHostsPath: path.join(sshFolder, 'known_hosts'),
|
||||
});
|
||||
|
||||
// Allow GIT_SSH_COMMAND so we can point SSH at n8n's own private key and known_hosts.
|
||||
// This is safe because the command is constructed internally above, not from user input.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import type { SourceControlledFile } from '@n8n/api-types';
|
|||
import { isContainedWithin, Logger, safeJoinPath } from '@n8n/backend-common';
|
||||
import type { TagEntity, WorkflowTagMapping } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { generateKeyPairSync } from 'crypto';
|
||||
import { accessSync, constants as fsConstants, mkdirSync } from 'fs';
|
||||
import chunk from 'lodash/chunk';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
|
|
@ -19,6 +18,7 @@ import { readFile as fsReadFile } from 'node:fs/promises';
|
|||
import path from 'path';
|
||||
|
||||
import { License } from '@/license';
|
||||
import { generateSshKeyPair as generateGitSshKeyPair } from '@/modules/git-connections.ee/git-connections-git.utils';
|
||||
import { containsExpression } from '@/utils';
|
||||
|
||||
import {
|
||||
|
|
@ -286,47 +286,8 @@ export function isSourceControlLicensed() {
|
|||
return license.isSourceControlLicensed();
|
||||
}
|
||||
|
||||
export async function generateSshKeyPair(keyType: KeyPairType) {
|
||||
// sshpk is CommonJS (`export =`): under nodenext, a native dynamic import only
|
||||
// hoists some named exports onto the namespace (parsePrivateKey is missed), so
|
||||
// read the real module.exports off `.default`.
|
||||
const { default: sshpk } = await import('sshpk');
|
||||
const keyPair: KeyPair = {
|
||||
publicKey: '',
|
||||
privateKey: '',
|
||||
};
|
||||
let generatedKeyPair: KeyPair;
|
||||
switch (keyType) {
|
||||
case 'ed25519':
|
||||
generatedKeyPair = generateKeyPairSync('ed25519', {
|
||||
privateKeyEncoding: { format: 'pem', type: 'pkcs8' },
|
||||
publicKeyEncoding: { format: 'pem', type: 'spki' },
|
||||
});
|
||||
break;
|
||||
case 'rsa':
|
||||
generatedKeyPair = generateKeyPairSync('rsa', {
|
||||
modulusLength: 4096,
|
||||
publicKeyEncoding: {
|
||||
type: 'spki',
|
||||
format: 'pem',
|
||||
},
|
||||
privateKeyEncoding: {
|
||||
type: 'pkcs8',
|
||||
format: 'pem',
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
const keyPublic = sshpk.parseKey(generatedKeyPair.publicKey, 'pem');
|
||||
keyPublic.comment = SOURCE_CONTROL_GIT_KEY_COMMENT;
|
||||
keyPair.publicKey = keyPublic.toString('ssh');
|
||||
const keyPrivate = sshpk.parsePrivateKey(generatedKeyPair.privateKey, 'pem');
|
||||
keyPrivate.comment = SOURCE_CONTROL_GIT_KEY_COMMENT;
|
||||
keyPair.privateKey = keyPrivate.toString('ssh-private');
|
||||
return {
|
||||
privateKey: keyPair.privateKey,
|
||||
publicKey: keyPair.publicKey,
|
||||
};
|
||||
export async function generateSshKeyPair(keyType: KeyPairType): Promise<KeyPair> {
|
||||
return await generateGitSshKeyPair(keyType, SOURCE_CONTROL_GIT_KEY_COMMENT);
|
||||
}
|
||||
|
||||
export function getRepoType(repoUrl: string): 'github' | 'gitlab' | 'other' {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user