mirror of
https://github.com/biersoeckli/QuickStack.git
synced 2026-05-18 15:58:28 -05:00
Merge pull request #48 from biersoeckli/feat/replace-traefikme-dns-service
Feat/replace traefikme dns service
This commit is contained in:
+2
-1
@@ -99,5 +99,6 @@
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.6.3",
|
||||
"zod-prisma": "^0.5.4"
|
||||
}
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { HostnameDnsProviderUtils } from '../../../shared/utils/domain-dns-provider.utils';
|
||||
|
||||
describe('DomainDnsProviderUtils', () => {
|
||||
describe('isValidDnsProviderHostname', () => {
|
||||
it('should return true for valid quickstack.me domain with subdomain', () => {
|
||||
expect(HostnameDnsProviderUtils.isValidDnsProviderHostname('sub.example.quickstack.me')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for hex IP-based domain', () => {
|
||||
expect(HostnameDnsProviderUtils.isValidDnsProviderHostname('c0a80101.quickstack.me')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for simple domain ending with .quickstack.me', () => {
|
||||
expect(HostnameDnsProviderUtils.isValidDnsProviderHostname('example.quickstack.me')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for domain not ending with .quickstack.me', () => {
|
||||
expect(HostnameDnsProviderUtils.isValidDnsProviderHostname('example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for domain with only provider domain', () => {
|
||||
expect(HostnameDnsProviderUtils.isValidDnsProviderHostname('quickstack.me')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty string', () => {
|
||||
expect(HostnameDnsProviderUtils.isValidDnsProviderHostname('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('containsDnsProviderHostname', () => {
|
||||
it('should return true for domain containing .quickstack.me', () => {
|
||||
expect(HostnameDnsProviderUtils.containsDnsProviderHostname('example.quickstack.me')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for subdomain containing .quickstack.me', () => {
|
||||
expect(HostnameDnsProviderUtils.containsDnsProviderHostname('sub.example.quickstack.me')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for domain not containing .quickstack.me', () => {
|
||||
expect(HostnameDnsProviderUtils.containsDnsProviderHostname('example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty string', () => {
|
||||
expect(HostnameDnsProviderUtils.containsDnsProviderHostname('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHostnameForIpAddress', () => {
|
||||
it('should convert IP address to hostname with hex format', () => {
|
||||
expect(HostnameDnsProviderUtils.getHexHostanmeForIpAddress('192.168.1.1')).toBe('c0a80101.quickstack.me');
|
||||
});
|
||||
|
||||
it('should handle another IP address format', () => {
|
||||
expect(HostnameDnsProviderUtils.getHexHostanmeForIpAddress('10.0.0.1')).toBe('0a000001.quickstack.me');
|
||||
});
|
||||
|
||||
it('should handle localhost IP', () => {
|
||||
expect(HostnameDnsProviderUtils.getHexHostanmeForIpAddress('127.0.0.1')).toBe('7f000001.quickstack.me');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHostnameForIpAdress', () => {
|
||||
it('should convert IP address to hostname with dash-separated format', () => {
|
||||
expect(HostnameDnsProviderUtils.getHostnameForIpAdress('192.168.1.1')).toBe('192-168-1-1.quickstack.me');
|
||||
});
|
||||
|
||||
it('should handle another IP address format', () => {
|
||||
expect(HostnameDnsProviderUtils.getHostnameForIpAdress('10.0.0.1')).toBe('10-0-0-1.quickstack.me');
|
||||
});
|
||||
|
||||
it('should handle localhost IP', () => {
|
||||
expect(HostnameDnsProviderUtils.getHostnameForIpAdress('127.0.0.1')).toBe('127-0-0-1.quickstack.me');
|
||||
});
|
||||
|
||||
it('should handle max values correctly', () => {
|
||||
expect(HostnameDnsProviderUtils.getHostnameForIpAdress('255.255.255.255')).toBe('255-255-255-255.quickstack.me');
|
||||
});
|
||||
|
||||
it('should handle minimum values correctly', () => {
|
||||
expect(HostnameDnsProviderUtils.getHostnameForIpAdress('0.0.0.0')).toBe('0-0-0-0.quickstack.me');
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -6,8 +6,9 @@ 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";
|
||||
import paramService, { ParamService } from "@/server/services/param.service";
|
||||
|
||||
const actionAppDomainEditZodModel = appDomainEditZodModel.merge(z.object({
|
||||
appId: z.string(),
|
||||
@@ -23,9 +24,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.`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,4 +58,13 @@ export const deletePort = async (portId: string) =>
|
||||
await isAuthorizedWriteForApp(await appService.getPortById(portId).then(p => p.appId));
|
||||
await appService.deletePortById(portId);
|
||||
return new SuccessActionResult(undefined, 'Successfully deleted port');
|
||||
});
|
||||
});
|
||||
|
||||
export const getQuickstackDomainSuffix = async () => simpleAction(async () => {
|
||||
await getAuthUserSession();
|
||||
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.');
|
||||
}
|
||||
return HostnameDnsProviderUtils.getHexHostanmeForIpAddress(publicIpv4);
|
||||
});
|
||||
@@ -19,15 +19,45 @@ import { SubmitButton } from "@/components/custom/submit-button";
|
||||
import { AppDomain } from "@prisma/client"
|
||||
import { AppDomainEditModel, appDomainEditZodModel } from "@/shared/model/domain-edit.model"
|
||||
import { ServerActionResult } from "@/shared/model/server-action-error-return.model"
|
||||
import { saveDomain } from "./actions"
|
||||
import { saveDomain, getQuickstackDomainSuffix } from "./actions"
|
||||
import { toast } from "sonner"
|
||||
import CheckboxFormField from "@/components/custom/checkbox-form-field"
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { HostnameDnsProviderUtils } from "@/shared/utils/domain-dns-provider.utils"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
|
||||
|
||||
export default function DialogEditDialog({ children, domain, appId }: { children: React.ReactNode; domain?: AppDomain; appId: string; }) {
|
||||
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [domainSuffix, setDomainSuffix] = useState<string | undefined>(undefined);
|
||||
const [activeTab, setActiveTab] = useState<'custom' | 'quickstack'>('custom');
|
||||
|
||||
useEffect(() => {
|
||||
// Load the quickstack.me domain suffix when dialog opens
|
||||
if (isOpen) {
|
||||
getQuickstackDomainSuffix().then((res) => {
|
||||
if (res.status === 'success' && res.data) {
|
||||
setDomainSuffix(res.data);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Determine which tab should be active based on the domain
|
||||
useEffect(() => {
|
||||
if (domain?.hostname && domainSuffix) {
|
||||
if (HostnameDnsProviderUtils.containsDnsProviderHostname(domain.hostname)) {
|
||||
setActiveTab('quickstack');
|
||||
} else {
|
||||
setActiveTab('custom');
|
||||
}
|
||||
}
|
||||
}, [domain, domainSuffix]);
|
||||
|
||||
const form = useForm<AppDomainEditModel>({
|
||||
resolver: zodResolver(appDomainEditZodModel),
|
||||
@@ -58,8 +88,24 @@ export default function DialogEditDialog({ children, domain, appId }: { children
|
||||
const values = form.watch();
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(domain);
|
||||
}, [domain]);
|
||||
if (domain) {
|
||||
form.reset(domain);
|
||||
}
|
||||
}, [domain, form]);
|
||||
|
||||
// Extract the custom prefix from quickstack.me domain when editing
|
||||
const getQuickstackPrefix = (hostname: string): string => {
|
||||
if (!hostname || !domainSuffix) return '';
|
||||
if (hostname.endsWith(`.${domainSuffix}`)) {
|
||||
return hostname.replace(`.${domainSuffix}`, '');
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// Handle form submission
|
||||
const handleFormSubmit = (data: AppDomainEditModel) => {
|
||||
return formAction(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -76,39 +122,107 @@ export default function DialogEditDialog({ children, domain, appId }: { children
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form action={(e) => form.handleSubmit((data) => {
|
||||
return formAction(data);
|
||||
return handleFormSubmit(data);
|
||||
})()}>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hostname"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Hostname</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as 'custom' | 'quickstack')} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="custom">Custom Domain</TabsTrigger>
|
||||
{!!domainSuffix && <TabsTrigger value="quickstack">quickstack.me Domain</TabsTrigger>}
|
||||
</TabsList>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="port"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>App Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="ex. 80" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<TabsContent value="custom" className="space-y-4 mt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hostname"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Hostname</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<CheckboxFormField form={form} name="useSsl" label="use HTTPS" />
|
||||
{values.useSsl && <CheckboxFormField form={form} name="redirectHttps" label="Redirect HTTP to HTTPS" />}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="port"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>App Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="ex. 80" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<CheckboxFormField form={form} name="useSsl" label="use HTTPS" />
|
||||
{values.useSsl && <CheckboxFormField form={form} name="redirectHttps" label="Redirect HTTP to HTTPS" />}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="quickstack" className="space-y-4 mt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hostname"
|
||||
render={({ field }) => {
|
||||
const prefixValue = getQuickstackPrefix(field.value || '');
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Domain Prefix</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="my-app"
|
||||
value={prefixValue}
|
||||
onChange={(e) => {
|
||||
const newPrefix = e.target.value;
|
||||
const fullHostname = newPrefix ? `${newPrefix}.${domainSuffix}` : '';
|
||||
field.onChange(fullHostname);
|
||||
}}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
.{domainSuffix}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>This ist the quickstack.me <br />domain for your instance.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="port"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>App Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="ex. 80" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<CheckboxFormField form={form} name="useSsl" label="use HTTPS" />
|
||||
{values.useSsl && <CheckboxFormField form={form} name="redirectHttps" label="Redirect HTTP to HTTPS" />}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<p className="text-red-500">{state.message}</p>
|
||||
<SubmitButton>Save</SubmitButton>
|
||||
</div>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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 "../hostname-dns-provider.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) {
|
||||
@@ -125,28 +125,30 @@ export class BaseDbToolService {
|
||||
|
||||
const existingIngress = await ingressService.getIngressByName(projectId, toolAppName);
|
||||
if (existingIngress) {
|
||||
await k3s.network.deleteNamespacedIngress(KubeObjectNameUtils.getIngressName(toolAppName), projectId);
|
||||
// do not delete ingress to reduce cert-manager issues --> todo; add cleanup function in maintenance section
|
||||
//await k3s.network.deleteNamespacedIngress(KubeObjectNameUtils.getIngressName(toolAppName), projectId);
|
||||
}
|
||||
}
|
||||
|
||||
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 +168,8 @@ export class BaseDbToolService {
|
||||
},
|
||||
],
|
||||
tls: [{
|
||||
hosts: [traefikHostname],
|
||||
secretName: Constants.TRAEFIK_ME_SECRET_NAME,
|
||||
hosts: [hostname],
|
||||
secretName: `sec-tls-${hostname}`
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 "./hostname-dns-provider.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);
|
||||
|
||||
|
||||
@@ -0,0 +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";
|
||||
|
||||
/**
|
||||
* 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.');
|
||||
}
|
||||
if (prefix) {
|
||||
return `${prefix}-${appId}.${HostnameDnsProviderUtils.getHostnameForIpAdress(publicIpv4)}`;
|
||||
}
|
||||
return `${appId}.${HostnameDnsProviderUtils.getHostnameForIpAdress(publicIpv4)}`;
|
||||
}
|
||||
}
|
||||
|
||||
const hostnameDnsProviderService = new HostnameDnsProviderService();
|
||||
export default hostnameDnsProviderService;
|
||||
@@ -2,12 +2,10 @@ import { AppExtendedModel } from "@/shared/model/app-extended.model";
|
||||
import k3s from "../adapter/kubernetes-api.adapter";
|
||||
import { V1Ingress, V1Secret } from "@kubernetes/client-node";
|
||||
import { KubeObjectNameUtils } from "../utils/kube-object-name.utils";
|
||||
import { AppDomain } from "@prisma/client";
|
||||
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 {
|
||||
|
||||
@@ -68,7 +66,6 @@ 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,
|
||||
@@ -84,7 +81,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' }),
|
||||
...(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
|
||||
},
|
||||
@@ -116,7 +113,7 @@ class IngressService {
|
||||
tls: [
|
||||
{
|
||||
hosts: [hostname],
|
||||
secretName: isATraefikMeDomain ? Constants.TRAEFIK_ME_SECRET_NAME : `secret-tls-${domain.id}`,
|
||||
secretName: `secret-tls-${domain.id}`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -1,21 +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.');
|
||||
}
|
||||
const traefikFriendlyIpv4 = publicIpv4.split('.').join('-');
|
||||
if (prefix) {
|
||||
return `${prefix}-${appId}-${traefikFriendlyIpv4}.traefik.me`;
|
||||
}
|
||||
return `${appId}-${traefikFriendlyIpv4}.traefik.me`;
|
||||
}
|
||||
}
|
||||
|
||||
const traefikMeDomainService = new TraefikMeDomainService();
|
||||
export default traefikMeDomainService;
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 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 and custom domain quickstack.me.
|
||||
*/
|
||||
export class HostnameDnsProviderUtils {
|
||||
|
||||
public static readonly PROVIDER_HOSTNAME = 'quickstack.me';
|
||||
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 getHexHostanmeForIpAddress(ipv4Address: string): string {
|
||||
const traefikFriendlyIpv4 = this.ipv4ToHex(ipv4Address)
|
||||
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('');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user