Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
9f10f0f
fix(migrate-auth-secret): exit cleanly when there are no 2FA records
ngenohkevin May 12, 2026
a714e0f
Merge pull request #4394 from ngenohkevin/fix/migrate-auth-secret-exi…
Siumauricio May 12, 2026
754774e
feat(compose): add import from base64 in create service dropdown
Siumauricio May 12, 2026
63e33a2
[autofix.ci] apply automated fixes
autofix-ci[bot] May 12, 2026
7a568aa
Merge pull request #4395 from Dokploy/feat/import-compose-from-base64
Siumauricio May 12, 2026
f8fcf68
Enhance version synchronization workflow to include SDK repository
Siumauricio May 12, 2026
558d809
feat(deployment): add readLogs procedure to fetch deployment logs
Siumauricio May 13, 2026
aff200f
feat(deployment): add server access validation for deployment actions
Siumauricio May 13, 2026
67278d8
feat(organization): prevent inviting users with owner role
Siumauricio May 13, 2026
1fdbe87
feat(user): implement session cleanup on user update
Siumauricio May 13, 2026
a50f958
feat(settings): add copy button to server IP in web server settings (…
Siumauricio May 13, 2026
8d88a34
fix: copy Dokploy server IP when clicking server badge (#4390)
vadamk May 13, 2026
ef0cf9b
fix: responsive layout (#4391)
nhridoy May 13, 2026
6e342ee
fix: automatically converting username to lowercase both in creation …
Baker May 13, 2026
af8072d
fix: allow square brackets in zip path validation for Next.js dynamic…
Siumauricio May 22, 2026
b06138b
fix: prevent webhook deploy crash when commit data lacks modified fil…
Siumauricio May 22, 2026
f6e6e5c
fix: add type="button" to TooltipTrigger in form components to preven…
mixelburg May 22, 2026
34d38cf
fix: enable comment toggle shortcut in env variable editor (#4402) (#…
Siumauricio May 22, 2026
103e2f7
fix: add tls=true label for domains when certificateType is none (#40…
Siumauricio May 22, 2026
2f43f60
chore: update version to v0.29.5 in package.json
Siumauricio May 22, 2026
6675aa6
chore(deps): upgrade next to 16.2.6 (#4477)
jasael May 24, 2026
8018027
feat: add self-hosted enterprise restrictions (remote-servers-only, e…
Siumauricio May 30, 2026
4ba0f71
fix: grant create and delete SSH key permissions when canAccessToSSHK…
Siumauricio May 30, 2026
d7d6422
fix: use create permission for basic auth delete instead of delete (#…
Siumauricio May 30, 2026
ad680ae
fix: wrap long server names and keep actions menu visible (#4434)
pparage May 30, 2026
9bd4451
chore: update version to v0.29.6 in package.json
Siumauricio May 30, 2026
85211af
fix: preserve HOME in compose deploy so --with-registry-auth can read…
youcefzemmar May 30, 2026
d56a17c
Merge branch 'main' into canary
Siumauricio May 30, 2026
6ff2ca0
fix: scope dokploy-server schedules to organization instead of user (…
Siumauricio May 31, 2026
6b15591
feat(cloudflare): add Cloudflare integration credentials and settings
maxsam4 May 30, 2026
817d66b
feat(cloudflare): native Cloudflare Tunnel domain publishing
maxsam4 May 30, 2026
698c3a6
feat(cloudflare): Cloudflare Access (zero-trust) for published domains
maxsam4 May 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { cloudflareAccessRouter } from "@/server/api/routers/cloudflare-access";
import { createCallerFactory } from "@/server/api/trpc";

/**
* Cloudflare Access configuration changes org-wide auth on a published domain,
* so every procedure is admin-gated. A `member` must never reach them.
*/
const createCaller = createCallerFactory(cloudflareAccessRouter);

const ctxFor = (role: "owner" | "admin" | "member") =>
({
user: { id: "user-1", email: "user@test.com", role },
session: { activeOrganizationId: "org-1" },
req: {} as unknown,
res: {} as unknown,
}) as never;

describe("cloudflareAccess router authorization", () => {
const caller = createCaller(ctxFor("member"));

it("member cannot read Access config", async () => {
await expect(
caller.byDomainId({ domainId: "dom-1" }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});

it("member cannot enable/update Access", async () => {
await expect(
caller.upsert({
domainId: "dom-1",
sessionDuration: "24h",
allowEmails: ["a@example.com"],
allowEmailDomains: [],
}),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});

it("member cannot remove Access", async () => {
await expect(caller.remove({ domainId: "dom-1" })).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
});
});
117 changes: 117 additions & 0 deletions apps/dokploy/__test__/cloudflare/cloudflare-access-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import {
buildAccessIncludeRules,
createAccessApplication,
createAccessPolicy,
} from "@dokploy/server/utils/providers/cloudflare";
import { afterEach, describe, expect, it, vi } from "vitest";

const fakeResponse = (ok: boolean, status: number, body: unknown) =>
({ ok, status, json: async () => body }) as unknown as Response;

const stubFetch = (response: Response) => {
const fetchMock = vi.fn().mockResolvedValue(response);
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
};

afterEach(() => {
vi.unstubAllGlobals();
});

describe("buildAccessIncludeRules", () => {
it("maps emails and email domains to Cloudflare include rules", () => {
const rules = buildAccessIncludeRules(
["a@example.com", "b@example.com"],
["example.org"],
);
expect(rules).toEqual([
{ email: { email: "a@example.com" } },
{ email: { email: "b@example.com" } },
{ email_domain: { domain: "example.org" } },
]);
});

it("returns an empty array when there are no allow rules", () => {
expect(buildAccessIncludeRules([], [])).toEqual([]);
});
});

describe("createAccessApplication", () => {
it("posts a self_hosted application bound to the host", async () => {
const fetchMock = stubFetch(
fakeResponse(true, 200, {
success: true,
errors: [],
messages: [],
result: { id: "app-1", name: "app", domain: "app.example.com" },
}),
);
const app = await createAccessApplication("token", "acct-1", {
name: "app",
domain: "app.example.com",
sessionDuration: "24h",
});
expect(app.id).toBe("app-1");
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(
"https://api.cloudflare.com/client/v4/accounts/acct-1/access/apps",
);
expect(init.method).toBe("POST");
expect(JSON.parse(init.body as string)).toEqual({
name: "app",
type: "self_hosted",
domain: "app.example.com",
session_duration: "24h",
});
});

it("defaults the session duration to 24h", async () => {
const fetchMock = stubFetch(
fakeResponse(true, 200, {
success: true,
errors: [],
messages: [],
result: { id: "app-1", name: "app", domain: "app.example.com" },
}),
);
await createAccessApplication("token", "acct-1", {
name: "app",
domain: "app.example.com",
});
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(JSON.parse(init.body as string).session_duration).toBe("24h");
});
});

describe("createAccessPolicy", () => {
it("posts an app-scoped allow policy with include rules", async () => {
const fetchMock = stubFetch(
fakeResponse(true, 200, {
success: true,
errors: [],
messages: [],
result: { id: "pol-1", name: "allow", decision: "allow" },
}),
);
const include = buildAccessIncludeRules(["a@example.com"], ["example.org"]);
const policy = await createAccessPolicy("token", "acct-1", "app-1", {
name: "Dokploy allow policy",
include,
});
expect(policy.id).toBe("pol-1");
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
// App-scoped endpoint, NOT the detached /access/policies endpoint.
expect(url).toBe(
"https://api.cloudflare.com/client/v4/accounts/acct-1/access/apps/app-1/policies",
);
expect(init.method).toBe("POST");
expect(JSON.parse(init.body as string)).toEqual({
name: "Dokploy allow policy",
decision: "allow",
include: [
{ email: { email: "a@example.com" } },
{ email_domain: { domain: "example.org" } },
],
});
});
});
102 changes: 102 additions & 0 deletions apps/dokploy/__test__/cloudflare/cloudflare-api-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import {
CloudflareApiError,
verifyToken,
} from "@dokploy/server/utils/providers/cloudflare";
import { afterEach, describe, expect, it, vi } from "vitest";

/**
* Minimal stand-in for a `fetch` Response. The client only relies on `ok`,
* `status` and `json()`.
*/
const fakeResponse = (ok: boolean, status: number, body: unknown) =>
({
ok,
status,
json: async () => body,
}) as unknown as Response;

const stubFetch = (response: Response) => {
const fetchMock = vi.fn().mockResolvedValue(response);
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
};

afterEach(() => {
vi.unstubAllGlobals();
});

describe("verifyToken", () => {
it("resolves and sends a bearer token to the verify endpoint", async () => {
const fetchMock = stubFetch(
fakeResponse(true, 200, {
success: true,
errors: [],
messages: [],
result: { id: "abc", status: "active" },
}),
);

const result = await verifyToken("token-123");

expect(result.status).toBe("active");
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe("https://api.cloudflare.com/client/v4/user/tokens/verify");
expect((init.headers as Record<string, string>).Authorization).toBe(
"Bearer token-123",
);
});

it("throws a mapped CloudflareApiError when the token is invalid", async () => {
stubFetch(
fakeResponse(false, 401, {
success: false,
errors: [{ code: 1000, message: "Invalid API Token" }],
messages: [],
result: null,
}),
);

await expect(verifyToken("bad-token")).rejects.toThrow("Invalid API Token");
await expect(verifyToken("bad-token")).rejects.toBeInstanceOf(
CloudflareApiError,
);
});

it("throws when the token verifies but is not active", async () => {
stubFetch(
fakeResponse(true, 200, {
success: true,
errors: [],
messages: [],
result: { id: "abc", status: "disabled" },
}),
);

await expect(verifyToken("token")).rejects.toThrow(/not active|disabled/i);
});

it("never leaks the API token in the error message", async () => {
const secret = "super-secret-token-value";
stubFetch(
fakeResponse(false, 403, {
success: false,
errors: [
{ code: 9109, message: "Unauthorized to access requested resource" },
],
messages: [],
result: null,
}),
);

await expect(verifyToken(secret)).rejects.toMatchObject({
message: expect.not.stringContaining(secret),
});
});

it("falls back to a generic message when the body has no errors", async () => {
stubFetch(fakeResponse(false, 500, { success: false }));

await expect(verifyToken("token")).rejects.toThrow(/HTTP 500/);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, expect, it, vi } from "vitest";

/**
* Publishing a domain via Cloudflare triggers org-wide DNS/Tunnel changes, so it
* must require an owner/admin even when the caller holds service-level
* `domain:create`. These tests isolate that gate by stubbing the service-access
* check (so a member passes it) and asserting the Cloudflare admin gate still
* blocks the member.
*/
vi.mock("@dokploy/server/services/permission", async (importOriginal) => ({
...(await importOriginal<object>()),
checkServicePermissionAndAccess: vi.fn(() => Promise.resolve()),
}));

vi.mock("@dokploy/server", async (importOriginal) => {
const actual = await importOriginal<typeof import("@dokploy/server")>();
return {
...actual,
createDomain: vi.fn(async (input: { host: string }) => ({
domainId: "dom-1",
host: input.host,
applicationId: "app-1",
})),
findDomainById: vi.fn(async () => ({
domainId: "dom-1",
host: "app.example.com",
applicationId: "app-1",
composeId: null,
previewDeploymentId: null,
publishToCloudflare: false,
})),
provisionCloudflareForDomain: vi.fn(async () => {}),
isCloudflarePublished: vi.fn(() => false),
findCloudflareById: vi.fn(async () => ({
cloudflareId: "cf-1",
organizationId: "org-1",
name: "prod",
apiToken: "secret",
accountId: "acct-1",
})),
};
});

const { domainRouter } = await import("@/server/api/routers/domain");
const { createCallerFactory } = await import("@/server/api/trpc");
const { findCloudflareById } = await import("@dokploy/server");

const createCaller = createCallerFactory(domainRouter);

const ctxFor = (role: "owner" | "admin" | "member") =>
({
user: { id: "user-1", email: "user@test.com", role },
session: { activeOrganizationId: "org-1" },
req: {} as unknown,
res: {} as unknown,
}) as never;

const publishInput = {
host: "app.example.com",
domainType: "application" as const,
applicationId: "app-1",
publishToCloudflare: true,
cloudflareId: "cf-1",
cloudflareTunnelMode: "shared-managed" as const,
};

describe("domain.create Cloudflare publish gate", () => {
it("rejects a member trying to publish via Cloudflare", async () => {
const caller = createCaller(ctxFor("member"));
await expect(caller.create(publishInput)).rejects.toThrow(
/owners or admins can publish/i,
);
});

it("allows an admin to publish via Cloudflare", async () => {
const caller = createCaller(ctxFor("admin"));
await expect(caller.create(publishInput)).resolves.toBeDefined();
});

it("rejects publishing with an integration from another organization", async () => {
vi.mocked(findCloudflareById).mockResolvedValueOnce({
cloudflareId: "cf-1",
organizationId: "other-org",
name: "prod",
apiToken: "secret",
accountId: "acct-1",
} as Awaited<ReturnType<typeof findCloudflareById>>);
const caller = createCaller(ctxFor("admin"));
await expect(caller.create(publishInput)).rejects.toThrow(
/not found in this organization/i,
);
});
});

describe("domain.update Cloudflare publish gate", () => {
it("rejects a member turning on Cloudflare publishing", async () => {
const caller = createCaller(ctxFor("member"));
await expect(
caller.update({ domainId: "dom-1", ...publishInput }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
});
Loading