Merge branch 'canary' into ayham291/canary

This commit is contained in:
Mauricio Siu
2025-06-21 20:21:41 -06:00
35 changed files with 6669 additions and 133 deletions

View File

@@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1
FROM node:20.9-slim AS base FROM node:20.9-slim AS base
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"

View File

@@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1
FROM node:20.9-slim AS base FROM node:20.9-slim AS base
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"

View File

@@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1
# Build stage # Build stage
FROM golang:1.21-alpine3.19 AS builder FROM golang:1.21-alpine3.19 AS builder

View File

@@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1
FROM node:20.9-slim AS base FROM node:20.9-slim AS base
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"

View File

@@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1
FROM node:20.9-slim AS base FROM node:20.9-slim AS base
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"

28
SECURITY.md Normal file
View File

@@ -0,0 +1,28 @@
# Dokploy Security Policy
At Dokploy, security is a top priority. We appreciate the help of security researchers and the community in identifying and reporting vulnerabilities.
## How to Report a Vulnerability
If you have discovered a security vulnerability in Dokploy, we ask that you report it responsibly by following these guidelines:
1. **Contact us:** Send an email to [contact@dokploy.com](mailto:contact@dokploy.com).
2. **Provide clear details:** Include as much information as possible to help us understand and reproduce the vulnerability. This should include:
* A clear description of the vulnerability.
* Steps to reproduce the vulnerability.
* Any sample code, screenshots, or videos that might be helpful.
* The potential impact of the vulnerability.
3. **Do not make the vulnerability public:** Please refrain from publicly disclosing the vulnerability until we have had the opportunity to investigate and address it. This is crucial for protecting our users.
4. **Allow us time:** We will endeavor to acknowledge receipt of your report as soon as possible and keep you informed of our progress. The time to resolve the vulnerability may vary depending on its complexity and severity.
## What We Expect From You
* Do not access user data or systems beyond what is necessary to demonstrate the vulnerability.
* Do not perform denial-of-service (DoS) attacks, spamming, or social engineering.
* Do not modify or destroy data that does not belong to you.
## Our Commitment
We are committed to working with you quickly and responsibly to address any legitimate security vulnerability.
Thank you for helping us keep Dokploy secure for everyone.

View File

