wip trpc
This commit is contained in:
111
apps/server/src/lib/common.ts
Normal file
111
apps/server/src/lib/common.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { Permission, Setting, Team, TeamInvitation, User } from '@prisma/client';
|
||||
import { prisma } from '../prisma';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs/promises';
|
||||
import { uniqueNamesGenerator, adjectives, colors, animals } from 'unique-names-generator';
|
||||
import type { Config } from 'unique-names-generator';
|
||||
import { env } from '../env';
|
||||
import { day } from './dayjs';
|
||||
|
||||
const customConfig: Config = {
|
||||
dictionaries: [adjectives, colors, animals],
|
||||
style: 'capital',
|
||||
separator: ' ',
|
||||
length: 3
|
||||
};
|
||||
const algorithm = 'aes-256-ctr';
|
||||
export const isDev = env.NODE_ENV === 'development';
|
||||
export const version = '3.13.0';
|
||||
export const sentryDSN =
|
||||
'https://409f09bcb7af47928d3e0f46b78987f3@o1082494.ingest.sentry.io/4504236622217216';
|
||||
|
||||
export async function listSettings(): Promise<Setting | null> {
|
||||
return await prisma.setting.findUnique({ where: { id: '0' } });
|
||||
}
|
||||
export async function getCurrentUser(
|
||||
userId: string
|
||||
): Promise<(User & { permission: Permission[]; teams: Team[] }) | null> {
|
||||
return await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: { teams: true, permission: true }
|
||||
});
|
||||
}
|
||||
export async function getTeamInvitation(userId: string): Promise<TeamInvitation[]> {
|
||||
return await prisma.teamInvitation.findMany({ where: { uid: userId } });
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const saltRounds = 15;
|
||||
return bcrypt.hash(password, saltRounds);
|
||||
}
|
||||
export async function comparePassword(password: string, hashedPassword: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, hashedPassword);
|
||||
}
|
||||
export const uniqueName = (): string => uniqueNamesGenerator(customConfig);
|
||||
|
||||
export const decrypt = (hashString: string) => {
|
||||
if (hashString) {
|
||||
try {
|
||||
const hash = JSON.parse(hashString);
|
||||
const decipher = crypto.createDecipheriv(
|
||||
algorithm,
|
||||
env.COOLIFY_SECRET_KEY,
|
||||
Buffer.from(hash.iv, 'hex')
|
||||
);
|
||||
const decrpyted = Buffer.concat([
|
||||
decipher.update(Buffer.from(hash.content, 'hex')),
|
||||
decipher.final()
|
||||
]);
|
||||
return decrpyted.toString();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.log({ decryptionError: error.message });
|
||||
}
|
||||
return hashString;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export function generateRangeArray(start, end) {
|
||||
return Array.from({ length: end - start }, (v, k) => k + start);
|
||||
}
|
||||
export function generateTimestamp(): string {
|
||||
return `${day().format('HH:mm:ss.SSS')}`;
|
||||
}
|
||||
export const encrypt = (text: string) => {
|
||||
if (text) {
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(algorithm, env.COOLIFY_SECRET_KEY, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(text.trim()), cipher.final()]);
|
||||
return JSON.stringify({
|
||||
iv: iv.toString('hex'),
|
||||
content: encrypted.toString('hex')
|
||||
});
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export async function getTemplates() {
|
||||
const templatePath = isDev ? './templates.json' : '/app/templates.json';
|
||||
const open = await fs.open(templatePath, 'r');
|
||||
try {
|
||||
let data = await open.readFile({ encoding: 'utf-8' });
|
||||
let jsonData = JSON.parse(data);
|
||||
if (isARM(process.arch)) {
|
||||
jsonData = jsonData.filter((d) => d.arch !== 'amd64');
|
||||
}
|
||||
return jsonData;
|
||||
} catch (error) {
|
||||
return [];
|
||||
} finally {
|
||||
await open?.close();
|
||||
}
|
||||
}
|
||||
export function isARM(arch: string) {
|
||||
if (arch === 'arm' || arch === 'arm64' || arch === 'aarch' || arch === 'aarch64') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
7
apps/server/src/lib/dayjs.ts
Normal file
7
apps/server/src/lib/dayjs.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc.js';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime.js';
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export { dayjs as day };
|
||||
47
apps/server/src/lib/docker.ts
Normal file
47
apps/server/src/lib/docker.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { executeCommand } from "./executeCommand";
|
||||
|
||||
export async function checkContainer({ dockerId, container, remove = false }: { dockerId: string, container: string, remove?: boolean }): Promise<{ found: boolean, status?: { isExited: boolean, isRunning: boolean, isRestarting: boolean } }> {
|
||||
let containerFound = false;
|
||||
try {
|
||||
const { stdout } = await executeCommand({
|
||||
dockerId,
|
||||
command:
|
||||
`docker inspect --format '{{json .State}}' ${container}`
|
||||
});
|
||||
containerFound = true
|
||||
const parsedStdout = JSON.parse(stdout);
|
||||
const status = parsedStdout.Status;
|
||||
const isRunning = status === 'running';
|
||||
const isRestarting = status === 'restarting'
|
||||
const isExited = status === 'exited'
|
||||
if (status === 'created') {
|
||||
await executeCommand({
|
||||
dockerId,
|
||||
command:
|
||||
`docker rm ${container}`
|
||||
});
|
||||
}
|
||||
if (remove && status === 'exited') {
|
||||
await executeCommand({
|
||||
dockerId,
|
||||
command:
|
||||
`docker rm ${container}`
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
found: containerFound,
|
||||
status: {
|
||||
isRunning,
|
||||
isRestarting,
|
||||
isExited
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
// Container not found
|
||||
}
|
||||
return {
|
||||
found: false
|
||||
};
|
||||
|
||||
}
|
||||
188
apps/server/src/lib/executeCommand.ts
Normal file
188
apps/server/src/lib/executeCommand.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { prisma } from '../prisma';
|
||||
import os from 'os';
|
||||
import fs from 'fs/promises';
|
||||
import type { ExecaChildProcess } from 'execa';
|
||||
import sshConfig from 'ssh-config';
|
||||
|
||||
import { getFreeSSHLocalPort } from './ssh';
|
||||
import { env } from '../env';
|
||||
import { saveBuildLog } from './logging';
|
||||
import { decrypt } from './common';
|
||||
|
||||
export async function executeCommand({
|
||||
command,
|
||||
dockerId = null,
|
||||
sshCommand = false,
|
||||
shell = false,
|
||||
stream = false,
|
||||
buildId,
|
||||
applicationId,
|
||||
debug
|
||||
}: {
|
||||
command: string;
|
||||
sshCommand?: boolean;
|
||||
shell?: boolean;
|
||||
stream?: boolean;
|
||||
dockerId?: string;
|
||||
buildId?: string;
|
||||
applicationId?: string;
|
||||
debug?: boolean;
|
||||
}): Promise<ExecaChildProcess<string>> {
|
||||
const { execa, execaCommand } = await import('execa');
|
||||
const { parse } = await import('shell-quote');
|
||||
const parsedCommand = parse(command);
|
||||
const dockerCommand = parsedCommand[0];
|
||||
const dockerArgs = parsedCommand.slice(1);
|
||||
|
||||
if (dockerId) {
|
||||
const destinationDocker = await prisma.destinationDocker.findUnique({
|
||||
where: { id: dockerId }
|
||||
});
|
||||
if (!destinationDocker) {
|
||||
throw new Error('Destination docker not found');
|
||||
}
|
||||
let { remoteEngine, remoteIpAddress, engine } = destinationDocker;
|
||||
if (remoteEngine) {
|
||||
await createRemoteEngineConfiguration(dockerId);
|
||||
engine = `ssh://${remoteIpAddress}-remote`;
|
||||
} else {
|
||||
engine = 'unix:///var/run/docker.sock';
|
||||
}
|
||||
if (env.CODESANDBOX_HOST) {
|
||||
if (command.startsWith('docker compose')) {
|
||||
command = command.replace(/docker compose/gi, 'docker-compose');
|
||||
}
|
||||
}
|
||||
if (sshCommand) {
|
||||
if (shell) {
|
||||
return execaCommand(`ssh ${remoteIpAddress}-remote ${command}`);
|
||||
}
|
||||
return await execa('ssh', [`${remoteIpAddress}-remote`, dockerCommand, ...dockerArgs]);
|
||||
}
|
||||
if (stream) {
|
||||
return await new Promise(async (resolve, reject) => {
|
||||
let subprocess = null;
|
||||
if (shell) {
|
||||
subprocess = execaCommand(command, {
|
||||
env: { DOCKER_BUILDKIT: '1', DOCKER_HOST: engine }
|
||||
});
|
||||
} else {
|
||||
subprocess = execa(dockerCommand, dockerArgs, {
|
||||
env: { DOCKER_BUILDKIT: '1', DOCKER_HOST: engine }
|
||||
});
|
||||
}
|
||||
const logs: any[] = [];
|
||||
if (subprocess && subprocess.stdout && subprocess.stderr) {
|
||||
subprocess.stdout.on('data', async (data) => {
|
||||
const stdout = data.toString();
|
||||
const array = stdout.split('\n');
|
||||
for (const line of array) {
|
||||
if (line !== '\n' && line !== '') {
|
||||
const log = {
|
||||
line: `${line.replace('\n', '')}`,
|
||||
buildId,
|
||||
applicationId
|
||||
};
|
||||
logs.push(log);
|
||||
if (debug) {
|
||||
await saveBuildLog(log);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
subprocess.stderr.on('data', async (data) => {
|
||||
const stderr = data.toString();
|
||||
const array = stderr.split('\n');
|
||||
for (const line of array) {
|
||||
if (line !== '\n' && line !== '') {
|
||||
const log = {
|
||||
line: `${line.replace('\n', '')}`,
|
||||
buildId,
|
||||
applicationId
|
||||
};
|
||||
logs.push(log);
|
||||
if (debug) {
|
||||
await saveBuildLog(log);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
subprocess.on('exit', async (code) => {
|
||||
if (code === 0) {
|
||||
resolve('success');
|
||||
} else {
|
||||
if (!debug) {
|
||||
for (const log of logs) {
|
||||
await saveBuildLog(log);
|
||||
}
|
||||
}
|
||||
reject(code);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (shell) {
|
||||
return await execaCommand(command, {
|
||||
env: { DOCKER_BUILDKIT: '1', DOCKER_HOST: engine }
|
||||
});
|
||||
} else {
|
||||
return await execa(dockerCommand, dockerArgs, {
|
||||
env: { DOCKER_BUILDKIT: '1', DOCKER_HOST: engine }
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (shell) {
|
||||
return execaCommand(command, { shell: true });
|
||||
}
|
||||
return await execa(dockerCommand, dockerArgs);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createRemoteEngineConfiguration(id: string) {
|
||||
const homedir = os.homedir();
|
||||
const sshKeyFile = `/tmp/id_rsa-${id}`;
|
||||
const localPort = await getFreeSSHLocalPort(id);
|
||||
const {
|
||||
sshKey: { privateKey },
|
||||
network,
|
||||
remoteIpAddress,
|
||||
remotePort,
|
||||
remoteUser
|
||||
} = await prisma.destinationDocker.findFirst({ where: { id }, include: { sshKey: true } });
|
||||
await fs.writeFile(sshKeyFile, decrypt(privateKey) + '\n', { encoding: 'utf8', mode: 400 });
|
||||
const config = sshConfig.parse('');
|
||||
const Host = `${remoteIpAddress}-remote`;
|
||||
|
||||
try {
|
||||
await executeCommand({ command: `ssh-keygen -R ${Host}` });
|
||||
await executeCommand({ command: `ssh-keygen -R ${remoteIpAddress}` });
|
||||
await executeCommand({ command: `ssh-keygen -R localhost:${localPort}` });
|
||||
} catch (error) {}
|
||||
|
||||
const found = config.find({ Host });
|
||||
const foundIp = config.find({ Host: remoteIpAddress });
|
||||
|
||||
if (found) config.remove({ Host });
|
||||
if (foundIp) config.remove({ Host: remoteIpAddress });
|
||||
|
||||
config.append({
|
||||
Host,
|
||||
Hostname: remoteIpAddress,
|
||||
Port: remotePort.toString(),
|
||||
User: remoteUser,
|
||||
StrictHostKeyChecking: 'no',
|
||||
IdentityFile: sshKeyFile,
|
||||
ControlMaster: 'auto',
|
||||
ControlPath: `${homedir}/.ssh/coolify-${remoteIpAddress}-%r@%h:%p`,
|
||||
ControlPersist: '10m'
|
||||
});
|
||||
|
||||
try {
|
||||
await fs.stat(`${homedir}/.ssh/`);
|
||||
} catch (error) {
|
||||
await fs.mkdir(`${homedir}/.ssh/`);
|
||||
}
|
||||
return await fs.writeFile(`${homedir}/.ssh/config`, sshConfig.stringify(config));
|
||||
}
|
||||
50
apps/server/src/lib/logging.ts
Normal file
50
apps/server/src/lib/logging.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { prisma } from '../prisma';
|
||||
import { encrypt, generateTimestamp, isDev } from './common';
|
||||
import { day } from './dayjs';
|
||||
|
||||
export const saveBuildLog = async ({
|
||||
line,
|
||||
buildId,
|
||||
applicationId
|
||||
}: {
|
||||
line: string;
|
||||
buildId: string;
|
||||
applicationId: string;
|
||||
}): Promise<any> => {
|
||||
if (buildId === 'undefined' || buildId === 'null' || !buildId) return;
|
||||
if (applicationId === 'undefined' || applicationId === 'null' || !applicationId) return;
|
||||
const { default: got } = await import('got');
|
||||
if (typeof line === 'object' && line) {
|
||||
if (line.shortMessage) {
|
||||
line = line.shortMessage + '\n' + line.stderr;
|
||||
} else {
|
||||
line = JSON.stringify(line);
|
||||
}
|
||||
}
|
||||
if (line && typeof line === 'string' && line.includes('ghs_')) {
|
||||
const regex = /ghs_.*@/g;
|
||||
line = line.replace(regex, '<SENSITIVE_DATA_DELETED>@');
|
||||
}
|
||||
const addTimestamp = `[${generateTimestamp()}] ${line}`;
|
||||
const fluentBitUrl = isDev ? 'http://localhost:24224' : 'http://coolify-fluentbit:24224';
|
||||
|
||||
if (isDev) {
|
||||
console.debug(`[${applicationId}] ${addTimestamp}`);
|
||||
}
|
||||
try {
|
||||
return await got.post(`${fluentBitUrl}/${applicationId}_buildlog_${buildId}.csv`, {
|
||||
json: {
|
||||
line: encrypt(line)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
return await prisma.buildLog.create({
|
||||
data: {
|
||||
line: addTimestamp,
|
||||
buildId,
|
||||
time: Number(day().valueOf()),
|
||||
applicationId
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
47
apps/server/src/lib/ssh.ts
Normal file
47
apps/server/src/lib/ssh.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { prisma } from '../prisma';
|
||||
import { generateRangeArray } from './common';
|
||||
|
||||
export async function getFreeSSHLocalPort(id: string): Promise<number | boolean> {
|
||||
const { default: isReachable } = await import('is-port-reachable');
|
||||
const { remoteIpAddress, sshLocalPort } = await prisma.destinationDocker.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
if (sshLocalPort) {
|
||||
return Number(sshLocalPort);
|
||||
}
|
||||
|
||||
const data = await prisma.setting.findFirst();
|
||||
const { minPort, maxPort } = data;
|
||||
|
||||
const ports = await prisma.destinationDocker.findMany({
|
||||
where: { sshLocalPort: { not: null }, remoteIpAddress: { not: remoteIpAddress } }
|
||||
});
|
||||
|
||||
const alreadyConfigured = await prisma.destinationDocker.findFirst({
|
||||
where: {
|
||||
remoteIpAddress,
|
||||
id: { not: id },
|
||||
sshLocalPort: { not: null }
|
||||
}
|
||||
});
|
||||
if (alreadyConfigured?.sshLocalPort) {
|
||||
await prisma.destinationDocker.update({
|
||||
where: { id },
|
||||
data: { sshLocalPort: alreadyConfigured.sshLocalPort }
|
||||
});
|
||||
return Number(alreadyConfigured.sshLocalPort);
|
||||
}
|
||||
const range = generateRangeArray(minPort, maxPort);
|
||||
const availablePorts = range.filter((port) => !ports.map((p) => p.sshLocalPort).includes(port));
|
||||
for (const port of availablePorts) {
|
||||
const found = await isReachable(port, { host: 'localhost' });
|
||||
if (!found) {
|
||||
await prisma.destinationDocker.update({
|
||||
where: { id },
|
||||
data: { sshLocalPort: Number(port) }
|
||||
});
|
||||
return Number(port);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user