Compare commits

...

58 Commits

Author SHA1 Message Date
Mauricio Siu ba9c21955a fix: use a temp file instead of process substitution for POSIX sh compat
The restore script runs under /bin/sh, not bash. Process substitution
("< <(...)") is a bashism dash doesn't support, so the previous fix
failed at runtime with a syntax error and the restore never ran.
Write STOPPED_CONTAINERS to a temp file and read the while loops from
that instead — same effect, works under plain POSIX sh.
2026-08-30 16:42:25 -06:00
Mauricio Siu 1805cb9531 fix: address review findings on volume restore container removal
- Only treat exited/created/dead containers as safely removable;
  paused/restarting/removing containers now still block the restore
  like a running one does.
- Re-check each container's live state right before removing it, to
  avoid deleting one that started running again after the initial
  docker ps -a snapshot.
- Stop swallowing docker rm/docker volume rm failures with '|| true';
  abort the restore instead of silently continuing and extracting
  into a volume that may still be attached to a container.
2026-08-30 16:14:57 -06:00
Mauricio Siu 931fa4a3c4 fix: remove stopped containers referencing a volume before restore
Volume restore aborted with "volume is in use" whenever a stopped
container still referenced the target volume, forcing users to SSH in
and remove it manually. Running containers still block the restore,
but stopped ones are now removed automatically before the volume is
recreated.

Fixes #3995
2026-08-30 15:53:46 -06:00
Mauricio Siu 542eced075 Merge pull request #4889 from automaze-me/fix/security-audit-json-parse
fix(server): security audit JSON parse error on duplicate sshd_config directives
2026-08-28 16:57:58 -06:00
Mauricio Siu 4b2de31b1c Merge pull request #5214 from Dokploy/fix/issue-4658-domain-validation-multi-ip
fix: domain validation false negative on servers with multiple IPs
2026-08-28 16:50:48 -06:00
Mauricio Siu 4b7a6295fc fix: verify server ownership before running IP detection
validateDomain accepted an arbitrary serverId and used it to look up
the server and SSH into it, without checking it belonged to the
caller's active organization. A member with domain:read could probe
other orgs' servers and get their SSH/detected IPs back in the
response.

Addresses greptile review on #5214
2026-08-28 16:49:06 -06:00
Mauricio Siu a4e29172f4 Merge pull request #5215 from Dokploy/fix/traefik-config-test-typecheck
fix: typecheck errors in traefik config test
2026-08-28 16:47:45 -06:00
Mauricio Siu 22450abc4b fix: satisfy HttpRouter type in traefik config test
Test routers were missing the required "service" field, and
mock.calls[0] destructuring failed under noUncheckedIndexedAccess.
2026-08-28 16:47:09 -06:00
Mauricio Siu 976161647f fix: domain validation false negative on servers with multiple IPs
Validate against the server's SSH IP plus its detected public egress
IP, instead of only the stored SSH address.

Fixes #4658
2026-08-28 16:43:23 -06:00
Mauricio Siu bfc180dfef Merge pull request #5048 from Dokploy/fix/openapi-body-size-limits
fix(api): configurable body size limits for OpenAPI catch-all route
2026-08-28 16:35:34 -06:00
Mauricio Siu 628b14d07a Merge pull request #5213 from Dokploy/fix/issue-5133-stack-monitoring-remote-node
fix: report remote-node containers correctly in stack monitoring
2026-08-28 16:19:47 -06:00
Mauricio Siu 0efe7feaf5 fix: report remote-node containers correctly in stack monitoring
Free monitoring resolved containers via a local Dockerode listContainers()
call, which only sees containers scheduled on the host running Dokploy.
For Stack services with tasks on other Swarm nodes, this always fell into
the "Container not running" branch even when the task was healthy
elsewhere, since local docker.listContainers() can't see remote-node
containers.

Now falls back to `docker service ps` (Swarm-aggregated, works from any
manager regardless of task placement) to tell a genuinely stopped
container apart from one running on another node, and reports that
distinction in the WS close reason instead of the misleading message.