@@ -247,7 +247,7 @@ export const UpdateVolume = ({
control={form.control} control={form.control}
name="content" name="content"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem className="max-w-full max-w-[45rem]">
<FormLabel>Content</FormLabel> <FormLabel>Content</FormLabel>
<FormControl> <FormControl>
<FormControl> <FormControl>
@@ -256,7 +256,7 @@ export const UpdateVolume = ({
placeholder={`NODE_ENV=production placeholder={`NODE_ENV=production
PORT=3000 PORT=3000
`} `}
className="h-96 font-mono" className="h-96 font-mono w-full"
{...field} {...field}
/> />
</FormControl> </FormControl>

View File

@@ -39,6 +39,7 @@ export type ValidationState = {
error?: string; error?: string;
resolvedIp?: string; resolvedIp?: string;
message?: string; message?: string;
cdnProvider?: string;
}; };
export type ValidationStates = Record<string, ValidationState>; export type ValidationStates = Record<string, ValidationState>;
@@ -119,6 +120,7 @@ export const ShowDomains = ({ id, type }: Props) => {
isValid: result.isValid, isValid: result.isValid,
error: result.error, error: result.error,
resolvedIp: result.resolvedIp, resolvedIp: result.resolvedIp,
cdnProvider: result.cdnProvider,
message: result.error && result.isValid ? result.error : undefined, message: result.error && result.isValid ? result.error : undefined,
}, },
})); }));
@@ -354,8 +356,9 @@ export const ShowDomains = ({ id, type }: Props) => {
) : validationState?.isValid ? ( ) : validationState?.isValid ? (
<> <>
<CheckCircle2 className="size-3 mr-1" /> <CheckCircle2 className="size-3 mr-1" />
{validationState.message {validationState.message &&
? "Behind Cloudflare" validationState.cdnProvider
? `Behind ${validationState.cdnProvider}`
: "DNS Valid"} : "DNS Valid"}
</> </>
) : validationState?.error ? ( ) : validationState?.error ? (

View File

@@ -49,7 +49,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
currentBuildArgs !== (data?.buildArgs || ""); currentBuildArgs !== (data?.buildArgs || "");
useEffect(() => { useEffect(() => {
if (data) { if (data && !hasChanges) {
form.reset({ form.reset({
env: data.env || "", env: data.env || "",
buildArgs: data.buildArgs || "", buildArgs: data.buildArgs || "",

View File

@@ -44,8 +44,10 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
resolver: zodResolver(AddComposeFile), resolver: zodResolver(AddComposeFile),
}); });
const composeFile = form.watch("composeFile");
useEffect(() => { useEffect(() => {
if (data) { if (data && !composeFile) {
form.reset({ form.reset({
composeFile: data.composeFile || "", composeFile: data.composeFile || "",
}); });

View File

@@ -38,7 +38,7 @@ const AddProjectSchema = z.object({
(name) => { (name) => {
const trimmedName = name.trim(); const trimmedName = name.trim();
const validNameRegex = const validNameRegex =
/^[\p{L}\p{N}_-][\p{L}\p{N}\s_-]*[\p{L}\p{N}_-]$/u; /^[\p{L}\p{N}_-][\p{L}\p{N}\s_.-]*[\p{L}\p{N}_-]$/u;
return validNameRegex.test(trimmedName); return validNameRegex.test(trimmedName);
}, },
{ {

View File

@@ -1,80 +1,89 @@
export const extractExpirationDate = (certData: string): Date | null => { export const extractExpirationDate = (certData: string): Date | null => {
try { try {
const match = certData.match( // Decode PEM base64 to DER binary
/-----BEGIN CERTIFICATE-----\s*([^-]+)\s*-----END CERTIFICATE-----/, const b64 = certData.replace(/-----[^-]+-----/g, "").replace(/\s+/g, "");
); const binStr = atob(b64);
if (!match?.[1]) return null; const der = new Uint8Array(binStr.length);
for (let i = 0; i < binStr.length; i++) {
const base64Cert = match[1].replace(/\s/g, ""); der[i] = binStr.charCodeAt(i);
const binaryStr = window.atob(base64Cert);
const bytes = new Uint8Array(binaryStr.length);
for (let i = 0; i < binaryStr.length; i++) {
bytes[i] = binaryStr.charCodeAt(i);
} }
// ASN.1 tag for UTCTime is 0x17, GeneralizedTime is 0x18 let offset = 0;
// We need to find the second occurrence of either tag as it's the "not after" (expiration) date
let dateFound = false;
for (let i = 0; i < bytes.length - 2; i++) {
// Look for sequence containing validity period (0x30)
if (bytes[i] === 0x30) {
// Check next bytes for UTCTime or GeneralizedTime
let j = i + 1;
while (j < bytes.length - 2) {
if (bytes[j] === 0x17 || bytes[j] === 0x18) {
const dateType = bytes[j];
const dateLength = bytes[j + 1];
if (typeof dateLength === "undefined") break;
if (!dateFound) { // Helper: read ASN.1 length field
// Skip "not before" date function readLength(pos: number): { length: number; offset: number } {
dateFound = true; let len = der[pos++];
j += dateLength + 2; if (len & 0x80) {
continue; const bytes = len & 0x7f;
} len = 0;
for (let i = 0; i < bytes; i++) {
// Found "not after" date len = (len << 8) + der[pos++];
let dateStr = "";
for (let k = 0; k < dateLength; k++) {
const charCode = bytes[j + 2 + k];
if (typeof charCode === "undefined") continue;
dateStr += String.fromCharCode(charCode);
}
if (dateType === 0x17) {
// UTCTime (YYMMDDhhmmssZ)
const year = Number.parseInt(dateStr.slice(0, 2));
const fullYear = year >= 50 ? 1900 + year : 2000 + year;
return new Date(
Date.UTC(
fullYear,
Number.parseInt(dateStr.slice(2, 4)) - 1,
Number.parseInt(dateStr.slice(4, 6)),
Number.parseInt(dateStr.slice(6, 8)),
Number.parseInt(dateStr.slice(8, 10)),
Number.parseInt(dateStr.slice(10, 12)),
),
);
}
// GeneralizedTime (YYYYMMDDhhmmssZ)
return new Date(
Date.UTC(
Number.parseInt(dateStr.slice(0, 4)),
Number.parseInt(dateStr.slice(4, 6)) - 1,
Number.parseInt(dateStr.slice(6, 8)),
Number.parseInt(dateStr.slice(8, 10)),
Number.parseInt(dateStr.slice(10, 12)),
Number.parseInt(dateStr.slice(12, 14)),
),
);
}
j++;
} }
} }
return { length: len, offset: pos };
} }
return null;
// Skip the outer certificate sequence
if (der[offset++] !== 0x30) throw new Error("Expected sequence");
({ offset } = readLength(offset));
// Skip tbsCertificate sequence
if (der[offset++] !== 0x30) throw new Error("Expected tbsCertificate");
({ offset } = readLength(offset));
// Check for optional version field (context-specific tag [0])
if (der[offset] === 0xa0) {
offset++;
const versionLen = readLength(offset);
offset = versionLen.offset + versionLen.length;
}
// Skip serialNumber, signature, issuer
for (let i = 0; i < 3; i++) {
if (der[offset] !== 0x30 && der[offset] !== 0x02)
throw new Error("Unexpected structure");
offset++;
const fieldLen = readLength(offset);
offset = fieldLen.offset + fieldLen.length;
}
// Validity sequence (notBefore and notAfter)
if (der[offset++] !== 0x30) throw new Error("Expected validity sequence");
const validityLen = readLength(offset);
offset = validityLen.offset;
// notBefore
offset++;
const notBeforeLen = readLength(offset);
offset = notBeforeLen.offset + notBeforeLen.length;
// notAfter
offset++;
const notAfterLen = readLength(offset);
const notAfterStr = new TextDecoder().decode(
der.slice(notAfterLen.offset, notAfterLen.offset + notAfterLen.length),
);
// Parse GeneralizedTime (15 chars) or UTCTime (13 chars)
function parseTime(str: string): Date {
if (str.length === 13) {
// UTCTime YYMMDDhhmmssZ
const year = parseInt(str.slice(0, 2), 10);
const fullYear = year < 50 ? 2000 + year : 1900 + year;
return new Date(
`${fullYear}-${str.slice(2, 4)}-${str.slice(4, 6)}T${str.slice(6, 8)}:${str.slice(8, 10)}:${str.slice(10, 12)}Z`,
);
} else if (str.length === 15) {
// GeneralizedTime YYYYMMDDhhmmssZ
return new Date(
`${str.slice(0, 4)}-${str.slice(4, 6)}-${str.slice(6, 8)}T${str.slice(8, 10)}:${str.slice(10, 12)}:${str.slice(12, 14)}Z`,
);
} else {
throw new Error("Invalid ASN.1 time format");
}
}
return parseTime(notAfterStr);
} catch (error) { } catch (error) {
console.error("Error parsing certificate:", error); console.error("Error parsing certificate:", error);
return null; return null;

View File

@@ -41,6 +41,7 @@ const addInvitation = z.object({
.min(1, "Email is required") .min(1, "Email is required")
.email({ message: "Invalid email" }), .email({ message: "Invalid email" }),
role: z.enum(["member", "admin"]), role: z.enum(["member", "admin"]),
notificationId: z.string().optional(),
}); });
type AddInvitation = z.infer<typeof addInvitation>; type AddInvitation = z.infer<typeof addInvitation>;
@@ -49,6 +50,10 @@ export const AddInvitation = () => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const utils = api.useUtils(); const utils = api.useUtils();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: emailProviders } =
api.notification.getEmailProviders.useQuery();
const { mutateAsync: sendInvitation } = api.user.sendInvitation.useMutation();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { data: activeOrganization } = authClient.useActiveOrganization(); const { data: activeOrganization } = authClient.useActiveOrganization();
@@ -56,6 +61,7 @@ export const AddInvitation = () => {
defaultValues: { defaultValues: {
email: "", email: "",
role: "member", role: "member",
notificationId: "",
}, },
resolver: zodResolver(addInvitation), resolver: zodResolver(addInvitation),
}); });
@@ -74,7 +80,20 @@ export const AddInvitation = () => {
if (result.error) { if (result.error) {
setError(result.error.message || ""); setError(result.error.message || "");
} else { } else {
toast.success("Invitation created"); if (!isCloud && data.notificationId) {
await sendInvitation({
invitationId: result.data.id,
notificationId: data.notificationId || "",
})
.then(() => {
toast.success("Invitation created and email sent");
})
.catch((error: any) => {
toast.error(error.message);
});
} else {
toast.success("Invitation created");
}
setError(null); setError(null);
setOpen(false); setOpen(false);
} }
@@ -149,6 +168,47 @@ export const AddInvitation = () => {
); );
}} }}
/> />
{!isCloud && (
<FormField
control={form.control}
name="notificationId"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Email Provider</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an email provider" />
</SelectTrigger>
</FormControl>
<SelectContent>
{emailProviders?.map((provider) => (
<SelectItem
key={provider.notificationId}
value={provider.notificationId}
>
{provider.name}
</SelectItem>
))}
<SelectItem value="none" disabled>
None
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Select the email provider to send the invitation
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
)}
<DialogFooter className="flex w-full flex-row"> <DialogFooter className="flex w-full flex-row">
<Button <Button
isLoading={isLoading} isLoading={isLoading}

View File

@@ -0,0 +1,3 @@
ALTER TABLE "user_temp" ALTER COLUMN "logCleanupCron" SET DEFAULT '0 0 * * *';
UPDATE "user_temp" SET "logCleanupCron" = '0 0 * * *' WHERE "logCleanupCron" IS NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -652,6 +652,13 @@
"when": 1747713229160, "when": 1747713229160,
"tag": "0092_stiff_the_watchers", "tag": "0092_stiff_the_watchers",
"breakpoints": true "breakpoints": true
},
{
"idx": 93,
"version": "7",
"when": 1750397258622,
"tag": "0093_nice_gorilla_man",
"breakpoints": true
} }
] ]
} }

View File

@@ -74,12 +74,7 @@ const Service = (
} }
}, [router.query.tab]); }, [router.query.tab]);
const { data } = api.compose.one.useQuery( const { data } = api.compose.one.useQuery({ composeId });
{ composeId },
{
refetchInterval: 5000,
},
);
const { data: auth } = api.user.get.useQuery(); const { data: auth } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery();

