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/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;