refactor: replace TraefikMeUtils with HostnameDnsProviderUtils and remove related code. Switched to sslip.io as dns-hostname service

This commit is contained in:
biersoeckli
2025-11-08 16:09:12 +00:00
parent 721bfb5f85
commit b52950d3d7
16 changed files with 145 additions and 173 deletions

View File

@@ -0,0 +1,79 @@
import { HostnameDnsProviderUtils } from '../../../shared/utils/domain-dns-provider.utils';
describe('DomainDnsProviderUtils', () => {
describe('isValidTraefikMeDomain', () => {
it('should return true for valid sslip.io domain with subdomain', () => {
expect(HostnameDnsProviderUtils.isValidDnsProviderHostname('sub.example.sslip.io')).toBe(true);
});
it('should return true for IP-based domain', () => {
expect(HostnameDnsProviderUtils.isValidDnsProviderHostname('192.168.1.1.sslip.io')).toBe(true);
});
it('should return false for simple domain ending with .sslip.io', () => {
expect(HostnameDnsProviderUtils.isValidDnsProviderHostname('example.sslip.io')).toBe(false);
});
it('should return false for domain not ending with .sslip.io', () => {
expect(HostnameDnsProviderUtils.isValidDnsProviderHostname('example.com')).toBe(false);
});
it('should return false for domain with only provider domain', () => {
expect(HostnameDnsProviderUtils.isValidDnsProviderHostname('sslip.io')).toBe(false);
});
it('should return false for empty string', () => {
expect(HostnameDnsProviderUtils.isValidDnsProviderHostname('')).toBe(false);
});
});
describe('containesTraefikMeDomain', () => {
it('should return true for domain containing .sslip.io', () => {
expect(HostnameDnsProviderUtils.containsDnsProviderHostname('example.sslip.io')).toBe(true);
});
it('should return true for subdomain containing .sslip.io', () => {
expect(HostnameDnsProviderUtils.containsDnsProviderHostname('sub.example.sslip.io')).toBe(true);
});
it('should return false for domain not containing .sslip.io', () => {
expect(HostnameDnsProviderUtils.containsDnsProviderHostname('example.com')).toBe(false);
});
it('should return false for empty string', () => {
expect(HostnameDnsProviderUtils.containsDnsProviderHostname('')).toBe(false);
});
});
describe('getHostnameForIpAdress', () => {
it('should convert IP address to hostname with dashes', () => {
expect(HostnameDnsProviderUtils.getHostnameForIpAdress('192.168.1.1')).toBe('192-168-1-1.sslip.io');
});
it('should handle another IP address format', () => {
expect(HostnameDnsProviderUtils.getHostnameForIpAdress('10.0.0.1')).toBe('10-0-0-1.sslip.io');
});
it('should handle localhost IP', () => {
expect(HostnameDnsProviderUtils.getHostnameForIpAdress('127.0.0.1')).toBe('127-0-0-1.sslip.io');
});
});
describe('ipv4ToHex', () => {
it('should convert IPv4 address to hex', () => {
expect(HostnameDnsProviderUtils.ipv4ToHex('192.168.1.1')).toBe('c0a80101');
});
it('should convert another IPv4 address to hex', () => {
expect(HostnameDnsProviderUtils.ipv4ToHex('10.0.0.1')).toBe('0a000001');
});
it('should handle leading zeros correctly', () => {
expect(HostnameDnsProviderUtils.ipv4ToHex('1.2.3.4')).toBe('01020304');
});
it('should handle max values correctly', () => {
expect(HostnameDnsProviderUtils.ipv4ToHex('255.255.255.255')).toBe('ffffffff');
});
});
});

View File

@@ -1,25 +0,0 @@
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);
});
});
});

View File

