Compare commits

..

17 Commits

Author SHA1 Message Date
Mauricio Siu
f48c023250 fix: enable comment toggle shortcut in env variable editor (#4402) 2026-05-22 16:54:31 -06:00
Mauricio Siu
b06138b230 fix: prevent webhook deploy crash when commit data lacks modified files (#4470)
shouldDeploy passed undefined/null entries from commit.modified straight
into micromatch, which throws "Expected input to be a string" and fails
every webhook deployment when watch paths are configured. Filter out
non-string values before matching.
2026-05-22 16:46:26 -06:00
Mauricio Siu
af8072d7ad fix: allow square brackets in zip path validation for Next.js dynamic routes (#4468)
* fix: allow square brackets in zip drop path validation for Next.js dynamic routes

ZIP uploads containing Next.js dynamic route files (e.g. app/api/[id]/route.ts,
pages/[slug].tsx) were rejected by readValidDirectory because the path regex
did not include square bracket characters.

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-22 16:26:34 -06:00
Francis
6e342ee2f2 fix: automatically converting username to lowercase both in creation of register, and build for extra. (#4382) 2026-05-13 01:09:47 -06:00
Nahidujjaman Hridoy
ef0cf9bd02 fix: responsive layout (#4391)
Signed-off-by: Nahidujjaman Hridoy <hridoyboss12@gmail.com>
2026-05-13 01:03:59 -06:00
Volodymyr Kravchuk
8d88a34a64 fix: copy Dokploy server IP when clicking server badge (#4390)
* fix: copy Dokploy server IP when clicking server badge

When a service runs on the local Dokploy server (no remote server),
clicking the server badge did nothing because `data.server` is null.
Now falls back to the server IP from settings so the badge always
copies an IP address.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(copy-ip): implement IP address copying functionality across database service components

- Added the ability to copy the server IP address to the clipboard when clicking the server badge in various database service components (Libsql, MariaDB, MongoDB, MySQL, PostgreSQL, Redis).
- Integrated the `copy-to-clipboard` library and `sonner` for user feedback upon successful copy action.
- Ensured fallback to the server IP from settings when the service data is not available, enhancing user experience and functionality.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Mauricio Siu <siumauricio@icloud.com>
2026-05-13 01:03:29 -06:00
Mauricio Siu
a50f958a6f feat(settings): add copy button to server IP in web server settings (#4397) 2026-05-13 00:54:20 -06:00
Mauricio Siu
1fdbe87d84 feat(user): implement session cleanup on user update
- Added functionality to delete old sessions when a user updates their password, ensuring that only the current session remains active.
- This change enhances security by preventing unauthorized access from previous sessions after a password change.

Close here https://github.com/Dokploy/dokploy/security/advisories/GHSA-rr9m-w87g-46f3
2026-05-13 00:49:32 -06:00
Mauricio Siu
67278d8783 feat(organization): prevent inviting users with owner role
- Added validation to prevent users from being invited with the owner role in the organization and user routers.
- Implemented TRPCError responses to ensure proper error handling when attempting to assign the owner role.
This change enhances role management and security within the organization structure.

https://github.com/Dokploy/dokploy/security/advisories/GHSA-fm9p-wmpw-gxjh
2026-05-13 00:42:29 -06:00
Mauricio Siu
aff200f84f feat(deployment): add server access validation for deployment actions
- Implemented server access validation in deployment procedures to ensure users can only access deployments associated with their active organization.
- Added checks to throw an UNAUTHORIZED error if a user attempts to access a deployment linked to a server outside their organization.

This enhancement improves security and access control within the deployment management system.
2026-05-13 00:09:47 -06:00
Mauricio Siu
558d809871 feat(deployment): add readLogs procedure to fetch deployment logs
- Introduced a new `readLogs` procedure that allows users to retrieve logs for a specific deployment by providing the deployment ID and an optional tail parameter.
- Implemented permission checks to ensure users have access to the requested logs.
- Enhanced log retrieval for both cloud and non-cloud environments, utilizing appropriate commands based on the server context.

Resolve https://github.com/Dokploy/mcp/issues/14
2026-05-13 00:04:26 -06:00
Mauricio Siu
f8fcf68909 Enhance version synchronization workflow to include SDK repository
- Updated the GitHub Actions workflow to sync versioning across MCP, CLI, and SDK repositories.
- Added steps to bump the version in the SDK repository and regenerate tools from the latest OpenAPI spec.
- Improved commit message formatting to include source and release information for all repositories.
- Ensured successful synchronization messages for each repository after the version update.
2026-05-12 13:26:09 -06:00
Mauricio Siu
7a568aadac Merge pull request #4395 from Dokploy/feat/import-compose-from-base64
feat(compose): add import from base64 in create service dropdown
2026-05-12 13:13:33 -06:00
autofix-ci[bot]
63e33a29cc [autofix.ci] apply automated fixes 2026-05-12 19:12:46 +00:00
Mauricio Siu
754774ea02 feat(compose): add import from base64 in create service dropdown
Adds an "Import" option to the Create Service dropdown that lets users
paste a base64-encoded compose export, preview the template (compose YAML,
domains, envs, mounts) before confirming, and create the service only on
confirm. Adds a `previewTemplate` tRPC procedure that processes the base64
without touching the DB, with server access validation via session.
2026-05-12 13:12:14 -06:00
Mauricio Siu
a714e0f83f Merge pull request #4394 from ngenohkevin/fix/migrate-auth-secret-exit-on-empty
fix(migrate-auth-secret): exit cleanly when there are no 2FA records
2026-05-12 13:04:23 -06:00
ngenohkevin
9f10f0f4e9 fix(migrate-auth-secret): exit cleanly when there are no 2FA records
The empty-records branch of `main()` returned without calling
`process.exit(0)`, leaving the Drizzle Postgres connection pool
holding the event loop open. The `migrate-auth-secret` process
then hangs indefinitely after printing "No 2FA records found,
nothing to migrate." causing the upstream `0.29.3.sh` security
migration script (which calls this via `docker exec`) to never
reach its final `docker service update` step that mounts the new
Docker Secret. Operators end up with the new secret created but
the dokploy service still configured with the hardcoded
`BETTER_AUTH_SECRET`, while believing the migration completed.

Match the success branch a few lines below which already does
`process.exit(0)`, and the pattern used in sibling scripts
`reset-password.ts` and `reset-2fa.ts`.

Closes #4392
2026-05-12 21:35:02 +03:00
130 changed files with 492 additions and 45454 deletions

21
.github/workflows/pr-quality.yml vendored Normal file
View File

@@ -0,0 +1,21 @@
name: PR Quality
permissions:
contents: read
issues: read
pull-requests: write
on:
pull_request_target:
types: [opened, reopened]
jobs:
anti-slop:
runs-on: ubuntu-latest
steps:
- uses: peakoss/anti-slop@v0
with:
blocked-commit-authors: "claude,copilot"
require-description: true
min-account-age: 5

View File

@@ -1,52 +0,0 @@
import { getBuildComposeCommand } from "@dokploy/server/utils/builders/compose";
import { describe, expect, it, vi } from "vitest";
// Isolate the command builder from the compose-file I/O performed by
// writeDomainsToCompose; we only care about the docker invocation it emits.
vi.mock("@dokploy/server/utils/docker/domain", () => ({
writeDomainsToCompose: vi.fn().mockResolvedValue(""),
}));
const baseCompose = {
appName: "my-app",
sourceType: "raw",
command: "",
composePath: "docker-compose.yml",
composeType: "stack",
isolatedDeployment: false,
randomize: false,
suffix: "",
serverId: null,
env: "",
mounts: [],
domains: [],
environment: { project: { env: "" }, env: "" },
} as unknown as Parameters<typeof getBuildComposeCommand>[0];
// Regression coverage for #4401: the deploy command runs under `env -i`, which
// clears the environment except for the vars listed explicitly. HOME must be
// preserved so docker can resolve ~/.docker/config.json — otherwise
// `docker stack deploy --with-registry-auth` ships no credentials to the swarm
// and private-registry images fail to pull.
describe("getBuildComposeCommand registry auth (#4401)", () => {
it("preserves HOME for swarm stack deploys", async () => {
const command = await getBuildComposeCommand({
...baseCompose,
composeType: "stack",
});
expect(command).toContain("stack deploy");
expect(command).toContain("--with-registry-auth");
expect(command).toContain('env -i PATH="$PATH" HOME="$HOME"');
});
it("preserves HOME for docker compose deploys", async () => {
const command = await getBuildComposeCommand({
...baseCompose,
composeType: "docker-compose",
});
expect(command).toContain("compose -p my-app");
expect(command).toContain('env -i PATH="$PATH" HOME="$HOME"');
});
});

View File

@@ -34,7 +34,6 @@ describe("Host rule format regression tests", () => {
stripPath: false,
customEntrypoint: null,
middlewares: null,
forwardAuthEnabled: false,
};
describe("Host rule format validation", () => {

View File

@@ -23,7 +23,6 @@ describe("createDomainLabels", () => {
internalPath: "/",
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
};
it("should create basic labels for web entrypoint", async () => {
@@ -104,51 +103,6 @@ describe("createDomainLabels", () => {
);
});
it("should add tls=true for certificateType none on websecure entrypoint", async () => {
const noneDomain = {
...baseDomain,
https: true,
certificateType: "none" as const,
};
const labels = await createDomainLabels(appName, noneDomain, "websecure");
expect(labels).toContain(
"traefik.http.routers.test-app-1-websecure.tls=true",
);
// no cert resolver should be set when relying on a default/custom cert
expect(labels).not.toContain(
"traefik.http.routers.test-app-1-websecure.tls.certresolver=letsencrypt",
);
});
it("should not add tls=true for certificateType none on web entrypoint", async () => {
const noneDomain = {
...baseDomain,
https: true,
certificateType: "none" as const,
};
const labels = await createDomainLabels(appName, noneDomain, "web");
expect(labels).not.toContain(
"traefik.http.routers.test-app-1-web.tls=true",
);
});
it("should add tls=true for certificateType none on a custom https entrypoint", async () => {
const noneDomain = {
...baseDomain,
https: true,
customEntrypoint: "websecure-custom",
certificateType: "none" as const,
};
const labels = await createDomainLabels(
appName,
noneDomain,
"websecure-custom",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-websecure-custom.tls=true",
);
});
it("should handle different ports correctly", async () => {
const customPortDomain = { ...baseDomain, port: 3000 };
const labels = await createDomainLabels(appName, customPortDomain, "web");

View File

@@ -1,369 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
canEditDeployGitSource,
getAccessibleGitProviderIds,
} from "@dokploy/server/services/git-provider";
const mockDb = vi.hoisted(() => ({
query: {
gitProvider: {
findMany: vi.fn(),
findFirst: vi.fn(),
},
member: {
findFirst: vi.fn(),
},
},
}));
vi.mock("@dokploy/server/db", () => ({ db: mockDb }));
const mockHasValidLicense = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: mockHasValidLicense,
}));
const ORG_ID = "org-1";
const USER_OWNER = "user-owner";
const USER_ADMIN = "user-admin";
const USER_MEMBER = "user-member";
const USER_MEMBER_2 = "user-member-2";
const providerOwned = {
gitProviderId: "gp-owned",
userId: USER_MEMBER,
sharedWithOrganization: false,
};
const providerShared = {
gitProviderId: "gp-shared",
userId: USER_OWNER,
sharedWithOrganization: true,
};
const providerPrivate = {
gitProviderId: "gp-private",
userId: USER_OWNER,
sharedWithOrganization: false,
};
const providerOtherMember = {
gitProviderId: "gp-other",
userId: USER_MEMBER_2,
sharedWithOrganization: false,
};
const allProviders = [
providerOwned,
providerShared,
providerPrivate,
providerOtherMember,
];
function session(userId: string) {
return { userId, activeOrganizationId: ORG_ID };
}
beforeEach(() => {
vi.clearAllMocks();
mockDb.query.gitProvider.findMany.mockResolvedValue(allProviders);
mockHasValidLicense.mockResolvedValue(false);
});
describe("getAccessibleGitProviderIds", () => {
describe("owner", () => {
beforeEach(() => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "owner",
accessedGitProviders: [],
});
});
it("returns all org providers", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_OWNER));
expect(ids).toEqual(new Set(allProviders.map((p) => p.gitProviderId)));
});
it("includes providers owned by other members", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_OWNER));
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
expect(ids.has(providerOtherMember.gitProviderId)).toBe(true);
});
});
describe("admin", () => {
beforeEach(() => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "admin",
accessedGitProviders: [],
});
});
it("returns all org providers", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
expect(ids).toEqual(new Set(allProviders.map((p) => p.gitProviderId)));
});
it("includes providers owned by other members — fixes issue #4469", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
expect(ids.has(providerOtherMember.gitProviderId)).toBe(true);
});
});
describe("member without enterprise license", () => {
beforeEach(() => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [providerPrivate.gitProviderId],
});
mockHasValidLicense.mockResolvedValue(false);
});
it("can access their own provider", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
});
it("can access shared providers", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerShared.gitProviderId)).toBe(true);
});
it("cannot access private providers of other users even if assigned (no license)", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
});
it("cannot access providers of other members", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
});
});
describe("member with enterprise license", () => {
beforeEach(() => {
mockHasValidLicense.mockResolvedValue(true);
});
it("can access provider explicitly assigned to them", async () => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [providerPrivate.gitProviderId],
});
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
});
it("cannot access provider not assigned and not shared", async () => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [],
});
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
});
it("can access shared provider even without explicit assignment", async () => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [],
});
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerShared.gitProviderId)).toBe(true);
});
it("can access own provider regardless of assignments", async () => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [],
});
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
});
it("cannot access provider of other member even with license but no assignment", async () => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [],
});
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
});
});
describe("member with no member record", () => {
beforeEach(() => {
mockDb.query.member.findFirst.mockResolvedValue(null);
mockHasValidLicense.mockResolvedValue(true);
});
it("only returns own providers and shared ones", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
expect(ids.has(providerShared.gitProviderId)).toBe(true);
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
});
});
describe("enterprise license — member assigned to a provider they do not own", () => {
// getAccessibleGitProviderIds still returns the provider (member can connect NEW deploys)
it("member assigned to owner's private provider can USE the provider for new deploys", async () => {
mockHasValidLicense.mockResolvedValue(true);
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [providerPrivate.gitProviderId],
});
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
});
it("member NOT assigned to owner's private provider cannot use it at all", async () => {
mockHasValidLicense.mockResolvedValue(true);
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [],
});
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
});
});
describe("empty org", () => {
beforeEach(() => {
mockDb.query.gitProvider.findMany.mockResolvedValue([]);
mockDb.query.member.findFirst.mockResolvedValue({
role: "admin",
accessedGitProviders: [],
});
});
it("returns empty set when org has no providers", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
expect(ids.size).toBe(0);
});
});
});
describe("canEditDeployGitSource", () => {
beforeEach(() => {
vi.clearAllMocks();
mockHasValidLicense.mockResolvedValue(true);
});
describe("owner", () => {
it("can edit deploy using any provider", async () => {
mockDb.query.member.findFirst.mockResolvedValue({ role: "owner" });
const result = await canEditDeployGitSource(
providerPrivate.gitProviderId,
session(USER_OWNER),
);
expect(result).toBe(true);
});
});
describe("admin", () => {
beforeEach(() => {
mockDb.query.member.findFirst.mockResolvedValue({ role: "admin" });
});
it("cannot edit deploy using owner's private provider (not shared)", async () => {
mockDb.query.gitProvider.findFirst.mockResolvedValue({
userId: USER_OWNER,
sharedWithOrganization: false,
});
const result = await canEditDeployGitSource(
providerPrivate.gitProviderId,
session(USER_ADMIN),
);
expect(result).toBe(false);
});
it("can edit deploy using a provider shared with the org", async () => {
mockDb.query.gitProvider.findFirst.mockResolvedValue({
userId: USER_OWNER,
sharedWithOrganization: true,
});
const result = await canEditDeployGitSource(
providerShared.gitProviderId,
session(USER_ADMIN),
);
expect(result).toBe(true);
});
it("can edit deploy using their own provider", async () => {
mockDb.query.gitProvider.findFirst.mockResolvedValue({
userId: USER_ADMIN,
sharedWithOrganization: false,
});
const result = await canEditDeployGitSource(
"gp-admin-owned",
session(USER_ADMIN),
);
expect(result).toBe(true);
});
});
describe("member", () => {
beforeEach(() => {
mockDb.query.member.findFirst.mockResolvedValue({ role: "member" });
});
it("can edit deploy using their own provider", async () => {
mockDb.query.gitProvider.findFirst.mockResolvedValue({
userId: USER_MEMBER,
sharedWithOrganization: false,
});
const result = await canEditDeployGitSource(
providerOwned.gitProviderId,
session(USER_MEMBER),
);
expect(result).toBe(true);
});
it("can edit deploy using a provider shared with the org", async () => {
mockDb.query.gitProvider.findFirst.mockResolvedValue({
userId: USER_OWNER,
sharedWithOrganization: true,
});
const result = await canEditDeployGitSource(
providerShared.gitProviderId,
session(USER_MEMBER),
);
expect(result).toBe(true);
});
it("cannot edit deploy using owner's private provider even with enterprise license and assignment", async () => {
// This is the key case: enterprise, provider del owner, no compartido,
// member tiene accessedGitProviders asignado — pero NO puede cambiar la branch del deploy del owner
mockDb.query.gitProvider.findFirst.mockResolvedValue({
userId: USER_OWNER,
sharedWithOrganization: false,
});
const result = await canEditDeployGitSource(
providerPrivate.gitProviderId,
session(USER_MEMBER),
);
expect(result).toBe(false);
});
it("cannot edit deploy using another member's private provider", async () => {
mockDb.query.gitProvider.findFirst.mockResolvedValue({
userId: USER_MEMBER_2,
sharedWithOrganization: false,
});
const result = await canEditDeployGitSource(
providerOtherMember.gitProviderId,
session(USER_MEMBER),
);
expect(result).toBe(false);
});
it("returns false if provider does not exist", async () => {
mockDb.query.gitProvider.findFirst.mockResolvedValue(null);
const result = await canEditDeployGitSource(
"nonexistent-id",
session(USER_MEMBER),
);
expect(result).toBe(false);
});
});
});