Also fixes a client-side race where the stats websocket connected with
an empty appName on first mount (before the container selector settled),
and surfaces the close reason as a toast instead of only logging it.
2026-08-28 16:19:10 -06:00
Mauricio Siu 474081a264 Merge pull request #5212 from Dokploy/fix/issue-5172-terminal-ws-close
fix: close terminal websocket when local container shell exits
2026-08-28 15:51:34 -06:00
Mauricio Siu 1053743a1c fix: close terminal websocket when local container shell exits 2026-08-28 15:50:53 -06:00
Mauricio Siu 355b856185 Merge pull request #5211 from Dokploy/fix/issue-5063-user-remove-double-submit
fix: disable delete/unlink user button while mutation is pending
2026-08-28 15:48:41 -06:00
autofix-ci[bot] a1cc2778f3 [autofix.ci] apply automated fixes 2026-08-28 21:48:23 +00:00
Mauricio Siu fee0071223 fix: disable delete/unlink user button while mutation is pending
Prevents double-submit when clicking Confirm on the Delete/Unlink User
dialog before the request resolves (issue #5063).
2026-08-28 15:47:46 -06:00
Mauricio Siu 3334edef73 Merge pull request #5210 from Dokploy/fix/issue-5134-stale-native-log-container-id
fix: keep native log container id in sync when Swarm/compose replaces it
2026-08-28 15:38:36 -06:00
Mauricio Siu cf0e8d236c fix: keep native log container id in sync when Swarm/compose replaces it
Closes #5134. The container list queries in the Logs tab only fetched
once on mount, so if the underlying container was replaced (restart,
reschedule, redeploy) while the page stayed open, the native log
WebSocket kept streaming from the stale id and either froze or failed
with 'No such container'.

Poll the container list every 5s and reconcile the selected id against
it, matching the pattern already used for automatic selection.
2026-08-28 15:38:01 -06:00
Mauricio Siu 5b6a8bd1ef Merge pull request #5142 from Dokploy/feat/add-terminal-permission
feat(permissions): add server.terminal to decouple server access from root SSH
2026-08-28 11:20:29 -06:00
Mauricio Siu d26f70642d Add journal entry for granting terminal permission to read roles 2026-08-28 11:20:13 -06:00
Mauricio Siu e2d2dbb00a Merge branch 'canary' into feat/add-terminal-permission 2026-08-28 11:19:02 -06:00
Mauricio Siu e4012d857d fix: enhance error handling and directory management in updateFileMount function 2026-08-28 11:18:53 -06:00
Mauricio Siu ab62080e9d Merge pull request #5203 from Dokploy/fix/file-mount-nested-path-update-5152
fix: File Mount update fails silently on nested paths
2026-08-27 10:49:56 -06:00
Mauricio Siu 3a8fad57e6 fix: mkdir parent dir before writing file mount updates
Fixes #5152. updateFileMount wrote directly to the target path without
ensuring its parent directory existed, and silently swallowed errors
instead of surfacing them. If the path already existed as a directory
(e.g. Docker pre-creating a bind mount target), the write silently
failed and left an empty directory instead of the file.
2026-08-27 10:49:15 -06:00
Mauricio Siu 203cddbeef Merge pull request #5202 from Dokploy/fix/traefik-empty-dynamic-config-5189
fix: don't write invalid empty traefik config for apps without domains
2026-08-27 09:37:56 -06:00
Mauricio Siu 032e804114 fix: remove empty routers/services traefik config instead of writing invalid yaml
Attaching Basic Auth or a Redirect to an application with no domains
wrote an empty routers/services block to the app's dynamic config
file. Traefik's file provider rejects that as invalid and aborts its
watcher, blocking config updates for every other application.

Fixes #5189
2026-08-27 09:37:14 -06:00
Mauricio Siu ca3da4dc5d Merge pull request #5201 from Dokploy/fix/compose-git-mounts-5181
fix: resolve relative bind mounts against code dir for git-based compose deploys
2026-08-27 09:15:38 -06:00
Mauricio Siu 87b914996f fix: resolve relative bind mounts against code dir for git-based compose deploys
Compose deployments using a git provider (GitHub/GitLab/Gitea/Bitbucket/
custom Git) clone the repo verbatim, so composePath can point into a
subdirectory (e.g. deploy/docker-compose.yml). Docker Compose resolves
relative bind mounts (../files) against the compose file's own directory,
not against the code/ dir where file mounts are actually written, so the
mount silently binds an empty directory instead of the configured content.

Pin --project-directory to code/ when building the docker compose command
so relative mounts always resolve the same way raw deployments already do.

Fixes #5181
2026-08-27 09:15:06 -06:00
Mauricio Siu 8f3230e2ed Merge pull request #5021 from Souvik-Cyclic/fix/railpack-deploy-sudo-5007
fix: run Railpack install with sudo during remote deploy
2026-08-27 09:10:47 -06:00
Mauricio Siu cebd380856 Merge pull request #5200 from Dokploy/fix/network-chart-labels-5115
fix: correct network monitoring chart labels and units
2026-08-27 01:47:16 -06:00
Mauricio Siu a822db5967 fix: correct network monitoring chart labels and units
Network chart showed values labeled as KB/s, but the underlying data
is MB totals accumulated since boot. Convert to GB and relabel as
Network Total (since Boot).

Fixes #5115
2026-08-27 01:44:49 -06:00
Mauricio Siu 71816d0853 chore: remove obsolete .mcp.json configuration file 2026-08-27 01:39:28 -06:00
Mauricio Siu 85ed75a5c6 Merge pull request #5199 from Dokploy/fix/org-form-unsaved-reset-window-focus-5146
fix: unsaved organization form fields reset on window visibility change
2026-08-27 01:31:42 -06:00
Mauricio Siu f46cd4601c fix: prevent unsaved organization form fields from resetting on window focus 2026-08-27 01:29:20 -06:00
Mauricio Siu 2a585933e9 Merge pull request #5198 from Dokploy/fix/network-sync-docker-id-5179
fix: detect network delete/recreate by Docker ID during sync
2026-08-27 01:17:52 -06:00
autofix-ci[bot] 62e6a4c017 [autofix.ci] apply automated fixes 2026-08-27 07:15:51 +00:00
Mauricio Siu 4710c28db5 docs: add CLAUDE.md code style notes 2026-08-27 01:15:17 -06:00
Mauricio Siu 63efbffcfd fix: detect network delete/recreate by Docker ID during sync
Network sync compared only by name, so a network deleted and
recreated with the same name but different attributes (driver,
attachable, etc.) was reported as already in sync.

Store Docker's network Id (dockerId column) and compare it against
the live Id during sync. A name match with a dockerId mismatch is
now surfaced as a new "changed" state, with an Update action to
refresh the DB row from the live network.

Fixes #5179
2026-08-27 01:15:10 -06:00
Mauricio Siu 523e8f4e4f Merge pull request #5197 from Dokploy/fix/env-file-var-interpolation-5151
fix: preserve ${VAR} interpolation in .env values
2026-08-27 00:54:45 -06:00
Mauricio Siu b67519ebd5 fix: preserve parameter expansion (${VAR:-default}) in .env interpolation 2026-08-27 00:54:02 -06:00
Mauricio Siu 452b7aa2f2 fix: preserve ${VAR} interpolation in .env files
Escaping in prepareEnvironmentVariablesForFile escaped every $,
including inside a deliberate ${VAR} reference, breaking Compose's
documented .env variable interpolation. Only escape $ that isn't
part of a ${IDENTIFIER} sequence.

Fixes #5151
2026-08-27 00:47:01 -06:00
Mauricio Siu 3c6a96a30a Merge pull request #5141 from Dokploy/fix/monitoring-zombie-container
fix(monitoring): remove legacy container and default empty cronJob
2026-08-27 00:03:36 -06:00
Narciso E. Núñez Arias 546686ea35 Merge pull request #5190 from somuai/fix/allow-plus-at-in-read-valid-directory
fix(server): allow '+' and '@' in readValidDirectory path validation
2026-08-26 12:07:29 -04:00
autofix-ci[bot] 217ca07842 [autofix.ci] apply automated fixes 2026-08-26 15:05:57 +00:00
somuai bd8ba128cd fix(server): allow plus, at, and valid path characters in readValidDirectory 2026-08-26 04:54:13 +05:30
Mauricio Siu 72a554de5f chore: add script to spawn agent worktree with environment setup 2026-08-25 01:29:51 -06:00
Mauricio Siu cda047feb2 chore: add install-worktree-deps.sh script for dependency installation 2026-08-25 01:20:10 -06:00
Mauricio Siu feccb51287 Merge pull request #5183 from Dokploy/chore/worktree-automation
chore: automate git worktree setup
2026-08-25 01:03:38 -06:00
Narciso 875baa139c mend 2026-08-24 20:59:30 -04:00
Narciso e2ab2eb6bc remove unused code 2026-08-24 20:52:35 -04:00
Narciso bbde5ebbc3 feat(permissions): add server.terminal to decouple server access from root SSH
Reaching a server implied being able to open an SSH root shell on it: the
/terminal websocket only checked that the server was in the caller's accessible
set, so granting a server to a developer necessarily granted root on it.

Add a server.terminal action, assignable on custom roles, and require it on the
remote-server terminal websocket on top of server access. Owner/admin keep it
through the enterprise bypass; the local host terminal stays owner/admin only.

A migration grants server.terminal to existing custom roles that already have
server.read, which is the permission that surfaces the terminal today, so
current setups keep working. Roles without a server entry are left alone.

Note: server.create still implies root execution (server.update persists
server.command and server.setup runs it over SSH), reflected in the Create
description in the role editor.
2026-08-21 11:08:31 -04:00
Narciso bee6918d9a fix(monitoring): remove legacy container and default empty cronJob
The swarm migration in 3848fa9c0 dropped the container.remove({force:true})
that the standalone deploy path used to run. Swarm tasks are named
dokploy-monitoring.<slot>.<id>, so there is no name collision and the
pre-v0.30.0 container survives every redeploy. It also stays pinned to an
orphaned image ID once pullRemoteImage moves the latest tag, so neither a
pull nor a Save clears it and it restarts forever.

Cloud setup spread metricsConfig straight from the row, shipping
cronJob: "" to the agent. robfig/cron rejects an empty spec, so the Go
binary exits before Fiber binds 4500 and Docker restarts it every ~60s.

- remove the legacy container in deployMonitoringService, which covers both
  setupMonitoring and setupWebMonitoring. Cleanup is best effort: a failure
  is logged and the deploy continues, matching the pre-migration behaviour
- default cronJob when configuring monitoring for cloud
- on build servers, clean up the legacy container but deploy no service.
  They never join the swarm, yet cloud setup did create the standalone
  container there before v0.30.0, and the monitoring form has always been
  hidden for them, so those agents are all stuck with an empty cronJob
- cover the above with real-docker tests
2026-08-20 11:17:41 -04:00
Narciso 529f54e2a5 fix(api): reject non-integer body size env vars 2026-08-11 15:06:02 -04:00
Narciso 8144e1a2fe fix(api): validate OpenAPI body size limits against Greptile findings 2026-08-11 14:57:42 -04:00
Narciso d11735d4cd fix(api): configurable body size limits for OpenAPI catch-all route 2026-08-11 14:24:35 -04:00
Souvik Kumar 679dbf98cb fix: run Railpack install with sudo during remote deploy
Deploy-time Railpack install ran without sudo, so deploys to a remote
server with a non-root user (passwordless sudo) failed because the
install script could not write to /usr/local/bin ("A terminal is
required to authenticate").

Detect whether sudo is needed (root vs. passwordless-sudo user) and run
the install through it, matching the server-setup script.

Fixes #5007
2026-08-09 20:01:06 +05:30
Jörn Bungartz 09ea766fb5 fix(server): sanitize sshd config values in security audit JSON output
Duplicate directives in sshd_config (e.g. two active PermitRootLogin
lines from Match blocks or provisioning scripts) made the grep|awk
captures multi-line. The embedded newline ended up inside a JSON string
literal, so JSON.parse failed with "Bad control character in string
literal" and the Security tab showed a red banner plus default values.

Take the first match (mirroring sshd's first-match-wins semantics) and
strip carriage returns from CRLF-edited configs.
2026-07-21 18:35:16 -06:00
56 changed files with 19600 additions and 135 deletions
+5 -2
View File
@@ -1,13 +1,16 @@
{
"worktree": {
"baseRef": "fresh",
"symlinkDirectories": ["node_modules"]
"baseRef": "fresh"
},
"hooks": {
"PostToolUse": [
{
"matcher": "EnterWorktree",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/scripts/install-worktree-deps.sh\""
},
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/scripts/assign-worktree-port.sh\""
+17 -1
View File
@@ -4,7 +4,23 @@ description: Implement a GitHub issue with reproduction and verification
allowed-tools: Bash, Edit, Write, Read, Glob, Grep, mcp__playwright__*, mcp__dokploy__*
---
The issue number is passed as $1. An isolated instance is running at $DOKPLOY_BASE_URL.
The issue number is passed as $1.
## Instance
No instance is running yet — start your own, isolated to this worktree:
1. Check `apps/dokploy/.env` for `PORT` (assigned per-worktree already).
2. If nothing is listening on that port, start it: `pnpm dokploy:dev` in the
background, then poll `curl -s -o /dev/null -w '%{http_code}' http://localhost:$PORT`
until it answers (usually ~10-15s).
3. Use `http://localhost:$PORT` as the base URL for Playwright navigation.
Note: `mcp__dokploy__*` (this repo's `.mcp.json`) resolves its URL from
`$DOKPLOY_BASE_URL` once, at session startup — it cannot pick up a port
discovered mid-session. If those tools are unavailable or point at the wrong
instance, fall back to `curl`/`gh api` for API-level checks, or ask the user
to relaunch with `DOKPLOY_BASE_URL` exported first.
## Tools
-13
View File
@@ -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"]
}
}
}
+6
View File
@@ -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", () => {
@@ -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({
@@ -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,236 @@
import { execSync } from "node:child_process";
import { docker } from "@dokploy/server/constants";
import { setupMonitoring } from "@dokploy/server/setup/monitoring-setup";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
const REAL_TEST_TIMEOUT = 120000;
const SERVICE_NAME = "dokploy-monitoring";
const TEST_IMAGE = "busybox:latest";
// Mock ONLY db-backed lookups and remote I/O. Dockerode stays real and talks to
// the local daemon, so the legacy-container cleanup is exercised for real.
vi.mock("@dokploy/server/services/server", () => ({
findServerById: vi.fn().mockResolvedValue({
serverId: "test-server",
serverType: "deploy",
sshKeyId: null, // -> getRemoteDocker returns the local docker instance
metricsConfig: {
server: {
type: "Remote",
port: 4500,
token: "test-token",
urlCallback: "http://localhost/callback",
cronJob: "0 0 * * *",
retentionDays: 2,
refreshRate: 60,
thresholds: { cpu: 0, memory: 0 },
},
containers: { refreshRate: 60, services: { include: [], exclude: [] } },
},
}),
}));
vi.mock("@dokploy/server/services/settings", () => ({
getDokployImageTag: vi.fn(() => "latest"),
}));
vi.mock("@dokploy/server/utils/docker/utils", () => ({
pullImage: vi.fn().mockResolvedValue(undefined),
pullRemoteImage: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
execAsync: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }),
execAsyncRemote: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }),
}));
const containerExists = async (name: string) => {
try {
await docker.getContainer(name).inspect();
return true;
} catch (error: any) {
if (error.statusCode === 404) return false;
throw error;
}
};
const serviceExists = async (name: string) => {
try {
await docker.getService(name).inspect();
return true;
} catch (error: any) {
if (error.statusCode === 404) return false;
throw error;
}
};
const swarmTaskNames = async () => {
const list = await docker.listContainers({ all: true });
return list
.flatMap((c) => c.Names.map((n) => n.replace(/^\//, "")))
.filter((n) => n.startsWith(`${SERVICE_NAME}.`));
};
const cleanup = async () => {
try {
await docker.getService(SERVICE_NAME).remove();
} catch {}
try {
await docker.getContainer(SERVICE_NAME).remove({ force: true });
} catch {}
for (const name of await swarmTaskNames()) {
try {
await docker.getContainer(name).remove({ force: true });
} catch {}
}
};
// Recreates the pre-v0.30.0 standalone agent stuck in a crash loop.
const createLegacyZombie = async () => {
const container = await docker.createContainer({
name: SERVICE_NAME,
Image: TEST_IMAGE,
Cmd: [
"sh",
"-c",
"echo 'Error starting metrics cleanup system: empty spec string'; exit 1",
],
HostConfig: { RestartPolicy: { Name: "always" }, NetworkMode: "host" },
});
await container.start();
};
const pullTestImage = async () => {
try {
await docker.getImage(TEST_IMAGE).inspect();
return;
} catch {}
await new Promise<void>((resolve, reject) =>
docker.pull(TEST_IMAGE, (err: any, stream: any) =>
err
? reject(err)
: docker.modem.followProgress(stream, (e: any) =>
e ? reject(e) : resolve(),
),
),
);
};
// The code under test hardcodes the production name, so this suite cannot
// namespace its fixtures. Skip rather than wipe a real agent on a Dokploy host.
const hasRealMonitoring = () => {
const query = (cmd: string) => {
try {
return execSync(cmd, { stdio: ["ignore", "pipe", "ignore"] })
.toString()
.trim();
} catch {
return "";
}
};
return (
query(
`docker service ls --filter name=${SERVICE_NAME} --format '{{.Name}}'`,
) !== "" ||
query(
`docker ps -a --filter name=^${SERVICE_NAME}$ --format '{{.Names}}'`,
) !== ""
);
};
describe.skipIf(hasRealMonitoring())(
"setupMonitoring - legacy container cleanup (real docker)",
() => {
beforeEach(async () => {
await pullTestImage();
await cleanup();
}, REAL_TEST_TIMEOUT);
afterAll(async () => {
await cleanup();
}, REAL_TEST_TIMEOUT);
it(
"removes the legacy standalone container left behind by the swarm migration",
async () => {
await createLegacyZombie();
expect(await containerExists(SERVICE_NAME)).toBe(true);
await setupMonitoring("test-server");
expect(await containerExists(SERVICE_NAME)).toBe(false);
expect(await serviceExists(SERVICE_NAME)).toBe(true);
},
REAL_TEST_TIMEOUT,
);
it(
"removes the legacy container without touching swarm tasks, which are named dokploy-monitoring.<slot>.<id>",
async () => {
const taskName = `${SERVICE_NAME}.1.qh3ldvg2h9x0test`;
const task = await docker.createContainer({
name: taskName,
Image: TEST_IMAGE,
Cmd: ["sleep", "3600"],
});
await task.start();
await createLegacyZombie();
await setupMonitoring("test-server");
expect(await containerExists(SERVICE_NAME)).toBe(false);
expect(await containerExists(taskName)).toBe(true);
const inspect = await docker.getContainer(taskName).inspect();
expect(inspect.State.Running).toBe(true);
},
REAL_TEST_TIMEOUT,
);
it(
"is idempotent when no legacy container exists",
async () => {
expect(await containerExists(SERVICE_NAME)).toBe(false);
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
expect(await serviceExists(SERVICE_NAME)).toBe(true);
},
REAL_TEST_TIMEOUT,
);
it(
"deploys the service even when removing the legacy container fails",
async () => {
const failingDocker = {
getContainer: () => ({
remove: async () => {
const error: any = new Error("device or resource busy");
error.statusCode = 500;
throw error;
},
}),
getService: docker.getService.bind(docker),
createService: docker.createService.bind(docker),
};
const remoteDocker = await import(
"@dokploy/server/utils/servers/remote-docker"
);
const spy = vi
.spyOn(remoteDocker, "getRemoteDocker")
.mockResolvedValue(failingDocker as any);
try {
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
expect(spy).toHaveBeenCalled(); // guards against the spy silently not intercepting
expect(await serviceExists(SERVICE_NAME)).toBe(true);
} finally {
spy.mockRestore();
}
},
REAL_TEST_TIMEOUT,
);
},
);
@@ -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 /);
});
});
+26 -1
View File
@@ -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();
});
});
@@ -94,4 +94,32 @@ describe("readValidDirectory (path traversal)", () => {
),
).toBe(true);
});
it("returns true for SvelteKit routes with + prefix and @ symbols", () => {
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/src/routes/+page.svelte`,
),
).toBe(true);
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/src/routes/+layout.svelte`,
),
).toBe(true);
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/src/routes/+server.ts`,
),
).toBe(true);
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/src/routes/+error.svelte`,
),
).toBe(true);
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/node_modules/@types/node/index.d.ts`,
),
).toBe(true);
});
});
@@ -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 (
@@ -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"],
@@ -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
+14
View File
@@ -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
}
]
}
+32 -1
View File
@@ -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,
},
};
+5 -1
View File
@@ -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
+15 -3
View File
@@ -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 }) => {
+7 -2
View File
@@ -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();
});
+55 -1
View File
@@ -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(
+22
View File
@@ -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;
+1
View File
@@ -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),
+3 -3
View File
@@ -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"],
+52 -6
View File
@@ -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);
};
+13 -3
View File
@@ -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,
});
}
};
+80 -8
View File
@@ -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",
@@ -21,11 +21,31 @@ const getMonitoringImage = () => {
return imageName;
};
// Swarm tasks are dokploy-monitoring.<slot>.<id>, so this only matches the
// pre-v0.30.0 standalone container. A cleanup failure must not block the deploy.
const removeLegacyContainer = async (
docker: Awaited<ReturnType<typeof getRemoteDocker>>,
serviceName: string,
) => {
try {
await docker.getContainer(serviceName).remove({ force: true });
console.log("Removed legacy monitoring container ✅");
} catch (error: any) {
if (error?.statusCode !== 404) {
console.warn(
`Could not remove legacy monitoring container: ${error?.message ?? error}`,
);
}
}
};
const deployMonitoringService = async (
docker: Awaited<ReturnType<typeof getRemoteDocker>>,
serviceName: string,
settings: CreateServiceOptions,
) => {
await removeLegacyContainer(docker, serviceName);
try {
const service = docker.getService(serviceName);
const inspect = await service.inspect();
+5 -5
View File
@@ -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"
@@ -86,6 +86,7 @@ export const serverSetup = async (
...server.metricsConfig.server,
token: token,
urlCallback: urlCallback,
cronJob: server.metricsConfig.server.cronJob || "0 0 * * *",
},
containers: server.metricsConfig.containers,
},
@@ -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..." ;
+1 -1
View File
@@ -548,7 +548,7 @@ export const prepareEnvironmentVariablesForFile = (
const escapedValue = value
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\$/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 = (
@@ -66,38 +66,98 @@ export const restoreVolume = async (
docker volume rm ${volumeName} --force
${baseRestoreCommand}
else
echo ""
echo "⚠️ WARNING: Cannot restore volume as it is currently in use!"
echo ""
echo "📋 The following containers are using volume '${volumeName}':"
echo ""
echo "$CONTAINERS_USING_VOLUME" | while IFS='|' read container_id container_name container_state labels; do
echo " 🐳 Container: $container_name ($container_id)"
echo " Status: $container_state"
# Determine container type
if echo "$labels" | grep -q "com.docker.swarm.service.name="; then
SERVICE_NAME=$(echo "$labels" | grep -o "com.docker.swarm.service.name=[^,]*" | cut -d'=' -f2)
echo " Type: Docker Swarm Service ($SERVICE_NAME)"
elif echo "$labels" | grep -q "com.docker.compose.project="; then
PROJECT_NAME=$(echo "$labels" | grep -o "com.docker.compose.project=[^,]*" | cut -d'=' -f2)
echo " Type: Docker Compose ($PROJECT_NAME)"
else
echo " Type: Regular Container"
fi
# Only "exited", "created" and "dead" are safe to remove. Anything else
# (running, restarting, paused, removing) is treated as still in use.
RUNNING_CONTAINERS=$(echo "$CONTAINERS_USING_VOLUME" | awk -F'|' '$3 != "exited" && $3 != "created" && $3 != "dead"')
STOPPED_CONTAINERS=$(echo "$CONTAINERS_USING_VOLUME" | awk -F'|' '$3 == "exited" || $3 == "created" || $3 == "dead"')
if [ -n "$RUNNING_CONTAINERS" ]; then
echo ""
done
echo "⚠️ WARNING: Cannot restore volume as it is currently in use!"
echo ""
echo "📋 The following containers are using volume '${volumeName}':"
echo ""
echo "$RUNNING_CONTAINERS" | while IFS='|' read container_id container_name container_state labels; do
echo " 🐳 Container: $container_name ($container_id)"
echo " Status: $container_state"
# Determine container type
if echo "$labels" | grep -q "com.docker.swarm.service.name="; then
SERVICE_NAME=$(echo "$labels" | grep -o "com.docker.swarm.service.name=[^,]*" | cut -d'=' -f2)
echo " Type: Docker Swarm Service ($SERVICE_NAME)"
elif echo "$labels" | grep -q "com.docker.compose.project="; then
PROJECT_NAME=$(echo "$labels" | grep -o "com.docker.compose.project=[^,]*" | cut -d'=' -f2)
echo " Type: Docker Compose ($PROJECT_NAME)"
else
echo " Type: Regular Container"
fi
echo ""
done
echo ""
echo "🔧 To restore this volume, please:"
echo " 1. Stop all containers/services using this volume"
echo " 2. Remove the existing volume: docker volume rm ${volumeName}"
echo " 3. Run the restore operation again"
echo ""
echo "❌ Volume restore aborted - volume is in use"
exit 1
fi
# Use a temp file instead of process substitution ("< <(...)") so this
# still works under a plain POSIX /bin/sh, not just bash.
STOPPED_CONTAINERS_FILE=$(mktemp)
printf '%s\\n' "$STOPPED_CONTAINERS" > "$STOPPED_CONTAINERS_FILE"
# Re-check each container's live state right before removing it: it may have
# started running again since the "docker ps -a" snapshot above.
ACTIVE_AGAIN=""
while IFS='|' read -r container_id container_name container_state labels; do
CURRENT_STATE=$(docker inspect -f '{{.State.Status}}' "$container_id" 2>/dev/null || echo "gone")
case "$CURRENT_STATE" in
running|restarting|paused|removing)
ACTIVE_AGAIN="$ACTIVE_AGAIN $container_name"
;;
esac
done < "$STOPPED_CONTAINERS_FILE"
if [ -n "$ACTIVE_AGAIN" ]; then
rm -f "$STOPPED_CONTAINERS_FILE"
echo ""
echo "⚠️ WARNING: Container(s)$ACTIVE_AGAIN became active again, aborting restore"
echo "❌ Volume restore aborted - volume is in use"
exit 1
fi
echo "Volume exists but is only referenced by stopped containers, removing them..."
echo ""
echo "🔧 To restore this volume, please:"
echo " 1. Stop all containers/services using this volume"
echo " 2. Remove the existing volume: docker volume rm ${volumeName}"
echo " 3. Run the restore operation again"
REMOVE_FAILED=""
while IFS='|' read -r container_id container_name container_state labels; do
echo " 🗑 Removing stopped container: $container_name ($container_id) [status: $container_state]"
if ! docker rm -f "$container_id" >/dev/null 2>&1; then
REMOVE_FAILED="$REMOVE_FAILED $container_name"
fi
done < "$STOPPED_CONTAINERS_FILE"
rm -f "$STOPPED_CONTAINERS_FILE"
if [ -n "$REMOVE_FAILED" ]; then
echo ""
echo "❌ Failed to remove container(s):$REMOVE_FAILED"
echo "❌ Volume restore aborted - could not free the volume"
exit 1
fi
echo ""
echo "❌ Volume restore aborted - volume is in use"
exit 1
echo "Removing existing volume and proceeding with restore"
if ! docker volume rm ${volumeName} --force; then
echo "❌ Volume restore aborted - failed to remove existing volume"
exit 1
fi
${baseRestoreCommand}
fi
fi
`;
+1 -1
View File
@@ -40,7 +40,7 @@ export const readValidDirectory = (
directory: string,
serverId?: string | null,
) => {
if (!/^[\w/. :[\]-]{1,500}$/.test(directory)) {
if (!/^[\w/. :[\]+@~(),=%-]{1,500}$/.test(directory)) {
return false;
}
+4 -1
View File
@@ -18,6 +18,9 @@ async function findFreePort(start) {
return port;
}
const start = Number.parseInt(process.argv[2] || process.env.PORT || "3000", 10);
const start = Number.parseInt(
process.argv[2] || process.env.PORT || "3000",
10,
);
const port = await findFreePort(start);
process.stdout.write(String(port));
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
PAYLOAD=$(cat)
WORKTREE_PATH=$(echo "$PAYLOAD" | jq -r '.tool_response.worktreePath // empty')
if [ -z "$WORKTREE_PATH" ]; then
exit 0
fi
# Turbopack refuses to resolve through anything outside its detected
# workspace root, so a symlinked node_modules (whole dir or per-entry)
# doesn't work for apps/dokploy. A real `pnpm install` is required, but
# since pnpm's global content-addressable store is already warm, this
# only links locally — no network fetch, a few seconds.
cd "$WORKTREE_PATH"
pnpm install --prefer-offline
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
set -euo pipefail
NAME="${1:?usage: spawn-agent-worktree.sh <name>}"
# --show-toplevel would return the CURRENT worktree's own root if this is
# run from inside one (e.g. another agent's worktree) instead of the main
# checkout. --git-common-dir always points at the shared .git regardless of
# which worktree you're standing in.
REPO_ROOT="$(dirname "$(git rev-parse --path-format=absolute --git-common-dir)")"
WORKTREE_PATH="$REPO_ROOT/.claude/worktrees/$NAME"
BRANCH="worktree-$NAME"
if [ -e "$WORKTREE_PATH" ]; then
echo "Worktree already exists: $WORKTREE_PATH" >&2
exit 1
fi
git -C "$REPO_ROOT" fetch origin canary --quiet
git -C "$REPO_ROOT" worktree add -b "$BRANCH" "$WORKTREE_PATH" origin/canary >&2
# .worktreeinclude lists gitignored files (.env, .env.local) that a plain
# `git worktree add` won't check out on its own - copy them in.
while IFS= read -r pattern; do
[ -z "$pattern" ] && continue
find "$REPO_ROOT" \( -path "$REPO_ROOT/.claude/worktrees" -o -path "$REPO_ROOT/node_modules" \) -prune -o -name "$pattern" -print 2>/dev/null
done < "$REPO_ROOT/.worktreeinclude" | while read -r src; do
rel="${src#"$REPO_ROOT"/}"
dest="$WORKTREE_PATH/$rel"
mkdir -p "$(dirname "$dest")"
cp "$src" "$dest"
done
cd "$WORKTREE_PATH"
pnpm install --prefer-offline >&2
FREE_PORT=$(node "$REPO_ROOT/scripts/find-free-port.mjs")
sed -i.bak "s/^PORT=.*/PORT=$FREE_PORT/" apps/dokploy/.env
sed -i.bak -E "s#^(BETTER_AUTH_URL=https?://[^:/]+):[0-9]+#\1:$FREE_PORT#" apps/dokploy/.env
rm -f apps/dokploy/.env.bak
pnpm --filter=dokploy run dev > "$WORKTREE_PATH/dev-server.log" 2>&1 &
echo $! > "$WORKTREE_PATH/dev-server.pid"
BASE_URL="http://localhost:$FREE_PORT"
for _ in $(seq 1 30); do
CODE=$(curl -s -o /dev/null -w '%{http_code}' --max-time 1 "$BASE_URL/" || true)
if [ "$CODE" != "000" ]; then
break
fi
sleep 1
done
echo "export WORKTREE_PATH=$WORKTREE_PATH"
echo "export DOKPLOY_BASE_URL=$BASE_URL"
echo "export PORT=$FREE_PORT"