@@ -8,7 +8,6 @@ import quickStackService from "@/server/services/qs.service";
import userService from "@/server/services/user.service";
import { saveFormAction } from "@/server/utils/action-wrapper.utils";
import ipAddressFinderAdapter from "@/server/adapter/ip-adress-finder.adapter";
import traefikMeDomainStandaloneService from "@/server/services/standalone-services/traefik-me-domain-standalone.service";
import userGroupService from "@/server/services/user-group.service";
@@ -28,12 +27,6 @@ export const registerUser = async (prevState: any, inputData: RegisterFormInputS
// ignore
console.error('Failes to evaluate public ip address', e);
}
try {
await traefikMeDomainStandaloneService.updateTraefikMeCertificate();
} catch (e) {
// ignore
console.error('Failed to update traefik me certificate', e);
}
if (validatedData.qsHostname) {
const url = new URL(validatedData.qsHostname.includes('://') ? validatedData.qsHostname : `https://${validatedData.qsHostname}`);
await paramService.save({

View File

@@ -6,7 +6,7 @@ import { SuccessActionResult } from "@/shared/model/server-action-error-return.m
import appService from "@/server/services/app.service";
import { getAuthUserSession, isAuthorizedWriteForApp, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
import { z } from "zod";
import { TraefikMeUtils } from "@/shared/utils/traefik-me.utils";
import { HostnameDnsProviderUtils } from "@/shared/utils/domain-dns-provider.utils";
import { ServiceException } from "@/shared/model/service.exception.model";
const actionAppDomainEditZodModel = appDomainEditZodModel.merge(z.object({
@@ -23,9 +23,9 @@ export const saveDomain = async (prevState: any, inputData: z.infer<typeof actio
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.');
if (HostnameDnsProviderUtils.containsDnsProviderHostname(validatedData.hostname)) {
if (!HostnameDnsProviderUtils.isValidDnsProviderHostname(validatedData.hostname)) {
throw new ServiceException(`Invalid ${HostnameDnsProviderUtils.PROVIDER_HOSTNAME} domain. Subdomain of ${HostnameDnsProviderUtils.PROVIDER_HOSTNAME} cannot contain dots.`);
}
}

View File

@@ -1,7 +1,7 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cleanupOldBuildJobs, cleanupOldTmpFiles, deleteAllFailedAndSuccededPods, deleteOldAppLogs, purgeRegistryImages, updateRegistry, updateTraefikMeCertificates } from "../server/actions";
import { cleanupOldBuildJobs, cleanupOldTmpFiles, deleteAllFailedAndSuccededPods, deleteOldAppLogs, purgeRegistryImages, updateRegistry } from "../server/actions";
import { Button } from "@/components/ui/button";
import { Toast } from "@/frontend/utils/toast.utils";
import { useConfirmDialog } from "@/frontend/states/zustand.states";
@@ -95,18 +95,6 @@ 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>;

View File

@@ -13,7 +13,6 @@ import { QsPublicIpv4SettingsModel, qsPublicIpv4SettingsZodModel } from "@/share
import ipAddressFinderAdapter from "@/server/adapter/ip-adress-finder.adapter";
import { KubeSizeConverter } from "@/shared/utils/kubernetes-size-converter.utils";
import buildService from "@/server/services/build.service";
import traefikMeDomainStandaloneService from "@/server/services/standalone-services/traefik-me-domain-standalone.service";
import standalonePodService from "@/server/services/standalone-services/standalone-pod.service";
import maintenanceService from "@/server/services/standalone-services/maintenance.service";
import appLogsService from "@/server/services/standalone-services/app-logs.service";
@@ -111,13 +110,6 @@ 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 getAdminUserSession();
await traefikMeDomainStandaloneService.updateTraefikMeCertificate();
return new SuccessActionResult(undefined, 'Certificates will be updated, this might take a few seconds.');
});
export const deleteAllFailedAndSuccededPods = async () =>
simpleAction(async () => {
await getAdminUserSession();

View File

@@ -8,7 +8,6 @@ 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 traefikMeDomainStandaloneService from './server/services/standalone-services/traefik-me-domain-standalone.service'
import maintenanceService from './server/services/standalone-services/maintenance.service'
import passwordChangeService from './server/services/standalone-services/password-change.service'
import appLogsService from './server/services/standalone-services/app-logs.service'
@@ -56,7 +55,6 @@ async function initializeNextJs() {
}
await backupService.registerAllBackups();
traefikMeDomainStandaloneService.configureSchedulingForTraefikMeCertificateUpdate();
maintenanceService.configureMaintenanceCronJobs();
appLogsService.configureCronJobs();

View File

@@ -1,34 +0,0 @@
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;

View File

@@ -1,6 +1,6 @@
import { ServiceException } from "@/shared/model/service.exception.model";
import dataAccess from "../../adapter/db.client";
import traefikMeDomainService from "../traefik-me-domain.service";
import hostnameDnsProviderService from "../traefik-me-domain.service";
import { KubeObjectNameUtils } from "../../utils/kube-object-name.utils";
import deploymentService from "../deployment.service";
import { V1Deployment, V1Ingress } from "@kubernetes/client-node";
@@ -60,7 +60,7 @@ export class BaseDbToolService {
}
const { username, password } = searchFunc(existingDeployment, app);
const traefikHostname = await traefikMeDomainService.getDomainForApp(toolAppName);
const traefikHostname = await hostnameDnsProviderService.getDomainForApp(toolAppName);
return { url: `https://${traefikHostname}`, username, password };
}
@@ -75,7 +75,7 @@ export class BaseDbToolService {
const namespace = app.projectId;
console.log(`Deploying DB Tool ${toolAppName} for app ${appId}`);
const traefikHostname = await traefikMeDomainService.getDomainForApp(toolAppName);
const hostnameDnsProviderHostname = await hostnameDnsProviderService.getDomainForApp(toolAppName);
console.log(`Creating DB Tool ${toolAppName} deployment for app ${appId}`);
await this.createOrUpdateDbGateDeployment(app, deplyomentBuilder);
@@ -88,7 +88,7 @@ export class BaseDbToolService {
}]);
console.log(`Creating ingress for DB Tool ${toolAppName} for app ${appId}`);
await this.createOrUpdateIngress(toolAppName, namespace, traefikHostname);
await this.createOrUpdateIngress(toolAppName, namespace, hostnameDnsProviderHostname);
const fileBrowserPods = await podService.getPodsForApp(namespace, toolAppName);
for (const pod of fileBrowserPods) {
@@ -129,24 +129,25 @@ export class BaseDbToolService {
}
}
private async createOrUpdateIngress(dbGateAppName: string, namespace: string, traefikHostname: string) {
private async createOrUpdateIngress(dbGateAppName: string, namespace: string, hostname: string) {
const ingressDefinition: V1Ingress = {
apiVersion: 'networking.k8s.io/v1',
kind: 'Ingress',
metadata: {
name: KubeObjectNameUtils.getIngressName(dbGateAppName),
namespace: namespace,
// dont annotate, because ingress will be deleted after redeployment of app
/* annotations: {
[Constants.QS_ANNOTATION_APP_ID]: appId,
[Constants.QS_ANNOTATION_PROJECT_ID]: projectId,
},*/
annotations: {
// dont annotate, because ingress will be deleted after redeployment of app
// [Constants.QS_ANNOTATION_APP_ID]: appId,
// [Constants.QS_ANNOTATION_PROJECT_ID]: projectId,
...({ 'cert-manager.io/cluster-issuer': 'letsencrypt-production' }), // --> dont start cert-manager for traefik.me domains
}
},
spec: {
ingressClassName: 'traefik',
rules: [
{
host: traefikHostname,
host: hostname,
http: {
paths: [
{
@@ -166,8 +167,8 @@ export class BaseDbToolService {
},
],
tls: [{
hosts: [traefikHostname],
secretName: Constants.TRAEFIK_ME_SECRET_NAME,
hosts: [hostname],
secretName: `secret-tls-${hostname}`.substring(0, 63)
}],
},
};

View File

@@ -1,6 +1,5 @@
import { V1Deployment, V1Ingress } from "@kubernetes/client-node";
import dataAccess from "../adapter/db.client";
import traefikMeDomainStandaloneService from "./standalone-services/traefik-me-domain-standalone.service";
import { Constants } from "@/shared/utils/constants";
import { KubeObjectNameUtils } from "../utils/kube-object-name.utils";
import deploymentService from "./deployment.service";
@@ -10,7 +9,7 @@ import svcService from "./svc.service";
import { randomBytes } from "crypto";
import podService from "./pod.service";
import bcrypt from "bcrypt";
import traefikMeDomainService from "./traefik-me-domain.service";
import hostnameDnsProviderService from "./traefik-me-domain.service";
import pvcService from "./pvc.service";
class FileBrowserService {
@@ -37,7 +36,7 @@ class FileBrowserService {
await pvcService.createPvcForVolumeIfNotExists(volume.app.projectId, volume);
console.log(`Deploying filebrowser for volume ${volumeId}`);
const traefikHostname = await traefikMeDomainService.getDomainForApp(volume.id);
const traefikHostname = await hostnameDnsProviderService.getDomainForApp(volume.id);
const pvcName = KubeObjectNameUtils.toPvcName(volume.id);

View File

@@ -7,7 +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";
import { HostnameDnsProviderUtils } from "@/shared/utils/domain-dns-provider.utils";
class IngressService {
@@ -68,7 +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 isATraefikMeDomain = HostnameDnsProviderUtils.isValidDnsProviderHostname(hostname);
const middlewares = [
basicAuthMiddlewareName,
@@ -84,7 +84,7 @@ class IngressService {
annotations: {
[Constants.QS_ANNOTATION_APP_ID]: app.id,
[Constants.QS_ANNOTATION_PROJECT_ID]: app.projectId,
...(!isATraefikMeDomain && domain.useSsl === true && { 'cert-manager.io/cluster-issuer': 'letsencrypt-production' }),
...(!isATraefikMeDomain && domain.useSsl === true && { 'cert-manager.io/cluster-issuer': 'letsencrypt-production' }), // for traefik.me domains no cert-manager --> uses default traefik self signed certificate
...(middlewares && { 'traefik.ingress.kubernetes.io/router.middlewares': middlewares }),
...(domain.useSsl === false && { 'traefik.ingress.kubernetes.io/router.entrypoints': 'web' }), // disable requests from https --> only http
},
@@ -112,11 +112,11 @@ class IngressService {
},
},
],
...(domain.useSsl === true && {
...(domain.useSsl === true && !isATraefikMeDomain && {
tls: [
{
hosts: [hostname],
secretName: isATraefikMeDomain ? Constants.TRAEFIK_ME_SECRET_NAME : `secret-tls-${domain.id}`,
secretName: `secret-tls-${domain.id}`,
},
],
}),

View File

@@ -6,7 +6,6 @@ import { KubeObjectNameUtils } from "../utils/kube-object-name.utils";
import deploymentService from "./deployment.service";
import namespaceService from "./namespace.service";
import buildService from "./build.service";
import traefikMeDomainStandaloneService from "./standalone-services/traefik-me-domain-standalone.service";
import { ProjectExtendedModel } from "@/shared/model/project-extended.model";
class ProjectService {
@@ -73,7 +72,6 @@ class ProjectService {
} finally {
revalidateTag(Tags.projects());
}
await traefikMeDomainStandaloneService.updateTraefikMeCertificate();
return savedItem;
}
}

View File

@@ -1,40 +0,0 @@
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 TraefikMeDomainStandaloneService {
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 traefikMeDomainStandaloneService = new TraefikMeDomainStandaloneService();
export default traefikMeDomainStandaloneService;

View File

@@ -1,21 +1,23 @@
import { ServiceException } from "@/shared/model/service.exception.model";
import paramService, { ParamService } from "./param.service";
import { HostnameDnsProviderUtils } from "@/shared/utils/domain-dns-provider.utils";
class TraefikMeDomainService {
/**
* Service for Domaing DNS providers like traefik.me or sslip.io.
*/
class HostnameDnsProviderService {
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 `${prefix}-${appId}-${HostnameDnsProviderUtils.getHostnameForIpAdress(publicIpv4)}`;
}
return `${appId}-${traefikFriendlyIpv4}.traefik.me`;
return `${appId}-${HostnameDnsProviderUtils.getHostnameForIpAdress(publicIpv4)}`;
}
}
const traefikMeDomainService = new TraefikMeDomainService();
export default traefikMeDomainService;
const hostnameDnsProviderService = new HostnameDnsProviderService();
export default hostnameDnsProviderService;

View File

@@ -0,0 +1,31 @@
/**
* Utils for a provider wich supports domains like xip.io or traefik.me.
* In first versions of QuickStack traefik.me was used. Due to availability issues with traefik.me,
* it was replaced with https://sslip.io.
*/
export class HostnameDnsProviderUtils {
public static readonly PROVIDER_HOSTNAME = 'sslip.io';
private static readonly PROVIDER_HOSTNAME_SUFFIX = `.${this.PROVIDER_HOSTNAME}`;
static getHostnameForIpAdress(ipv4Address: string): string {
const traefikFriendlyIpv4 = ipv4Address.split('.').join('-');
return `${traefikFriendlyIpv4}.${this.PROVIDER_HOSTNAME}`;
}
static isValidDnsProviderHostname(domain: string): boolean {
return this.containsDnsProviderHostname(domain) && domain.replace(this.PROVIDER_HOSTNAME_SUFFIX, '').includes('.');
}
static containsDnsProviderHostname(domain: string): boolean {
return domain.includes(this.PROVIDER_HOSTNAME_SUFFIX);
}
static ipv4ToHex(ip: string): string {
return ip.split('.')
.map(octet => {
const hex = parseInt(octet, 10).toString(16);
return hex.padStart(2, '0');
}).join('');
}
}

View File

@@ -1,10 +0,0 @@
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');
}
}