View File

@@ -22,6 +22,7 @@ import {
findPostgresByBackupId, findPostgresByBackupId,
findPostgresById, findPostgresById,
findServerById, findServerById,
keepLatestNBackups,
removeBackupById, removeBackupById,
removeScheduleBackup, removeScheduleBackup,
runMariadbBackup, runMariadbBackup,
@@ -197,6 +198,8 @@ export const backupRouter = createTRPCRouter({
const backup = await findBackupById(input.backupId); const backup = await findBackupById(input.backupId);
const postgres = await findPostgresByBackupId(backup.backupId); const postgres = await findPostgresByBackupId(backup.backupId);
await runPostgresBackup(postgres, backup); await runPostgresBackup(postgres, backup);
await keepLatestNBackups(backup, postgres?.serverId);
return true; return true;
} catch (error) { } catch (error) {
const message = const message =
@@ -217,6 +220,7 @@ export const backupRouter = createTRPCRouter({
const backup = await findBackupById(input.backupId); const backup = await findBackupById(input.backupId);
const mysql = await findMySqlByBackupId(backup.backupId); const mysql = await findMySqlByBackupId(backup.backupId);
await runMySqlBackup(mysql, backup); await runMySqlBackup(mysql, backup);
await keepLatestNBackups(backup, mysql?.serverId);
return true; return true;
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
@@ -233,6 +237,7 @@ export const backupRouter = createTRPCRouter({
const backup = await findBackupById(input.backupId); const backup = await findBackupById(input.backupId);
const mariadb = await findMariadbByBackupId(backup.backupId); const mariadb = await findMariadbByBackupId(backup.backupId);
await runMariadbBackup(mariadb, backup); await runMariadbBackup(mariadb, backup);
await keepLatestNBackups(backup, mariadb?.serverId);
return true; return true;
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
@@ -249,6 +254,7 @@ export const backupRouter = createTRPCRouter({
const backup = await findBackupById(input.backupId); const backup = await findBackupById(input.backupId);
const compose = await findComposeByBackupId(backup.backupId); const compose = await findComposeByBackupId(backup.backupId);
await runComposeBackup(compose, backup); await runComposeBackup(compose, backup);
await keepLatestNBackups(backup, compose?.serverId);
return true; return true;
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
@@ -265,6 +271,7 @@ export const backupRouter = createTRPCRouter({
const backup = await findBackupById(input.backupId); const backup = await findBackupById(input.backupId);
const mongo = await findMongoByBackupId(backup.backupId); const mongo = await findMongoByBackupId(backup.backupId);
await runMongoBackup(mongo, backup); await runMongoBackup(mongo, backup);
await keepLatestNBackups(backup, mongo?.serverId);
return true; return true;
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({

View File

@@ -496,7 +496,7 @@ export const composeRouter = createTRPCRouter({
} }
} }
return null; return compose;
}), }),
templates: publicProcedure templates: publicProcedure

View File

@@ -446,4 +446,12 @@ export const notificationRouter = createTRPCRouter({
}); });
} }
}), }),
getEmailProviders: adminProcedure.query(async ({ ctx }) => {
return await db.query.notifications.findMany({
where: eq(notifications.organizationId, ctx.session.activeOrganizationId),
with: {
email: true,
},
});
}),
}); });

