diff --git a/src/__tests__/shared/utils/traefik-me.utils.test.ts b/src/__tests__/shared/utils/traefik-me.utils.test.ts new file mode 100644 index 0000000..194851a --- /dev/null +++ b/src/__tests__/shared/utils/traefik-me.utils.test.ts @@ -0,0 +1,25 @@ +import { TraefikMeUtils } from '../../../shared/utils/traefik-me.utils'; + +describe('TraefikMeUtils', () => { + describe('isValidTraefikMeDomain', () => { + it('should return true for valid traefik.me domain', () => { + expect(TraefikMeUtils.isValidTraefikMeDomain('example.traefik.me')).toBe(true); + }); + + it('should return false for domain not ending with .traefik.me', () => { + expect(TraefikMeUtils.isValidTraefikMeDomain('example.com')).toBe(false); + }); + + it('should return false for domain with more than three parts', () => { + expect(TraefikMeUtils.isValidTraefikMeDomain('sub.example.traefik.me')).toBe(false); + }); + + it('should return false for domain with less than three parts', () => { + expect(TraefikMeUtils.isValidTraefikMeDomain('traefik.me')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(TraefikMeUtils.isValidTraefikMeDomain('')).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/src/app/project/app/[appId]/domains/actions.ts b/src/app/project/app/[appId]/domains/actions.ts index 96d4be9..1c938b2 100644 --- a/src/app/project/app/[appId]/domains/actions.ts +++ b/src/app/project/app/[appId]/domains/actions.ts @@ -6,6 +6,8 @@ import { SuccessActionResult } from "@/shared/model/server-action-error-return.m import appService from "@/server/services/app.service"; import { getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils"; import { z } from "zod"; +import { TraefikMeUtils } from "@/shared/utils/traefik-me.utils"; +import { ServiceException } from "@/shared/model/service.exception.model"; const actionAppDomainEditZodModel = appDomainEditZodModel.merge(z.object({ appId: z.string(), @@ -20,6 +22,13 @@ export const saveDomain = async (prevState: any, inputData: z.infer - {appPods.map(pod => {pod.podName})} + {appPods.map(pod => {pod.podName} ({pod.status}))} diff --git a/src/app/settings/maintenance/qs-maintenance-settings.tsx b/src/app/settings/maintenance/qs-maintenance-settings.tsx index f0d09a6..40147b6 100644 --- a/src/app/settings/maintenance/qs-maintenance-settings.tsx +++ b/src/app/settings/maintenance/qs-maintenance-settings.tsx @@ -1,7 +1,7 @@ 'use client'; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { cleanupOldBuildJobs, cleanupOldTmpFiles, purgeRegistryImages, updateRegistry } from "../server/actions"; +import { cleanupOldBuildJobs, cleanupOldTmpFiles, purgeRegistryImages, updateRegistry, updateTraefikMeCertificates } from "../server/actions"; import { Button } from "@/components/ui/button"; import { Toast } from "@/frontend/utils/toast.utils"; import { useConfirmDialog } from "@/frontend/states/zustand.states"; @@ -76,6 +76,17 @@ export default function QuickStackMaintenanceSettings({ } }}> Force Update Registry + + + ; diff --git a/src/app/settings/server/actions.ts b/src/app/settings/server/actions.ts index 1dad159..ecaf486 100644 --- a/src/app/settings/server/actions.ts +++ b/src/app/settings/server/actions.ts @@ -15,6 +15,7 @@ import { KubeSizeConverter } from "@/shared/utils/kubernetes-size-converter.util import buildService from "@/server/services/build.service"; import { PathUtils } from "@/server/utils/path.utils"; import { FsUtils } from "@/server/utils/fs.utils"; +import traefikMeDomainService from "@/server/services/standalone-services/traefik-me-domain.service"; export const updateIngressSettings = async (prevState: any, inputData: QsIngressSettingsModel) => saveFormAction(inputData, qsIngressSettingsZodModel, async (validatedData) => { @@ -111,6 +112,13 @@ export const updateRegistry = async () => return new SuccessActionResult(undefined, 'Registry will be updated, this might take a few seconds.'); }); +export const updateTraefikMeCertificates = async () => + simpleAction(async () => { + await getAuthUserSession(); + await traefikMeDomainService.updateTraefikMeCertificate(); + return new SuccessActionResult(undefined, 'Certificates will be updated, this might take a few seconds.'); + }); + export const purgeRegistryImages = async () => simpleAction(async () => { await getAuthUserSession(); diff --git a/src/server.ts b/src/server.ts index 7f91ea7..41deebb 100644 --- a/src/server.ts +++ b/src/server.ts @@ -8,6 +8,7 @@ import dataAccess from './server/adapter/db.client' import { FancyConsoleUtils } from './shared/utils/fancy-console.utils' import { Constants } from './shared/utils/constants' import backupService from './server/services/standalone-services/backup.service' +import traefikMeDomainService from './server/services/standalone-services/traefik-me-domain.service' // Source: https://nextjs.org/docs/app/building-your-application/configuring/custom-server @@ -52,6 +53,7 @@ async function initializeNextJs() { } await backupService.registerAllBackups(); + traefikMeDomainService.configureSchedulingForTraefikMeCertificateUpdate(); const app = next({ dev }); const handle = app.getRequestHandler(); diff --git a/src/server/adapter/traefik-me.adapter.ts b/src/server/adapter/traefik-me.adapter.ts new file mode 100644 index 0000000..d6391c0 --- /dev/null +++ b/src/server/adapter/traefik-me.adapter.ts @@ -0,0 +1,34 @@ + + +class TraefikMeAdapter { + private traefikMeBaseURL = 'https://traefik.me'; + + async getCurrentPrivateKey() { + const result = await fetch(`${this.traefikMeBaseURL}/privkey.pem`, { + method: 'GET', + cache: 'no-store', + }); + + if (!result.ok) { + throw new Error('Failed to get private key from traefik.me'); + } + const privateKeyText = result.text(); + return privateKeyText; + } + + async getFullChainCertificate() { + const result = await fetch(`${this.traefikMeBaseURL}/fullchain.pem`, { + method: 'GET', + cache: 'no-store', + }); + + if (!result.ok) { + throw new Error('Failed to get full chain from traefik.me'); + } + const fullChainText = result.text(); + return fullChainText; + } +} + +const traefikMeAdapter = new TraefikMeAdapter(); +export default traefikMeAdapter; \ No newline at end of file diff --git a/src/server/services/file-browser-service.ts b/src/server/services/file-browser-service.ts index 5f81dd4..44e96d8 100644 --- a/src/server/services/file-browser-service.ts +++ b/src/server/services/file-browser-service.ts @@ -1,6 +1,6 @@ import { V1Deployment, V1Ingress } from "@kubernetes/client-node"; import dataAccess from "../adapter/db.client"; -import traefikMeDomainService from "./traefik-me-domain.service"; +import traefikMeDomainService from "./standalone-services/traefik-me-domain.service"; import { Constants } from "@/shared/utils/constants"; import { KubeObjectNameUtils } from "../utils/kube-object-name.utils"; import deploymentService from "./deployment.service"; @@ -32,7 +32,7 @@ class FileBrowserService { await deploymentService.setReplicasToZeroAndWaitForShutdown(projectId, appId); console.log(`Deploying filebrowser for volume ${volumeId}`); - const traefikHostname = await traefikMeDomainService.getDomainForApp(volume.appId, volume.id); + const traefikHostname = await traefikMeDomainService.getDomainForApp(volume.id); const pvcName = KubeObjectNameUtils.toPvcName(volume.id); @@ -41,7 +41,6 @@ class FileBrowserService { const randomPassword = randomBytes(15).toString('hex'); await this.createOrUpdateFilebrowserDeployment(kubeAppName, appId, projectId, pvcName, randomPassword); - console.log(`Creating service for filebrowser for volume ${volumeId}`); await svcService.createOrUpdateService(projectId, kubeAppName, [{ name: 'http', @@ -101,8 +100,6 @@ class FileBrowserService { annotations: { [Constants.QS_ANNOTATION_APP_ID]: appId, [Constants.QS_ANNOTATION_PROJECT_ID]: projectId, - ...(true && { 'cert-manager.io/cluster-issuer': 'letsencrypt-production' }), - // 'traefik.ingress.kubernetes.io/router.middlewares': middlewareName, }, }, spec: { @@ -130,7 +127,7 @@ class FileBrowserService { ], tls: [{ hosts: [traefikHostname], - secretName: `secret-tls-${kubeAppName}`, + secretName: Constants.TRAEFIK_ME_SECRET_NAME, }], }, }; diff --git a/src/server/services/ingress.service.ts b/src/server/services/ingress.service.ts index 9f8079b..3d98c98 100644 --- a/src/server/services/ingress.service.ts +++ b/src/server/services/ingress.service.ts @@ -7,6 +7,7 @@ import { Constants } from "../../shared/utils/constants"; import ingressSetupService from "./setup-services/ingress-setup.service"; import { dlog } from "./deployment-logs.service"; import { createHash } from "crypto"; +import { TraefikMeUtils } from "@/shared/utils/traefik-me.utils"; class IngressService { @@ -67,6 +68,7 @@ class IngressService { const hostname = domain.hostname; const ingressName = KubeObjectNameUtils.getIngressName(domain.id); const existingIngress = await this.getIngressByName(app.projectId, domain.id); + const isATraefikMeDomain = TraefikMeUtils.isValidTraefikMeDomain(hostname); const middlewares = [ basicAuthMiddlewareName, @@ -82,7 +84,7 @@ class IngressService { annotations: { [Constants.QS_ANNOTATION_APP_ID]: app.id, [Constants.QS_ANNOTATION_PROJECT_ID]: app.projectId, - ...(domain.useSsl === true && { 'cert-manager.io/cluster-issuer': 'letsencrypt-production' }), + ...(!isATraefikMeDomain && domain.useSsl === true && { 'cert-manager.io/cluster-issuer': 'letsencrypt-production' }), ...(middlewares && { 'traefik.ingress.kubernetes.io/router.middlewares': middlewares }), ...(domain.useSsl === false && { 'traefik.ingress.kubernetes.io/router.entrypoints': 'web' }), // disable requests from https --> only http }, @@ -114,7 +116,7 @@ class IngressService { tls: [ { hosts: [hostname], - secretName: `secret-tls-${domain.id}`, + secretName: isATraefikMeDomain ? Constants.TRAEFIK_ME_SECRET_NAME : `secret-tls-${domain.id}`, }, ], }), diff --git a/src/server/services/project.service.ts b/src/server/services/project.service.ts index 62d60d2..4dd7ad8 100644 --- a/src/server/services/project.service.ts +++ b/src/server/services/project.service.ts @@ -6,6 +6,7 @@ import { KubeObjectNameUtils } from "../utils/kube-object-name.utils"; import deploymentService from "./deployment.service"; import namespaceService from "./namespace.service"; import buildService from "./build.service"; +import traefikMeDomainService from "./standalone-services/traefik-me-domain.service"; class ProjectService { @@ -28,7 +29,7 @@ class ProjectService { } async getAllProjects() { - return await unstable_cache(() => dataAccess.client.project.findMany({ + return await unstable_cache(() => dataAccess.client.project.findMany({ include: { apps: true }, @@ -69,6 +70,7 @@ class ProjectService { } finally { revalidateTag(Tags.projects()); } + await traefikMeDomainService.updateTraefikMeCertificate(); return savedItem; } } diff --git a/src/server/services/secret.service.ts b/src/server/services/secret.service.ts index 53dcc7c..81b3c01 100644 --- a/src/server/services/secret.service.ts +++ b/src/server/services/secret.service.ts @@ -44,20 +44,13 @@ class SecretService { type: 'kubernetes.io/dockerconfigjson', }; - const existingSecret = await this.getExistingSecret(namespace, app.id); - if (existingSecret) { - console.log(`Updating existing Docker registry secret ${secretName}...`); - await k3s.core.replaceNamespacedSecret(secretName, namespace, secretManifest); - } else { - console.log(`Creating new Docker registry secret ${secretName}...`); - await k3s.core.createNamespacedSecret(namespace, secretManifest); - } + await this.saveSecret(namespace, secretName, secretManifest); return secretName; } async delteUnusedSecrets(app: AppExtendedModel) { if (this.appNeedsNoSecret(app)) { - const existingSecret = await this.getExistingSecret(app.projectId, app.id); + const existingSecret = await this.getExistingSecret(app.projectId, KubeObjectNameUtils.toSecretId(app.id)); if (existingSecret) { console.log(`Deleting secret ${existingSecret.metadata?.name}...`); await k3s.core.deleteNamespacedSecret(existingSecret.metadata?.name!, app.projectId); @@ -69,9 +62,20 @@ class SecretService { return app.sourceType === 'GIT' || !app.containerImageSource || !app.containerRegistryUsername || !app.containerRegistryPassword; } - async getExistingSecret(namespace: string, appId: string) { + async saveSecret(namespace: string, secretName: string, secretManifest: V1Secret) { + const existingSecret = await this.getExistingSecret(namespace, secretName); + if (existingSecret) { + console.log(`Updating existing Docker registry secret ${secretName}...`); + await k3s.core.replaceNamespacedSecret(secretName, namespace, secretManifest); + } else { + console.log(`Creating new Docker registry secret ${secretName}...`); + await k3s.core.createNamespacedSecret(namespace, secretManifest); + } + } + + async getExistingSecret(namespace: string, secretName: string) { const existingSecrets = await k3s.core.listNamespacedSecret(namespace); - const existingSecret = existingSecrets.body.items.find(s => s.metadata?.name === KubeObjectNameUtils.toSecretId(appId)); + const existingSecret = existingSecrets.body.items.find(s => s.metadata?.name === secretName); return existingSecret; } } diff --git a/src/server/services/standalone-services/backup.service.ts b/src/server/services/standalone-services/backup.service.ts index 1758f61..95602d4 100644 --- a/src/server/services/standalone-services/backup.service.ts +++ b/src/server/services/standalone-services/backup.service.ts @@ -26,7 +26,7 @@ class BackupService { const groupedByCron = ListUtils.groupBy(allVolumeBackups, vb => vb.cron); for (const [cron, volumeBackups] of Array.from(groupedByCron.entries())) { - scheduleService.scheduleJob(cron, cron, async () => { + scheduleService.scheduleJob(`backup-${cron}`, cron, async () => { console.log(`Running backup for ${volumeBackups.length} volumes...`); for (const volumeBackup of volumeBackups) { try { @@ -43,7 +43,8 @@ class BackupService { async unregisterAllBackups() { const allJobs = scheduleService.getAlJobs(); - for (const jobName of allJobs) { + const backupJobs = allJobs.filter(j => j.startsWith('backup-')); + for (const jobName of backupJobs) { scheduleService.cancelJob(jobName); } } diff --git a/src/server/services/standalone-services/standalone-pod.service.ts b/src/server/services/standalone-services/standalone-pod.service.ts index 1118e40..f4cd037 100644 --- a/src/server/services/standalone-services/standalone-pod.service.ts +++ b/src/server/services/standalone-services/standalone-pod.service.ts @@ -52,12 +52,14 @@ class SetupPodService { podName: string; containerName: string; uid?: string; + status?: string; }[]> { const res = await k3s.core.listNamespacedPod(projectId, undefined, undefined, undefined, undefined, `app=${appId}`); return res.body.items.map((item) => ({ podName: item.metadata?.name!, containerName: item.spec?.containers?.[0].name!, uid: item.metadata?.uid, + status: item.status?.phase, })).filter((item) => !!item.podName && !!item.containerName); } diff --git a/src/server/services/standalone-services/traefik-me-domain.service.ts b/src/server/services/standalone-services/traefik-me-domain.service.ts new file mode 100644 index 0000000..f83cf8c --- /dev/null +++ b/src/server/services/standalone-services/traefik-me-domain.service.ts @@ -0,0 +1,54 @@ +import { ServiceException } from "../../../shared/model/service.exception.model"; +import paramService, { ParamService } from "../param.service"; +import traefikMeAdapter from "../../adapter/traefik-me.adapter"; +import { V1Secret } from "@kubernetes/client-node"; +import secretService from "../secret.service"; +import { Constants } from "../../../shared/utils/constants"; +import dataAccess from "../../adapter/db.client"; +import scheduleService from "./schedule.service"; + +class TraefikMeDomainService { + + async getDomainForApp(appId: string, prefix?: string) { + const publicIpv4 = await paramService.getString(ParamService.PUBLIC_IPV4_ADDRESS); + if (!publicIpv4) { + throw new ServiceException('Please set the main public IPv4 address in the QuickStack settings first.'); + } + const traefikFriendlyIpv4 = publicIpv4.split('.').join('-'); + if (prefix) { + return `${prefix}-${appId}-${traefikFriendlyIpv4}.traefik.me`; + } + return `${appId}-${traefikFriendlyIpv4}.traefik.me`; + } + + async updateTraefikMeCertificate() { + const fullChainCert = await traefikMeAdapter.getFullChainCertificate(); + const privateKey = await traefikMeAdapter.getCurrentPrivateKey(); + + const projects = await dataAccess.client.project.findMany(); + const secretName = Constants.TRAEFIK_ME_SECRET_NAME; + + for (const project of projects) { + const secretManifest: V1Secret = { + metadata: { + name: secretName, + }, + data: { + 'tls.crt': Buffer.from(fullChainCert).toString('base64'), + 'tls.key': Buffer.from(privateKey).toString('base64'), + }, + type: 'kubernetes.io/tls', + }; + await secretService.saveSecret(project.id, secretName, secretManifest); + } + } + + configureSchedulingForTraefikMeCertificateUpdate() { + scheduleService.scheduleJob('traefik-me-certificate-update', '0 1 * * *', async () => { + await this.updateTraefikMeCertificate(); + }); + } +} + +const traefikMeDomainService = new TraefikMeDomainService(); +export default traefikMeDomainService; \ No newline at end of file diff --git a/src/server/services/traefik-me-domain.service.ts b/src/server/services/traefik-me-domain.service.ts deleted file mode 100644 index 4f2abf7..0000000 --- a/src/server/services/traefik-me-domain.service.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ServiceException } from "@/shared/model/service.exception.model"; -import paramService, { ParamService } from "./param.service"; - -class TraefikMeDomainService { - - async getDomainForApp(appId: string, prefix?: string) { - const publicIpv4 = await paramService.getString(ParamService.PUBLIC_IPV4_ADDRESS); - if (!publicIpv4) { - throw new ServiceException('Please set the main public IPv4 address in the QuickStack settings first.'); - } - if (prefix) { - return `${prefix}.${appId}.${publicIpv4}.traefik.me`; - } - return `${appId}.${publicIpv4}.traefik.me`; - } -} - -const traefikMeDomainService = new TraefikMeDomainService(); -export default traefikMeDomainService; \ No newline at end of file diff --git a/src/shared/model/pods-info.model.ts b/src/shared/model/pods-info.model.ts index 97dd1d0..7c959bf 100644 --- a/src/shared/model/pods-info.model.ts +++ b/src/shared/model/pods-info.model.ts @@ -4,6 +4,7 @@ export const podsInfoZodModel = z.object({ podName: z.string(), containerName: z.string(), uid: z.string().optional(), + status: z.string().optional(), }); export type PodsInfoModel = z.infer; diff --git a/src/shared/utils/constants.ts b/src/shared/utils/constants.ts index 9681c35..06bf017 100644 --- a/src/shared/utils/constants.ts +++ b/src/shared/utils/constants.ts @@ -7,4 +7,5 @@ export class Constants { static readonly QS_NAMESPACE = 'quickstack'; static readonly QS_APP_NAME = 'quickstack'; static readonly INTERNAL_REGISTRY_LOCATION = 'internal-registry-location'; + static readonly TRAEFIK_ME_SECRET_NAME = 'traefik-me-tls'; } \ No newline at end of file diff --git a/src/shared/utils/traefik-me.utils.ts b/src/shared/utils/traefik-me.utils.ts new file mode 100644 index 0000000..6898061 --- /dev/null +++ b/src/shared/utils/traefik-me.utils.ts @@ -0,0 +1,10 @@ +export class TraefikMeUtils { + + static isValidTraefikMeDomain(domain: string): boolean { + return this.containesTraefikMeDomain(domain) && domain.split('.').length === 3; + } + + static containesTraefikMeDomain(domain: string): boolean { + return domain.includes('.traefik.me'); + } +} \ No newline at end of file