View File

@@ -58,7 +58,7 @@ beforeEach(() => {
vi.clearAllMocks();
});
describe("owner and admin bypass enterprise resources", () => {
describe("static roles bypass enterprise resources", () => {
it("owner bypasses deployment.read", async () => {
memberToReturn = mockMemberData("owner");
await expect(
@@ -73,8 +73,15 @@ describe("owner and admin bypass enterprise resources", () => {
).resolves.toBeUndefined();
});
it("owner bypasses multiple enterprise permissions at once", async () => {
memberToReturn = mockMemberData("owner");
it("member bypasses schedule.delete", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { schedule: ["delete"] }),
).resolves.toBeUndefined();
});
it("member bypasses multiple enterprise permissions at once", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, {
deployment: ["read"],
@@ -85,55 +92,6 @@ describe("owner and admin bypass enterprise resources", () => {
});
});
describe("member is denied org-level enterprise resources (CVE: bypass via staticRoles)", () => {
it("member is denied registry.read", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { registry: ["read"] }),
).rejects.toThrow();
});
it("member is denied certificate.read", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { certificate: ["read"] }),
).rejects.toThrow();
});
it("member is denied destination.read", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { destination: ["read"] }),
).rejects.toThrow();
});
it("member is denied notification.read", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { notification: ["read"] }),
).rejects.toThrow();
});
it("member is denied auditLog.read", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { auditLog: ["read"] }),
).rejects.toThrow();
});
it("member is denied server.read", async () => {
memberToReturn = mockMemberData("member");
await expect(checkPermission(ctx, { server: ["read"] })).rejects.toThrow();
});
it("member is denied registry.create", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { registry: ["create"] }),
).rejects.toThrow();
});
});
describe("static roles validate free-tier resources", () => {
it("owner passes project.create", async () => {
memberToReturn = mockMemberData("owner");

View File

@@ -1,233 +0,0 @@
import type { ApplicationNested, Domain } from "@dokploy/server";
import {
buildForwardAuthEnv,
createRouterConfig,
deriveBaseDomain,
deriveCookieSecret,
forwardAuthCallbackUrl,
forwardAuthMiddlewareName,
} from "@dokploy/server";
import { beforeAll, describe, expect, test } from "vitest";
const app = {
appName: "my-app",
redirects: [],
security: [],
} as unknown as ApplicationNested;
const baseDomain: Domain = {
applicationId: "app-1",
certificateType: "none",
createdAt: "",
domainId: "domain-1",
host: "app.example.com",
https: false,
path: null,
port: 3000,
customEntrypoint: null,
serviceName: "",
composeId: "",
customCertResolver: null,
domainType: "application",
uniqueConfigKey: 7,
previewDeploymentId: "",
internalPath: "/",
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
};
describe("forwardAuthMiddlewareName", () => {
test("is stable and unique per app + uniqueConfigKey", () => {
expect(forwardAuthMiddlewareName("my-app", 7)).toBe(
"forward-auth-my-app-7",
);
expect(forwardAuthMiddlewareName("my-app", 7)).toBe(
forwardAuthMiddlewareName("my-app", 7),
);
expect(forwardAuthMiddlewareName("my-app", 7)).not.toBe(
forwardAuthMiddlewareName("my-app", 8),
);
});
});
describe("createRouterConfig forward-auth wiring", () => {
test("does NOT add forward-auth middleware when no provider is linked", async () => {
const config = await createRouterConfig(app, baseDomain, "websecure");
expect(config.middlewares).not.toContain(
forwardAuthMiddlewareName("my-app", 7),
);
});
test("adds forward-auth middleware when a provider is linked", async () => {
const domain: Domain = {
...baseDomain,
forwardAuthEnabled: true,
};
const config = await createRouterConfig(app, domain, "websecure");
expect(config.middlewares).toContain(
forwardAuthMiddlewareName("my-app", 7),
);
});
test("forward-auth runs before custom domain middlewares", async () => {
const domain: Domain = {
...baseDomain,
forwardAuthEnabled: true,
middlewares: ["rate-limit@file"],
};
const config = await createRouterConfig(app, domain, "websecure");
const forwardAuthIdx = config.middlewares?.indexOf(
forwardAuthMiddlewareName("my-app", 7),
);
const customIdx = config.middlewares?.indexOf("rate-limit@file");
expect(forwardAuthIdx).toBeGreaterThanOrEqual(0);
expect(customIdx).toBeGreaterThan(forwardAuthIdx as number);
});
test("redirect-only web router does not get the forward-auth middleware", async () => {
const domain: Domain = {
...baseDomain,
https: true,
forwardAuthEnabled: true,
};
const config = await createRouterConfig(app, domain, "web");
expect(config.middlewares).toContain("redirect-to-https");
expect(config.middlewares).not.toContain(
forwardAuthMiddlewareName("my-app", 7),
);
});
});
describe("buildForwardAuthEnv", () => {
const baseOptions = {
oidc: {
clientId: "client-123",
clientSecret: "secret-xyz",
issuer: "https://idp.example.com",
},
cookieSecret: "cookie-secret-value",
authDomain: "auth.acme.com",
baseDomain: ".acme.com",
authDomainHttps: true,
};
test("emits the required oauth2-proxy OIDC env vars", () => {
const env = buildForwardAuthEnv(baseOptions);
expect(env).toContain("OAUTH2_PROXY_PROVIDER=oidc");
expect(env).toContain(
"OAUTH2_PROXY_OIDC_ISSUER_URL=https://idp.example.com",
);
expect(env).toContain("OAUTH2_PROXY_CLIENT_ID=client-123");
expect(env).toContain("OAUTH2_PROXY_CLIENT_SECRET=secret-xyz");
expect(env).toContain("OAUTH2_PROXY_COOKIE_SECRET=cookie-secret-value");
expect(env).toContain("OAUTH2_PROXY_REVERSE_PROXY=true");
expect(env).toContain("OAUTH2_PROXY_HTTP_ADDRESS=0.0.0.0:4180");
});
test("uses the central auth domain for the single fixed callback", () => {
const env = buildForwardAuthEnv(baseOptions);
expect(env).toContain(
"OAUTH2_PROXY_REDIRECT_URL=https://auth.acme.com/oauth2/callback",
);
});
test("shares cookie + whitelist on the base domain (no per-app redeploy)", () => {
const env = buildForwardAuthEnv(baseOptions);
expect(env).toContain("OAUTH2_PROXY_COOKIE_DOMAINS=.acme.com");
expect(env).toContain("OAUTH2_PROXY_WHITELIST_DOMAINS=.acme.com");
});
test("matches cookie Secure flag and callback scheme to https setting", () => {
const https = buildForwardAuthEnv(baseOptions);
expect(https).toContain("OAUTH2_PROXY_COOKIE_SECURE=true");
const http = buildForwardAuthEnv({
...baseOptions,
authDomainHttps: false,
});
expect(http).toContain("OAUTH2_PROXY_COOKIE_SECURE=false");
expect(http).toContain(
"OAUTH2_PROXY_REDIRECT_URL=http://auth.acme.com/oauth2/callback",
);
});
test("allows unverified emails so OIDC providers don't 500 the callback", () => {
const env = buildForwardAuthEnv(baseOptions);
expect(env).toContain(
"OAUTH2_PROXY_INSECURE_OIDC_ALLOW_UNVERIFIED_EMAIL=true",
);
});
test("defaults to any authenticated user and standard scopes", () => {
const env = buildForwardAuthEnv(baseOptions);
expect(env).toContain("OAUTH2_PROXY_EMAIL_DOMAINS=*");
expect(env).toContain("OAUTH2_PROXY_SCOPE=openid email profile");
});
test("honors custom scopes and email domains", () => {
const env = buildForwardAuthEnv({
...baseOptions,
oidc: { ...baseOptions.oidc, scopes: ["openid", "groups"] },
emailDomains: ["acme.com", "corp.com"],
});
expect(env).toContain("OAUTH2_PROXY_SCOPE=openid groups");
expect(env).toContain("OAUTH2_PROXY_EMAIL_DOMAINS=acme.com,corp.com");
});
test("sets skip-discovery flag only when requested", () => {
const withoutSkip = buildForwardAuthEnv(baseOptions);
expect(withoutSkip).not.toContain("OAUTH2_PROXY_SKIP_OIDC_DISCOVERY=true");
const withSkip = buildForwardAuthEnv({
...baseOptions,
oidc: { ...baseOptions.oidc, skipDiscovery: true },
});
expect(withSkip).toContain("OAUTH2_PROXY_SKIP_OIDC_DISCOVERY=true");
});
});
describe("deriveBaseDomain", () => {
test("strips the auth subdomain to the shared base", () => {
expect(deriveBaseDomain("auth.acme.com")).toBe(".acme.com");
expect(deriveBaseDomain("sso.apps.acme.com")).toBe(".apps.acme.com");
});
test("keeps a two-label apex as the base", () => {
expect(deriveBaseDomain("acme.com")).toBe(".acme.com");
});
});
describe("forwardAuthCallbackUrl", () => {
test("builds the single IdP callback per scheme", () => {
expect(forwardAuthCallbackUrl("auth.acme.com", true)).toBe(
"https://auth.acme.com/oauth2/callback",
);
expect(forwardAuthCallbackUrl("auth.acme.com", false)).toBe(
"http://auth.acme.com/oauth2/callback",
);
});
});
describe("deriveCookieSecret", () => {
beforeAll(() => {
process.env.BETTER_AUTH_SECRET = "test-root-secret";
});
test("is deterministic for the same salt (survives service updates)", () => {
expect(deriveCookieSecret(".acme.com")).toBe(
deriveCookieSecret(".acme.com"),
);
});
test("differs per salt", () => {
expect(deriveCookieSecret(".acme.com")).not.toBe(
deriveCookieSecret(".other.com"),
);
});
test("produces a 16-byte hex secret (oauth2-proxy requirement)", () => {
const secret = deriveCookieSecret(".acme.com");
expect(Buffer.from(secret, "hex")).toHaveLength(16);
});
});

View File

@@ -65,8 +65,6 @@ const baseSettings: WebServerSettings = {
cleanupCacheApplications: false,
cleanupCacheOnCompose: false,
cleanupCacheOnPreviews: false,
remoteServersOnly: false,
enforceSSO: false,
createdAt: null,
updatedAt: new Date(),
};

View File

@@ -148,7 +148,6 @@ const baseDomain: Domain = {
internalPath: "/",
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
};
const baseRedirect: Redirect = {

View File

@@ -16,17 +16,12 @@ import {
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const optionalNumber = z
.union([z.string(), z.number()])
.transform((val) => (val === "" ? undefined : Number(val)))
.optional();
export const healthCheckFormSchema = z.object({
Test: z.array(z.string()).optional(),
Interval: optionalNumber,
Timeout: optionalNumber,
StartPeriod: optionalNumber,
Retries: optionalNumber,
Interval: z.coerce.number().optional(),
Timeout: z.coerce.number().optional(),
StartPeriod: z.coerce.number().optional(),
Retries: z.coerce.number().optional(),
});
interface HealthCheckFormProps {
@@ -200,12 +195,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
Time between health checks (e.g., 10000000000 for 10 seconds)
</FormDescription>
<FormControl>
<Input
type="number"
placeholder="10000000000"
{...field}
value={field.value ?? ""}
/>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -222,12 +212,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
Maximum time to wait for health check response
</FormDescription>
<FormControl>
<Input
type="number"
placeholder="10000000000"
{...field}
value={field.value ?? ""}
/>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -244,12 +229,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
Initial grace period before health checks begin
</FormDescription>
<FormControl>
<Input
type="number"
placeholder="10000000000"
{...field}
value={field.value ?? ""}
/>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -267,12 +247,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
unhealthy
</FormDescription>
<FormControl>
<Input
type="number"
placeholder="3"
{...field}
value={field.value ?? ""}
/>
<Input type="number" placeholder="3" {...field} />
</FormControl>
<FormMessage />
</FormItem>

View File

@@ -224,7 +224,7 @@ export const ShowResources = ({ id, type }: Props) => {
<FormLabel>Memory Limit</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger type="button">
<TooltipTrigger>
<InfoIcon className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
@@ -263,7 +263,7 @@ export const ShowResources = ({ id, type }: Props) => {
<FormLabel>Memory Reservation</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger type="button">
<TooltipTrigger>
<InfoIcon className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
@@ -303,7 +303,7 @@ export const ShowResources = ({ id, type }: Props) => {
<FormLabel>CPU Limit</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger type="button">
<TooltipTrigger>
<InfoIcon className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
@@ -343,7 +343,7 @@ export const ShowResources = ({ id, type }: Props) => {
<FormLabel>CPU Reservation</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger type="button">
<TooltipTrigger>
<InfoIcon className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
@@ -379,7 +379,7 @@ export const ShowResources = ({ id, type }: Props) => {
<FormLabel className="text-base">Ulimits</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger type="button">
<TooltipTrigger>
<InfoIcon className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">

View File

@@ -806,7 +806,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
<FormLabel>Middlewares</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger type="button">
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>

View File

@@ -1,147 +0,0 @@
import { ShieldCheck } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
interface Props {
domainId: string;
applicationId: string;
}
export const HandleForwardAuth = ({ domainId, applicationId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { data: haveValidLicense } =
api.licenseKey.haveValidLicenseKey.useQuery();
const utils = api.useUtils();
const { data: status } = api.forwardAuth.status.useQuery(
{ domainId },
{ enabled: isOpen },
);
const { mutateAsync: enable, isPending: isEnabling } =
api.forwardAuth.enable.useMutation();
const { mutateAsync: disable, isPending: isDisabling } =
api.forwardAuth.disable.useMutation();
if (!haveValidLicense) {
return null;
}
const isEnabled = !!status?.enabled;
const isPending = isEnabling || isDisabling;
const refresh = async () => {
await utils.forwardAuth.status.invalidate({ domainId });
await utils.domain.byApplicationId.invalidate({ applicationId });
await utils.application.readTraefikConfig.invalidate({ applicationId });
};
const handleToggle = async (next: boolean) => {
try {
if (next) {
await enable({ domainId });
toast.success("SSO authentication enabled for this domain");
} else {
await disable({ domainId });
toast.success("SSO authentication disabled for this domain");
}
await refresh();
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Error updating SSO authentication",
);
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="group hover:bg-emerald-500/10"
title="SSO authentication"
>
<ShieldCheck
className={`size-4 ${
isEnabled
? "text-emerald-500"
: "text-primary group-hover:text-emerald-500"
}`}
/>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>SSO Authentication</DialogTitle>
<DialogDescription>
Require visitors to authenticate against your identity provider
before reaching this application.
</DialogDescription>
</DialogHeader>
<AlertBlock type="warning">
<div className="flex flex-col gap-1">
<span className="font-medium">Requirements</span>
<ol className="list-decimal pl-4 text-sm">
<li>
The authentication proxy container must be deployed and running
on this app's server. Configure it under{" "}
<span className="font-medium">
Settings SSO Application Authentication
</span>
.
</li>
<li>
This domain must share the same base domain as the
authentication domain (e.g. <code>app.acme.com</code> and{" "}
<code>auth.acme.com</code>).
</li>
</ol>
</div>
</AlertBlock>
<div className="flex items-center justify-between rounded-lg border p-4 mt-2">
<div className="flex flex-col">
<span className="text-sm font-medium">
Protect this domain with SSO
</span>
<span className="text-xs text-muted-foreground">
{isEnabled
? "Visitors must log in via your identity provider."
: "The domain is publicly accessible."}
</span>
</div>
<Switch
checked={isEnabled}
disabled={isPending}
onCheckedChange={handleToggle}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsOpen(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -62,7 +62,6 @@ import { api } from "@/utils/api";
import { createColumns } from "./columns";
import { DnsHelperModal } from "./dns-helper-modal";
import { AddDomain } from "./handle-domain";
import { HandleForwardAuth } from "./handle-forward-auth";
export type ValidationState = {
isLoading: boolean;
@@ -454,12 +453,6 @@ export const ShowDomains = ({ id, type }: Props) => {
</Button>
</AddDomain>
)}
{canCreateDomain && type === "application" && (
<HandleForwardAuth
domainId={item.domainId}
applicationId={id}
/>
)}
{canDeleteDomain && (
<DialogAction
title="Delete Domain"

View File

@@ -1,4 +1,3 @@
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
import Link from "next/link";
@@ -6,6 +5,7 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";

View File

@@ -1,4 +1,3 @@
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
import Link from "next/link";
@@ -7,6 +6,7 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { GitIcon } from "@/components/icons/data-tools-icons";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";

View File

@@ -1,4 +1,3 @@
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link";
@@ -6,6 +5,7 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { GiteaIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";

View File

@@ -1,4 +1,3 @@
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link";
@@ -6,6 +5,7 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { GithubIcon } from "@/components/icons/data-tools-icons";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";

View File

@@ -1,4 +1,3 @@
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link";
@@ -6,6 +5,7 @@ import { useEffect, useMemo } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { GitlabIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";

View File

@@ -1,4 +1,3 @@
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import Link from "next/link";
@@ -6,6 +5,7 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
@@ -422,7 +422,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger type="button">
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>

View File

@@ -1,4 +1,3 @@
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
import Link from "next/link";
@@ -7,6 +6,7 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { GitIcon } from "@/components/icons/data-tools-icons";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";

View File

@@ -1,4 +1,3 @@
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link";
@@ -6,6 +5,7 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { GiteaIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";

View File

@@ -449,7 +449,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger type="button">
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>

View File

@@ -1,4 +1,3 @@
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import Link from "next/link";
@@ -6,6 +5,7 @@ import { useEffect, useMemo } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { GitlabIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
@@ -440,7 +440,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger type="button">
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>

View File

@@ -71,9 +71,6 @@ interface Props {
export const AddApplication = ({ environmentId, projectName }: Props) => {
const utils = api.useUtils();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: webServerSettings } =
api.settings.getWebServerSettings.useQuery();
const showLocalOption = !isCloud && !webServerSettings?.remoteServersOnly;
const [visible, setVisible] = useState(false);
const slug = slugify(projectName);
const { data: servers } = api.server.withSSHKey.useQuery();
@@ -174,8 +171,7 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
<Tooltip>
<TooltipTrigger asChild>
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
Select a Server{" "}
{showLocalOption ? "(Optional)" : ""}
Select a Server {!isCloud ? "(Optional)" : ""}
<HelpCircle className="size-4 text-muted-foreground" />
</FormLabel>
</TooltipTrigger>
@@ -195,19 +191,17 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
<Select
onValueChange={field.onChange}
defaultValue={
field.value || (showLocalOption ? "dokploy" : undefined)
field.value || (!isCloud ? "dokploy" : undefined)
}
>
<SelectTrigger>
<SelectValue
placeholder={
showLocalOption ? "Dokploy" : "Select a Server"
}
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{showLocalOption && (
{!isCloud && (
<SelectItem value="dokploy">
<span className="flex items-center gap-2 justify-between w-full">
<span>Dokploy</span>
@@ -231,8 +225,7 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
</SelectItem>
))}
<SelectLabel>
Servers (
{servers?.length + (showLocalOption ? 1 : 0)})
Servers ({servers?.length + (!isCloud ? 1 : 0)})
</SelectLabel>
</SelectGroup>
</SelectContent>

View File

@@ -74,9 +74,6 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
const [visible, setVisible] = useState(false);
const slug = slugify(projectName);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: webServerSettings } =
api.settings.getWebServerSettings.useQuery();
const showLocalOption = !isCloud && !webServerSettings?.remoteServersOnly;
const { data: servers } = api.server.withSSHKey.useQuery();
const { mutateAsync, isPending, error, isError } =
api.compose.create.useMutation();
@@ -185,8 +182,7 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
<Tooltip>
<TooltipTrigger asChild>
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
Select a Server{" "}
{showLocalOption ? "(Optional)" : ""}
Select a Server {!isCloud ? "(Optional)" : ""}
<HelpCircle className="size-4 text-muted-foreground" />
</FormLabel>
</TooltipTrigger>
@@ -206,19 +202,17 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
<Select
onValueChange={field.onChange}
defaultValue={
field.value || (showLocalOption ? "dokploy" : undefined)
field.value || (!isCloud ? "dokploy" : undefined)
}
>
<SelectTrigger>
<SelectValue
placeholder={
showLocalOption ? "Dokploy" : "Select a Server"
}
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{showLocalOption && (
{!isCloud && (
<SelectItem value="dokploy">
<span className="flex items-center gap-2 justify-between w-full">
<span>Dokploy</span>
@@ -242,8 +236,7 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
</SelectItem>
))}
<SelectLabel>
Servers (
{servers?.length + (showLocalOption ? 1 : 0)})
Servers ({servers?.length + (!isCloud ? 1 : 0)})
</SelectLabel>
</SelectGroup>
</SelectContent>

View File

@@ -219,9 +219,6 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
const [visible, setVisible] = useState(false);
const slug = slugify(projectName);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: webServerSettings } =
api.settings.getWebServerSettings.useQuery();
const showLocalOption = !isCloud && !webServerSettings?.remoteServersOnly;
const { data: servers } = api.server.withSSHKey.useQuery();
const libsqlMutation = api.libsql.create.useMutation();
const mariadbMutation = api.mariadb.create.useMutation();
@@ -473,20 +470,19 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
<Select
onValueChange={field.onChange}
defaultValue={
field.value ||
(showLocalOption ? "dokploy" : undefined)
field.value || (!isCloud ? "dokploy" : undefined)
}
>
<SelectTrigger>
<SelectValue
placeholder={
showLocalOption ? "Dokploy" : "Select a Server"
!isCloud ? "Dokploy" : "Select a Server"
}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{showLocalOption && (
{!isCloud && (
<SelectItem value="dokploy">
<span className="flex items-center gap-2 justify-between w-full">
<span>Dokploy</span>
@@ -505,8 +501,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
</SelectItem>
))}
<SelectLabel>
Servers (
{servers?.length + (showLocalOption ? 1 : 0)})
Servers ({servers?.length + (!isCloud ? 1 : 0)})
</SelectLabel>
</SelectGroup>
</SelectContent>

View File

@@ -49,11 +49,7 @@ export const ShowGitProviders = () => {
api.gitProvider.remove.useMutation();
const { mutateAsync: toggleShare, isPending: isToggling } =
api.gitProvider.toggleShare.useMutation();
const { data: currentMember } = api.user.get.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const url = useUrl();
const isOrgAdmin =
currentMember?.role === "owner" || currentMember?.role === "admin";
const getGitlabUrl = (
clientId: string,
@@ -91,20 +87,18 @@ export const ShowGitProviders = () => {
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<GitBranch className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
No Git Providers configured
Create your first Git Provider
</span>
{permissions?.gitProviders.create && (
<div>
<div className="flex items-center bg-sidebar p-1 w-full rounded-lg">
<div className="flex flex-wrap items-center gap-4 p-3.5 rounded-lg bg-background border w-full [&>button]:grow">
<AddGithubProvider />
<AddGitlabProvider />
<AddBitbucketProvider />
<AddGiteaProvider />
</div>
<div>
<div className="flex items-center bg-sidebar p-1 w-full rounded-lg">
<div className="flex flex-wrap items-center gap-4 p-3.5 rounded-lg bg-background border w-full [&>button]:grow">
<AddGithubProvider />
<AddGitlabProvider />
<AddBitbucketProvider />
<AddGiteaProvider />
</div>
</div>
)}
</div>
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
@@ -112,16 +106,14 @@ export const ShowGitProviders = () => {
<span className="text-base font-medium">
Available Providers
</span>
{permissions?.gitProviders.create && (
<div className="flex items-center bg-sidebar p-1 w-full rounded-lg">
<div className="flex flex-wrap items-center gap-4 p-3.5 rounded-lg bg-background border w-full [&>button]:grow">
<AddGithubProvider />
<AddGitlabProvider />
<AddBitbucketProvider />
<AddGiteaProvider />
</div>
<div className="flex items-center bg-sidebar p-1 w-full rounded-lg">
<div className="flex flex-wrap items-center gap-4 p-3.5 rounded-lg bg-background border w-full [&>button]:grow">
<AddGithubProvider />
<AddGitlabProvider />
<AddBitbucketProvider />
<AddGiteaProvider />
</div>
)}
</div>
</div>
<div className="flex flex-col gap-4 rounded-lg ">
@@ -131,13 +123,17 @@ export const ShowGitProviders = () => {
const isBitbucket =
gitProvider.providerType === "bitbucket";
const isGitea = gitProvider.providerType === "gitea";
const canManage = gitProvider.isOwner || isOrgAdmin;
const haveGithubRequirements =
isGithub && gitProvider.github?.isConfigured;
isGithub &&
gitProvider.github?.githubPrivateKey &&
gitProvider.github?.githubAppId &&
gitProvider.github?.githubInstallationId;
const haveGitlabRequirements =
isGitlab && gitProvider.gitlab?.isConfigured;
isGitlab &&
gitProvider.gitlab?.accessToken &&
gitProvider.gitlab?.refreshToken;
return (
<div
@@ -225,7 +221,8 @@ export const ShowGitProviders = () => {
)}
{isBitbucket &&
gitProvider.bitbucket?.isDeprecated ? (
gitProvider.bitbucket?.appPassword &&
!gitProvider.bitbucket?.apiToken ? (
<Badge variant="yellow">Deprecated</Badge>
) : null}
@@ -238,7 +235,7 @@ export const ShowGitProviders = () => {
Action Required
</Badge>
<Link
href={`${gitProvider?.github?.githubAppName}/installations/new?state=gh_setup:${gitProvider?.github?.githubId}`}
href={`${gitProvider?.github?.githubAppName}/installations/new?state=gh_setup:${gitProvider?.github.githubId}`}
className={buttonVariants({
size: "icon",
variant: "ghost",
@@ -274,7 +271,7 @@ export const ShowGitProviders = () => {
href={getGitlabUrl(
gitProvider.gitlab?.applicationId || "",
gitProvider.gitlab?.gitlabId || "",
gitProvider.gitlab?.gitlabUrl || "",
gitProvider.gitlab?.gitlabUrl,
)}
target="_blank"
className={buttonVariants({
@@ -287,35 +284,31 @@ export const ShowGitProviders = () => {
</div>
)}
{canManage && (
{gitProvider.isOwner && (
<>
{isGithub &&
haveGithubRequirements &&
gitProvider.github?.githubId && (
<EditGithubProvider
githubId={gitProvider.github.githubId}
/>
)}
{isGithub && haveGithubRequirements && (
<EditGithubProvider
githubId={gitProvider.github?.githubId}
/>
)}
{isGitlab &&
gitProvider.gitlab?.gitlabId && (
<EditGitlabProvider
gitlabId={gitProvider.gitlab.gitlabId}
/>
)}
{isGitlab && (
<EditGitlabProvider
gitlabId={gitProvider.gitlab?.gitlabId}
/>
)}
{isBitbucket &&
gitProvider.bitbucket?.bitbucketId && (
<EditBitbucketProvider
bitbucketId={
gitProvider.bitbucket.bitbucketId
}
/>
)}
{isBitbucket && (
<EditBitbucketProvider
bitbucketId={
gitProvider.bitbucket?.bitbucketId
}
/>
)}
{isGitea && gitProvider.gitea?.giteaId && (
{isGitea && (
<EditGiteaProvider
giteaId={gitProvider.gitea.giteaId}
giteaId={gitProvider.gitea?.giteaId}
/>
)}

View File

@@ -1,48 +0,0 @@
import { HelpCircle } from "lucide-react";
import { toast } from "sonner";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
export const ToggleEnforceSSO = () => {
const { data, refetch } = api.settings.getWebServerSettings.useQuery();
const { mutateAsync } = api.settings.updateEnforceSSO.useMutation();
const handleToggle = async (checked: boolean) => {
try {
await mutateAsync({ enforceSSO: checked });
await refetch();
toast.success("Enforce SSO updated");
} catch {
toast.error("Error updating Enforce SSO");
}
};
return (
<div className="flex items-center gap-4">
<Switch checked={!!data?.enforceSSO} onCheckedChange={handleToggle} />
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Label className="text-primary flex items-center gap-1.5 cursor-pointer">
Enforce SSO
<HelpCircle className="size-4 text-muted-foreground" />
</Label>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-sm">
<p>
When enabled, the email/password login form is hidden and users
must sign in exclusively through SSO.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
};

View File

@@ -1,53 +0,0 @@
import { HelpCircle } from "lucide-react";
import { toast } from "sonner";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
export const ToggleRemoteServersOnly = () => {
const { data, refetch } = api.settings.getWebServerSettings.useQuery();
const { mutateAsync } = api.settings.updateRemoteServersOnly.useMutation();
const handleToggle = async (checked: boolean) => {
try {
await mutateAsync({ remoteServersOnly: checked });
await refetch();
toast.success("Remote Servers Only updated");
} catch {
toast.error("Error updating Remote Servers Only");
}
};
return (
<div className="flex items-center gap-4">
<Switch
checked={!!data?.remoteServersOnly}
onCheckedChange={handleToggle}
/>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Label className="text-primary flex items-center gap-1.5 cursor-pointer">
Remote Servers Only
<HelpCircle className="size-4 text-muted-foreground" />
</Label>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-sm">
<p>
When enabled, all services (applications, databases, compose) must
be deployed to a remote server. Deploying directly to the Dokploy
host VM is not allowed.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
};

View File

@@ -36,7 +36,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
@@ -54,7 +53,6 @@ const Schema = z.object({
message: "SSH Key is required",
}),
serverType: z.enum(["deploy", "build"]).default("deploy"),
enableDockerCleanup: z.boolean().default(true),
});
type Schema = z.infer<typeof Schema>;
@@ -92,7 +90,6 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
username: "root",
sshKeyId: "",
serverType: "deploy",
enableDockerCleanup: true,
},
resolver: zodResolver(Schema),
});
@@ -106,7 +103,6 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
username: data?.username || "root",
sshKeyId: data?.sshKeyId || "",
serverType: data?.serverType || "deploy",
enableDockerCleanup: data?.enableDockerCleanup ?? true,
});
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
@@ -123,7 +119,6 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
username: data.username || "root",
sshKeyId: data.sshKeyId || "",
serverType: data.serverType || "deploy",
enableDockerCleanup: data.enableDockerCleanup,
serverId: serverId || "",
})
.then(async (_data) => {
@@ -423,27 +418,6 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="enableDockerCleanup"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel>Enable Docker Cleanup</FormLabel>
<FormDescription>
Automatically prune unused Docker images daily. Keeps disk
usage in check on this remote server.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</form>
<DialogFooter>

View File

@@ -131,10 +131,10 @@ export const ShowServers = () => {
className="relative hover:shadow-lg transition-shadow flex flex-col bg-transparent"
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<ServerIcon className="size-5 shrink-0 text-muted-foreground" />
<CardTitle className="text-lg break-words min-w-0">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<ServerIcon className="size-5 text-muted-foreground" />
<CardTitle className="text-lg">
{server.name}
</CardTitle>
</div>
@@ -145,7 +145,7 @@ export const ShowServers = () => {
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 shrink-0 p-0"
className="h-8 w-8 p-0"
>
<span className="sr-only">
More options

View File

@@ -1,6 +1,4 @@
import copy from "copy-to-clipboard";
import { CopyIcon, ServerIcon } from "lucide-react";
import { toast } from "sonner";
import {
Card,
CardContent,
@@ -9,6 +7,8 @@ import {
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import copy from "copy-to-clipboard";
import { toast } from "sonner";
import { ShowDokployActions } from "./servers/actions/show-dokploy-actions";
import { ShowStorageActions } from "./servers/actions/show-storage-actions";
import { ShowTraefikActions } from "./servers/actions/show-traefik-actions";

View File

@@ -1,482 +0,0 @@
"use client";
import {
Copy,
Dices,
HelpCircle,
Loader2,
ShieldCheck,
ShieldOff,
} from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { DnsHelperModal } from "@/components/dashboard/application/domains/dns-helper-modal";
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
type ServerStatus = "running" | "stopped" | "unknown";
type Target = { serverId: string | null; name: string };
type CertType = "none" | "letsencrypt" | "custom";
type DomainForm = {
host: string;
https: boolean;
certificateType: CertType;
customCertResolver: string;
};
export const ForwardAuthServers = () => {
const utils = api.useUtils();
const [enabled, setEnabled] = useState(false);
const [deployTarget, setDeployTarget] = useState<Target | null>(null);
const [selectedProviderId, setSelectedProviderId] = useState("");
const [forms, setForms] = useState<Record<string, DomainForm>>({});
useEffect(() => {
const id = setTimeout(() => setEnabled(true), 0);
return () => clearTimeout(id);
}, []);
const { data: hostIp } = api.settings.getIp.useQuery();
const { data: servers, isPending } = api.forwardAuth.serverStatus.useQuery(
undefined,
{ enabled, refetchOnWindowFocus: false, staleTime: 30_000 },
);
const { data: providers } = api.forwardAuth.listProviders.useQuery(
undefined,
{
enabled: !!deployTarget,
},
);
const { mutateAsync: saveAuthDomain, isPending: isSaving } =
api.forwardAuth.setAuthDomain.useMutation();
const { mutateAsync: deployOnServer, isPending: isDeploying } =
api.forwardAuth.deployOnServer.useMutation();
const { mutateAsync: removeOnServer, isPending: isRemoving } =
api.forwardAuth.removeOnServer.useMutation();
const { mutateAsync: generateDomain, isPending: isGenerating } =
api.domain.generateDomain.useMutation();
const keyOf = (serverId: string | null) => serverId ?? "local";
useEffect(() => {
if (!servers) return;
setForms((prev) => {
const next = { ...prev };
for (const srv of servers) {
const key = srv.serverId ?? "local";
if (next[key] === undefined) {
next[key] = {
host: srv.authDomain ?? "",
https: srv.https ?? true,
certificateType: (srv.certificateType ?? "letsencrypt") as CertType,
customCertResolver: srv.customCertResolver ?? "",
};
}
}
return next;
});
}, [servers]);
const hasProviders = (providers?.length ?? 0) > 0;
const patchForm = (serverId: string | null, patch: Partial<DomainForm>) =>
setForms((p) => {
const key = keyOf(serverId);
const current: DomainForm = p[key] ?? {
host: "",
https: true,
certificateType: "letsencrypt",
customCertResolver: "",
};
return { ...p, [key]: { ...current, ...patch } };
});
const handleSaveDomain = async (serverId: string | null) => {
const f = forms[keyOf(serverId)];
if (!f?.host.trim()) {
toast.error("Enter an auth domain first");
return false;
}
if (f.certificateType === "custom" && !f.customCertResolver.trim()) {
toast.error("Enter the custom certificate resolver");
return false;
}
try {
await saveAuthDomain({
serverId,
authDomain: f.host.trim(),
https: f.https,
certificateType: f.certificateType,
customCertResolver: f.customCertResolver.trim() || undefined,
});
return true;
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error saving auth domain",
);
return false;
}
};
const handleDeploy = async () => {
if (!deployTarget || !selectedProviderId) {
toast.error("Select an SSO provider first");
return;
}
try {
const saved = await handleSaveDomain(deployTarget.serverId);
if (!saved) return;
await deployOnServer({
serverId: deployTarget.serverId,
providerId: selectedProviderId,
});
await utils.forwardAuth.serverStatus.invalidate();
toast.success("Authentication proxy deployed");
setDeployTarget(null);
setSelectedProviderId("");
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error deploying proxy",
);
}
};
const handleRemove = async (serverId: string | null) => {
try {
await removeOnServer({ serverId });
await utils.forwardAuth.serverStatus.invalidate();
toast.success("Authentication proxy removed");
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error removing proxy",
);
}
};
const handleGenerateDomain = async (serverId: string | null) => {
try {
const host = await generateDomain({
appName: "auth",
serverId: serverId ?? undefined,
});
patchForm(serverId, { host, https: false, certificateType: "none" });
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error generating domain",
);
}
};
const statusBadge = (status: ServerStatus) => {
if (status === "running") {
return (
<Badge
variant="outline"
className="border-emerald-500/40 text-emerald-500"
>
<ShieldCheck className="mr-1 size-3" />
Running
</Badge>
);
}
if (status === "stopped") {
return (
<Badge variant="secondary">
<ShieldOff className="mr-1 size-3" />
Not deployed
</Badge>
);
}
return (
<Badge
variant="outline"
className="border-amber-500/40 text-amber-500"
title="Could not reach this server in time"
>
<HelpCircle className="mr-1 size-3" />
Unknown
</Badge>
);
};
return (
<Card className="bg-transparent">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl">
<ShieldCheck className="size-5" />
Application Authentication
</CardTitle>
<CardDescription>
Each server has its own authentication domain and proxy. Set an auth
domain (e.g. auth.acme.com) per server, register its callback URL once
in your identity provider, then deploy the proxy. Apps on that server
under the same base domain are then one click to protect.
<span className="mt-2 block font-medium">
Only OIDC providers are supported SAML is not compatible with the
forward-auth proxy.
</span>
</CardDescription>
</CardHeader>
<CardContent>
{isPending || !enabled ? (
<div className="flex items-center gap-2 justify-center py-6 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
<span className="text-sm">Checking servers...</span>
</div>
) : (
<div className="flex flex-col gap-4">
{servers?.map((srv) => {
const key = keyOf(srv.serverId);
const f = forms[key];
return (
<div
key={key}
className="flex flex-col gap-3 rounded-lg border p-4"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{srv.name}</span>
<div className="flex items-center gap-2">
{statusBadge(srv.status)}
{srv.status === "running" && (
<DialogAction
title="Remove authentication proxy"
description="Domains on this server protected with SSO will stop requiring authentication until re-enabled. Continue?"
type="destructive"
onClick={() => handleRemove(srv.serverId)}
>
<Button
variant="ghost"
size="sm"
isLoading={isRemoving}
>
Remove
</Button>
</DialogAction>
)}
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-1">
<span className="text-xs font-medium">Auth domain</span>
<div className="flex gap-2">
<Input
placeholder="auth.acme.com"
value={f?.host ?? ""}
onChange={(e) =>
patchForm(srv.serverId, { host: e.target.value })
}
className="font-mono text-sm"
/>
{f?.host && !f.host.includes("sslip.io") && (
<DnsHelperModal
domain={{
host: f.host,
https: f.https,
}}
serverIp={
srv.ipAddress ?? hostIp?.toString() ?? undefined
}
/>
)}
<Button
type="button"
variant="secondary"
size="icon"
isLoading={isGenerating}
title="Generate sslip.io domain"
onClick={() => handleGenerateDomain(srv.serverId)}
>
<Dices className="size-4 text-muted-foreground" />
</Button>
</div>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs font-medium">
Certificate provider
</span>
<Select
value={f?.https ? f.certificateType : "none"}
onValueChange={(v) =>
patchForm(srv.serverId, {
certificateType: v as CertType,
https: v !== "none",
})
}
>
<SelectTrigger>
<SelectValue placeholder="Select a certificate provider" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None (HTTP)</SelectItem>
<SelectItem value="letsencrypt">
Let's Encrypt
</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{f?.certificateType === "custom" && f?.https && (
<div className="flex flex-col gap-1">
<span className="text-xs font-medium">
Custom certificate resolver
</span>
<Input
placeholder="Enter your custom certificate resolver"
value={f?.customCertResolver ?? ""}
onChange={(e) =>
patchForm(srv.serverId, {
customCertResolver: e.target.value,
})
}
/>
</div>
)}
<div className="flex justify-end">
<Button
size="sm"
disabled={!f?.host?.trim()}
onClick={() =>
setDeployTarget({
serverId: srv.serverId,
name: srv.name,
})
}
>
Deploy
</Button>
</div>
{srv.callbackUrl && (
<div className="flex flex-col gap-1">
<span className="text-xs font-medium">
Callback URL (register once in your IdP)
</span>
<div className="flex gap-2">
<Input
readOnly
value={srv.callbackUrl}
className="font-mono text-xs"
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => {
navigator.clipboard.writeText(
srv.callbackUrl as string,
);
toast.success("Callback URL copied");
}}
>
<Copy className="size-4" />
</Button>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</CardContent>
<Dialog
open={!!deployTarget}
onOpenChange={(open) => {
if (!open) {
setDeployTarget(null);
setSelectedProviderId("");
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Deploy authentication proxy</DialogTitle>
<DialogDescription>
Deploy the SSO proxy on{" "}
<span className="font-medium">{deployTarget?.name}</span> using an
OIDC provider.
</DialogDescription>
</DialogHeader>
{!hasProviders && (
<AlertBlock type="warning">
No SSO providers configured. Add an OIDC provider above first.
</AlertBlock>
)}
<div className="flex flex-col gap-2 py-2">
<span className="text-sm font-medium">Identity provider</span>
<Select
value={selectedProviderId}
onValueChange={setSelectedProviderId}
disabled={!hasProviders}
>
<SelectTrigger>
<SelectValue placeholder="Select an SSO provider">
{selectedProviderId || ""}
</SelectValue>
</SelectTrigger>
<SelectContent>
{providers?.map((provider) => (
<SelectItem
key={provider.providerId}
value={provider.providerId}
>
<div className="flex flex-col">
<span className="font-medium">{provider.providerId}</span>
<span className="text-xs text-muted-foreground">
{provider.issuer}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button
isLoading={isSaving || isDeploying}
disabled={!hasProviders || !selectedProviderId}
onClick={handleDeploy}
>
Deploy
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
};

View File

@@ -29,15 +29,10 @@ type SSOEmailForm = z.infer<typeof ssoEmailSchema>;
interface SignInWithSSOProps {
/** Content shown when SSO is collapsed (e.g. email/password form) */
children?: React.ReactNode;
/** When true, SSO is the only option — no fallback to email/password */
enforce?: boolean;
children: React.ReactNode;
}
export function SignInWithSSO({
children,
enforce = false,
}: SignInWithSSOProps) {
export function SignInWithSSO({ children }: SignInWithSSOProps) {
const [expanded, setExpanded] = useState(false);
const form = useForm<SSOEmailForm>({
@@ -77,7 +72,7 @@ export function SignInWithSSO({
<LogIn className="mr-2 size-4" />
Sign in with SSO
</Button>
{!enforce && children}
{children}
</div>
);
}
@@ -118,15 +113,13 @@ export function SignInWithSSO({
</FormItem>
)}
/>
{!enforce && (
<button
type="button"
onClick={() => setExpanded(false)}
className="text-xs text-muted-foreground hover:underline"
>
Use email and password instead
</button>
)}
<button
type="button"
onClick={() => setExpanded(false)}
className="text-xs text-muted-foreground hover:underline"
>
Use email and password instead
</button>
</form>
</Form>
</div>

View File

@@ -1 +0,0 @@
ALTER TABLE "webServerSettings" ADD COLUMN "remoteServersOnly" boolean DEFAULT false NOT NULL;

View File

@@ -1 +0,0 @@
ALTER TABLE "webServerSettings" ADD COLUMN "enforceSSO" boolean DEFAULT false NOT NULL;

View File

@@ -1,11 +0,0 @@
ALTER TABLE "schedule" DROP CONSTRAINT "schedule_userId_user_id_fk";
--> statement-breakpoint
ALTER TABLE "schedule" ADD COLUMN "organizationId" text;--> statement-breakpoint
ALTER TABLE "schedule" ADD CONSTRAINT "schedule_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
UPDATE "schedule" s
SET "organizationId" = m."organization_id"
FROM "member" m
WHERE s."scheduleType" = 'dokploy-server'
AND s."userId" = m."user_id"
AND m."role" = 'owner';--> statement-breakpoint
ALTER TABLE "schedule" DROP COLUMN "userId";

View File

@@ -1,16 +0,0 @@
CREATE TABLE "forward_auth_settings" (
"forwardAuthSettingsId" text PRIMARY KEY NOT NULL,
"authDomain" text NOT NULL,
"baseDomain" text NOT NULL,
"https" boolean DEFAULT true NOT NULL,
"certificateType" "certificateType" DEFAULT 'letsencrypt' NOT NULL,
"customCertResolver" text,
"providerId" text,
"serverId" text,
"createdAt" text NOT NULL,
CONSTRAINT "forward_auth_settings_serverId_unique" UNIQUE("serverId")
);
--> statement-breakpoint
ALTER TABLE "domain" ADD COLUMN "forwardAuthEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "forward_auth_settings" ADD CONSTRAINT "forward_auth_settings_providerId_sso_provider_provider_id_fk" FOREIGN KEY ("providerId") REFERENCES "public"."sso_provider"("provider_id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "forward_auth_settings" ADD CONSTRAINT "forward_auth_settings_serverId_server_serverId_fk" FOREIGN KEY ("serverId") REFERENCES "public"."server"("serverId") ON DELETE cascade ON UPDATE no action;

View File

@@ -1,3 +0,0 @@
ALTER TABLE "sso_provider" DROP CONSTRAINT "sso_provider_user_id_user_id_fk";
--> statement-breakpoint
ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1170,41 +1170,6 @@
"when": 1778303519111,
"tag": "0166_nosy_slapstick",
"breakpoints": true
},
{
"idx": 167,
"version": "7",
"when": 1780122576214,
"tag": "0167_fresh_goliath",
"breakpoints": true
},
{
"idx": 168,
"version": "7",
"when": 1780122833339,
"tag": "0168_long_justice",
"breakpoints": true
},
{
"idx": 169,
"version": "7",
"when": 1780127552074,
"tag": "0169_parched_johnny_storm",
"breakpoints": true
},
{
"idx": 170,
"version": "7",
"when": 1780739532982,
"tag": "0170_amusing_spot",
"breakpoints": true
},
{
"idx": 171,
"version": "7",
"when": 1780775037209,
"tag": "0171_lucky_echo",
"breakpoints": true
}
]
}

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.29.8",
"version": "v0.29.4",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -123,7 +123,7 @@
"lucide-react": "^0.469.0",
"micromatch": "4.0.8",
"nanoid": "3.3.11",
"next": "16.2.6",
"next": "^16.2.0",
"next-themes": "^0.2.1",
"nextjs-toploader": "^3.9.17",
"node-os-utils": "2.0.1",

View File

@@ -1,6 +1,6 @@
import copy from "copy-to-clipboard";
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import copy from "copy-to-clipboard";
import { HelpCircle, ServerOff } from "lucide-react";
import type {
GetServerSidePropsContext,
@@ -10,8 +10,8 @@ import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import { type ReactElement, useState } from "react";
import { toast } from "sonner";
import superjson from "superjson";
import { toast } from "sonner";
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
import { DeleteService } from "@/components/dashboard/compose/delete-service";

View File

@@ -1,6 +1,6 @@
import copy from "copy-to-clipboard";
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import copy from "copy-to-clipboard";
import { HelpCircle, ServerOff } from "lucide-react";
import type {
GetServerSidePropsContext,
@@ -10,8 +10,8 @@ import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import { type ReactElement, useState } from "react";
import { toast } from "sonner";
import superjson from "superjson";
import { toast } from "sonner";
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
import { DeleteService } from "@/components/dashboard/compose/delete-service";

View File

@@ -1,6 +1,6 @@
import copy from "copy-to-clipboard";
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import copy from "copy-to-clipboard";
import { HelpCircle, ServerOff } from "lucide-react";
import type {
GetServerSidePropsContext,
@@ -10,8 +10,8 @@ import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import { type ReactElement, useState } from "react";
import { toast } from "sonner";
import superjson from "superjson";
import { toast } from "sonner";
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
import { DeleteService } from "@/components/dashboard/compose/delete-service";

View File

@@ -10,8 +10,8 @@ import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import { type ReactElement, useState } from "react";
import { toast } from "sonner";
import superjson from "superjson";
import { toast } from "sonner";
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
import { DeleteService } from "@/components/dashboard/compose/delete-service";

View File

@@ -1,6 +1,6 @@
import copy from "copy-to-clipboard";
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import copy from "copy-to-clipboard";
import { HelpCircle, ServerOff } from "lucide-react";
import type {
GetServerSidePropsContext,
@@ -10,8 +10,8 @@ import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import { type ReactElement, useState } from "react";
import { toast } from "sonner";
import superjson from "superjson";
import { toast } from "sonner";
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
import { DeleteService } from "@/components/dashboard/compose/delete-service";

View File

@@ -1,6 +1,6 @@
import copy from "copy-to-clipboard";
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import copy from "copy-to-clipboard";
import { HelpCircle, ServerOff } from "lucide-react";
import type {
GetServerSidePropsContext,
@@ -10,8 +10,8 @@ import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import { type ReactElement, useState } from "react";
import { toast } from "sonner";
import superjson from "superjson";
import { toast } from "sonner";
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
import { DeleteService } from "@/components/dashboard/compose/delete-service";

View File

@@ -5,13 +5,18 @@ import type { ReactElement } from "react";
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { Card } from "@/components/ui/card";
import { api } from "@/utils/api";
function SchedulesPage() {
const { data: user } = api.user.get.useQuery();
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-8xl mx-auto min-h-[45vh]">
<div className="rounded-xl bg-background shadow-md h-full">
<ShowSchedules scheduleType="dokploy-server" id="dokploy-server" />
<ShowSchedules
scheduleType="dokploy-server"
id={user?.user.id || ""}
/>
</div>
</Card>
</div>

View File

@@ -1,28 +1,15 @@
import { IS_CLOUD, validateRequest } from "@dokploy/server";
import { validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { ToggleEnforceSSO } from "@/components/dashboard/settings/servers/actions/toggle-enforce-sso";
import { ToggleRemoteServersOnly } from "@/components/dashboard/settings/servers/actions/toggle-remote-servers-only";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-feature-gate";
import { ForwardAuthServers } from "@/components/proprietary/sso/forward-auth-servers";
import { SSOSettings } from "@/components/proprietary/sso/sso-settings";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Card } from "@/components/ui/card";
import { appRouter } from "@/server/api/root";
interface Props {
isCloud: boolean;
}
const Page = ({ isCloud }: Props) => {
const Page = () => {
return (
<div className="w-full">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
@@ -42,47 +29,6 @@ const Page = ({ isCloud }: Props) => {
</div>
</div>
</Card>
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<EnterpriseFeatureGate
lockedProps={{
title: "Application Authentication",
description:
"Protect deployed applications behind an OIDC SSO gate (oauth2-proxy). Part of Dokploy Enterprise.",
ctaLabel: "Go to License",
}}
>
<ForwardAuthServers />
</EnterpriseFeatureGate>
</div>
</Card>
{!isCloud && (
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<EnterpriseFeatureGate
lockedProps={{
title: "Self-hosted Restrictions",
description:
"Deployment and authentication restrictions are part of Dokploy Enterprise. Add a valid license to configure them.",
ctaLabel: "Go to License",
}}
>
<CardHeader>
<CardTitle className="text-xl">
Self-hosted Restrictions
</CardTitle>
<CardDescription>
Control deployment targets and authentication behavior.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<ToggleRemoteServersOnly />
<ToggleEnforceSSO />
</CardContent>
</EnterpriseFeatureGate>
</div>
</Card>
)}
</div>
</div>
);
@@ -130,7 +76,6 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
return {
props: {
trpcState: helpers.dehydrate(),
isCloud: IS_CLOUD,
},
};
}

View File

@@ -1,8 +1,4 @@
import {
getWebServerSettings,
IS_CLOUD,
isAdminPresent,
} from "@dokploy/server";
import { IS_CLOUD, isAdminPresent } from "@dokploy/server";
import { validateRequest } from "@dokploy/server/lib/auth";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { REGEXP_ONLY_DIGITS } from "input-otp";
@@ -56,9 +52,8 @@ type LoginForm = z.infer<typeof LoginSchema>;
interface Props {
IS_CLOUD: boolean;
enforceSSO: boolean;
}
export default function Home({ IS_CLOUD, enforceSSO }: Props) {
export default function Home({ IS_CLOUD }: Props) {
const router = useRouter();
const { config: whitelabeling } = useWhitelabelingPublic();
const { data: showSignInWithSSO } = api.sso.showSignInWithSSO.useQuery();
@@ -252,9 +247,7 @@ export default function Home({ IS_CLOUD, enforceSSO }: Props) {
<CardContent className="p-0">
{!isTwoFactor ? (
<>
{enforceSSO ? (
<SignInWithSSO enforce />
) : showSignInWithSSO ? (
{showSignInWithSSO ? (
<SignInWithSSO>{loginContent}</SignInWithSSO>
) : (
loginContent
@@ -424,7 +417,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
return {
props: {
IS_CLOUD: IS_CLOUD,
enforceSSO: false,
},
};
}
@@ -450,12 +442,9 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
};
}
const webServerSettings = await getWebServerSettings();
return {
props: {
hasAdmin,
enforceSSO: webServerSettings?.enforceSSO ?? false,
},
};
}

View File

@@ -30,7 +30,6 @@ import { previewDeploymentRouter } from "./routers/preview-deployment";
import { projectRouter } from "./routers/project";
import { auditLogRouter } from "./routers/proprietary/audit-log";
import { customRoleRouter } from "./routers/proprietary/custom-role";
import { forwardAuthRouter } from "./routers/proprietary/forward-auth";
import { licenseKeyRouter } from "./routers/proprietary/license-key";
import { ssoRouter } from "./routers/proprietary/sso";
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
@@ -94,7 +93,6 @@ export const appRouter = createTRPCRouter({
organization: organizationRouter,
licenseKey: licenseKeyRouter,
sso: ssoRouter,
forwardAuth: forwardAuthRouter,
whitelabeling: whitelabelingRouter,
customRole: customRoleRouter,
auditLog: auditLogRouter,

View File

@@ -4,11 +4,11 @@ import {
deleteAllMiddlewares,
findApplicationById,
findEnvironmentById,
findGitProviderById,
findProjectById,
getAccessibleServerIds,
getApplicationStats,
getContainerLogs,
getWebServerSettings,
IS_CLOUD,
mechanizeDockerContainer,
readConfig,
@@ -30,7 +30,6 @@ import {
writeConfigRemote,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { canEditDeployGitSource } from "@dokploy/server/services/git-provider";
import {
addNewService,
checkServiceAccess,
@@ -88,11 +87,7 @@ export const applicationRouter = createTRPCRouter({
await checkServiceAccess(ctx, project.projectId, "create");
const webServerSettings = await getWebServerSettings();
if (
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
!input.serverId
) {
if (IS_CLOUD && !input.serverId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to use a server to create an application",
@@ -174,11 +169,13 @@ export const applicationRouter = createTRPCRouter({
const gitProviderId = getGitProviderId();
if (gitProviderId) {
const canEdit = await canEditDeployGitSource(
gitProviderId,
ctx.session,
);
if (!canEdit) {
try {
const gitProvider = await findGitProviderById(gitProviderId);
if (gitProvider.userId !== ctx.session.userId) {
hasGitProviderAccess = false;
unauthorizedProvider = application.sourceType;
}
} catch {
hasGitProviderAccess = false;
unauthorizedProvider = application.sourceType;
}

View File

@@ -96,11 +96,9 @@ export const clusterRouter = createTRPCRouter({
const docker = await getRemoteDocker(input.serverId);
const result = await docker.swarmInspect();
const docker_version = await docker.version();
const info = await docker.info();
const swarmNodeAddr = info?.Swarm?.NodeAddr;
let ip = swarmNodeAddr || (await getLocalServerIp());
if (!swarmNodeAddr && input.serverId) {
let ip = await getLocalServerIp();
if (input.serverId) {
const server = await findServerById(input.serverId);
ip = server?.ipAddress;
}
@@ -130,11 +128,9 @@ export const clusterRouter = createTRPCRouter({
const docker = await getRemoteDocker(input.serverId);
const result = await docker.swarmInspect();
const docker_version = await docker.version();
const info = await docker.info();
const swarmNodeAddr = info?.Swarm?.NodeAddr;
let ip = swarmNodeAddr || (await getLocalServerIp());
if (!swarmNodeAddr && input.serverId) {
let ip = await getLocalServerIp();
if (input.serverId) {
const server = await findServerById(input.serverId);
ip = server?.ipAddress;
}

View File

@@ -13,6 +13,7 @@ import {
findComposeById,
findDomainsByComposeId,
findEnvironmentById,
findGitProviderById,
findProjectById,
findServerById,
getAccessibleServerIds,
@@ -33,7 +34,6 @@ import {
updateDeploymentStatus,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { canEditDeployGitSource } from "@dokploy/server/services/git-provider";
import {
addNewService,
checkServiceAccess,
@@ -91,11 +91,7 @@ export const composeRouter = createTRPCRouter({
await checkServiceAccess(ctx, project.projectId, "create");
const webServerSettings = await getWebServerSettings();
if (
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
!input.serverId
) {
if (IS_CLOUD && !input.serverId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to use a server to create a compose",
@@ -173,11 +169,13 @@ export const composeRouter = createTRPCRouter({
const gitProviderId = getGitProviderId();
if (gitProviderId) {
const canEdit = await canEditDeployGitSource(
gitProviderId,
ctx.session,
);
if (!canEdit) {
try {
const gitProvider = await findGitProviderById(gitProviderId);
if (gitProvider.userId !== ctx.session.userId) {
hasGitProviderAccess = false;
unauthorizedProvider = compose.sourceType;
}
} catch {
hasGitProviderAccess = false;
unauthorizedProvider = compose.sourceType;
}
@@ -587,11 +585,7 @@ export const composeRouter = createTRPCRouter({
await checkServiceAccess(ctx, environment.projectId, "create");
const webServerSettings = await getWebServerSettings();
if (
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
!input.serverId
) {
if (IS_CLOUD && !input.serverId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to use a server to create a compose",

View File

@@ -38,7 +38,7 @@ export const dockerRouter = createTRPCRouter({
return await getContainers(input.serverId);
}),
restartContainer: withPermission("docker", "read")
restartContainer: withPermission("service", "read")
.input(
z.object({
containerId: z
@@ -64,7 +64,7 @@ export const dockerRouter = createTRPCRouter({
});
}),
startContainer: withPermission("docker", "read")
startContainer: withPermission("service", "read")
.input(
z.object({
containerId: z
@@ -90,7 +90,7 @@ export const dockerRouter = createTRPCRouter({
});
}),
stopContainer: withPermission("docker", "read")
stopContainer: withPermission("service", "read")
.input(
z.object({
containerId: z
@@ -116,7 +116,7 @@ export const dockerRouter = createTRPCRouter({
});
}),
killContainer: withPermission("docker", "read")
killContainer: withPermission("service", "read")
.input(
z.object({
containerId: z

View File

@@ -42,43 +42,6 @@ export const gitProviderRouter = createTRPCRouter({
return results.map((r) => ({
...r,
isOwner: r.userId === ctx.session.userId,
github: r.github
? {
githubId: r.github.githubId,
githubAppName: r.github.githubAppName,
githubAppId: r.github.githubAppId,
githubInstallationId: r.github.githubInstallationId,
isConfigured: !!(
r.github.githubPrivateKey &&
r.github.githubAppId &&
r.github.githubInstallationId
),
}
: null,
gitlab: r.gitlab
? {
gitlabId: r.gitlab.gitlabId,
applicationId: r.gitlab.applicationId,
gitlabUrl: r.gitlab.gitlabUrl,
isConfigured: !!(r.gitlab.accessToken && r.gitlab.refreshToken),
}
: null,
bitbucket: r.bitbucket
? {
bitbucketId: r.bitbucket.bitbucketId,
bitbucketUsername: r.bitbucket.bitbucketUsername,
isConfigured: false,
isDeprecated: !!(r.bitbucket.appPassword && !r.bitbucket.apiToken),
}
: null,
gitea: r.gitea
? {
giteaId: r.gitea.giteaId,
giteaUrl: r.gitea.giteaUrl,
clientId: r.gitea.clientId,
isConfigured: !!(r.gitea.accessToken && r.gitea.refreshToken),
}
: null,
}));
}),

View File

@@ -8,7 +8,6 @@ import {
findProjectById,
getAccessibleServerIds,
getContainerLogs,
getWebServerSettings,
IS_CLOUD,
rebuildDatabase,
removeLibsqlById,
@@ -52,11 +51,7 @@ export const libsqlRouter = createTRPCRouter({
await checkServiceAccess(ctx, project.projectId, "create");
const webServerSettings = await getWebServerSettings();
if (
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
!input.serverId
) {
if (IS_CLOUD && !input.serverId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to use a server to create a Libsql",

View File

@@ -12,7 +12,6 @@ import {
getAccessibleServerIds,
getContainerLogs,
getServiceContainerCommand,
getWebServerSettings,
IS_CLOUD,
rebuildDatabase,
removeMariadbById,
@@ -63,11 +62,7 @@ export const mariadbRouter = createTRPCRouter({
await checkServiceAccess(ctx, project.projectId, "create");
const webServerSettings = await getWebServerSettings();
if (
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
!input.serverId
) {
if (IS_CLOUD && !input.serverId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to use a server to create a Mariadb",

View File

@@ -12,7 +12,6 @@ import {
getAccessibleServerIds,
getContainerLogs,
getServiceContainerCommand,
getWebServerSettings,
IS_CLOUD,
rebuildDatabase,
removeMongoById,
@@ -62,11 +61,7 @@ export const mongoRouter = createTRPCRouter({
await checkServiceAccess(ctx, project.projectId, "create");
const webServerSettings = await getWebServerSettings();
if (
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
!input.serverId
) {
if (IS_CLOUD && !input.serverId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to use a server to create a mongo",

View File

@@ -12,7 +12,6 @@ import {
getAccessibleServerIds,
getContainerLogs,
getServiceContainerCommand,
getWebServerSettings,
IS_CLOUD,
rebuildDatabase,
removeMySqlById,
@@ -63,11 +62,7 @@ export const mysqlRouter = createTRPCRouter({
await checkServiceAccess(ctx, project.projectId, "create");
const webServerSettings = await getWebServerSettings();
if (
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
!input.serverId
) {
if (IS_CLOUD && !input.serverId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to use a server to create a MySQL",

View File

@@ -13,7 +13,6 @@ import {
getContainerLogs,
getMountPath,
getServiceContainerCommand,
getWebServerSettings,
IS_CLOUD,
rebuildDatabase,
removePostgresById,
@@ -64,11 +63,7 @@ export const postgresRouter = createTRPCRouter({
await checkServiceAccess(ctx, project.projectId, "create");
const webServerSettings = await getWebServerSettings();
if (
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
!input.serverId
) {
if (IS_CLOUD && !input.serverId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to use a server to create a Postgres",

View File

@@ -1,207 +0,0 @@
import {
assertApplicationDomainAccess,
deployForwardAuthOnServer,
disableForwardAuthOnDomain,
enableForwardAuthOnDomain,
findServerById,
forwardAuthCallbackUrl,
getDomainSsoStatus,
getForwardAuthServerStatus,
getForwardAuthSettings,
listSsoProvidersForOrg,
removeForwardAuthProxy,
removeForwardAuthSettings,
setForwardAuthSettings,
} from "@dokploy/server";
import {
apiDeployForwardAuthOnServer,
apiForwardAuthDomainTarget,
apiForwardAuthServerTarget,
apiSetForwardAuthSettings,
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import {
createTRPCRouter,
enterpriseProcedure,
withPermission,
} from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
export const forwardAuthRouter = createTRPCRouter({
getAuthDomain: enterpriseProcedure
.input(apiForwardAuthServerTarget)
.query(async ({ ctx, input }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
const settings = await getForwardAuthSettings(input.serverId);
if (!settings) return null;
return {
host: settings.authDomain,
https: settings.https,
certificateType: settings.certificateType,
customCertResolver: settings.customCertResolver,
callbackUrl: forwardAuthCallbackUrl(
settings.authDomain,
settings.https,
),
};
}),
setAuthDomain: enterpriseProcedure
.input(apiSetForwardAuthSettings)
.mutation(async ({ ctx, input }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
const result = await setForwardAuthSettings({
organizationId: ctx.session.activeOrganizationId,
serverId: input.serverId,
authDomain: input.authDomain,
https: input.https,
certificateType: input.certificateType,
customCertResolver: input.customCertResolver,
});
await audit(ctx, {
action: "update",
resourceType: "server",
resourceId: input.serverId ?? "local",
resourceName: "forward-auth-domain",
});
return result;
}),
removeAuthDomain: enterpriseProcedure
.input(apiForwardAuthServerTarget)
.mutation(async ({ ctx, input }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
const result = await removeForwardAuthSettings(input.serverId);
await audit(ctx, {
action: "delete",
resourceType: "server",
resourceId: input.serverId ?? "local",
resourceName: "forward-auth-domain",
});
return result;
}),
listProviders: enterpriseProcedure.query(({ ctx }) =>
listSsoProvidersForOrg(ctx.session.activeOrganizationId),
),
serverStatus: enterpriseProcedure.query(({ ctx }) =>
getForwardAuthServerStatus(ctx.session.activeOrganizationId),
),
deployOnServer: enterpriseProcedure
.input(apiDeployForwardAuthOnServer)
.mutation(async ({ ctx, input }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
const result = await deployForwardAuthOnServer({
serverId: input.serverId ?? undefined,
providerId: input.providerId,
organizationId: ctx.session.activeOrganizationId,
});
await audit(ctx, {
action: "create",
resourceType: "server",
resourceId: input.serverId ?? "local",
resourceName: "forward-auth",
});
return result;
}),
removeOnServer: enterpriseProcedure
.input(apiForwardAuthServerTarget)
.mutation(async ({ ctx, input }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
const result = await removeForwardAuthProxy(input.serverId);
await audit(ctx, {
action: "delete",
resourceType: "server",
resourceId: input.serverId ?? "local",
resourceName: "forward-auth",
});
return result;
}),
status: withPermission("domain", "read")
.input(apiForwardAuthDomainTarget)
.query(({ ctx, input }) => getDomainSsoStatus(ctx, input.domainId)),
enable: withPermission("domain", "create")
.input(apiForwardAuthDomainTarget)
.mutation(async ({ ctx, input }) => {
const domain = await assertApplicationDomainAccess(
ctx,
input.domainId,
"create",
);
const result = await enableForwardAuthOnDomain({
domainId: input.domainId,
});
await audit(ctx, {
action: "update",
resourceType: "domain",
resourceId: domain.domainId,
resourceName: domain.host,
});
return result;
}),
disable: withPermission("domain", "create")
.input(apiForwardAuthDomainTarget)
.mutation(async ({ ctx, input }) => {
const domain = await assertApplicationDomainAccess(
ctx,
input.domainId,
"create",
);
const result = await disableForwardAuthOnDomain({
domainId: input.domainId,
});
await audit(ctx, {
action: "update",
resourceType: "domain",
resourceId: domain.domainId,
resourceName: domain.host,
});
return result;
}),
});

View File

@@ -8,7 +8,6 @@ import {
requestToHeaders,
} from "@dokploy/server/index";
import { auth } from "@dokploy/server/lib/auth";
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
import { TRPCError } from "@trpc/server";
import { and, asc, eq } from "drizzle-orm";
import { z } from "zod";
@@ -44,16 +43,12 @@ export const ssoRouter = createTRPCRouter({
owner.user.enableEnterpriseFeatures && owner.user.isValidEnterpriseLicense
);
}),
enforceSSO: publicProcedure.query(async () => {
if (IS_CLOUD) {
return false;
}
const settings = await getWebServerSettings();
return settings?.enforceSSO ?? false;
}),
listProviders: enterpriseProcedure.query(async ({ ctx }) => {
const providers = await db.query.ssoProvider.findMany({
where: eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
where: and(
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
eq(ssoProvider.userId, ctx.session.userId),
),
columns: {
id: true,
providerId: true,
@@ -85,6 +80,7 @@ export const ssoRouter = createTRPCRouter({
where: and(
eq(ssoProvider.providerId, input.providerId),
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
eq(ssoProvider.userId, ctx.session.userId),
),
columns: {
id: true,
@@ -112,12 +108,12 @@ export const ssoRouter = createTRPCRouter({
where: and(
eq(ssoProvider.providerId, input.providerId),
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
eq(ssoProvider.userId, ctx.session.userId),
),
columns: {
id: true,
issuer: true,
domain: true,
userId: true,
},
});
@@ -129,13 +125,6 @@ export const ssoRouter = createTRPCRouter({
});
}
if (existing.userId !== ctx.session.userId) {
await db
.update(ssoProvider)
.set({ userId: ctx.session.userId })
.where(eq(ssoProvider.id, existing.id));
}
const providers = await db.query.ssoProvider.findMany({
where: eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
columns: { providerId: true, domain: true },
@@ -221,6 +210,7 @@ export const ssoRouter = createTRPCRouter({
where: and(
eq(ssoProvider.providerId, input.providerId),
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
eq(ssoProvider.userId, ctx.session.userId),
),
columns: {
id: true,
@@ -243,6 +233,7 @@ export const ssoRouter = createTRPCRouter({
and(
eq(ssoProvider.providerId, input.providerId),
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
eq(ssoProvider.userId, ctx.session.userId),
),
)
.returning({ id: ssoProvider.id });

View File

@@ -11,7 +11,6 @@ import {
getAccessibleServerIds,
getContainerLogs,
getServiceContainerCommand,
getWebServerSettings,
IS_CLOUD,
rebuildDatabase,
removeRedisById,
@@ -60,11 +59,7 @@ export const redisRouter = createTRPCRouter({
await checkServiceAccess(ctx, project.projectId, "create");
const webServerSettings = await getWebServerSettings();
if (
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
!input.serverId
) {
if (IS_CLOUD && !input.serverId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to use a server to create a Redis",

View File

@@ -75,12 +75,7 @@ export const scheduleRouter = createTRPCRouter({
}
}
}
const newSchedule = await createSchedule({
...input,
...(input.scheduleType === "dokploy-server" && {
organizationId: ctx.session.activeOrganizationId,
}),
});
const newSchedule = await createSchedule(input);
if (newSchedule?.enabled) {
if (IS_CLOUD) {
@@ -167,6 +162,17 @@ export const scheduleRouter = createTRPCRouter({
});
}
}
if (
existingSchedule.scheduleType === "dokploy-server" &&
existingSchedule.userId &&
existingSchedule.userId !== ctx.user.id
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You can only manage your own host-level schedules.",
});
}
}
const updatedSchedule = await updateSchedule(input);
@@ -250,6 +256,17 @@ export const scheduleRouter = createTRPCRouter({
});
}
}
if (
scheduleItem.scheduleType === "dokploy-server" &&
scheduleItem.userId &&
scheduleItem.userId !== ctx.user.id
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You can only manage your own host-level schedules.",
});
}
}
await deleteSchedule(input.scheduleId);
@@ -306,27 +323,21 @@ export const scheduleRouter = createTRPCRouter({
}
}
if (input.scheduleType === "dokploy-server") {
const member = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (member.role !== "owner" && member.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only owners and admins can list host-level schedules.",
});
}
if (
input.scheduleType === "dokploy-server" &&
input.id !== ctx.user.id
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You can only list your own host-level schedules.",
});
}
}
const where = {
application: eq(schedules.applicationId, input.id),
compose: eq(schedules.composeId, input.id),
server: eq(schedules.serverId, input.id),
"dokploy-server": eq(
schedules.organizationId,
ctx.session.activeOrganizationId,
),
"dokploy-server": eq(schedules.userId, input.id),
};
return db.query.schedules.findMany({
where: where[input.scheduleType],
@@ -365,6 +376,17 @@ export const scheduleRouter = createTRPCRouter({
});
}
}
if (
schedule.scheduleType === "dokploy-server" &&
schedule.userId &&
schedule.userId !== ctx.user.id
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this schedule.",
});
}
}
return schedule;
}),
@@ -417,6 +439,17 @@ export const scheduleRouter = createTRPCRouter({
});
}
}
if (
scheduleItem.scheduleType === "dokploy-server" &&
scheduleItem.userId &&
scheduleItem.userId !== ctx.user.id
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You can only manage your own host-level schedules.",
});
}
}
try {
await runCommand(input.scheduleId);

View File

@@ -45,7 +45,7 @@ export const securityRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
const security = await findSecurityById(input.securityId);
await checkServicePermissionAndAccess(ctx, security.applicationId, {
service: ["create"],
service: ["delete"],
});
const result = await deleteSecurityById(input.securityId);
await audit(ctx, {

View File

@@ -45,7 +45,6 @@ import {
redis,
server,
} from "@/server/db/schema";
import { applyDockerCleanupSchedule } from "@/server/utils/docker-cleanup";
export const serverRouter = createTRPCRouter({
create: withPermission("server", "create")
@@ -64,11 +63,6 @@ export const serverRouter = createTRPCRouter({
input,
ctx.session.activeOrganizationId,
);
await applyDockerCleanupSchedule(
project.serverId,
ctx.session.activeOrganizationId,
input.enableDockerCleanup,
);
await audit(ctx, {
action: "create",
resourceType: "server",
@@ -462,12 +456,6 @@ export const serverRouter = createTRPCRouter({
...input,
});
await applyDockerCleanupSchedule(
input.serverId,
ctx.session.activeOrganizationId,
input.enableDockerCleanup,
);
await audit(ctx, {
action: "update",
resourceType: "server",

View File

@@ -77,7 +77,6 @@ import { appRouter } from "../root";
import {
adminProcedure,
createTRPCRouter,
enterpriseProcedure,
protectedProcedure,
publicProcedure,
} from "../trpc";
@@ -446,50 +445,6 @@ export const settingsRouter = createTRPCRouter({
return true;
}),
updateRemoteServersOnly: enterpriseProcedure
.input(z.object({ remoteServersOnly: z.boolean() }))
.mutation(async ({ input, ctx }) => {
if (IS_CLOUD) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "This feature is only available for self-hosted instances",
});
}
await updateWebServerSettings({
remoteServersOnly: input.remoteServersOnly,
});
await audit(ctx, {
action: "update",
resourceType: "settings",
resourceName: "remote-servers-only",
});
return true;
}),
updateEnforceSSO: enterpriseProcedure
.input(z.object({ enforceSSO: z.boolean() }))
.mutation(async ({ input, ctx }) => {
if (IS_CLOUD) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "This feature is only available for self-hosted instances",
});
}
await updateWebServerSettings({
enforceSSO: input.enforceSSO,
});
await audit(ctx, {
action: "update",
resourceType: "settings",
resourceName: "enforce-sso",
});
return true;
}),
readTraefikConfig: adminProcedure.query(() => {
if (IS_CLOUD) {
return true;

View File

@@ -1,39 +0,0 @@
import {
CLEANUP_CRON_JOB,
cleanupAll,
IS_CLOUD,
sendDockerCleanupNotifications,
} from "@dokploy/server";
import { scheduledJobs, scheduleJob } from "node-schedule";
import { removeJob, schedule } from "./backup";
export const applyDockerCleanupSchedule = async (
serverId: string,
organizationId: string,
enable: boolean,
) => {
if (enable) {
if (IS_CLOUD) {
await schedule({
cronSchedule: CLEANUP_CRON_JOB,
serverId,
type: "server",
});
} else {
scheduleJob(serverId, CLEANUP_CRON_JOB, async () => {
await cleanupAll(serverId);
await sendDockerCleanupNotifications(organizationId);
});
}
} else {
if (IS_CLOUD) {
await removeJob({
cronSchedule: CLEANUP_CRON_JOB,
serverId,
type: "server",
});
} else {
scheduledJobs[serverId]?.cancel();
}
}
};

View File

@@ -55,7 +55,6 @@ export const domains = pgTable("domain", {
internalPath: text("internalPath").default("/"),
stripPath: boolean("stripPath").notNull().default(false),
middlewares: text("middlewares").array().default(sql`ARRAY[]::text[]`),
forwardAuthEnabled: boolean("forwardAuthEnabled").notNull().default(false),
});
export const domainsRelations = relations(domains, ({ one }) => ({
@@ -95,7 +94,6 @@ export const apiCreateDomain = createSchema.pick({
internalPath: true,
stripPath: true,
middlewares: true,
forwardAuthEnabled: true,
});
export const apiFindDomain = z.object({
@@ -128,6 +126,5 @@ export const apiUpdateDomain = createSchema
internalPath: true,
stripPath: true,
middlewares: true,
forwardAuthEnabled: true,
})
.merge(createSchema.pick({ domainId: true }).required());

View File

@@ -1,75 +0,0 @@
import { relations } from "drizzle-orm";
import { boolean, pgTable, text } from "drizzle-orm/pg-core";
import { nanoid } from "nanoid";
import { z } from "zod";
import { server } from "./server";
import { certificateType } from "./shared";
import { ssoProvider } from "./sso";
export const forwardAuthSettings = pgTable("forward_auth_settings", {
forwardAuthSettingsId: text("forwardAuthSettingsId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
authDomain: text("authDomain").notNull(),
baseDomain: text("baseDomain").notNull(),
https: boolean("https").notNull().default(true),
certificateType: certificateType("certificateType")
.notNull()
.default("letsencrypt"),
customCertResolver: text("customCertResolver"),
providerId: text("providerId").references(() => ssoProvider.providerId, {
onDelete: "set null",
}),
serverId: text("serverId")
.unique()
.references(() => server.serverId, {
onDelete: "cascade",
}),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
});
export const forwardAuthSettingsRelations = relations(
forwardAuthSettings,
({ one }) => ({
server: one(server, {
fields: [forwardAuthSettings.serverId],
references: [server.serverId],
}),
provider: one(ssoProvider, {
fields: [forwardAuthSettings.providerId],
references: [ssoProvider.providerId],
}),
}),
);
const domainRegex = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/;
export const apiForwardAuthServerTarget = z.object({
serverId: z.string().nullable(),
});
export const apiForwardAuthDomainTarget = z.object({
domainId: z.string().min(1),
});
export const apiSetForwardAuthSettings = z.object({
serverId: z.string().nullable(),
authDomain: z
.string()
.trim()
.toLowerCase()
.refine((v) => domainRegex.test(v), { message: "Invalid auth domain" }),
https: z.boolean().default(true),
certificateType: z
.enum(["none", "letsencrypt", "custom"])
.default("letsencrypt"),
customCertResolver: z.string().optional(),
});
export const apiDeployForwardAuthOnServer = z.object({
serverId: z.string().nullable(),
providerId: z.string().min(1),
});

View File

@@ -10,7 +10,6 @@ export * from "./deployment";
export * from "./destination";
export * from "./domain";
export * from "./environment";
export * from "./forward-auth";
export * from "./git-provider";
export * from "./gitea";
export * from "./github";

View File

@@ -3,11 +3,11 @@ import { boolean, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
import { organization } from "./account";
import { applications } from "./application";
import { compose } from "./compose";
import { deployments } from "./deployment";
import { server } from "./server";
import { user } from "./user";
import { generateAppName } from "./utils";
export const shellTypes = pgEnum("shellType", ["bash", "sh"]);
@@ -46,7 +46,7 @@ export const schedules = pgTable("schedule", {
serverId: text("serverId").references(() => server.serverId, {
onDelete: "cascade",
}),
organizationId: text("organizationId").references(() => organization.id, {
userId: text("userId").references(() => user.id, {
onDelete: "cascade",
}),
enabled: boolean("enabled").notNull().default(true),
@@ -71,9 +71,9 @@ export const schedulesRelations = relations(schedules, ({ one, many }) => ({
fields: [schedules.serverId],
references: [server.serverId],
}),
organization: one(organization, {
fields: [schedules.organizationId],
references: [organization.id],
user: one(user, {
fields: [schedules.userId],
references: [user.id],
}),
deployments: many(deployments),
}));

View File

@@ -147,12 +147,8 @@ export const apiCreateServer = createSchema
username: true,
sshKeyId: true,
serverType: true,
enableDockerCleanup: true,
})
.required()
.extend({
enableDockerCleanup: z.boolean().default(true),
});
.required();
export const apiFindOneServer = z.object({
serverId: z.string().min(1),
@@ -174,12 +170,10 @@ export const apiUpdateServer = createSchema
username: true,
sshKeyId: true,
serverType: true,
enableDockerCleanup: true,
})
.required()
.extend({
command: z.string().optional(),
enableDockerCleanup: z.boolean().default(true),
});
export const apiUpdateServerMonitoring = createSchema

View File

@@ -10,7 +10,7 @@ export const ssoProvider = pgTable("sso_provider", {
oidcConfig: text("oidc_config"),
samlConfig: text("saml_config"),
providerId: text("provider_id").notNull().unique(),
userId: text("user_id").references(() => user.id, { onDelete: "set null" }),
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
organizationId: text("organization_id").references(() => organization.id, {
onDelete: "cascade",
}),

View File

@@ -96,10 +96,6 @@ export const webServerSettings = pgTable("webServerSettings", {
metaTitle: null,
footerText: null,
}),
// Deployment Configuration (self-hosted only)
remoteServersOnly: boolean("remoteServersOnly").notNull().default(false),
// Auth Configuration (self-hosted only)
enforceSSO: boolean("enforceSSO").notNull().default(false),
// Cache Cleanup Configuration
cleanupCacheApplications: boolean("cleanupCacheApplications")
.notNull()
@@ -159,8 +155,6 @@ export const apiUpdateWebServerSettings = createSchema.partial().extend({
cleanupCacheApplications: z.boolean().optional(),
cleanupCacheOnPreviews: z.boolean().optional(),
cleanupCacheOnCompose: z.boolean().optional(),
remoteServersOnly: z.boolean().optional(),
enforceSSO: z.boolean().optional(),
});
export const apiAssignDomain = z

View File

@@ -35,7 +35,6 @@ export * from "./services/port";
export * from "./services/postgres";
export * from "./services/preview-deployment";
export * from "./services/project";
export * from "./services/proprietary/forward-auth";
export * from "./services/proprietary/license-key";
export * from "./services/proprietary/sso";
export * from "./services/redirect";
@@ -51,7 +50,6 @@ export * from "./services/user";
export * from "./services/volume-backups";
export * from "./services/web-server-settings";
export * from "./setup/config-paths";
export * from "./setup/forward-auth-setup";
export * from "./setup/monitoring-setup";
export * from "./setup/postgres-setup";
export * from "./setup/redis-setup";
@@ -102,7 +100,6 @@ export * from "./utils/docker/types";
export * from "./utils/docker/utils";
export * from "./utils/filesystem/directory";
export * from "./utils/filesystem/ssh";
export * from "./utils/git-branch-validation";
export * from "./utils/gpu-setup";
export * from "./utils/notifications/build-error";
export * from "./utils/notifications/build-success";
@@ -111,6 +108,7 @@ export * from "./utils/notifications/docker-cleanup";
export * from "./utils/notifications/dokploy-restart";
export * from "./utils/notifications/server-threshold";
export * from "./utils/notifications/utils";
export * from "./utils/git-branch-validation";
export * from "./utils/process/execAsync";
export * from "./utils/process/spawnAsync";
export * from "./utils/providers/bitbucket";
@@ -129,7 +127,6 @@ export * from "./utils/tracking/hubspot";
export * from "./utils/traefik/application";
export * from "./utils/traefik/domain";
export * from "./utils/traefik/file-types";
export * from "./utils/traefik/forward-auth";
export * from "./utils/traefik/middleware";
export * from "./utils/traefik/redirect";
export * from "./utils/traefik/security";

View File

@@ -95,22 +95,26 @@ export const findApplicationById = async (applicationId: string) => {
const application = await db.query.applications.findFirst({
where: eq(applications.applicationId, applicationId),
with: {
environment: { with: { project: true } },
environment: {
with: {
project: true,
},
},
domains: true,
deployments: true,
mounts: true,
redirects: true,
security: true,
ports: true,
registry: true,
gitlab: true,
github: true,
bitbucket: true,
gitea: true,
server: true,
previewDeployments: true,
registry: { columns: { password: false } },
buildRegistry: { columns: { password: false } },
rollbackRegistry: { columns: { password: false } },
buildRegistry: true,
rollbackRegistry: true,
},
});
if (!application) {

View File

@@ -34,12 +34,7 @@ export const findBackupById = async (backupId: string) => {
mariadb: true,
mongo: true,
libsql: true,
destination: {
columns: {
accessKey: false,
secretAccessKey: false,
},
},
destination: true,
compose: true,
},
});
@@ -88,12 +83,7 @@ export const findBackupsByDbId = async (
mariadb: true,
mongo: true,
libsql: true,
destination: {
columns: {
accessKey: false,
secretAccessKey: false,
},
},
destination: true,
},
});
return result || [];

View File

@@ -131,12 +131,7 @@ export const findComposeById = async (composeId: string) => {
server: true,
backups: {
with: {
destination: {
columns: {
accessKey: false,
secretAccessKey: false,
},
},
destination: true,
deployments: true,
},
},

View File

@@ -43,38 +43,6 @@ export const updateGitProvider = async (
.then((response) => response[0]);
};
// Returns true if the user can edit the git source configuration of an existing
// deploy that is connected to the given provider.
// Owner/admin: always yes.
// Member: only if they own the provider or it's shared with the org.
// Being in accessedGitProviders only grants permission to connect NEW deploys,
// not to modify the git config of an existing deploy owned by someone else.
export const canEditDeployGitSource = async (
gitProviderId: string,
session: { userId: string; activeOrganizationId: string },
): Promise<boolean> => {
const { userId, activeOrganizationId } = session;
const memberRecord = await db.query.member.findFirst({
where: and(
eq(member.userId, userId),
eq(member.organizationId, activeOrganizationId),
),
columns: { role: true },
});
if (memberRecord?.role === "owner") return true;
const provider = await db.query.gitProvider.findFirst({
where: eq(gitProvider.gitProviderId, gitProviderId),
columns: { userId: true, sharedWithOrganization: true },
});
if (!provider) return false;
return provider.userId === userId || provider.sharedWithOrganization;
};
export const getAccessibleGitProviderIds = async (session: {
userId: string;
activeOrganizationId: string;

View File

@@ -63,12 +63,7 @@ export const findLibsqlById = async (libsqlId: string) => {
server: true,
backups: {
with: {
destination: {
columns: {
accessKey: false,
secretAccessKey: false,
},
},
destination: true,
deployments: true,
},
},

View File

@@ -68,12 +68,7 @@ export const findMariadbById = async (mariadbId: string) => {
server: true,
backups: {
with: {
destination: {
columns: {
accessKey: false,
secretAccessKey: false,
},
},
destination: true,
deployments: true,
},
},

View File

@@ -63,12 +63,7 @@ export const findMongoById = async (mongoId: string) => {
server: true,
backups: {
with: {
destination: {
columns: {
accessKey: false,
secretAccessKey: false,
},
},
destination: true,
deployments: true,
},
},

View File

@@ -66,12 +66,7 @@ export const findMySqlById = async (mysqlId: string) => {
server: true,
backups: {
with: {
destination: {
columns: {
accessKey: false,
secretAccessKey: false,
},
},
destination: true,
deployments: true,
},
},

View File

@@ -80,10 +80,9 @@ export const checkPermission = async (
const { id: userId } = ctx.user;
const { activeOrganizationId: organizationId } = ctx.session;
const memberRecord = await findMemberByUserId(userId, organizationId);
const isStaticRole = memberRecord.role in staticRoles;
const isPrivilegedStaticRole =
memberRecord.role === "owner" || memberRecord.role === "admin";
if (isPrivilegedStaticRole) {
if (isStaticRole) {
const allEnterprise = Object.keys(permissions).every((r) =>
enterpriseOnlyResources.has(r),
);
@@ -165,8 +164,6 @@ const getLegacyOverrides = (
},
sshKeys: {
read: !!memberRecord.canAccessToSSHKeys,
create: !!memberRecord.canAccessToSSHKeys,
delete: !!memberRecord.canAccessToSSHKeys,
},
gitProviders: {
read: !!memberRecord.canAccessToGitProviders,

View File

@@ -76,12 +76,7 @@ export const findPostgresById = async (postgresId: string) => {
server: true,
backups: {
with: {
destination: {
columns: {
accessKey: false,
secretAccessKey: false,
},
},
destination: true,
deployments: true,
},
},

View File

@@ -1,382 +0,0 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import { db } from "@dokploy/server/db";
import {
forwardAuthSettings,
server,
ssoProvider,
} from "@dokploy/server/db/schema";
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
import {
deriveBaseDomain,
deriveCookieSecret,
type ForwardAuthOidcConfig,
forwardAuthCallbackUrl,
isForwardAuthRunning,
removeForwardAuth,
setupForwardAuth,
} from "@dokploy/server/setup/forward-auth-setup";
import { manageDomain } from "@dokploy/server/utils/traefik/domain";
import {
manageForwardAuthDomain,
removeForwardAuthDomain,
removeForwardAuthMiddleware,
} from "@dokploy/server/utils/traefik/forward-auth";
import { TRPCError } from "@trpc/server";
import { and, asc, desc, eq, isNotNull, isNull } from "drizzle-orm";
import { findApplicationById } from "../application";
import { findDomainById, updateDomainById } from "../domain";
const resolveOidcConfig = (provider: {
issuer: string;
oidcConfig: string | null;
}): ForwardAuthOidcConfig => {
if (!provider.oidcConfig) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Forward-auth requires an OIDC provider — SAML is not supported.",
});
}
let parsed: any;
try {
parsed = JSON.parse(provider.oidcConfig);
} catch {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to parse the SSO provider OIDC configuration",
});
}
if (!parsed?.clientId || !parsed?.clientSecret) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "SSO provider OIDC config is missing clientId/clientSecret",
});
}
return {
clientId: parsed.clientId,
clientSecret: parsed.clientSecret,
issuer: provider.issuer,
scopes: parsed.scopes,
skipDiscovery: parsed.skipDiscovery,
};
};
const findProviderForOrg = async (
providerId: string,
organizationId: string,
) => {
const provider = await db.query.ssoProvider.findFirst({
where: and(
eq(ssoProvider.providerId, providerId),
eq(ssoProvider.organizationId, organizationId),
),
columns: { providerId: true, issuer: true, oidcConfig: true },
});
if (!provider) {
throw new TRPCError({
code: "NOT_FOUND",
message: "SSO provider not found",
});
}
return provider;
};
export const listSsoProvidersForOrg = async (organizationId: string) => {
return db.query.ssoProvider.findMany({
where: and(
eq(ssoProvider.organizationId, organizationId),
isNotNull(ssoProvider.oidcConfig),
),
columns: { providerId: true, issuer: true, domain: true },
orderBy: [asc(ssoProvider.createdAt)],
});
};
export const getDomainSsoStatus = async (
ctx: { session: { activeOrganizationId: string } },
domainId: string,
) => {
const domain = await findDomainById(domainId);
if (domain.applicationId) {
await checkServicePermissionAndAccess(ctx as any, domain.applicationId, {
domain: ["read"],
});
}
return { enabled: !!domain.forwardAuthEnabled };
};
const settingsWhere = (serverId: string | null) =>
serverId
? eq(forwardAuthSettings.serverId, serverId)
: isNull(forwardAuthSettings.serverId);
export const getForwardAuthSettings = async (serverId: string | null) => {
return db.query.forwardAuthSettings.findFirst({
where: settingsWhere(serverId),
});
};
export const setForwardAuthSettings = async (input: {
organizationId: string;
serverId: string | null;
authDomain: string;
https: boolean;
certificateType: "none" | "letsencrypt" | "custom";
customCertResolver?: string | null;
}) => {
const baseDomain = deriveBaseDomain(input.authDomain);
const existing = await getForwardAuthSettings(input.serverId);
const values = {
authDomain: input.authDomain,
baseDomain,
https: input.https,
certificateType: input.certificateType,
customCertResolver: input.customCertResolver ?? null,
};
if (existing) {
await db
.update(forwardAuthSettings)
.set(values)
.where(settingsWhere(input.serverId));
} else {
await db.insert(forwardAuthSettings).values({
...values,
serverId: input.serverId,
});
}
await manageForwardAuthDomain(input.serverId, {
authDomain: input.authDomain,
https: input.https,
certificateType: input.certificateType,
customCertResolver: input.customCertResolver,
});
if (existing?.providerId) {
const proxyRunning = await isForwardAuthRunning(
input.serverId ?? undefined,
);
if (proxyRunning) {
await deployForwardAuthOnServer({
serverId: input.serverId ?? undefined,
providerId: existing.providerId,
organizationId: input.organizationId,
});
}
}
return { callbackUrl: forwardAuthCallbackUrl(input.authDomain, input.https) };
};
export const removeForwardAuthSettings = async (serverId: string | null) => {
const existing = await getForwardAuthSettings(serverId);
if (!existing) return { ok: true } as const;
await removeForwardAuthDomain(serverId);
await db.delete(forwardAuthSettings).where(settingsWhere(serverId));
return { ok: true } as const;
};
export const deployForwardAuthOnServer = async (input: {
serverId?: string;
providerId: string;
organizationId: string;
}) => {
const settings = await getForwardAuthSettings(input.serverId ?? null);
if (!settings) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message:
"Set the authentication domain for this server before deploying the proxy.",
});
}
const provider = await findProviderForOrg(
input.providerId,
input.organizationId,
);
const oidc = resolveOidcConfig(provider);
await setupForwardAuth({
serverId: input.serverId,
oidc,
cookieSecret: deriveCookieSecret(
`${input.serverId ?? "host"}:${settings.baseDomain}`,
),
authDomain: settings.authDomain,
baseDomain: settings.baseDomain,
authDomainHttps: settings.https,
});
if (settings.providerId !== input.providerId) {
await db
.update(forwardAuthSettings)
.set({ providerId: input.providerId })
.where(settingsWhere(input.serverId ?? null));
}
return { ok: true } as const;
};
const FORWARD_AUTH_CHECK_TIMEOUT_MS = 4000;
const proxyStatus = async (
serverId: string | null,
): Promise<"running" | "stopped" | "unknown"> => {
try {
const running = await Promise.race([
isForwardAuthRunning(serverId ?? undefined),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error("timeout")),
FORWARD_AUTH_CHECK_TIMEOUT_MS,
),
),
]);
return running ? "running" : "stopped";
} catch {
return "unknown";
}
};
export const getForwardAuthServerStatus = async (organizationId: string) => {
const servers = await db.query.server.findMany({
where: and(
eq(server.organizationId, organizationId),
isNotNull(server.sshKeyId),
eq(server.serverType, "deploy"),
),
columns: { serverId: true, name: true, ipAddress: true },
orderBy: [desc(server.createdAt)],
});
const targets: {
serverId: string | null;
name: string;
ipAddress: string | null;
}[] = [
...(IS_CLOUD
? []
: [
{
serverId: null,
name: "Dokploy Server (local)",
ipAddress: null,
},
]),
...servers.map((s) => ({
serverId: s.serverId,
name: s.name,
ipAddress: s.ipAddress,
})),
];
return Promise.all(
targets.map(async (t) => {
const settings = await getForwardAuthSettings(t.serverId);
return {
...t,
status: await proxyStatus(t.serverId),
authDomain: settings?.authDomain ?? null,
https: settings?.https ?? true,
certificateType: settings?.certificateType ?? "none",
customCertResolver: settings?.customCertResolver ?? null,
callbackUrl: settings
? forwardAuthCallbackUrl(settings.authDomain, settings.https)
: null,
};
}),
);
};
export const removeForwardAuthProxy = async (serverId: string | null) => {
await removeForwardAuth(serverId ?? undefined);
await db
.update(forwardAuthSettings)
.set({ providerId: null })
.where(settingsWhere(serverId));
return { ok: true } as const;
};
const resolveApplicationDomain = async (domainId: string) => {
const domain = await findDomainById(domainId);
if (!domain.applicationId) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"SSO forward-auth is currently only supported on application domains",
});
}
const application = await findApplicationById(domain.applicationId);
return { domain, application };
};
export const assertApplicationDomainAccess = async (
ctx: { session: { activeOrganizationId: string } },
domainId: string,
action: "create" | "delete",
) => {
const domain = await findDomainById(domainId);
if (!domain.applicationId) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"SSO forward-auth is currently only supported on application domains",
});
}
await checkServicePermissionAndAccess(ctx as any, domain.applicationId, {
domain: [action],
});
return domain;
};
export const enableForwardAuthOnDomain = async (input: {
domainId: string;
}) => {
const { application } = await resolveApplicationDomain(input.domainId);
const serverId = application.serverId ?? undefined;
const settings = await getForwardAuthSettings(serverId ?? null);
if (!settings?.providerId) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message:
"Deploy the authentication proxy for this server in SSO settings first.",
});
}
const proxyRunning = await isForwardAuthRunning(serverId);
if (!proxyRunning) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message:
"The authentication proxy is not deployed on this server. Deploy it in SSO settings first.",
});
}
await updateDomainById(input.domainId, { forwardAuthEnabled: true });
const domain = await findDomainById(input.domainId);
await manageDomain(application, domain);
return { ok: true } as const;
};
export const disableForwardAuthOnDomain = async (input: {
domainId: string;
}) => {
const { application, domain } = await resolveApplicationDomain(
input.domainId,
);
const uniqueConfigKey = domain.uniqueConfigKey;
await updateDomainById(input.domainId, { forwardAuthEnabled: false });
const updated = await findDomainById(input.domainId);
await manageDomain(application, updated);
await removeForwardAuthMiddleware(application, uniqueConfigKey);
return { ok: true } as const;
};

View File

@@ -27,16 +27,6 @@ export function safeDockerLoginCommand(
return `printf %s ${escapedPassword} | docker login ${escapedRegistry} -u ${escapedUser} --password-stdin`;
}
function sanitizeRegistryError(
error: unknown,
password: string | null | undefined,
): string {
const message =
error instanceof Error ? error.message : "Error with registry login";
if (!password) return message;
return message.split(password).join("***");
}
export const createRegistry = async (
input: z.infer<typeof apiCreateRegistry>,
organizationId: string,
@@ -69,15 +59,10 @@ export const createRegistry = async (
input.username,
input.password,
);
try {
if (input.serverId && input.serverId !== "none") {
await execAsyncRemote(input.serverId, loginCommand);
} else if (newRegistry.registryType === "cloud") {
await execAsync(loginCommand);
}
} catch (error) {
const sanitized = sanitizeRegistryError(error, input.password);
throw new TRPCError({ code: "BAD_REQUEST", message: sanitized });
if (input.serverId && input.serverId !== "none") {
await execAsyncRemote(input.serverId, loginCommand);
} else if (newRegistry.registryType === "cloud") {
await execAsync(loginCommand);
}
return newRegistry;
@@ -144,24 +129,16 @@ export const updateRegistry = async (
});
}
try {
if (registryData?.serverId && registryData?.serverId !== "none") {
await execAsyncRemote(registryData.serverId, loginCommand);
} else if (response?.registryType === "cloud") {
await execAsync(loginCommand);
}
} catch (execError) {
throw new Error(sanitizeRegistryError(execError, response?.password));
if (registryData?.serverId && registryData?.serverId !== "none") {
await execAsyncRemote(registryData.serverId, loginCommand);
} else if (response?.registryType === "cloud") {
await execAsync(loginCommand);
}
return response;
} catch (error) {
const message =
error instanceof TRPCError
? error.message
: error instanceof Error
? error.message
: "Error updating this registry";
error instanceof Error ? error.message : "Error updating this registry";
throw new TRPCError({
code: "BAD_REQUEST",
message,
@@ -185,19 +162,6 @@ export const findRegistryById = async (registryId: string) => {
return registryResponse;
};
export const findRegistryByIdWithCredentials = async (registryId: string) => {
const registryResponse = await db.query.registry.findFirst({
where: eq(registry.registryId, registryId),
});
if (!registryResponse) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Registry not found",
});
}
return registryResponse;
};
export const findAllRegistryByOrganizationId = async (
organizationId: string,
) => {

View File

@@ -7,6 +7,7 @@ import {
deployments as deploymentsSchema,
rollbacks,
} from "../db/schema";
import type { ApplicationNested } from "../utils/builders";
import { getRegistryTag } from "../utils/cluster/upload";
import {
calculateResources,
@@ -22,11 +23,7 @@ import { findDeploymentById } from "./deployment";
import type { Mount } from "./mount";
import type { Port } from "./port";
import type { Project } from "./project";
import {
findRegistryByIdWithCredentials,
type Registry,
safeDockerLoginCommand,
} from "./registry";
import { type Registry, safeDockerLoginCommand } from "./registry";
export const createRollback = async (
input: z.infer<typeof createRollbackSchema>,
@@ -59,29 +56,11 @@ export const createRollback = async (
...rest
} = await findApplicationById(deployment.applicationId);
const registry = rest.registryId
? await findRegistryByIdWithCredentials(rest.registryId)
: rest.registry;
const buildRegistry = rest.buildRegistryId
? await findRegistryByIdWithCredentials(rest.buildRegistryId)
: rest.buildRegistry;
const rollbackRegistry = rest.rollbackRegistryId
? await findRegistryByIdWithCredentials(rest.rollbackRegistryId)
: rest.rollbackRegistry;
const fullContextWithCredentials = {
...rest,
registry,
buildRegistry,
rollbackRegistry,
};
await tx
.update(rollbacks)
.set({
image: tagImage,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fullContext: fullContextWithCredentials as any,
fullContext: rest,
})
.where(eq(rollbacks.rollbackId, rollback.rollbackId));
@@ -183,6 +162,7 @@ export const rollback = async (rollbackId: string) => {
if (!result.fullContext) {
throw new Error("Rollback context not found");
}
// Use the full context for rollback
await rollbackApplication(
application.appName,
result.image || "",
@@ -218,25 +198,24 @@ const rollbackApplication = async (
};
mounts: Mount[];
ports: Port[];
rollbackRegistry?: Registry | null;
rollbackRegistry?: Registry;
},
) => {
if (!fullContext) {
throw new Error("Full context is required for rollback");
}
const rollbackRegistry = fullContext.rollbackRegistry ?? undefined;
// Ensure Docker daemon is authenticated with the rollback registry
// before updating the swarm service. The authconfig in CreateServiceOptions
// alone is not sufficient — Docker Swarm also relies on the daemon's
// cached credentials (~/.docker/config.json) to distribute auth to nodes.
if (rollbackRegistry) {
await dockerLoginForRegistry(rollbackRegistry, serverId);
if (fullContext.rollbackRegistry) {
await dockerLoginForRegistry(fullContext.rollbackRegistry, serverId);
}
const docker = await getRemoteDocker(serverId);
// Use the same configuration as mechanizeDockerContainer
const {
env,
mounts,
@@ -267,9 +246,7 @@ const rollbackApplication = async (
UpdateConfig,
Networks,
Ulimits,
} = generateConfigContainer(
fullContext as Parameters<typeof generateConfigContainer>[0],
);
} = generateConfigContainer(fullContext as ApplicationNested);
const bindsMount = generateBindMounts(mounts);
const envVariables = prepareEnvironmentVariables(
@@ -277,16 +254,18 @@ const rollbackApplication = async (
fullContext.environment.project.env,
);
// Build the full registry image path if rollbackRegistry is available
// e.g., "appName:v5" -> "siumauricio/appName:v5" or "registry.com/prefix/appName:v5"
let rollbackImage = image;
if (rollbackRegistry) {
rollbackImage = getRegistryTag(rollbackRegistry, image);
if (fullContext.rollbackRegistry) {
rollbackImage = getRegistryTag(fullContext.rollbackRegistry, image);
}
const settings: CreateServiceOptions = {
authconfig: {
password: rollbackRegistry?.password || "",
username: rollbackRegistry?.username || "",
serveraddress: rollbackRegistry?.registryUrl || "",
password: fullContext.rollbackRegistry?.password || "",
username: fullContext.rollbackRegistry?.username || "",
serveraddress: fullContext.rollbackRegistry?.registryUrl || "",
},
Name: appName,
TaskTemplate: {

View File

@@ -85,9 +85,6 @@ export const findScheduleOrganizationId = async (scheduleId: string) => {
if (schedule?.server) {
return schedule?.server?.organization?.id;
}
if (schedule?.organizationId) {
return schedule.organizationId;
}
return null;
};

View File

@@ -84,12 +84,7 @@ export const findVolumeBackupById = async (volumeBackupId: string) => {
},
},
},
destination: {
columns: {
accessKey: false,
secretAccessKey: false,
},
},
destination: true,
},
});

View File

@@ -1,158 +0,0 @@
import { createHmac } from "node:crypto";
import type { CreateServiceOptions } from "dockerode";
import { betterAuthSecret } from "../lib/auth-secret";
import { getRemoteDocker } from "../utils/servers/remote-docker";
export const FORWARD_AUTH_SERVICE_NAME = "dokploy-forward-auth";
const FORWARD_AUTH_IMAGE = "quay.io/oauth2-proxy/oauth2-proxy:v7.6.0";
export const FORWARD_AUTH_PORT = 4180;
export interface ForwardAuthOidcConfig {
clientId: string;
clientSecret: string;
issuer: string;
scopes?: string[];
skipDiscovery?: boolean;
}
export interface SetupForwardAuthOptions {
serverId?: string;
oidc: ForwardAuthOidcConfig;
cookieSecret: string;
authDomain: string;
baseDomain: string;
authDomainHttps?: boolean;
emailDomains?: string[];
}
export const deriveBaseDomain = (authDomain: string): string => {
const labels = authDomain.trim().toLowerCase().split(".").filter(Boolean);
const base = labels.length > 2 ? labels.slice(1) : labels;
return `.${base.join(".")}`;
};
export const forwardAuthCallbackUrl = (
authDomain: string,
https: boolean,
): string => `${https ? "https" : "http"}://${authDomain}/oauth2/callback`;
export const deriveCookieSecret = (salt: string): string => {
// oauth2-proxy requires cookie_secret to be exactly 16, 24, or 32 bytes.
// Take the first 32 hex chars (= 16 bytes) to satisfy that constraint.
return createHmac("sha256", betterAuthSecret)
.update(`forward-auth:${salt}`)
.digest("hex")
.slice(0, 32);
};
export const buildForwardAuthEnv = (
options: SetupForwardAuthOptions,
): string[] => {
const { oidc, cookieSecret, authDomain, baseDomain, authDomainHttps } =
options;
const scheme = authDomainHttps ? "https" : "http";
const emailDomains =
options.emailDomains && options.emailDomains.length > 0
? options.emailDomains
: ["*"];
const env: string[] = [
"OAUTH2_PROXY_PROVIDER=oidc",
`OAUTH2_PROXY_OIDC_ISSUER_URL=${oidc.issuer}`,
`OAUTH2_PROXY_CLIENT_ID=${oidc.clientId}`,
`OAUTH2_PROXY_CLIENT_SECRET=${oidc.clientSecret}`,
`OAUTH2_PROXY_COOKIE_SECRET=${cookieSecret}`,
`OAUTH2_PROXY_HTTP_ADDRESS=0.0.0.0:${FORWARD_AUTH_PORT}`,
"OAUTH2_PROXY_REVERSE_PROXY=true",
"OAUTH2_PROXY_SKIP_PROVIDER_BUTTON=true",
"OAUTH2_PROXY_SET_XAUTHREQUEST=true",
"OAUTH2_PROXY_UPSTREAMS=static://202",
`OAUTH2_PROXY_REDIRECT_URL=${scheme}://${authDomain}/oauth2/callback`,
`OAUTH2_PROXY_COOKIE_DOMAINS=${baseDomain}`,
`OAUTH2_PROXY_WHITELIST_DOMAINS=${baseDomain}`,
`OAUTH2_PROXY_COOKIE_SECURE=${authDomainHttps ? "true" : "false"}`,
"OAUTH2_PROXY_INSECURE_OIDC_ALLOW_UNVERIFIED_EMAIL=true",
`OAUTH2_PROXY_EMAIL_DOMAINS=${emailDomains.join(",")}`,
];
const scopes = oidc.scopes?.length
? oidc.scopes
: ["openid", "email", "profile"];
env.push(`OAUTH2_PROXY_SCOPE=${scopes.join(" ")}`);
if (oidc.skipDiscovery) {
env.push("OAUTH2_PROXY_SKIP_OIDC_DISCOVERY=true");
}
return env;
};
export const setupForwardAuth = async (options: SetupForwardAuthOptions) => {
const { serverId } = options;
const docker = await getRemoteDocker(serverId);
const settings: CreateServiceOptions = {
Name: FORWARD_AUTH_SERVICE_NAME,
TaskTemplate: {
ContainerSpec: {
Image: FORWARD_AUTH_IMAGE,
Env: buildForwardAuthEnv(options),
},
Networks: [{ Target: "dokploy-network" }],
Placement: {
Constraints: ["node.role==manager"],
},
},
Mode: {
Replicated: {
Replicas: 1,
},
},
};
try {
const service = docker.getService(FORWARD_AUTH_SERVICE_NAME);
const inspect = await service.inspect();
await service.update({
version: Number.parseInt(inspect.Version.Index),
...settings,
TaskTemplate: {
...settings.TaskTemplate,
ForceUpdate: inspect.Spec.TaskTemplate.ForceUpdate + 1,
},
});
console.log("Forward Auth Updated ✅");
} catch (_) {
try {
await docker.createService(settings);
console.log("Forward Auth Started ✅");
} catch (error: any) {
if (error?.statusCode !== 409) {
throw error;
}
console.log("Forward Auth service already exists, continuing...");
}
}
};
export const removeForwardAuth = async (serverId?: string) => {
const docker = await getRemoteDocker(serverId);
try {
const service = docker.getService(FORWARD_AUTH_SERVICE_NAME);
await service.remove();
console.log("Forward Auth Removed ✅");
} catch {}
};
export const isForwardAuthRunning = async (
serverId?: string,
): Promise<boolean> => {
const docker = await getRemoteDocker(serverId);
try {
await docker.getService(FORWARD_AUTH_SERVICE_NAME).inspect();
return true;
} catch {
return false;
}
};

View File

@@ -4,7 +4,6 @@ import {
createDeploymentBackup,
updateDeploymentStatus,
} from "@dokploy/server/services/deployment";
import { findDestinationById } from "@dokploy/server/services/destination";
import { findEnvironmentById } from "@dokploy/server/services/environment";
import { findProjectById } from "@dokploy/server/services/project";
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
@@ -24,7 +23,7 @@ export const runComposeBackup = async (
const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId);
const { prefix, databaseType, serviceName } = backup;
const destination = await findDestinationById(backup.destinationId);
const destination = backup.destination;
const backupFileName = `${getBackupTimestamp()}.${databaseType === "mongo" ? "bson" : "sql"}.gz`;
const s3AppName = serviceName ? `${appName}_${serviceName}` : appName;
const bucketDestination = `${s3AppName}/${normalizeS3Path(prefix)}${backupFileName}`;

Some files were not shown because too many files have changed in this diff Show More