diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 52fd7f2f..a69fa686 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,7 +52,7 @@ feat: add new feature Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch. -We use Node v20.9.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 20.9.0 && nvm use` in the root directory. +We use Node v20.9.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 20.9.0 && nvm use` in the root directory. ```bash git clone https://github.com/dokploy/dokploy.git @@ -147,11 +147,9 @@ curl -sSL https://railpack.com/install.sh | sh ```bash # Install Buildpacks -curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.32.1/pack-v0.32.1-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack +curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.35.0/pack-v0.35.0-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack ``` - - ## Pull Request - The `main` branch is the source of truth and should always reflect the latest stable release. @@ -169,7 +167,6 @@ Thank you for your contribution! To add a new template, go to `https://github.com/Dokploy/templates` repository and read the README.md file. - ### Recommendations - Use the same name of the folder as the id of the template. diff --git a/Dockerfile b/Dockerfile index ad2239b0..a9b5f951 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,7 +49,7 @@ RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh && rm # Install Nixpacks and tsx # | VERBOSE=1 VERSION=1.21.0 bash -ARG NIXPACKS_VERSION=1.29.1 +ARG NIXPACKS_VERSION=1.35.0 RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \ && chmod +x install.sh \ && ./install.sh \ diff --git a/apps/dokploy/__test__/templates/config.template.test.ts b/apps/dokploy/__test__/templates/config.template.test.ts index d6e87cb7..202abdf2 100644 --- a/apps/dokploy/__test__/templates/config.template.test.ts +++ b/apps/dokploy/__test__/templates/config.template.test.ts @@ -51,6 +51,35 @@ describe("processTemplate", () => { expect(result.domains).toHaveLength(0); expect(result.mounts).toHaveLength(0); }); + + it("should allow creation of real jwt secret", () => { + const template: CompleteTemplate = { + metadata: {} as any, + variables: { + jwt_secret: "cQsdycq1hDLopQonF6jUTqgQc5WEZTwWLL02J6XJ", + anon_payload: JSON.stringify({ + role: "tester", + iss: "dockploy", + iat: "${timestamps:2025-01-01T00:00:00Z}", + exp: "${timestamps:2030-01-01T00:00:00Z}", + }), + anon_key: "${jwt:jwt_secret:anon_payload}", + }, + config: { + domains: [], + env: { + ANON_KEY: "${anon_key}", + }, + }, + }; + const result = processTemplate(template, mockSchema); + expect(result.envs).toHaveLength(1); + expect(result.envs).toContain( + "ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOiIxNzM1Njg5NjAwIiwiZXhwIjoiMTg5MzQ1NjAwMCIsInJvbGUiOiJ0ZXN0ZXIiLCJpc3MiOiJkb2NrcGxveSJ9.BG5JoxL2_NaTFbPgyZdm3kRWenf_O3su_HIRKGCJ_kY", + ); + expect(result.mounts).toHaveLength(0); + expect(result.domains).toHaveLength(0); + }); }); describe("domains processing", () => { diff --git a/apps/dokploy/__test__/templates/helpers.template.test.ts b/apps/dokploy/__test__/templates/helpers.template.test.ts new file mode 100644 index 00000000..1144b65f --- /dev/null +++ b/apps/dokploy/__test__/templates/helpers.template.test.ts @@ -0,0 +1,232 @@ +import type { Schema } from "@dokploy/server/templates"; +import { processValue } from "@dokploy/server/templates/processors"; +import { describe, expect, it } from "vitest"; + +describe("helpers functions", () => { + // Mock schema for testing + const mockSchema: Schema = { + projectName: "test", + serverIp: "127.0.0.1", + }; + // some helpers to test jwt + type JWTParts = [string, string, string]; + const jwtMatchExp = /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/; + const jwtBase64Decode = (str: string) => { + const base64 = str.replace(/-/g, "+").replace(/_/g, "/"); + const padding = "=".repeat((4 - (base64.length % 4)) % 4); + const decoded = Buffer.from(base64 + padding, "base64").toString("utf-8"); + return JSON.parse(decoded); + }; + const jwtCheckHeader = (jwtHeader: string) => { + const decodedHeader = jwtBase64Decode(jwtHeader); + expect(decodedHeader).toHaveProperty("alg"); + expect(decodedHeader).toHaveProperty("typ"); + expect(decodedHeader.alg).toEqual("HS256"); + expect(decodedHeader.typ).toEqual("JWT"); + }; + + describe("${domain}", () => { + it("should generate a random domain", () => { + const domain = processValue("${domain}", {}, mockSchema); + expect(domain.startsWith(`${mockSchema.projectName}-`)).toBeTruthy(); + expect( + domain.endsWith( + `${mockSchema.serverIp.replaceAll(".", "-")}.traefik.me`, + ), + ).toBeTruthy(); + }); + }); + + describe("${base64}", () => { + it("should generate a base64 string", () => { + const base64 = processValue("${base64}", {}, mockSchema); + expect(base64).toMatch(/^[A-Za-z0-9+=/]+={0,2}$/); + }); + it.each([ + [4, 8], + [8, 12], + [16, 24], + [32, 44], + [64, 88], + [128, 172], + ])( + "should generate a base64 string from parameter %d bytes length", + (length, finalLength) => { + const base64 = processValue(`\${base64:${length}}`, {}, mockSchema); + expect(base64).toMatch(/^[A-Za-z0-9+=/]+={0,2}$/); + expect(base64.length).toBe(finalLength); + }, + ); + }); + + describe("${password}", () => { + it("should generate a password string", () => { + const password = processValue("${password}", {}, mockSchema); + expect(password).toMatch(/^[A-Za-z0-9]+$/); + }); + it.each([6, 8, 12, 16, 32])( + "should generate a password string respecting parameter %d length", + (length) => { + const password = processValue(`\${password:${length}}`, {}, mockSchema); + expect(password).toMatch(/^[A-Za-z0-9]+$/); + expect(password.length).toBe(length); + }, + ); + }); + + describe("${hash}", () => { + it("should generate a hash string", () => { + const hash = processValue("${hash}", {}, mockSchema); + expect(hash).toMatch(/^[A-Za-z0-9]+$/); + }); + it.each([6, 8, 12, 16, 32])( + "should generate a hash string respecting parameter %d length", + (length) => { + const hash = processValue(`\${hash:${length}}`, {}, mockSchema); + expect(hash).toMatch(/^[A-Za-z0-9]+$/); + expect(hash.length).toBe(length); + }, + ); + }); + + describe("${uuid}", () => { + it("should generate a UUID string", () => { + const uuid = processValue("${uuid}", {}, mockSchema); + expect(uuid).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + }); + }); + + describe("${timestamp}", () => { + it("should generate a timestamp string in milliseconds", () => { + const timestamp = processValue("${timestamp}", {}, mockSchema); + const nowLength = Math.floor(Date.now()).toString().length; + expect(timestamp).toMatch(/^\d+$/); + expect(timestamp.length).toBe(nowLength); + }); + }); + describe("${timestampms}", () => { + it("should generate a timestamp string in milliseconds", () => { + const timestamp = processValue("${timestampms}", {}, mockSchema); + const nowLength = Date.now().toString().length; + expect(timestamp).toMatch(/^\d+$/); + expect(timestamp.length).toBe(nowLength); + }); + it("should generate a timestamp string in milliseconds from parameter", () => { + const timestamp = processValue( + "${timestampms:2025-01-01}", + {}, + mockSchema, + ); + expect(timestamp).toEqual("1735689600000"); + }); + }); + describe("${timestamps}", () => { + it("should generate a timestamp string in seconds", () => { + const timestamps = processValue("${timestamps}", {}, mockSchema); + const nowLength = Math.floor(Date.now() / 1000).toString().length; + expect(timestamps).toMatch(/^\d+$/); + expect(timestamps.length).toBe(nowLength); + }); + it("should generate a timestamp string in seconds from parameter", () => { + const timestamps = processValue( + "${timestamps:2025-01-01}", + {}, + mockSchema, + ); + expect(timestamps).toEqual("1735689600"); + }); + }); + + describe("${randomPort}", () => { + it("should generate a random port string", () => { + const randomPort = processValue("${randomPort}", {}, mockSchema); + expect(randomPort).toMatch(/^\d+$/); + expect(Number(randomPort)).toBeLessThan(65536); + }); + }); + + describe("${username}", () => { + it("should generate a username string", () => { + const username = processValue("${username}", {}, mockSchema); + expect(username).toMatch(/^[a-zA-Z0-9._-]{3,}$/); + }); + }); + + describe("${email}", () => { + it("should generate an email string", () => { + const email = processValue("${email}", {}, mockSchema); + expect(email).toMatch(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/); + }); + }); + + describe("${jwt}", () => { + it("should generate a JWT string", () => { + const jwt = processValue("${jwt}", {}, mockSchema); + expect(jwt).toMatch(jwtMatchExp); + const parts = jwt.split(".") as JWTParts; + const decodedPayload = jwtBase64Decode(parts[1]); + jwtCheckHeader(parts[0]); + expect(decodedPayload).toHaveProperty("iat"); + expect(decodedPayload).toHaveProperty("iss"); + expect(decodedPayload).toHaveProperty("exp"); + expect(decodedPayload.iss).toEqual("dokploy"); + }); + it.each([6, 8, 12, 16, 32])( + "should generate a random hex string from parameter %d byte length", + (length) => { + const jwt = processValue(`\${jwt:${length}}`, {}, mockSchema); + expect(jwt).toMatch(/^[A-Za-z0-9-_.]+$/); + expect(jwt.length).toBeGreaterThanOrEqual(length); // bytes translated to hex can take up to 2x the length + expect(jwt.length).toBeLessThanOrEqual(length * 2); + }, + ); + }); + describe("${jwt:secret}", () => { + it("should generate a JWT string respecting parameter secret from variable", () => { + const jwt = processValue( + "${jwt:secret}", + { secret: "mysecret" }, + mockSchema, + ); + expect(jwt).toMatch(jwtMatchExp); + const parts = jwt.split(".") as JWTParts; + const decodedPayload = jwtBase64Decode(parts[1]); + jwtCheckHeader(parts[0]); + expect(decodedPayload).toHaveProperty("iat"); + expect(decodedPayload).toHaveProperty("iss"); + expect(decodedPayload).toHaveProperty("exp"); + expect(decodedPayload.iss).toEqual("dokploy"); + }); + }); + describe("${jwt:secret:payload}", () => { + it("should generate a JWT string respecting parameters secret and payload from variables", () => { + const iat = Math.floor(new Date("2025-01-01T00:00:00Z").getTime() / 1000); + const expiry = iat + 3600; + const jwt = processValue( + "${jwt:secret:payload}", + { + secret: "mysecret", + payload: `{"iss": "test-issuer", "iat": ${iat}, "exp": ${expiry}, "customprop": "customvalue"}`, + }, + mockSchema, + ); + expect(jwt).toMatch(jwtMatchExp); + const parts = jwt.split(".") as JWTParts; + jwtCheckHeader(parts[0]); + const decodedPayload = jwtBase64Decode(parts[1]); + expect(decodedPayload).toHaveProperty("iat"); + expect(decodedPayload.iat).toEqual(iat); + expect(decodedPayload).toHaveProperty("iss"); + expect(decodedPayload.iss).toEqual("test-issuer"); + expect(decodedPayload).toHaveProperty("exp"); + expect(decodedPayload.exp).toEqual(expiry); + expect(decodedPayload).toHaveProperty("customprop"); + expect(decodedPayload.customprop).toEqual("customvalue"); + expect(jwt).toEqual( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTczNTY5MzIwMCwiaXNzIjoidGVzdC1pc3N1ZXIiLCJjdXN0b21wcm9wIjoiY3VzdG9tdmFsdWUifQ.m42U7PZSUSCf7gBOJrxJir0rQmyPq4rA59Dydr_QahI", + ); + }); + }); +}); diff --git a/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx b/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx index bbcbfd83..e582d266 100644 --- a/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx +++ b/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx @@ -79,6 +79,22 @@ export const ComposeFileEditor = ({ composeId }: Props) => { toast.error("Error updating the Compose config"); }); }; + + // Add keyboard shortcut for Ctrl+S/Cmd+S + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 's' && !isLoading) { + e.preventDefault(); + form.handleSubmit(onSubmit)(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [form, onSubmit, isLoading]); + return ( <>
diff --git a/apps/dokploy/components/layouts/user-nav.tsx b/apps/dokploy/components/layouts/user-nav.tsx index 0aca5b00..05c601f6 100644 --- a/apps/dokploy/components/layouts/user-nav.tsx +++ b/apps/dokploy/components/layouts/user-nav.tsx @@ -133,17 +133,6 @@ export const UserNav = () => { Servers )} - - {data?.role === "owner" && ( - { - router.push("/dashboard/settings"); - }} - > - Settings - - )} )} diff --git a/packages/server/src/lib/auth.ts b/packages/server/src/lib/auth.ts index 7ff53117..dbe8842d 100644 --- a/packages/server/src/lib/auth.ts +++ b/packages/server/src/lib/auth.ts @@ -201,7 +201,7 @@ const { handler, api } = betterAuth({ const host = process.env.NODE_ENV === "development" ? "http://localhost:3000" - : "https://dokploy.com"; + : "https://app.dokploy.com"; const inviteLink = `${host}/invitation?token=${data.id}`; await sendEmail({ diff --git a/packages/server/src/setup/server-setup.ts b/packages/server/src/setup/server-setup.ts index 6fefabe9..bb0d34d8 100644 --- a/packages/server/src/setup/server-setup.ts +++ b/packages/server/src/setup/server-setup.ts @@ -76,7 +76,7 @@ CURRENT_USER=$USER echo "Installing requirements for: OS: $OS_TYPE" if [ $EUID != 0 ]; then - echo "Please run this script as root or with sudo ❌" + echo "Please run this script as root or with sudo ❌" exit fi @@ -263,7 +263,7 @@ const setupMainDirectory = () => ` # Create the /etc/dokploy directory mkdir -p /etc/dokploy chmod 777 /etc/dokploy - + echo "Directory /etc/dokploy created ✅" fi `; @@ -276,16 +276,16 @@ export const setupSwarm = () => ` # Get IP address get_ip() { local ip="" - + # Try IPv4 with multiple services # First attempt: ifconfig.io ip=\$(curl -4s --connect-timeout 5 https://ifconfig.io 2>/dev/null) - + # Second attempt: icanhazip.com if [ -z "\$ip" ]; then ip=\$(curl -4s --connect-timeout 5 https://icanhazip.com 2>/dev/null) fi - + # Third attempt: ipecho.net if [ -z "\$ip" ]; then ip=\$(curl -4s --connect-timeout 5 https://ipecho.net/plain 2>/dev/null) @@ -295,12 +295,12 @@ export const setupSwarm = () => ` if [ -z "\$ip" ]; then # Try IPv6 with ifconfig.io ip=\$(curl -6s --connect-timeout 5 https://ifconfig.io 2>/dev/null) - + # Try IPv6 with icanhazip.com if [ -z "\$ip" ]; then ip=\$(curl -6s --connect-timeout 5 https://icanhazip.com 2>/dev/null) fi - + # Try IPv6 with ipecho.net if [ -z "\$ip" ]; then ip=\$(curl -6s --connect-timeout 5 https://ipecho.net/plain 2>/dev/null) @@ -549,7 +549,7 @@ export const createTraefikInstance = () => { sleep 8 echo "Traefik migrated to Standalone ✅" fi - + if docker inspect dokploy-traefik > /dev/null 2>&1; then echo "Traefik already exists ✅" else @@ -577,7 +577,7 @@ const installNixpacks = () => ` if command_exists nixpacks; then echo "Nixpacks already installed ✅" else - export NIXPACKS_VERSION=1.29.1 + export NIXPACKS_VERSION=1.35.0 bash -c "$(curl -fsSL https://nixpacks.com/install.sh)" echo "Nixpacks version $NIXPACKS_VERSION installed ✅" fi diff --git a/packages/server/src/templates/index.ts b/packages/server/src/templates/index.ts index 6ae26418..c42dd1b7 100644 --- a/packages/server/src/templates/index.ts +++ b/packages/server/src/templates/index.ts @@ -1,4 +1,4 @@ -import { randomBytes } from "node:crypto"; +import { randomBytes, createHmac } from "node:crypto"; import { existsSync } from "node:fs"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -24,6 +24,12 @@ export interface Template { domains: DomainSchema[]; } +export interface GenerateJWTOptions { + length?: number; + secret?: string; + payload?: Record | undefined; +} + export const generateRandomDomain = ({ serverIp, projectName, @@ -61,8 +67,48 @@ export function generateBase64(bytes = 32): string { return randomBytes(bytes).toString("base64"); } -export function generateJwt(length = 256): string { - return randomBytes(length).toString("hex"); +function safeBase64(str: string): string { + return str.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); +} +function objToJWTBase64(obj: any): string { + return safeBase64( + Buffer.from(JSON.stringify(obj), "utf8").toString("base64"), + ); +} + +export function generateJwt(options: GenerateJWTOptions = {}): string { + let { length, secret, payload = {} } = options; + if (length) { + return randomBytes(length).toString("hex"); + } + const encodedHeader = objToJWTBase64({ + alg: "HS256", + typ: "JWT", + }); + if (!payload.iss) { + payload.iss = "dokploy"; + } + if (!payload.iat) { + payload.iat = Math.floor(Date.now() / 1000); + } + if (!payload.exp) { + payload.exp = Math.floor(new Date("2030-01-01T00:00:00Z").getTime() / 1000); + } + const encodedPayload = objToJWTBase64({ + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(new Date("2030-01-01T00:00:00Z").getTime() / 1000), + ...payload, + }); + if (!secret) { + secret = randomBytes(32).toString("hex"); + } + const signature = safeBase64( + createHmac("SHA256", secret) + .update(`${encodedHeader}.${encodedPayload}`) + .digest("base64"), + ); + + return `${encodedHeader}.${encodedPayload}.${signature}`; } /** diff --git a/packages/server/src/templates/processors.ts b/packages/server/src/templates/processors.ts index 31e7861a..5d9270aa 100644 --- a/packages/server/src/templates/processors.ts +++ b/packages/server/src/templates/processors.ts @@ -65,7 +65,7 @@ export interface Template { /** * Process a string value and replace variables */ -function processValue( +export function processValue( value: string, variables: Record, schema: Schema, @@ -84,11 +84,11 @@ function processValue( const length = Number.parseInt(varName.split(":")[1], 10) || 32; return generateBase64(length); } + if (varName.startsWith("password:")) { const length = Number.parseInt(varName.split(":")[1], 10) || 16; return generatePassword(length); } - if (varName === "password") { return generatePassword(16); } @@ -97,14 +97,31 @@ function processValue( const length = Number.parseInt(varName.split(":")[1], 10) || 8; return generateHash(length); } + if (varName === "hash") { + return generateHash(); + } + if (varName === "uuid") { return crypto.randomUUID(); } - if (varName === "timestamp") { + if (varName === "timestamp" || varName === "timestampms") { return Date.now().toString(); } + if (varName === "timestamps") { + return Math.round(Date.now() / 1000).toString(); + } + + if (varName.startsWith("timestampms:")) { + return new Date(varName.slice(12)).getTime().toString(); + } + if (varName.startsWith("timestamps:")) { + return Math.round( + new Date(varName.slice(11)).getTime() / 1000, + ).toString(); + } + if (varName === "randomPort") { return Math.floor(Math.random() * 65535).toString(); } @@ -114,8 +131,34 @@ function processValue( } if (varName.startsWith("jwt:")) { - const length = Number.parseInt(varName.split(":")[1], 10) || 256; - return generateJwt(length); + const params: string[] = varName.split(":").slice(1); + if (params.length === 1 && params[0] && params[0].match(/^\d{1,3}$/)) { + return generateJwt({ length: Number.parseInt(params[0], 10) }); + } + let [secret, payload] = params; + if (typeof payload === "string" && variables[payload]) { + payload = variables[payload]; + } + if ( + typeof payload === "string" && + payload.startsWith("{") && + payload.endsWith("}") + ) { + try { + payload = JSON.parse(payload); + } catch (e) { + // If payload is not a valid JSON, invalid it + payload = undefined; + console.error("Invalid JWT payload", e); + } + } + if (typeof payload !== "object") { + payload = undefined; + } + return generateJwt({ + secret: secret ? variables[secret] || secret : undefined, + payload: payload as any, + }); } if (varName === "username") { @@ -147,7 +190,7 @@ export function processVariables( ): Record { const variables: Record = {}; - // First pass: Process variables that don't depend on other variables + // First pass: Process some variables that don't depend on other variables for (const [key, value] of Object.entries(template.variables)) { if (typeof value !== "string") continue; @@ -161,6 +204,8 @@ export function processVariables( const match = value.match(/\${password:(\d+)}/); const length = match?.[1] ? Number.parseInt(match[1], 10) : 16; variables[key] = generatePassword(length); + } else if (value === "${hash}") { + variables[key] = generateHash(); } else if (value.startsWith("${hash:")) { const match = value.match(/\${hash:(\d+)}/); const length = match?.[1] ? Number.parseInt(match[1], 10) : 8;