mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-16 12:45:21 +02:00
Compare commits
60 Commits
v0.29.7
...
feat/concu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0429f40fce | ||
|
|
439f575669 | ||
|
|
fa25fef57b | ||
|
|
1f4f94042f | ||
|
|
e9a0932b23 | ||
|
|
6b68fcab8c | ||
|
|
dfbae18557 | ||
|
|
c1c887d03c | ||
|
|
0f77c40ee3 | ||
|
|
a0288f83d5 | ||
|
|
4900204107 | ||
|
|
0f76d8f385 | ||
|
|
c968a2755e | ||
|
|
f35f3064e9 | ||
|
|
c377be0a14 | ||
|
|
e944603f99 | ||
|
|
e6fc3db08f | ||
|
|
57ef96a458 | ||
|
|
b29a87aaa8 | ||
|
|
705ca54ccc | ||
|
|
aa545ec71c | ||
|
|
51b5af55d0 | ||
|
|
28673a6166 | ||
|
|
f886010acc | ||
|
|
238bb2f6f9 | ||
|
|
1df6774ee8 | ||
|
|
35f452d25f | ||
|
|
931203a310 | ||
|
|
a3c8b3bd42 | ||
|
|
4f6e57cc9c | ||
|
|
41c09cd86b | ||
|
|
6ff2ca0173 | ||
|
|
d56a17c8ae | ||
|
|
85211afd41 | ||
|
|
9bd44512f0 | ||
|
|
ad680ae108 | ||
|
|
d7d642230c | ||
|
|
4ba0f71220 | ||
|
|
8018027330 | ||
|
|
6675aa6f37 | ||
|
|
2f43f605f3 | ||
|
|
103e2f70a8 | ||
|
|
34d38cf90e | ||
|
|
f6e6e5cc00 | ||
|
|
b06138b230 | ||
|
|
af8072d7ad | ||
|
|
6e342ee2f2 | ||
|
|
ef0cf9bd02 | ||
|
|
8d88a34a64 | ||
|
|
a50f958a6f | ||
|
|
1fdbe87d84 | ||
|
|
67278d8783 | ||
|
|
aff200f84f | ||
|
|
558d809871 | ||
|
|
f8fcf68909 | ||
|
|
7a568aadac | ||
|
|
63e33a29cc | ||
|
|
754774ea02 | ||
|
|
a714e0f83f | ||
|
|
9f10f0f4e9 |
42
.claude/skills/frontend-design/SKILL.md
Normal file
42
.claude/skills/frontend-design/SKILL.md
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: frontend-design
|
||||
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
|
||||
license: Complete terms in LICENSE.txt
|
||||
---
|
||||
|
||||
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
|
||||
|
||||
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
|
||||
|
||||
## Design Thinking
|
||||
|
||||
Before coding, understand the context and commit to a BOLD aesthetic direction:
|
||||
- **Purpose**: What problem does this interface solve? Who uses it?
|
||||
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
|
||||
- **Constraints**: Technical requirements (framework, performance, accessibility).
|
||||
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
|
||||
|
||||
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
|
||||
|
||||
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
|
||||
- Production-grade and functional
|
||||
- Visually striking and memorable
|
||||
- Cohesive with a clear aesthetic point-of-view
|
||||
- Meticulously refined in every detail
|
||||
|
||||
## Frontend Aesthetics Guidelines
|
||||
|
||||
Focus on:
|
||||
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
|
||||
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
|
||||
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
|
||||
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
|
||||
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
|
||||
|
||||
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
|
||||
|
||||
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
|
||||
|
||||
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
|
||||
|
||||
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
|
||||
21
.github/workflows/pr-quality.yml
vendored
21
.github/workflows/pr-quality.yml
vendored
@@ -1,21 +0,0 @@
|
||||
|
||||
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
|
||||
@@ -34,6 +34,7 @@ describe("Host rule format regression tests", () => {
|
||||
stripPath: false,
|
||||
customEntrypoint: null,
|
||||
middlewares: null,
|
||||
forwardAuthEnabled: false,
|
||||
};
|
||||
|
||||
describe("Host rule format validation", () => {
|
||||
|
||||
@@ -23,6 +23,7 @@ describe("createDomainLabels", () => {
|
||||
internalPath: "/",
|
||||
stripPath: false,
|
||||
middlewares: null,
|
||||
forwardAuthEnabled: false,
|
||||
};
|
||||
|
||||
it("should create basic labels for web entrypoint", async () => {
|
||||
|
||||
369
apps/dokploy/__test__/git-provider/git-provider-access.test.ts
Normal file
369
apps/dokploy/__test__/git-provider/git-provider-access.test.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
109
apps/dokploy/__test__/queues/concurrency.test.ts
Normal file
109
apps/dokploy/__test__/queues/concurrency.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const hasValidLicense = vi.fn();
|
||||
const getWebServerSettings = vi.fn();
|
||||
const findFirstOrg = vi.fn();
|
||||
const findFirstServer = vi.fn();
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
organization: {
|
||||
findFirst: (...args: unknown[]) => findFirstOrg(...args),
|
||||
},
|
||||
server: {
|
||||
findFirst: (...args: unknown[]) => findFirstServer(...args),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db/schema", () => ({
|
||||
organization: {},
|
||||
server: {},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
||||
hasValidLicense: (...args: unknown[]) => hasValidLicense(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/web-server-settings", () => ({
|
||||
getWebServerSettings: (...args: unknown[]) => getWebServerSettings(...args),
|
||||
}));
|
||||
|
||||
vi.mock("drizzle-orm", () => ({ eq: vi.fn() }));
|
||||
|
||||
import { resolveBuildsConcurrency } from "../../server/queues/concurrency";
|
||||
import { LOCAL_PARTITION } from "../../server/queues/in-memory-queue";
|
||||
|
||||
describe("resolveBuildsConcurrency (enterprise gating)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
findFirstOrg.mockResolvedValue({ id: "org-1" });
|
||||
});
|
||||
|
||||
describe("local web server partition", () => {
|
||||
it("returns the configured concurrency when licensed", async () => {
|
||||
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 5 });
|
||||
hasValidLicense.mockResolvedValue(true);
|
||||
|
||||
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(5);
|
||||
});
|
||||
|
||||
it("clamps to 1 when there is no valid license", async () => {
|
||||
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 10 });
|
||||
hasValidLicense.mockResolvedValue(false);
|
||||
|
||||
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1);
|
||||
});
|
||||
|
||||
it("caps the configured value at 20 when licensed", async () => {
|
||||
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 999 });
|
||||
hasValidLicense.mockResolvedValue(true);
|
||||
|
||||
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(20);
|
||||
});
|
||||
|
||||
it("defaults to 1 when settings are missing", async () => {
|
||||
getWebServerSettings.mockResolvedValue(undefined);
|
||||
hasValidLicense.mockResolvedValue(true);
|
||||
|
||||
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remote server partition", () => {
|
||||
it("returns the server concurrency when its org is licensed", async () => {
|
||||
findFirstServer.mockResolvedValue({
|
||||
buildsConcurrency: 4,
|
||||
organizationId: "org-1",
|
||||
});
|
||||
hasValidLicense.mockResolvedValue(true);
|
||||
|
||||
await expect(resolveBuildsConcurrency("server-1")).resolves.toBe(4);
|
||||
expect(hasValidLicense).toHaveBeenCalledWith("org-1");
|
||||
});
|
||||
|
||||
it("clamps to 1 when the server org is not licensed", async () => {
|
||||
findFirstServer.mockResolvedValue({
|
||||
buildsConcurrency: 8,
|
||||
organizationId: "org-1",
|
||||
});
|
||||
hasValidLicense.mockResolvedValue(false);
|
||||
|
||||
await expect(resolveBuildsConcurrency("server-1")).resolves.toBe(1);
|
||||
});
|
||||
|
||||
it("defaults to 1 for an unknown server", async () => {
|
||||
findFirstServer.mockResolvedValue(undefined);
|
||||
|
||||
await expect(resolveBuildsConcurrency("ghost")).resolves.toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to 1 if resolution throws", async () => {
|
||||
getWebServerSettings.mockRejectedValue(new Error("db down"));
|
||||
|
||||
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1);
|
||||
});
|
||||
});
|
||||
337
apps/dokploy/__test__/queues/in-memory-queue.test.ts
Normal file
337
apps/dokploy/__test__/queues/in-memory-queue.test.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getGroup,
|
||||
getPartition,
|
||||
InMemoryQueue,
|
||||
LOCAL_PARTITION,
|
||||
} from "../../server/queues/in-memory-queue";
|
||||
import type { DeploymentJob } from "../../server/queues/queue-types";
|
||||
|
||||
const appJob = (applicationId: string, serverId?: string): DeploymentJob => ({
|
||||
applicationId,
|
||||
titleLog: "deploy",
|
||||
descriptionLog: "",
|
||||
type: "deploy",
|
||||
applicationType: "application",
|
||||
serverId,
|
||||
});
|
||||
|
||||
const composeJob = (composeId: string, serverId?: string): DeploymentJob => ({
|
||||
composeId,
|
||||
titleLog: "deploy",
|
||||
descriptionLog: "",
|
||||
type: "deploy",
|
||||
applicationType: "compose",
|
||||
serverId,
|
||||
});
|
||||
|
||||
/** A controllable async task: resolves only when `release()` is called. */
|
||||
const deferred = () => {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, release: resolve };
|
||||
};
|
||||
|
||||
const flush = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
describe("getPartition / getGroup", () => {
|
||||
it("partitions by serverId, falling back to the local partition", () => {
|
||||
expect(getPartition(appJob("a"))).toBe(LOCAL_PARTITION);
|
||||
expect(getPartition(appJob("a", "server-1"))).toBe("server-1");
|
||||
});
|
||||
|
||||
it("groups applications and compose by their id", () => {
|
||||
expect(getGroup(appJob("a"))).toBe("application:a");
|
||||
expect(getGroup(composeJob("c"))).toBe("compose:c");
|
||||
});
|
||||
});
|
||||
|
||||
describe("InMemoryQueue concurrency", () => {
|
||||
let nowValue = 0;
|
||||
const now = () => ++nowValue;
|
||||
|
||||
beforeEach(() => {
|
||||
nowValue = 0;
|
||||
});
|
||||
|
||||
it("runs different applications concurrently up to the limit", async () => {
|
||||
const tasks = new Map<string, ReturnType<typeof deferred>>();
|
||||
const started: string[] = [];
|
||||
|
||||
const queue = new InMemoryQueue({ resolveConcurrency: () => 2, now });
|
||||
queue.process(async (job) => {
|
||||
const id = (job.data as any).applicationId;
|
||||
started.push(id);
|
||||
const d = deferred();
|
||||
tasks.set(id, d);
|
||||
await d.promise;
|
||||
});
|
||||
await queue.run();
|
||||
|
||||
await queue.add(appJob("a"));
|
||||
await queue.add(appJob("b"));
|
||||
await queue.add(appJob("c"));
|
||||
await flush();
|
||||
|
||||
// Concurrency 2 -> only a and b start, c waits.
|
||||
expect(started).toEqual(["a", "b"]);
|
||||
|
||||
tasks.get("a")!.release();
|
||||
await flush();
|
||||
|
||||
// A slot freed -> c starts.
|
||||
expect(started).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("serializes jobs of the same application (per-group FIFO)", async () => {
|
||||
const tasks: Array<ReturnType<typeof deferred>> = [];
|
||||
const started: number[] = [];
|
||||
let counter = 0;
|
||||
|
||||
const queue = new InMemoryQueue({ resolveConcurrency: () => 5, now });
|
||||
queue.process(async () => {
|
||||
started.push(++counter);
|
||||
const d = deferred();
|
||||
tasks.push(d);
|
||||
await d.promise;
|
||||
});
|
||||
await queue.run();
|
||||
|
||||
// Two deploys of the SAME app, even with concurrency 5.
|
||||
await queue.add(appJob("same"));
|
||||
await queue.add(appJob("same"));
|
||||
await flush();
|
||||
|
||||
// Only the first one runs; the second waits for the group to free.
|
||||
expect(started).toEqual([1]);
|
||||
|
||||
tasks[0]!.release();
|
||||
await flush();
|
||||
|
||||
expect(started).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("isolates concurrency per server partition", async () => {
|
||||
const started: string[] = [];
|
||||
const tasks = new Map<string, ReturnType<typeof deferred>>();
|
||||
|
||||
// server-1 allows 1, server-2 allows 1, but they are independent.
|
||||
const queue = new InMemoryQueue({
|
||||
resolveConcurrency: () => 1,
|
||||
now,
|
||||
});
|
||||
queue.process(async (job) => {
|
||||
const id = `${job.data.serverId}:${(job.data as any).applicationId}`;
|
||||
started.push(id);
|
||||
const d = deferred();
|
||||
tasks.set(id, d);
|
||||
await d.promise;
|
||||
});
|
||||
await queue.run();
|
||||
|
||||
await queue.add(appJob("a", "server-1"));
|
||||
await queue.add(appJob("b", "server-2"));
|
||||
await flush();
|
||||
|
||||
// One per partition runs in parallel despite concurrency 1 each.
|
||||
expect(started.sort()).toEqual(["server-1:a", "server-2:b"]);
|
||||
});
|
||||
|
||||
it("honors a different concurrency per server", async () => {
|
||||
const started: string[] = [];
|
||||
const tasks = new Map<string, ReturnType<typeof deferred>>();
|
||||
|
||||
// server-fast allows 2, server-slow allows 1.
|
||||
const queue = new InMemoryQueue({
|
||||
resolveConcurrency: (partition) => (partition === "server-fast" ? 2 : 1),
|
||||
now,
|
||||
});
|
||||
queue.process(async (job) => {
|
||||
const id = `${job.data.serverId}:${(job.data as any).applicationId}`;
|
||||
started.push(id);
|
||||
const d = deferred();
|
||||
tasks.set(id, d);
|
||||
await d.promise;
|
||||
});
|
||||
await queue.run();
|
||||
|
||||
await queue.add(appJob("a", "server-fast"));
|
||||
await queue.add(appJob("b", "server-fast"));
|
||||
await queue.add(appJob("c", "server-slow"));
|
||||
await queue.add(appJob("d", "server-slow"));
|
||||
await flush();
|
||||
|
||||
// server-fast runs 2 in parallel; server-slow only 1.
|
||||
expect(started.sort()).toEqual([
|
||||
"server-fast:a",
|
||||
"server-fast:b",
|
||||
"server-slow:c",
|
||||
]);
|
||||
|
||||
// Free a server-slow slot -> its queued app starts.
|
||||
tasks.get("server-slow:c")!.release();
|
||||
await flush();
|
||||
expect(started).toContain("server-slow:d");
|
||||
});
|
||||
|
||||
it("serializes the same app on a server even with spare concurrency", async () => {
|
||||
const started: number[] = [];
|
||||
const tasks: Array<ReturnType<typeof deferred>> = [];
|
||||
let counter = 0;
|
||||
|
||||
// Plenty of room (concurrency 2) but two deploys of the SAME app.
|
||||
const queue = new InMemoryQueue({ resolveConcurrency: () => 2, now });
|
||||
queue.process(async () => {
|
||||
started.push(++counter);
|
||||
const d = deferred();
|
||||
tasks.push(d);
|
||||
await d.promise;
|
||||
});
|
||||
await queue.run();
|
||||
|
||||
await queue.add(appJob("app-x", "server-1"));
|
||||
await queue.add(appJob("app-x", "server-1"));
|
||||
await flush();
|
||||
|
||||
// Only one build of app-x runs despite 2 free slots.
|
||||
expect(started).toEqual([1]);
|
||||
|
||||
tasks[0]!.release();
|
||||
await flush();
|
||||
expect(started).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("clamps concurrency below 1 up to 1 (license-disabled behaviour)", async () => {
|
||||
const started: string[] = [];
|
||||
const tasks = new Map<string, ReturnType<typeof deferred>>();
|
||||
|
||||
// Simulate a non-licensed resolver returning 0 — must still run 1.
|
||||
const queue = new InMemoryQueue({ resolveConcurrency: () => 0, now });
|
||||
queue.process(async (job) => {
|
||||
const id = (job.data as any).applicationId;
|
||||
started.push(id);
|
||||
const d = deferred();
|
||||
tasks.set(id, d);
|
||||
await d.promise;
|
||||
});
|
||||
await queue.run();
|
||||
|
||||
await queue.add(appJob("a"));
|
||||
await queue.add(appJob("b"));
|
||||
await flush();
|
||||
|
||||
expect(started).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("picks up concurrency changes between scheduling ticks", async () => {
|
||||
const started: string[] = [];
|
||||
const tasks = new Map<string, ReturnType<typeof deferred>>();
|
||||
let limit = 1;
|
||||
|
||||
const queue = new InMemoryQueue({
|
||||
resolveConcurrency: () => limit,
|
||||
now,
|
||||
});
|
||||
queue.process(async (job) => {
|
||||
const id = (job.data as any).applicationId;
|
||||
started.push(id);
|
||||
const d = deferred();
|
||||
tasks.set(id, d);
|
||||
await d.promise;
|
||||
});
|
||||
await queue.run();
|
||||
|
||||
await queue.add(appJob("a"));
|
||||
await queue.add(appJob("b"));
|
||||
await flush();
|
||||
expect(started).toEqual(["a"]);
|
||||
|
||||
// Raise the limit (e.g. license activated) and release the running job
|
||||
// so a new tick observes the new concurrency.
|
||||
limit = 2;
|
||||
tasks.get("a")!.release();
|
||||
await flush();
|
||||
|
||||
expect(started).toContain("b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("InMemoryQueue job management", () => {
|
||||
it("lists waiting jobs and removes them by predicate", async () => {
|
||||
const block = deferred();
|
||||
const queue = new InMemoryQueue({ resolveConcurrency: () => 1 });
|
||||
queue.process(async () => {
|
||||
await block.promise;
|
||||
});
|
||||
await queue.run();
|
||||
|
||||
await queue.add(appJob("running"));
|
||||
await queue.add(appJob("waiting-1"));
|
||||
await queue.add(composeJob("waiting-2"));
|
||||
await flush();
|
||||
|
||||
const waiting = await queue.getJobs(["waiting"]);
|
||||
expect(waiting.map((j) => j.data)).toHaveLength(2);
|
||||
|
||||
const removed = queue.removeWaiting(
|
||||
(data) => (data as any).applicationId === "waiting-1",
|
||||
);
|
||||
expect(removed).toBe(1);
|
||||
|
||||
const after = await queue.getJobs(["waiting"]);
|
||||
expect(after).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("clears all waiting jobs", async () => {
|
||||
const block = deferred();
|
||||
const queue = new InMemoryQueue({ resolveConcurrency: () => 1 });
|
||||
queue.process(async () => {
|
||||
await block.promise;
|
||||
});
|
||||
await queue.run();
|
||||
|
||||
await queue.add(appJob("running"));
|
||||
await queue.add(appJob("waiting-1"));
|
||||
await queue.add(appJob("waiting-2"));
|
||||
await flush();
|
||||
|
||||
expect(queue.clearWaiting()).toBe(2);
|
||||
expect(await queue.getJobs(["waiting"])).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("starts processing as soon as a processor is registered", async () => {
|
||||
const started: string[] = [];
|
||||
const queue = new InMemoryQueue({ resolveConcurrency: () => 5 });
|
||||
|
||||
// No processor yet -> jobs queue but do not run.
|
||||
await queue.add(appJob("a"));
|
||||
await flush();
|
||||
expect(started).toEqual([]);
|
||||
|
||||
// Registering the processor auto-starts the queue (no separate run()).
|
||||
queue.process(async (job) => {
|
||||
started.push((job.data as any).applicationId);
|
||||
});
|
||||
await flush();
|
||||
expect(started).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("continues scheduling after a job throws", async () => {
|
||||
const started: string[] = [];
|
||||
const queue = new InMemoryQueue({ resolveConcurrency: () => 1 });
|
||||
queue.process(async (job) => {
|
||||
const id = (job.data as any).applicationId;
|
||||
started.push(id);
|
||||
if (id === "a") throw new Error("boom");
|
||||
});
|
||||
await queue.run();
|
||||
|
||||
await queue.add(appJob("a"));
|
||||
await queue.add(appJob("b"));
|
||||
await flush();
|
||||
|
||||
expect(started).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
233
apps/dokploy/__test__/traefik/forward-auth.test.ts
Normal file
233
apps/dokploy/__test__/traefik/forward-auth.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -148,6 +148,7 @@ const baseDomain: Domain = {
|
||||
internalPath: "/",
|
||||
stripPath: false,
|
||||
middlewares: null,
|
||||
forwardAuthEnabled: false,
|
||||
};
|
||||
|
||||
const baseRedirect: Redirect = {
|
||||
|
||||
@@ -16,12 +16,17 @@ 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: z.coerce.number().optional(),
|
||||
Timeout: z.coerce.number().optional(),
|
||||
StartPeriod: z.coerce.number().optional(),
|
||||
Retries: z.coerce.number().optional(),
|
||||
Interval: optionalNumber,
|
||||
Timeout: optionalNumber,
|
||||
StartPeriod: optionalNumber,
|
||||
Retries: optionalNumber,
|
||||
});
|
||||
|
||||
interface HealthCheckFormProps {
|
||||
@@ -195,7 +200,12 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||
Time between health checks (e.g., 10000000000 for 10 seconds)
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="10000000000" {...field} />
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="10000000000"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -212,7 +222,12 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||
Maximum time to wait for health check response
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="10000000000" {...field} />
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="10000000000"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -229,7 +244,12 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||
Initial grace period before health checks begin
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="10000000000" {...field} />
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="10000000000"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -247,7 +267,12 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||
unhealthy
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="3" {...field} />
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="3"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -62,6 +62,7 @@ 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;
|
||||
@@ -453,6 +454,12 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
</Button>
|
||||
</AddDomain>
|
||||
)}
|
||||
{canCreateDomain && type === "application" && (
|
||||
<HandleForwardAuth
|
||||
domainId={item.domainId}
|
||||
applicationId={id}
|
||||
/>
|
||||
)}
|
||||
{canDeleteDomain && (
|
||||
<DialogAction
|
||||
title="Delete Domain"
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { FileIcon, Folder, Loader2, Workflow } from "lucide-react";
|
||||
import {
|
||||
FileIcon,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
MousePointerClick,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
import React from "react";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import {
|
||||
@@ -68,12 +75,22 @@ export const ShowTraefikSystem = ({ serverId }: Props) => {
|
||||
</div>
|
||||
)}
|
||||
{directories?.length === 0 && (
|
||||
<div className="w-full flex-col gap-2 flex items-center justify-center h-[55vh]">
|
||||
<span className="text-muted-foreground text-lg font-medium">
|
||||
No directories or files detected in{" "}
|
||||
{"'/etc/dokploy/traefik'"}
|
||||
</span>
|
||||
<Folder className="size-8 text-muted-foreground" />
|
||||
<div className="w-full flex-col gap-4 flex items-center justify-center h-[55vh] border border-dashed rounded-lg">
|
||||
<div className="flex items-center justify-center size-14 rounded-full bg-muted">
|
||||
<FolderOpen className="size-7 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1 text-center px-4">
|
||||
<span className="text-base font-medium">
|
||||
No configuration files found
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
There are no directories or files in{" "}
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-xs">
|
||||
/etc/dokploy/traefik
|
||||
</code>{" "}
|
||||
on this server yet.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{directories && directories?.length > 0 && (
|
||||
@@ -89,11 +106,19 @@ export const ShowTraefikSystem = ({ serverId }: Props) => {
|
||||
{file ? (
|
||||
<ShowTraefikFile path={file} serverId={serverId} />
|
||||
) : (
|
||||
<div className="h-full w-full flex-col gap-2 flex items-center justify-center">
|
||||
<span className="text-muted-foreground text-lg font-medium">
|
||||
No file selected
|
||||
</span>
|
||||
<FileIcon className="size-8 text-muted-foreground" />
|
||||
<div className="h-full min-h-[300px] w-full flex-col gap-4 flex items-center justify-center border border-dashed rounded-lg">
|
||||
<div className="flex items-center justify-center size-14 rounded-full bg-muted">
|
||||
<MousePointerClick className="size-7 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1 text-center px-4">
|
||||
<span className="text-base font-medium">
|
||||
Select a file to edit
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Choose a file from the tree on the left to view
|
||||
and edit its contents.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { ShowNodes } from "./show-nodes";
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export const ShowNodesModal = ({ serverId }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer "
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
Show Swarm Nodes
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="min-w-[70vw]">
|
||||
<div className="grid w-full gap-1">
|
||||
<ShowNodes serverId={serverId} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -49,7 +49,11 @@ 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,
|
||||
@@ -87,18 +91,20 @@ 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">
|
||||
Create your first Git Provider
|
||||
No Git Providers configured
|
||||
</span>
|
||||
<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 />
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||
@@ -106,14 +112,16 @@ export const ShowGitProviders = () => {
|
||||
<span className="text-base font-medium">
|
||||
Available Providers
|
||||
</span>
|
||||
<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 />
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 rounded-lg ">
|
||||
@@ -123,17 +131,13 @@ export const ShowGitProviders = () => {
|
||||
const isBitbucket =
|
||||
gitProvider.providerType === "bitbucket";
|
||||
const isGitea = gitProvider.providerType === "gitea";
|
||||
const canManage = gitProvider.isOwner || isOrgAdmin;
|
||||
|
||||
const haveGithubRequirements =
|
||||
isGithub &&
|
||||
gitProvider.github?.githubPrivateKey &&
|
||||
gitProvider.github?.githubAppId &&
|
||||
gitProvider.github?.githubInstallationId;
|
||||
isGithub && gitProvider.github?.isConfigured;
|
||||
|
||||
const haveGitlabRequirements =
|
||||
isGitlab &&
|
||||
gitProvider.gitlab?.accessToken &&
|
||||
gitProvider.gitlab?.refreshToken;
|
||||
isGitlab && gitProvider.gitlab?.isConfigured;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -221,8 +225,7 @@ export const ShowGitProviders = () => {
|
||||
)}
|
||||
|
||||
{isBitbucket &&
|
||||
gitProvider.bitbucket?.appPassword &&
|
||||
!gitProvider.bitbucket?.apiToken ? (
|
||||
gitProvider.bitbucket?.isDeprecated ? (
|
||||
<Badge variant="yellow">Deprecated</Badge>
|
||||
) : null}
|
||||
|
||||
@@ -235,7 +238,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",
|
||||
@@ -271,7 +274,7 @@ export const ShowGitProviders = () => {
|
||||
href={getGitlabUrl(
|
||||
gitProvider.gitlab?.applicationId || "",
|
||||
gitProvider.gitlab?.gitlabId || "",
|
||||
gitProvider.gitlab?.gitlabUrl,
|
||||
gitProvider.gitlab?.gitlabUrl || "",
|
||||
)}
|
||||
target="_blank"
|
||||
className={buttonVariants({
|
||||
@@ -284,31 +287,35 @@ export const ShowGitProviders = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gitProvider.isOwner && (
|
||||
{canManage && (
|
||||
<>
|
||||
{isGithub && haveGithubRequirements && (
|
||||
<EditGithubProvider
|
||||
githubId={gitProvider.github?.githubId}
|
||||
/>
|
||||
)}
|
||||
{isGithub &&
|
||||
haveGithubRequirements &&
|
||||
gitProvider.github?.githubId && (
|
||||
<EditGithubProvider
|
||||
githubId={gitProvider.github.githubId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isGitlab && (
|
||||
<EditGitlabProvider
|
||||
gitlabId={gitProvider.gitlab?.gitlabId}
|
||||
/>
|
||||
)}
|
||||
{isGitlab &&
|
||||
gitProvider.gitlab?.gitlabId && (
|
||||
<EditGitlabProvider
|
||||
gitlabId={gitProvider.gitlab.gitlabId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isBitbucket && (
|
||||
<EditBitbucketProvider
|
||||
bitbucketId={
|
||||
gitProvider.bitbucket?.bitbucketId
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{isBitbucket &&
|
||||
gitProvider.bitbucket?.bitbucketId && (
|
||||
<EditBitbucketProvider
|
||||
bitbucketId={
|
||||
gitProvider.bitbucket.bitbucketId
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isGitea && (
|
||||
{isGitea && gitProvider.gitea?.giteaId && (
|
||||
<EditGiteaProvider
|
||||
giteaId={gitProvider.gitea?.giteaId}
|
||||
giteaId={gitProvider.gitea.giteaId}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const clamp = (value: number) => Math.min(20, Math.max(1, value));
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* When provided, configures concurrency for that remote server. When
|
||||
* omitted, configures the local Dokploy web server.
|
||||
*/
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enterprise-only control to set the number of concurrent builds, either for a
|
||||
* remote server (`serverId` provided) or the local web server (omitted).
|
||||
* Hidden when the instance has no valid license.
|
||||
*/
|
||||
export const BuildsConcurrency = ({ serverId }: Props) => {
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data: haveValidLicense } =
|
||||
api.licenseKey.haveValidLicenseKey.useQuery();
|
||||
|
||||
const serverQuery = api.server.one.useQuery(
|
||||
{ serverId: serverId ?? "" },
|
||||
{ enabled: !!serverId },
|
||||
);
|
||||
const webServerQuery = api.settings.getWebServerSettings.useQuery(undefined, {
|
||||
enabled: !serverId,
|
||||
});
|
||||
|
||||
const current = serverId
|
||||
? serverQuery.data?.buildsConcurrency
|
||||
: webServerQuery.data?.buildsConcurrency;
|
||||
const refetch = serverId ? serverQuery.refetch : webServerQuery.refetch;
|
||||
|
||||
const updateServer = api.server.updateBuildsConcurrency.useMutation();
|
||||
const updateWebServer = api.settings.updateBuildsConcurrency.useMutation();
|
||||
const isPending = serverId
|
||||
? updateServer.isPending
|
||||
: updateWebServer.isPending;
|
||||
|
||||
const [value, setValue] = useState("1");
|
||||
|
||||
useEffect(() => {
|
||||
if (current) {
|
||||
setValue(String(current));
|
||||
}
|
||||
}, [current]);
|
||||
|
||||
// Concurrent builds are a self-hosted enterprise feature; not shown in cloud.
|
||||
if (isCloud || !haveValidLicense) return null;
|
||||
|
||||
const handleSave = async () => {
|
||||
const parsed = clamp(Number.parseInt(value, 10) || 1);
|
||||
setValue(String(parsed));
|
||||
try {
|
||||
if (serverId) {
|
||||
await updateServer.mutateAsync({ serverId, buildsConcurrency: parsed });
|
||||
} else {
|
||||
await updateWebServer.mutateAsync({ buildsConcurrency: parsed });
|
||||
}
|
||||
await refetch();
|
||||
toast.success("Builds concurrency updated");
|
||||
} catch {
|
||||
toast.error("Error updating builds concurrency");
|
||||
}
|
||||
};
|
||||
|
||||
const hasChanges = Number(value) !== (current ?? 1);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">Concurrent Builds</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Maximum number of deployments that can build at the same time on
|
||||
{serverId ? " this server" : " the local Dokploy server"}. Builds of
|
||||
the same service are always serialized.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
className="w-20"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
isLoading={isPending}
|
||||
disabled={!hasChanges}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -36,8 +36,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
import { BuildsConcurrency } from "./actions/builds-concurrency";
|
||||
|
||||
const Schema = z.object({
|
||||
name: z.string().min(1, {
|
||||
@@ -53,6 +55,7 @@ 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>;
|
||||
@@ -90,6 +93,7 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
||||
username: "root",
|
||||
sshKeyId: "",
|
||||
serverType: "deploy",
|
||||
enableDockerCleanup: true,
|
||||
},
|
||||
resolver: zodResolver(Schema),
|
||||
});
|
||||
@@ -103,6 +107,7 @@ 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]);
|
||||
|
||||
@@ -119,6 +124,7 @@ 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) => {
|
||||
@@ -418,6 +424,28 @@ 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>
|
||||
)}
|
||||
/>
|
||||
{serverId && <BuildsConcurrency serverId={serverId} />}
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { ShowContainers } from "../../docker/show/show-containers";
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export const ShowDockerContainersModal = ({ serverId }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer "
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
Show Docker Containers
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-7xl ">
|
||||
<div className="grid w-full gap-1">
|
||||
<ShowContainers serverId={serverId} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BarChartHorizontalBigIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { ShowPaidMonitoring } from "../../monitoring/paid/servers/show-paid-monitoring";
|
||||
|
||||
interface Props {
|
||||
@@ -14,12 +15,9 @@ export const ShowMonitoringModal = ({ url, token }: Props) => {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer "
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
Show Monitoring
|
||||
</DropdownMenuItem>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||
<BarChartHorizontalBigIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-7xl ">
|
||||
<div className="flex gap-4 py-4 w-full">
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export const ShowSchedulesModal = ({ serverId }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer "
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
Show Schedules
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-5xl ">
|
||||
<ShowSchedules id={serverId} scheduleType="server" />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Key,
|
||||
KeyIcon,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Network,
|
||||
ServerIcon,
|
||||
Terminal,
|
||||
@@ -25,12 +24,6 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -38,16 +31,11 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
import { ShowNodesModal } from "../cluster/nodes/show-nodes-modal";
|
||||
import { TerminalModal } from "../web-server/terminal-modal";
|
||||
import { ShowServerActions } from "./actions/show-server-actions";
|
||||
import { HandleServers } from "./handle-servers";
|
||||
import { SetupServer } from "./setup-server";
|
||||
import { ShowDockerContainersModal } from "./show-docker-containers-modal";
|
||||
import { ShowMonitoringModal } from "./show-monitoring-modal";
|
||||
import { ShowSchedulesModal } from "./show-schedules-modal";
|
||||
import { ShowSwarmOverviewModal } from "./show-swarm-overview-modal";
|
||||
import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
|
||||
import { WelcomeSubscription } from "./welcome-stripe/welcome-subscription";
|
||||
|
||||
export const ShowServers = () => {
|
||||
@@ -138,52 +126,6 @@ export const ShowServers = () => {
|
||||
{server.name}
|
||||
</CardTitle>
|
||||
</div>
|
||||
{isActive &&
|
||||
server.sshKeyId &&
|
||||
!isBuildServer && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0 p-0"
|
||||
>
|
||||
<span className="sr-only">
|
||||
More options
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>
|
||||
Advanced
|
||||
</DropdownMenuLabel>
|
||||
<ShowTraefikFileSystemModal
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
<ShowDockerContainersModal
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
{isCloud && (
|
||||
<ShowMonitoringModal
|
||||
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
|
||||
token={
|
||||
server?.metricsConfig?.server
|
||||
?.token
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<ShowSwarmOverviewModal
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
<ShowNodesModal
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
<ShowSchedulesModal
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<div className="flex gap-2 mt-2 flex-wrap">
|
||||
@@ -361,6 +303,27 @@ export const ShowServers = () => {
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{isCloud &&
|
||||
server.sshKeyId &&
|
||||
!isBuildServer && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<ShowMonitoringModal
|
||||
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
|
||||
token={
|
||||
server?.metricsConfig
|
||||
?.server?.token
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Monitoring</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{permissions?.server.delete && (
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ShowSwarmContainers } from "../../swarm/containers/show-swarm-containers";
|
||||
import SwarmMonitorCard from "../../swarm/monitoring-card";
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export const ShowSwarmOverviewModal = ({ serverId }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer "
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
Show Swarm Overview
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-7xl ">
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="containers">Containers</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview">
|
||||
<div className="grid w-full gap-1">
|
||||
<SwarmMonitorCard serverId={serverId} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="containers">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
||||
<div className="rounded-xl bg-background shadow-md p-6">
|
||||
<ShowSwarmContainers serverId={serverId} />
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { ShowTraefikSystem } from "../../file-system/show-traefik-system";
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export const ShowTraefikFileSystemModal = ({ serverId }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer "
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
Show Traefik File System
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-7xl ">
|
||||
<ShowTraefikSystem serverId={serverId} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -182,36 +182,31 @@ const MENU: Menu = {
|
||||
title: "Schedules",
|
||||
url: "/dashboard/schedules",
|
||||
icon: Clock,
|
||||
// Only enabled in non-cloud environments
|
||||
isEnabled: ({ isCloud, permissions }) =>
|
||||
!isCloud && !!permissions?.organization.update,
|
||||
isEnabled: ({ permissions }) => !!permissions?.organization.update,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Traefik File System",
|
||||
url: "/dashboard/traefik",
|
||||
icon: GalleryVerticalEnd,
|
||||
// Only enabled for users with access to Traefik files in non-cloud environments
|
||||
isEnabled: ({ permissions, isCloud }) =>
|
||||
!!(permissions?.traefikFiles.read && !isCloud),
|
||||
// Only enabled for users with access to Traefik files
|
||||
isEnabled: ({ permissions }) => !!permissions?.traefikFiles.read,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Docker",
|
||||
url: "/dashboard/docker",
|
||||
icon: BlocksIcon,
|
||||
// Only enabled for users with access to Docker in non-cloud environments
|
||||
isEnabled: ({ permissions, isCloud }) =>
|
||||
!!(permissions?.docker.read && !isCloud),
|
||||
// Only enabled for users with access to Docker
|
||||
isEnabled: ({ permissions }) => !!permissions?.docker.read,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Swarm",
|
||||
url: "/dashboard/swarm",
|
||||
icon: PieChart,
|
||||
// Only enabled for users with access to Docker in non-cloud environments
|
||||
isEnabled: ({ permissions, isCloud }) =>
|
||||
!!(permissions?.docker.read && !isCloud),
|
||||
// Only enabled for users with access to Docker
|
||||
isEnabled: ({ permissions }) => !!permissions?.docker.read,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
@@ -375,9 +370,8 @@ const MENU: Menu = {
|
||||
title: "Cluster",
|
||||
url: "/dashboard/settings/cluster",
|
||||
icon: Boxes,
|
||||
// Only enabled for admins in non-cloud environments
|
||||
isEnabled: ({ permissions, isCloud }) =>
|
||||
!!(permissions?.organization.update && !isCloud),
|
||||
// Only enabled for admins
|
||||
isEnabled: ({ permissions }) => !!permissions?.organization.update,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
|
||||
482
apps/dokploy/components/proprietary/sso/forward-auth-servers.tsx
Normal file
482
apps/dokploy/components/proprietary/sso/forward-auth-servers.tsx
Normal file
@@ -0,0 +1,482 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
156
apps/dokploy/components/shared/server-filter.tsx
Normal file
156
apps/dokploy/components/shared/server-filter.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { Loader2, PlusIcon, ServerIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { Fragment, type ReactNode } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const DOKPLOY_SERVER = "dokploy-server";
|
||||
|
||||
interface Props {
|
||||
children: (serverId?: string) => ReactNode;
|
||||
}
|
||||
|
||||
export const ServerFilter = ({ children }: Props) => {
|
||||
const router = useRouter();
|
||||
const { data: servers, isLoading: isLoadingServers } =
|
||||
api.server.withSSHKey.useQuery();
|
||||
const { data: isCloud, isLoading: isLoadingCloud } =
|
||||
api.settings.isCloud.useQuery();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
|
||||
const queryServerId =
|
||||
typeof router.query.serverId === "string"
|
||||
? router.query.serverId
|
||||
: undefined;
|
||||
|
||||
const selectedServer = servers?.find(
|
||||
(server) => server.serverId === queryServerId,
|
||||
);
|
||||
// Cloud has no local Dokploy server, so fall back to the first remote server
|
||||
const serverId = selectedServer
|
||||
? selectedServer.serverId
|
||||
: isCloud
|
||||
? servers?.[0]?.serverId
|
||||
: undefined;
|
||||
|
||||
const setServerId = (value: string) => {
|
||||
const { serverId: _current, ...query } = router.query;
|
||||
router.replace(
|
||||
{
|
||||
pathname: router.pathname,
|
||||
query: value === DOKPLOY_SERVER ? query : { ...query, serverId: value },
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true },
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoadingServers || isLoadingCloud) {
|
||||
return (
|
||||
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md flex flex-col gap-2 items-center justify-center min-h-[60vh]">
|
||||
<span className="text-muted-foreground text-lg font-medium">
|
||||
Loading...
|
||||
</span>
|
||||
<Loader2 className="animate-spin size-8 text-muted-foreground" />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCloud && !servers?.length) {
|
||||
return (
|
||||
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md flex flex-col items-center justify-center gap-5 min-h-[60vh] border border-dashed px-4">
|
||||
<div className="flex items-center justify-center size-16 rounded-full bg-muted">
|
||||
<ServerIcon className="size-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1.5 text-center max-w-md">
|
||||
<span className="text-lg font-medium">No servers yet</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{permissions?.server.create
|
||||
? "This section works on your remote servers. Add your first server to start managing it from here."
|
||||
: "This section works on your remote servers. Ask an administrator to add a server to your organization."}
|
||||
</span>
|
||||
</div>
|
||||
{permissions?.server.create && (
|
||||
<Button asChild>
|
||||
<Link href="/dashboard/settings/servers">
|
||||
<PlusIcon className="size-4" />
|
||||
Add Server
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
{!!servers?.length && (
|
||||
<div className="flex w-full items-center justify-end gap-3">
|
||||
<Label
|
||||
htmlFor="server-filter"
|
||||
className="text-sm text-muted-foreground whitespace-nowrap"
|
||||
>
|
||||
Viewing server
|
||||
</Label>
|
||||
<Select
|
||||
value={serverId ?? DOKPLOY_SERVER}
|
||||
onValueChange={setServerId}
|
||||
>
|
||||
<SelectTrigger id="server-filter" className="w-fit min-w-[220px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<ServerIcon className="size-4 text-muted-foreground" />
|
||||
<SelectValue placeholder="Select a server" />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Servers</SelectLabel>
|
||||
{!isCloud && (
|
||||
<SelectItem value={DOKPLOY_SERVER}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Dokploy Server</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
Local
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
)}
|
||||
{servers.map((server) => (
|
||||
<SelectItem key={server.serverId} value={server.serverId}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{server.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{server.ipAddress}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<Fragment key={serverId ?? DOKPLOY_SERVER}>{children(serverId)}</Fragment>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
16
apps/dokploy/drizzle/0170_amusing_spot.sql
Normal file
16
apps/dokploy/drizzle/0170_amusing_spot.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
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;
|
||||
3
apps/dokploy/drizzle/0171_lucky_echo.sql
Normal file
3
apps/dokploy/drizzle/0171_lucky_echo.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
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;
|
||||
2
apps/dokploy/drizzle/0172_quick_the_professor.sql
Normal file
2
apps/dokploy/drizzle/0172_quick_the_professor.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "server" ADD COLUMN "buildsConcurrency" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "webServerSettings" ADD COLUMN "buildsConcurrency" integer DEFAULT 1 NOT NULL;
|
||||
@@ -8329,4 +8329,4 @@
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
8444
apps/dokploy/drizzle/meta/0170_snapshot.json
Normal file
8444
apps/dokploy/drizzle/meta/0170_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
8444
apps/dokploy/drizzle/meta/0171_snapshot.json
Normal file
8444
apps/dokploy/drizzle/meta/0171_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
8458
apps/dokploy/drizzle/meta/0172_snapshot.json
Normal file
8458
apps/dokploy/drizzle/meta/0172_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1191,6 +1191,27 @@
|
||||
"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
|
||||
},
|
||||
{
|
||||
"idx": 172,
|
||||
"version": "7",
|
||||
"when": 1781045439162,
|
||||
"tag": "0172_quick_the_professor",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.29.7",
|
||||
"version": "v0.29.8",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { IS_CLOUD } from "@dokploy/server/constants";
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
@@ -6,10 +5,15 @@ import type { ReactElement } from "react";
|
||||
import superjson from "superjson";
|
||||
import { ShowContainers } from "@/components/dashboard/docker/show/show-containers";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { ServerFilter } from "@/components/shared/server-filter";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
|
||||
const Dashboard = () => {
|
||||
return <ShowContainers />;
|
||||
return (
|
||||
<ServerFilter>
|
||||
{(serverId) => <ShowContainers serverId={serverId} />}
|
||||
</ServerFilter>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
@@ -20,14 +24,6 @@ Dashboard.getLayout = (page: ReactElement) => {
|
||||
export async function getServerSideProps(
|
||||
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||
) {
|
||||
if (IS_CLOUD) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
const { user, session } = await validateRequest(ctx.req);
|
||||
if (!user) {
|
||||
return {
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
import { IS_CLOUD } from "@dokploy/server/constants";
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import type { ReactElement } from "react";
|
||||
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { ServerFilter } from "@/components/shared/server-filter";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
function SchedulesPage() {
|
||||
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" />
|
||||
<ServerFilter>
|
||||
{(serverId) => (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full min-h-[45vh]">
|
||||
<div className="rounded-xl bg-background shadow-md h-full">
|
||||
<ShowSchedules
|
||||
scheduleType={serverId ? "server" : "dokploy-server"}
|
||||
id={serverId ?? "dokploy-server"}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</ServerFilter>
|
||||
);
|
||||
}
|
||||
export default SchedulesPage;
|
||||
@@ -26,14 +33,6 @@ SchedulesPage.getLayout = (page: ReactElement) => {
|
||||
export async function getServerSideProps(
|
||||
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||
) {
|
||||
if (IS_CLOUD) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
const { user } = await validateRequest(ctx.req);
|
||||
if (!user || (user.role !== "owner" && user.role !== "admin")) {
|
||||
return {
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
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 { ShowNodes } from "@/components/dashboard/settings/cluster/nodes/show-nodes";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { ServerFilter } from "@/components/shared/server-filter";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<ShowNodes />
|
||||
</div>
|
||||
<ServerFilter>
|
||||
{(serverId) => (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<ShowNodes serverId={serverId} />
|
||||
</div>
|
||||
)}
|
||||
</ServerFilter>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -24,14 +29,6 @@ export async function getServerSideProps(
|
||||
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||
) {
|
||||
const { req, res } = ctx;
|
||||
if (IS_CLOUD) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
const { user, session } = await validateRequest(ctx.req);
|
||||
if (!user || user.role === "member") {
|
||||
return {
|
||||
|
||||
@@ -3,10 +3,12 @@ import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import type { ReactElement } from "react";
|
||||
import superjson from "superjson";
|
||||
import { BuildsConcurrency } from "@/components/dashboard/settings/servers/actions/builds-concurrency";
|
||||
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,
|
||||
@@ -41,6 +43,20 @@ 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">
|
||||
@@ -63,6 +79,7 @@ const Page = ({ isCloud }: Props) => {
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<ToggleRemoteServersOnly />
|
||||
<ToggleEnforceSSO />
|
||||
<BuildsConcurrency />
|
||||
</CardContent>
|
||||
</EnterpriseFeatureGate>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { IS_CLOUD } from "@dokploy/server/constants";
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
@@ -7,30 +6,35 @@ import superjson from "superjson";
|
||||
import { ShowSwarmContainers } from "@/components/dashboard/swarm/containers/show-swarm-containers";
|
||||
import SwarmMonitorCard from "@/components/dashboard/swarm/monitoring-card";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { ServerFilter } from "@/components/shared/server-filter";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
|
||||
const Dashboard = () => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="containers">Containers</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview">
|
||||
<SwarmMonitorCard />
|
||||
</TabsContent>
|
||||
<TabsContent value="containers">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
||||
<div className="rounded-xl bg-background shadow-md p-6">
|
||||
<ShowSwarmContainers />
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
<ServerFilter>
|
||||
{(serverId) => (
|
||||
<div className="space-y-4">
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="containers">Containers</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview">
|
||||
<SwarmMonitorCard serverId={serverId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="containers">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
||||
<div className="rounded-xl bg-background shadow-md p-6">
|
||||
<ShowSwarmContainers serverId={serverId} />
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
</ServerFilter>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -42,14 +46,6 @@ Dashboard.getLayout = (page: ReactElement) => {
|
||||
export async function getServerSideProps(
|
||||
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||
) {
|
||||
if (IS_CLOUD) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
const { user, session } = await validateRequest(ctx.req);
|
||||
if (!user) {
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { IS_CLOUD } from "@dokploy/server/constants";
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
@@ -6,10 +5,15 @@ import type { ReactElement } from "react";
|
||||
import superjson from "superjson";
|
||||
import { ShowTraefikSystem } from "@/components/dashboard/file-system/show-traefik-system";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { ServerFilter } from "@/components/shared/server-filter";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
|
||||
const Dashboard = () => {
|
||||
return <ShowTraefikSystem />;
|
||||
return (
|
||||
<ServerFilter>
|
||||
{(serverId) => <ShowTraefikSystem serverId={serverId} />}
|
||||
</ServerFilter>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
@@ -20,14 +24,6 @@ Dashboard.getLayout = (page: ReactElement) => {
|
||||
export async function getServerSideProps(
|
||||
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||
) {
|
||||
if (IS_CLOUD) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
const { user, session } = await validateRequest(ctx.req);
|
||||
if (!user) {
|
||||
return {
|
||||
|
||||
@@ -30,6 +30,7 @@ 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";
|
||||
@@ -93,6 +94,7 @@ export const appRouter = createTRPCRouter({
|
||||
organization: organizationRouter,
|
||||
licenseKey: licenseKeyRouter,
|
||||
sso: ssoRouter,
|
||||
forwardAuth: forwardAuthRouter,
|
||||
whitelabeling: whitelabelingRouter,
|
||||
customRole: customRoleRouter,
|
||||
auditLog: auditLogRouter,
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
deleteAllMiddlewares,
|
||||
findApplicationById,
|
||||
findEnvironmentById,
|
||||
findGitProviderById,
|
||||
findProjectById,
|
||||
getAccessibleServerIds,
|
||||
getApplicationStats,
|
||||
@@ -31,6 +30,7 @@ import {
|
||||
writeConfigRemote,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { canEditDeployGitSource } from "@dokploy/server/services/git-provider";
|
||||
import {
|
||||
addNewService,
|
||||
checkServiceAccess,
|
||||
@@ -68,11 +68,9 @@ import {
|
||||
environments,
|
||||
projects,
|
||||
} from "@/server/db/schema";
|
||||
import { deploymentWorker } from "@/server/queues/deployments-queue";
|
||||
import type { DeploymentJob } from "@/server/queues/queue-types";
|
||||
import {
|
||||
cleanQueuesByApplication,
|
||||
getJobsByApplicationId,
|
||||
killDockerBuild,
|
||||
myQueue,
|
||||
} from "@/server/queues/queueSetup";
|
||||
@@ -174,13 +172,11 @@ export const applicationRouter = createTRPCRouter({
|
||||
const gitProviderId = getGitProviderId();
|
||||
|
||||
if (gitProviderId) {
|
||||
try {
|
||||
const gitProvider = await findGitProviderById(gitProviderId);
|
||||
if (gitProvider.userId !== ctx.session.userId) {
|
||||
hasGitProviderAccess = false;
|
||||
unauthorizedProvider = application.sourceType;
|
||||
}
|
||||
} catch {
|
||||
const canEdit = await canEditDeployGitSource(
|
||||
gitProviderId,
|
||||
ctx.session,
|
||||
);
|
||||
if (!canEdit) {
|
||||
hasGitProviderAccess = false;
|
||||
unauthorizedProvider = application.sourceType;
|
||||
}
|
||||
@@ -244,12 +240,7 @@ export const applicationRouter = createTRPCRouter({
|
||||
.returning();
|
||||
|
||||
if (!IS_CLOUD) {
|
||||
const queueJobs = await getJobsByApplicationId(input.applicationId);
|
||||
for (const job of queueJobs) {
|
||||
if (job.id) {
|
||||
deploymentWorker.cancelJob(job.id, "User requested cancellation");
|
||||
}
|
||||
}
|
||||
await cleanQueuesByApplication(input.applicationId);
|
||||
}
|
||||
|
||||
const cleanupOperations = [
|
||||
@@ -341,10 +332,10 @@ export const applicationRouter = createTRPCRouter({
|
||||
type: "redeploy",
|
||||
applicationType: "application",
|
||||
server: !!application.serverId,
|
||||
serverId: application.serverId ?? undefined,
|
||||
};
|
||||
|
||||
if (IS_CLOUD && application.serverId) {
|
||||
jobData.serverId = application.serverId;
|
||||
deploy(jobData).catch((error) => {
|
||||
console.error("Background deployment failed:", error);
|
||||
});
|
||||
@@ -709,9 +700,9 @@ export const applicationRouter = createTRPCRouter({
|
||||
type: "deploy",
|
||||
applicationType: "application",
|
||||
server: !!application.serverId,
|
||||
serverId: application.serverId ?? undefined,
|
||||
};
|
||||
if (IS_CLOUD && application.serverId) {
|
||||
jobData.serverId = application.serverId;
|
||||
deploy(jobData).catch((error) => {
|
||||
console.error("Background deployment failed:", error);
|
||||
});
|
||||
@@ -828,9 +819,9 @@ export const applicationRouter = createTRPCRouter({
|
||||
type: "deploy",
|
||||
applicationType: "application",
|
||||
server: !!app.serverId,
|
||||
serverId: app.serverId ?? undefined,
|
||||
};
|
||||
if (IS_CLOUD && app.serverId) {
|
||||
jobData.serverId = app.serverId;
|
||||
deploy(jobData).catch((error) => {
|
||||
console.error("Background deployment failed:", error);
|
||||
});
|
||||
|
||||
@@ -96,9 +96,11 @@ 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();
|
||||
|
||||
let ip = await getLocalServerIp();
|
||||
if (input.serverId) {
|
||||
const swarmNodeAddr = info?.Swarm?.NodeAddr;
|
||||
let ip = swarmNodeAddr || (await getLocalServerIp());
|
||||
if (!swarmNodeAddr && input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
ip = server?.ipAddress;
|
||||
}
|
||||
@@ -128,9 +130,11 @@ 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();
|
||||
|
||||
let ip = await getLocalServerIp();
|
||||
if (input.serverId) {
|
||||
const swarmNodeAddr = info?.Swarm?.NodeAddr;
|
||||
let ip = swarmNodeAddr || (await getLocalServerIp());
|
||||
if (!swarmNodeAddr && input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
ip = server?.ipAddress;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
findComposeById,
|
||||
findDomainsByComposeId,
|
||||
findEnvironmentById,
|
||||
findGitProviderById,
|
||||
findProjectById,
|
||||
findServerById,
|
||||
getAccessibleServerIds,
|
||||
@@ -34,6 +33,7 @@ import {
|
||||
updateDeploymentStatus,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { canEditDeployGitSource } from "@dokploy/server/services/git-provider";
|
||||
import {
|
||||
addNewService,
|
||||
checkServiceAccess,
|
||||
@@ -68,11 +68,9 @@ import {
|
||||
environments,
|
||||
projects,
|
||||
} from "@/server/db/schema";
|
||||
import { deploymentWorker } from "@/server/queues/deployments-queue";
|
||||
import type { DeploymentJob } from "@/server/queues/queue-types";
|
||||
import {
|
||||
cleanQueuesByCompose,
|
||||
getJobsByComposeId,
|
||||
killDockerBuild,
|
||||
myQueue,
|
||||
} from "@/server/queues/queueSetup";
|
||||
@@ -173,13 +171,11 @@ export const composeRouter = createTRPCRouter({
|
||||
const gitProviderId = getGitProviderId();
|
||||
|
||||
if (gitProviderId) {
|
||||
try {
|
||||
const gitProvider = await findGitProviderById(gitProviderId);
|
||||
if (gitProvider.userId !== ctx.session.userId) {
|
||||
hasGitProviderAccess = false;
|
||||
unauthorizedProvider = compose.sourceType;
|
||||
}
|
||||
} catch {
|
||||
const canEdit = await canEditDeployGitSource(
|
||||
gitProviderId,
|
||||
ctx.session,
|
||||
);
|
||||
if (!canEdit) {
|
||||
hasGitProviderAccess = false;
|
||||
unauthorizedProvider = compose.sourceType;
|
||||
}
|
||||
@@ -254,12 +250,7 @@ export const composeRouter = createTRPCRouter({
|
||||
.returning();
|
||||
|
||||
if (!IS_CLOUD) {
|
||||
const queueJobs = await getJobsByComposeId(input.composeId);
|
||||
for (const job of queueJobs) {
|
||||
if (job.id) {
|
||||
deploymentWorker.cancelJob(job.id, "User requested cancellation");
|
||||
}
|
||||
}
|
||||
await cleanQueuesByCompose(input.composeId);
|
||||
}
|
||||
|
||||
const cleanupOperations = [
|
||||
@@ -432,10 +423,10 @@ export const composeRouter = createTRPCRouter({
|
||||
applicationType: "compose",
|
||||
descriptionLog: input.description || "",
|
||||
server: !!compose.serverId,
|
||||
serverId: compose.serverId ?? undefined,
|
||||
};
|
||||
|
||||
if (IS_CLOUD && compose.serverId) {
|
||||
jobData.serverId = compose.serverId;
|
||||
deploy(jobData).catch((error) => {
|
||||
console.error("Background deployment failed:", error);
|
||||
});
|
||||
@@ -481,9 +472,9 @@ export const composeRouter = createTRPCRouter({
|
||||
applicationType: "compose",
|
||||
descriptionLog: input.description || "",
|
||||
server: !!compose.serverId,
|
||||
serverId: compose.serverId ?? undefined,
|
||||
};
|
||||
if (IS_CLOUD && compose.serverId) {
|
||||
jobData.serverId = compose.serverId;
|
||||
deploy(jobData).catch((error) => {
|
||||
console.error("Background deployment failed:", error);
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ export const dockerRouter = createTRPCRouter({
|
||||
return await getContainers(input.serverId);
|
||||
}),
|
||||
|
||||
restartContainer: withPermission("service", "read")
|
||||
restartContainer: withPermission("docker", "read")
|
||||
.input(
|
||||
z.object({
|
||||
containerId: z
|
||||
@@ -64,7 +64,7 @@ export const dockerRouter = createTRPCRouter({
|
||||
});
|
||||
}),
|
||||
|
||||
startContainer: withPermission("service", "read")
|
||||
startContainer: withPermission("docker", "read")
|
||||
.input(
|
||||
z.object({
|
||||
containerId: z
|
||||
@@ -90,7 +90,7 @@ export const dockerRouter = createTRPCRouter({
|
||||
});
|
||||
}),
|
||||
|
||||
stopContainer: withPermission("service", "read")
|
||||
stopContainer: withPermission("docker", "read")
|
||||
.input(
|
||||
z.object({
|
||||
containerId: z
|
||||
@@ -116,7 +116,7 @@ export const dockerRouter = createTRPCRouter({
|
||||
});
|
||||
}),
|
||||
|
||||
killContainer: withPermission("service", "read")
|
||||
killContainer: withPermission("docker", "read")
|
||||
.input(
|
||||
z.object({
|
||||
containerId: z
|
||||
|
||||
@@ -42,6 +42,43 @@ 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,
|
||||
}));
|
||||
}),
|
||||
|
||||
|
||||
@@ -86,10 +86,10 @@ export const previewDeploymentRouter = createTRPCRouter({
|
||||
applicationType: "application-preview",
|
||||
previewDeploymentId: input.previewDeploymentId,
|
||||
server: !!application.serverId,
|
||||
serverId: application.serverId ?? undefined,
|
||||
};
|
||||
|
||||
if (IS_CLOUD && application.serverId) {
|
||||
jobData.serverId = application.serverId;
|
||||
deploy(jobData).catch((error) => {
|
||||
console.error("Background deployment failed:", error);
|
||||
});
|
||||
|
||||
207
apps/dokploy/server/api/routers/proprietary/forward-auth.ts
Normal file
207
apps/dokploy/server/api/routers/proprietary/forward-auth.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
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;
|
||||
}),
|
||||
});
|
||||
@@ -53,10 +53,7 @@ export const ssoRouter = createTRPCRouter({
|
||||
}),
|
||||
listProviders: enterpriseProcedure.query(async ({ ctx }) => {
|
||||
const providers = await db.query.ssoProvider.findMany({
|
||||
where: and(
|
||||
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
|
||||
eq(ssoProvider.userId, ctx.session.userId),
|
||||
),
|
||||
where: eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
|
||||
columns: {
|
||||
id: true,
|
||||
providerId: true,
|
||||
@@ -88,7 +85,6 @@ 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,
|
||||
@@ -116,12 +112,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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -133,6 +129,13 @@ 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 },
|
||||
@@ -218,7 +221,6 @@ 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,
|
||||
@@ -241,7 +243,6 @@ 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 });
|
||||
|
||||
@@ -25,6 +25,7 @@ import { z } from "zod";
|
||||
import { updateServersBasedOnQuantity } from "@/pages/api/stripe/webhook";
|
||||
import {
|
||||
createTRPCRouter,
|
||||
enterpriseProcedure,
|
||||
protectedProcedure,
|
||||
withPermission,
|
||||
} from "@/server/api/trpc";
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
apiFindOneServer,
|
||||
apiRemoveServer,
|
||||
apiUpdateServer,
|
||||
apiUpdateServerBuildsConcurrency,
|
||||
apiUpdateServerMonitoring,
|
||||
applications,
|
||||
compose,
|
||||
@@ -45,6 +47,7 @@ import {
|
||||
redis,
|
||||
server,
|
||||
} from "@/server/db/schema";
|
||||
import { applyDockerCleanupSchedule } from "@/server/utils/docker-cleanup";
|
||||
|
||||
export const serverRouter = createTRPCRouter({
|
||||
create: withPermission("server", "create")
|
||||
@@ -63,6 +66,11 @@ export const serverRouter = createTRPCRouter({
|
||||
input,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
await applyDockerCleanupSchedule(
|
||||
project.serverId,
|
||||
ctx.session.activeOrganizationId,
|
||||
input.enableDockerCleanup,
|
||||
);
|
||||
await audit(ctx, {
|
||||
action: "create",
|
||||
resourceType: "server",
|
||||
@@ -456,6 +464,12 @@ export const serverRouter = createTRPCRouter({
|
||||
...input,
|
||||
});
|
||||
|
||||
await applyDockerCleanupSchedule(
|
||||
input.serverId,
|
||||
ctx.session.activeOrganizationId,
|
||||
input.enableDockerCleanup,
|
||||
);
|
||||
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "server",
|
||||
@@ -467,6 +481,20 @@ export const serverRouter = createTRPCRouter({
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
updateBuildsConcurrency: enterpriseProcedure
|
||||
.input(apiUpdateServerBuildsConcurrency)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const currentServer = await findServerById(input.serverId);
|
||||
if (currentServer.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to update this server",
|
||||
});
|
||||
}
|
||||
return await updateServerById(input.serverId, {
|
||||
buildsConcurrency: input.buildsConcurrency,
|
||||
});
|
||||
}),
|
||||
publicIp: protectedProcedure.query(async () => {
|
||||
if (IS_CLOUD) {
|
||||
return "";
|
||||
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
apiServerSchema,
|
||||
apiTraefikConfig,
|
||||
apiUpdateDockerCleanup,
|
||||
apiUpdateWebServerBuildsConcurrency,
|
||||
projects,
|
||||
server,
|
||||
} from "@/server/db/schema";
|
||||
@@ -468,6 +469,28 @@ export const settingsRouter = createTRPCRouter({
|
||||
return true;
|
||||
}),
|
||||
|
||||
updateBuildsConcurrency: enterpriseProcedure
|
||||
.input(apiUpdateWebServerBuildsConcurrency)
|
||||
.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({
|
||||
buildsConcurrency: input.buildsConcurrency,
|
||||
});
|
||||
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "settings",
|
||||
resourceName: "builds-concurrency",
|
||||
});
|
||||
return true;
|
||||
}),
|
||||
|
||||
updateEnforceSSO: enterpriseProcedure
|
||||
.input(z.object({ enforceSSO: z.boolean() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
|
||||
66
apps/dokploy/server/queues/concurrency.ts
Normal file
66
apps/dokploy/server/queues/concurrency.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { organization, server } from "@dokploy/server/db/schema";
|
||||
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
|
||||
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { LOCAL_PARTITION } from "./in-memory-queue";
|
||||
|
||||
/**
|
||||
* Resolve the effective builds concurrency for a queue partition.
|
||||
*
|
||||
* Concurrent deployments (concurrency > 1) are an enterprise feature: without a
|
||||
* valid license the effective concurrency is always clamped to 1, so the
|
||||
* community experience is unchanged and an expired license degrades gracefully
|
||||
* back to sequential deployments instead of breaking anything.
|
||||
*
|
||||
* - `LOCAL_PARTITION` -> concurrency stored on the web server settings (the
|
||||
* local Dokploy web server), gated by the owner organization's license.
|
||||
* - any other partition -> concurrency stored on the matching `server` row,
|
||||
* gated by that server's organization license.
|
||||
*/
|
||||
export const resolveBuildsConcurrency = async (
|
||||
partition: string,
|
||||
): Promise<number> => {
|
||||
try {
|
||||
if (partition === LOCAL_PARTITION) {
|
||||
return await resolveLocalConcurrency();
|
||||
}
|
||||
return await resolveServerConcurrency(partition);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Failed to resolve builds concurrency, defaulting to 1",
|
||||
error,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
const clamp = (value: number, licensed: boolean): number => {
|
||||
if (!licensed) return 1;
|
||||
return Math.min(20, Math.max(1, value));
|
||||
};
|
||||
|
||||
const resolveLocalConcurrency = async (): Promise<number> => {
|
||||
const settings = await getWebServerSettings();
|
||||
const buildsConcurrency = settings?.buildsConcurrency ?? 1;
|
||||
|
||||
// Self-hosted is single-tenant; gate on any organization's license.
|
||||
const anyOrg = await db.query.organization.findFirst({
|
||||
columns: { id: true },
|
||||
});
|
||||
const licensed = anyOrg ? await hasValidLicense(anyOrg.id) : false;
|
||||
|
||||
return clamp(buildsConcurrency, licensed);
|
||||
};
|
||||
|
||||
const resolveServerConcurrency = async (serverId: string): Promise<number> => {
|
||||
const currentServer = await db.query.server.findFirst({
|
||||
where: eq(server.serverId, serverId),
|
||||
columns: { buildsConcurrency: true, organizationId: true },
|
||||
});
|
||||
|
||||
if (!currentServer) return 1;
|
||||
|
||||
const licensed = await hasValidLicense(currentServer.organizationId);
|
||||
return clamp(currentServer.buildsConcurrency, licensed);
|
||||
};
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
deployApplication,
|
||||
deployCompose,
|
||||
deployPreviewApplication,
|
||||
IS_CLOUD,
|
||||
rebuildApplication,
|
||||
rebuildCompose,
|
||||
rebuildPreviewApplication,
|
||||
@@ -10,87 +9,69 @@ import {
|
||||
updateCompose,
|
||||
updatePreviewDeployment,
|
||||
} from "@dokploy/server";
|
||||
import { type Job, Worker } from "bullmq";
|
||||
import type { DeploymentJob } from "./queue-types";
|
||||
import { redisConfig } from "./redis-connection";
|
||||
import type { InMemoryJob } from "./in-memory-queue";
|
||||
|
||||
const createDeploymentWorker = () =>
|
||||
new Worker(
|
||||
"deployments",
|
||||
async (job: Job<DeploymentJob>) => {
|
||||
try {
|
||||
if (job.data.applicationType === "application") {
|
||||
await updateApplicationStatus(job.data.applicationId, "running");
|
||||
/**
|
||||
* Processes a single deployment job. Shared by the in-memory queue worker and
|
||||
* (in cloud) the direct background execution path.
|
||||
*/
|
||||
export const processDeploymentJob = async (job: InMemoryJob) => {
|
||||
try {
|
||||
if (job.data.applicationType === "application") {
|
||||
await updateApplicationStatus(job.data.applicationId, "running");
|
||||
|
||||
if (job.data.type === "redeploy") {
|
||||
await rebuildApplication({
|
||||
applicationId: job.data.applicationId,
|
||||
titleLog: job.data.titleLog,
|
||||
descriptionLog: job.data.descriptionLog,
|
||||
});
|
||||
} else if (job.data.type === "deploy") {
|
||||
await deployApplication({
|
||||
applicationId: job.data.applicationId,
|
||||
titleLog: job.data.titleLog,
|
||||
descriptionLog: job.data.descriptionLog,
|
||||
});
|
||||
}
|
||||
} else if (job.data.applicationType === "compose") {
|
||||
await updateCompose(job.data.composeId, {
|
||||
composeStatus: "running",
|
||||
});
|
||||
if (job.data.type === "deploy") {
|
||||
await deployCompose({
|
||||
composeId: job.data.composeId,
|
||||
titleLog: job.data.titleLog,
|
||||
descriptionLog: job.data.descriptionLog,
|
||||
});
|
||||
} else if (job.data.type === "redeploy") {
|
||||
await rebuildCompose({
|
||||
composeId: job.data.composeId,
|
||||
titleLog: job.data.titleLog,
|
||||
descriptionLog: job.data.descriptionLog,
|
||||
});
|
||||
}
|
||||
} else if (job.data.applicationType === "application-preview") {
|
||||
await updatePreviewDeployment(job.data.previewDeploymentId, {
|
||||
previewStatus: "running",
|
||||
});
|
||||
|
||||
if (job.data.type === "redeploy") {
|
||||
await rebuildPreviewApplication({
|
||||
applicationId: job.data.applicationId,
|
||||
titleLog: job.data.titleLog,
|
||||
descriptionLog: job.data.descriptionLog,
|
||||
previewDeploymentId: job.data.previewDeploymentId,
|
||||
});
|
||||
} else if (job.data.type === "deploy") {
|
||||
await deployPreviewApplication({
|
||||
applicationId: job.data.applicationId,
|
||||
titleLog: job.data.titleLog,
|
||||
descriptionLog: job.data.descriptionLog,
|
||||
previewDeploymentId: job.data.previewDeploymentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error", error);
|
||||
if (job.data.type === "redeploy") {
|
||||
await rebuildApplication({
|
||||
applicationId: job.data.applicationId,
|
||||
titleLog: job.data.titleLog,
|
||||
descriptionLog: job.data.descriptionLog,
|
||||
});
|
||||
} else if (job.data.type === "deploy") {
|
||||
await deployApplication({
|
||||
applicationId: job.data.applicationId,
|
||||
titleLog: job.data.titleLog,
|
||||
descriptionLog: job.data.descriptionLog,
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
autorun: false,
|
||||
connection: redisConfig,
|
||||
},
|
||||
);
|
||||
} else if (job.data.applicationType === "compose") {
|
||||
await updateCompose(job.data.composeId, {
|
||||
composeStatus: "running",
|
||||
});
|
||||
if (job.data.type === "deploy") {
|
||||
await deployCompose({
|
||||
composeId: job.data.composeId,
|
||||
titleLog: job.data.titleLog,
|
||||
descriptionLog: job.data.descriptionLog,
|
||||
});
|
||||
} else if (job.data.type === "redeploy") {
|
||||
await rebuildCompose({
|
||||
composeId: job.data.composeId,
|
||||
titleLog: job.data.titleLog,
|
||||
descriptionLog: job.data.descriptionLog,
|
||||
});
|
||||
}
|
||||
} else if (job.data.applicationType === "application-preview") {
|
||||
await updatePreviewDeployment(job.data.previewDeploymentId, {
|
||||
previewStatus: "running",
|
||||
});
|
||||
|
||||
/** No-op worker when Redis is disabled (e.g. IS_CLOUD). Avoids BullMQ connection errors. */
|
||||
const noopWorker = {
|
||||
run: () => Promise.resolve(),
|
||||
close: () => Promise.resolve(),
|
||||
cancelJob: () => Promise.resolve(),
|
||||
cancelAllJobs: () => Promise.resolve(),
|
||||
if (job.data.type === "redeploy") {
|
||||
await rebuildPreviewApplication({
|
||||
applicationId: job.data.applicationId,
|
||||
titleLog: job.data.titleLog,
|
||||
descriptionLog: job.data.descriptionLog,
|
||||
previewDeploymentId: job.data.previewDeploymentId,
|
||||
});
|
||||
} else if (job.data.type === "deploy") {
|
||||
await deployPreviewApplication({
|
||||
applicationId: job.data.applicationId,
|
||||
titleLog: job.data.titleLog,
|
||||
descriptionLog: job.data.descriptionLog,
|
||||
previewDeploymentId: job.data.previewDeploymentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error", error);
|
||||
}
|
||||
};
|
||||
|
||||
export const deploymentWorker = !IS_CLOUD
|
||||
? createDeploymentWorker()
|
||||
: (noopWorker as unknown as Worker<DeploymentJob>);
|
||||
|
||||
262
apps/dokploy/server/queues/in-memory-queue.ts
Normal file
262
apps/dokploy/server/queues/in-memory-queue.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import type { DeploymentJob } from "./queue-types";
|
||||
|
||||
/**
|
||||
* In-memory deployment queue for self-hosted instances.
|
||||
*
|
||||
* Replaces BullMQ/Redis for deployments. The model is per-group FIFO with a
|
||||
* configurable concurrency per partition (server):
|
||||
*
|
||||
* - Jobs are partitioned by `serverId` (the local web server uses the
|
||||
* `LOCAL_PARTITION` key). Each partition runs up to `concurrency` jobs at
|
||||
* the same time, so two different applications can build concurrently.
|
||||
* - Within a partition, jobs that belong to the same group (same application
|
||||
* or compose) never run in parallel — they are serialized FIFO. This avoids
|
||||
* two builds of the same service stepping on each other (same code dir,
|
||||
* same container name, etc).
|
||||
*
|
||||
* The concurrency is resolved lazily per partition through `resolveConcurrency`
|
||||
* so it can be gated by the enterprise license at run time (a non-licensed
|
||||
* instance always resolves to 1).
|
||||
*
|
||||
* The public surface (`add`, `getJobs`, `close`, `on`) mirrors the subset of
|
||||
* BullMQ used by the routers so it can be a drop-in replacement.
|
||||
*/
|
||||
|
||||
export const LOCAL_PARTITION = "__local__";
|
||||
|
||||
export type JobState = "waiting" | "active";
|
||||
|
||||
export interface InMemoryJob {
|
||||
id: string;
|
||||
name: string;
|
||||
data: DeploymentJob;
|
||||
timestamp: number;
|
||||
processedOn?: number;
|
||||
finishedOn?: number;
|
||||
failedReason?: string;
|
||||
getState: () => Promise<JobState>;
|
||||
remove: () => Promise<void>;
|
||||
}
|
||||
|
||||
type Processor = (job: InMemoryJob) => Promise<void>;
|
||||
|
||||
/** Resolve the partition key (serverId) a job belongs to. */
|
||||
export const getPartition = (data: DeploymentJob): string =>
|
||||
data.serverId ?? LOCAL_PARTITION;
|
||||
|
||||
/** Resolve the FIFO group a job belongs to (the service being deployed). */
|
||||
export const getGroup = (data: DeploymentJob): string => {
|
||||
if (data.applicationType === "compose") {
|
||||
return `compose:${data.composeId}`;
|
||||
}
|
||||
return `application:${data.applicationId}`;
|
||||
};
|
||||
|
||||
interface InternalJob {
|
||||
id: string;
|
||||
name: string;
|
||||
data: DeploymentJob;
|
||||
timestamp: number;
|
||||
processedOn?: number;
|
||||
finishedOn?: number;
|
||||
failedReason?: string;
|
||||
state: JobState;
|
||||
partition: string;
|
||||
group: string;
|
||||
}
|
||||
|
||||
interface Partition {
|
||||
waiting: InternalJob[];
|
||||
/** Groups currently running in this partition. */
|
||||
activeGroups: Set<string>;
|
||||
active: InternalJob[];
|
||||
}
|
||||
|
||||
export interface InMemoryQueueOptions {
|
||||
/**
|
||||
* Returns the max number of jobs that may run in parallel for a given
|
||||
* partition. Called on every scheduling tick so license/config changes are
|
||||
* picked up without restarting the queue. Must return a value >= 1.
|
||||
*/
|
||||
resolveConcurrency: (partition: string) => Promise<number> | number;
|
||||
/** Monotonic clock; injectable for tests. Defaults to Date.now. */
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export class InMemoryQueue {
|
||||
private partitions = new Map<string, Partition>();
|
||||
private processor: Processor | null = null;
|
||||
private running = false;
|
||||
private seq = 0;
|
||||
private readonly resolveConcurrency: InMemoryQueueOptions["resolveConcurrency"];
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(options: InMemoryQueueOptions) {
|
||||
this.resolveConcurrency = options.resolveConcurrency;
|
||||
this.now = options.now ?? (() => Date.now());
|
||||
}
|
||||
|
||||
private getPartitionState(key: string): Partition {
|
||||
let partition = this.partitions.get(key);
|
||||
if (!partition) {
|
||||
partition = { waiting: [], activeGroups: new Set(), active: [] };
|
||||
this.partitions.set(key, partition);
|
||||
}
|
||||
return partition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the worker that processes each job. Registering a processor also
|
||||
* starts the queue: in dev (tsx/Next) the module that calls `run()` and the
|
||||
* module that calls `add()` can resolve to different instances, so we must
|
||||
* not depend on a separate `run()` call to flip `running` on.
|
||||
*/
|
||||
process(processor: Processor) {
|
||||
this.processor = processor;
|
||||
this.running = true;
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
run() {
|
||||
this.running = true;
|
||||
this.schedule();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async add(data: DeploymentJob): Promise<{ id: string }> {
|
||||
const id = `job-${++this.seq}`;
|
||||
const partitionKey = getPartition(data);
|
||||
const job: InternalJob = {
|
||||
id,
|
||||
name: "deployments",
|
||||
data,
|
||||
timestamp: this.now(),
|
||||
state: "waiting",
|
||||
partition: partitionKey,
|
||||
group: getGroup(data),
|
||||
};
|
||||
this.getPartitionState(partitionKey).waiting.push(job);
|
||||
this.schedule();
|
||||
return { id };
|
||||
}
|
||||
|
||||
private toPublic(job: InternalJob): InMemoryJob {
|
||||
return {
|
||||
id: job.id,
|
||||
name: job.name,
|
||||
data: job.data,
|
||||
timestamp: job.timestamp,
|
||||
processedOn: job.processedOn,
|
||||
finishedOn: job.finishedOn,
|
||||
getState: () => Promise.resolve(job.state),
|
||||
remove: () => this.remove(job.id),
|
||||
};
|
||||
}
|
||||
|
||||
/** Snapshot of jobs in the requested states (defaults to waiting + active). */
|
||||
getJobs(states?: JobState[]): Promise<InMemoryJob[]> {
|
||||
const wantWaiting = !states || states.includes("waiting");
|
||||
const wantActive = !states || states.includes("active");
|
||||
const jobs: InMemoryJob[] = [];
|
||||
for (const partition of this.partitions.values()) {
|
||||
if (wantWaiting) {
|
||||
jobs.push(...partition.waiting.map((job) => this.toPublic(job)));
|
||||
}
|
||||
if (wantActive) {
|
||||
jobs.push(...partition.active.map((job) => this.toPublic(job)));
|
||||
}
|
||||
}
|
||||
return Promise.resolve(jobs);
|
||||
}
|
||||
|
||||
/** Remove a single waiting job by id. Active jobs cannot be removed. */
|
||||
remove(id: string): Promise<void> {
|
||||
for (const partition of this.partitions.values()) {
|
||||
const before = partition.waiting.length;
|
||||
partition.waiting = partition.waiting.filter((job) => job.id !== id);
|
||||
if (partition.waiting.length !== before) break;
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/** Remove waiting jobs matching a predicate. Active jobs are not affected. */
|
||||
removeWaiting(predicate: (data: DeploymentJob) => boolean): number {
|
||||
let removed = 0;
|
||||
for (const partition of this.partitions.values()) {
|
||||
partition.waiting = partition.waiting.filter((job) => {
|
||||
const match = predicate(job.data);
|
||||
if (match) removed++;
|
||||
return !match;
|
||||
});
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/** Drop every waiting job across all partitions. */
|
||||
clearWaiting(): number {
|
||||
let removed = 0;
|
||||
for (const partition of this.partitions.values()) {
|
||||
removed += partition.waiting.length;
|
||||
partition.waiting = [];
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
on() {
|
||||
// No-op: kept for BullMQ API compatibility (error events, etc).
|
||||
}
|
||||
|
||||
close() {
|
||||
this.running = false;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
private schedule() {
|
||||
if (!this.running || !this.processor) return;
|
||||
for (const key of this.partitions.keys()) {
|
||||
void this.drainPartition(key);
|
||||
}
|
||||
}
|
||||
|
||||
private async drainPartition(key: string) {
|
||||
const partition = this.partitions.get(key);
|
||||
if (!partition || !this.processor) return;
|
||||
|
||||
const concurrency = Math.max(1, await this.resolveConcurrency(key));
|
||||
|
||||
while (partition.active.length < concurrency) {
|
||||
// First waiting job whose group is not already running.
|
||||
const index = partition.waiting.findIndex(
|
||||
(job) => !partition.activeGroups.has(job.group),
|
||||
);
|
||||
if (index === -1) break;
|
||||
|
||||
const job = partition.waiting.splice(index, 1)[0];
|
||||
if (!job) break;
|
||||
job.state = "active";
|
||||
job.processedOn = this.now();
|
||||
partition.activeGroups.add(job.group);
|
||||
partition.active.push(job);
|
||||
|
||||
void this.runJob(job);
|
||||
}
|
||||
}
|
||||
|
||||
private async runJob(job: InternalJob) {
|
||||
try {
|
||||
await this.processor?.(this.toPublic(job));
|
||||
} catch (error) {
|
||||
job.failedReason = error instanceof Error ? error.message : String(error);
|
||||
console.error("In-memory deployment job failed", error);
|
||||
} finally {
|
||||
job.finishedOn = this.now();
|
||||
const partition = this.partitions.get(job.partition);
|
||||
if (partition) {
|
||||
partition.active = partition.active.filter((j) => j.id !== job.id);
|
||||
partition.activeGroups.delete(job.group);
|
||||
}
|
||||
// A slot (and possibly the group) freed up — try to schedule more.
|
||||
void this.drainPartition(job.partition);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,32 +3,89 @@ import {
|
||||
execAsync,
|
||||
execAsyncRemote,
|
||||
} from "@dokploy/server/utils/process/execAsync";
|
||||
import type { Job } from "bullmq";
|
||||
import { Queue } from "bullmq";
|
||||
import { deploymentWorker } from "./deployments-queue";
|
||||
import { redisConfig } from "./redis-connection";
|
||||
import { resolveBuildsConcurrency } from "./concurrency";
|
||||
import { processDeploymentJob } from "./deployments-queue";
|
||||
import { type InMemoryJob, InMemoryQueue } from "./in-memory-queue";
|
||||
import type { DeploymentJob } from "./queue-types";
|
||||
|
||||
/** No-op queue when Redis is disabled (e.g. IS_CLOUD). Avoids BullMQ connection errors. */
|
||||
const createNoopQueue = () => ({
|
||||
getJobs: () => Promise.resolve([] as Job[]),
|
||||
add: () =>
|
||||
Promise.resolve({ id: "noop", remove: () => Promise.resolve() } as Job),
|
||||
/**
|
||||
* Deployment queue.
|
||||
*
|
||||
* Self-hosted uses an in-memory, per-group FIFO queue with configurable
|
||||
* concurrency per server (enterprise-gated). Cloud does not use the queue at
|
||||
* all — deployments run directly in the background — so we expose a no-op.
|
||||
*/
|
||||
|
||||
interface DeploymentQueue {
|
||||
add: (
|
||||
name: string,
|
||||
data: DeploymentJob,
|
||||
opts?: Record<string, unknown>,
|
||||
) => Promise<{ id: string }>;
|
||||
getJobs: (states?: Array<"waiting" | "active">) => Promise<InMemoryJob[]>;
|
||||
close: () => Promise<void>;
|
||||
on: (...args: unknown[]) => void;
|
||||
run: () => Promise<void>;
|
||||
removeWaiting: (predicate: (data: DeploymentJob) => boolean) => number;
|
||||
clearWaiting: () => number;
|
||||
}
|
||||
|
||||
const createNoopQueue = (): DeploymentQueue => ({
|
||||
add: () => Promise.resolve({ id: "noop" }),
|
||||
getJobs: () => Promise.resolve([]),
|
||||
close: () => Promise.resolve(),
|
||||
on: () => {},
|
||||
run: () => Promise.resolve(),
|
||||
removeWaiting: () => 0,
|
||||
clearWaiting: () => 0,
|
||||
});
|
||||
|
||||
const myQueue = !IS_CLOUD
|
||||
? new Queue("deployments", { connection: redisConfig })
|
||||
: (createNoopQueue() as unknown as Queue);
|
||||
const createInMemoryQueue = (): DeploymentQueue => {
|
||||
const queue = new InMemoryQueue({
|
||||
resolveConcurrency: resolveBuildsConcurrency,
|
||||
});
|
||||
queue.process(processDeploymentJob);
|
||||
|
||||
return {
|
||||
add: (_name, data) => queue.add(data),
|
||||
getJobs: (states) => queue.getJobs(states),
|
||||
close: () => queue.close(),
|
||||
on: () => {},
|
||||
run: () => queue.run(),
|
||||
removeWaiting: (predicate) => queue.removeWaiting(predicate),
|
||||
clearWaiting: () => queue.clearWaiting(),
|
||||
};
|
||||
};
|
||||
|
||||
// Use a global singleton so the deployment queue is shared across every module
|
||||
// instance. In dev (tsx/Next) the same file can be evaluated more than once
|
||||
// (relative import in server.ts vs `@/` alias in the routers); without this the
|
||||
// worker and the `add()` calls would land on different queue instances.
|
||||
const globalForQueue = globalThis as unknown as {
|
||||
__dokployDeploymentQueue?: DeploymentQueue;
|
||||
};
|
||||
|
||||
if (!globalForQueue.__dokployDeploymentQueue) {
|
||||
globalForQueue.__dokployDeploymentQueue = !IS_CLOUD
|
||||
? createInMemoryQueue()
|
||||
: createNoopQueue();
|
||||
}
|
||||
|
||||
const myQueue: DeploymentQueue = globalForQueue.__dokployDeploymentQueue;
|
||||
|
||||
/** Start processing jobs. Called once on server startup (self-hosted). */
|
||||
export const startDeploymentWorker = () => myQueue.run();
|
||||
|
||||
export const getJobsByApplicationId = async (applicationId: string) => {
|
||||
const jobs = await myQueue.getJobs();
|
||||
return jobs.filter((job) => job?.data?.applicationId === applicationId);
|
||||
return jobs.filter(
|
||||
(job) => (job.data as any)?.applicationId === applicationId,
|
||||
);
|
||||
};
|
||||
|
||||
export const getJobsByComposeId = async (composeId: string) => {
|
||||
const jobs = await myQueue.getJobs();
|
||||
return jobs.filter((job) => job?.data?.composeId === composeId);
|
||||
return jobs.filter((job) => (job.data as any)?.composeId === composeId);
|
||||
};
|
||||
|
||||
if (!IS_CLOUD) {
|
||||
@@ -36,44 +93,33 @@ if (!IS_CLOUD) {
|
||||
myQueue.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
myQueue.on("error", (error) => {
|
||||
if ((error as any).code === "ECONNREFUSED") {
|
||||
console.error(
|
||||
"Make sure you have installed Redis and it is running.",
|
||||
error,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const cleanQueuesByApplication = async (applicationId: string) => {
|
||||
const jobs = await myQueue.getJobs(["waiting", "delayed"]);
|
||||
const removed = myQueue.removeWaiting(
|
||||
(data) => (data as any)?.applicationId === applicationId,
|
||||
);
|
||||
if (removed > 0) {
|
||||
console.log(
|
||||
`Removed ${removed} waiting job(s) for application ${applicationId}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
for (const job of jobs) {
|
||||
if (job?.data?.applicationId === applicationId) {
|
||||
await job.remove();
|
||||
console.log(`Removed job ${job.id} for application ${applicationId}`);
|
||||
}
|
||||
export const cleanQueuesByCompose = async (composeId: string) => {
|
||||
const removed = myQueue.removeWaiting(
|
||||
(data) => (data as any)?.composeId === composeId,
|
||||
);
|
||||
if (removed > 0) {
|
||||
console.log(`Removed ${removed} waiting job(s) for compose ${composeId}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const cleanAllDeploymentQueue = async () => {
|
||||
deploymentWorker.cancelAllJobs("User requested cancellation");
|
||||
myQueue.clearWaiting();
|
||||
return true;
|
||||
};
|
||||
|
||||
export const cleanQueuesByCompose = async (composeId: string) => {
|
||||
const jobs = await myQueue.getJobs(["waiting", "delayed"]);
|
||||
|
||||
for (const job of jobs) {
|
||||
if (job?.data?.composeId === composeId) {
|
||||
await job.remove();
|
||||
console.log(`Removed job ${job.id} for compose ${composeId}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const killDockerBuild = async (
|
||||
type: "application" | "compose",
|
||||
serverId: string | null,
|
||||
|
||||
@@ -71,8 +71,8 @@ void app.prepare().then(async () => {
|
||||
|
||||
if (!IS_CLOUD) {
|
||||
console.log("Starting Deployment Worker");
|
||||
const { deploymentWorker } = await import("./queues/deployments-queue");
|
||||
await deploymentWorker.run();
|
||||
const { startDeploymentWorker } = await import("./queues/queueSetup");
|
||||
await startDeploymentWorker();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Main Server Error", e);
|
||||
|
||||
39
apps/dokploy/server/utils/docker-cleanup.ts
Normal file
39
apps/dokploy/server/utils/docker-cleanup.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -55,6 +55,7 @@ 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 }) => ({
|
||||
@@ -94,6 +95,7 @@ export const apiCreateDomain = createSchema.pick({
|
||||
internalPath: true,
|
||||
stripPath: true,
|
||||
middlewares: true,
|
||||
forwardAuthEnabled: true,
|
||||
});
|
||||
|
||||
export const apiFindDomain = z.object({
|
||||
@@ -126,5 +128,6 @@ export const apiUpdateDomain = createSchema
|
||||
internalPath: true,
|
||||
stripPath: true,
|
||||
middlewares: true,
|
||||
forwardAuthEnabled: true,
|
||||
})
|
||||
.merge(createSchema.pick({ domainId: true }).required());
|
||||
|
||||
75
packages/server/src/db/schema/forward-auth.ts
Normal file
75
packages/server/src/db/schema/forward-auth.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
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),
|
||||
});
|
||||
@@ -10,6 +10,7 @@ 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";
|
||||
|
||||
@@ -41,6 +41,7 @@ export const server = pgTable("server", {
|
||||
.notNull()
|
||||
.$defaultFn(() => generateAppName("server")),
|
||||
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false),
|
||||
buildsConcurrency: integer("buildsConcurrency").notNull().default(1),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
organizationId: text("organizationId")
|
||||
.notNull()
|
||||
@@ -147,8 +148,12 @@ export const apiCreateServer = createSchema
|
||||
username: true,
|
||||
sshKeyId: true,
|
||||
serverType: true,
|
||||
enableDockerCleanup: true,
|
||||
})
|
||||
.required();
|
||||
.required()
|
||||
.extend({
|
||||
enableDockerCleanup: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export const apiFindOneServer = z.object({
|
||||
serverId: z.string().min(1),
|
||||
@@ -170,12 +175,19 @@ 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 apiUpdateServerBuildsConcurrency = z.object({
|
||||
serverId: z.string().min(1),
|
||||
buildsConcurrency: z.number().int().min(1).max(20),
|
||||
});
|
||||
|
||||
export const apiUpdateServerMonitoring = createSchema
|
||||
.pick({
|
||||
serverId: true,
|
||||
|
||||
@@ -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: "cascade" }),
|
||||
userId: text("user_id").references(() => user.id, { onDelete: "set null" }),
|
||||
organizationId: text("organization_id").references(() => organization.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { boolean, jsonb, pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
||||
import {
|
||||
boolean,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
@@ -98,6 +105,8 @@ export const webServerSettings = pgTable("webServerSettings", {
|
||||
}),
|
||||
// Deployment Configuration (self-hosted only)
|
||||
remoteServersOnly: boolean("remoteServersOnly").notNull().default(false),
|
||||
// Concurrent builds on the local web server (enterprise-gated to > 1)
|
||||
buildsConcurrency: integer("buildsConcurrency").notNull().default(1),
|
||||
// Auth Configuration (self-hosted only)
|
||||
enforceSSO: boolean("enforceSSO").notNull().default(false),
|
||||
// Cache Cleanup Configuration
|
||||
@@ -161,6 +170,11 @@ export const apiUpdateWebServerSettings = createSchema.partial().extend({
|
||||
cleanupCacheOnCompose: z.boolean().optional(),
|
||||
remoteServersOnly: z.boolean().optional(),
|
||||
enforceSSO: z.boolean().optional(),
|
||||
buildsConcurrency: z.number().int().min(1).max(20).optional(),
|
||||
});
|
||||
|
||||
export const apiUpdateWebServerBuildsConcurrency = z.object({
|
||||
buildsConcurrency: z.number().int().min(1).max(20),
|
||||
});
|
||||
|
||||
export const apiAssignDomain = z
|
||||
|
||||
@@ -35,6 +35,7 @@ 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";
|
||||
@@ -50,6 +51,7 @@ 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";
|
||||
@@ -127,6 +129,7 @@ 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";
|
||||
|
||||
@@ -95,26 +95,22 @@ 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,
|
||||
buildRegistry: true,
|
||||
rollbackRegistry: true,
|
||||
registry: { columns: { password: false } },
|
||||
buildRegistry: { columns: { password: false } },
|
||||
rollbackRegistry: { columns: { password: false } },
|
||||
},
|
||||
});
|
||||
if (!application) {
|
||||
|
||||
@@ -34,7 +34,12 @@ export const findBackupById = async (backupId: string) => {
|
||||
mariadb: true,
|
||||
mongo: true,
|
||||
libsql: true,
|
||||
destination: true,
|
||||
destination: {
|
||||
columns: {
|
||||
accessKey: false,
|
||||
secretAccessKey: false,
|
||||
},
|
||||
},
|
||||
compose: true,
|
||||
},
|
||||
});
|
||||
@@ -83,7 +88,12 @@ export const findBackupsByDbId = async (
|
||||
mariadb: true,
|
||||
mongo: true,
|
||||
libsql: true,
|
||||
destination: true,
|
||||
destination: {
|
||||
columns: {
|
||||
accessKey: false,
|
||||
secretAccessKey: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return result || [];
|
||||
|
||||
@@ -131,7 +131,12 @@ export const findComposeById = async (composeId: string) => {
|
||||
server: true,
|
||||
backups: {
|
||||
with: {
|
||||
destination: true,
|
||||
destination: {
|
||||
columns: {
|
||||
accessKey: false,
|
||||
secretAccessKey: false,
|
||||
},
|
||||
},
|
||||
deployments: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -43,6 +43,38 @@ 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;
|
||||
|
||||
@@ -63,7 +63,12 @@ export const findLibsqlById = async (libsqlId: string) => {
|
||||
server: true,
|
||||
backups: {
|
||||
with: {
|
||||
destination: true,
|
||||
destination: {
|
||||
columns: {
|
||||
accessKey: false,
|
||||
secretAccessKey: false,
|
||||
},
|
||||
},
|
||||
deployments: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -68,7 +68,12 @@ export const findMariadbById = async (mariadbId: string) => {
|
||||
server: true,
|
||||
backups: {
|
||||
with: {
|
||||
destination: true,
|
||||
destination: {
|
||||
columns: {
|
||||
accessKey: false,
|
||||
secretAccessKey: false,
|
||||
},
|
||||
},
|
||||
deployments: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -63,7 +63,12 @@ export const findMongoById = async (mongoId: string) => {
|
||||
server: true,
|
||||
backups: {
|
||||
with: {
|
||||
destination: true,
|
||||
destination: {
|
||||
columns: {
|
||||
accessKey: false,
|
||||
secretAccessKey: false,
|
||||
},
|
||||
},
|
||||
deployments: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -66,7 +66,12 @@ export const findMySqlById = async (mysqlId: string) => {
|
||||
server: true,
|
||||
backups: {
|
||||
with: {
|
||||
destination: true,
|
||||
destination: {
|
||||
columns: {
|
||||
accessKey: false,
|
||||
secretAccessKey: false,
|
||||
},
|
||||
},
|
||||
deployments: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -76,7 +76,12 @@ export const findPostgresById = async (postgresId: string) => {
|
||||
server: true,
|
||||
backups: {
|
||||
with: {
|
||||
destination: true,
|
||||
destination: {
|
||||
columns: {
|
||||
accessKey: false,
|
||||
secretAccessKey: false,
|
||||
},
|
||||
},
|
||||
deployments: true,
|
||||
},
|
||||
},
|
||||
|
||||
382
packages/server/src/services/proprietary/forward-auth.ts
Normal file
382
packages/server/src/services/proprietary/forward-auth.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
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;
|
||||
};
|
||||
@@ -27,6 +27,16 @@ 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,
|
||||
@@ -59,10 +69,15 @@ export const createRegistry = async (
|
||||
input.username,
|
||||
input.password,
|
||||
);
|
||||
if (input.serverId && input.serverId !== "none") {
|
||||
await execAsyncRemote(input.serverId, loginCommand);
|
||||
} else if (newRegistry.registryType === "cloud") {
|
||||
await execAsync(loginCommand);
|
||||
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 });
|
||||
}
|
||||
|
||||
return newRegistry;
|
||||
@@ -129,16 +144,24 @@ export const updateRegistry = async (
|
||||
});
|
||||
}
|
||||
|
||||
if (registryData?.serverId && registryData?.serverId !== "none") {
|
||||
await execAsyncRemote(registryData.serverId, loginCommand);
|
||||
} else if (response?.registryType === "cloud") {
|
||||
await execAsync(loginCommand);
|
||||
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));
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Error updating this registry";
|
||||
error instanceof TRPCError
|
||||
? error.message
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "Error updating this registry";
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message,
|
||||
@@ -162,6 +185,19 @@ 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,
|
||||
) => {
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
deployments as deploymentsSchema,
|
||||
rollbacks,
|
||||
} from "../db/schema";
|
||||
import type { ApplicationNested } from "../utils/builders";
|
||||
import { getRegistryTag } from "../utils/cluster/upload";
|
||||
import {
|
||||
calculateResources,
|
||||
@@ -23,7 +22,11 @@ import { findDeploymentById } from "./deployment";
|
||||
import type { Mount } from "./mount";
|
||||
import type { Port } from "./port";
|
||||
import type { Project } from "./project";
|
||||
import { type Registry, safeDockerLoginCommand } from "./registry";
|
||||
import {
|
||||
findRegistryByIdWithCredentials,
|
||||
type Registry,
|
||||
safeDockerLoginCommand,
|
||||
} from "./registry";
|
||||
|
||||
export const createRollback = async (
|
||||
input: z.infer<typeof createRollbackSchema>,
|
||||
@@ -56,11 +59,29 @@ 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,
|
||||
fullContext: rest,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
fullContext: fullContextWithCredentials as any,
|
||||
})
|
||||
.where(eq(rollbacks.rollbackId, rollback.rollbackId));
|
||||
|
||||
@@ -162,7 +183,6 @@ 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 || "",
|
||||
@@ -198,24 +218,25 @@ const rollbackApplication = async (
|
||||
};
|
||||
mounts: Mount[];
|
||||
ports: Port[];
|
||||
rollbackRegistry?: Registry;
|
||||
rollbackRegistry?: Registry | null;
|
||||
},
|
||||
) => {
|
||||
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 (fullContext.rollbackRegistry) {
|
||||
await dockerLoginForRegistry(fullContext.rollbackRegistry, serverId);
|
||||
if (rollbackRegistry) {
|
||||
await dockerLoginForRegistry(rollbackRegistry, serverId);
|
||||
}
|
||||
|
||||
const docker = await getRemoteDocker(serverId);
|
||||
|
||||
// Use the same configuration as mechanizeDockerContainer
|
||||
const {
|
||||
env,
|
||||
mounts,
|
||||
@@ -246,7 +267,9 @@ const rollbackApplication = async (
|
||||
UpdateConfig,
|
||||
Networks,
|
||||
Ulimits,
|
||||
} = generateConfigContainer(fullContext as ApplicationNested);
|
||||
} = generateConfigContainer(
|
||||
fullContext as Parameters<typeof generateConfigContainer>[0],
|
||||
);
|
||||
|
||||
const bindsMount = generateBindMounts(mounts);
|
||||
const envVariables = prepareEnvironmentVariables(
|
||||
@@ -254,18 +277,16 @@ 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 (fullContext.rollbackRegistry) {
|
||||
rollbackImage = getRegistryTag(fullContext.rollbackRegistry, image);
|
||||
if (rollbackRegistry) {
|
||||
rollbackImage = getRegistryTag(rollbackRegistry, image);
|
||||
}
|
||||
|
||||
const settings: CreateServiceOptions = {
|
||||
authconfig: {
|
||||
password: fullContext.rollbackRegistry?.password || "",
|
||||
username: fullContext.rollbackRegistry?.username || "",
|
||||
serveraddress: fullContext.rollbackRegistry?.registryUrl || "",
|
||||
password: rollbackRegistry?.password || "",
|
||||
username: rollbackRegistry?.username || "",
|
||||
serveraddress: rollbackRegistry?.registryUrl || "",
|
||||
},
|
||||
Name: appName,
|
||||
TaskTemplate: {
|
||||
|
||||
@@ -84,7 +84,12 @@ export const findVolumeBackupById = async (volumeBackupId: string) => {
|
||||
},
|
||||
},
|
||||
},
|
||||
destination: true,
|
||||
destination: {
|
||||
columns: {
|
||||
accessKey: false,
|
||||
secretAccessKey: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
158
packages/server/src/setup/forward-auth-setup.ts
Normal file
158
packages/server/src/setup/forward-auth-setup.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
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;
|
||||
}
|
||||
};
|
||||
@@ -4,6 +4,7 @@ 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";
|
||||
@@ -23,7 +24,7 @@ export const runComposeBackup = async (
|
||||
const environment = await findEnvironmentById(environmentId);
|
||||
const project = await findProjectById(environment.projectId);
|
||||
const { prefix, databaseType, serviceName } = backup;
|
||||
const destination = backup.destination;
|
||||
const destination = await findDestinationById(backup.destinationId);
|
||||
const backupFileName = `${getBackupTimestamp()}.${databaseType === "mongo" ? "bson" : "sql"}.gz`;
|
||||
const s3AppName = serviceName ? `${appName}_${serviceName}` : appName;
|
||||
const bucketDestination = `${s3AppName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CLEANUP_CRON_JOB } from "@dokploy/server/constants";
|
||||
import { member } from "@dokploy/server/db/schema";
|
||||
import type { BackupSchedule } from "@dokploy/server/services/backup";
|
||||
import { findDestinationById } from "@dokploy/server/services/destination";
|
||||
import { getAllServers } from "@dokploy/server/services/server";
|
||||
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
|
||||
import { eq } from "drizzle-orm";
|
||||
@@ -131,9 +132,10 @@ export const keepLatestNBackups = async (
|
||||
if (!backup.keepLatestCount) return;
|
||||
|
||||
try {
|
||||
const rcloneFlags = getS3Credentials(backup.destination);
|
||||
const destination = await findDestinationById(backup.destinationId);
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
const appName = getServiceAppName(backup);
|
||||
const backupFilesPath = `:s3:${backup.destination.bucket}/${appName}/${normalizeS3Path(backup.prefix)}`;
|
||||
const backupFilesPath = `:s3:${destination.bucket}/${appName}/${normalizeS3Path(backup.prefix)}`;
|
||||
|
||||
// --include "*.bson.gz" or "*.sql.gz" or "*.zip" ensures nothing else other than the dokploy backup files are touched by rclone
|
||||
const rcloneList = `rclone lsf ${rcloneFlags.join(" ")} --include "*${backup.databaseType === "web-server" ? ".zip" : ".{sql.gz,bson.gz}"}" ${backupFilesPath}`;
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
createDeploymentBackup,
|
||||
updateDeploymentStatus,
|
||||
} from "@dokploy/server/services/deployment";
|
||||
import { findDestinationById } from "@dokploy/server/services/destination";
|
||||
import { findEnvironmentById } from "@dokploy/server/services/environment";
|
||||
import type { Libsql } from "@dokploy/server/services/libsql";
|
||||
import { findProjectById } from "@dokploy/server/services/project";
|
||||
@@ -29,7 +30,7 @@ export const runLibsqlBackup = async (
|
||||
description: "Initializing Backup",
|
||||
});
|
||||
const { prefix } = backup;
|
||||
const destination = backup.destination;
|
||||
const destination = await findDestinationById(backup.destinationId);
|
||||
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
|
||||
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
createDeploymentBackup,
|
||||
updateDeploymentStatus,
|
||||
} from "@dokploy/server/services/deployment";
|
||||
import { findDestinationById } from "@dokploy/server/services/destination";
|
||||
import { findEnvironmentById } from "@dokploy/server/services/environment";
|
||||
import type { Mariadb } from "@dokploy/server/services/mariadb";
|
||||
import { findProjectById } from "@dokploy/server/services/project";
|
||||
@@ -23,7 +24,7 @@ export const runMariadbBackup = async (
|
||||
const environment = await findEnvironmentById(environmentId);
|
||||
const project = await findProjectById(environment.projectId);
|
||||
const { prefix } = backup;
|
||||
const destination = backup.destination;
|
||||
const destination = await findDestinationById(backup.destinationId);
|
||||
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
|
||||
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const deployment = await createDeploymentBackup({
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
createDeploymentBackup,
|
||||
updateDeploymentStatus,
|
||||
} from "@dokploy/server/services/deployment";
|
||||
import { findDestinationById } from "@dokploy/server/services/destination";
|
||||
import { findEnvironmentById } from "@dokploy/server/services/environment";
|
||||
import type { Mongo } from "@dokploy/server/services/mongo";
|
||||
import { findProjectById } from "@dokploy/server/services/project";
|
||||
@@ -20,7 +21,7 @@ export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => {
|
||||
const environment = await findEnvironmentById(environmentId);
|
||||
const project = await findProjectById(environment.projectId);
|
||||
const { prefix } = backup;
|
||||
const destination = backup.destination;
|
||||
const destination = await findDestinationById(backup.destinationId);
|
||||
const backupFileName = `${getBackupTimestamp()}.bson.gz`;
|
||||
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const deployment = await createDeploymentBackup({
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
createDeploymentBackup,
|
||||
updateDeploymentStatus,
|
||||
} from "@dokploy/server/services/deployment";
|
||||
import { findDestinationById } from "@dokploy/server/services/destination";
|
||||
import { findEnvironmentById } from "@dokploy/server/services/environment";
|
||||
import type { MySql } from "@dokploy/server/services/mysql";
|
||||
import { findProjectById } from "@dokploy/server/services/project";
|
||||
@@ -20,7 +21,7 @@ export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => {
|
||||
const environment = await findEnvironmentById(environmentId);
|
||||
const project = await findProjectById(environment.projectId);
|
||||
const { prefix } = backup;
|
||||
const destination = backup.destination;
|
||||
const destination = await findDestinationById(backup.destinationId);
|
||||
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
|
||||
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const deployment = await createDeploymentBackup({
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
createDeploymentBackup,
|
||||
updateDeploymentStatus,
|
||||
} from "@dokploy/server/services/deployment";
|
||||
import { findDestinationById } from "@dokploy/server/services/destination";
|
||||
import { findEnvironmentById } from "@dokploy/server/services/environment";
|
||||
import type { Postgres } from "@dokploy/server/services/postgres";
|
||||
import { findProjectById } from "@dokploy/server/services/project";
|
||||
@@ -29,7 +30,7 @@ export const runPostgresBackup = async (
|
||||
description: "Initializing Backup",
|
||||
});
|
||||
const { prefix } = backup;
|
||||
const destination = backup.destination;
|
||||
const destination = await findDestinationById(backup.destinationId);
|
||||
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
|
||||
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { findRegistryByIdWithCredentials } from "@dokploy/server/services/registry";
|
||||
import type { InferResultType } from "@dokploy/server/types/with";
|
||||
import type { CreateServiceOptions } from "dockerode";
|
||||
import { getRegistryTag, uploadImageRemoteCommand } from "../cluster/upload";
|
||||
@@ -28,9 +29,9 @@ export type ApplicationNested = InferResultType<
|
||||
security: true;
|
||||
redirects: true;
|
||||
ports: true;
|
||||
registry: true;
|
||||
buildRegistry: true;
|
||||
rollbackRegistry: true;
|
||||
registry: { columns: { password: false } };
|
||||
buildRegistry: { columns: { password: false } };
|
||||
rollbackRegistry: { columns: { password: false } };
|
||||
deployments: true;
|
||||
environment: { with: { project: true } };
|
||||
}
|
||||
@@ -121,8 +122,8 @@ export const mechanizeDockerContainer = async (
|
||||
application.environment.env,
|
||||
);
|
||||
|
||||
const image = getImageName(application);
|
||||
const authConfig = getAuthConfig(application);
|
||||
const image = await getImageName(application);
|
||||
const authConfig = await getAuthConfig(application);
|
||||
const docker = await getRemoteDocker(application.serverId);
|
||||
|
||||
const settings: CreateServiceOptions = {
|
||||
@@ -190,7 +191,7 @@ export const mechanizeDockerContainer = async (
|
||||
}
|
||||
};
|
||||
|
||||
const getImageName = (application: ApplicationNested) => {
|
||||
const getImageName = async (application: ApplicationNested) => {
|
||||
const { appName, sourceType, dockerImage, registry, buildRegistry } =
|
||||
application;
|
||||
const imageName = `${appName}:latest`;
|
||||
@@ -199,18 +200,18 @@ const getImageName = (application: ApplicationNested) => {
|
||||
}
|
||||
|
||||
if (registry) {
|
||||
const registryTag = getRegistryTag(registry, imageName);
|
||||
return registryTag;
|
||||
const r = await findRegistryByIdWithCredentials(registry.registryId);
|
||||
return getRegistryTag(r, imageName);
|
||||
}
|
||||
if (buildRegistry) {
|
||||
const registryTag = getRegistryTag(buildRegistry, imageName);
|
||||
return registryTag;
|
||||
const r = await findRegistryByIdWithCredentials(buildRegistry.registryId);
|
||||
return getRegistryTag(r, imageName);
|
||||
}
|
||||
|
||||
return imageName;
|
||||
};
|
||||
|
||||
export const getAuthConfig = (application: ApplicationNested) => {
|
||||
export const getAuthConfig = async (application: ApplicationNested) => {
|
||||
const {
|
||||
registry,
|
||||
buildRegistry,
|
||||
@@ -222,23 +223,21 @@ export const getAuthConfig = (application: ApplicationNested) => {
|
||||
|
||||
if (sourceType === "docker") {
|
||||
if (username && password) {
|
||||
return {
|
||||
password,
|
||||
username,
|
||||
serveraddress: registryUrl || "",
|
||||
};
|
||||
return { password, username, serveraddress: registryUrl || "" };
|
||||
}
|
||||
} else if (registry) {
|
||||
const r = await findRegistryByIdWithCredentials(registry.registryId);
|
||||
return {
|
||||
password: registry.password,
|
||||
username: registry.username,
|
||||
serveraddress: registry.registryUrl,
|
||||
password: r.password,
|
||||
username: r.username,
|
||||
serveraddress: r.registryUrl,
|
||||
};
|
||||
} else if (buildRegistry) {
|
||||
const r = await findRegistryByIdWithCredentials(buildRegistry.registryId);
|
||||
return {
|
||||
password: buildRegistry.password,
|
||||
username: buildRegistry.username,
|
||||
serveraddress: buildRegistry.registryUrl,
|
||||
password: r.password,
|
||||
username: r.username,
|
||||
serveraddress: r.registryUrl,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { findAllDeploymentsByApplicationId } from "@dokploy/server/services/deployment";
|
||||
import type { Registry } from "@dokploy/server/services/registry";
|
||||
import {
|
||||
findRegistryByIdWithCredentials,
|
||||
safeDockerLoginCommand,
|
||||
type Registry,
|
||||
} from "@dokploy/server/services/registry";
|
||||
import { createRollback } from "@dokploy/server/services/rollbacks";
|
||||
import type { ApplicationNested } from "../builders";
|
||||
|
||||
@@ -22,19 +26,19 @@ export const uploadImageRemoteCommand = async (
|
||||
|
||||
const commands: string[] = [];
|
||||
if (registry) {
|
||||
const registryTag = getRegistryTag(registry, imageName);
|
||||
const r = await findRegistryByIdWithCredentials(registry.registryId);
|
||||
const registryTag = getRegistryTag(r, imageName);
|
||||
if (registryTag) {
|
||||
commands.push(`echo "📦 [Enabled Registry Swarm]"`);
|
||||
commands.push(getRegistryCommands(registry, imageName, registryTag));
|
||||
commands.push(getRegistryCommands(r, imageName, registryTag));
|
||||
}
|
||||
}
|
||||
if (buildRegistry) {
|
||||
const buildRegistryTag = getRegistryTag(buildRegistry, imageName);
|
||||
const r = await findRegistryByIdWithCredentials(buildRegistry.registryId);
|
||||
const buildRegistryTag = getRegistryTag(r, imageName);
|
||||
if (buildRegistryTag) {
|
||||
commands.push(`echo "🔑 [Enabled Build Registry]"`);
|
||||
commands.push(
|
||||
getRegistryCommands(buildRegistry, imageName, buildRegistryTag),
|
||||
);
|
||||
commands.push(getRegistryCommands(r, imageName, buildRegistryTag));
|
||||
commands.push(
|
||||
`echo "⚠️ INFO: After the build is finished, you need to wait a few seconds for the server to download the image and run the container."`,
|
||||
);
|
||||
@@ -57,15 +61,13 @@ export const uploadImageRemoteCommand = async (
|
||||
deploymentId: deploymentId,
|
||||
});
|
||||
|
||||
const rollbackRegistryTag = getRegistryTag(
|
||||
rollbackRegistry,
|
||||
rollback?.image || "",
|
||||
const r = await findRegistryByIdWithCredentials(
|
||||
rollbackRegistry.registryId,
|
||||
);
|
||||
const rollbackRegistryTag = getRegistryTag(r, rollback?.image || "");
|
||||
if (rollbackRegistryTag) {
|
||||
commands.push(`echo "🔄 [Enabled Rollback Registry]"`);
|
||||
commands.push(
|
||||
getRegistryCommands(rollbackRegistry, imageName, rollbackRegistryTag),
|
||||
);
|
||||
commands.push(getRegistryCommands(r, imageName, rollbackRegistryTag));
|
||||
}
|
||||
}
|
||||
try {
|
||||
@@ -74,6 +76,7 @@ export const uploadImageRemoteCommand = async (
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract the repository name from imageName by taking the last part after '/'
|
||||
* Examples:
|
||||
@@ -115,19 +118,24 @@ const getRegistryCommands = (
|
||||
imageName: string,
|
||||
registryTag: string,
|
||||
): string => {
|
||||
const loginCmd = safeDockerLoginCommand(
|
||||
registry.registryUrl,
|
||||
registry.username,
|
||||
registry.password,
|
||||
);
|
||||
return `
|
||||
echo "📦 [Enabled Registry] Uploading image to '${registry.registryType}' | '${registryTag}'" ;
|
||||
echo "${registry.password}" | docker login ${registry.registryUrl} -u '${registry.username}' --password-stdin || {
|
||||
${loginCmd} || {
|
||||
echo "❌ DockerHub Failed" ;
|
||||
exit 1;
|
||||
}
|
||||
echo "✅ Registry Login Success" ;
|
||||
docker tag ${imageName} ${registryTag} || {
|
||||
docker tag ${imageName} ${registryTag} || {
|
||||
echo "❌ Error tagging image" ;
|
||||
exit 1;
|
||||
}
|
||||
echo "✅ Image Tagged" ;
|
||||
docker push ${registryTag} || {
|
||||
docker push ${registryTag} || {
|
||||
echo "❌ Error pushing image" ;
|
||||
exit 1;
|
||||
}
|
||||
|
||||
@@ -140,7 +140,11 @@ export const buildLibsql = async (libsql: LibsqlNested) => {
|
||||
: []),
|
||||
],
|
||||
},
|
||||
UpdateConfig,
|
||||
UpdateConfig: libsql.updateConfigSwarm ?? {
|
||||
Parallelism: 1,
|
||||
Order: "stop-first" as const,
|
||||
FailureAction: "rollback" as const,
|
||||
},
|
||||
};
|
||||
try {
|
||||
const service = docker.getService(appName);
|
||||
|
||||
@@ -111,7 +111,11 @@ export const buildMariadb = async (mariadb: MariadbNested) => {
|
||||
]
|
||||
: [],
|
||||
},
|
||||
UpdateConfig,
|
||||
UpdateConfig: mariadb.updateConfigSwarm ?? {
|
||||
Parallelism: 1,
|
||||
Order: "stop-first" as const,
|
||||
FailureAction: "rollback" as const,
|
||||
},
|
||||
};
|
||||
try {
|
||||
const service = docker.getService(appName);
|
||||
|
||||
@@ -167,7 +167,11 @@ ${command ?? "wait $MONGOD_PID"}`;
|
||||
]
|
||||
: [],
|
||||
},
|
||||
UpdateConfig,
|
||||
UpdateConfig: mongo.updateConfigSwarm ?? {
|
||||
Parallelism: 1,
|
||||
Order: "stop-first" as const,
|
||||
FailureAction: "rollback" as const,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -117,7 +117,11 @@ export const buildMysql = async (mysql: MysqlNested) => {
|
||||
]
|
||||
: [],
|
||||
},
|
||||
UpdateConfig,
|
||||
UpdateConfig: mysql.updateConfigSwarm ?? {
|
||||
Parallelism: 1,
|
||||
Order: "stop-first" as const,
|
||||
FailureAction: "rollback" as const,
|
||||
},
|
||||
};
|
||||
try {
|
||||
const service = docker.getService(appName);
|
||||
|
||||
@@ -109,7 +109,11 @@ export const buildPostgres = async (postgres: PostgresNested) => {
|
||||
]
|
||||
: [],
|
||||
},
|
||||
UpdateConfig,
|
||||
UpdateConfig: postgres.updateConfigSwarm ?? {
|
||||
Parallelism: 1,
|
||||
Order: "stop-first" as const,
|
||||
FailureAction: "rollback" as const,
|
||||
},
|
||||
};
|
||||
try {
|
||||
const service = docker.getService(appName);
|
||||
|
||||
@@ -115,7 +115,11 @@ export const buildRedis = async (redis: RedisNested) => {
|
||||
]
|
||||
: [],
|
||||
},
|
||||
UpdateConfig,
|
||||
UpdateConfig: redis.updateConfigSwarm ?? {
|
||||
Parallelism: 1,
|
||||
Order: "stop-first" as const,
|
||||
FailureAction: "rollback" as const,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { safeDockerLoginCommand } from "@dokploy/server/services/registry";
|
||||
import type { ApplicationNested } from "../builders";
|
||||
|
||||
export const buildRemoteDocker = async (application: ApplicationNested) => {
|
||||
@@ -13,7 +14,7 @@ echo "Pulling ${dockerImage}";
|
||||
|
||||
if (username && password) {
|
||||
command += `
|
||||
if ! echo "${password}" | docker login --username "${username}" --password-stdin "${registryUrl || ""}" 2>&1; then
|
||||
if ! ${safeDockerLoginCommand(registryUrl || "", username, password)} 2>&1; then
|
||||
echo "❌ Login failed";
|
||||
exit 1;
|
||||
fi
|
||||
|
||||
@@ -77,9 +77,9 @@ export const restoreComposeBackup = async (
|
||||
});
|
||||
|
||||
emit("Starting restore...");
|
||||
emit(`Backup path: ${backupPath}`);
|
||||
|
||||
emit(`Executing command: ${restoreCommand}`);
|
||||
emit(
|
||||
`Restoring database: ${backupInput.databaseName} from ${backupInput.backupFile}`,
|
||||
);
|
||||
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, restoreCommand);
|
||||
|
||||
@@ -21,15 +21,13 @@ export const restoreLibsqlBackup = async (
|
||||
|
||||
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}"`;
|
||||
|
||||
emit("Starting restore...");
|
||||
emit(`Backup path: ${backupPath}`);
|
||||
|
||||
const containerSearch = getServiceContainerCommand(appName);
|
||||
const restoreCommand = `docker exec -i $CONTAINER_ID sh -c "tar xzf - -C /var/lib/sqld"`;
|
||||
|
||||
const command = `CONTAINER_ID=$(${containerSearch}) && ${rcloneCommand} | ${restoreCommand}`;
|
||||
|
||||
emit(`Executing command: ${command}`);
|
||||
emit("Starting restore...");
|
||||
emit(`Restoring libsql from ${backupInput.backupFile}`);
|
||||
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
|
||||
@@ -34,8 +34,9 @@ export const restoreMariadbBackup = async (
|
||||
});
|
||||
|
||||
emit("Starting restore...");
|
||||
|
||||
emit(`Executing command: ${command}`);
|
||||
emit(
|
||||
`Restoring database: ${backupInput.databaseName} from ${backupInput.backupFile}`,
|
||||
);
|
||||
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
|
||||
@@ -34,8 +34,9 @@ export const restoreMongoBackup = async (
|
||||
});
|
||||
|
||||
emit("Starting restore...");
|
||||
|
||||
emit(`Executing command: ${command}`);
|
||||
emit(
|
||||
`Restoring database: ${backupInput.databaseName} from ${backupInput.backupFile}`,
|
||||
);
|
||||
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
|
||||
@@ -33,8 +33,9 @@ export const restoreMySqlBackup = async (
|
||||
});
|
||||
|
||||
emit("Starting restore...");
|
||||
|
||||
emit(`Executing command: ${command}`);
|
||||
emit(
|
||||
`Restoring database: ${backupInput.databaseName} from ${backupInput.backupFile}`,
|
||||
);
|
||||
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user