From bc17991580b195fa044e0c680f501577a319d02d Mon Sep 17 00:00:00 2001 From: Jonathan Gotti Date: Mon, 21 Apr 2025 15:53:38 +0200 Subject: [PATCH 1/7] test: Add some template helpers test --- .../templates/helpers.template.test.ts | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 apps/dokploy/__test__/templates/helpers.template.test.ts 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..d6eb532c --- /dev/null +++ b/apps/dokploy/__test__/templates/helpers.template.test.ts @@ -0,0 +1,197 @@ +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") + }); + }); +}); \ No newline at end of file From 11b9cee73dbafa70b2eacd80f857be0d9452d883 Mon Sep 17 00:00:00 2001 From: Jonathan Gotti Date: Mon, 21 Apr 2025 15:59:49 +0200 Subject: [PATCH 2/7] feat(template-helpers): Add more parameters to jwt helper - jwt without parameter now generate a real jwt - keep length parameter as is for backward compatibility - add secret and payload parameters - payload properties iss, iat, exp are automaticly set if not provided --- packages/server/src/templates/index.ts | 47 ++++++++++++++++++--- packages/server/src/templates/processors.ts | 32 +++++++++++--- 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/packages/server/src/templates/index.ts b/packages/server/src/templates/index.ts index 6ae26418..0d0f87ce 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"; @@ -9,7 +9,7 @@ import { fetchTemplateFiles } from "./github"; export interface Schema { serverIp: string; projectName: string; -} +}; export type DomainSchema = Pick & { path?: string; @@ -22,6 +22,12 @@ export interface Template { content: string; }>; domains: DomainSchema[]; +}; + +export interface GenerateJWTOptions { + length?: number; + secret?: string; + payload?: Record | undefined; } export const generateRandomDomain = ({ @@ -59,10 +65,41 @@ export const generatePassword = (quantity = 16): string => { */ 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", + }); + payload.iss || (payload.iss = "dokploy"); + payload.iat || (payload.iat = Math.floor(Date.now() / 1000)); + 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, + }); + 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..e59cddf5 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); } @@ -114,8 +114,30 @@ 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 +169,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; From 2b5af1897f1381b7f854d8b9f769a88be6bb383c Mon Sep 17 00:00:00 2001 From: Jonathan Gotti Date: Mon, 21 Apr 2025 16:04:00 +0200 Subject: [PATCH 3/7] fix(template-helpers): hash not working without parameter --- packages/server/src/templates/processors.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/server/src/templates/processors.ts b/packages/server/src/templates/processors.ts index e59cddf5..fb463b63 100644 --- a/packages/server/src/templates/processors.ts +++ b/packages/server/src/templates/processors.ts @@ -97,6 +97,10 @@ export 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(); } @@ -183,6 +187,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; From d0dbc1837fff66088ae5d8181fd76252c1ca65c5 Mon Sep 17 00:00:00 2001 From: Jonathan Gotti Date: Mon, 21 Apr 2025 16:05:08 +0200 Subject: [PATCH 4/7] feat(template-helpers): Add timestamps and timestampms helpers --- packages/server/src/templates/processors.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/server/src/templates/processors.ts b/packages/server/src/templates/processors.ts index fb463b63..ff8fe277 100644 --- a/packages/server/src/templates/processors.ts +++ b/packages/server/src/templates/processors.ts @@ -105,10 +105,21 @@ export function processValue( 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(); } From e6d0b7b4eebd351599560032a6bd4980b24e99d7 Mon Sep 17 00:00:00 2001 From: Jonathan Gotti Date: Mon, 21 Apr 2025 16:12:34 +0200 Subject: [PATCH 5/7] test(templates): Add test for jwt generation --- .../templates/config.template.test.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/apps/dokploy/__test__/templates/config.template.test.ts b/apps/dokploy/__test__/templates/config.template.test.ts index d6e87cb7..6f5baaf1 100644 --- a/apps/dokploy/__test__/templates/config.template.test.ts +++ b/apps/dokploy/__test__/templates/config.template.test.ts @@ -51,6 +51,33 @@ 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", () => { From c3986d7a080628eb036b361999f3d2698437ee95 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 26 Apr 2025 07:40:07 +0000 Subject: [PATCH 6/7] [autofix.ci] apply automated fixes --- .../templates/config.template.test.ts | 12 +- .../templates/helpers.template.test.ts | 121 +++++++++++------- packages/server/src/templates/index.ts | 32 +++-- packages/server/src/templates/processors.ts | 20 ++- 4 files changed, 116 insertions(+), 69 deletions(-) diff --git a/apps/dokploy/__test__/templates/config.template.test.ts b/apps/dokploy/__test__/templates/config.template.test.ts index 6f5baaf1..202abdf2 100644 --- a/apps/dokploy/__test__/templates/config.template.test.ts +++ b/apps/dokploy/__test__/templates/config.template.test.ts @@ -58,10 +58,10 @@ describe("processTemplate", () => { 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}", + 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}", }, @@ -74,7 +74,9 @@ describe("processTemplate", () => { }; 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.envs).toContain( + "ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOiIxNzM1Njg5NjAwIiwiZXhwIjoiMTg5MzQ1NjAwMCIsInJvbGUiOiJ0ZXN0ZXIiLCJpc3MiOiJkb2NrcGxveSJ9.BG5JoxL2_NaTFbPgyZdm3kRWenf_O3su_HIRKGCJ_kY", + ); expect(result.mounts).toHaveLength(0); expect(result.domains).toHaveLength(0); }); diff --git a/apps/dokploy/__test__/templates/helpers.template.test.ts b/apps/dokploy/__test__/templates/helpers.template.test.ts index d6eb532c..1144b65f 100644 --- a/apps/dokploy/__test__/templates/helpers.template.test.ts +++ b/apps/dokploy/__test__/templates/helpers.template.test.ts @@ -2,7 +2,6 @@ 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 = { @@ -30,7 +29,11 @@ describe("helpers functions", () => { 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(); + expect( + domain.endsWith( + `${mockSchema.serverIp.replaceAll(".", "-")}.traefik.me`, + ), + ).toBeTruthy(); }); }); @@ -46,11 +49,14 @@ describe("helpers functions", () => { [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); - }); + ])( + "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}", () => { @@ -58,11 +64,14 @@ describe("helpers functions", () => { 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); - }); + 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}", () => { @@ -70,17 +79,22 @@ describe("helpers functions", () => { 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); - }); + 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}$/); + 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}$/, + ); }); }); @@ -100,8 +114,12 @@ describe("helpers functions", () => { 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'); + const timestamp = processValue( + "${timestampms:2025-01-01}", + {}, + mockSchema, + ); + expect(timestamp).toEqual("1735689600000"); }); }); describe("${timestamps}", () => { @@ -112,8 +130,12 @@ describe("helpers functions", () => { 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'); + const timestamps = processValue( + "${timestamps:2025-01-01}", + {}, + mockSchema, + ); + expect(timestamps).toEqual("1735689600"); }); }); @@ -146,39 +168,50 @@ describe("helpers functions", () => { 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); + 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); + 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"); + 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); + 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]); @@ -191,7 +224,9 @@ describe("helpers functions", () => { expect(decodedPayload.exp).toEqual(expiry); expect(decodedPayload).toHaveProperty("customprop"); expect(decodedPayload.customprop).toEqual("customvalue"); - expect(jwt).toEqual("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTczNTY5MzIwMCwiaXNzIjoidGVzdC1pc3N1ZXIiLCJjdXN0b21wcm9wIjoiY3VzdG9tdmFsdWUifQ.m42U7PZSUSCf7gBOJrxJir0rQmyPq4rA59Dydr_QahI") + expect(jwt).toEqual( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTczNTY5MzIwMCwiaXNzIjoidGVzdC1pc3N1ZXIiLCJjdXN0b21wcm9wIjoiY3VzdG9tdmFsdWUifQ.m42U7PZSUSCf7gBOJrxJir0rQmyPq4rA59Dydr_QahI", + ); }); }); -}); \ No newline at end of file +}); diff --git a/packages/server/src/templates/index.ts b/packages/server/src/templates/index.ts index 0d0f87ce..083b90bf 100644 --- a/packages/server/src/templates/index.ts +++ b/packages/server/src/templates/index.ts @@ -9,7 +9,7 @@ import { fetchTemplateFiles } from "./github"; export interface Schema { serverIp: string; projectName: string; -}; +} export type DomainSchema = Pick & { path?: string; @@ -22,7 +22,7 @@ export interface Template { content: string; }>; domains: DomainSchema[]; -}; +} export interface GenerateJWTOptions { length?: number; @@ -65,17 +65,16 @@ export const generatePassword = (quantity = 16): string => { */ export function generateBase64(bytes = 32): string { return randomBytes(bytes).toString("base64"); -}; +} function safeBase64(str: string): string { - return str - .replace(/=/g, "") - .replace(/\+/g, "-") - .replace(/\//g, "_"); -}; + return str.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); +} function objToJWTBase64(obj: any): string { - return safeBase64(Buffer.from(JSON.stringify(obj), "utf8").toString("base64")); -}; + return safeBase64( + Buffer.from(JSON.stringify(obj), "utf8").toString("base64"), + ); +} export function generateJwt(options: GenerateJWTOptions = {}): string { let { length, secret, payload = {} } = options; @@ -88,16 +87,21 @@ export function generateJwt(options: GenerateJWTOptions = {}): string { }); payload.iss || (payload.iss = "dokploy"); payload.iat || (payload.iat = Math.floor(Date.now() / 1000)); - payload.exp || (payload.exp = Math.floor(new Date("2030-01-01T00:00:00Z").getTime() / 1000)); + 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, }); secret || (secret = randomBytes(32).toString("hex")); - const signature = safeBase64(createHmac("SHA256", secret) - .update(`${encodedHeader}.${encodedPayload}`) - .digest("base64")); + 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 ff8fe277..5d9270aa 100644 --- a/packages/server/src/templates/processors.ts +++ b/packages/server/src/templates/processors.ts @@ -117,7 +117,9 @@ export function processValue( return new Date(varName.slice(12)).getTime().toString(); } if (varName.startsWith("timestamps:")) { - return Math.round(new Date(varName.slice(11)).getTime() / 1000).toString(); + return Math.round( + new Date(varName.slice(11)).getTime() / 1000, + ).toString(); } if (varName === "randomPort") { @@ -129,15 +131,19 @@ export function processValue( } if (varName.startsWith("jwt:")) { - const params:string[] = varName.split(":").slice(1); + 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)}); + 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("}")) { + if ( + typeof payload === "string" && + payload.startsWith("{") && + payload.endsWith("}") + ) { try { payload = JSON.parse(payload); } catch (e) { @@ -146,12 +152,12 @@ export function processValue( console.error("Invalid JWT payload", e); } } - if (typeof payload !== 'object') { + if (typeof payload !== "object") { payload = undefined; } return generateJwt({ - secret: secret ? (variables[secret] || secret) : undefined, - payload: payload as any + secret: secret ? variables[secret] || secret : undefined, + payload: payload as any, }); } From f49a67f8dfc4b0c3050b0ee7e5eecb1e878332c2 Mon Sep 17 00:00:00 2001 From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Date: Sat, 26 Apr 2025 01:50:26 -0600 Subject: [PATCH 7/7] refactor(jwt generation): Simplify payload property assignments and secret initialization --- packages/server/src/templates/index.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/server/src/templates/index.ts b/packages/server/src/templates/index.ts index 083b90bf..c42dd1b7 100644 --- a/packages/server/src/templates/index.ts +++ b/packages/server/src/templates/index.ts @@ -85,18 +85,23 @@ export function generateJwt(options: GenerateJWTOptions = {}): string { alg: "HS256", typ: "JWT", }); - payload.iss || (payload.iss = "dokploy"); - payload.iat || (payload.iat = Math.floor(Date.now() / 1000)); - payload.exp || - (payload.exp = Math.floor( - new Date("2030-01-01T00:00:00Z").getTime() / 1000, - )); + 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, }); - secret || (secret = randomBytes(32).toString("hex")); + if (!secret) { + secret = randomBytes(32).toString("hex"); + } const signature = safeBase64( createHmac("SHA256", secret) .update(`${encodedHeader}.${encodedPayload}`)