View File

@@ -825,6 +825,9 @@ export const settingsRouter = createTRPCRouter({
}), }),
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
if (IS_CLOUD) {
return true;
}
if (input.cronExpression) { if (input.cronExpression) {
return startLogCleanup(input.cronExpression); return startLogCleanup(input.cronExpression);
} }

View File

@@ -1,10 +1,13 @@
import { import {
IS_CLOUD, IS_CLOUD,
createApiKey, createApiKey,
findAdmin,
findNotificationById,
findOrganizationById, findOrganizationById,
findUserById, findUserById,
getUserByToken, getUserByToken,
removeUserById, removeUserById,
sendEmailNotification,
updateUser, updateUser,
} from "@dokploy/server"; } from "@dokploy/server";
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
@@ -362,4 +365,59 @@ export const userRouter = createTRPCRouter({
return organizations.length; return organizations.length;
}), }),
sendInvitation: adminProcedure
.input(
z.object({
invitationId: z.string().min(1),
notificationId: z.string().min(1),
}),
)
.mutation(async ({ input, ctx }) => {
if (IS_CLOUD) {
return;
}
const notification = await findNotificationById(input.notificationId);
const email = notification.email;
const currentInvitation = await db.query.invitation.findFirst({
where: eq(invitation.id, input.invitationId),
});
if (!email) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Email notification not found",
});
}
const admin = await findAdmin();
const host =
process.env.NODE_ENV === "development"
? "http://localhost:3000"
: admin.user.host;
const inviteLink = `${host}/invitation?token=${input.invitationId}`;
const organization = await findOrganizationById(
ctx.session.activeOrganizationId,
);
try {
await sendEmailNotification(
{
...email,
toAddresses: [currentInvitation?.email || ""],
},
"Invitation to join organization",
`
<p>You are invited to join ${organization?.name || "organization"} on Dokploy. Click the link to accept the invitation: <a href="${inviteLink}">Accept Invitation</a></p>
`,
);
} catch (error) {
console.log(error);
throw error;
}
return inviteLink;
}),
}); });

View File

@@ -56,7 +56,7 @@ export const users_temp = pgTable("user_temp", {
letsEncryptEmail: text("letsEncryptEmail"), letsEncryptEmail: text("letsEncryptEmail"),
sshPrivateKey: text("sshPrivateKey"), sshPrivateKey: text("sshPrivateKey"),
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false), enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false),
logCleanupCron: text("logCleanupCron"), logCleanupCron: text("logCleanupCron").default("0 0 * * *"),
role: text("role").notNull().default("user"), role: text("role").notNull().default("user"),
// Metrics // Metrics
enablePaidFeatures: boolean("enablePaidFeatures").notNull().default(false), enablePaidFeatures: boolean("enablePaidFeatures").notNull().default(false),

View File

