diff --git a/apps/api/package.json b/apps/api/package.json index f3635517b..0f8c8842a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -21,6 +21,7 @@ "@fastify/env": "4.1.0", "@fastify/jwt": "6.3.2", "@fastify/static": "6.5.0", + "@fastify/multipart": "7.2.0", "@iarna/toml": "2.2.5", "@ladjs/graceful": "3.0.2", "@prisma/client": "4.3.1", @@ -49,6 +50,7 @@ "p-all": "4.0.0", "p-throttle": "5.0.0", "public-ip": "6.0.1", + "pump": "^3.0.0", "ssh-config": "4.1.6", "strip-ansi": "7.0.1", "unique-names-generator": "4.7.1" diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index a7500539d..94b907bf1 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -8,6 +8,15 @@ datasource db { url = env("COOLIFY_DATABASE_URL") } +model Certificate { + id String @id @default(cuid()) + key String + cert String + team Team[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + model Setting { id String @id @default(cuid()) fqdn String? @unique @@ -70,6 +79,7 @@ model Team { gitLabApps GitlabApp[] service Service[] users User[] + certificate Certificate[] } model TeamInvitation { diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 902596459..df89febd5 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -3,6 +3,7 @@ import cors from '@fastify/cors'; import serve from '@fastify/static'; import env from '@fastify/env'; import cookie from '@fastify/cookie'; +import multipart from '@fastify/multipart'; import path, { join } from 'path'; import autoLoad from '@fastify/autoload'; import { asyncExecShell, createRemoteEngineConfiguration, getDomain, isDev, listSettings, prisma, version } from './lib/common'; @@ -31,6 +32,7 @@ prisma.setting.findFirst().then(async (settings) => { logger: settings?.isAPIDebuggingEnabled || false, trustProxy: true }); + const schema = { type: 'object', required: ['COOLIFY_SECRET_KEY', 'COOLIFY_DATABASE_URL', 'COOLIFY_IS_ON'], @@ -88,13 +90,13 @@ prisma.setting.findFirst().then(async (settings) => { return reply.status(200).sendFile('index.html'); }); } + fastify.register(multipart, { limits: { fileSize: 100000 } }); fastify.register(autoLoad, { dir: join(__dirname, 'plugins') }); fastify.register(autoLoad, { dir: join(__dirname, 'routes') }); - fastify.register(cookie) fastify.register(cors); fastify.addHook('onRequest', async (request, reply) => { @@ -145,11 +147,15 @@ prisma.setting.findFirst().then(async (settings) => { scheduler.workers.has('infrastructure') && scheduler.workers.get('infrastructure').postMessage("action:cleanupStorage") }, isDev ? 6000 : 60000 * 10) - // checkProxies + // checkProxies and checkFluentBit setInterval(async () => { scheduler.workers.has('infrastructure') && scheduler.workers.get('infrastructure').postMessage("action:checkProxies") + scheduler.workers.has('infrastructure') && scheduler.workers.get('infrastructure').postMessage("action:checkFluentBit") }, 10000) + setInterval(async () => { + scheduler.workers.has('infrastructure') && scheduler.workers.get('infrastructure').postMessage("action:copySSLCertificates") + }, 2000) // cleanupPrismaEngines // setInterval(async () => { // scheduler.workers.has('infrastructure') && scheduler.workers.get('infrastructure').postMessage("action:cleanupPrismaEngines") diff --git a/apps/api/src/jobs/infrastructure.ts b/apps/api/src/jobs/infrastructure.ts index 23e380610..04acb81ae 100644 --- a/apps/api/src/jobs/infrastructure.ts +++ b/apps/api/src/jobs/infrastructure.ts @@ -1,8 +1,9 @@ import { parentPort } from 'node:worker_threads'; import axios from 'axios'; import { compareVersions } from 'compare-versions'; -import { asyncExecShell, cleanupDockerStorage, executeDockerCmd, isDev, prisma, startTraefikTCPProxy, generateDatabaseConfiguration, startTraefikProxy, listSettings, version, createRemoteEngineConfiguration } from '../lib/common'; - +import { asyncExecShell, cleanupDockerStorage, executeDockerCmd, isDev, prisma, startTraefikTCPProxy, generateDatabaseConfiguration, startTraefikProxy, listSettings, version, createRemoteEngineConfiguration, decrypt } from '../lib/common'; +import { checkContainer } from '../lib/docker'; +import fs from 'fs/promises' async function autoUpdater() { try { const currentVersion = version; @@ -39,6 +40,46 @@ async function autoUpdater() { } } catch (error) { } } +async function checkFluentBit() { + if (!isDev) { + const engine = '/var/run/docker.sock'; + const { id } = await prisma.destinationDocker.findFirst({ + where: { engine, network: 'coolify' } + }); + const { found } = await checkContainer({ dockerId: id, container: 'coolify-fluentbit' }); + if (!found) { + await asyncExecShell(`env | grep COOLIFY > .env`); + await asyncExecShell(`docker compose up -d fluent-bit`); + } + } +} +async function copySSLCertificates() { + try { + const certificates = await prisma.certificate.findMany({ include: { team: true } }) + const teamIds = certificates.map(c => c.team.map(t => t.id)).flat() + const destinations = await prisma.destinationDocker.findMany({ where: { isCoolifyProxyUsed: true, teams: { some: { id: { in: teamIds } } } } }) + for (const destination of destinations) { + if (destination.remoteEngine) { + // TODO: copy certificates to remote engine + } else { + for (const certificate of certificates) { + const { id, key, cert } = certificate + const decryptedKey = decrypt(key) + await asyncExecShell(`docker exec coolify-proxy sh -c 'mkdir -p /etc/traefik/acme/custom/'`) + await fs.writeFile(`/tmp/${id}-key.pem`, decryptedKey) + await fs.writeFile(`/tmp/${id}-cert.pem`, cert) + await asyncExecShell(`docker cp /tmp/${id}-key.pem coolify-proxy:/etc/traefik/acme/custom/`) + await asyncExecShell(`docker cp /tmp/${id}-cert.pem coolify-proxy:/etc/traefik/acme/custom/`) + await fs.rm(`/tmp/${id}-key.pem`) + await fs.rm(`/tmp/${id}-cert.pem`) + } + } + + } + } catch (error) { + + } +} async function checkProxies() { try { const { default: isReachable } = await import('is-port-reachable'); @@ -215,6 +256,14 @@ async function cleanupStorage() { await checkProxies(); return; } + if (message === 'action:checkFluentBit') { + await checkFluentBit(); + return; + } + if (message === 'action:copySSLCertificates') { + await copySSLCertificates(); + return; + } if (message === 'action:autoUpdater') { if (!status.cleanupStorage) { status.autoUpdater = true diff --git a/apps/api/src/routes/api/v1/index.ts b/apps/api/src/routes/api/v1/index.ts index 6ec94e479..1f5ab0696 100644 --- a/apps/api/src/routes/api/v1/index.ts +++ b/apps/api/src/routes/api/v1/index.ts @@ -1,6 +1,9 @@ import { FastifyPluginAsync } from 'fastify'; import { checkUpdate, login, showDashboard, update, resetQueue, getCurrentUser, cleanupManually, restartCoolify } from './handlers'; import { GetCurrentUser } from './types'; +import pump from 'pump' +import fs from 'fs' +import { asyncExecShell, encrypt, errorHandler, prisma } from '../../../lib/common'; export interface Update { Body: { latestVersion: string } diff --git a/apps/api/src/routes/api/v1/settings/handlers.ts b/apps/api/src/routes/api/v1/settings/handlers.ts index 831ce606e..ebd806cb7 100644 --- a/apps/api/src/routes/api/v1/settings/handlers.ts +++ b/apps/api/src/routes/api/v1/settings/handlers.ts @@ -1,8 +1,9 @@ import { promises as dns } from 'dns'; +import { X509Certificate } from 'node:crypto'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { checkDomainsIsValidInDNS, decrypt, encrypt, errorHandler, getDomain, isDNSValid, isDomainConfigured, listSettings, prisma } from '../../../../lib/common'; -import { CheckDNS, CheckDomain, DeleteDomain, DeleteSSHKey, SaveSettings, SaveSSHKey } from './types'; +import { CheckDNS, CheckDomain, DeleteDomain, DeleteSSHKey, OnlyIdInBody, SaveSettings, SaveSSHKey } from './types'; export async function listAllSettings(request: FastifyRequest) { @@ -16,8 +17,16 @@ export async function listAllSettings(request: FastifyRequest) { unencryptedKeys.push({ id: key.id, name: key.name, privateKey: decrypt(key.privateKey), createdAt: key.createdAt }) } } + const certificates = await prisma.certificate.findMany({ where: { team: { every: { id: teamId } } } }) + let cns = []; + for (const certificate of certificates) { + const x509 = new X509Certificate(certificate.cert); + cns.push({ commonName: x509.subject.split('\n').find((s) => s.startsWith('CN=')).replace('CN=', ''), id: certificate.id, createdAt: certificate.createdAt }) + } + return { settings, + certificates: cns, sshKeys: unencryptedKeys } } catch ({ status, message }) { @@ -118,7 +127,7 @@ export async function saveSSHKey(request: FastifyRequest, reply: Fas return errorHandler({ status, message }) } } -export async function deleteSSHKey(request: FastifyRequest, reply: FastifyReply) { +export async function deleteSSHKey(request: FastifyRequest, reply: FastifyReply) { try { const { id } = request.body; await prisma.sshKey.delete({ where: { id } }) @@ -126,4 +135,14 @@ export async function deleteSSHKey(request: FastifyRequest, reply: } catch ({ status, message }) { return errorHandler({ status, message }) } +} + +export async function deleteCertificates(request: FastifyRequest, reply: FastifyReply) { + try { + const { id } = request.body; + await prisma.certificate.delete({ where: { id } }) + return reply.code(201).send() + } catch ({ status, message }) { + return errorHandler({ status, message }) + } } \ No newline at end of file diff --git a/apps/api/src/routes/api/v1/settings/index.ts b/apps/api/src/routes/api/v1/settings/index.ts index 96da5948b..2f5d5d4bc 100644 --- a/apps/api/src/routes/api/v1/settings/index.ts +++ b/apps/api/src/routes/api/v1/settings/index.ts @@ -1,21 +1,58 @@ import { FastifyPluginAsync } from 'fastify'; -import { checkDNS, checkDomain, deleteDomain, deleteSSHKey, listAllSettings, saveSettings, saveSSHKey } from './handlers'; -import { CheckDNS, CheckDomain, DeleteDomain, DeleteSSHKey, SaveSettings, SaveSSHKey } from './types'; +import { X509Certificate } from 'node:crypto'; + +import { encrypt, errorHandler, prisma } from '../../../../lib/common'; +import { checkDNS, checkDomain, deleteCertificates, deleteDomain, deleteSSHKey, getCertificates, listAllSettings, saveSettings, saveSSHKey } from './handlers'; +import { CheckDNS, CheckDomain, DeleteDomain, DeleteSSHKey, OnlyIdInBody, SaveSettings, SaveSSHKey } from './types'; const root: FastifyPluginAsync = async (fastify): Promise => { - fastify.addHook('onRequest', async (request) => { - return await request.jwtVerify() - }) - fastify.get('/', async (request) => await listAllSettings(request)); - fastify.post('/', async (request, reply) => await saveSettings(request, reply)); - fastify.delete('/', async (request, reply) => await deleteDomain(request, reply)); + fastify.addHook('onRequest', async (request) => { + return await request.jwtVerify() + }) + fastify.get('/', async (request) => await listAllSettings(request)); + fastify.post('/', async (request, reply) => await saveSettings(request, reply)); + fastify.delete('/', async (request, reply) => await deleteDomain(request, reply)); - fastify.get('/check', async (request) => await checkDNS(request)); - fastify.post('/check', async (request) => await checkDomain(request)); + fastify.get('/check', async (request) => await checkDNS(request)); + fastify.post('/check', async (request) => await checkDomain(request)); - fastify.post('/sshKey', async (request, reply) => await saveSSHKey(request, reply)); - fastify.delete('/sshKey', async (request, reply) => await deleteSSHKey(request, reply)); + fastify.post('/sshKey', async (request, reply) => await saveSSHKey(request, reply)); + fastify.delete('/sshKey', async (request, reply) => await deleteSSHKey(request, reply)); + + fastify.post('/upload', async (request) => { + try { + const teamId = request.user.teamId; + const certificates = await prisma.certificate.findMany({}) + let cns = []; + for (const certificate of certificates) { + const x509 = new X509Certificate(certificate.cert); + cns.push(x509.subject.split('\n').find((s) => s.startsWith('CN=')).replace('CN=', '')) + } + const parts = await request.files() + let key = null + let cert = null + for await (const part of parts) { + const name = part.fieldname + if (name === 'key') key = (await part.toBuffer()).toString() + if (name === 'cert') cert = (await part.toBuffer()).toString() + } + const x509 = new X509Certificate(cert); + const cn = x509.subject.split('\n').find((s) => s.startsWith('CN=')).replace('CN=', '') + if (cns.includes(cn)) { + throw { + message: `A certificate with ${cn} common name already exists.` + } + } + await prisma.certificate.create({ data: { cert, key: encrypt(key), team: { connect: { id: teamId } } } }) + return { message: 'Certificated uploaded' } + } catch ({ status, message }) { + return errorHandler({ status, message }); + } + + }); + fastify.delete('/certificate', async (request, reply) => await deleteCertificates(request, reply)) + // fastify.get('/certificates', async (request) => await getCertificates(request)) }; export default root; diff --git a/apps/api/src/routes/api/v1/settings/types.ts b/apps/api/src/routes/api/v1/settings/types.ts index 956c58b5c..618101bba 100644 --- a/apps/api/src/routes/api/v1/settings/types.ts +++ b/apps/api/src/routes/api/v1/settings/types.ts @@ -41,4 +41,9 @@ export interface DeleteSSHKey { Body: { id: string } +} +export interface OnlyIdInBody { + Body: { + id: string + } } \ No newline at end of file diff --git a/apps/api/src/routes/webhooks/traefik/handlers.ts b/apps/api/src/routes/webhooks/traefik/handlers.ts index d79c2cb01..cd2d80023 100644 --- a/apps/api/src/routes/webhooks/traefik/handlers.ts +++ b/apps/api/src/routes/webhooks/traefik/handlers.ts @@ -178,7 +178,19 @@ function configureMiddleware( export async function traefikConfiguration(request, reply) { try { + const sslpath = '/etc/traefik/acme/custom'; + const certificates = await prisma.certificate.findMany() + let parsedCertificates = [] + for (const certificate of certificates) { + parsedCertificates.push({ + certFile: `${sslpath}/${certificate.id}-cert.pem`, + keyFile: `${sslpath}/${certificate.id}-key.pem` + }) + } const traefik = { + tls: { + certificates: parsedCertificates + }, http: { routers: {}, services: {}, diff --git a/apps/ui/package.json b/apps/ui/package.json index 74e232854..805539a5d 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -42,13 +42,14 @@ }, "type": "module", "dependencies": { - "dayjs": "1.11.5", "@sveltejs/adapter-static": "1.0.0-next.39", "@tailwindcss/typography": "^0.5.7", "cuid": "2.1.8", "daisyui": "2.24.2", + "dayjs": "1.11.5", "js-cookie": "3.0.1", "p-limit": "4.0.0", + "svelte-file-dropzone": "^1.0.0", "svelte-select": "4.4.7", "sveltekit-i18n": "2.2.2" } diff --git a/apps/ui/src/lib/api.ts b/apps/ui/src/lib/api.ts index 1520f863c..ed781b8a7 100644 --- a/apps/ui/src/lib/api.ts +++ b/apps/ui/src/lib/api.ts @@ -39,7 +39,7 @@ export function getWebhookUrl(type: string) { async function send({ method, path, - data = {}, + data = null, headers, timeout = 120000 }: { @@ -53,7 +53,7 @@ async function send({ const controller = new AbortController(); const id = setTimeout(() => controller.abort(), timeout); const opts: any = { method, headers: {}, body: null, signal: controller.signal }; - if (Object.keys(data).length > 0) { + if (data && Object.keys(data).length > 0) { const parsedData = data; for (const [key, value] of Object.entries(data)) { if (value === '') { @@ -85,7 +85,9 @@ async function send({ if (dev && !path.startsWith('https://')) { path = `${getAPIUrl()}${path}`; } - + if (method === 'POST' && data && !opts.body) { + opts.body = data; + } const response = await fetch(`${path}`, opts); clearTimeout(id); @@ -132,7 +134,7 @@ export function del( export function post( path: string, - data: Record, + data: Record | FormData, headers?: Record ): Promise> { return send({ method: 'POST', path, data, headers }); diff --git a/apps/ui/src/lib/components/Toast.svelte b/apps/ui/src/lib/components/Toast.svelte index cdffbec4a..6e8913330 100644 --- a/apps/ui/src/lib/components/Toast.svelte +++ b/apps/ui/src/lib/components/Toast.svelte @@ -4,7 +4,7 @@ export let type = 'info'; function success() { if (type === 'success') { - return 'bg-gradient-to-r from-purple-500 via-pink-500 to-red-500'; + return 'bg-coollabs'; } } diff --git a/apps/ui/src/lib/components/Upload.svelte b/apps/ui/src/lib/components/Upload.svelte new file mode 100644 index 000000000..992d91153 --- /dev/null +++ b/apps/ui/src/lib/components/Upload.svelte @@ -0,0 +1,20 @@ + + +
+ + + + +
+ +
diff --git a/apps/ui/src/routes/__layout.svelte b/apps/ui/src/routes/__layout.svelte index 32fa383f1..761567994 100644 --- a/apps/ui/src/routes/__layout.svelte +++ b/apps/ui/src/routes/__layout.svelte @@ -226,7 +226,7 @@ diff --git a/apps/ui/src/routes/applications/[id]/__layout.svelte b/apps/ui/src/routes/applications/[id]/__layout.svelte index dcfdf7e27..2cca44071 100644 --- a/apps/ui/src/routes/applications/[id]/__layout.svelte +++ b/apps/ui/src/routes/applications/[id]/__layout.svelte @@ -218,7 +218,7 @@ id="git" href="{application.gitSource.htmlUrl}/{application.repository}/tree/{application.branch}" target="_blank" - class="w-6 h-6" + class="w-6 h-6 lg:w-10 lg:h-10" > {#if application.gitSource?.type === 'gitlab'} diff --git a/apps/ui/src/routes/destinations/[id]/configuration/sshkey.svelte b/apps/ui/src/routes/destinations/[id]/configuration/sshkey.svelte index 6a1daae40..800153345 100644 --- a/apps/ui/src/routes/destinations/[id]/configuration/sshkey.svelte +++ b/apps/ui/src/routes/destinations/[id]/configuration/sshkey.svelte @@ -61,7 +61,7 @@
No SSH key found
diff --git a/apps/ui/src/routes/settings/_Menu.svelte b/apps/ui/src/routes/settings/_Menu.svelte index b403f2c44..a4d2ece77 100644 --- a/apps/ui/src/routes/settings/_Menu.svelte +++ b/apps/ui/src/routes/settings/_Menu.svelte @@ -3,19 +3,29 @@ import { appSession } from '$lib/store'; - + SSH Keys + +
  • + SSL Certificates +
  • + diff --git a/apps/ui/src/routes/settings/__layout.svelte b/apps/ui/src/routes/settings/__layout.svelte index 0fc272517..b17c9d2be 100644 --- a/apps/ui/src/routes/settings/__layout.svelte +++ b/apps/ui/src/routes/settings/__layout.svelte @@ -1,7 +1,8 @@ - - +
    + +
    + +
    +
    diff --git a/apps/ui/src/routes/settings/certificates.svelte b/apps/ui/src/routes/settings/certificates.svelte new file mode 100644 index 000000000..036a4ec7b --- /dev/null +++ b/apps/ui/src/routes/settings/certificates.svelte @@ -0,0 +1,144 @@ + + + + +
    SSL Certificates
    +
    + {#if certificates.length === 0} +
    No SSL Certificate found
    + + {:else} +
    + + + + + + + + + + {#each certificates as cert} + + + + + + {/each} + +
    Common NameCreatedAtActions
    {cert.commonName}{cert.createdAt}
    + +
    + {/if} +
    + +{#if isModalActive} + +