mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-17 06:05:29 +00:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e3d6fdf2c | |||
| cabfe2e81e | |||
| b0ab9c77fb | |||
| 2f7f50aec5 | |||
| 532de2c59e | |||
| 542eced075 | |||
| 4b2de31b1c | |||
| 4b7a6295fc | |||
| a4e29172f4 | |||
| 22450abc4b | |||
| 976161647f | |||
| bfc180dfef | |||
| 628b14d07a | |||
| 0efe7feaf5 | |||
| 474081a264 | |||
| 1053743a1c | |||
| 355b856185 | |||
| a1cc2778f3 | |||
| fee0071223 | |||
| 3334edef73 | |||
| cf0e8d236c | |||
| 9d9b41c1e5 | |||
| bdf33750a3 | |||
| a66684a784 | |||
| abc2439ec4 | |||
| 5b6a8bd1ef | |||
| d26f70642d | |||
| e2d2dbb00a | |||
| e4012d857d | |||
| ab62080e9d | |||
| 3a8fad57e6 | |||
| 203cddbeef | |||
| 032e804114 | |||
| ca3da4dc5d | |||
| 87b914996f | |||
| 8f3230e2ed | |||
| cebd380856 | |||
| a822db5967 | |||
| 71816d0853 | |||
| 85ed75a5c6 | |||
| f46cd4601c | |||
| 2a585933e9 | |||
| 62e6a4c017 | |||
| 4710c28db5 | |||
| 63efbffcfd | |||
| 523e8f4e4f | |||
| b67519ebd5 | |||
| 452b7aa2f2 | |||
| bbde5ebbc3 | |||
| 529f54e2a5 | |||
| 8144e1a2fe | |||
| d11735d4cd | |||
| 679dbf98cb | |||
| 09ea766fb5 |
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"dokploy": {
|
||||
"type": "http",
|
||||
"url": "${DOKPLOY_BASE_URL}/api/mcp",
|
||||
"headers": { "Authorization": "Bearer ${DOKPLOY_TOKEN}" }
|
||||
},
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@playwright/mcp@latest", "--headless", "--isolated"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
## Code style
|
||||
- Don't write comments that restate what the code already says.
|
||||
- Comment only the "why" when something isn't obvious: workarounds,
|
||||
counterintuitive decisions, constraints from an external API.
|
||||
- No section-divider comments like `// --- Helpers ---`.
|
||||
- Don't leave comments describing the change you just made.
|
||||
@@ -0,0 +1,58 @@
|
||||
import { createCommand } from "@dokploy/server/utils/builders/compose";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const base = {
|
||||
composeType: "docker-compose" as const,
|
||||
appName: "compose-app",
|
||||
sourceType: "github" as const,
|
||||
command: "",
|
||||
};
|
||||
|
||||
describe("compose createCommand --project-directory", () => {
|
||||
it("pins --project-directory to the code dir when composePath is nested", () => {
|
||||
const cmd = createCommand(
|
||||
{ ...base, composePath: "./deploy/docker-compose.yml" } as any,
|
||||
"/etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
|
||||
expect(cmd).toContain(
|
||||
"--project-directory /etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
expect(cmd).toContain("-f ./deploy/docker-compose.yml");
|
||||
});
|
||||
|
||||
it("omits --project-directory when no projectPath is passed", () => {
|
||||
const cmd = createCommand({
|
||||
...base,
|
||||
composePath: "./deploy/docker-compose.yml",
|
||||
} as any);
|
||||
|
||||
expect(cmd).not.toContain("--project-directory");
|
||||
});
|
||||
|
||||
it("does not add --project-directory to stack deploy (unsupported flag)", () => {
|
||||
const cmd = createCommand(
|
||||
{
|
||||
...base,
|
||||
composeType: "stack",
|
||||
composePath: "./deploy/docker-compose.yml",
|
||||
} as any,
|
||||
"/etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
|
||||
expect(cmd).not.toContain("--project-directory");
|
||||
expect(cmd.startsWith("stack deploy")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps raw sourceType resolving from the code dir (root docker-compose.yml)", () => {
|
||||
const cmd = createCommand(
|
||||
{ ...base, sourceType: "raw", composePath: "docker-compose.yml" } as any,
|
||||
"/etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
|
||||
expect(cmd).toContain(
|
||||
"--project-directory /etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
expect(cmd).toContain("-f docker-compose.yml");
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,9 @@ const cases: Record<string, string> = {
|
||||
APOSTROPHE: "it's a test",
|
||||
UNICODE: "héllo wörld 日本語 🚀",
|
||||
MULTILINE_PEM: "-----BEGIN KEY-----\nabc123\n-----END KEY-----",
|
||||
APP_URL: "https://example.com",
|
||||
ASSET_URL: "https://example.com",
|
||||
DB_HOST: "localhost",
|
||||
};
|
||||
|
||||
// How each value must be typed in the UI so the (unchanged) dotenv input
|
||||
@@ -46,6 +49,9 @@ const inputEncoding: Record<string, string> = {
|
||||
APOSTROPHE: "it's a test",
|
||||
UNICODE: "héllo wörld 日本語 🚀",
|
||||
MULTILINE_PEM: '"-----BEGIN KEY-----\nabc123\n-----END KEY-----"',
|
||||
APP_URL: "https://example.com",
|
||||
ASSET_URL: '"${APP_URL}"',
|
||||
DB_HOST: '"${UNDEFINED_HOST:-localhost}"',
|
||||
};
|
||||
|
||||
describe("getCreateEnvFileCommand", () => {
|
||||
@@ -93,46 +99,4 @@ describe("getCreateEnvFileCommand", () => {
|
||||
expect(actual[key], key).toBe(value);
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
it("still interpolates a ${VAR} self-reference in the .env file", () => {
|
||||
mkdirSync(codePath, { recursive: true });
|
||||
|
||||
const serviceEnv = [
|
||||
"APP_URL=https://example.com",
|
||||
'ASSET_URL="${APP_URL}"',
|
||||
].join("\n");
|
||||
|
||||
const command = getCreateEnvFileCommand({
|
||||
appName,
|
||||
composePath: "docker-compose.yml",
|
||||
env: serviceEnv,
|
||||
randomize: false,
|
||||
suffix: "",
|
||||
serverId: null,
|
||||
environment: { project: { env: "" }, env: "" },
|
||||
} as Parameters<typeof getCreateEnvFileCommand>[0]);
|
||||
|
||||
execFileSync("bash", ["-c", command]);
|
||||
|
||||
const composeFile =
|
||||
"services:\n test:\n image: busybox\n environment:\n - ASSET_URL=${ASSET_URL}\n";
|
||||
writeFileSync(join(codePath, "docker-compose.yml"), composeFile);
|
||||
|
||||
const out = execFileSync(
|
||||
"docker",
|
||||
[
|
||||
"compose",
|
||||
"run",
|
||||
"--rm",
|
||||
"-T",
|
||||
"test",
|
||||
"sh",
|
||||
"-c",
|
||||
"printf '%s' \"$ASSET_URL\"",
|
||||
],
|
||||
{ cwd: codePath, encoding: "utf8" },
|
||||
);
|
||||
|
||||
expect(out).toBe("https://example.com");
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
@@ -50,6 +50,15 @@ describe("getRailpackCommand", () => {
|
||||
expect(command).toContain("--build-arg cache-key=");
|
||||
});
|
||||
|
||||
it("installs Railpack through sudo for non-root users", () => {
|
||||
const command = getRailpackCommand(createApplication());
|
||||
|
||||
expect(command).toContain(
|
||||
'$SUDO_CMD bash -c "$(curl -fsSL https://railpack.com/install.sh)"',
|
||||
);
|
||||
expect(command).toContain("sudo -n true 2>/dev/null");
|
||||
});
|
||||
|
||||
it("changes secrets-hash when an environment value changes", () => {
|
||||
const firstCommand = getRailpackCommand(
|
||||
createApplication({
|
||||
|
||||
@@ -16,6 +16,30 @@ describe("shouldDeploy", () => {
|
||||
expect(shouldDeploy(["src/**"], ["docs/readme.md"])).toBe(false);
|
||||
});
|
||||
|
||||
it("should apply multiple negated watch paths as one pattern set", () => {
|
||||
const watchPaths = ["!CHANGELOG.md", "!VERSION", "!tests/**"];
|
||||
|
||||
expect(shouldDeploy(watchPaths, ["CHANGELOG.md"])).toBe(false);
|
||||
expect(shouldDeploy(watchPaths, ["VERSION"])).toBe(false);
|
||||
expect(shouldDeploy(watchPaths, ["tests/unit/example.test.ts"])).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("should deploy when a changed file remains after exclusions", () => {
|
||||
const watchPaths = ["!CHANGELOG.md", "!VERSION", "!tests/**"];
|
||||
|
||||
expect(shouldDeploy(watchPaths, ["VERSION", "src/index.ts"])).toBe(true);
|
||||
});
|
||||
|
||||
it("should combine positive and negated watch paths", () => {
|
||||
const watchPaths = ["src/**", "!src/**/*.test.ts"];
|
||||
|
||||
expect(shouldDeploy(watchPaths, ["src/index.ts"])).toBe(true);
|
||||
expect(shouldDeploy(watchPaths, ["src/index.test.ts"])).toBe(false);
|
||||
expect(shouldDeploy(watchPaths, ["docs/readme.md"])).toBe(false);
|
||||
});
|
||||
|
||||
it("should not throw when modified files contain non-string values", () => {
|
||||
expect(() =>
|
||||
shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any),
|
||||
|
||||
-51
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
prepareEnvironmentVariables,
|
||||
prepareEnvironmentVariablesForFile,
|
||||
prepareEnvironmentVariablesForShell,
|
||||
} from "@dokploy/server/index";
|
||||
import { describe, expect, it } from "vitest";
|
||||
@@ -643,53 +642,3 @@ SPECIAL=café résumé naïve
|
||||
expect(resolved[2]).toContain("café");
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareEnvironmentVariablesForFile (.env file escaping)", () => {
|
||||
it("keeps a well-formed ${VAR} self-reference interpolatable", () => {
|
||||
const serviceEnv = `
|
||||
APP_URL=https://example.com
|
||||
ASSET_URL=\${APP_URL}
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForFile(serviceEnv, "", "");
|
||||
|
||||
expect(resolved).toEqual([
|
||||
`APP_URL="https://example.com"`,
|
||||
`ASSET_URL="\${APP_URL}"`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a ${VAR:-default} self-reference with a modifier interpolatable", () => {
|
||||
const serviceEnv = `ASSET_URL=\${APP_URL:-https://default.com}`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForFile(serviceEnv, "", "");
|
||||
|
||||
expect(resolved).toEqual([`ASSET_URL="\${APP_URL:-https://default.com}"`]);
|
||||
});
|
||||
|
||||
it("escapes a literal dollar sign followed by a word so it isn't interpolated", () => {
|
||||
const serviceEnv = "PASSWORD=pa$word";
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForFile(serviceEnv, "", "");
|
||||
|
||||
expect(resolved).toEqual([`PASSWORD="pa\\$word"`]);
|
||||
});
|
||||
|
||||
it("escapes a trailing dollar sign", () => {
|
||||
const serviceEnv = "PRICE=100$";
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForFile(serviceEnv, "", "");
|
||||
|
||||
expect(resolved).toEqual([`PRICE="100\\$"`]);
|
||||
});
|
||||
|
||||
it("still escapes double quotes and backslashes", () => {
|
||||
const serviceEnv = String.raw`MESSAGE=say "hi" and \backslash`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForFile(serviceEnv, "", "");
|
||||
|
||||
expect(resolved).toEqual([
|
||||
String.raw`MESSAGE="say \"hi\" and \\backslash"`,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,6 +126,13 @@ describe("member is denied org-level enterprise resources (CVE: bypass via stati
|
||||
await expect(checkPermission(ctx, { server: ["read"] })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member is denied server.terminal", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { server: ["terminal"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member is denied registry.create", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
|
||||
@@ -105,6 +105,7 @@ describe("enterprise resources for static roles", () => {
|
||||
const perms = await resolvePermissions(ctx);
|
||||
|
||||
expect(perms.server.read).toBe(false);
|
||||
expect(perms.server.terminal).toBe(false);
|
||||
expect(perms.registry.read).toBe(false);
|
||||
expect(perms.certificate.read).toBe(false);
|
||||
expect(perms.destination.read).toBe(false);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockMemberData = (role: string) => ({
|
||||
id: "member-1",
|
||||
role,
|
||||
userId: "user-1",
|
||||
organizationId: "org-1",
|
||||
accessedProjects: [] as string[],
|
||||
accessedServices: [] as string[],
|
||||
accessedEnvironments: [] as string[],
|
||||
accessedServers: [] as string[],
|
||||
canCreateProjects: false,
|
||||
canDeleteProjects: false,
|
||||
canCreateServices: false,
|
||||
canDeleteServices: false,
|
||||
canCreateEnvironments: false,
|
||||
canDeleteEnvironments: false,
|
||||
canAccessToTraefikFiles: false,
|
||||
canAccessToDocker: false,
|
||||
canAccessToAPI: false,
|
||||
canAccessToSSHKeys: false,
|
||||
canAccessToGitProviders: false,
|
||||
user: { id: "user-1", email: "test@test.com" },
|
||||
});
|
||||
|
||||
let memberToReturn = mockMemberData("deployer");
|
||||
let rolesToReturn: { permission: string }[] = [];
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
member: {
|
||||
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
},
|
||||
organizationRole: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(() => Promise.resolve(rolesToReturn)),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
||||
hasValidLicense: vi.fn(() => Promise.resolve(true)),
|
||||
}));
|
||||
|
||||
const { checkPermission, resolvePermissions } = await import(
|
||||
"@dokploy/server/services/permission"
|
||||
);
|
||||
|
||||
const ctx = {
|
||||
user: { id: "user-1" },
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
};
|
||||
|
||||
const withPermissions = (permissions: Record<string, string[]>) => {
|
||||
rolesToReturn = [{ permission: JSON.stringify(permissions) }];
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
memberToReturn = mockMemberData("deployer");
|
||||
rolesToReturn = [];
|
||||
});
|
||||
|
||||
describe("server.terminal on custom roles", () => {
|
||||
it("a role with server.read alone cannot open a terminal", async () => {
|
||||
withPermissions({ server: ["read"] });
|
||||
|
||||
await expect(
|
||||
checkPermission(ctx, { server: ["read"] }),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
checkPermission(ctx, { server: ["terminal"] }),
|
||||
).rejects.toThrow();
|
||||
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.server.read).toBe(true);
|
||||
expect(perms.server.terminal).toBe(false);
|
||||
});
|
||||
|
||||
it("a role with server.terminal can open a terminal", async () => {
|
||||
withPermissions({ server: ["read", "terminal"] });
|
||||
|
||||
await expect(
|
||||
checkPermission(ctx, { server: ["terminal"] }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.server.terminal).toBe(true);
|
||||
});
|
||||
|
||||
it("owner and admin keep terminal access", async () => {
|
||||
for (const role of ["owner", "admin"]) {
|
||||
memberToReturn = mockMemberData(role);
|
||||
await expect(
|
||||
checkPermission(ctx, { server: ["terminal"] }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.server.terminal).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { writeAppTraefikConfig } from "@dokploy/server/utils/traefik/application";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
execAsyncRemote: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/utils/process/execAsync")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
execAsyncRemote: mocks.execAsyncRemote,
|
||||
};
|
||||
});
|
||||
|
||||
describe("writeAppTraefikConfig", () => {
|
||||
let cwd: string;
|
||||
let dynamicPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dokploy-traefik-"));
|
||||
dynamicPath = path.join(cwd, ".docker", "traefik", "dynamic");
|
||||
fs.mkdirSync(dynamicPath, { recursive: true });
|
||||
vi.spyOn(process, "cwd").mockReturnValue(cwd);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Regression test for #5189: Traefik's file provider rejects a standalone
|
||||
// `routers: {}` / `services: {}` map and aborts its watcher for every
|
||||
// dynamic config once it hits one, so an app with no domains must never
|
||||
// get an on-disk config file at all.
|
||||
it("removes the file instead of writing empty routers/services", async () => {
|
||||
const appName = "no-domain-app";
|
||||
const configPath = path.join(dynamicPath, `${appName}.yml`);
|
||||
fs.writeFileSync(configPath, "stale content", "utf8");
|
||||
|
||||
await writeAppTraefikConfig(
|
||||
{ http: { routers: {}, services: {} } },
|
||||
appName,
|
||||
);
|
||||
|
||||
expect(fs.existsSync(configPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("writes the file when routers/services are present", async () => {
|
||||
const appName = "with-domain-app";
|
||||
const configPath = path.join(dynamicPath, `${appName}.yml`);
|
||||
|
||||
await writeAppTraefikConfig(
|
||||
{
|
||||
http: {
|
||||
routers: {
|
||||
[`${appName}-router-1`]: {
|
||||
rule: "Host(`x`)",
|
||||
service: `${appName}-service`,
|
||||
},
|
||||
},
|
||||
services: {},
|
||||
},
|
||||
},
|
||||
appName,
|
||||
);
|
||||
|
||||
expect(fs.existsSync(configPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("removes the remote file instead of writing empty routers/services", async () => {
|
||||
mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
|
||||
await writeAppTraefikConfig(
|
||||
{ http: { routers: {}, services: {} } },
|
||||
"no-domain-app",
|
||||
"server-id",
|
||||
);
|
||||
|
||||
expect(mocks.execAsyncRemote).toHaveBeenCalledOnce();
|
||||
const [, command] = mocks.execAsyncRemote.mock.calls[0] ?? [];
|
||||
expect(command).toMatch(/^rm -f /);
|
||||
expect(command).toContain("no-domain-app.yml");
|
||||
});
|
||||
|
||||
it("writes the remote file when routers/services are present", async () => {
|
||||
mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
|
||||
await writeAppTraefikConfig(
|
||||
{
|
||||
http: {
|
||||
routers: {
|
||||
"with-domain-app-router-1": {
|
||||
rule: "Host(`x`)",
|
||||
service: "with-domain-app-service",
|
||||
},
|
||||
},
|
||||
services: {},
|
||||
},
|
||||
},
|
||||
"with-domain-app",
|
||||
"server-id",
|
||||
);
|
||||
|
||||
expect(mocks.execAsyncRemote).toHaveBeenCalledOnce();
|
||||
const [, command] = mocks.execAsyncRemote.mock.calls[0] ?? [];
|
||||
expect(command).toMatch(/^echo /);
|
||||
});
|
||||
});
|
||||
@@ -95,10 +95,35 @@ describe("canAccessTerminalOverWss", () => {
|
||||
});
|
||||
|
||||
it("gates a remote server terminal on server access", async () => {
|
||||
mockHasPermission.mockResolvedValue(true);
|
||||
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
|
||||
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(true);
|
||||
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-2")).toBe(false);
|
||||
// role lookup must not be needed for the remote path
|
||||
// the remote path must never fall through to the owner/admin local branch
|
||||
expect(mockFindMember).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("denies a remote server terminal without the server.terminal permission", async () => {
|
||||
// Reaching a server (to deploy on it) must not imply a root shell on it.
|
||||
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
|
||||
mockHasPermission.mockResolvedValue(false);
|
||||
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(false);
|
||||
expect(mockHasPermission).toHaveBeenCalledWith(
|
||||
{ user: { id: USER.id }, session: { activeOrganizationId: "org-1" } },
|
||||
{ server: ["terminal"] },
|
||||
);
|
||||
});
|
||||
|
||||
it("allows a remote server terminal with the server.terminal permission", async () => {
|
||||
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
|
||||
mockHasPermission.mockResolvedValue(true);
|
||||
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not check permissions for a server the caller cannot access", async () => {
|
||||
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
|
||||
mockHasPermission.mockResolvedValue(true);
|
||||
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-2")).toBe(false);
|
||||
expect(mockHasPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -191,8 +191,7 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
try {
|
||||
const result = await validateDomain({
|
||||
domain: host,
|
||||
serverIp:
|
||||
application?.server?.ipAddress?.toString() || ip?.toString() || "",
|
||||
serverId: application?.serverId ?? undefined,
|
||||
});
|
||||
|
||||
setValidationStates((prev) => ({
|
||||
|
||||
@@ -55,6 +55,7 @@ export const ShowDockerLogsStack = ({
|
||||
},
|
||||
{
|
||||
enabled: !!appName && option === "swarm",
|
||||
refetchInterval: 5000,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -67,6 +68,7 @@ export const ShowDockerLogsStack = ({
|
||||
},
|
||||
{
|
||||
enabled: !!appName && option === "native",
|
||||
refetchInterval: 5000,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Loader2 } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useState } from "react";
|
||||
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
|
||||
import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
@@ -52,14 +53,15 @@ export const ShowDockerLogsCompose = ({
|
||||
},
|
||||
{
|
||||
enabled: !!appName,
|
||||
refetchInterval: 5000,
|
||||
},
|
||||
);
|
||||
const [containerId, setContainerId] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (data && data?.length > 0) {
|
||||
setContainerId(data[0]?.containerId);
|
||||
}
|
||||
setContainerId((currentContainerId) =>
|
||||
resolveContainerSelection(currentContainerId, data),
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
|
||||
+6
-1
@@ -1,5 +1,6 @@
|
||||
import { formatMb } from "@dokploy/server/monitoring/units";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { api } from "@/utils/api";
|
||||
@@ -167,6 +168,8 @@ export const ContainerFreeMonitoring = ({
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appName) return;
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsUrl = `${protocol}//${window.location.host}/listen-docker-stats-monitoring?appName=${appName}&appType=${appType}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
@@ -196,7 +199,9 @@ export const ContainerFreeMonitoring = ({
|
||||
};
|
||||
|
||||
ws.onclose = (e) => {
|
||||
console.log(e.reason);
|
||||
if (e.reason) {
|
||||
toast.error(e.reason);
|
||||
}
|
||||
};
|
||||
|
||||
return () => ws.close();
|
||||
|
||||
@@ -19,6 +19,11 @@ interface NetworkChartProps {
|
||||
data: any[];
|
||||
}
|
||||
|
||||
function formatNetworkGB(valueInMB: number) {
|
||||
if (Number.isNaN(valueInMB)) return "0";
|
||||
return (valueInMB / 1024).toFixed(1);
|
||||
}
|
||||
|
||||
const chartConfig = {
|
||||
networkIn: {
|
||||
label: "Network In",
|
||||
@@ -38,8 +43,8 @@ export function NetworkChart({ data }: NetworkChartProps) {
|
||||
<CardHeader className="border-b py-5">
|
||||
<CardTitle>Network</CardTitle>
|
||||
<CardDescription>
|
||||
Network Traffic: ↑ {latestData.networkOut} KB/s ↓{" "}
|
||||
{latestData.networkIn} KB/s
|
||||
Network Total: ↑ {formatNetworkGB(latestData.networkOut)} GB ↓{" "}
|
||||
{formatNetworkGB(latestData.networkIn)} GB (since Boot)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
||||
@@ -83,7 +88,7 @@ export function NetworkChart({ data }: NetworkChartProps) {
|
||||
minTickGap={32}
|
||||
tickFormatter={(value) => formatTimestamp(value)}
|
||||
/>
|
||||
<YAxis tickFormatter={(value) => `${value} KB/s`} />
|
||||
<YAxis tickFormatter={(value) => `${formatNetworkGB(value)} GB`} />
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={({ active, payload, label }) => {
|
||||
@@ -105,8 +110,8 @@ export function NetworkChart({ data }: NetworkChartProps) {
|
||||
Network
|
||||
</span>
|
||||
<span className="font-bold">
|
||||
↑ {data.networkOut} KB/s
|
||||
<br />↓ {data.networkIn} KB/s
|
||||
↑ {formatNetworkGB(data.networkOut)} GB
|
||||
<br />↓ {formatNetworkGB(data.networkIn)} GB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,6 +34,7 @@ export const SyncNetworks = ({ serverId }: Props) => {
|
||||
const importMutation = api.network.import.useMutation();
|
||||
const removeMutation = api.network.remove.useMutation();
|
||||
const recreateMutation = api.network.recreate.useMutation();
|
||||
const resyncMutation = api.network.resync.useMutation();
|
||||
|
||||
const toggleSelected = (name: string) => {
|
||||
setSelected((prev) => {
|
||||
@@ -102,6 +103,20 @@ export const SyncNetworks = ({ serverId }: Props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const onResync = async (networkId: string, name: string) => {
|
||||
try {
|
||||
await resyncMutation.mutateAsync({ networkId });
|
||||
toast.success(`Network "${name}" updated from Docker`);
|
||||
await utils.network.all.invalidate();
|
||||
await utils.network.networksToSync.invalidate();
|
||||
await refetch();
|
||||
} catch (error) {
|
||||
toast.error("Error updating network", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
@@ -177,6 +192,45 @@ export const SyncNetworks = ({ serverId }: Props) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!!data?.changed.length && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
Changed ({data.changed.length})
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
These networks were deleted and recreated in Docker under
|
||||
the same name — attributes in Dokploy are outdated.
|
||||
</span>
|
||||
{data.changed.map((changed) => (
|
||||
<div
|
||||
key={changed.networkId}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-dashed p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm">{changed.name}</span>
|
||||
{changed.driver && (
|
||||
<Badge variant="outline">{changed.driver}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
isLoading={resyncMutation.isPending}
|
||||
onClick={() =>
|
||||
onResync(changed.networkId, changed.name)
|
||||
}
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!!data?.missing.length && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
@@ -48,6 +48,7 @@ export function AddOrganization({ organizationId }: Props) {
|
||||
},
|
||||
{
|
||||
enabled: !!organizationId,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
const { mutateAsync, isPending } = organizationId
|
||||
|
||||
@@ -250,29 +250,30 @@ export const ShowServers = () => {
|
||||
</div>
|
||||
|
||||
<TooltipProvider>
|
||||
{server.sshKeyId && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<TerminalModal
|
||||
serverId={server.serverId}
|
||||
asButton={true}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
{server.sshKeyId &&
|
||||
permissions?.server.terminal && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<TerminalModal
|
||||
serverId={server.serverId}
|
||||
asButton={true}
|
||||
>
|
||||
<Terminal className="h-4 w-4" />
|
||||
</Button>
|
||||
</TerminalModal>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Terminal</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
>
|
||||
<Terminal className="h-4 w-4" />
|
||||
</Button>
|
||||
</TerminalModal>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Terminal</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -35,7 +35,7 @@ import { ChangeRole } from "./change-role";
|
||||
export const ShowUsers = () => {
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data, isPending, refetch } = api.user.all.useQuery();
|
||||
const { mutateAsync } = api.user.remove.useMutation();
|
||||
const { mutateAsync, isPending: isRemoving } = api.user.remove.useMutation();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const { data: hasValidLicense } =
|
||||
api.licenseKey.haveValidLicenseKey.useQuery();
|
||||
@@ -237,6 +237,7 @@ export const ShowUsers = () => {
|
||||
title="Delete User"
|
||||
description="Are you sure you want to delete this user?"
|
||||
type="destructive"
|
||||
disabled={isRemoving}
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
userId: member.user.id,
|
||||
@@ -269,6 +270,7 @@ export const ShowUsers = () => {
|
||||
title="Unlink User"
|
||||
description="Are you sure you want to unlink this user?"
|
||||
type="destructive"
|
||||
disabled={isRemoving}
|
||||
onClick={async () => {
|
||||
if (!isCloud) {
|
||||
const orgCount =
|
||||
|
||||
@@ -328,6 +328,10 @@ const ACTION_META: Record<
|
||||
label: "Delete",
|
||||
description: "Remove servers from the organization",
|
||||
},
|
||||
terminal: {
|
||||
label: "Terminal",
|
||||
description: "Open an SSH root shell on remote servers",
|
||||
},
|
||||
},
|
||||
registry: {
|
||||
read: { label: "Read", description: "View configured Docker registries" },
|
||||
@@ -569,7 +573,7 @@ const ROLE_PRESETS: {
|
||||
envVars: ["read", "write"],
|
||||
projectEnvVars: ["read", "write"],
|
||||
environmentEnvVars: ["read", "write"],
|
||||
server: ["read", "create", "delete"],
|
||||
server: ["read", "create", "delete", "terminal"],
|
||||
registry: ["read", "create", "delete"],
|
||||
certificate: ["read", "create", "delete"],
|
||||
backup: ["read", "create", "delete", "restore"],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { OpenPanelComponent } from "@openpanel/nextjs";
|
||||
import { useRouter } from "next/router";
|
||||
import Script from "next/script";
|
||||
import { useEffect } from "react";
|
||||
@@ -5,9 +6,10 @@ import { GTM_ID, pushToDataLayer } from "@/lib/analytics";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
/**
|
||||
* Loads Google Tag Manager and the HubSpot marketing tag on the cloud
|
||||
* version only, and translates tracking query params appended by the
|
||||
* auth/billing flows (?signup=..., ?subscription=new) into dataLayer events.
|
||||
* Loads Google Tag Manager, the HubSpot marketing tag, and OpenPanel
|
||||
* product analytics on the cloud version only, and translates tracking
|
||||
* query params appended by the auth/billing flows (?signup=...,
|
||||
* ?subscription=new) into dataLayer events.
|
||||
*/
|
||||
export const Analytics = () => {
|
||||
const router = useRouter();
|
||||
@@ -40,6 +42,14 @@ export const Analytics = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<OpenPanelComponent
|
||||
apiUrl="https://openpanel.dokploy.com/api"
|
||||
clientId="bf5a178b-7f28-4461-bf47-d63feff15922"
|
||||
trackScreenViews={true}
|
||||
trackOutgoingLinks={true}
|
||||
trackAttributes={true}
|
||||
globalProperties={{ site: "app" }}
|
||||
/>
|
||||
<Script id="analytics-init" strategy="afterInteractive">
|
||||
{`
|
||||
window.hsConversationsSettings = { loadImmediately: false };
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "network" ADD COLUMN "dockerId" text;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Grant the new "server.terminal" permission to existing custom roles that already have
|
||||
-- "server.read", which is the permission that surfaces the terminal in the UI today.
|
||||
-- Roles without a "server" entry are deliberately left alone: they never saw the terminal in the
|
||||
-- UI, so from now on they are denied at the websocket too.
|
||||
UPDATE "organization_role" AS r
|
||||
SET "permission" = jsonb_set(
|
||||
r."permission"::jsonb,
|
||||
'{server}',
|
||||
(r."permission"::jsonb->'server') || '["terminal"]'::jsonb
|
||||
)::text
|
||||
WHERE jsonb_typeof(r."permission"::jsonb->'server') = 'array'
|
||||
AND r."permission"::jsonb->'server' @> '["read"]'::jsonb
|
||||
AND NOT r."permission"::jsonb->'server' @> '["terminal"]'::jsonb;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1303,6 +1303,20 @@
|
||||
"when": 1786526512677,
|
||||
"tag": "0185_needy_kingpin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 186,
|
||||
"version": "7",
|
||||
"when": 1787813846067,
|
||||
"tag": "0186_tearful_dragon_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 187,
|
||||
"version": "7",
|
||||
"when": 1787937580323,
|
||||
"tag": "0187_grant_terminal_permission_to_read_roles",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.30.2",
|
||||
"version": "v0.30.3",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
@@ -67,6 +67,7 @@
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@octokit/auth-app": "^6.1.3",
|
||||
"@octokit/webhooks": "^13.9.0",
|
||||
"@openpanel/nextjs": "^1.5.1",
|
||||
"@react-email/components": "^1.0.12",
|
||||
"@stepperize/react": "4.0.1",
|
||||
"@stripe/stripe-js": "4.8.0",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { validateRequest } from "@dokploy/server";
|
||||
import {
|
||||
OPENAPI_MAX_JSON_BODY_SIZE,
|
||||
OPENAPI_MAX_UPLOAD_SIZE,
|
||||
validateRequest,
|
||||
} from "@dokploy/server";
|
||||
import { createOpenApiNextHandler } from "@dokploy/trpc-openapi";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
@@ -12,10 +16,31 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// getMultipartBody doesn't accept maxBodySize, so we cap it here instead.
|
||||
const contentLength = Number(req.headers["content-length"]);
|
||||
const isMultipart = req.headers["content-type"]?.startsWith(
|
||||
"multipart/form-data",
|
||||
);
|
||||
|
||||
if (isMultipart && !Number.isFinite(contentLength)) {
|
||||
res.status(411).json({ message: "Content-Length required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const limit = isMultipart
|
||||
? OPENAPI_MAX_UPLOAD_SIZE
|
||||
: OPENAPI_MAX_JSON_BODY_SIZE;
|
||||
|
||||
if (Number.isFinite(contentLength) && contentLength > limit) {
|
||||
res.status(413).json({ message: "Payload too large" });
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
return createOpenApiNextHandler({
|
||||
router: appRouter,
|
||||
createContext: createTRPCContext,
|
||||
maxBodySize: OPENAPI_MAX_JSON_BODY_SIZE,
|
||||
onError:
|
||||
process.env.NODE_ENV === "development"
|
||||
? ({ path, error }: { path: string | undefined; error: Error }) => {
|
||||
@@ -28,3 +53,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
};
|
||||
|
||||
export default handler;
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
addDomainToCompose,
|
||||
clearOldDeployments,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
updateCompose,
|
||||
updateDeploymentStatus,
|
||||
} from "@dokploy/server";
|
||||
import { paths } from "@dokploy/server/constants";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { canEditDeployGitSource } from "@dokploy/server/services/git-provider";
|
||||
import {
|
||||
@@ -547,7 +549,9 @@ export const composeRouter = createTRPCRouter({
|
||||
service: ["create"],
|
||||
});
|
||||
const compose = await findComposeById(input.composeId);
|
||||
const command = createCommand(compose);
|
||||
const { COMPOSE_PATH } = paths(!!compose.serverId);
|
||||
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
|
||||
const command = createCommand(compose, projectPath);
|
||||
return `docker ${command}`;
|
||||
}),
|
||||
refreshToken: protectedProcedure
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
findPreviewDeploymentById,
|
||||
findServerById,
|
||||
generateTraefikMeDomain,
|
||||
getServerIpCandidates,
|
||||
getWebServerSettings,
|
||||
manageDomain,
|
||||
removeDomain,
|
||||
@@ -248,10 +249,21 @@ export const domainRouter = createTRPCRouter({
|
||||
.input(
|
||||
z.object({
|
||||
domain: z.string(),
|
||||
serverIp: z.string().optional(),
|
||||
serverId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
return validateDomain(input.domain, input.serverIp);
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
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 expectedIps = await getServerIpCandidates(input.serverId);
|
||||
return validateDomain(input.domain, expectedIps);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
inspectNetwork,
|
||||
recreateNetwork,
|
||||
removeNetwork,
|
||||
resyncNetwork,
|
||||
} from "@dokploy/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
@@ -129,6 +130,29 @@ export const networkRouter = createTRPCRouter({
|
||||
return recreated;
|
||||
}),
|
||||
|
||||
resync: protectedProcedure
|
||||
.input(apiFindOneNetwork)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const network = await findNetworkById(input.networkId);
|
||||
if (network.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Network not found",
|
||||
});
|
||||
}
|
||||
const resynced = await resyncNetwork(
|
||||
input.networkId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "network",
|
||||
resourceId: resynced.networkId,
|
||||
resourceName: resynced.name,
|
||||
});
|
||||
return resynced;
|
||||
}),
|
||||
|
||||
remove: protectedProcedure
|
||||
.input(apiRemoveNetwork)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
|
||||
@@ -62,7 +62,8 @@ export const canAccessDockerOverWss = async (
|
||||
|
||||
// Authorizes the host/server SSH terminal opened over a WebSocket. The local
|
||||
// host terminal is a root shell on the control-plane host, so it is restricted
|
||||
// to owner/admin. A remote server terminal is gated on server access.
|
||||
// to owner/admin. A remote server terminal needs server access plus
|
||||
// server.terminal.
|
||||
export const canAccessTerminalOverWss = async (
|
||||
user: WssUser,
|
||||
session: WssSession,
|
||||
@@ -75,7 +76,11 @@ export const canAccessTerminalOverWss = async (
|
||||
userId: user.id,
|
||||
activeOrganizationId: session.activeOrganizationId,
|
||||
});
|
||||
return accessible.has(serverId);
|
||||
if (!accessible.has(serverId)) return false;
|
||||
|
||||
return await hasPermission(buildCtx(user, session.activeOrganizationId), {
|
||||
server: ["terminal"],
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -182,6 +182,10 @@ export const setupDockerContainerTerminalWebSocketServer = (
|
||||
ptyProcess.onData((data) => {
|
||||
ws.send(data);
|
||||
});
|
||||
ptyProcess.onExit(({ exitCode }) => {
|
||||
ws.send(`\nContainer closed with code: ${exitCode}\n`);
|
||||
ws.close();
|
||||
});
|
||||
ws.on("close", () => {
|
||||
ptyProcess.kill();
|
||||
});
|
||||
|
||||
@@ -8,9 +8,57 @@ import {
|
||||
recordAdvancedStats,
|
||||
validateRequest,
|
||||
} from "@dokploy/server";
|
||||
import { quote } from "shell-quote";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { canAccessDockerOverWss } from "./authorize";
|
||||
|
||||
type AppType = "application" | "stack" | "docker-compose";
|
||||
|
||||
// Swarm task names are "<service>.<slot>.<taskId>"; the manager's local
|
||||
// `docker ps` only sees containers scheduled on this host, so a task running
|
||||
// on another node looks identical to a stopped one. `docker service ps` is
|
||||
// swarm-aggregated and answers from the manager regardless of which node the
|
||||
// task landed on, so we use it here to tell the two cases apart.
|
||||
const findRemoteSwarmNode = async (
|
||||
appName: string,
|
||||
appType: AppType,
|
||||
): Promise<string | null> => {
|
||||
if (appType === "docker-compose") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// `docker service ps --format {{.Name}}` only prints "<service>.<slot>",
|
||||
// never the taskId, so we match on the task ID instead — the last segment
|
||||
// of the stack task name — rather than trying to reconstruct the full name.
|
||||
const [serviceName, taskId] =
|
||||
appType === "stack"
|
||||
? [appName.split(".").slice(0, -2).join("."), appName.split(".").pop()]
|
||||
: [appName, null];
|
||||
|
||||
if (!serviceName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`docker service ps ${quote([serviceName])} --filter "desired-state=running" --no-trunc --format '{"ID":"{{.ID}}","Node":"{{.Node}}","CurrentState":"{{.CurrentState}}"}'`,
|
||||
);
|
||||
|
||||
for (const line of stdout.trim().split("\n")) {
|
||||
if (!line) continue;
|
||||
const task = JSON.parse(line);
|
||||
const isMatch = taskId ? task.ID === taskId : true;
|
||||
if (isMatch && task.CurrentState?.startsWith("Running")) {
|
||||
return task.Node;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not a swarm service (or the swarm CLI isn't available) — fall back to "not running".
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const setupDockerStatsMonitoringSocketServer = (
|
||||
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
|
||||
) => {
|
||||
@@ -98,7 +146,13 @@ export const setupDockerStatsMonitoringSocketServer = (
|
||||
|
||||
const container = containers[0];
|
||||
if (!container || container?.State !== "running") {
|
||||
ws.close(4000, "Container not running");
|
||||
const remoteNode = await findRemoteSwarmNode(appName, appType);
|
||||
ws.close(
|
||||
4000,
|
||||
remoteNode
|
||||
? `Container running on remote node "${remoteNode}"`.slice(0, 123)
|
||||
: "Container not running",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { stdout, stderr } = await execAsync(
|
||||
|
||||
@@ -13,6 +13,28 @@ export const DOKPLOY_DOCKER_PORT = process.env.DOKPLOY_DOCKER_PORT
|
||||
|
||||
export const CLEANUP_CRON_JOB = "50 23 * * *";
|
||||
|
||||
// Body size limits for the OpenAPI catch-all route (pages/api/[...trpc].ts).
|
||||
const parseByteSize = (envVar: string, fallback: number): number => {
|
||||
const raw = process.env[envVar];
|
||||
if (!raw) return fallback;
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
console.warn(`Invalid ${envVar}="${raw}", using default ${fallback}`);
|
||||
return fallback;
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
export const OPENAPI_MAX_JSON_BODY_SIZE = parseByteSize(
|
||||
"OPENAPI_MAX_JSON_BODY_SIZE",
|
||||
10 * 1024 * 1024, // 10mb
|
||||
);
|
||||
|
||||
export const OPENAPI_MAX_UPLOAD_SIZE = parseByteSize(
|
||||
"OPENAPI_MAX_UPLOAD_SIZE",
|
||||
1024 * 1024 * 1024, // 1gb
|
||||
);
|
||||
|
||||
type DockerSocketCandidate = {
|
||||
label: string;
|
||||
path: string;
|
||||
|
||||
@@ -21,6 +21,7 @@ export const network = pgTable("network", {
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
name: text("name").notNull(),
|
||||
dockerId: text("dockerId"),
|
||||
driver: networkDriver("driver").notNull().default("bridge"),
|
||||
internal: boolean("internal").notNull().default(false),
|
||||
attachable: boolean("attachable").notNull().default(false),
|
||||
|
||||
@@ -35,7 +35,7 @@ export const statements = {
|
||||
envVars: ["read", "write"],
|
||||
projectEnvVars: ["read", "write"],
|
||||
environmentEnvVars: ["read", "write"],
|
||||
server: ["read", "create", "delete"],
|
||||
server: ["read", "create", "delete", "terminal"],
|
||||
registry: ["read", "create", "delete"],
|
||||
certificate: ["read", "create", "update", "delete"],
|
||||
backup: ["read", "create", "update", "delete", "restore"],
|
||||
@@ -104,7 +104,7 @@ export const ownerRole = ac.newRole({
|
||||
envVars: ["read", "write"],
|
||||
projectEnvVars: ["read", "write"],
|
||||
environmentEnvVars: ["read", "write"],
|
||||
server: ["read", "create", "delete"],
|
||||
server: ["read", "create", "delete", "terminal"],
|
||||
registry: ["read", "create", "delete"],
|
||||
certificate: ["read", "create", "update", "delete"],
|
||||
backup: ["read", "create", "update", "delete", "restore"],
|
||||
@@ -143,7 +143,7 @@ export const adminRole = ac.newRole({
|
||||
envVars: ["read", "write"],
|
||||
projectEnvVars: ["read", "write"],
|
||||
environmentEnvVars: ["read", "write"],
|
||||
server: ["read", "create", "delete"],
|
||||
server: ["read", "create", "delete", "terminal"],
|
||||
registry: ["read", "create", "delete"],
|
||||
certificate: ["read", "create", "update", "delete"],
|
||||
backup: ["read", "create", "update", "delete", "restore"],
|
||||
|
||||
@@ -3,7 +3,9 @@ import { promisify } from "node:util";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
|
||||
import { generateRandomDomain } from "@dokploy/server/templates";
|
||||
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
|
||||
import { manageDomain } from "@dokploy/server/utils/traefik/domain";
|
||||
import { getPublicIpWithFallback } from "@dokploy/server/wss/utils";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { z } from "zod";
|
||||
@@ -154,7 +156,7 @@ const resolveDns = promisify(dns.resolve4);
|
||||
|
||||
export const validateDomain = async (
|
||||
domain: string,
|
||||
expectedIp?: string,
|
||||
expectedIps?: string[],
|
||||
): Promise<{
|
||||
isValid: boolean;
|
||||
resolvedIp?: string;
|
||||
@@ -186,13 +188,13 @@ export const validateDomain = async (
|
||||
};
|
||||
}
|
||||
|
||||
// If we have an expected IP, validate against it
|
||||
if (expectedIp) {
|
||||
if (expectedIps && expectedIps.length > 0) {
|
||||
const isValid = resolvedIps.some((ip) => expectedIps.includes(ip));
|
||||
return {
|
||||
isValid: resolvedIps.includes(expectedIp),
|
||||
isValid,
|
||||
resolvedIp: resolvedIps.join(", "),
|
||||
error: !resolvedIps.includes(expectedIp)
|
||||
? `Domain resolves to ${resolvedIps.join(", ")} but should point to ${expectedIp}`
|
||||
error: !isValid
|
||||
? `Domain resolves to ${resolvedIps.join(", ")} but should point to ${expectedIps.join(" or ")}`
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
@@ -210,3 +212,47 @@ export const validateDomain = async (
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const getServerIpCandidates = async (
|
||||
serverId?: string | null,
|
||||
): Promise<string[]> => {
|
||||
const candidates = new Set<string>();
|
||||
|
||||
if (serverId) {
|
||||
const server = await findServerById(serverId);
|
||||
if (server.ipAddress) {
|
||||
candidates.add(server.ipAddress);
|
||||
}
|
||||
|
||||
const publicIp = await withTimeout(
|
||||
execAsyncRemote(
|
||||
serverId,
|
||||
"curl -s -m 5 https://ifconfig.me || curl -s -m 5 https://icanhazip.com",
|
||||
),
|
||||
7000,
|
||||
);
|
||||
const detectedIp = publicIp?.stdout?.trim();
|
||||
if (detectedIp) {
|
||||
candidates.add(detectedIp);
|
||||
}
|
||||
} else {
|
||||
const settings = await getWebServerSettings();
|
||||
if (settings?.serverIp) {
|
||||
candidates.add(settings.serverIp);
|
||||
}
|
||||
|
||||
const publicIp = await withTimeout(getPublicIpWithFallback(), 7000);
|
||||
if (publicIp) {
|
||||
candidates.add(publicIp);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(candidates);
|
||||
};
|
||||
|
||||
const withTimeout = <T>(promise: Promise<T>, ms: number): Promise<T | null> => {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), ms)),
|
||||
]).catch(() => null);
|
||||
};
|
||||
|
||||
@@ -262,18 +262,28 @@ export const updateFileMount = async (mountId: string) => {
|
||||
if (!mount || !mount.filePath) return;
|
||||
const basePath = await getBaseFilesPath(mountId);
|
||||
const fullPath = path.join(basePath, mount.filePath);
|
||||
const directory = path.dirname(fullPath);
|
||||
|
||||
try {
|
||||
const serverId = await getServerId(mount);
|
||||
const encodedContent = encodeBase64(mount.content || "");
|
||||
const command = `echo "${encodedContent}" | base64 -d > ${quote([fullPath])}`;
|
||||
const command = `
|
||||
mkdir -p ${quote([directory])};
|
||||
if [ -d ${quote([fullPath])} ]; then rm -rf ${quote([fullPath])}; fi;
|
||||
echo "${encodedContent}" | base64 -d > ${quote([fullPath])};
|
||||
`;
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
}
|
||||
} catch {
|
||||
console.log("Error updating file mount");
|
||||
} catch (error) {
|
||||
console.log(`Error updating the file mount: ${error}`);
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Error updating the mount ${error instanceof Error ? error.message : error}`,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { type apiCreateNetwork, network } from "@dokploy/server/db/schema";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import type Dockerode from "dockerode";
|
||||
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||
import type { z } from "zod";
|
||||
import { IS_CLOUD } from "../constants";
|
||||
@@ -17,6 +18,7 @@ const RESERVED_NETWORKS = [
|
||||
];
|
||||
|
||||
type DockerNetworkInfo = {
|
||||
Id?: string;
|
||||
Name: string;
|
||||
Driver: string;
|
||||
Internal?: boolean;
|
||||
@@ -35,6 +37,12 @@ type DockerNetworkInfo = {
|
||||
};
|
||||
};
|
||||
|
||||
// EnableIPv4 is missing from dockerode's NetworkCreateOptions but supported
|
||||
// by the daemon (API >= 1.47); the body is sent as-is
|
||||
type NetworkCreateOptions = Dockerode.NetworkCreateOptions & {
|
||||
EnableIPv4?: boolean;
|
||||
};
|
||||
|
||||
const parseMtu = (value: string | undefined) => {
|
||||
const mtu = Number.parseInt(value ?? "", 10);
|
||||
return Number.isNaN(mtu) ? null : mtu;
|
||||
@@ -51,6 +59,7 @@ const mapDockerNetworkToRow = (
|
||||
serverId: string | null,
|
||||
) => ({
|
||||
name: dockerNetwork.Name,
|
||||
dockerId: dockerNetwork.Id ?? null,
|
||||
driver: dockerNetwork.Driver as "bridge" | "overlay",
|
||||
internal: dockerNetwork.Internal ?? false,
|
||||
attachable: dockerNetwork.Attachable ?? false,
|
||||
@@ -110,7 +119,7 @@ export const findNetworksToSync = async (
|
||||
|
||||
const existing = await findNetworksByServer(organizationId, serverId);
|
||||
const existingNames = new Set(existing.map((row) => row.name));
|
||||
const dockerNames = new Set(dockerNetworks.map((d) => d.Name));
|
||||
const dockerByName = new Map(dockerNetworks.map((d) => [d.Name, d] as const));
|
||||
|
||||
const importable = dockerNetworks
|
||||
.filter(
|
||||
@@ -128,12 +137,28 @@ export const findNetworksToSync = async (
|
||||
.filter((s): s is string => !!s),
|
||||
}));
|
||||
|
||||
// Rows in Dokploy whose network no longer exists in Docker
|
||||
const missing = existing
|
||||
.filter((row) => !dockerNames.has(row.name))
|
||||
.filter((row) => !dockerByName.has(row.name))
|
||||
.map((row) => ({ networkId: row.networkId, name: row.name }));
|
||||
|
||||
return { importable, missing };
|
||||
const changed = existing
|
||||
.filter((row) => {
|
||||
if (!row.dockerId) return false;
|
||||
const dockerNetwork = dockerByName.get(row.name);
|
||||
return !!dockerNetwork?.Id && dockerNetwork.Id !== row.dockerId;
|
||||
})
|
||||
.map((row) => {
|
||||
const dockerNetwork = dockerByName.get(row.name);
|
||||
return {
|
||||
networkId: row.networkId,
|
||||
name: row.name,
|
||||
driver: dockerNetwork?.Driver,
|
||||
internal: dockerNetwork?.Internal ?? false,
|
||||
attachable: dockerNetwork?.Attachable ?? false,
|
||||
};
|
||||
});
|
||||
|
||||
return { importable, missing, changed };
|
||||
};
|
||||
|
||||
export const importDockerNetworks = async (
|
||||
@@ -184,6 +209,49 @@ export const importDockerNetworks = async (
|
||||
return { imported, errors };
|
||||
};
|
||||
|
||||
export const resyncNetwork = async (
|
||||
networkId: string,
|
||||
organizationId: string,
|
||||
) => {
|
||||
const row = await findNetworkById(networkId);
|
||||
if (row.organizationId !== organizationId) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Network not found",
|
||||
});
|
||||
}
|
||||
|
||||
const docker = await getRemoteDocker(row.serverId ?? null);
|
||||
let info: DockerNetworkInfo;
|
||||
try {
|
||||
info = (await docker.getNetwork(row.name).inspect()) as DockerNetworkInfo;
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to inspect Docker network",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(network)
|
||||
.set(mapDockerNetworkToRow(info, organizationId, row.serverId))
|
||||
.where(eq(network.networkId, networkId))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Network not found",
|
||||
});
|
||||
}
|
||||
|
||||
return updated;
|
||||
};
|
||||
|
||||
export const findNetworkById = async (networkId: string) => {
|
||||
const [row] = await db
|
||||
.select()
|
||||
@@ -252,14 +320,12 @@ const createDockerNetworkFromRow = async (row: typeof network.$inferSelect) => {
|
||||
|
||||
const docker = await getRemoteDocker(row.serverId ?? null);
|
||||
try {
|
||||
await docker.createNetwork({
|
||||
const createOptions: NetworkCreateOptions = {
|
||||
Name: row.name,
|
||||
Driver: row.driver,
|
||||
CheckDuplicate: true,
|
||||
Internal: row.internal,
|
||||
Attachable: row.attachable,
|
||||
// EnableIPv4 is missing from dockerode's types but supported by
|
||||
// the daemon (API >= 1.47); the body is sent as-is
|
||||
EnableIPv4: row.enableIPv4,
|
||||
EnableIPv6: row.enableIPv6,
|
||||
Options: row.mtu
|
||||
@@ -269,7 +335,13 @@ const createDockerNetworkFromRow = async (row: typeof network.$inferSelect) => {
|
||||
Driver: ipam.driver || "default",
|
||||
Config: ipamConfig.length > 0 ? ipamConfig : undefined,
|
||||
},
|
||||
} as Parameters<typeof docker.createNetwork>[0]);
|
||||
};
|
||||
const created = await docker.createNetwork(createOptions);
|
||||
|
||||
await db
|
||||
.update(network)
|
||||
.set({ dockerId: created.id })
|
||||
.where(eq(network.networkId, row.networkId));
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
|
||||
@@ -6,7 +6,7 @@ const validateUfw = () => `
|
||||
if command -v ufw >/dev/null 2>&1; then
|
||||
isInstalled=true
|
||||
isActive=$(sudo ufw status | grep -q "Status: active" && echo true || echo false)
|
||||
defaultIncoming=$(sudo ufw status verbose | grep "Default:" | grep "incoming" | awk '{print $2}')
|
||||
defaultIncoming=$(sudo ufw status verbose | grep "Default:" | grep "incoming" | awk '{print $2}' | head -n1 | tr -d '\\r')
|
||||
echo "{\\"installed\\": $isInstalled, \\"active\\": $isActive, \\"defaultIncoming\\": \\"$defaultIncoming\\"}"
|
||||
else
|
||||
echo "{\\"installed\\": false, \\"active\\": false, \\"defaultIncoming\\": \\"unknown\\"}"
|
||||
@@ -27,7 +27,7 @@ const validateSsh = () => `
|
||||
|
||||
# Check for key authentication
|
||||
# SSH key auth is enabled by default unless explicitly disabled
|
||||
pubkey_line=$(sudo grep -i "^PubkeyAuthentication" "$sshd_config" 2>/dev/null | grep -v "#")
|
||||
pubkey_line=$(sudo grep -i "^PubkeyAuthentication" "$sshd_config" 2>/dev/null | grep -v "#" | head -n1)
|
||||
if [ -z "$pubkey_line" ] || echo "$pubkey_line" | grep -q -i "yes"; then
|
||||
keyAuth=true
|
||||
else
|
||||
@@ -36,21 +36,21 @@ const validateSsh = () => `
|
||||
|
||||
# Get the exact PermitRootLogin value from config
|
||||
# This preserves values like "prohibit-password" without normalization
|
||||
permitRootLogin=$(sudo grep -i "^PermitRootLogin" "$sshd_config" 2>/dev/null | grep -v "#" | awk '{print $2}')
|
||||
permitRootLogin=$(sudo grep -i "^PermitRootLogin" "$sshd_config" 2>/dev/null | grep -v "#" | awk '{print $2}' | head -n1 | tr -d '\\r')
|
||||
if [ -z "$permitRootLogin" ]; then
|
||||
# Default is prohibit-password in newer versions
|
||||
permitRootLogin="prohibit-password"
|
||||
fi
|
||||
|
||||
# Get the exact PasswordAuthentication value from config
|
||||
passwordAuth=$(sudo grep -i "^PasswordAuthentication" "$sshd_config" 2>/dev/null | grep -v "#" | awk '{print $2}')
|
||||
passwordAuth=$(sudo grep -i "^PasswordAuthentication" "$sshd_config" 2>/dev/null | grep -v "#" | awk '{print $2}' | head -n1 | tr -d '\\r')
|
||||
if [ -z "$passwordAuth" ]; then
|
||||
# Default is yes
|
||||
passwordAuth="yes"
|
||||
fi
|
||||
|
||||
# Get the exact UsePAM value from config
|
||||
usePam=$(sudo grep -i "^UsePAM" "$sshd_config" 2>/dev/null | grep -v "#" | awk '{print $2}')
|
||||
usePam=$(sudo grep -i "^UsePAM" "$sshd_config" 2>/dev/null | grep -v "#" | awk '{print $2}' | head -n1 | tr -d '\\r')
|
||||
if [ -z "$usePam" ]; then
|
||||
# Default is yes in most distros
|
||||
usePam="yes"
|
||||
|
||||
@@ -21,11 +21,11 @@ export const getBuildComposeCommand = async (rawCompose: ComposeNested) => {
|
||||
const compose = await withResolvedVaultRefs(rawCompose);
|
||||
const { COMPOSE_PATH } = paths(!!compose.serverId);
|
||||
const { sourceType, appName, mounts, composeType, domains } = compose;
|
||||
const command = createCommand(compose);
|
||||
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
|
||||
const command = createCommand(compose, projectPath);
|
||||
const envCommand = compose.createEnvFile
|
||||
? getCreateEnvFileCommand(compose)
|
||||
: "";
|
||||
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
|
||||
const exportEnvCommand = getExportEnvCommand(compose);
|
||||
|
||||
const newCompose = await writeDomainsToCompose(compose, domains);
|
||||
@@ -119,7 +119,7 @@ const sanitizeCommand = (command: string) => {
|
||||
return restCommand.join(" ");
|
||||
};
|
||||
|
||||
export const createCommand = (compose: ComposeNested) => {
|
||||
export const createCommand = (compose: ComposeNested, projectPath?: string) => {
|
||||
const { composeType, appName, sourceType } = compose;
|
||||
if (compose.command) {
|
||||
return `${sanitizeCommand(compose.command)}`;
|
||||
@@ -130,7 +130,10 @@ export const createCommand = (compose: ComposeNested) => {
|
||||
let command = "";
|
||||
|
||||
if (composeType === "docker-compose") {
|
||||
command = `compose -p ${quote([appName])} -f ${quote([path])} up -d --build --remove-orphans`;
|
||||
const projectDirectoryFlag = projectPath
|
||||
? `--project-directory ${quote([projectPath])} `
|
||||
: "";
|
||||
command = `compose -p ${quote([appName])} ${projectDirectoryFlag}-f ${quote([path])} up -d --build --remove-orphans`;
|
||||
} else if (composeType === "stack") {
|
||||
command = `stack deploy -c ${quote([path])} ${quote([appName])} --prune --with-registry-auth`;
|
||||
}
|
||||
|
||||
@@ -87,7 +87,15 @@ export const getRailpackCommand = (application: ApplicationNested) => {
|
||||
# Ensure we have a builder with containerd (isolated per build)
|
||||
|
||||
export RAILPACK_VERSION=${application.railpackVersion}
|
||||
bash -c "$(curl -fsSL https://railpack.com/install.sh)"
|
||||
# use sudo for non-root so the install can write to /usr/local/bin
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
SUDO_CMD=""
|
||||
elif sudo -n true 2>/dev/null; then
|
||||
SUDO_CMD="sudo"
|
||||
else
|
||||
SUDO_CMD=""
|
||||
fi
|
||||
$SUDO_CMD bash -c "$(curl -fsSL https://railpack.com/install.sh)"
|
||||
docker buildx create --name ${builderName} --driver docker-container || true
|
||||
|
||||
echo "Preparing Railpack build plan..." ;
|
||||
|
||||
@@ -548,7 +548,7 @@ export const prepareEnvironmentVariablesForFile = (
|
||||
const escapedValue = value
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\$(?!\{[A-Za-z_][A-Za-z0-9_]*(?::?[-+?][^}]*)?\})/g, "\\$");
|
||||
.replace(/\$(?!\{[A-Za-z_][A-Za-z0-9_]*(?::?[-+?][^{}]*)?\})/g, "\\$");
|
||||
return `${key}="${escapedValue}"`;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -292,6 +292,26 @@ export const writeTraefikConfigRemote = async (
|
||||
}
|
||||
};
|
||||
|
||||
const isEmptyHttpRoutersAndServices = (traefikConfig: FileConfig) =>
|
||||
Object.keys(traefikConfig.http?.routers || {}).length === 0 &&
|
||||
Object.keys(traefikConfig.http?.services || {}).length === 0;
|
||||
|
||||
export const writeAppTraefikConfig = async (
|
||||
traefikConfig: FileConfig,
|
||||
appName: string,
|
||||
serverId?: string | null,
|
||||
) => {
|
||||
if (isEmptyHttpRoutersAndServices(traefikConfig)) {
|
||||
await removeTraefikConfig(appName, serverId);
|
||||
return;
|
||||
}
|
||||
if (serverId) {
|
||||
await writeTraefikConfigRemote(traefikConfig, appName, serverId);
|
||||
} else {
|
||||
writeTraefikConfig(traefikConfig, appName);
|
||||
}
|
||||
};
|
||||
|
||||
export const createServiceConfig = (
|
||||
appName: string,
|
||||
domain: Domain,
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ApplicationNested } from "../builders";
|
||||
import {
|
||||
loadOrCreateConfig,
|
||||
loadOrCreateConfigRemote,
|
||||
writeTraefikConfig,
|
||||
writeAppTraefikConfig,
|
||||
writeTraefikConfigRemote,
|
||||
} from "./application";
|
||||
import type { FileConfig } from "./file-types";
|
||||
@@ -89,11 +89,10 @@ export const createRedirectMiddleware = async (
|
||||
|
||||
if (serverId) {
|
||||
await writeTraefikConfigRemote(config, "middlewares", serverId);
|
||||
await writeTraefikConfigRemote(appConfig, appName, serverId);
|
||||
} else {
|
||||
writeMiddleware(config);
|
||||
writeTraefikConfig(appConfig, appName);
|
||||
}
|
||||
await writeAppTraefikConfig(appConfig, appName, serverId);
|
||||
};
|
||||
|
||||
export const removeRedirectMiddleware = async (
|
||||
@@ -124,9 +123,8 @@ export const removeRedirectMiddleware = async (
|
||||
|
||||
if (serverId) {
|
||||
await writeTraefikConfigRemote(config, "middlewares", serverId);
|
||||
await writeTraefikConfigRemote(appConfig, appName, serverId);
|
||||
} else {
|
||||
writeTraefikConfig(appConfig, appName);
|
||||
writeMiddleware(config);
|
||||
}
|
||||
await writeAppTraefikConfig(appConfig, appName, serverId);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ApplicationNested } from "../builders";
|
||||
import {
|
||||
loadOrCreateConfig,
|
||||
loadOrCreateConfigRemote,
|
||||
writeTraefikConfig,
|
||||
writeAppTraefikConfig,
|
||||
writeTraefikConfigRemote,
|
||||
} from "./application";
|
||||
import type {
|
||||
@@ -62,11 +62,10 @@ export const createSecurityMiddleware = async (
|
||||
addMiddleware(appConfig, middlewareName);
|
||||
if (serverId) {
|
||||
await writeTraefikConfigRemote(config, "middlewares", serverId);
|
||||
await writeTraefikConfigRemote(appConfig, appName, serverId);
|
||||
} else {
|
||||
writeTraefikConfig(appConfig, appName);
|
||||
writeMiddleware(config);
|
||||
}
|
||||
await writeAppTraefikConfig(appConfig, appName, serverId);
|
||||
};
|
||||
|
||||
export const removeSecurityMiddleware = async (
|
||||
@@ -89,6 +88,7 @@ export const removeSecurityMiddleware = async (
|
||||
appConfig = loadOrCreateConfig(appName);
|
||||
}
|
||||
const middlewareName = `auth-${appName}`;
|
||||
let removedLastUser = false;
|
||||
|
||||
if (config.http?.middlewares) {
|
||||
const currentMiddleware = config.http.middlewares[middlewareName];
|
||||
@@ -106,11 +106,7 @@ export const removeSecurityMiddleware = async (
|
||||
delete config.http.middlewares[middlewareName];
|
||||
}
|
||||
deleteMiddleware(appConfig, middlewareName);
|
||||
if (serverId) {
|
||||
await writeTraefikConfigRemote(appConfig, appName, serverId);
|
||||
} else {
|
||||
writeTraefikConfig(appConfig, appName);
|
||||
}
|
||||
removedLastUser = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,6 +116,9 @@ export const removeSecurityMiddleware = async (
|
||||
} else {
|
||||
writeMiddleware(config);
|
||||
}
|
||||
if (removedLastUser) {
|
||||
await writeAppTraefikConfig(appConfig, appName, serverId);
|
||||
}
|
||||
};
|
||||
|
||||
const isBasicAuthMiddleware = (
|
||||
|
||||
@@ -8,5 +8,5 @@ export const shouldDeploy = (
|
||||
const files = (modifiedFiles ?? []).filter(
|
||||
(file): file is string => typeof file === "string",
|
||||
);
|
||||
return micromatch.some(files, watchPaths);
|
||||
return micromatch(files, watchPaths).length > 0;
|
||||
};
|
||||
|
||||
Generated
+90
@@ -175,6 +175,9 @@ importers:
|
||||
'@octokit/webhooks':
|
||||
specifier: ^13.9.0
|
||||
version: 13.9.1
|
||||
'@openpanel/nextjs':
|
||||
specifier: ^1.5.1
|
||||
version: 1.5.1(next@16.3.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@react-email/components':
|
||||
specifier: ^1.0.12
|
||||
version: 1.0.12(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
@@ -2170,6 +2173,19 @@ packages:
|
||||
resolution: {integrity: sha512-Nss2b4Jyn4wB3EAqAPJypGuCJFalz/ZujKBQQ5934To7Xw9xjf4hkr/EAByxQY7hp7MKd790bWGz7XYSTsHmaw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
'@openpanel/nextjs@1.5.1':
|
||||
resolution: {integrity: sha512-60vhKw29weFWmwntx/cSIYU1TcwNzRykeg8P70onr3jzeyMGsEd78d/wgF5Mp/NIxQDN0IT4LneS6hIDIyrTpw==}
|
||||
peerDependencies:
|
||||
next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@openpanel/sdk@1.3.1':
|
||||
resolution: {integrity: sha512-mQ5xaBUGXnyRg3qYxwnbVaUYyW7w8u+munrWk/ClnqxEeR2j+FYPiA6noHbHMCFC2aZutYD5OoRzEvw3FBNFxw==}
|
||||
|
||||
'@openpanel/web@1.4.1':
|
||||
resolution: {integrity: sha512-DgUWeocZw+b57YiJjefZPg+6sceR7TdOF8x7Ut75lu3CwcwqGi5jZNPXjikNemGL37aNWXCB4buhcoNan2j7RA==}
|
||||
|
||||
'@opentelemetry/api-logs@0.57.2':
|
||||
resolution: {integrity: sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -3856,6 +3872,12 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@rrweb/types@2.0.0-alpha.20':
|
||||
resolution: {integrity: sha512-RbnDgKxA/odwB1R4gF7eUUj+rdSrq6ROQJsnMw7MIsGzlbSYvJeZN8YY4XqU0G6sKJvXI6bSzk7w/G94jNwzhw==}
|
||||
|
||||
'@rrweb/utils@2.1.1':
|
||||
resolution: {integrity: sha512-x2SgJAD3YJ9eVcLZ5l6OqWMtczdA3VfS5XqPeqpZdQgV9oh6Id5GK2cDN4bimnkqryOcIJdz8cTvV0yNvqQ+nA==}
|
||||
|
||||
'@rvf/set-get@7.0.1':
|
||||
resolution: {integrity: sha512-GkTSn9K1GrTYoTUqlUs36k6nJnzjQaFBTTEIqUYmzBcsGsoJM8xG7EAx2WLHWAA4QzFjcwWUSHQ3vM3Fbw50Tg==}
|
||||
|
||||
@@ -4236,6 +4258,9 @@ packages:
|
||||
'@types/connect@3.4.38':
|
||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||
|
||||
'@types/css-font-loading-module@0.0.7':
|
||||
resolution: {integrity: sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==}
|
||||
|
||||
'@types/d3-array@3.2.2':
|
||||
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||
|
||||
@@ -4604,6 +4629,9 @@ packages:
|
||||
engines: {node: '>=10.0.0'}
|
||||
deprecated: this version has critical issues, please update to the latest version
|
||||
|
||||
'@xstate/fsm@1.6.5':
|
||||
resolution: {integrity: sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==}
|
||||
|
||||
'@xterm/addon-attach@0.10.0':
|
||||
resolution: {integrity: sha512-ES/XO8pC1tPHSkh4j7qzM8ajFt++u8KMvfRc9vKIbjHTDOxjl9IUVo+vcQgLn3FTCM3w2czTvBss8nMWlD83Cg==}
|
||||
peerDependencies:
|
||||
@@ -4808,6 +4836,10 @@ packages:
|
||||
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
base64-arraybuffer@1.0.2:
|
||||
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
|
||||
engines: {node: '>= 0.6.0'}
|
||||
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
@@ -7076,6 +7108,9 @@ packages:
|
||||
resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
mitt@3.0.1:
|
||||
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||
|
||||
mkdirp-classic@0.5.3:
|
||||
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
|
||||
|
||||
@@ -8142,6 +8177,15 @@ packages:
|
||||
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
rrdom@2.1.1:
|
||||
resolution: {integrity: sha512-VBkTF3bGNqcZjqnbo/gKi9GVQPIxiG0dofw7CQdAZQFQ2juJdt2YKIf3oS9QudL8HTpGh9bz5TUN+3amBn1Geg==}
|
||||
|
||||
rrweb-snapshot@2.1.1:
|
||||
resolution: {integrity: sha512-al4Kx7Am7YlIn/5Yb2EMr7cdmjGiK+cLhdilJEXqkFXgpnys8t/yMJumXy2Ormgirpg3MBnFha0GTgny+iIL2w==}
|
||||
|
||||
rrweb@2.0.0-alpha.20:
|
||||
resolution: {integrity: sha512-CZKDlm+j1VA50Ko3gnMbpvguCAleljsTNXPnVk9aeNP8o6T6kolRbISHyDZpqZ4G+bdDLlQOignPP3jEsXs8Gg==}
|
||||
|
||||
run-applescript@7.1.0:
|
||||
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -10697,6 +10741,21 @@ snapshots:
|
||||
'@octokit/request-error': 6.1.8
|
||||
'@octokit/webhooks-methods': 5.1.1
|
||||
|
||||
'@openpanel/nextjs@1.5.1(next@16.3.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@openpanel/web': 1.4.1
|
||||
next: 16.3.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
|
||||
'@openpanel/sdk@1.3.1': {}
|
||||
|
||||
'@openpanel/web@1.4.1':
|
||||
dependencies:
|
||||
'@openpanel/sdk': 1.3.1
|
||||
'@rrweb/types': 2.0.0-alpha.20
|
||||
rrweb: 2.0.0-alpha.20
|
||||
|
||||
'@opentelemetry/api-logs@0.57.2':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
@@ -12688,6 +12747,10 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rrweb/types@2.0.0-alpha.20': {}
|
||||
|
||||
'@rrweb/utils@2.1.1': {}
|
||||
|
||||
'@rvf/set-get@7.0.1': {}
|
||||
|
||||
'@scarf/scarf@1.4.0': {}
|
||||
@@ -13367,6 +13430,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 24.10.13
|
||||
|
||||
'@types/css-font-loading-module@0.0.7': {}
|
||||
|
||||
'@types/d3-array@3.2.2': {}
|
||||
|
||||
'@types/d3-color@3.1.3': {}
|
||||
@@ -13711,6 +13776,8 @@ snapshots:
|
||||
|
||||
'@xmldom/xmldom@0.8.11': {}
|
||||
|
||||
'@xstate/fsm@1.6.5': {}
|
||||
|
||||
'@xterm/addon-attach@0.10.0(@xterm/xterm@5.5.0)':
|
||||
dependencies:
|
||||
'@xterm/xterm': 5.5.0
|
||||
@@ -13895,6 +13962,8 @@ snapshots:
|
||||
|
||||
balanced-match@4.0.4: {}
|
||||
|
||||
base64-arraybuffer@1.0.2: {}
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
baseline-browser-mapping@2.10.0: {}
|
||||
@@ -16154,6 +16223,8 @@ snapshots:
|
||||
minipass: 3.3.6
|
||||
yallist: 4.0.0
|
||||
|
||||
mitt@3.0.1: {}
|
||||
|
||||
mkdirp-classic@0.5.3: {}
|
||||
|
||||
mkdirp@1.0.4: {}
|
||||
@@ -17331,6 +17402,25 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
rrdom@2.1.1:
|
||||
dependencies:
|
||||
rrweb-snapshot: 2.1.1
|
||||
|
||||
rrweb-snapshot@2.1.1:
|
||||
dependencies:
|
||||
postcss: 8.5.23
|
||||
|
||||
rrweb@2.0.0-alpha.20:
|
||||
dependencies:
|
||||
'@rrweb/types': 2.0.0-alpha.20
|
||||
'@rrweb/utils': 2.1.1
|
||||
'@types/css-font-loading-module': 0.0.7
|
||||
'@xstate/fsm': 1.6.5
|
||||
base64-arraybuffer: 1.0.2
|
||||
mitt: 3.0.1
|
||||
rrdom: 2.1.1
|
||||
rrweb-snapshot: 2.1.1
|
||||
|
||||
run-applescript@7.1.0: {}
|
||||
|
||||
run-parallel@1.2.0:
|
||||
|
||||
Reference in New Issue
Block a user