@@ -18,9 +18,6 @@ const { handler, api } = betterAuth({
provider: "pg", provider: "pg",
schema: schema, schema: schema,
}), }),
logger: {
disabled: process.env.NODE_ENV === "production",
},
appName: "Dokploy", appName: "Dokploy",
socialProviders: { socialProviders: {
github: { github: {

View File

@@ -0,0 +1,634 @@
// CDN Provider Interface
export interface CDNProvider {
name: string;
displayName: string;
checkIp: (ip: string) => boolean;
warningMessage: string;
}
const isIPInCIDR = (ip: string, cidr: string): boolean => {
const [network, prefixLength] = cidr.split("/");
if (!network || !prefixLength) return false;
const prefix = Number.parseInt(prefixLength, 10);
// Convert IP addresses to 32-bit integers
const ipToInt = (ipStr: string): number => {
return (
ipStr
.split(".")
.reduce((acc, octet) => (acc << 8) + Number.parseInt(octet, 10), 0) >>>
0
);
};
const ipInt = ipToInt(ip);
const networkInt = ipToInt(network);
const mask = (0xffffffff << (32 - prefix)) >>> 0;
return (ipInt & mask) === (networkInt & mask);
};
// Cloudflare IP ranges
// https://www.cloudflare.com/ips-v4
const CLOUDFLARE_IP_RANGES = [
"173.245.48.0/20",
"103.21.244.0/22",
"103.22.200.0/22",
"103.31.4.0/22",
"141.101.64.0/18",
"108.162.192.0/18",
"190.93.240.0/20",
"188.114.96.0/20",
"197.234.240.0/22",
"198.41.128.0/17",
"162.158.0.0/15",
"104.16.0.0/13",
"104.24.0.0/14",
"172.64.0.0/13",
"131.0.72.0/22",
];
// Fastly IP ranges
// https://api.fastly.com/public-ip-list
const FASTLY_IP_RANGES = [
"23.235.32.0/20",
"43.249.72.0/22",
"103.244.50.0/24",
"103.245.222.0/23",
"103.245.224.0/24",
"104.156.80.0/20",
"140.248.64.0/18",
"140.248.128.0/17",
"146.75.0.0/17",
"151.101.0.0/16",
"157.52.64.0/18",
"167.82.0.0/17",
"167.82.128.0/20",
"167.82.160.0/20",
"167.82.224.0/20",
"172.111.64.0/18",
"185.31.16.0/22",
"199.27.72.0/21",
"199.232.0.0/16",
];
// Bunny CDN IP addresses
// https://bunnycdn.com/api/system/edgeserverlist
const BUNNY_CDN_IPS = new Set([
"89.187.188.227",
"89.187.188.228",
"139.180.134.196",
"89.38.96.158",
"89.187.162.249",
"89.187.162.242",
"185.102.217.65",
"185.93.1.243",
"156.146.40.49",
"185.59.220.199",
"185.59.220.198",
"195.181.166.158",
"185.180.12.68",
"138.199.24.209",
"138.199.24.211",
"79.127.216.111",
"79.127.216.112",
"89.187.169.47",
"138.199.24.218",
"185.40.106.117",
"200.25.45.4",
"200.25.57.5",
"193.162.131.1",
"200.25.11.8",
"200.25.53.5",
"200.25.13.98",
"41.242.2.18",
"200.25.62.5",
"200.25.38.69",
"200.25.42.70",
"200.25.36.166",
"195.206.229.106",
"194.242.11.186",
"185.164.35.8",
"94.20.154.22",
"185.93.1.244",
"156.59.145.154",
"143.244.49.177",
"138.199.46.66",
"138.199.37.227",
"138.199.37.231",
"138.199.37.230",
"138.199.37.229",
"138.199.46.69",
"138.199.46.68",
"138.199.46.67",
"185.93.1.246",
"138.199.37.232",
"195.181.163.196",
"107.182.163.162",
"195.181.163.195",
"84.17.46.53",
"212.102.40.114",
"84.17.46.54",
"138.199.40.58",
"143.244.38.134",
"143.244.38.136",
"185.152.64.17",
"84.17.59.115",
"89.187.165.194",
"138.199.15.193",
"89.35.237.170",
"37.19.216.130",
"185.93.1.247",
"185.93.3.244",
"143.244.49.179",
"143.244.49.180",
"138.199.9.104",
"185.152.66.243",
"143.244.49.178",
"169.150.221.147",
"200.25.18.73",
"84.17.63.178",
"200.25.32.131",
"37.19.207.34",
"192.189.65.146",
"143.244.45.177",
"185.93.1.249",
"185.93.1.250",
"169.150.215.115",
"209.177.87.197",
"156.146.56.162",
"156.146.56.161",
"185.93.2.246",
"185.93.2.245",
"212.102.40.113",
"185.93.2.244",
"143.244.50.82",
"143.244.50.83",
"156.146.56.163",
"129.227.217.178",
"129.227.217.179",
"200.25.69.94",
"128.1.52.179",
"200.25.16.103",
"15.235.54.226",
"102.67.138.155",
"156.146.43.65",
"195.181.163.203",
"195.181.163.202",
"156.146.56.169",
"156.146.56.170",
"156.146.56.166",
"156.146.56.171",
"169.150.207.210",
"156.146.56.167",
"143.244.50.84",
"143.244.50.85",
"143.244.50.86",
"143.244.50.87",
"156.146.56.168",
"169.150.207.211",
"212.102.50.59",
"146.185.248.15",
"143.244.50.90",
"143.244.50.91",
"143.244.50.88",
"143.244.50.209",
"143.244.50.213",
"143.244.50.214",
"143.244.49.183",
"143.244.50.89",
"143.244.50.210",
"143.244.50.211",
"143.244.50.212",
"5.42.206.66",
"94.46.27.186",
"169.150.207.213",
"169.150.207.214",
"169.150.207.215",
"169.150.207.212",
"169.150.219.114",
"169.150.202.210",
"169.150.242.193",
"185.93.1.251",
"169.150.207.216",
"169.150.207.217",
"169.150.238.19",
"102.219.126.20",
"156.59.66.182",
"122.10.251.130",
"185.24.11.18",
"138.199.36.7",
"138.199.36.8",
"138.199.36.9",
"138.199.36.10",
"138.199.36.11",
"138.199.37.225",
"84.17.46.49",
"84.17.37.217",
"169.150.225.35",
"169.150.225.36",
"169.150.225.37",
"169.150.225.38",
"169.150.225.39",
"169.150.225.34",
"169.150.236.97",
"169.150.236.98",
"169.150.236.99",
"169.150.236.100",
"93.189.63.149",
"143.244.56.49",
"143.244.56.50",
"143.244.56.51",
"169.150.247.40",
"169.150.247.33",
"169.150.247.34",
"169.150.247.35",
"169.150.247.36",
"169.150.247.37",
"169.150.247.38",
"169.150.247.39",
"38.142.94.218",
"87.249.137.52",
"38.104.169.186",
"66.181.163.74",
"84.17.38.227",
"84.17.38.228",
"84.17.38.229",
"84.17.38.230",
"84.17.38.231",
"84.17.38.232",
"169.150.225.41",
"169.150.225.42",
"169.150.249.162",
"169.150.249.163",
"169.150.249.164",
"169.150.249.165",
"169.150.249.166",
"169.150.249.167",
"169.150.249.168",
"169.150.249.169",
"185.131.64.124",
"103.112.0.22",
"37.236.234.2",
"169.150.252.209",
"212.102.46.118",
"192.169.120.162",
"93.180.217.214",
"37.19.203.178",
"107.155.47.146",
"193.201.190.174",
"156.59.95.218",
"213.170.143.139",
"129.227.186.154",
"195.238.127.98",
"200.25.22.6",
"204.16.244.92",
"200.25.70.101",
"200.25.66.100",
"139.180.209.182",
"103.108.231.41",
"103.108.229.5",
"103.216.220.9",
"169.150.225.40",
"212.102.50.49",
"212.102.50.52",
"109.61.83.242",
"109.61.83.243",
"212.102.50.50",
"169.150.225.43",
"45.125.247.57",
"103.235.199.170",
"128.1.35.170",
"38.32.110.58",
"169.150.220.228",
"169.150.220.229",
"169.150.220.230",
"169.150.220.231",
"138.199.4.179",
"207.211.214.145",
"109.61.86.193",
"103.214.20.95",
"178.175.134.51",
"138.199.4.178",
"172.255.253.140",
"185.24.11.19",
"109.61.83.244",
"109.61.83.245",
"84.17.38.250",
"84.17.38.251",
"146.59.69.202",
"146.70.80.218",
"200.25.80.74",
"79.127.213.214",
"79.127.213.215",
"79.127.213.216",
"79.127.213.217",
"195.69.140.112",
"109.61.83.247",
"109.61.83.246",
"185.93.2.248",
"109.61.83.249",
"109.61.83.250",
"109.61.83.251",
"46.199.75.115",
"141.164.35.160",
"109.61.83.97",
"109.61.83.98",
"109.61.83.99",
"129.227.179.18",
"185.180.14.250",
"152.89.160.26",
"5.189.202.62",
"98.98.242.142",
"156.59.92.126",
"84.17.59.117",
"79.127.216.66",
"79.127.204.113",
"79.127.237.132",
"169.150.236.104",
"169.150.236.105",
"37.27.135.61",
"158.51.123.205",
"156.146.43.70",
"156.146.43.71",
"156.146.43.72",
"180.149.231.175",
"185.93.2.243",
"143.244.56.52",
"143.244.56.53",
"143.244.56.54",
"143.244.56.55",
"143.244.56.56",
"143.244.56.57",
"143.244.56.58",
"144.76.236.44",
"88.198.57.50",
"78.46.69.199",
"136.243.16.49",
"138.201.86.122",
"136.243.42.90",
"88.99.95.221",
"178.63.2.112",
"5.9.98.45",
"136.243.42.10",
"169.150.236.106",
"169.150.236.107",
"185.93.1.242",
"185.93.1.245",
"143.244.60.193",
"195.181.163.194",
"79.127.188.193",
"79.127.188.196",
"79.127.188.194",
"79.127.188.195",
"104.166.144.106",
"156.59.126.78",
"185.135.85.154",
"38.54.5.37",
"38.54.3.92",
"185.165.170.74",
"207.121.80.118",
"207.121.46.228",
"207.121.46.236",
"207.121.46.244",
"207.121.46.252",
"216.202.235.164",
"207.121.46.220",
"207.121.75.132",
"207.121.80.12",
"207.121.80.172",
"207.121.90.60",
"207.121.90.68",
"207.121.97.204",
"207.121.90.252",
"207.121.97.236",
"207.121.99.12",
"138.199.24.219",
"185.93.2.251",
"138.199.46.65",
"207.121.41.196",
"207.121.99.20",
"207.121.99.36",
"207.121.99.44",
"207.121.99.52",
"207.121.99.60",
"207.121.23.68",
"207.121.23.124",
"207.121.23.244",
"207.121.23.180",
"207.121.23.188",
"207.121.23.196",
"207.121.23.204",
"207.121.24.52",
"207.121.24.60",
"207.121.24.68",
"207.121.24.76",
"207.121.24.92",
"207.121.24.100",
"207.121.24.108",
"207.121.24.116",
"154.95.86.76",
"5.9.99.73",
"78.46.92.118",
"144.76.65.213",
"78.46.156.89",
"88.198.9.155",
"144.76.79.22",
"103.1.215.93",
"103.137.12.33",
"103.107.196.31",
"116.90.72.155",
"103.137.14.5",
"116.90.75.65",
"37.19.207.37",
"208.83.234.224",
"116.202.155.146",
"116.202.193.178",
"116.202.224.168",
"188.40.126.227",
"88.99.26.189",
"168.119.39.238",
"88.99.26.97",
"168.119.12.188",
"176.9.139.55",
"142.132.223.79",
"142.132.223.80",
"142.132.223.81",
"46.4.116.17",
"46.4.119.81",
"167.235.114.167",
"159.69.68.171",
"178.63.21.52",
"46.4.120.152",
"116.202.80.247",
"5.9.71.119",
"195.201.11.156",
"78.46.123.17",
"46.4.113.143",
"136.243.2.236",
"195.201.81.217",
"148.251.42.123",
"94.130.68.122",
"88.198.22.103",
"46.4.102.90",
"157.90.180.205",
"162.55.135.11",
"195.201.109.59",
"148.251.41.244",
"116.202.235.16",
"128.140.70.141",
"78.46.74.86",
"78.46.74.85",
"178.63.41.242",
"178.63.41.247",
"178.63.41.234",
"104.237.53.74",
"104.237.54.154",
"104.237.51.58",
"64.185.235.90",
"64.185.234.114",
"64.185.232.194",
"64.185.232.178",
"64.185.232.82",
"103.60.15.169",
"103.60.15.170",
"103.60.15.171",
"103.60.15.172",
"103.60.15.173",
"103.60.15.162",
"103.60.15.163",
"103.60.15.164",
"103.60.15.165",
"103.60.15.166",
"103.60.15.167",
"103.60.15.168",
"109.248.43.116",
"109.248.43.117",
"109.248.43.162",
"109.248.43.163",
"109.248.43.164",
"109.248.43.165",
"49.12.71.27",
"49.12.0.158",
"78.47.94.156",
"109.248.43.159",
"109.248.43.160",
"109.248.43.208",
"109.248.43.179",
"109.248.43.232",
"109.248.43.231",
"109.248.43.241",
"109.248.43.236",
"109.248.43.240",
"109.248.43.103",
"116.202.118.194",
"116.202.80.29",
"159.69.57.80",
"139.180.129.216",
"139.99.174.7",
"89.187.169.18",
"89.187.179.7",
"143.244.62.213",
"185.93.3.246",
"195.181.163.198",
"185.152.64.19",
"84.17.37.211",
"212.102.50.54",
"212.102.46.115",
"143.244.38.135",
"169.150.238.21",
"169.150.207.51",
"169.150.207.49",
"84.17.38.226",
"84.17.38.225",
"37.19.222.248",
"37.19.222.249",
"169.150.247.139",
"169.150.247.177",
"169.150.247.178",
"169.150.213.49",
"212.102.46.119",
"84.17.38.234",
"84.17.38.233",
"169.150.247.179",
"169.150.247.180",
"169.150.247.181",
"169.150.247.182",
"169.150.247.183",
"169.150.247.138",
"169.150.247.184",
"169.150.247.185",
"156.146.58.83",
"212.102.43.88",
"89.187.169.26",
"109.61.89.57",
"109.61.89.58",
"109.61.83.241",
"84.17.38.243",
"84.17.38.244",
"84.17.38.246",
"84.17.38.247",
"84.17.38.245",
"143.244.38.129",
"84.17.38.248",
"89.187.176.34",
"185.152.64.23",
"79.127.213.209",
"79.127.213.210",
"84.17.37.209",
"156.146.43.68",
"185.93.3.243",
"79.127.219.198",
"138.199.33.57",
"79.127.242.89",
"138.199.4.136",
"169.150.220.235",
"138.199.4.129",
"138.199.4.177",
"37.19.222.34",
"46.151.193.85",
"212.104.158.17",
"212.104.158.18",
"212.104.158.19",
"212.104.158.20",
"212.104.158.21",
"212.104.158.22",
"212.104.158.24",
"212.104.158.26",
"79.127.237.134",
"89.187.184.177",
"89.187.184.179",
"89.187.184.173",
"89.187.184.178",
"89.187.184.176",
]);
const CDN_PROVIDERS: CDNProvider[] = [
{
name: "cloudflare",
displayName: "Cloudflare",
checkIp: (ip: string) =>
CLOUDFLARE_IP_RANGES.some((range) => isIPInCIDR(ip, range)),
warningMessage:
"Domain is behind Cloudflare - actual IP is masked by Cloudflare proxy",
},
{
name: "bunnycdn",
displayName: "Bunny CDN",
checkIp: (ip: string) => BUNNY_CDN_IPS.has(ip),
warningMessage:
"Domain is behind Bunny CDN - actual IP is masked by CDN proxy",
},
{
name: "fastly",
displayName: "Fastly",
checkIp: (ip: string) =>
FASTLY_IP_RANGES.some((range) => isIPInCIDR(ip, range)),
warningMessage:
"Domain is behind Fastly - actual IP is masked by CDN proxy",
},
];
export const detectCDNProvider = (ip: string): CDNProvider | null => {
return CDN_PROVIDERS.find((provider) => provider.checkIp(ip)) || null;
};

View File

@@ -8,6 +8,7 @@ import { eq } from "drizzle-orm";
import { type apiCreateDomain, domains } from "../db/schema"; import { type apiCreateDomain, domains } from "../db/schema";
import { findUserById } from "./admin"; import { findUserById } from "./admin";
import { findApplicationById } from "./application"; import { findApplicationById } from "./application";
import { detectCDNProvider } from "./cdn";
import { findServerById } from "./server"; import { findServerById } from "./server";
export type Domain = typeof domains.$inferSelect; export type Domain = typeof domains.$inferSelect;
@@ -142,28 +143,6 @@ export const getDomainHost = (domain: Domain) => {
const resolveDns = promisify(dns.resolve4); const resolveDns = promisify(dns.resolve4);
// Cloudflare IP ranges (simplified - these are some common ones)
const CLOUDFLARE_IPS = [
"172.67.",
"104.21.",
"104.16.",
"104.17.",
"104.18.",
"104.19.",
"104.20.",
"104.22.",
"104.23.",
"104.24.",
"104.25.",
"104.26.",
"104.27.",
"104.28.",
];
const isCloudflareIp = (ip: string) => {
return CLOUDFLARE_IPS.some((range) => ip.startsWith(range));
};
export const validateDomain = async ( export const validateDomain = async (
domain: string, domain: string,
expectedIp?: string, expectedIp?: string,
@@ -172,6 +151,7 @@ export const validateDomain = async (
resolvedIp?: string; resolvedIp?: string;
error?: string; error?: string;
isCloudflare?: boolean; isCloudflare?: boolean;
cdnProvider?: string;
}> => { }> => {
try { try {
// Remove protocol and path if present // Remove protocol and path if present
@@ -182,17 +162,18 @@ export const validateDomain = async (
const resolvedIps = ips.map((ip) => ip.toString()); const resolvedIps = ips.map((ip) => ip.toString());
// Check if it's a Cloudflare IP // Check if any IP belongs to a CDN provider
const behindCloudflare = ips.some((ip) => isCloudflareIp(ip)); const cdnProvider = ips
.map((ip) => detectCDNProvider(ip))
.find((provider) => provider !== null);
// If behind Cloudflare, we consider it valid but inform the user // If behind a CDN, we consider it valid but inform the user
if (behindCloudflare) { if (cdnProvider) {
return { return {
isValid: true, isValid: true,
resolvedIp: resolvedIps.join(", "), resolvedIp: resolvedIps.join(", "),
isCloudflare: true, cdnProvider: cdnProvider.displayName,
error: error: cdnProvider.warningMessage,
"Domain is behind Cloudflare - actual IP is masked by Cloudflare proxy",
}; };
} }

View File

@@ -139,6 +139,8 @@ export const initializeTraefik = async ({
const newContainer = docker.getContainer(containerName); const newContainer = docker.getContainer(containerName);
await newContainer.start(); await newContainer.start();
console.log("Traefik container started successfully after retry"); console.log("Traefik container started successfully after retry");
} else {
throw error;
} }
} }
} catch (error) { } catch (error) {

View File

@@ -37,7 +37,8 @@ export const startLogCleanup = async (
} }
return true; return true;
} catch (_) { } catch (error) {
console.error("Error starting log cleanup:", error);
return false; return false;
} }
}; };

View File

@@ -88,6 +88,7 @@ export const initCronJobs = async () => {
} }
if (admin?.user.logCleanupCron) { if (admin?.user.logCleanupCron) {
console.log("Starting log requests cleanup", admin.user.logCleanupCron);
await startLogCleanup(admin.user.logCleanupCron); await startLogCleanup(admin.user.logCleanupCron);
} }
}; };

View File

@@ -2,7 +2,10 @@ import { createHash } from "node:crypto";
import type { WriteStream } from "node:fs"; import type { WriteStream } from "node:fs";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import type { ApplicationNested } from "."; import type { ApplicationNested } from ".";
import { prepareEnvironmentVariables } from "../docker/utils"; import {
parseEnvironmentKeyValuePair,
prepareEnvironmentVariables,
} from "../docker/utils";
import { getBuildAppDirectory } from "../filesystem/directory"; import { getBuildAppDirectory } from "../filesystem/directory";
import { execAsync } from "../process/execAsync"; import { execAsync } from "../process/execAsync";
import { spawnAsync } from "../process/spawnAsync"; import { spawnAsync } from "../process/spawnAsync";
@@ -81,10 +84,10 @@ export const buildRailpack = async (
// Add secrets properly formatted // Add secrets properly formatted
const env: { [key: string]: string } = {}; const env: { [key: string]: string } = {};
for (const envVar of envVariables) { for (const pair of envVariables) {
const [key, value] = envVar.split("="); const [key, value] = parseEnvironmentKeyValuePair(pair);
if (key && value) { if (key && value) {
buildArgs.push("--secret", `id=${key},env='${key}'`); buildArgs.push("--secret", `id=${key},env=${key}`);
env[key] = value; env[key] = value;
} }
} }
@@ -161,11 +164,11 @@ export const getRailpackCommand = (
// Add secrets properly formatted // Add secrets properly formatted
const exportEnvs = []; const exportEnvs = [];
for (const envVar of envVariables) { for (const pair of envVariables) {
const [key, value] = envVar.split("="); const [key, value] = parseEnvironmentKeyValuePair(pair);
if (key && value) { if (key && value) {
buildArgs.push("--secret", `id=${key},env='${key}'`); buildArgs.push("--secret", `id=${key},env=${key}`);
exportEnvs.push(`export ${key}=${value}`); exportEnvs.push(`export ${key}='${value}'`);
} }
} }

View File

@@ -279,6 +279,17 @@ export const prepareEnvironmentVariables = (
return resolvedVars; return resolvedVars;
}; };
export const parseEnvironmentKeyValuePair = (
pair: string,
): [string, string] => {
const [key, ...valueParts] = pair.split("=");
if (!key || !valueParts.length) {
throw new Error(`Invalid environment variable pair: ${pair}`);
}
return [key, valueParts.join("")];
};
export const getEnviromentVariablesObject = ( export const getEnviromentVariablesObject = (
input: string | null, input: string | null,
projectEnv?: string | null, projectEnv?: string | null,
@@ -288,7 +299,7 @@ export const getEnviromentVariablesObject = (
const jsonObject: Record<string, string> = {}; const jsonObject: Record<string, string> = {};
for (const pair of envs) { for (const pair of envs) {
const [key, value] = pair.split("="); const [key, value] = parseEnvironmentKeyValuePair(pair);
if (key && value) { if (key && value) {
jsonObject[key] = value; jsonObject[key] = value;
} }

View File

@@ -73,7 +73,7 @@ export const cloneBitbucketRepository = async (
}); });
writeStream.write(`\nCloned ${repoclone} to ${outputPath}: ✅\n`); writeStream.write(`\nCloned ${repoclone} to ${outputPath}: ✅\n`);
} catch (error) { } catch (error) {
writeStream.write(`ERROR Clonning: ${error}: ❌`); writeStream.write(`ERROR Cloning: ${error}: ❌`);
throw error; throw error;
} finally { } finally {
writeStream.end(); writeStream.end();

View File

@@ -155,7 +155,7 @@ export const getGiteaCloneCommand = async (
const cloneCommand = ` const cloneCommand = `
rm -rf ${outputPath}; rm -rf ${outputPath};
mkdir -p ${outputPath}; mkdir -p ${outputPath};
if ! git clone --branch ${giteaBranch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${cloneUrl} ${outputPath} >> ${logPath} 2>&1; then if ! git clone --branch ${giteaBranch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${cloneUrl} ${outputPath} >> ${logPath} 2>&1; then
echo "❌ [ERROR] Failed to clone the repository ${repoClone}" >> ${logPath}; echo "❌ [ERROR] Failed to clone the repository ${repoClone}" >> ${logPath};
exit 1; exit 1;
@@ -232,7 +232,7 @@ export const cloneGiteaRepository = async (
); );
writeStream.write(`\nCloned ${repoClone}: ✅\n`); writeStream.write(`\nCloned ${repoClone}: ✅\n`);
} catch (error) { } catch (error) {
writeStream.write(`ERROR Clonning: ${error}: ❌`); writeStream.write(`ERROR Cloning: ${error}: ❌`);
throw error; throw error;
} finally { } finally {
writeStream.end(); writeStream.end();

View File

@@ -149,7 +149,7 @@ export const cloneGithubRepository = async ({
}); });
writeStream.write(`\nCloned ${repoclone}: ✅\n`); writeStream.write(`\nCloned ${repoclone}: ✅\n`);
} catch (error) { } catch (error) {
writeStream.write(`ERROR Clonning: ${error}: ❌`); writeStream.write(`ERROR Cloning: ${error}: ❌`);
throw error; throw error;
} finally { } finally {
writeStream.end(); writeStream.end();

View File

@@ -132,7 +132,7 @@ export const cloneGitlabRepository = async (
const cloneUrl = `https://oauth2:${gitlab?.accessToken}@${repoclone}`; const cloneUrl = `https://oauth2:${gitlab?.accessToken}@${repoclone}`;
try { try {
writeStream.write(`\nClonning Repo ${repoclone} to ${outputPath}: ✅\n`); writeStream.write(`\nCloning Repo ${repoclone} to ${outputPath}: ✅\n`);
const cloneArgs = [ const cloneArgs = [
"clone", "clone",
"--branch", "--branch",
@@ -152,7 +152,7 @@ export const cloneGitlabRepository = async (
}); });
writeStream.write(`\nCloned ${repoclone}: ✅\n`); writeStream.write(`\nCloned ${repoclone}: ✅\n`);
} catch (error) { } catch (error) {
writeStream.write(`ERROR Clonning: ${error}: ❌`); writeStream.write(`ERROR Cloning: ${error}: ❌`);
throw error; throw error;
} finally { } finally {
writeStream.end(); writeStream.end();