mirror of
https://github.com/biersoeckli/QuickStack.git
synced 2026-02-10 21:49:19 -06:00
feat: Enabled SSL certificates for traefik.me subdomains
This commit is contained in:
25
src/__tests__/shared/utils/traefik-me.utils.test.ts
Normal file
25
src/__tests__/shared/utils/traefik-me.utils.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<typeof actio
|
||||
const url = new URL(validatedData.hostname);
|
||||
validatedData.hostname = url.hostname;
|
||||
}
|
||||
|
||||
if (TraefikMeUtils.containesTraefikMeDomain(validatedData.hostname)) {
|
||||
if (!TraefikMeUtils.isValidTraefikMeDomain(validatedData.hostname)) {
|
||||
throw new ServiceException('Invalid traefik.me domain. Subdomain of traefik.me cannot contain dots.');
|
||||
}
|
||||
}
|
||||
|
||||
await appService.saveDomain({
|
||||
...validatedData,
|
||||
id: validatedData.id ?? undefined
|
||||
|
||||
@@ -70,7 +70,7 @@ export default function Logs({
|
||||
<SelectValue placeholder="Pod wählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{appPods.map(pod => <SelectItem key={pod.podName} value={pod.podName}>{pod.podName}</SelectItem>)}
|
||||
{appPods.map(pod => <SelectItem key={pod.podName} value={pod.podName}>{pod.podName} ({pod.status})</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -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({
|
||||
}
|
||||
}}><RotateCcw /> Force Update Registry</Button>
|
||||
|
||||
|
||||
<Button variant="secondary" onClick={async () => {
|
||||
if (await useConfirm.openConfirmDialog({
|
||||
title: 'Update Traefik.me SSL Certificates',
|
||||
description: 'To use SSL with traefik.me domains, wildcard SSL certificates must be provided. Normally, this is done automatically. Use this action to force an update.',
|
||||
okButton: "Update Certificates"
|
||||
})) {
|
||||
Toast.fromAction(() => updateTraefikMeCertificates());
|
||||
}
|
||||
}}><RotateCcw />Update Traefik.me SSL Certificates</Button>
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
34
src/server/adapter/traefik-me.adapter.ts
Normal file
34
src/server/adapter/traefik-me.adapter.ts
Normal file
@@ -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;
|
||||
@@ -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,
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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}`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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<typeof podsInfoZodModel>;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
10
src/shared/utils/traefik-me.utils.ts
Normal file
10
src/shared/utils/traefik-me.utils.ts
Normal file
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user