Compare commits

..

65 Commits

Author SHA1 Message Date
Mauricio Siu 18fb2547e4 fix: block bind mounts to sensitive host paths for non-owner/admin members
The Mounts feature (application/compose/database services) let any
member with volume:create access set an arbitrary bind mount hostPath,
with zero validation. Since generateBindMounts wires that path straight
into the Swarm service spec, a non-privileged member could mount
/var/run/docker.sock, /etc/dokploy, or other host-critical paths and
escalate to root on the Dokploy control host - same impact as bringing
your own docker-compose.yml with a docker.sock mount, but reachable
through the plain Mounts UI on any service type.

Add isDangerousBindMountPath() with a fixed blocklist of host-critical
paths (docker.sock, /etc/dokploy, /root, /boot, /proc, /sys, docker's
data dir) and isPrivilegedOrgRole(); mount.create/update now reject a
bind mount targeting one of those paths unless the caller is the org's
owner or admin. Single-user self-hosted installs are unaffected - the
sole user is always the owner.
2026-09-01 03:49:12 -06:00
Mauricio Siu c225188ed4 Merge pull request #5253 from Dokploy/fix/git-provider-secret-leak
fix: stop leaking git provider secrets to non-owner org members
2026-09-01 03:25:40 -06:00
Mauricio Siu 6239073e2a fix: strip git provider secrets from compose.one response
findComposeById embedded the full github/gitlab/bitbucket/gitea
relations (client secrets, OAuth tokens, private keys, app passwords)
and compose.one only used canEditDeployGitSource to set a
hasGitProviderAccess flag, never to hide the fields — so any member
with read access to a compose service got the connected git
provider's raw credentials, regardless of their access to that
provider itself.

Exclude the same secret columns findApplicationById already excludes.
Deploys are unaffected: the actual clone step always re-fetches the
provider fresh by id (findGithubById/findGitlabById/...), it never
reads secrets off the embedded relation.
2026-09-01 03:24:43 -06:00
Mauricio Siu a42614004f fix: don't leak git provider secrets to non-owner org members
gitlab.one, github.one, gitea.one and bitbucket.one returned the full
DB record (OAuth access/refresh tokens, client secrets, private keys,
webhook secrets, app passwords) to any org member who merely had
access to *use* a shared provider (sharedWithOrganization: true),
not just its owner or an org owner/admin.

Add canViewGitProviderSecrets() and null out the secret fields in
each .one response when the caller isn't the provider owner or an
org owner/admin.
2026-09-01 03:22:52 -06:00
Mauricio Siu e1a432d5c8 Merge pull request #5251 from Dokploy/feat/delete-server-services
feat: list associated services in delete server modal
2026-09-01 03:12:42 -06:00
Mauricio Siu 02760212f7 feat: list associated services in delete server modal
Show the services attached to a remote server directly in its delete
confirmation modal, with a link to each service and a per-service
delete action, instead of only showing a generic 'has active
services' blocker.
2026-09-01 03:11:12 -06:00
Mauricio Siu 0478f34be5 Merge pull request #5250 from Dokploy/feat/vault-bulk-import
feat: bulk import secrets from vault providers
2026-09-01 03:07:12 -06:00
Mauricio Siu 8b5c14ca74 test: fix stale traefik remote-write test expectation
writeTraefikConfigRemote moved from an execAsyncRemote echo command to
writeFileRemote (SFTP) a while back; the test still mocked the old
execAsyncRemote path and asserted on an echo command that no longer runs.
2026-09-01 03:05:38 -06:00
Mauricio Siu 50acb791cd feat: bulk import secrets from vault providers
Adds a modal to select and import multiple secrets from an assigned
vault provider at once, instead of typing each ${{vault.x.y}} reference
by hand. Existing keys are skipped by default and can be overridden per
row; also adds an "Access all" shortcut to the vault provider assignment
picker.
2026-09-01 03:00:09 -06:00
Mauricio Siu 3a6e18be79 Merge pull request #5228 from Dokploy/fix/hubspot-chat-multi-subscription-plan
fix: detect billing plan across all active Stripe subscriptions
2026-09-01 02:48:58 -06:00
Mauricio Siu 23b15801d5 bump version to v0.30.4 in package.json 2026-09-01 02:44:09 -06:00
Mauricio Siu 78fe5e3a33 Merge pull request #5150 from logical-tech/feat/dns-records-management
feat(dns): rework provider management and support all record types
2026-09-01 02:42:42 -06:00
Mauricio Siu 8cd2ba80f2 Merge branch 'canary' into feat/dns-records-management 2026-09-01 02:41:18 -06:00
Mauricio Siu 8639605b03 Merge pull request #5165 from outeiroDev/feat/porkbun-dns-provider
feat: add Porkbun DNS provider support
2026-09-01 02:40:33 -06:00
Mauricio Siu 1443500b9d Add new journal entry for wooden_nextwave version 7 2026-09-01 02:37:50 -06:00
Mauricio Siu 9c444ebca3 Merge branch 'canary' into feat/porkbun-dns-provider 2026-09-01 02:37:36 -06:00
Mauricio Siu e7eb20d92f Remove unused journal entry for porkbun DNS provider from _journal.json 2026-09-01 02:37:31 -06:00
Mauricio Siu f1a4f4317a refactor: remove DnsPageTransition component and update loading indicators in DNS-related components 2026-09-01 02:36:41 -06:00
Mauricio Siu 1ab4a8a70a Merge branch 'canary' into feat/dns-records-management 2026-09-01 02:28:22 -06:00
Mauricio Siu 53a6e926d6 Merge pull request #5123 from barrynorman/feat/vault-phase-provider
feat(vault): add Phase.dev secrets provider
2026-09-01 02:23:47 -06:00
autofix-ci[bot] 43178b6442 [autofix.ci] apply automated fixes 2026-09-01 08:23:38 +00:00
Mauricio Siu a194e1e27b Add entry for volatile piledriver in journal with version 7 2026-09-01 01:58:57 -06:00
Mauricio Siu 50479f20ff Merge branch 'canary' into feat/vault-phase-provider 2026-09-01 01:58:35 -06:00
Mauricio Siu 4453c55086 Remove unused journal entry for phase vault provider from _journal.json 2026-09-01 01:58:29 -06:00
Mauricio Siu 3c95912001 Merge pull request #5247 from Dokploy/fix/db-deploy-swarm-convergence-check
fix: verify swarm task convergence before marking db deploys done
2026-09-01 01:57:33 -06:00
Mauricio Siu 5624dff5c1 fix: verify swarm task convergence before marking db deploys done 2026-09-01 01:56:58 -06:00
Mauricio Siu d7ea9cf9bc Merge pull request #5246 from Dokploy/fix/traefik-remote-config-sftp-write
fix: write remote traefik config via SFTP instead of exec+base64
2026-09-01 01:54:21 -06:00
Mauricio Siu 68a05551d0 fix: write remote traefik config via SFTP instead of exec+base64
Embedding the full YAML (base64-encoded) into a single SSH exec command
silently fails once the payload passes the SSH transport's packet size
ceiling (~90-100KB), truncating the dynamic config with no error
surfaced anywhere. Switch to SFTP, which has no such limit.
2026-09-01 01:53:45 -06:00
autofix-ci[bot] 57c221de6f [autofix.ci] apply automated fixes 2026-09-01 07:49:01 +00:00
Mauricio Siu 48e0b747b6 Merge pull request #5245 from Dokploy/fix/schedule-cloud-update-orphan-job
fix: orphaned BullMQ job when updating an enabled schedule in cloud
2026-09-01 01:38:12 -06:00
Mauricio Siu 4e38925119 fix: pass timezone when removing repeatable schedule job
removeJob() for type 'schedule' computed the BullMQ repeatable key
using only the cron pattern, without the timezone. scheduleJob()
always registers the repeatable job with tz set (job.timezone || UTC),
so BullMQ's repeat key (name:jobId:endDate:tz:pattern) never matched
on removal — removeRepeatable silently no-op'd and the job kept
firing forever, surviving disable, delete, and reboots.

Forward timezone through removeJob (queue.ts), the /update-backup
lookup (index.ts), and the disable/delete call sites in the
schedule router.
2026-09-01 01:31:59 -06:00
Mauricio Siu 91bcf88df9 fix: use updateJob to replace repeatable schedule job on update in cloud
Fixes #5233. Updating an enabled schedule (cron/timezone change) called
schedule() (create-backup), which only adds a new repeatable job and
never removes the one registered under the old cron pattern, leaving
an orphaned BullMQ job running in Redis indefinitely. Switched to
updateJob() (update-backup), which looks up the currently registered
repeatable pattern, removes it, then re-adds — same pattern already
used in backup.ts for backups.
2026-09-01 01:26:15 -06:00
Mauricio Siu a63157cd51 Merge pull request #5244 from Dokploy/fix/notification-server-threshold-teams
fix: persist serverThreshold for gotify/ntfy and guard teams dispatch
2026-09-01 01:13:22 -06:00
Mauricio Siu 47308070a8 fix: persist serverThreshold for gotify/ntfy and guard teams dispatch 2026-09-01 01:11:31 -06:00
Mauricio Siu a1c57011a4 Merge pull request #5243 from Dokploy/fix/compose-project-directory-build-context
fix: only pin --project-directory when compose has mounts
2026-09-01 00:50:42 -06:00
Mauricio Siu ec8e645758 fix: only pin --project-directory when compose has mounts
--project-directory was added unconditionally in 87b914996 to fix
relative bind mount resolution for git-based compose deploys with a
nested composePath (#5181). It also moves where build.context and the
generated .env resolve, breaking any compose file in a subdirectory
that has context: . alongside its Dockerfile (#5230), or interpolates
env vars (#5242).

Only pin --project-directory when the compose actually has mounts
configured; otherwise build.context and .env resolve against the
compose file's own directory like plain docker compose. Also pass
--env-file explicitly pointing at the generated .env next to the
compose file, so it's found even when --project-directory is pinned
(credit: tonnenpinguin, PR #5235).

Fixes #5230
Fixes #5242
2026-09-01 00:50:05 -06:00
Narciso E. Núñez Arias e8f4a1715c Merge pull request #5014 from Shivam8584/fix/ui-polish-bugs
fix: honour metaName for page titles and show a loading state in requests
2026-08-31 16:48:32 -04:00
Narciso 880bb55272 fix failing test 2026-08-31 16:42:26 -04:00
Narciso a36739eb07 Merge remote-tracking branch 'origin/canary' into fix/ui-polish-bugs 2026-08-31 16:20:48 -04:00
Dokploy Bot 1ff9294009 chore: sync hotfix from main into canary [skip ci] 2026-08-31 19:37:35 +00:00
Narciso E. Núñez Arias 142082fe89 Merge pull request #5237 from shuv-o/feat/reset-password-by-email
feat: allow reset-password script to reset a specific user by email
(cherry picked from commit 5345c00ff3)

[skip ci]
2026-08-31 19:37:33 +00:00
Narciso E. Núñez Arias 5345c00ff3 Merge pull request #5237 from shuv-o/feat/reset-password-by-email
feat: allow reset-password script to reset a specific user by email
2026-08-31 15:37:20 -04:00
Narciso E. Núñez Arias d181384833 Merge pull request #5238 from rakeshbhugra/fix/deployment-commit-hash-overflow-mobile
fix: prevent deployment commit hash overflow on mobile
2026-08-31 14:40:07 -04:00
autofix-ci[bot] 1f5f5576f1 [autofix.ci] apply automated fixes 2026-08-31 18:33:29 +00:00
Rakesh Bhugra d6ec4bd3d1 fix: prevent deployment commit hash overflow on mobile 2026-08-31 23:49:36 +05:30
Shivam Gupta 7e9b77e93f fix: surface the error state instead of "No results." when the log query fails
The loading branch added in the previous commit split the fallback cell
two ways: spinner while in flight, "No results." otherwise. That second
branch also catches the failure case -- when readStatsLogs errors,
statsLogs stays undefined and isLoading goes false, so a failed request
renders as a successful empty response.

The query's isError/error were not destructured. The cell now branches
three ways and reports the error through AlertBlock, matching how
ShowTraefikSystem surfaces a failed readDirectories query.
2026-08-31 22:58:36 +05:30
Shuvo 8db39a8d15 fix: normalize email argument before lookup in reset-password
Trims and lowercases the CLI email arg to match the normalization
used elsewhere for user emails, so a differently-cased or
whitespace-padded email no longer falsely reports "User not found".
2026-08-31 23:06:04 +06:00
autofix-ci[bot] 714bf03244 [autofix.ci] apply automated fixes 2026-08-31 23:06:04 +06:00
Shuvo dada3049a8 feat: allow reset-password script to target a specific user by email
Previously reset-password only reset the owner account's password.
Passing an email as an argument now resets that specific user's
password instead, while omitting it keeps the existing owner-reset
behavior.

Also scopes the update to the credential (password-based) account
row via providerId, and fixes the success check to verify a row was
actually updated instead of always reporting success.
2026-08-31 23:06:04 +06:00
Shuvo 0971e1952a Merge branch 'Dokploy:canary' into canary 2026-08-31 22:22:09 +06:00
Narciso E. Núñez Arias e0a9b83a39 Merge pull request #4180 from TuroYT/fix/responsive-application-logs 2026-08-31 10:41:09 -04:00
Romain PINSOLLE ccfc44815e Merge branch 'canary' into fix/responsive-application-logs 2026-08-31 14:00:24 +02:00
Mauricio Siu 3f6dfbaaa7 fix: only sum currentPriceAmount from the identified plan subscription
Summing across all active subscriptions could mix amounts with
different billing intervals (isAnnualCurrent only reflects the
matched plan subscription), producing an inconsistent total.
2026-08-30 18:13:33 -06:00
Mauricio Siu e79239a100 fix: detect billing plan across all active Stripe subscriptions
getCurrentPlanForUser and getProducts only inspected subscriptions.data[0],
so a customer with more than one active Stripe subscription (e.g. Startup
plus a separately purchased additional server) could have their plan
resolved from the wrong subscription, resulting in currentPlan !== "startup"
and the HubSpot chat bubble not rendering.
2026-08-30 18:08:35 -06:00
Shuvo c6f81ce60d Merge branch 'Dokploy:canary' into canary 2026-08-26 15:10:36 +06:00
outeiroDev 74d2d41961 feat: add Porkbun DNS provider support
Adds Porkbun as a supported DNS provider alongside Cloudflare and
AWS Route53, allowing Dokploy to automatically create DNS records
for domains managed on Porkbun.

- New DnsClient implementation for the Porkbun API v3
- porkbun enum value and config schema (apiKey/secretApiKey)
- Drizzle migration for the new DnsProviderType enum value
- UI: provider icon, form fields and provider selector entry
- Unit tests covering listZones/listRecords/upsertRecord/updateRecord/deleteRecord/testConnection
2026-08-22 21:50:37 +02:00
logical-tech 38e855bc5e fix(dns): preserve every value of a Route53 record set
Route53 returns a record set as a list of values, but listing joined them
into one string and writing sent that string back as a single
ResourceRecord. Editing a multi-value NS, MX or TXT set therefore either
failed validation or collapsed the set into one bogus value, and creating
a record for a name that already had values replaced them silently.

Values are now newline separated end to end: listing joins with a
newline, writing splits back into one ResourceRecord per line, and
creating merges into the existing set instead of replacing it. Unquoted
TXT values get the quotes Route53 requires. The record panel shows a
textarea for Route53 and validates every line.
2026-08-21 14:58:08 +02:00
logical-tech 130be40b49 fix(dns): keep the record table on a valid page while filtering
Filtering from a later page left pageIndex past the end of the
filtered set, so the table said no records matched while the counter
above it reported matches. The page index is clamped to the available
page count, and changing the search or the type filter goes back to
the first page.
2026-08-21 11:47:29 +02:00
logical-tech db2eb4f60a fix(dns): send SRV and CAA values to Cloudflare as structured data
Cloudflare treats content as read-only for SRV and CAA and expects a
data object instead, so both types were rejected on write. The client
now parses the inline value into the fields Cloudflare wants, and
builds the payload before the lookup request so a malformed value
fails without spending an API call.

The form rejects a malformed SRV or CAA value up front and shows the
expected shape, so the error lands on the field instead of coming
back from the provider.
2026-08-21 11:47:29 +02:00
logical-tech 9c4ad14c70 feat(dns): rework provider management and support all record types
Providers, domains and records now each have their own page instead of
a stack of modals. A provider opens its domains as cards, with the
record count loading separately so the domains appear right away. A
domain opens its records as a table with search, type filter and
pagination. Creating or editing a record happens in a panel that
slides in next to the table.

The type list was capped at A and CNAME in the zod schema, so widening
the dropdown alone would not have worked. AAAA, MX, TXT, NS, SRV, CAA
and PTR work now. MX carries a priority that Cloudflare takes as a
separate field and Route53 takes inline in the value, so the
Cloudflare client splits it out on write and puts it back on read.
The form keeps one value field for both providers.

Cloudflare proxy status is now editable and visible. A, AAAA and CNAME
records get a Proxied / DNS only toggle in the form and a cloud icon
in the table. The proxy field only goes to the API when the caller
sets it, so an update from another path cannot silently disable the
proxy.

Tests cover the MX priority round trip and the proxy rules in the
Cloudflare client.
2026-08-21 10:24:52 +02:00
Shuvo e3f4310755 Merge branch 'Dokploy:canary' into canary 2026-08-19 11:53:07 +06:00
Barry Norman 80859dc5b6 feat(vault): add Phase.dev secrets provider
Enable deploy-time ${{vault.*}} resolution from Phase via the REST API
(Service Account token + SSE-enabled apps), matching existing Infisical/Doppler providers.

Fixes #5122

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 22:27:42 +02:00
Shuvo 479e8bb551 feat: refactor serviceColumns for project access and enhance data retrieval 2026-08-17 12:41:58 +06:00
Shivam Gupta 6204415dc5 fix: honour metaName for page titles and show a loading state in requests
Two cases where the UI silently did nothing:

- DashboardLayout declared a `metaName` prop and 18 settings pages pass
  it, but the component only destructured `children`, so the value was
  discarded. None of those pages render their own <Head> either, so every
  settings page fell back to the default title from _app. The layout now
  renders a <title>, using the whitelabeling app name so the suffix is
  correct on rebranded instances.

- The requests table's fallback cell only rendered its message when
  `statsLogs?.data.length === 0`. While the query is in flight statsLogs
  is undefined, so that guard is false and the cell rendered nothing at
  all -- a blank panel, indistinguishable from a broken page. The query's
  isLoading flag wasn't even destructured. It now shows a spinner, matching
  the queue and deployments tables.
2026-08-09 01:01:41 +05:30
Romain PINSOLLE 24cc455168 fix: improve responsive layout for application logs page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 13:17:09 +02:00
89 changed files with 22506 additions and 810 deletions
+9 -1
View File
@@ -120,12 +120,20 @@ pnpm run docker:push
## Password Reset
In the case you lost your password, you can reset it using the following command
In the case you lost your password, you can reset the owner's password using the following command
```bash
pnpm run reset-password
```
To reset the password of a specific user instead, pass their email as an argument
```bash
pnpm run reset-password -- user@example.com
```
Both commands print the new randomly generated password to the console.
If you want to test the webhooks on development mode using localtunnel, make sure to install [`localtunnel`](https://localtunnel.app/)
```bash
@@ -55,4 +55,69 @@ describe("compose createCommand --project-directory", () => {
);
expect(cmd).toContain("-f docker-compose.yml");
});
it("resolves build.context against the compose file's own directory when there are no mounts", () => {
const cmd = createCommand({
...base,
composePath: "./backend/docker-compose.yml",
} as any);
expect(cmd).not.toContain("--project-directory");
expect(cmd).toContain("-f ./backend/docker-compose.yml");
});
});
describe("compose createCommand --env-file", () => {
it("points --env-file at the generated .env next to a nested compose file", () => {
const cmd = createCommand(
{
...base,
composePath: "./deploy/docker-compose.yml",
createEnvFile: true,
} as any,
"/etc/dokploy/compose/compose-app/code",
);
expect(cmd).toContain("--env-file deploy/.env");
expect(cmd).toContain(
"--project-directory /etc/dokploy/compose/compose-app/code",
);
});
it("omits --env-file when createEnvFile is disabled", () => {
const cmd = createCommand(
{ ...base, composePath: "./deploy/docker-compose.yml" } as any,
"/etc/dokploy/compose/compose-app/code",
);
expect(cmd).not.toContain("--env-file");
});
it("uses the code-root .env for raw sourceType", () => {
const cmd = createCommand(
{
...base,
sourceType: "raw",
composePath: "docker-compose.yml",
createEnvFile: true,
} as any,
"/etc/dokploy/compose/compose-app/code",
);
expect(cmd).toContain("--env-file .env");
});
it("does not add --env-file to stack deploy (unsupported flag)", () => {
const cmd = createCommand(
{
...base,
composeType: "stack",
composePath: "./deploy/docker-compose.yml",
createEnvFile: true,
} as any,
"/etc/dokploy/compose/compose-app/code",
);
expect(cmd).not.toContain("--env-file");
});
});
@@ -87,6 +87,224 @@ describe("cloudflareClient.listRecords", () => {
});
});
describe("cloudflareClient MX priority", () => {
it("inlines the priority into the content when listing", async () => {
mockFetch.mockResolvedValue(
cfSuccess([
{
id: "mx-1",
type: "MX",
name: "example.com",
content: "mail.example.com",
ttl: 300,
priority: 20,
},
]),
);
const records = await cloudflareClient.listRecords(config, "zone-1");
expect(records[0]?.content).toBe("20 mail.example.com");
});
it("splits the priority back out when writing", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "mx-1" }));
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "MX",
name: "example.com",
content: "20 mail.example.com",
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
expect(JSON.parse(init.body as string)).toMatchObject({
content: "mail.example.com",
priority: 20,
});
});
it("falls back to priority 10 when the content has no leading number", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "mx-1" }));
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "MX",
name: "example.com",
content: "mail.example.com",
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
expect(JSON.parse(init.body as string)).toMatchObject({
content: "mail.example.com",
priority: 10,
});
});
it("leaves non-MX content untouched", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "txt-1" }));
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "TXT",
name: "example.com",
content: "10 not-a-priority",
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body.content).toBe("10 not-a-priority");
expect(body.priority).toBeUndefined();
});
});
describe("cloudflareClient structured records", () => {
const stubCreate = () =>
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "rec-1" }));
it("sends SRV values as structured data", async () => {
stubCreate();
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "SRV",
name: "_sip._tcp.example.com",
content: "1 10 5269 talk.example.com",
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body.data).toEqual({
priority: 1,
weight: 10,
port: 5269,
target: "talk.example.com",
});
expect(body.content).toBeUndefined();
});
it("sends CAA values as structured data and drops the quotes", async () => {
stubCreate();
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "CAA",
name: "example.com",
content: '0 issue "letsencrypt.org"',
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body.data).toEqual({
flags: 0,
tag: "issue",
value: "letsencrypt.org",
});
expect(body.content).toBeUndefined();
});
it("rejects an SRV value that is missing a part", async () => {
await expect(
cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "SRV",
name: "_sip._tcp.example.com",
content: "1 10 5269",
}),
).rejects.toThrow(/SRV value/);
});
it("rejects a CAA value that has no tag", async () => {
await expect(
cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "CAA",
name: "example.com",
content: "0",
}),
).rejects.toThrow(/CAA value/);
});
});
describe("cloudflareClient proxy status", () => {
it("sends the proxy status for proxiable types", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "a-1" }));
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
proxied: true,
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
expect(JSON.parse(init.body as string).proxied).toBe(true);
});
it("omits the proxy status for types Cloudflare cannot proxy", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "txt-1" }));
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "TXT",
name: "example.com",
content: "hello",
proxied: true,
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
expect(JSON.parse(init.body as string).proxied).toBeUndefined();
});
it("leaves the proxy status untouched when the caller does not set it", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "a-1" }));
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
expect("proxied" in JSON.parse(init.body as string)).toBe(false);
});
it("returns the proxy status when listing records", async () => {
mockFetch.mockResolvedValue(
cfSuccess([
{
id: "a-1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 1,
proxied: true,
},
]),
);
const records = await cloudflareClient.listRecords(config, "zone-1");
expect(records[0]?.proxied).toBe(true);
});
});
describe("cloudflareClient.upsertRecord", () => {
it("creates a record when none exists for the name/type", async () => {
mockFetch
+211
View File
@@ -0,0 +1,211 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockFetch = vi.fn();
global.fetch = mockFetch as typeof fetch;
import { porkbunClient } from "@dokploy/server/utils/dns/porkbun";
const jsonResponse = (body: unknown, ok = true, status = 200) =>
({
ok,
status,
json: async () => body,
}) as Response;
const pbSuccess = (result: Record<string, unknown> = {}) =>
jsonResponse({ status: "SUCCESS", ...result });
const pbError = (message: string, status = 400) =>
jsonResponse({ status: "ERROR", message }, false, status);
const config = {
providerType: "porkbun" as const,
apiKey: "pk1_test",
secretApiKey: "sk1_test",
};
beforeEach(() => {
mockFetch.mockReset();
});
describe("porkbunClient.listZones", () => {
it("lists all domains as zones", async () => {
mockFetch.mockResolvedValue(
pbSuccess({ domains: [{ domain: "example.com" }] }),
);
const zones = await porkbunClient.listZones(config);
expect(zones).toEqual([{ id: "example.com", name: "example.com" }]);
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toContain("/domain/listAll");
const body = JSON.parse(init.body as string);
expect(body).toMatchObject({
apikey: "pk1_test",
secretapikey: "sk1_test",
});
});
});
describe("porkbunClient.listRecords", () => {
it("lists records for a domain", async () => {
mockFetch.mockResolvedValue(
pbSuccess({
records: [
{
id: "1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: "600",
prio: "0",
notes: "",
},
],
}),
);
const records = await porkbunClient.listRecords(config, "example.com");
expect(records).toEqual([
{
id: "1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 600,
},
]);
expect(mockFetch.mock.calls[0]?.[0]).toContain("/dns/retrieve/example.com");
});
});
describe("porkbunClient.upsertRecord", () => {
it("creates a record when none exists for the name/type", async () => {
mockFetch
.mockResolvedValueOnce(pbSuccess({ records: [] }))
.mockResolvedValueOnce(pbSuccess({ id: "new-1" }));
const result = await porkbunClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
expect(result).toEqual({ id: "new-1" });
const [lookupUrl] = mockFetch.mock.calls[0] as [string];
expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/app");
const [createUrl, createInit] = mockFetch.mock.calls[1] as [
string,
RequestInit,
];
expect(createUrl).toContain("/dns/create/example.com");
const body = JSON.parse(createInit.body as string);
expect(body).toMatchObject({ name: "app", type: "A", content: "1.2.3.4" });
});
it("resolves the apex domain to an empty subdomain", async () => {
mockFetch
.mockResolvedValueOnce(pbSuccess({ records: [] }))
.mockResolvedValueOnce(pbSuccess({ id: "new-2" }));
await porkbunClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "example.com",
content: "1.2.3.4",
});
const [lookupUrl] = mockFetch.mock.calls[0] as [string];
expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/");
});
it("edits the existing record instead of creating a duplicate", async () => {
mockFetch
.mockResolvedValueOnce(pbSuccess({ records: [{ id: "existing-1" }] }))
.mockResolvedValueOnce(pbSuccess({}));
const result = await porkbunClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "5.6.7.8",
});
expect(result).toEqual({ id: "existing-1" });
const [editUrl] = mockFetch.mock.calls[1] as [string, RequestInit];
expect(editUrl).toContain("/dns/edit/example.com/existing-1");
});
it("defaults ttl to 600 when not provided", async () => {
mockFetch
.mockResolvedValueOnce(pbSuccess({ records: [] }))
.mockResolvedValueOnce(pbSuccess({ id: "new-1" }));
await porkbunClient.upsertRecord(config, {
zoneId: "example.com",
type: "CNAME",
name: "www.example.com",
content: "example.com",
});
const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit];
const body = JSON.parse(createInit.body as string);
expect(body.ttl).toBe(600);
});
});
describe("porkbunClient.updateRecord", () => {
it("edits the given record id", async () => {
mockFetch.mockResolvedValue(pbSuccess({}));
const result = await porkbunClient.updateRecord(
config,
"example.com",
"1",
{
type: "A",
name: "app.example.com",
content: "9.9.9.9",
ttl: 300,
},
);
expect(result).toEqual({ id: "1" });
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toContain("/dns/edit/example.com/1");
expect(JSON.parse(init.body as string)).toMatchObject({
name: "app",
type: "A",
content: "9.9.9.9",
ttl: 300,
});
});
});
describe("porkbunClient.deleteRecord", () => {
it("posts to the delete endpoint for the given record id", async () => {
mockFetch.mockResolvedValue(pbSuccess({}));
await porkbunClient.deleteRecord(config, "example.com", "1");
const [url] = mockFetch.mock.calls[0] as [string];
expect(url).toContain("/dns/delete/example.com/1");
});
});
describe("porkbunClient.testConnection", () => {
it("succeeds when the credentials can ping the API", async () => {
mockFetch.mockResolvedValue(pbSuccess({}));
await expect(porkbunClient.testConnection(config)).resolves.toBeUndefined();
});
it("surfaces Porkbun's error message on invalid credentials", async () => {
mockFetch.mockResolvedValue(pbError("Invalid API key."));
await expect(porkbunClient.testConnection(config)).rejects.toThrow(
"Invalid API key.",
);
});
});
+101 -4
View File
@@ -142,13 +142,15 @@ describe("route53Client.listRecords", () => {
const records = await route53Client.listRecords(config, "Z123");
expect(records[0]?.content).toBe("ns1.example.com, ns2.example.com");
expect(records[0]?.content).toBe("ns1.example.com\nns2.example.com");
});
});
describe("route53Client.upsertRecord", () => {
it("sends a single UPSERT change", async () => {
send.mockResolvedValueOnce({});
it("sends a single UPSERT change when nothing exists yet", async () => {
send
.mockResolvedValueOnce({ ResourceRecordSets: [] })
.mockResolvedValueOnce({});
const result = await route53Client.upsertRecord(config, {
zoneId: "Z123",
@@ -158,7 +160,7 @@ describe("route53Client.upsertRecord", () => {
});
expect(result).toEqual({ id: "A:app.example.com" });
const command = send.mock.calls[0]?.[0] as HasInput;
const command = send.mock.calls[1]?.[0] as HasInput;
expect(command.input.HostedZoneId).toBe("Z123");
expect(command.input.ChangeBatch.Changes).toEqual([
{
@@ -172,6 +174,82 @@ describe("route53Client.upsertRecord", () => {
},
]);
});
it("keeps the values already in the record set", async () => {
send
.mockResolvedValueOnce({
ResourceRecordSets: [
{
Name: "app.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.1.1.1" }, { Value: "2.2.2.2" }],
},
],
})
.mockResolvedValueOnce({});
await route53Client.upsertRecord(config, {
zoneId: "Z123",
type: "A",
name: "app.example.com",
content: "3.3.3.3",
});
const command = send.mock.calls[1]?.[0] as HasInput;
expect(
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
).toEqual([
{ Value: "1.1.1.1" },
{ Value: "2.2.2.2" },
{ Value: "3.3.3.3" },
]);
});
it("does not duplicate a value that is already in the record set", async () => {
send
.mockResolvedValueOnce({
ResourceRecordSets: [
{
Name: "app.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.1.1.1" }],
},
],
})
.mockResolvedValueOnce({});
await route53Client.upsertRecord(config, {
zoneId: "Z123",
type: "A",
name: "app.example.com",
content: "1.1.1.1",
});
const command = send.mock.calls[1]?.[0] as HasInput;
expect(
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
).toEqual([{ Value: "1.1.1.1" }]);
});
it("wraps unquoted TXT values in double quotes", async () => {
send
.mockResolvedValueOnce({ ResourceRecordSets: [] })
.mockResolvedValueOnce({});
await route53Client.upsertRecord(config, {
zoneId: "Z123",
type: "TXT",
name: "example.com",
content: 'v=spf1 ~all\n"already quoted"',
});
const command = send.mock.calls[1]?.[0] as HasInput;
expect(
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
).toEqual([{ Value: '"v=spf1 ~all"' }, { Value: '"already quoted"' }]);
});
});
describe("route53Client.updateRecord", () => {
@@ -222,6 +300,25 @@ describe("route53Client.updateRecord", () => {
]);
});
it("keeps every line of a multi-value record set", async () => {
send.mockResolvedValueOnce({});
await route53Client.updateRecord(config, "Z123", "NS:example.com", {
type: "NS",
name: "example.com",
content: "ns1.example.com\nns2.example.com\n\n ns3.example.com ",
});
const command = send.mock.calls[0]?.[0] as HasInput;
expect(
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
).toEqual([
{ Value: "ns1.example.com" },
{ Value: "ns2.example.com" },
{ Value: "ns3.example.com" },
]);
});
it("skips the DELETE when the old record no longer exists", async () => {
send
.mockResolvedValueOnce({ ResourceRecordSets: [] })
+127
View File
@@ -20,6 +20,7 @@ import {
import { azureClient } from "@dokploy/server/utils/vault/azure";
import { dopplerClient } from "@dokploy/server/utils/vault/doppler";
import { hashicorpClient } from "@dokploy/server/utils/vault/hashicorp";
import { phaseClient } from "@dokploy/server/utils/vault/phase";
import { scalewayClient } from "@dokploy/server/utils/vault/scaleway";
const mockFetch = vi.fn();
@@ -616,3 +617,129 @@ describe("scaleway client", () => {
expect(result).toBe("DB_PASSWORD=s3cret");
});
});
describe("phase client", () => {
const config = {
providerType: "phase" as const,
token: "phase-rest-token",
appId: "app-123",
env: "Production",
path: "/",
apiUrl: "https://api.phase.dev",
};
it("fetches secrets by key and sends ServiceAccount auth", async () => {
mockFetch.mockResolvedValue(
jsonResponse([
{ key: "DB_URL", value: "postgres://real", path: "/" },
{ key: "API_KEY", value: "key-123", path: "/" },
]),
);
const result = await phaseClient.getSecrets(config, ["DB_URL", "API_KEY"]);
expect(result).toEqual({ DB_URL: "postgres://real", API_KEY: "key-123" });
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toBe(
"https://api.phase.dev/v1/secrets/?app_id=app-123&env=Production&path=%2F",
);
expect((init.headers as Record<string, string>).Authorization).toBe(
"Bearer ServiceAccount phase-rest-token",
);
});
it("throws when a requested secret is missing", async () => {
mockFetch.mockResolvedValue(
jsonResponse([{ key: "DB_URL", value: "postgres://real", path: "/" }]),
);
await expect(phaseClient.getSecrets(config, ["MISSING"])).rejects.toThrow(
'secret "MISSING" not found in environment "Production"',
);
});
it("reports authentication failures with the status code", async () => {
mockFetch.mockResolvedValue(
jsonResponse({ error: "unauthorized" }, false, 401),
);
await expect(phaseClient.getSecrets(config, ["DB_URL"])).rejects.toThrow(
"authentication failed (status 401: unauthorized)",
);
});
it("rejects apps without SSE enabled during testConnection", async () => {
mockFetch.mockResolvedValueOnce(
jsonResponse({
id: "app-123",
name: "My App",
sseEnabled: false,
}),
);
await expect(phaseClient.testConnection(config)).rejects.toThrow(
"enable Server-side Encryption (SSE)",
);
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch.mock.calls[0]?.[0]).toBe(
"https://api.phase.dev/v1/apps/app-123/",
);
});
it("tests connection against the app then secrets listing", async () => {
mockFetch
.mockResolvedValueOnce(
jsonResponse({
id: "app-123",
name: "My App",
sseEnabled: true,
}),
)
.mockResolvedValueOnce(jsonResponse([]));
await phaseClient.testConnection(config);
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch.mock.calls[0]?.[0]).toBe(
"https://api.phase.dev/v1/apps/app-123/",
);
expect(mockFetch.mock.calls[1]?.[0]).toBe(
"https://api.phase.dev/v1/secrets/?app_id=app-123&env=Production&path=%2F",
);
});
it("lists secret names from the configured path", async () => {
mockFetch.mockResolvedValue(
jsonResponse([
{ key: "DB_URL", value: "x", path: "/" },
{ key: "API_KEY", value: "y", path: "/" },
]),
);
const names = await phaseClient.listSecretNames?.(config);
expect(names).toEqual(["DB_URL", "API_KEY"]);
});
it("resolves env refs end to end through a phase provider", async () => {
findMany.mockResolvedValue([
{
name: "phase-prod",
providerType: "phase",
config,
assignments: assignedEverywhere,
},
]);
mockFetch.mockResolvedValue(
jsonResponse([{ key: "DB_PASSWORD", value: "s3cret", path: "/" }]),
);
const result = await resolveVaultReferences(
"DB_PASSWORD=${{vault.phase-prod.DB_PASSWORD}}",
scope,
);
expect(result).toBe("DB_PASSWORD=s3cret");
});
});
@@ -64,6 +64,21 @@ const serviceExists = async (name: string) => {
}
};
// Swarm keeps converging a service for a bit after it's created (scheduling
// tasks, resolving endpoints), which bumps Version.Index on its own. Calling
// setupMonitoring again before that settles races that internal bump, so wait
// for two consecutive reads to agree before treating the service as stable.
const waitForServiceConvergence = async (name: string, timeoutMs = 5000) => {
const deadline = Date.now() + timeoutMs;
let lastIndex: string | null = null;
while (Date.now() < deadline) {
const inspect = await docker.getService(name).inspect();
if (inspect.Version.Index === lastIndex) return;
lastIndex = inspect.Version.Index;
await new Promise((resolve) => setTimeout(resolve, 50));
}
};
const swarmTaskNames = async () => {
const list = await docker.listContainers({ all: true });
return list
@@ -193,6 +208,7 @@ describe.skipIf(hasRealMonitoring())(
expect(await containerExists(SERVICE_NAME)).toBe(false);
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
await waitForServiceConvergence(SERVICE_NAME);
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
expect(await serviceExists(SERVICE_NAME)).toBe(true);
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
execAsyncRemote: vi.fn(),
writeFileRemote: vi.fn(),
}));
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
@@ -16,6 +17,7 @@ vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
return {
...actual,
execAsyncRemote: mocks.execAsyncRemote,
writeFileRemote: mocks.writeFileRemote,
};
});
@@ -91,7 +93,7 @@ describe("writeAppTraefikConfig", () => {
});
it("writes the remote file when routers/services are present", async () => {
mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" });
mocks.writeFileRemote.mockResolvedValue(undefined);
await writeAppTraefikConfig(
{
@@ -109,8 +111,11 @@ describe("writeAppTraefikConfig", () => {
"server-id",
);
expect(mocks.execAsyncRemote).toHaveBeenCalledOnce();
const [, command] = mocks.execAsyncRemote.mock.calls[0] ?? [];
expect(command).toMatch(/^echo /);
expect(mocks.writeFileRemote).toHaveBeenCalledOnce();
const [serverId, remotePath, content] =
mocks.writeFileRemote.mock.calls[0] ?? [];
expect(serverId).toBe("server-id");
expect(remotePath).toContain("with-domain-app.yml");
expect(content).toContain("with-domain-app-router-1");
});
});
@@ -342,7 +342,7 @@ export const ShowDeployments = ({
)}
{/* Hash (from description) - shown in compact form */}
{deployment.description?.trim() && (
<span className="text-xs text-muted-foreground font-mono">
<span className="wrap-anywhere text-xs text-muted-foreground font-mono">
{deployment.description}
</span>
)}
@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { z } from "zod";
import { CodeEditor } from "@/components/shared/code-editor";
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
import { VaultImportDialog } from "@/components/shared/vault-import-dialog";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -197,6 +198,18 @@ export const ShowEnvironment = ({ id, type }: Props) => {
name="environment"
render={({ field }) => (
<FormItem>
<div className="flex justify-end">
<VaultImportDialog
projectId={data?.environment?.projectId}
environmentId={data?.environment?.environmentId}
currentEnv={field.value ?? ""}
onImport={(next) =>
form.setValue("environment", next, {
shouldDirty: true,
})
}
/>
</div>
<FormControl className="">
<CodeEditor
style={
@@ -151,6 +151,8 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
}
placeholder={["NODE_ENV=production", "PORT=3000"].join("\n")}
completionSource={completionSource}
projectId={data?.environment?.projectId}
environmentId={data?.environment?.environmentId}
/>
{data?.buildType === "dockerfile" && (
<Secrets
@@ -102,7 +102,7 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => {
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-row justify-between items-center gap-2">
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-2">
<Label>Select a container to view logs</Label>
<div className="flex flex-row gap-2 items-center">
<span className="text-sm text-muted-foreground">
@@ -352,11 +352,11 @@ export const DockerLogsId: React.FC<Props> = ({
/>
</div>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
className="h-9"
className="h-9 w-full sm:w-auto"
onClick={handlePauseResume}
title={isPaused ? "Resume logs" : "Pause logs"}
>
@@ -372,7 +372,7 @@ export const DockerLogsId: React.FC<Props> = ({
<Button
variant="outline"
size="sm"
className="h-9"
className="h-9 w-full sm:w-auto"
onClick={handleCopy}
disabled={filteredLogs.length === 0}
title="Copy logs to clipboard"
@@ -389,7 +389,7 @@ export const DockerLogsId: React.FC<Props> = ({
<Button
variant="outline"
size="sm"
className="h-9 sm:w-auto w-full"
className="h-9 w-full sm:w-auto"
onClick={handleDownload}
disabled={filteredLogs.length === 0 || !data?.Name}
title="Download logs as text file"
@@ -418,7 +418,7 @@ export const DockerLogsId: React.FC<Props> = ({
<div
ref={scrollRef}
onScroll={handleScroll}
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
className="h-[50vh] sm:h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
>
{filteredLogs.length > 0 ? (
filteredLogs.map((filteredLog: LogLine, index: number) => (
@@ -64,7 +64,9 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
const tooltip = (color: string, timestamp: string | null) => {
const square = (
<div className={cn("w-2 h-full shrink-0 rounded-[3px]", color)} />
<div
className={cn("w-2 min-h-4 h-full flex-shrink-0 rounded-[3px]", color)}
/>
);
return timestamp ? (
<TooltipProvider delayDuration={0} disableHoverableContent>
@@ -88,7 +90,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
return (
<div
className={cn(
"font-mono text-xs flex flex-row gap-3 py-2 sm:py-0.5 group",
"font-mono text-xs flex flex-col sm:flex-row gap-1 sm:gap-3 py-2 sm:py-0.5 group",
type === "error"
? "bg-red-500/10 hover:bg-red-500/15"
: type === "warning"
@@ -104,7 +106,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
{/* <Square className="size-4 text-muted-foreground opacity-0 group-hover/logitem:opacity-100 transition-opacity" /> */}
{tooltip(color, rawTimestamp)}
{!noTimestamp && (
<span className="select-none pl-2 text-muted-foreground w-full sm:w-40 shrink-0">
<span className="select-none pl-2 text-muted-foreground w-auto sm:w-40 flex-shrink-0">
{formattedTime}
</span>
)}
@@ -7,6 +7,7 @@ import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
import { VaultImportDialog } from "@/components/shared/vault-import-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -156,7 +157,17 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
name="env"
render={({ field }) => (
<FormItem>
<FormLabel>Environment variables</FormLabel>
<div className="flex items-center justify-between">
<FormLabel>Environment variables</FormLabel>
<VaultImportDialog
projectId={data?.projectId}
environmentId={environmentId}
currentEnv={field.value ?? ""}
onImport={(next) =>
form.setValue("env", next, { shouldDirty: true })
}
/>
</div>
<FormControl>
<CodeEditor
completionSource={completionSource}
@@ -7,6 +7,7 @@ import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
import { VaultImportDialog } from "@/components/shared/vault-import-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -152,7 +153,16 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
name="env"
render={({ field }) => (
<FormItem>
<FormLabel>Environment variables</FormLabel>
<div className="flex items-center justify-between">
<FormLabel>Environment variables</FormLabel>
<VaultImportDialog
projectId={projectId}
currentEnv={field.value ?? ""}
onImport={(next) =>
form.setValue("env", next, { shouldDirty: true })
}
/>
</div>
<FormControl>
<CodeEditor
completionSource={completionSource}
@@ -17,11 +17,13 @@ import {
Download,
Globe,
InfoIcon,
Loader2,
Server,
TrendingUpIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -100,7 +102,12 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
pageSize: 10,
});
const { data: statsLogs } = api.settings.readStatsLogs.useQuery(
const {
data: statsLogs,
isLoading,
isError,
error,
} = api.settings.readStatsLogs.useQuery(
{
sort: sorting[0],
page: pagination,
@@ -273,7 +280,18 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
colSpan={columns.length}
className="h-24 text-center"
>
{statsLogs?.data.length === 0 && (
{isLoading ? (
<div className="w-full flex gap-4 items-center justify-center h-[55vh] text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Loading requests...</span>
</div>
) : isError ? (
<div className="w-full flex items-center justify-center h-[55vh]">
<AlertBlock type="error" className="w-full">
{error?.message}
</AlertBlock>
</div>
) : (
<div className="w-full flex-col gap-2 flex items-center justify-center h-[55vh]">
<span className="text-muted-foreground text-lg font-medium">
No results.
@@ -0,0 +1,421 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Cloud, CloudOff, XIcon } from "lucide-react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
export const DNS_RECORD_TYPES = [
"A",
"AAAA",
"CNAME",
"MX",
"TXT",
"NS",
"SRV",
"CAA",
"PTR",
] as const;
type RecordType = (typeof DNS_RECORD_TYPES)[number];
export const PROXIABLE_TYPES: readonly string[] = ["A", "AAAA", "CNAME"];
const valueFields: Record<
RecordType,
{ label: string; placeholder: string; hint?: string }
> = {
A: { label: "IPv4 address", placeholder: "203.0.113.10" },
AAAA: { label: "IPv6 address", placeholder: "2001:db8::1" },
CNAME: { label: "Target", placeholder: "app.example.com" },
MX: {
label: "Mail server",
placeholder: "10 mail.example.com",
hint: "Start with the priority, then the mail server.",
},
TXT: { label: "Value", placeholder: "v=spf1 include:_spf.example.com ~all" },
NS: { label: "Nameserver", placeholder: "ns1.example.com" },
SRV: {
label: "Target",
placeholder: "1 10 5269 talk.example.com",
hint: "Priority, weight, port, then target.",
},
CAA: {
label: "Value",
placeholder: '0 issue "letsencrypt.org"',
hint: "Flags, tag, then the quoted value.",
},
PTR: { label: "Target", placeholder: "host.example.com" },
};
const structuredValuePatterns: Partial<Record<RecordType, RegExp>> = {
SRV: /^\d+\s+\d+\s+\d+\s+\S+$/,
CAA: /^\d+\s+\S+\s+.+$/,
};
const DnsRecordSchema = z
.object({
type: z.enum(DNS_RECORD_TYPES),
name: z.string().min(1, { message: "Name is required" }),
content: z.string().min(1, { message: "Content is required" }),
ttl: z.string(),
proxied: z.boolean(),
})
.superRefine((data, ctx) => {
const values = data.content
.split("\n")
.map((value) => value.trim())
.filter(Boolean);
if (!values.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["content"],
message: "Content is required",
});
return;
}
const pattern = structuredValuePatterns[data.type];
if (pattern && !values.every((value) => pattern.test(value))) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["content"],
message: `Expected ${valueFields[data.type].placeholder}`,
});
}
});
type DnsRecordForm = z.infer<typeof DnsRecordSchema>;
export interface DnsRecordValue {
id: string;
type: string;
name: string;
content: string;
ttl: number;
proxied?: boolean;
}
interface Props {
dnsProviderId: string;
zoneId: string;
zoneName: string;
record: DnsRecordValue | null;
onClose: () => void;
}
export const DnsRecordPanel = ({
dnsProviderId,
zoneId,
zoneName,
record,
onClose,
}: Props) => {
const utils = api.useUtils();
const createRecord = api.dnsProvider.createRecord.useMutation();
const updateRecord = api.dnsProvider.updateRecord.useMutation();
const { mutateAsync, isPending, error, isError } = record
? updateRecord
: createRecord;
const { data: provider } = api.dnsProvider.one.useQuery({ dnsProviderId });
const { data: servers } = api.server.all.useQuery();
const { data: panelPublicIp } = api.server.publicIp.useQuery();
const { data: panelStoredIp } = api.settings.getIp.useQuery();
const panelIp = panelPublicIp || panelStoredIp;
const ipSuggestions = [
...(panelIp ? [{ ip: panelIp, label: "This Dokploy server" }] : []),
...(servers ?? []).map((server) => ({
ip: server.ipAddress,
label: server.name,
})),
].filter(
(suggestion, index, all) =>
!!suggestion.ip && all.findIndex((s) => s.ip === suggestion.ip) === index,
);
const form = useForm<DnsRecordForm>({
defaultValues: record
? {
type: (DNS_RECORD_TYPES.includes(record.type as RecordType)
? record.type
: "A") as RecordType,
name: record.name,
content: record.content,
ttl: record.ttl && record.ttl !== 1 ? String(record.ttl) : "",
proxied: record.proxied ?? false,
}
: { type: "A", name: "", content: "", ttl: "", proxied: false },
resolver: zodResolver(DnsRecordSchema),
});
const type = form.watch("type");
const proxied = form.watch("proxied");
const canProxy =
provider?.providerType === "cloudflare" && PROXIABLE_TYPES.includes(type);
const supportsMultipleValues = provider?.providerType === "route53";
const usesAutomaticTtl = canProxy && proxied;
const onSubmit = async (data: DnsRecordForm) => {
const name = data.name.trim() === "@" ? zoneName : data.name;
const isProxied = canProxy && data.proxied;
const payload = {
dnsProviderId,
zoneId,
type: data.type,
name,
content: data.content,
ttl: !isProxied && data.ttl ? Number(data.ttl) : undefined,
...(canProxy && { proxied: data.proxied }),
...(record && { recordId: record.id }),
};
await mutateAsync(payload as any)
.then(() => {
toast.success(record ? "Record updated" : "Record created");
utils.dnsProvider.listRecords.invalidate({ dnsProviderId, zoneId });
onClose();
})
.catch(() => {});
};
return (
<div className="flex flex-col gap-4 rounded-lg border bg-muted/30 p-4">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">
{record ? "Edit record" : "New record"}
</span>
<span className="text-xs text-muted-foreground">{zoneName}</span>
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={onClose}
aria-label="Close panel"
>
<XIcon className="size-4" />
</Button>
</div>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Type</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{DNS_RECORD_TYPES.map((recordType) => (
<SelectItem key={recordType} value={recordType}>
{recordType}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="app.example.com" {...field} />
</FormControl>
<FormDescription>
Use <code>@</code> for the root domain.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{type === "A" && ipSuggestions.length > 0 && (
<FormItem>
<FormLabel>Fill from server (optional)</FormLabel>
<Select
onValueChange={(ip) =>
form.setValue("content", ip, { shouldValidate: true })
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a server" />
</SelectTrigger>
</FormControl>
<SelectContent>
{ipSuggestions.map((suggestion) => (
<SelectItem key={suggestion.ip} value={suggestion.ip}>
<div className="flex flex-row items-center gap-2">
<span>{suggestion.label}</span>
<span className="text-xs text-muted-foreground">
{suggestion.ip}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</FormItem>
)}
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>{valueFields[type].label}</FormLabel>
<FormControl>
{supportsMultipleValues ? (
<Textarea
className="min-h-[60px] font-mono text-xs"
placeholder={valueFields[type].placeholder}
{...field}
/>
) : (
<Input
placeholder={valueFields[type].placeholder}
{...field}
/>
)}
</FormControl>
{(supportsMultipleValues || valueFields[type].hint) && (
<FormDescription>
{[
valueFields[type].hint,
supportsMultipleValues &&
"One value per line: every line belongs to the same record set.",
]
.filter(Boolean)
.join(" ")}
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
{canProxy && (
<FormField
control={form.control}
name="proxied"
render={({ field }) => (
<FormItem>
<FormLabel>Proxy status</FormLabel>
<fieldset className="relative grid min-w-0 grid-cols-2 rounded-lg border bg-muted/40 p-1">
<legend className="sr-only">Proxy status</legend>
<span
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-y-1 left-1 z-0 w-[calc(50%-4px)] rounded-md bg-background shadow-sm ring-1 ring-foreground/10 transition-transform duration-[250ms] ease-[var(--ease-smooth-out)] will-change-transform motion-reduce:transition-none",
field.value && "translate-x-full",
)}
/>
<button
type="button"
aria-pressed={!field.value}
onClick={() => field.onChange(false)}
className={cn(
"relative z-10 flex h-8 items-center justify-center gap-1.5 rounded-md text-xs font-medium transition-colors duration-[250ms] ease-[var(--ease-smooth-out)] outline-none focus-visible:ring-3 focus-visible:ring-ring/50 motion-reduce:transition-none",
field.value
? "text-muted-foreground hover:text-foreground"
: "text-foreground",
)}
>
<CloudOff className="size-3.5" />
DNS only
</button>
<button
type="button"
aria-pressed={field.value}
onClick={() => field.onChange(true)}
className={cn(
"relative z-10 flex h-8 items-center justify-center gap-1.5 rounded-md text-xs font-medium transition-colors duration-[250ms] ease-[var(--ease-smooth-out)] outline-none focus-visible:ring-3 focus-visible:ring-ring/50 motion-reduce:transition-none",
field.value
? "text-[#f6821f]"
: "text-muted-foreground hover:text-foreground",
)}
>
<Cloud className="size-3.5" />
Proxied
</button>
</fieldset>
<FormDescription>
{field.value
? "Traffic runs through Cloudflare and the origin IP stays hidden."
: "Cloudflare only answers the DNS query; traffic reaches the origin directly."}
</FormDescription>
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="ttl"
render={({ field }) => (
<FormItem>
<FormLabel>TTL (optional)</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Auto"
className="tabular-nums"
disabled={usesAutomaticTtl}
{...field}
/>
</FormControl>
{usesAutomaticTtl && (
<FormDescription>
Proxied records always use automatic TTL.
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-row justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button type="submit" isLoading={isPending}>
{record ? "Update" : "Create"}
</Button>
</div>
</form>
</Form>
</div>
);
};
@@ -0,0 +1,33 @@
import { cn } from "@/lib/utils";
const recordTypeStyles: Record<string, string> = {
A: "bg-blue-500/10 text-blue-700 ring-blue-500/25 dark:text-blue-300",
AAAA: "bg-indigo-500/10 text-indigo-700 ring-indigo-500/25 dark:text-indigo-300",
CNAME:
"bg-violet-500/10 text-violet-700 ring-violet-500/25 dark:text-violet-300",
MX: "bg-amber-500/10 text-amber-700 ring-amber-500/25 dark:text-amber-300",
TXT: "bg-emerald-500/10 text-emerald-700 ring-emerald-500/25 dark:text-emerald-300",
NS: "bg-cyan-500/10 text-cyan-700 ring-cyan-500/25 dark:text-cyan-300",
SRV: "bg-rose-500/10 text-rose-700 ring-rose-500/25 dark:text-rose-300",
CAA: "bg-teal-500/10 text-teal-700 ring-teal-500/25 dark:text-teal-300",
PTR: "bg-orange-500/10 text-orange-700 ring-orange-500/25 dark:text-orange-300",
SOA: "bg-slate-500/10 text-slate-700 ring-slate-500/25 dark:text-slate-300",
};
interface Props {
type: string;
className?: string;
}
export const DnsRecordTypeBadge = ({ type, className }: Props) => (
<span
className={cn(
"inline-flex h-6 min-w-16 shrink-0 items-center justify-center rounded-md px-2 font-mono text-[11px] font-semibold tracking-wide ring-1 ring-inset",
recordTypeStyles[type.toUpperCase()] ??
"bg-foreground/5 text-muted-foreground ring-foreground/15",
className,
)}
>
{type.toUpperCase()}
</span>
);
@@ -33,11 +33,17 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
const providerLabels = {
cloudflare: "Cloudflare",
route53: "AWS Route53",
porkbun: "Porkbun",
} as const;
type ProviderType = keyof typeof providerLabels;
@@ -49,10 +55,12 @@ const DnsProviderSchema = z.object({
.regex(/^[a-zA-Z0-9_-]+$/, {
message: "Only letters, numbers, dashes and underscores",
}),
providerType: z.enum(["cloudflare", "route53"]),
providerType: z.enum(["cloudflare", "route53", "porkbun"]),
apiToken: z.string(),
accessKeyId: z.string(),
secretAccessKey: z.string(),
apiKey: z.string(),
secretApiKey: z.string(),
});
type DnsProviderForm = z.infer<typeof DnsProviderSchema>;
@@ -63,6 +71,8 @@ const defaultValues: DnsProviderForm = {
apiToken: "",
accessKeyId: "",
secretAccessKey: "",
apiKey: "",
secretApiKey: "",
};
const buildConfig = (data: DnsProviderForm) => {
@@ -78,6 +88,12 @@ const buildConfig = (data: DnsProviderForm) => {
accessKeyId: data.accessKeyId,
secretAccessKey: data.secretAccessKey,
};
case "porkbun":
return {
providerType: "porkbun" as const,
apiKey: data.apiKey,
secretApiKey: data.secretApiKey,
};
}
};
@@ -135,6 +151,10 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
accessKeyId: provider.config.accessKeyId,
secretAccessKey: provider.config.secretAccessKey,
}),
...(provider.config.providerType === "porkbun" && {
apiKey: provider.config.apiKey,
secretApiKey: provider.config.secretApiKey,
}),
});
} else if (!dnsProviderId) {
form.reset(defaultValues);
@@ -180,22 +200,30 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{dnsProviderId ? (
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10"
>
<PenBoxIcon className="size-4 text-primary group-hover:text-blue-500" />
</Button>
) : (
<Button className="cursor-pointer space-x-3">
<PlusIcon className="h-4 w-4" />
{dnsProviderId ? (
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground"
>
<PenBoxIcon className="size-4" />
<span className="sr-only">Edit provider</span>
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>Edit provider</TooltipContent>
</Tooltip>
) : (
<DialogTrigger asChild>
<Button>
<PlusIcon className="size-4" />
Add Provider
</Button>
)}
</DialogTrigger>
</DialogTrigger>
)}
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>
@@ -319,6 +347,42 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
</>
)}
{providerType === "porkbun" && (
<>
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem>
<FormLabel>API Key</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secretApiKey"
render={({ field }) => (
<FormItem>
<FormLabel>Secret API Key</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormDescription>
Create API keys at porkbun.com/account/api and make sure
API access is enabled for the domains you want Dokploy
to manage.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<DialogFooter className="flex w-full flex-row justify-between gap-2 sm:justify-between">
<Button
type="button"
@@ -1,271 +0,0 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
const DnsRecordSchema = z.object({
type: z.enum(["A", "CNAME"]),
name: z.string().min(1, { message: "Name is required" }),
content: z.string().min(1, { message: "Content is required" }),
ttl: z.string(),
});
type DnsRecordForm = z.infer<typeof DnsRecordSchema>;
interface DnsRecordValue {
id: string;
type: string;
name: string;
content: string;
ttl: number;
}
interface Props {
dnsProviderId: string;
zoneId: string;
zoneName: string;
record?: DnsRecordValue;
}
export const HandleDnsRecord = ({
dnsProviderId,
zoneId,
zoneName,
record,
}: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync, isPending, error, isError } = record
? api.dnsProvider.updateRecord.useMutation()
: api.dnsProvider.createRecord.useMutation();
const { data: servers } = api.server.all.useQuery(undefined, {
enabled: isOpen,
});
const { data: panelPublicIp } = api.server.publicIp.useQuery(undefined, {
enabled: isOpen,
});
const { data: panelStoredIp } = api.settings.getIp.useQuery(undefined, {
enabled: isOpen,
});
const panelIp = panelPublicIp || panelStoredIp;
const ipSuggestions = [
...(panelIp ? [{ ip: panelIp, label: "This Dokploy server" }] : []),
...(servers ?? []).map((server) => ({
ip: server.ipAddress,
label: server.name,
})),
].filter(
(suggestion, index, all) =>
!!suggestion.ip && all.findIndex((s) => s.ip === suggestion.ip) === index,
);
const form = useForm<DnsRecordForm>({
defaultValues: record
? {
type: record.type === "CNAME" ? "CNAME" : "A",
name: record.name,
content: record.content,
ttl: record.ttl && record.ttl !== 1 ? String(record.ttl) : "",
}
: { type: "A", name: "", content: "", ttl: "" },
resolver: zodResolver(DnsRecordSchema),
});
const type = form.watch("type");
const onSubmit = async (data: DnsRecordForm) => {
const name = data.name.trim() === "@" ? zoneName : data.name;
const payload = {
dnsProviderId,
zoneId,
type: data.type,
name,
content: data.content,
ttl: data.ttl ? Number(data.ttl) : undefined,
...(record && { recordId: record.id }),
};
await mutateAsync(payload as any)
.then(() => {
toast.success(record ? "Record updated" : "Record created");
utils.dnsProvider.listRecords.invalidate({ dnsProviderId, zoneId });
setIsOpen(false);
})
.catch(() => {});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{record ? (
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 size-6"
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
) : (
<Button variant="outline" size="sm">
<PlusIcon className="size-3.5 mr-1.5" />
Add Record
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{record ? "Edit Record" : "Add Record"}</DialogTitle>
<DialogDescription>
{record
? "Update this DNS record."
: "Create a new A or CNAME record in this zone."}
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Type</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="A">A</SelectItem>
<SelectItem value="CNAME">CNAME</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="app.example.com" {...field} />
</FormControl>
<FormDescription>
Use <code>@</code> for the root domain.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{type === "A" && ipSuggestions.length > 0 && (
<FormItem>
<FormLabel>Fill from server (optional)</FormLabel>
<Select
onValueChange={(ip) =>
form.setValue("content", ip, { shouldValidate: true })
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a server" />
</SelectTrigger>
</FormControl>
<SelectContent>
{ipSuggestions.map((suggestion) => (
<SelectItem key={suggestion.ip} value={suggestion.ip}>
<div className="flex flex-row items-center gap-2">
<span>{suggestion.label}</span>
<span className="text-xs text-muted-foreground">
{suggestion.ip}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</FormItem>
)}
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>
{type === "A" ? "IPv4 Address" : "Target"}
</FormLabel>
<FormControl>
<Input
placeholder={
type === "A" ? "203.0.113.10" : "app.example.com"
}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="ttl"
render={({ field }) => (
<FormItem>
<FormLabel>TTL (optional)</FormLabel>
<FormControl>
<Input type="number" placeholder="Auto" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="submit" isLoading={isPending}>
{record ? "Update" : "Create"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -1,218 +0,0 @@
import {
ChevronDown,
ChevronRight,
Globe,
Loader2,
Trash2,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
import { HandleDnsRecord } from "./handle-dns-record";
interface ZoneRecordsProps {
dnsProviderId: string;
zoneId: string;
zoneName: string;
}
const ZoneRecords = ({ dnsProviderId, zoneId, zoneName }: ZoneRecordsProps) => {
const utils = api.useUtils();
const { data, isLoading, isError, error } =
api.dnsProvider.listRecords.useQuery({ dnsProviderId, zoneId });
const { data: permissions } = api.user.getPermissions.useQuery();
const { mutateAsync: deleteRecord, isPending: isDeleting } =
api.dnsProvider.deleteRecord.useMutation();
const canWrite = !!permissions?.dnsProvider.update;
const canDelete = !!permissions?.dnsProvider.delete;
return (
<div className="flex flex-col gap-1 pl-8 pb-2 pr-3">
{isLoading && (
<div className="flex flex-row gap-2 items-center text-sm text-muted-foreground py-3">
<span>Loading records...</span>
<Loader2 className="animate-spin size-4" />
</div>
)}
{isError && (
<p className="text-sm text-destructive py-2">{error?.message}</p>
)}
{data && data.length === 0 && (
<p className="text-sm text-muted-foreground py-2">
No records found in this zone.
</p>
)}
{data?.map((record) => {
const isEditable = record.type === "A" || record.type === "CNAME";
return (
<div
key={record.id}
className="flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs"
>
<Badge variant="outline" className="shrink-0">
{record.type}
</Badge>
<span className="font-medium truncate">{record.name}</span>
<span className="text-muted-foreground truncate flex-1">
→ {record.content}
</span>
{canWrite && isEditable && (
<HandleDnsRecord
dnsProviderId={dnsProviderId}
zoneId={zoneId}
zoneName={zoneName}
record={record}
/>
)}
{canDelete && (
<DialogAction
title="Delete Record"
description={`Delete the ${record.type} record "${record.name}"? This removes it from the DNS provider, not just from Dokploy.`}
type="destructive"
onClick={async () => {
await deleteRecord({
dnsProviderId,
zoneId,
recordId: record.id,
})
.then(() => {
toast.success("Record deleted");
utils.dnsProvider.listRecords.invalidate({
dnsProviderId,
zoneId,
});
})
.catch(() => {
toast.error("Error deleting the record");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 size-6"
isLoading={isDeleting}
>
<Trash2 className="size-3.5 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div>
);
})}
{canWrite && (
<div className="pt-1">
<HandleDnsRecord
dnsProviderId={dnsProviderId}
zoneId={zoneId}
zoneName={zoneName}
/>
</div>
)}
</div>
);
};
interface Props {
dnsProviderId: string;
providerName: string;
}
export const ShowDnsProviderZones = ({
dnsProviderId,
providerName,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [expandedZoneId, setExpandedZoneId] = useState<string | null>(null);
const { data, isLoading, isError, error } =
api.dnsProvider.listZones.useQuery({ dnsProviderId }, { enabled: isOpen });
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open);
if (!open) {
setExpandedZoneId(null);
}
}}
>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Globe className="size-3.5 mr-1.5" />
View Domains
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Domains for {providerName}</DialogTitle>
<DialogDescription>
Zones this provider's API token can manage. Click a zone to see and
manage its records.
</DialogDescription>
</DialogHeader>
{isLoading && (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground py-6">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
)}
{isError && (
<p className="text-sm text-destructive py-2">{error?.message}</p>
)}
{data && data.length === 0 && (
<p className="text-sm text-muted-foreground py-2">
No zones found for this token. Make sure it has access to at least
one zone.
</p>
)}
{data && data.length > 0 && (
<div className="flex flex-col gap-1.5 max-h-[60vh] overflow-y-auto">
{data.map((zone) => {
const isExpanded = expandedZoneId === zone.id;
return (
<div key={zone.id} className="rounded-md border">
<button
type="button"
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-left"
onClick={() =>
setExpandedZoneId(isExpanded ? null : zone.id)
}
>
{isExpanded ? (
<ChevronDown className="size-3.5 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="size-3.5 text-muted-foreground shrink-0" />
)}
<Globe className="size-3.5 text-muted-foreground shrink-0" />
{zone.name}
</button>
{isExpanded && (
<ZoneRecords
dnsProviderId={dnsProviderId}
zoneId={zone.id}
zoneName={zone.name}
/>
)}
</div>
);
})}
</div>
)}
</DialogContent>
</Dialog>
);
};
@@ -1,8 +1,9 @@
import { Globe, Loader2, Trash2 } from "lucide-react";
import { EyeIcon, Globe, Loader2, Trash2 } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import { dnsProviderIcons } from "@/components/icons/dns-provider-icons";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -11,9 +12,13 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { HandleDnsProvider } from "./handle-dns-provider";
import { ShowDnsProviderZones } from "./show-dns-provider-zones";
const providerLabels: Record<string, string> = {
cloudflare: "Cloudflare",
@@ -21,121 +26,142 @@ const providerLabels: Record<string, string> = {
};
export const ShowDnsProviders = () => {
const { mutateAsync, isPending: isRemoving } =
api.dnsProvider.remove.useMutation();
const [removingId, setRemovingId] = useState<string | null>(null);
const { mutateAsync } = api.dnsProvider.remove.useMutation();
const { data, isPending, refetch } = api.dnsProvider.all.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="w-full max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
<Globe className="size-6 text-muted-foreground self-center" />
DNS Providers
</CardTitle>
<CardDescription>
Connect a DNS provider so Dokploy can create the A/CNAME record
for a domain instead of you setting it up by hand.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
<div className="flex flex-wrap items-center justify-between gap-4 p-6">
<CardHeader className="flex-1 p-0">
<CardTitle className="text-xl flex flex-row gap-2">
<Globe className="size-6 text-muted-foreground self-center" />
DNS Providers
</CardTitle>
<CardDescription>
Connect a DNS provider so Dokploy can create the A/CNAME record
for a domain instead of you setting it up by hand.
</CardDescription>
</CardHeader>
{permissions?.dnsProvider.create && <HandleDnsProvider />}
</div>
<CardContent className="flex min-h-[60vh] flex-col gap-4 border-t py-8">
{isPending ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
<div className="flex flex-1 flex-row items-center justify-center gap-2 text-sm text-muted-foreground">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : data?.length === 0 ? (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-3">
<Globe className="size-8 text-muted-foreground" />
<span className="font-medium text-muted-foreground">
No DNS providers connected
</span>
<span className="max-w-sm text-center text-sm text-muted-foreground">
Add Cloudflare or Route53 credentials to manage domain records
without leaving Dokploy.
</span>
</div>
) : (
<>
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<Globe className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
You don't have any DNS providers configured
</span>
{permissions?.dnsProvider.create && <HandleDnsProvider />}
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
<div className="flex flex-col gap-4 rounded-lg">
{data?.map((provider) => {
const ProviderIcon =
dnsProviderIcons[provider.providerType];
return (
<div
key={provider.dnsProviderId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex flex-row items-center gap-3">
<ProviderIcon className="size-7 shrink-0" />
<div className="flex gap-2 flex-col">
<span className="text-sm font-medium">
{provider.name}
</span>
<Badge variant="outline">
{providerLabels[provider.providerType] ??
provider.providerType}
</Badge>
</div>
</div>
<div className="flex flex-row gap-1">
<ShowDnsProviderZones
dnsProviderId={provider.dnsProviderId}
providerName={provider.name}
/>
{permissions?.dnsProvider.update && (
<HandleDnsProvider
dnsProviderId={provider.dnsProviderId}
/>
)}
{permissions?.dnsProvider.delete && (
<DialogAction
title="Delete DNS Provider"
description="Domains that rely on this provider to manage their records will need to be updated manually. Are you sure?"
type="destructive"
onClick={async () => {
await mutateAsync({
dnsProviderId: provider.dnsProviderId,
})
.then(() => {
toast.success("DNS provider deleted");
refetch();
})
.catch(() => {
toast.error(
"Error deleting the DNS provider",
);
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div>
</div>
</div>
);
})}
</div>
{permissions?.dnsProvider.create && (
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<HandleDnsProvider />
<ul className="flex flex-col gap-2">
{data?.map((provider) => {
const ProviderIcon = dnsProviderIcons[provider.providerType];
const href = `/dashboard/settings/dns/${provider.dnsProviderId}`;
return (
<li
key={provider.dnsProviderId}
className="group relative flex items-center gap-3 rounded-lg border bg-background px-4 py-3 transition-colors duration-150 ease-out hover:border-foreground/20 hover:bg-muted/50 focus-within:border-ring"
>
<Link
href={href}
aria-label={`View domains for ${provider.name}`}
className="absolute inset-0 rounded-lg outline-none"
/>
<ProviderIcon className="size-7 shrink-0" />
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-sm font-medium">
{provider.name}
</span>
<span className="text-xs text-muted-foreground">
{providerLabels[provider.providerType] ??
provider.providerType}
</span>
</div>
)}
</div>
)}
</>
<div className="relative z-10 ml-auto flex flex-row items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground"
asChild
>
<Link href={href}>
<EyeIcon className="size-4" />
<span className="sr-only">View domains</span>
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>View domains</TooltipContent>
</Tooltip>
{permissions?.dnsProvider.update && (
<HandleDnsProvider
dnsProviderId={provider.dnsProviderId}
/>
)}
{permissions?.dnsProvider.delete && (
<Tooltip>
<DialogAction
title="Delete DNS Provider"
description="Domains that rely on this provider to manage their records will need to be updated manually. Are you sure?"
type="destructive"
onClick={async () => {
setRemovingId(provider.dnsProviderId);
await mutateAsync({
dnsProviderId: provider.dnsProviderId,
})
.then(() => {
toast.success("DNS provider deleted");
refetch();
})
.catch(() => {
toast.error(
"Error deleting the DNS provider",
);
})
.finally(() => setRemovingId(null));
}}
>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:bg-red-500/10 hover:text-red-500"
isLoading={
removingId === provider.dnsProviderId
}
>
<Trash2 className="size-4" />
<span className="sr-only">
Delete provider
</span>
</Button>
</TooltipTrigger>
</DialogAction>
<TooltipContent>Delete provider</TooltipContent>
</Tooltip>
)}
</div>
</li>
);
})}
</ul>
)}
</CardContent>
</div>
@@ -0,0 +1,589 @@
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
type PaginationState,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import {
ArrowLeft,
ArrowUpDown,
Cloud,
CloudOff,
ListTree,
Loader2,
PenBoxIcon,
PlusIcon,
Search,
Trash2,
} from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import {
DNS_RECORD_TYPES,
DnsRecordPanel,
type DnsRecordValue,
PROXIABLE_TYPES,
} from "./dns-record-panel";
import { DnsRecordTypeBadge } from "./dns-record-type-badge";
interface Props {
dnsProviderId: string;
zoneId: string;
}
const PAGE_SIZES = [10, 20, 50, 100];
const SortableHeader = ({
column,
title,
className,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
title: string;
className?: string;
}) => (
<Button
variant="ghost"
size="xs"
className={cn("-ml-2.5 text-muted-foreground", className)}
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{title}
<ArrowUpDown className="size-3" />
</Button>
);
export const ShowDnsRecords = ({ dnsProviderId, zoneId }: Props) => {
const utils = api.useUtils();
const [isPanelOpen, setIsPanelOpen] = useState(false);
const [editing, setEditing] = useState<DnsRecordValue | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState("all");
const [sorting, setSorting] = useState<SortingState>([
{ id: "name", desc: false },
]);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const { data: provider } = api.dnsProvider.one.useQuery({ dnsProviderId });
const { data: zones } = api.dnsProvider.listZones.useQuery({ dnsProviderId });
const { data, isPending, isError, error } =
api.dnsProvider.listRecords.useQuery({ dnsProviderId, zoneId });
const { data: permissions } = api.user.getPermissions.useQuery();
const { mutateAsync: deleteRecord } =
api.dnsProvider.deleteRecord.useMutation();
const zoneName = zones?.find((zone) => zone.id === zoneId)?.name ?? "";
const isCloudflare = provider?.providerType === "cloudflare";
const canWrite = !!permissions?.dnsProvider.update;
const canDelete = !!permissions?.dnsProvider.delete;
const openEdit = (record: DnsRecordValue) => {
setEditing(record);
setIsPanelOpen(true);
};
const availableTypes = useMemo(
() => [...new Set((data ?? []).map((record) => record.type))].sort(),
[data],
);
const filteredRecords = useMemo(() => {
const query = search.trim().toLowerCase();
return (data ?? []).filter((record) => {
if (typeFilter !== "all" && record.type !== typeFilter) return false;
if (!query) return true;
return (
record.name.toLowerCase().includes(query) ||
record.content.toLowerCase().includes(query) ||
record.type.toLowerCase().includes(query)
);
});
}, [data, search, typeFilter]);
const handleDelete = async (record: DnsRecordValue) => {
setDeletingId(record.id);
await deleteRecord({ dnsProviderId, zoneId, recordId: record.id })
.then(() => {
toast.success("Record deleted");
utils.dnsProvider.listRecords.invalidate({ dnsProviderId, zoneId });
if (editing?.id === record.id) setIsPanelOpen(false);
})
.catch(() => {
toast.error("Error deleting the record");
})
.finally(() => setDeletingId(null));
};
const columns = useMemo<ColumnDef<DnsRecordValue>[]>(
() => [
{
accessorKey: "type",
header: ({ column }) => <SortableHeader column={column} title="Type" />,
cell: ({ row }) => <DnsRecordTypeBadge type={row.original.type} />,
},
{
accessorKey: "name",
header: ({ column }) => <SortableHeader column={column} title="Name" />,
cell: ({ row }) => (
<span
className="block max-w-[22ch] truncate font-medium"
title={row.original.name}
>
{row.original.name}
</span>
),
},
{
accessorKey: "content",
header: ({ column }) => (
<SortableHeader column={column} title="Value" />
),
cell: ({ row }) => (
<span
className="block max-w-[32ch] truncate font-mono text-xs text-muted-foreground"
title={row.original.content}
>
{row.original.content}
</span>
),
},
{
accessorKey: "ttl",
header: ({ column }) => <SortableHeader column={column} title="TTL" />,
cell: ({ row }) => (
<span className="text-xs text-muted-foreground tabular-nums">
{row.original.ttl === 1 ? "Auto" : row.original.ttl}
</span>
),
},
...(isCloudflare
? [
{
accessorKey: "proxied",
header: ({ column }) => (
<SortableHeader column={column} title="Proxy" />
),
cell: ({ row }) => {
if (!PROXIABLE_TYPES.includes(row.original.type)) {
return null;
}
const proxied = !!row.original.proxied;
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
{proxied ? (
<Cloud className="size-4 text-[#f6821f]" />
) : (
<CloudOff className="size-4 text-muted-foreground" />
)}
<span className="sr-only">
{proxied ? "Proxied" : "DNS only"}
</span>
</span>
</TooltipTrigger>
<TooltipContent>
{proxied ? "Proxied" : "DNS only"}
</TooltipContent>
</Tooltip>
);
},
} satisfies ColumnDef<DnsRecordValue>,
]
: []),
{
id: "actions",
enableSorting: false,
header: () => <span className="sr-only">Actions</span>,
cell: ({ row }) => {
const record = row.original;
const isEditable = (DNS_RECORD_TYPES as readonly string[]).includes(
record.type,
);
return (
<div className="flex items-center justify-end gap-1">
{canWrite && isEditable && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
onClick={() => openEdit(record)}
>
<PenBoxIcon className="size-4" />
<span className="sr-only">Edit record</span>
</Button>
</TooltipTrigger>
<TooltipContent>Edit record</TooltipContent>
</Tooltip>
)}
{canDelete && (
<Tooltip>
<DialogAction
title="Delete Record"
description={`Delete the ${record.type} record "${record.name}"? This removes it from the DNS provider, not just from Dokploy.`}
type="destructive"
onClick={() => handleDelete(record)}
>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:bg-red-500/10 hover:text-red-500"
isLoading={deletingId === record.id}
>
<Trash2 className="size-4" />
<span className="sr-only">Delete record</span>
</Button>
</TooltipTrigger>
</DialogAction>
<TooltipContent>Delete record</TooltipContent>
</Tooltip>
)}
</div>
);
},
},
],
[canWrite, canDelete, deletingId, editing?.id, isCloudflare],
);
const pageCount = Math.max(
1,
Math.ceil(filteredRecords.length / pagination.pageSize),
);
const pageIndex = Math.min(pagination.pageIndex, pageCount - 1);
const resetToFirstPage = () =>
setPagination((previous) => ({ ...previous, pageIndex: 0 }));
const table = useReactTable({
data: filteredRecords,
columns,
getRowId: (row) => row.id,
state: {
sorting,
pagination: { pageIndex, pageSize: pagination.pageSize },
},
onSortingChange: setSorting,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<div className="w-full ">
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md">
<div className="flex flex-wrap items-center justify-between gap-4 p-6">
<div className="flex flex-1 flex-row items-center gap-3">
<Button variant="ghost" size="icon" asChild>
<Link href={`/dashboard/settings/dns/${dnsProviderId}`}>
<ArrowLeft className="size-4" />
<span className="sr-only">Back to domains</span>
</Link>
</Button>
<CardHeader className="flex-1 p-0">
<CardTitle className="text-xl">
{zoneName || "DNS records"}
</CardTitle>
<CardDescription>
Records managed through {provider?.name ?? "this provider"}.
Changes are written straight to the provider.
</CardDescription>
</CardHeader>
</div>
{canWrite && (
<Button
onClick={() => {
setEditing(null);
setIsPanelOpen(true);
}}
>
<PlusIcon className="size-4" />
Add Record
</Button>
)}
</div>
<CardContent className="min-h-[60vh] border-t py-8">
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<div className="flex flex-col-reverse gap-4 lg:flex-row lg:items-start">
<div className="flex min-w-0 flex-1 flex-col gap-4">
{isPending ? (
<div className="flex min-h-[45vh] flex-row items-center justify-center gap-2 text-sm text-muted-foreground">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : data?.length === 0 ? (
<div className="flex min-h-[45vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8">
<div className="rounded-full bg-muted p-4">
<ListTree className="size-10 text-muted-foreground" />
</div>
<div className="space-y-1 text-center">
<p className="text-sm font-medium">
No records in this zone
</p>
<p className="max-w-sm text-sm text-muted-foreground">
Add an A or CNAME record to point this domain at one of
your servers.
</p>
</div>
</div>
) : (
<>
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-52 flex-1">
<FocusShortcutInput
placeholder="Filter records..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
resetToFirstPage();
}}
className="pr-10"
/>
<Search className="absolute right-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
</div>
<Select
value={typeFilter}
onValueChange={(value) => {
setTypeFilter(value);
resetToFirstPage();
}}
>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
{availableTypes.map((type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
{filteredRecords.length} of {data?.length ?? 0}
</span>
</div>
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader className="[&_tr]:border-b">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow
key={headerGroup.id}
className="bg-muted/40 hover:bg-muted/40"
>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className={cn(
"h-9 px-4 text-xs",
header.id === "actions" && "text-right",
)}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => {
const isEditable = (
DNS_RECORD_TYPES as readonly string[]
).includes(row.original.type);
const isSelected =
isPanelOpen && editing?.id === row.original.id;
return (
<TableRow
key={row.id}
data-state={
isSelected ? "selected" : undefined
}
onClick={
canWrite && isEditable
? () => openEdit(row.original)
: undefined
}
className={cn(
"duration-150 ease-out",
canWrite && isEditable && "cursor-pointer",
)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
onClick={
cell.column.id === "actions"
? (event) => event.stopPropagation()
: undefined
}
className="px-4 py-2"
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
);
})
) : (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={columns.length}
className="h-24 text-center text-muted-foreground"
>
No records match your filters.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
Rows per page
</span>
<Select
value={String(pagination.pageSize)}
onValueChange={(value) =>
setPagination({
pageIndex: 0,
pageSize: Number(value),
})
}
>
<SelectTrigger size="sm" className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZES.map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground tabular-nums">
Page {pageIndex + 1} of {pageCount}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</div>
</>
)}
</div>
<div
className={cn(
"t-panel-track grid lg:shrink-0 lg:grid-rows-[1fr]",
isPanelOpen
? "grid-rows-[1fr] lg:grid-cols-[1fr]"
: "grid-rows-[0fr] lg:grid-cols-[0fr]",
)}
>
<div className="overflow-hidden">
<div
data-open={isPanelOpen}
className="t-panel-slide-x w-full lg:w-[380px]"
>
{canWrite && (
<DnsRecordPanel
key={editing?.id ?? "new"}
dnsProviderId={dnsProviderId}
zoneId={zoneId}
zoneName={zoneName}
record={editing}
onClose={() => setIsPanelOpen(false)}
/>
)}
</div>
</div>
</div>
</div>
</CardContent>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,132 @@
import { ArrowLeft, Globe, Loader2 } from "lucide-react";
import Link from "next/link";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
interface Props {
dnsProviderId: string;
}
const RecordCount = ({
dnsProviderId,
zoneId,
}: {
dnsProviderId: string;
zoneId: string;
}) => {
const { data, isPending, isError } = api.dnsProvider.listRecords.useQuery({
dnsProviderId,
zoneId,
});
if (isPending) {
return <Loader2 className="animate-spin size-4 text-muted-foreground" />;
}
if (isError) {
return (
<span className="text-xs text-muted-foreground">Records unavailable</span>
);
}
return (
<span className="text-xs text-muted-foreground tabular-nums">
{data.length === 0
? "No records"
: `${data.length} record${data.length === 1 ? "" : "s"}`}
</span>
);
};
export const ShowDnsZones = ({ dnsProviderId }: Props) => {
const { data: provider } = api.dnsProvider.one.useQuery({ dnsProviderId });
const { data, isPending, isError, error } =
api.dnsProvider.listZones.useQuery({ dnsProviderId });
return (
<div className="w-full max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md">
<div className="flex flex-wrap items-center justify-between gap-4 p-6">
<div className="flex flex-1 flex-row items-center gap-3">
<Button variant="ghost" size="icon" asChild>
<Link href="/dashboard/settings/dns">
<ArrowLeft className="size-4" />
<span className="sr-only">Back to DNS providers</span>
</Link>
</Button>
<CardHeader className="flex-1 p-0">
<CardTitle className="text-xl">
{provider?.name ?? "Domains"}
</CardTitle>
<CardDescription>
Domains this provider's credentials can manage. Open one to
see and edit its DNS records.
</CardDescription>
</CardHeader>
</div>
</div>
<CardContent className="flex min-h-[60vh] flex-col gap-4 border-t py-8">
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
{isPending ? (
<div className="flex flex-1 flex-row items-center justify-center gap-2 text-sm text-muted-foreground">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : data?.length === 0 ? (
<div className="flex min-h-[45vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8">
<div className="rounded-full bg-muted p-4">
<Globe className="size-10 text-muted-foreground" />
</div>
<div className="space-y-1 text-center">
<p className="text-sm font-medium">No domains found</p>
<p className="max-w-sm text-sm text-muted-foreground">
These credentials can't reach any zone. Check that the token
has access to at least one domain.
</p>
</div>
</div>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(260px,1fr))] gap-4">
{data?.map((zone) => (
<Link
key={zone.id}
href={`/dashboard/settings/dns/${dnsProviderId}/${zone.id}`}
className="group flex flex-col justify-between gap-6 rounded-xl border bg-background p-4 outline-none transition-colors duration-150 ease-out hover:border-foreground/20 hover:bg-muted/40 focus-visible:border-ring"
>
<div className="flex items-start gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground transition-colors duration-150 group-hover:text-foreground">
<Globe className="size-4" />
</span>
<span
className="min-w-0 flex-1 truncate pt-2 text-sm font-medium"
title={zone.name}
>
{zone.name}
</span>
</div>
<div className="flex min-h-4 items-center justify-end">
<RecordCount
dnsProviderId={dnsProviderId}
zoneId={zone.id}
/>
</div>
</Link>
))}
</div>
)}
</CardContent>
</div>
</Card>
</div>
);
};
@@ -446,6 +446,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
serverUrl: notification.gotify?.serverUrl,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
});
} else if (notification.notificationType === "ntfy") {
form.reset({
@@ -681,6 +682,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
name: data.name,
dockerCleanup: dockerCleanup,
decoration: data.decoration,
serverThreshold: serverThreshold,
notificationId: notificationId || "",
gotifyId: notification?.gotifyId || "",
});
@@ -698,6 +700,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
priority: data.priority,
name: data.name,
dockerCleanup: dockerCleanup,
serverThreshold: serverThreshold,
notificationId: notificationId || "",
ntfyId: notification?.ntfyId || "",
});
@@ -0,0 +1,208 @@
import { ExternalLink, Loader2, Trash2 } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
interface Props {
serverId: string;
serverName: string;
children: React.ReactNode;
}
const serviceTypeLabel: Record<string, string> = {
application: "Application",
compose: "Compose",
postgres: "Postgres",
mysql: "MySQL",
mariadb: "MariaDB",
mongo: "MongoDB",
redis: "Redis",
libsql: "LibSQL",
};
export const DeleteServerModal = ({
serverId,
serverName,
children,
}: Props) => {
const [open, setOpen] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const utils = api.useUtils();
const {
data: services,
isLoading,
refetch,
} = api.server.getServices.useQuery({ serverId }, { enabled: open });
const { mutateAsync: deleteApplication } =
api.application.delete.useMutation();
const { mutateAsync: deleteCompose } = api.compose.delete.useMutation();
const { mutateAsync: deletePostgres } = api.postgres.remove.useMutation();
const { mutateAsync: deleteMysql } = api.mysql.remove.useMutation();
const { mutateAsync: deleteMariadb } = api.mariadb.remove.useMutation();
const { mutateAsync: deleteMongo } = api.mongo.remove.useMutation();
const { mutateAsync: deleteRedis } = api.redis.remove.useMutation();
const { mutateAsync: deleteLibsql } = api.libsql.remove.useMutation();
const { mutateAsync: deleteServer, isPending: isDeletingServer } =
api.server.remove.useMutation();
const canDelete = (services?.length ?? 0) === 0;
const handleDeleteService = async (
service: NonNullable<typeof services>[number],
) => {
setDeletingId(service.id);
try {
switch (service.type) {
case "application":
await deleteApplication({ applicationId: service.id });
break;
case "compose":
await deleteCompose({
composeId: service.id,
deleteVolumes: false,
});
break;
case "postgres":
await deletePostgres({ postgresId: service.id });
break;
case "mysql":
await deleteMysql({ mysqlId: service.id });
break;
case "mariadb":
await deleteMariadb({ mariadbId: service.id });
break;
case "mongo":
await deleteMongo({ mongoId: service.id });
break;
case "redis":
await deleteRedis({ redisId: service.id });
break;
case "libsql":
await deleteLibsql({ libsqlId: service.id });
break;
}
toast.success(`${service.name} deleted successfully`);
await refetch();
utils.server.all.invalidate();
} catch (error: any) {
toast.error(error.message);
} finally {
setDeletingId(null);
}
};
const handleDeleteServer = async () => {
try {
await deleteServer({ serverId });
toast.success(`Server ${serverName} deleted successfully`);
setOpen(false);
utils.server.all.invalidate();
} catch (error: any) {
toast.error(error.message);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Delete Server</DialogTitle>
<DialogDescription>
This will permanently delete "{serverName}" and all associated data.
</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className="flex justify-center py-6">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : canDelete ? (
<AlertBlock type="info">
No services are associated with this server. You can delete it
safely.
</AlertBlock>
) : (
<div className="flex flex-col gap-3">
<AlertBlock type="warning">
This server has {services?.length} service
{services?.length === 1 ? "" : "s"} associated. Delete them before
removing the server.
</AlertBlock>
<div className="flex max-h-72 flex-col gap-2 overflow-y-auto pr-1">
{services?.map((service) => (
<div
key={`${service.type}-${service.id}`}
className="flex items-center justify-between gap-2 rounded-lg border p-2"
>
<div className="flex min-w-0 flex-col gap-1">
<span className="truncate text-sm font-medium">
{service.name}
</span>
<Badge variant="outline" className="w-fit text-xs">
{serviceTypeLabel[service.type]}
</Badge>
</div>
<div className="flex shrink-0 items-center gap-1">
<Link href={service.url} target="_blank">
<Button variant="ghost" size="icon" className="h-8 w-8">
<ExternalLink className="h-4 w-4" />
</Button>
</Link>
<DialogAction
title="Delete Service"
description={`This will permanently delete "${service.name}" and all its associated data.`}
onClick={() => handleDeleteService(service)}
>
<Button
variant="ghost"
size="icon"
disabled={deletingId === service.id}
className="h-8 w-8 text-destructive hover:bg-destructive/10 hover:text-destructive"
>
{deletingId === service.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</DialogAction>
</div>
</div>
))}
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
variant="destructive"
disabled={!canDelete || isDeletingServer}
onClick={handleDeleteServer}
>
Delete Server
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -12,9 +12,6 @@ import {
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -33,6 +30,7 @@ import {
import { api } from "@/utils/api";
import { TerminalModal } from "../web-server/terminal-modal";
import { ShowServerActions } from "./actions/show-server-actions";
import { DeleteServerModal } from "./delete-server-modal";
import { HandleServers } from "./handle-servers";
import { SetupServer } from "./setup-server";
import { ShowHealthModal } from "./show-health-modal";
@@ -43,7 +41,6 @@ export const ShowServers = () => {
const router = useRouter();
const query = router.query;
const { data, refetch, isPending } = api.server.all.useQuery();
const { mutateAsync } = api.server.remove.useMutation();
const { data: sshKeys } = api.sshKey.all.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: canCreateMoreServers } =
@@ -111,7 +108,6 @@ export const ShowServers = () => {
<div className="flex flex-col gap-4 min-h-[25vh]">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{data?.map((server) => {
const canDelete = server.totalSum === 0;
const isActive = server.serverStatus === "active";
const isBuildServer = server.serverType === "build";
return (
@@ -350,64 +346,22 @@ export const ShowServers = () => {
<Tooltip>
<TooltipTrigger asChild>
<div>
<DialogAction
disabled={!canDelete}
title={
canDelete
? "Delete Server"
: "Server has active services"
}
description={
canDelete ? (
"This will delete the server and all associated data"
) : (
<div className="flex flex-col gap-2">
You can not delete this
server because it has
active services.
<AlertBlock type="warning">
You have active
services associated
with this server,
please delete them
first.
</AlertBlock>
</div>
)
}
onClick={async () => {
await mutateAsync({
serverId: server.serverId,
})
.then(() => {
refetch();
toast.success(
`Server ${server.name} deleted successfully`,
);
})
.catch((err) => {
toast.error(
err.message,
);
});
}}
<DeleteServerModal
serverId={server.serverId}
serverName={server.name}
>
<Button
variant="ghost"
size="icon"
className={`h-9 w-9 ${canDelete ? "text-destructive hover:text-destructive hover:bg-destructive/10" : "text-muted-foreground hover:bg-muted"}`}
className="h-9 w-9 text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4" />
</Button>
</DialogAction>
</DeleteServerModal>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{canDelete
? "Delete Server"
: "Cannot delete - has active services"}
</p>
<p>Delete Server</p>
</TooltipContent>
</Tooltip>
)}
@@ -43,6 +43,7 @@ const providerLabels = {
doppler: "Doppler",
azure: "Azure Key Vault",
scaleway: "Scaleway Secret Manager",
phase: "Phase",
} as const;
type ProviderType = keyof typeof providerLabels;
@@ -63,6 +64,7 @@ const VaultProviderSchema = z
"doppler",
"azure",
"scaleway",
"phase",
]),
url: z.string(),
token: z.string(),
@@ -89,6 +91,11 @@ const VaultProviderSchema = z
scalewayProjectId: z.string(),
scalewaySecretKey: z.string(),
scalewayApiUrl: z.string(),
phaseToken: z.string(),
phaseAppId: z.string(),
phaseEnv: z.string(),
phasePath: z.string(),
phaseApiUrl: z.string(),
assignments: z.array(
z.object({
projectId: z.string(),
@@ -161,6 +168,17 @@ const VaultProviderSchema = z
path: ["siteUrl"],
});
}
if (
data.providerType === "phase" &&
data.phaseApiUrl &&
!isValidUrl(data.phaseApiUrl)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Enter a valid URL (e.g. https://api.phase.dev)",
path: ["phaseApiUrl"],
});
}
const required: Partial<
Record<ProviderType, [keyof typeof data, string][]>
@@ -194,6 +212,11 @@ const VaultProviderSchema = z
["scalewayProjectId", "Project ID is required"],
["scalewaySecretKey", "Secret Key is required"],
],
phase: [
["phaseToken", "Service Account REST API token is required"],
["phaseAppId", "App ID is required"],
["phaseEnv", "Environment is required"],
],
};
for (const [field, message] of required[data.providerType] ?? []) {
@@ -254,6 +277,11 @@ const defaultValues: VaultProviderForm = {
scalewayProjectId: "",
scalewaySecretKey: "",
scalewayApiUrl: "https://api.scaleway.com",
phaseToken: "",
phaseAppId: "",
phaseEnv: "",
phasePath: "/",
phaseApiUrl: "https://api.phase.dev",
assignments: [],
};
@@ -308,6 +336,15 @@ const buildConfig = (data: VaultProviderForm) => {
secretKey: data.scalewaySecretKey,
apiUrl: data.scalewayApiUrl || "https://api.scaleway.com",
};
case "phase":
return {
providerType: "phase" as const,
token: data.phaseToken,
appId: data.phaseAppId,
env: data.phaseEnv,
path: data.phasePath || "/",
apiUrl: data.phaseApiUrl || "https://api.phase.dev",
};
}
};
@@ -429,6 +466,13 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
scalewaySecretKey: provider.config.secretKey,
scalewayApiUrl: provider.config.apiUrl,
}),
...(provider.config.providerType === "phase" && {
phaseToken: provider.config.token,
phaseAppId: provider.config.appId,
phaseEnv: provider.config.env,
phasePath: provider.config.path,
phaseApiUrl: provider.config.apiUrl,
}),
});
} else if (!vaultProviderId) {
form.reset(defaultValues);
@@ -996,8 +1040,118 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
</>
)}
{providerType === "phase" && (
<>
<FormField
control={form.control}
name="phaseToken"
render={({ field }) => (
<FormItem>
<FormLabel>Service Account REST API Token</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormDescription>
Use the REST API token from a Service Account — not the
CLI/SDK <code>pss_*</code> token. The Phase App must
have Server-side Encryption (SSE) enabled.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="phaseAppId"
render={({ field }) => (
<FormItem>
<FormLabel>App ID</FormLabel>
<FormControl>
<Input
placeholder="00000000-0000-0000-0000-000000000000"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phaseEnv"
render={({ field }) => (
<FormItem>
<FormLabel>Environment</FormLabel>
<FormControl>
<Input placeholder="Production" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="phasePath"
render={({ field }) => (
<FormItem>
<FormLabel>Secret Path</FormLabel>
<FormControl>
<Input placeholder="/" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phaseApiUrl"
render={({ field }) => (
<FormItem>
<FormLabel>API URL</FormLabel>
<FormControl>
<Input placeholder="https://api.phase.dev" {...field} />
</FormControl>
<FormDescription>
Self-hosted Phase defaults to{" "}
<code>{"${HTTP_PROTOCOL}${HOST}/service/public"}</code>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormDescription>
Reference format:{" "}
<code>{"${{vault.<name>.SECRET_KEY}}"}</code>
</FormDescription>
</>
)}
<div className="flex flex-col gap-2 rounded-lg border p-3">
<FormLabel>Access</FormLabel>
<div className="flex flex-row items-center justify-between">
<FormLabel>Access</FormLabel>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() =>
setAssignments(
assignments.length === orgProjects?.length
? []
: (orgProjects ?? []).map((project) => ({
projectId: project.projectId,
environmentIds: [],
})),
)
}
>
{assignments.length === orgProjects?.length
? "Clear all"
: "Access all"}
</Button>
</div>
<FormDescription>
This provider can only be referenced from the selected projects.
Pick environments to narrow it further — none selected means all
@@ -21,6 +21,7 @@ const providerLabels: Record<string, string> = {
doppler: "Doppler",
azure: "Azure Key Vault",
scaleway: "Scaleway Secret Manager",
phase: "Phase",
};
export const ShowVaultProviders = () => {
@@ -34,7 +34,65 @@ export const Route53Icon = ({ className }: Props) => (
</svg>
);
export const PorkbunIcon = ({ className }: Props) => (
<svg
viewBox="0 0 129.3 114.3"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
fill="#ED7778"
d="M76,23.6c-18.7,0-33.8,15.1-33.8,33.8S57.3,91.3,76,91.3s33.8-15.1,33.8-33.8S94.7,23.6,76,23.6z"
/>
<path
fill="#FFFFFF"
d="M67.1,43.4c-2.6-1.4-5.5-2.5-8.5-3.2c-0.6,1.3-0.9,2.6-0.9,4.1c0,2.2,0.7,4.2,1.9,5.8 C61.5,47.3,64,44.9,67.1,43.4z"
/>
<path
fill="#FFFFFF"
d="M92.4,50.1c1.2-1.6,1.9-3.6,1.9-5.8c0-1.5-0.3-2.9-0.9-4.1c-3,0.6-5.9,1.7-8.5,3.2 C87.9,44.9,90.5,47.3,92.4,50.1z"
/>
<path
fill="#FFFFFF"
d="M80.5,54.7c-0.6,0-1.1,0.5-1.1,1.1c0,0.2,0.1,0.4,0.2,0.6l0,0c0.4,0.6,1,1,1.7,1.2 c0.2-0.4,0.3-0.9,0.3-1.4c0-0.2,0-0.3,0-0.5C81.5,55.1,81.1,54.7,80.5,54.7z"
/>
<path
fill="#FFFFFF"
d="M75.3,42.5c-9.9,0.4-17.6,8.8-17.6,18.7v10.3c0,1.8,1.5,3.3,3.3,3.3c1.8,0,3.3-1.5,3.3-3.3v-2.7h23.2v2.7 c0,1.8,1.5,3.3,3.3,3.3c1.8,0,3.3-1.5,3.3-3.3V60.7C94.2,50.4,85.7,42.1,75.3,42.5z M85.7,56.9c-0.6,1-1.5,1.7-2.6,2.1 c-0.7,1.4-2.2,2.4-3.9,2.4c-0.2,0-0.4,0-0.5,0c-0.6,0-1.1-0.5-1.1-1.1c0-0.6,0.5-1.1,1.1-1.1v0c0.5,0,1-0.2,1.4-0.4 c-0.6-0.3-1.2-0.7-1.6-1.3c-0.4-0.5-0.6-1-0.6-1.7c0-1.4,1.2-2.6,2.6-2.6c0.9,0,1.6,0.4,2.1,1.1c0.6,0.8,1,1.7,1,2.8 c0,0.1,0,0.2,0,0.4c0.5-0.2,0.9-0.6,1.2-1c0.2-0.3,0.5-0.3,0.8-0.2C85.8,56.2,85.9,56.6,85.7,56.9z"
/>
<path
fill="#212222"
d="M128,44.6h4.5v2.2c0,1-0.1,1.9-0.1,1.9h0.1c0,0,2.2-4.6,8.5-4.6c6.8,0,11.1,5.4,11.1,13.3 c0,8.1-4.9,13.3-11.5,13.3c-5.6,0-7.8-4.2-7.8-4.2h-0.1c0,0,0.1,0.9,0.1,2.2v11.4H128V44.6z M139.9,66.4c4,0,7.3-3.3,7.3-9.1 c0-5.5-3-9.1-7.2-9.1c-3.8,0-7.3,2.7-7.3,9.1C132.7,61.9,135.2,66.4,139.9,66.4z"
/>
<path
fill="#212222"
d="M170.9,44c7.6,0,13.7,5.6,13.7,13.2c0,7.7-6.1,13.3-13.7,13.3s-13.7-5.6-13.7-13.3 C157.2,49.6,163.3,44,170.9,44z M170.9,66.3c4.8,0,8.7-3.8,8.7-9.1c0-5.3-3.9-9-8.7-9c-4.8,0-8.7,3.8-8.7,9 C162.2,62.5,166.1,66.3,170.9,66.3z"
/>
<path
fill="#212222"
d="M191.3,44.6h4.7V49c0,1.1-0.1,1.9-0.1,1.9h0.1c1.2-3.7,4.1-6.6,8-6.6c0.7,0,1.3,0.1,1.3,0.1v4.8 c0,0-0.7-0.1-1.4-0.1c-3.1,0-6,2.2-7.1,6c-0.5,1.5-0.6,3-0.6,4.6v10.4h-4.9V44.6z"
/>
<path
fill="#212222"
d="M211,34.6h4.9v19.3h3.6l6.9-9.3h5.5l-8.4,11.2v0.1l9.4,14.1h-5.7L219.5,58h-3.7v11.9H211V34.6z"
/>
<path
fill="#212222"
d="M238.3,34.6h4.9v11.6c0,1.3-0.1,2.2-0.1,2.2h0.1c0,0,2.2-4.3,8.1-4.3c6.8,0,11.1,5.4,11.1,13.3 c0,8.1-4.9,13.3-11.5,13.3c-5.7,0-8-4.4-8-4.4h-0.1c0,0,0.1,0.8,0.1,1.9v1.9h-4.6V34.6z M250.2,66.4c4,0,7.3-3.3,7.3-9.1 c0-5.5-3-9.1-7.2-9.1c-3.8,0-7.3,2.7-7.3,9.1C243,61.9,245.4,66.4,250.2,66.4z"
/>
<path
fill="#212222"
d="M269,44.6h4.9v15.1c0,3.5,0.7,6.3,4.8,6.3c5.2,0,8.2-4.6,8.2-9.6V44.6h4.9v25.3H287v-3.4 c0-1.1,0.1-1.9,0.1-1.9H287c-1.1,2.5-4.4,5.8-9.3,5.8c-5.7,0-8.7-3-8.7-9.7V44.6z"
/>
<path
fill="#212222"
d="M300.3,44.6h4.7V48c0,1-0.1,1.9-0.1,1.9h0.1c1-2.2,4-5.8,9.5-5.8c6,0,8.7,3.3,8.7,9.7v16.2h-4.9V54.8 c0-3.6-0.8-6.4-4.8-6.4c-3.9,0-7,2.6-8,6.2c-0.3,1-0.4,2.2-0.4,3.4v11.9h-4.9V44.6z"
/>
</svg>
);
export const dnsProviderIcons = {
cloudflare: CloudflareIcon,
route53: Route53Icon,
porkbun: PorkbunIcon,
} as const;
@@ -582,6 +582,28 @@ export const ScalewayIcon = ({ className }: Props) => (
</svg>
);
export const PhaseIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
d="M12 2L3.5 6.5v11L12 22l8.5-4.5v-11L12 2z"
stroke="currentColor"
strokeWidth="1.75"
strokeLinejoin="round"
/>
<path
d="M12 2v20M3.5 6.5L12 11l8.5-4.5M3.5 17.5L12 13l8.5 4.5"
stroke="currentColor"
strokeWidth="1.75"
strokeLinejoin="round"
/>
</svg>
);
export const vaultProviderIcons = {
hashicorp: HashicorpVaultIcon,
infisical: InfisicalIcon,
@@ -589,4 +611,5 @@ export const vaultProviderIcons = {
doppler: DopplerIcon,
azure: AzureIcon,
scaleway: ScalewayIcon,
phase: PhaseIcon,
} as const;
@@ -1,4 +1,6 @@
import Head from "next/head";
import { api } from "@/utils/api";
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
import { HubSpotWidget } from "../shared/HubSpotWidget";
import Page from "./side";
@@ -8,9 +10,11 @@ interface Props {
metaName?: string;
}
export const DashboardLayout = ({ children }: Props) => {
export const DashboardLayout = ({ children, metaName }: Props) => {
const { data: haveRootAccess } = api.user.haveRootAccess.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { config: whitelabeling } = useWhitelabeling();
const appName = whitelabeling?.appName || "Dokploy";
const { data: currentPlan } = api.stripe.getCurrentPlan.useQuery(undefined, {
enabled: isCloud === true,
refetchOnWindowFocus: false,
@@ -22,6 +26,13 @@ export const DashboardLayout = ({ children }: Props) => {
return (
<>
{metaName && (
<Head>
<title>
{metaName} | {appName}
</title>
</Head>
)}
<Page>{children}</Page>
{isChatEnabled && (
<>
@@ -173,7 +173,7 @@ const RESOURCE_META: Record<string, { label: string; description: string }> = {
vaultProvider: {
label: "Secrets Providers",
description:
"Manage external secret managers (HashiCorp Vault, AWS, Azure, Infisical, Doppler, Scaleway) and where their secrets can be referenced",
"Manage external secret managers (HashiCorp Vault, AWS, Azure, Infisical, Doppler, Scaleway, Phase) and where their secrets can be referenced",
},
dnsProvider: {
label: "DNS Providers",
@@ -0,0 +1,307 @@
import { DownloadIcon, Loader2 } from "lucide-react";
import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
interface Props {
projectId?: string;
environmentId?: string;
currentEnv: string;
onImport: (nextEnv: string) => void;
}
const normalizeSecretName = (secretName: string) => {
const normalized = secretName
.replace(/[^a-zA-Z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.toUpperCase();
return /^[0-9]/.test(normalized) ? `_${normalized}` : normalized || "SECRET";
};
const parseEnvKeys = (env: string) => {
const keys = new Set<string>();
for (const line of env.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) continue;
keys.add(trimmed.slice(0, trimmed.indexOf("=")).trim());
}
return keys;
};
const setEnvValue = (env: string, key: string, ref: string) => {
const lines = env.split("\n");
const line = `${key}=${ref}`;
const index = lines.findIndex((l) => {
const trimmed = l.trim();
return (
trimmed &&
!trimmed.startsWith("#") &&
trimmed.includes("=") &&
trimmed.slice(0, trimmed.indexOf("=")).trim() === key
);
});
if (index >= 0) {
lines[index] = line;
return lines.join("\n");
}
const withTrailingNewline = env.length > 0 && !env.endsWith("\n") ? "\n" : "";
return `${env}${withTrailingNewline}${line}\n`;
};
export const VaultImportDialog = ({
projectId,
environmentId,
currentEnv,
onImport,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [vaultProviderId, setVaultProviderId] = useState<string | undefined>();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [keyOverrides, setKeyOverrides] = useState<Record<string, string>>({});
const { data: allProviders } = api.vaultProvider.all.useQuery();
const providers = useMemo(
() =>
(allProviders ?? []).filter((provider) =>
provider.assignments?.some(
(assignment) =>
assignment.projectId === projectId &&
(assignment.environmentIds.length === 0 ||
!environmentId ||
assignment.environmentIds.includes(environmentId)),
),
),
[allProviders, projectId, environmentId],
);
const activeProviderId = vaultProviderId ?? providers[0]?.vaultProviderId;
const activeProvider = providers.find(
(p) => p.vaultProviderId === activeProviderId,
);
const { data: secretNames, isLoading } =
api.vaultProvider.listSecretNames.useQuery(
{
vaultProviderId: activeProviderId!,
projectId: projectId!,
environmentId,
},
{ enabled: isOpen && !!activeProviderId && !!projectId },
);
const existingKeys = useMemo(() => parseEnvKeys(currentEnv), [currentEnv]);
const rows = useMemo(
() =>
(secretNames ?? []).map((secretName) => {
const defaultKey = normalizeSecretName(secretName);
const key = keyOverrides[secretName] ?? defaultKey;
return {
secretName,
key,
conflict: existingKeys.has(key),
};
}),
[secretNames, keyOverrides, existingKeys],
);
const resetState = () => {
setSelected(new Set());
setKeyOverrides({});
};
const handleOpenChange = (open: boolean) => {
setIsOpen(open);
if (!open) {
setVaultProviderId(undefined);
resetState();
}
};
const toggleRow = (secretName: string, checked: boolean) => {
setSelected((prev) => {
const next = new Set(prev);
if (checked) next.add(secretName);
else next.delete(secretName);
return next;
});
};
const toggleAll = () => {
const selectableNonConflicting = rows.filter((r) => !r.conflict);
const allSelected =
selectableNonConflicting.length > 0 &&
selectableNonConflicting.every((r) => selected.has(r.secretName));
setSelected(
allSelected
? new Set()
: new Set(selectableNonConflicting.map((r) => r.secretName)),
);
};
const handleImport = () => {
if (!activeProvider) return;
let next = currentEnv;
for (const row of rows) {
if (!selected.has(row.secretName)) continue;
const ref = `\${{vault.${activeProvider.name}.${row.secretName}}}`;
next = setEnvValue(next, row.key, ref);
}
onImport(next);
setIsOpen(false);
setVaultProviderId(undefined);
resetState();
};
if (!projectId || providers.length === 0) {
return null;
}
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button type="button" variant="outline" size="sm">
<DownloadIcon className="mr-2 size-4" />
Import from Vault
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Import secrets from vault</DialogTitle>
<DialogDescription>
Select the secrets to import as{" "}
<code>{"${{vault.<provider>.<secret>}}"}</code> references. Secrets
already defined below are skipped unless you check them explicitly.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label>Vault provider</Label>
<Select
value={activeProviderId}
onValueChange={(value) => {
setVaultProviderId(value);
resetState();
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
{providers.map((provider) => (
<SelectItem
key={provider.vaultProviderId}
value={provider.vaultProviderId}
>
{provider.name} ({provider.providerType})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isLoading ? (
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground text-sm">
<Loader2 className="size-4 animate-spin" />
Loading secrets...
</div>
) : rows.length === 0 ? (
<div className="py-8 text-center text-muted-foreground text-sm">
No secrets found for this provider.
</div>
) : (
<>
<div className="flex items-center justify-between">
<Label className="flex items-center gap-2">
<Checkbox
checked={
rows.some((r) => !r.conflict) &&
rows
.filter((r) => !r.conflict)
.every((r) => selected.has(r.secretName))
}
onCheckedChange={toggleAll}
/>
Select all
</Label>
<span className="text-muted-foreground text-sm">
{selected.size} selected
</span>
</div>
<ScrollArea className="h-80 rounded-md border">
<div className="flex flex-col divide-y">
{rows.map((row) => (
<div
key={row.secretName}
className="flex items-center gap-3 p-3"
>
<Checkbox
checked={selected.has(row.secretName)}
onCheckedChange={(checked) =>
toggleRow(row.secretName, checked === true)
}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate font-mono text-muted-foreground text-xs">
{row.secretName}
</span>
<Input
value={row.key}
onChange={(e) =>
setKeyOverrides((prev) => ({
...prev,
[row.secretName]: e.target.value
.toUpperCase()
.replace(/[^A-Z0-9_]/g, "_"),
}))
}
className="h-8 font-mono text-sm"
/>
</div>
{row.conflict && (
<Badge variant="secondary">already exists</Badge>
)}
</div>
))}
</div>
</ScrollArea>
</>
)}
</div>
<DialogFooter>
<Button
type="button"
onClick={handleImport}
disabled={selected.size === 0}
>
Import {selected.size > 0 ? selected.size : ""} secret
{selected.size === 1 ? "" : "s"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
+24 -11
View File
@@ -3,6 +3,7 @@ import { EyeIcon, EyeOffIcon } from "lucide-react";
import { type CSSProperties, type ReactNode, useState } from "react";
import { useFormContext } from "react-hook-form";
import { CodeEditor } from "@/components/shared/code-editor";
import { VaultImportDialog } from "@/components/shared/vault-import-dialog";
import {
CardContent,
CardDescription,
@@ -23,6 +24,8 @@ interface Props {
description: ReactNode;
placeholder: string;
completionSource?: CompletionSource;
projectId?: string;
environmentId?: string;
}
export const Secrets = (props: Props) => {
@@ -37,17 +40,27 @@ export const Secrets = (props: Props) => {
<CardDescription>{props.description}</CardDescription>
</div>
<Toggle
aria-label="Toggle bold"
pressed={isVisible}
onPressedChange={setIsVisible}
>
{isVisible ? (
<EyeOffIcon className="h-4 w-4 text-muted-foreground" />
) : (
<EyeIcon className="h-4 w-4 text-muted-foreground" />
)}
</Toggle>
<div className="flex items-center gap-2">
<VaultImportDialog
projectId={props.projectId}
environmentId={props.environmentId}
currentEnv={form.watch(props.name) ?? ""}
onImport={(next) =>
form.setValue(props.name, next, { shouldDirty: true })
}
/>
<Toggle
aria-label="Toggle bold"
pressed={isVisible}
onPressedChange={setIsVisible}
>
{isVisible ? (
<EyeOffIcon className="h-4 w-4 text-muted-foreground" />
) : (
<EyeIcon className="h-4 w-4 text-muted-foreground" />
)}
</Toggle>
</div>
</CardHeader>
<CardContent className="w-full space-y-4 p-0">
<FormField
@@ -0,0 +1 @@
ALTER TYPE "public"."VaultProviderType" ADD VALUE 'phase';
@@ -0,0 +1 @@
ALTER TYPE "public"."DnsProviderType" ADD VALUE 'porkbun';
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -1317,6 +1317,20 @@
"when": 1787937580323,
"tag": "0187_grant_terminal_permission_to_read_roles",
"breakpoints": true
},
{
"idx": 188,
"version": "7",
"when": 1788249519837,
"tag": "0188_volatile_piledriver",
"breakpoints": true
},
{
"idx": 189,
"version": "7",
"when": 1788251861403,
"tag": "0189_wooden_nextwave",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.30.3",
"version": "v0.30.4",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -8,11 +8,7 @@ import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
const Page = () => {
return (
<div className="flex flex-col gap-4 w-full">
<ShowDnsProviders />
</div>
);
return <ShowDnsProviders />;
};
export default Page;
@@ -0,0 +1,57 @@
import { validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { ShowDnsZones } from "@/components/dashboard/settings/dns/show-dns-zones";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
interface Props {
dnsProviderId: string;
}
const Page = ({ dnsProviderId }: Props) => {
return <ShowDnsZones dnsProviderId={dnsProviderId} />;
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="DNS Providers">{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ dnsProviderId: string }>,
) {
const { req, res, params } = ctx;
const { user, session } = await validateRequest(req);
if (!user || user.role === "member" || !params?.dnsProviderId) {
return {
redirect: {
permanent: false,
destination: "/",
},
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
await helpers.user.get.prefetch();
await helpers.settings.isCloud.prefetch();
return {
props: {
trpcState: helpers.dehydrate(),
dnsProviderId: params.dnsProviderId,
},
};
}
@@ -0,0 +1,64 @@
import { validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { ShowDnsRecords } from "@/components/dashboard/settings/dns/show-dns-records";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
interface Props {
dnsProviderId: string;
zoneId: string;
}
const Page = ({ dnsProviderId, zoneId }: Props) => {
return <ShowDnsRecords dnsProviderId={dnsProviderId} zoneId={zoneId} />;
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="DNS Providers">{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ dnsProviderId: string; zoneId: string }>,
) {
const { req, res, params } = ctx;
const { user, session } = await validateRequest(req);
if (
!user ||
user.role === "member" ||
!params?.dnsProviderId ||
!params?.zoneId
) {
return {
redirect: {
permanent: false,
destination: "/",
},
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
await helpers.user.get.prefetch();
await helpers.settings.isCloud.prefetch();
return {
props: {
trpcState: helpers.dehydrate(),
dnsProviderId: params.dnsProviderId,
zoneId: params.zoneId,
},
};
}
+30 -6
View File
@@ -1,30 +1,54 @@
import { findOwner, generateRandomPassword } from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { account } from "@dokploy/server/db/schema";
import { eq } from "drizzle-orm";
import { account, user } from "@dokploy/server/db/schema";
import { and, eq } from "drizzle-orm";
(async () => {
try {
const email = process.argv[2]?.trim().toLowerCase();
const randomPassword = await generateRandomPassword();
const result = await findOwner();
let userId: string;
if (email) {
const foundUser = await db.query.user.findFirst({
where: eq(user.email, email),
});
if (!foundUser) {
console.log(`User not found for email: ${email}`);
process.exit(1);
}
userId = foundUser.id;
} else {
const owner = await findOwner();
userId = owner.userId;
}
const update = await db
.update(account)
.set({
password: randomPassword.hashedPassword,
})
.where(eq(account.userId, result.userId));
.where(
and(eq(account.userId, userId), eq(account.providerId, "credential")),
);
if (update) {
if (update.count > 0) {
console.log("Password reset successful");
console.log("New password: ", randomPassword.randomPassword);
} else {
console.log("Password reset failed");
console.log(
"Password reset failed: no credential account found for this user",
);
process.exit(1);
}
process.exit(0);
} catch (error) {
console.log("Error resetting password", error);
process.exit(1);
}
})();
@@ -1,5 +1,6 @@
import {
assertGitProviderAccess,
canViewGitProviderSecrets,
createBitbucket,
findBitbucketById,
getAccessibleGitProviderIds,
@@ -55,6 +56,17 @@ export const bitbucketRouter = createTRPCRouter({
.query(async ({ input, ctx }) => {
const bitbucket = await findBitbucketById(input.bitbucketId);
await assertGitProviderAccess(ctx.session, bitbucket.gitProvider);
if (
!(await canViewGitProviderSecrets(ctx.session, bitbucket.gitProvider))
) {
return {
...bitbucket,
appPassword: null,
apiToken: null,
};
}
return bitbucket;
}),
bitbucketProviders: protectedProcedure.query(async ({ ctx }) => {
+4 -1
View File
@@ -551,7 +551,10 @@ export const composeRouter = createTRPCRouter({
const compose = await findComposeById(input.composeId);
const { COMPOSE_PATH } = paths(!!compose.serverId);
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
const command = createCommand(compose, projectPath);
const command = createCommand(
compose,
compose.mounts.length > 0 ? projectPath : undefined,
);
return `docker ${command}`;
}),
refreshToken: protectedProcedure
@@ -162,6 +162,7 @@ export const dnsProviderRouter = createTRPCRouter({
name: input.name,
content: input.content,
ttl: input.ttl,
proxied: input.proxied,
});
await audit(ctx, {
action: "create",
@@ -188,6 +189,7 @@ export const dnsProviderRouter = createTRPCRouter({
name: input.name,
content: input.content,
ttl: input.ttl,
proxied: input.proxied,
},
);
await audit(ctx, {
+11
View File
@@ -1,5 +1,6 @@
import {
assertGitProviderAccess,
canViewGitProviderSecrets,
createGitea,
findGiteaById,
getAccessibleGitProviderIds,
@@ -59,6 +60,16 @@ export const giteaRouter = createTRPCRouter({
.query(async ({ input, ctx }) => {
const gitea = await findGiteaById(input.giteaId);
await assertGitProviderAccess(ctx.session, gitea.gitProvider);
if (!(await canViewGitProviderSecrets(ctx.session, gitea.gitProvider))) {
return {
...gitea,
clientSecret: null,
accessToken: null,
refreshToken: null,
};
}
return gitea;
}),
+11
View File
@@ -1,5 +1,6 @@
import {
assertGitProviderAccess,
canViewGitProviderSecrets,
findGithubById,
getAccessibleGitProviderIds,
getGithubBranches,
@@ -28,6 +29,16 @@ export const githubRouter = createTRPCRouter({
.query(async ({ input, ctx }) => {
const github = await findGithubById(input.githubId);
await assertGitProviderAccess(ctx.session, github.gitProvider);
if (!(await canViewGitProviderSecrets(ctx.session, github.gitProvider))) {
return {
...github,
githubClientSecret: null,
githubPrivateKey: null,
githubWebhookSecret: null,
};
}
return github;
}),
getGithubRepositories: protectedProcedure
+11
View File
@@ -1,5 +1,6 @@
import {
assertGitProviderAccess,
canViewGitProviderSecrets,
createGitlab,
findGitlabById,
getAccessibleGitProviderIds,
@@ -57,6 +58,16 @@ export const gitlabRouter = createTRPCRouter({
.query(async ({ input, ctx }) => {
const gitlab = await findGitlabById(input.gitlabId);
await assertGitProviderAccess(ctx.session, gitlab.gitProvider);
if (!(await canViewGitProviderSecrets(ctx.session, gitlab.gitProvider))) {
return {
...gitlab,
secret: null,
accessToken: null,
refreshToken: null,
};
}
return gitlab;
}),
gitlabProviders: protectedProcedure.query(async ({ ctx }) => {
+25
View File
@@ -12,12 +12,14 @@ import {
findPostgresById,
findRedisById,
getServiceContainer,
isDangerousBindMountPath,
updateMount,
} from "@dokploy/server";
import type { ServiceType } from "@dokploy/server/db/schema/mount";
import {
checkServiceAccess,
checkServicePermissionAndAccess,
isPrivilegedOrgRole,
} from "@dokploy/server/services/permission";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
@@ -73,6 +75,23 @@ async function getServiceOrganizationId(
}
}
async function assertSafeBindMountPath(
ctx: { session: { userId: string; activeOrganizationId: string } },
type: string | undefined,
hostPath: string | null | undefined,
) {
if (type !== "bind" || !hostPath) return;
if (!isDangerousBindMountPath(hostPath)) return;
if (!(await isPrivilegedOrgRole(ctx.session))) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"This host path is reserved and can't be used in a bind mount. Only an organization owner or admin can do that.",
});
}
}
export const mountRouter = createTRPCRouter({
create: protectedProcedure
.input(apiCreateMount)
@@ -80,6 +99,7 @@ export const mountRouter = createTRPCRouter({
await checkServicePermissionAndAccess(ctx, input.serviceId, {
volume: ["create"],
});
await assertSafeBindMountPath(ctx, input.type, input.hostPath);
const mount = await createMount(input);
await audit(ctx, {
action: "create",
@@ -153,6 +173,11 @@ export const mountRouter = createTRPCRouter({
volume: ["create"],
});
}
await assertSafeBindMountPath(
ctx,
input.type ?? mount.type,
input.hostPath ?? mount.hostPath,
);
await audit(ctx, {
action: "update",
resourceType: "mount",
+4 -2
View File
@@ -25,7 +25,7 @@ import { asc, desc, eq } from "drizzle-orm";
import { z } from "zod";
import { audit } from "@/server/api/utils/audit";
import { assertScheduledJobLimit } from "@/server/api/utils/plan-limits";
import { removeJob, schedule } from "@/server/utils/backup";
import { removeJob, schedule, updateJob } from "@/server/utils/backup";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const scheduleRouter = createTRPCRouter({
@@ -130,7 +130,7 @@ export const scheduleRouter = createTRPCRouter({
if (IS_CLOUD) {
if (updatedSchedule?.enabled) {
schedule({
await updateJob({
scheduleId: updatedSchedule.scheduleId,
type: "schedule",
cronSchedule: updatedSchedule.cronExpression,
@@ -141,6 +141,7 @@ export const scheduleRouter = createTRPCRouter({
cronSchedule: updatedSchedule.cronExpression,
scheduleId: updatedSchedule.scheduleId,
type: "schedule",
timezone: updatedSchedule.timezone,
});
}
} else {
@@ -185,6 +186,7 @@ export const scheduleRouter = createTRPCRouter({
cronSchedule: scheduleItem.cronExpression,
scheduleId: scheduleItem.scheduleId,
type: "schedule",
timezone: scheduleItem.timezone,
});
} else {
removeScheduleJob(scheduleItem.scheduleId);
+37
View File
@@ -7,6 +7,7 @@ import {
findUserById,
getAccessibleServerIds,
getPublicIpWithFallback,
getServicesByServerId,
haveActiveServices,
IS_CLOUD,
redactServerSshKey,
@@ -18,6 +19,7 @@ import {
updateServerById,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { findMemberByUserId } from "@dokploy/server/services/permission";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
@@ -115,6 +117,41 @@ export const serverRouter = createTRPCRouter({
const isBuildServer = server.serverType === "build";
return defaultCommand(isBuildServer);
}),
getServices: withPermission("server", "read")
.input(apiFindOneServer)
.query(async ({ input, ctx }) => {
const currentServer = await findServerById(input.serverId);
if (currentServer.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
const accessibleIds = await getAccessibleServerIds(ctx.session);
if (!accessibleIds.has(input.serverId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
const services = await getServicesByServerId(input.serverId);
const isPrivileged =
ctx.user.role === "owner" || ctx.user.role === "admin";
if (isPrivileged) {
return services;
}
const { accessedServices } = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
return services.filter((service) =>
accessedServices.includes(service.id),
);
}),
all: withPermission("server", "read").query(async ({ ctx }) => {
const accessibleIds = await getAccessibleServerIds(ctx.session);
+28 -19
View File
@@ -80,33 +80,42 @@ export const stripeRouter = createTRPCRouter({
let currentPlan: CurrentPlan = "legacy";
let isAnnualCurrent = false;
let currentPriceAmount: number | null = null;
const activeSub = subscriptions.data[0];
if (activeSub) {
const priceIds = activeSub.items.data.map(
(item) => (item.price as Stripe.Price).id,
if (subscriptions.data.length > 0) {
const matchedSub = subscriptions.data.find((sub) =>
sub.items.data.some(
(item) =>
(item.price as Stripe.Price).id === STARTUP_BASE_PRICE_MONTHLY_ID ||
(item.price as Stripe.Price).id === STARTUP_BASE_PRICE_ANNUAL_ID,
),
);
if (
priceIds.some(
(id) =>
id === STARTUP_BASE_PRICE_MONTHLY_ID ||
id === STARTUP_BASE_PRICE_ANNUAL_ID,
)
) {
const hobbySub = subscriptions.data.find((sub) =>
sub.items.data.some(
(item) =>
(item.price as Stripe.Price).id === HOBBY_PRICE_MONTHLY_ID ||
(item.price as Stripe.Price).id === HOBBY_PRICE_ANNUAL_ID,
),
);
const legacySub = subscriptions.data.find((sub) =>
sub.items.data.some((item) =>
LEGACY_PRICE_IDS.includes((item.price as Stripe.Price).id),
),
);
const activeSub = matchedSub ?? hobbySub ?? legacySub;
if (matchedSub) {
currentPlan = "startup";
} else if (
priceIds.some(
(id) => id === HOBBY_PRICE_MONTHLY_ID || id === HOBBY_PRICE_ANNUAL_ID,
)
) {
} else if (hobbySub) {
currentPlan = "hobby";
} else if (priceIds.some((id) => LEGACY_PRICE_IDS.includes(id))) {
} else if (legacySub) {
currentPlan = "legacy";
}
const firstPrice = activeSub.items.data[0]?.price as
const firstPrice = activeSub?.items.data[0]?.price as
| Stripe.Price
| undefined;
isAnnualCurrent = firstPrice?.recurring?.interval === "year";
const totalCents = activeSub.items.data.reduce((sum, item) => {
const totalCents = (activeSub?.items.data ?? []).reduce((sum, item) => {
const price = item.price as Stripe.Price;
const amount = price.unit_amount ?? 0;
const qty = item.quantity ?? 1;
+3 -4
View File
@@ -27,11 +27,10 @@ export const getCurrentPlanForUser = async (
status: "active",
expand: ["data.items.data.price"],
});
const activeSub = subscriptions.data[0];
if (!activeSub) return null;
if (subscriptions.data.length === 0) return null;
const priceIds = activeSub.items.data.map(
(item) => (item.price as Stripe.Price).id,
const priceIds = subscriptions.data.flatMap((sub) =>
sub.items.data.map((item) => (item.price as Stripe.Price).id),
);
if (
+95
View File
@@ -531,3 +531,98 @@ body[data-scroll-locked] [data-slot="sidebar-inset"] {
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--duration-quick: 150ms;
--duration-fast: 250ms;
--duration-medium: 350ms;
--duration-slow: 400ms;
--ease-smooth-out: cubic-bezier(0.22, 1, 0.36, 1);
--distance-base: 8px;
--blur-medium: 3px;
--panel-open-dur: 400ms;
--panel-close-dur: 350ms;
--panel-translate-x: 32px;
--panel-blur: 2px;
--panel-ease: cubic-bezier(0.22, 1, 0.36, 1);
}
@layer components {
.t-panel-slide-x {
transform: translateX(var(--panel-translate-x));
opacity: 0;
filter: blur(var(--panel-blur));
pointer-events: none;
transition:
transform var(--panel-close-dur) var(--panel-ease),
opacity var(--panel-close-dur) var(--panel-ease),
filter var(--panel-close-dur) var(--panel-ease);
will-change: transform, opacity, filter;
}
.t-panel-slide-x[data-open="true"] {
transform: translateX(0);
opacity: 1;
filter: blur(0);
pointer-events: auto;
transition:
transform var(--panel-open-dur) var(--panel-ease),
opacity var(--panel-open-dur) var(--panel-ease),
filter var(--panel-open-dur) var(--panel-ease);
}
.t-panel-track {
transition:
grid-template-columns var(--panel-open-dur) var(--panel-ease),
grid-template-rows var(--panel-open-dur) var(--panel-ease);
}
@media (prefers-reduced-motion: reduce) {
.t-panel-slide-x,
.t-panel-track {
transition: none !important;
}
}
}
@layer components {
.t-page-enter {
animation: t-page-enter var(--duration-fast) var(--ease-smooth-out) both;
}
.t-page-enter[data-direction="back"] {
animation-name: t-page-enter-back;
}
@keyframes t-page-enter {
from {
opacity: 0;
transform: translateX(var(--distance-base));
filter: blur(var(--blur-medium));
}
to {
opacity: 1;
transform: translateX(0);
filter: blur(0);
}
}
@keyframes t-page-enter-back {
from {
opacity: 0;
transform: translateX(calc(var(--distance-base) * -1));
filter: blur(var(--blur-medium));
}
to {
opacity: 1;
transform: translateX(0);
filter: blur(0);
}
}
@media (prefers-reduced-motion: reduce) {
.t-page-enter {
animation: none;
}
}
}
+1
View File
@@ -60,6 +60,7 @@ app.post("/update-backup", zValidator("json", jobQueueSchema), async (c) => {
scheduleId: data.scheduleId,
type: "schedule",
cronSchedule: job.pattern || "",
timezone: job.tz || data.timezone,
});
} else if (data.type === "volume-backup") {
result = await removeJob({
+2 -1
View File
@@ -66,9 +66,10 @@ export const removeJob = async (data: QueueJob) => {
return result;
}
if (data.type === "schedule") {
const { scheduleId, cronSchedule } = data;
const { scheduleId, cronSchedule, timezone } = data;
const result = await jobQueue.removeRepeatable(scheduleId, {
pattern: cronSchedule,
tz: timezone || "UTC",
});
return result;
}
+26 -1
View File
@@ -7,6 +7,7 @@ import { organization } from "./account";
export const dnsProviderType = pgEnum("DnsProviderType", [
"cloudflare",
"route53",
"porkbun",
]);
export const cloudflareDnsConfigSchema = z.object({
@@ -20,9 +21,16 @@ export const route53DnsConfigSchema = z.object({
secretAccessKey: z.string().trim().min(1),
});
export const porkbunDnsConfigSchema = z.object({
providerType: z.literal("porkbun"),
apiKey: z.string().trim().min(1),
secretApiKey: z.string().trim().min(1),
});
export const dnsProviderConfigSchema = z.discriminatedUnion("providerType", [
cloudflareDnsConfigSchema,
route53DnsConfigSchema,
porkbunDnsConfigSchema,
]);
export type DnsProviderConfig = z.infer<typeof dnsProviderConfigSchema>;
@@ -96,11 +104,28 @@ export const apiListDnsRecords = z.object({
zoneId: z.string().min(1),
});
export const dnsRecordTypes = [
"A",
"AAAA",
"CNAME",
"MX",
"TXT",
"NS",
"SRV",
"CAA",
"PTR",
] as const;
export type DnsRecordType = (typeof dnsRecordTypes)[number];
export const proxiableDnsRecordTypes = ["A", "AAAA", "CNAME"] as const;
const dnsRecordFieldsSchema = z.object({
type: z.enum(["A", "CNAME"]),
type: z.enum(dnsRecordTypes),
name: z.string().min(1),
content: z.string().min(1),
ttl: z.number().int().positive().optional(),
proxied: z.boolean().optional(),
});
export const apiCreateDnsRecord = dnsRecordFieldsSchema.extend({
@@ -433,6 +433,7 @@ export const apiCreateGotify = notificationsSchema
name: true,
appDeploy: true,
dockerCleanup: true,
serverThreshold: true,
})
.extend({
serverUrl: z.string().min(1),
@@ -468,6 +469,7 @@ export const apiCreateNtfy = notificationsSchema
name: true,
appDeploy: true,
dockerCleanup: true,
serverThreshold: true,
})
.extend({
serverUrl: z.string().min(1),
@@ -11,6 +11,7 @@ export const vaultProviderType = pgEnum("VaultProviderType", [
"doppler",
"azure",
"scaleway",
"phase",
]);
export const hashicorpVaultConfigSchema = z.object({
@@ -62,6 +63,15 @@ export const scalewayVaultConfigSchema = z.object({
apiUrl: z.string().url().default("https://api.scaleway.com"),
});
export const phaseVaultConfigSchema = z.object({
providerType: z.literal("phase"),
token: z.string().min(1),
appId: z.string().min(1),
env: z.string().min(1),
path: z.string().default("/"),
apiUrl: z.string().url().default("https://api.phase.dev"),
});
export const vaultProviderConfigSchema = z.discriminatedUnion("providerType", [
hashicorpVaultConfigSchema,
infisicalVaultConfigSchema,
@@ -69,6 +79,7 @@ export const vaultProviderConfigSchema = z.discriminatedUnion("providerType", [
dopplerVaultConfigSchema,
azureVaultConfigSchema,
scalewayVaultConfigSchema,
phaseVaultConfigSchema,
]);
export type VaultProviderConfig = z.infer<typeof vaultProviderConfigSchema>;
+18 -4
View File
@@ -125,10 +125,24 @@ export const findComposeById = async (composeId: string) => {
deployments: true,
mounts: true,
domains: true,
github: true,
gitlab: true,
bitbucket: true,
gitea: true,
github: {
columns: {
githubClientSecret: false,
githubPrivateKey: false,
githubWebhookSecret: false,
},
},
gitlab: {
columns: { secret: false, accessToken: false, refreshToken: false },
},
bitbucket: { columns: { appPassword: false, apiToken: false } },
gitea: {
columns: {
clientSecret: false,
accessToken: false,
refreshToken: false,
},
},
server: true,
backups: {
with: {
@@ -17,6 +17,7 @@ export const DNS_SECRET_MASK = "********";
const SENSITIVE_FIELDS: Record<DnsProviderConfig["providerType"], string[]> = {
cloudflare: ["apiToken"],
route53: ["secretAccessKey"],
porkbun: ["secretApiKey"],
};
export const maskDnsProviderConfig = (
+27 -2
View File
@@ -124,8 +124,12 @@ export const getAccessibleGitProviderIds = async (session: {
* Authorizes read access to a specific git provider for the current session.
* Throws if the provider belongs to a different organization (cross-org IDOR)
* or if the caller is not entitled to it within the active organization.
* Must be called before returning any git-provider record that carries secrets
* (OAuth tokens, app private keys, webhook secrets).
*
* This only proves the caller may *use* the provider (e.g. pick it as a repo
* source when creating a deploy) - it does NOT mean they may see its raw
* credentials. Being able to use a shared provider and being able to read its
* OAuth tokens / client secrets / private keys are different privileges; gate
* the latter with canViewGitProviderSecrets before returning secret fields.
*/
export const assertGitProviderAccess = async (
session: { userId: string; activeOrganizationId: string },
@@ -146,3 +150,24 @@ export const assertGitProviderAccess = async (
});
}
};
// Being allowed to use a shared provider (assertGitProviderAccess) must not
// imply being allowed to read its raw OAuth tokens / client secrets / private
// keys. Only the provider's owner or an org owner/admin gets those back.
export const canViewGitProviderSecrets = async (
session: { userId: string; activeOrganizationId: string },
provider: { userId: string; organizationId: string },
): Promise<boolean> => {
if (provider.organizationId !== session.activeOrganizationId) return false;
if (provider.userId === session.userId) return true;
const memberRecord = await db.query.member.findFirst({
where: and(
eq(member.userId, session.userId),
eq(member.organizationId, session.activeOrganizationId),
),
columns: { role: true },
});
return memberRecord?.role === "owner" || memberRecord?.role === "admin";
};
+5 -1
View File
@@ -7,7 +7,10 @@ import {
} from "@dokploy/server/db/schema";
import { generatePassword } from "@dokploy/server/templates";
import { buildLibsql } from "@dokploy/server/utils/databases/libsql";
import { pullImage } from "@dokploy/server/utils/docker/utils";
import {
pullImage,
waitForSwarmServiceConvergence,
} from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { eq, getTableColumns } from "drizzle-orm";
@@ -149,6 +152,7 @@ export const deployLibsql = async (
}
await buildLibsql(libsql);
await waitForSwarmServiceConvergence(libsql.appName, libsql.serverId);
await updateLibsqlById(libsqlId, {
applicationStatus: "done",
});
+5 -1
View File
@@ -7,7 +7,10 @@ import {
} from "@dokploy/server/db/schema";
import { generatePassword } from "@dokploy/server/templates";
import { buildMariadb } from "@dokploy/server/utils/databases/mariadb";
import { pullImage } from "@dokploy/server/utils/docker/utils";
import {
pullImage,
waitForSwarmServiceConvergence,
} from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { eq, getTableColumns } from "drizzle-orm";
@@ -154,6 +157,7 @@ export const deployMariadb = async (
}
await buildMariadb(mariadb);
await waitForSwarmServiceConvergence(mariadb.appName, mariadb.serverId);
await updateMariadbById(mariadbId, {
applicationStatus: "done",
});
+5 -1
View File
@@ -8,7 +8,10 @@ import {
} from "@dokploy/server/db/schema";
import { generatePassword } from "@dokploy/server/templates";
import { buildMongo } from "@dokploy/server/utils/databases/mongo";
import { pullImage } from "@dokploy/server/utils/docker/utils";
import {
pullImage,
waitForSwarmServiceConvergence,
} from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { eq, getTableColumns } from "drizzle-orm";
@@ -169,6 +172,7 @@ export const deployMongo = async (
}
await buildMongo(mongo);
await waitForSwarmServiceConvergence(mongo.appName, mongo.serverId);
await updateMongoById(mongoId, {
applicationStatus: "done",
});
+25
View File
@@ -23,6 +23,31 @@ import type { z } from "zod";
export type Mount = typeof mounts.$inferSelect;
// Absolute host paths a "bind" mount must never be allowed to target for a
// non-owner/admin caller - each one gives a way off the container onto the
// Dokploy control host (docker.sock -> full daemon control, /etc/dokploy ->
// Dokploy's own config/certs, /root //boot/proc/sys -> host takeover).
const DANGEROUS_HOST_PATH_PREFIXES = [
"/var/run/docker.sock",
"/run/docker.sock",
"/var/lib/docker",
"/etc/dokploy",
"/root",
"/boot",
"/proc",
"/sys",
];
export const isDangerousBindMountPath = (hostPath: string): boolean => {
// Resolve against "/" so relative segments and ".." traversal tricks
// (e.g. "../../var/run/docker.sock") can't sneak past the prefix check.
const normalized = path.posix.resolve("/", hostPath.trim());
if (normalized === "/") return true;
return DANGEROUS_HOST_PATH_PREFIXES.some(
(prefix) => normalized === prefix || normalized.startsWith(`${prefix}/`),
);
};
export const createMount = async (input: z.infer<typeof apiCreateMount>) => {
try {
const { serviceId, ...rest } = input;
+5 -1
View File
@@ -7,7 +7,10 @@ import {
} from "@dokploy/server/db/schema";
import { generatePassword } from "@dokploy/server/templates";
import { buildMysql } from "@dokploy/server/utils/databases/mysql";
import { pullImage } from "@dokploy/server/utils/docker/utils";
import {
pullImage,
waitForSwarmServiceConvergence,
} from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { eq, getTableColumns } from "drizzle-orm";
@@ -152,6 +155,7 @@ export const deployMySql = async (
}
await buildMysql(mysql);
await waitForSwarmServiceConvergence(mysql.appName, mysql.serverId);
await updateMySqlById(mysqlId, {
applicationStatus: "done",
});
@@ -561,6 +561,7 @@ export const createGotifyNotification = async (
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
serverThreshold: input.serverThreshold,
notificationType: "gotify",
organizationId: organizationId,
})
@@ -593,6 +594,7 @@ export const updateGotifyNotification = async (
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
serverThreshold: input.serverThreshold,
organizationId: input.organizationId,
})
.where(eq(notifications.notificationId, input.notificationId))
@@ -655,6 +657,7 @@ export const createNtfyNotification = async (
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
serverThreshold: input.serverThreshold,
notificationType: "ntfy",
organizationId: organizationId,
})
@@ -687,6 +690,7 @@ export const updateNtfyNotification = async (
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
serverThreshold: input.serverThreshold,
organizationId: input.organizationId,
})
.where(eq(notifications.notificationId, input.notificationId))
@@ -434,3 +434,22 @@ export const findMemberByUserId = async (
}
return result;
};
// Some actions (e.g. a bind mount targeting a sensitive host path) must stay
// restricted to the org's owner/admin even if a custom role was granted the
// underlying resource permission (e.g. volume:create) - that permission only
// covers ordinary use, not host takeover.
export const isPrivilegedOrgRole = async (session: {
userId: string;
activeOrganizationId: string;
}): Promise<boolean> => {
const memberRecord = await db.query.member.findFirst({
where: and(
eq(member.userId, session.userId),
eq(member.organizationId, session.activeOrganizationId),
),
columns: { role: true },
});
return memberRecord?.role === "owner" || memberRecord?.role === "admin";
};
+6 -1
View File
@@ -7,7 +7,10 @@ import {
} from "@dokploy/server/db/schema";
import { generatePassword } from "@dokploy/server/templates";
import { buildPostgres } from "@dokploy/server/utils/databases/postgres";
import { pullImage } from "@dokploy/server/utils/docker/utils";
import {
pullImage,
waitForSwarmServiceConvergence,
} from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { eq, getTableColumns } from "drizzle-orm";
@@ -165,6 +168,8 @@ export const deployPostgres = async (
await buildPostgres(postgres);
await waitForSwarmServiceConvergence(postgres.appName, postgres.serverId);
await updatePostgresById(postgresId, {
applicationStatus: "done",
});
+5 -1
View File
@@ -6,7 +6,10 @@ import {
} from "@dokploy/server/db/schema";
import { generatePassword } from "@dokploy/server/templates";
import { buildRedis } from "@dokploy/server/utils/databases/redis";
import { pullImage } from "@dokploy/server/utils/docker/utils";
import {
pullImage,
waitForSwarmServiceConvergence,
} from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
@@ -119,6 +122,7 @@ export const deployRedis = async (
}
await buildRedis(redis);
await waitForSwarmServiceConvergence(redis.appName, redis.serverId);
await updateRedisById(redisId, {
applicationStatus: "done",
});
+91
View File
@@ -134,6 +134,97 @@ export const haveActiveServices = async (serverId: string) => {
return true;
};
export const SERVICE_TYPES_BY_SERVER = [
{ type: "application", relation: "applications", idColumn: "applicationId" },
{ type: "compose", relation: "compose", idColumn: "composeId" },
{ type: "postgres", relation: "postgres", idColumn: "postgresId" },
{ type: "mysql", relation: "mysql", idColumn: "mysqlId" },
{ type: "mariadb", relation: "mariadb", idColumn: "mariadbId" },
{ type: "mongo", relation: "mongo", idColumn: "mongoId" },
{ type: "redis", relation: "redis", idColumn: "redisId" },
{ type: "libsql", relation: "libsql", idColumn: "libsqlId" },
] as const;
export interface ServerService {
id: string;
type: (typeof SERVICE_TYPES_BY_SERVER)[number]["type"];
name: string;
projectId: string;
environmentId: string;
url: string;
}
export const getServicesByServerId = async (
serverId: string,
): Promise<ServerService[]> => {
const currentServer = await db.query.server.findFirst({
where: eq(server.serverId, serverId),
columns: { serverId: true },
with: {
applications: {
columns: { applicationId: true, name: true },
with: { environment: { with: { project: true } } },
},
compose: {
columns: { composeId: true, name: true },
with: { environment: { with: { project: true } } },
},
postgres: {
columns: { postgresId: true, name: true },
with: { environment: { with: { project: true } } },
},
mysql: {
columns: { mysqlId: true, name: true },
with: { environment: { with: { project: true } } },
},
mariadb: {
columns: { mariadbId: true, name: true },
with: { environment: { with: { project: true } } },
},
mongo: {
columns: { mongoId: true, name: true },
with: { environment: { with: { project: true } } },
},
redis: {
columns: { redisId: true, name: true },
with: { environment: { with: { project: true } } },
},
libsql: {
columns: { libsqlId: true, name: true },
with: { environment: { with: { project: true } } },
},
},
});
if (!currentServer) {
return [];
}
const services: ServerService[] = [];
for (const { type, relation, idColumn } of SERVICE_TYPES_BY_SERVER) {
const rows = currentServer[relation as keyof typeof currentServer] as Array<
Record<string, any>
>;
for (const row of rows) {
const projectId = row.environment.project.projectId as string;
const environmentId = row.environment.environmentId as string;
services.push({
id: row[idColumn],
type,
name: row.name,
projectId,
environmentId,
url: `/dashboard/project/${projectId}/environment/${environmentId}/services/${type}/${row[idColumn]}`,
});
}
}
return services;
};
export const updateServerById = async (
serverId: string,
serverData: Partial<Server>,
@@ -23,6 +23,7 @@ const SENSITIVE_FIELDS: Record<VaultProviderConfig["providerType"], string[]> =
doppler: ["serviceToken"],
azure: ["clientSecret"],
scaleway: ["secretKey"],
phase: ["token"],
};
export const maskVaultProviderConfig = (
@@ -22,7 +22,10 @@ export const getBuildComposeCommand = async (rawCompose: ComposeNested) => {
const { COMPOSE_PATH } = paths(!!compose.serverId);
const { sourceType, appName, mounts, composeType, domains } = compose;
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
const command = createCommand(compose, projectPath);
const command = createCommand(
compose,
mounts.length > 0 ? projectPath : undefined,
);
const envCommand = compose.createEnvFile
? getCreateEnvFileCommand(compose)
: "";
@@ -133,7 +136,10 @@ export const createCommand = (compose: ComposeNested, projectPath?: string) => {
const projectDirectoryFlag = projectPath
? `--project-directory ${quote([projectPath])} `
: "";
command = `compose -p ${quote([appName])} ${projectDirectoryFlag}-f ${quote([path])} up -d --build --remove-orphans`;
const envFileFlag = compose.createEnvFile
? `--env-file ${quote([join(dirname(compose.composePath || "docker-compose.yml"), ".env")])} `
: "";
command = `compose -p ${quote([appName])} ${projectDirectoryFlag}${envFileFlag}-f ${quote([path])} up -d --build --remove-orphans`;
} else if (composeType === "stack") {
command = `stack deploy -c ${quote([path])} ${quote([appName])} --prune --with-registry-auth`;
}
+88 -10
View File
@@ -1,4 +1,7 @@
import type { cloudflareDnsConfigSchema } from "@dokploy/server/db/schema";
import {
type cloudflareDnsConfigSchema,
proxiableDnsRecordTypes,
} from "@dokploy/server/db/schema";
import type { z } from "zod";
import { type DnsClient, dnsFetch } from "./types";
@@ -13,6 +16,68 @@ type CloudflareResponse<T> = {
const CLOUDFLARE_API = "https://api.cloudflare.com/client/v4";
const inlinePriority = (record: {
type: string;
content: string;
priority?: number;
}) =>
record.type === "MX" && typeof record.priority === "number"
? `${record.priority} ${record.content}`
: record.content;
const proxySettings = (record: { type: string; proxied?: boolean }) =>
record.proxied !== undefined &&
(proxiableDnsRecordTypes as readonly string[]).includes(record.type)
? { proxied: record.proxied }
: {};
const buildValue = (record: { type: string; content: string }) => {
const value = record.content.trim();
if (record.type === "MX") {
const match = /^(\d+)\s+(\S.*)$/.exec(value);
return match
? { content: match[2] as string, priority: Number(match[1]) }
: { content: value, priority: 10 };
}
if (record.type === "SRV") {
const parts = value.split(/\s+/);
const [priority, weight, port, target] = parts;
if (parts.length !== 4 || !target) {
throw new Error(
`Cloudflare: an SRV value must be "priority weight port target", got "${value}"`,
);
}
return {
data: {
priority: Number(priority),
weight: Number(weight),
port: Number(port),
target,
},
};
}
if (record.type === "CAA") {
const match = /^(\d+)\s+(\S+)\s+"?([^"]+)"?$/.exec(value);
if (!match) {
throw new Error(
`Cloudflare: a CAA value must be \`flags tag "value"\`, got "${value}"`,
);
}
return {
data: {
flags: Number(match[1]),
tag: match[2] as string,
value: match[3] as string,
},
};
}
return { content: value };
};
const cfFetch = async <T>(
config: CloudflareConfig,
path: string,
@@ -72,9 +137,20 @@ export const cloudflareClient: DnsClient<CloudflareConfig> = {
name: string;
content: string;
ttl: number;
priority?: number;
proxied?: boolean;
}[]
>(config, `/zones/${zoneId}/dns_records?per_page=50&page=${page}`);
records.push(...result);
records.push(
...result.map((record) => ({
id: record.id,
type: record.type,
name: record.name,
content: inlinePriority(record),
ttl: record.ttl,
proxied: record.proxied,
})),
);
if (result.length < 50) {
break;
}
@@ -84,18 +160,19 @@ export const cloudflareClient: DnsClient<CloudflareConfig> = {
},
async upsertRecord(config, record) {
const payload = {
type: record.type,
name: record.name,
...buildValue(record),
...proxySettings(record),
ttl: record.ttl ?? 1,
};
const existing = await cfFetch<{ id: string }[]>(
config,
`/zones/${record.zoneId}/dns_records?type=${record.type}&name=${encodeURIComponent(record.name)}`,
);
const payload = {
type: record.type,
name: record.name,
content: record.content,
ttl: record.ttl ?? 1,
};
const existingRecord = existing[0];
if (existingRecord) {
const updated = await cfFetch<{ id: string }>(
@@ -123,7 +200,8 @@ export const cloudflareClient: DnsClient<CloudflareConfig> = {
body: JSON.stringify({
type: record.type,
name: record.name,
content: record.content,
...buildValue(record),
...proxySettings(record),
ttl: record.ttl ?? 1,
}),
},
+2
View File
@@ -1,11 +1,13 @@
import type { DnsProviderConfig } from "@dokploy/server/db/schema";
import { cloudflareClient } from "./cloudflare";
import { porkbunClient } from "./porkbun";
import { route53Client } from "./route53";
import type { DnsClient } from "./types";
const clients: Record<DnsProviderConfig["providerType"], DnsClient> = {
cloudflare: cloudflareClient as DnsClient,
route53: route53Client as DnsClient,
porkbun: porkbunClient as DnsClient,
};
export const getDnsClient = (providerType: DnsProviderConfig["providerType"]) =>
+134
View File
@@ -0,0 +1,134 @@
import type { porkbunDnsConfigSchema } from "@dokploy/server/db/schema";
import type { z } from "zod";
import { type DnsClient, dnsFetch } from "./types";
type PorkbunConfig = z.infer<typeof porkbunDnsConfigSchema>;
type PorkbunResponse<T> = T & {
status: "SUCCESS" | "ERROR";
message?: string;
};
const PORKBUN_API = "https://api.porkbun.com/api/json/v3";
const pbFetch = async <T>(
config: PorkbunConfig,
path: string,
body: Record<string, unknown> = {},
): Promise<PorkbunResponse<T>> => {
const response = await dnsFetch(`${PORKBUN_API}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
apikey: config.apiKey,
secretapikey: config.secretApiKey,
...body,
}),
});
const result = (await response.json()) as PorkbunResponse<T>;
if (!response.ok || result.status !== "SUCCESS") {
throw new Error(
`Porkbun: request to ${path} failed${
result.message ? `: ${result.message}` : ` (status ${response.status})`
}`,
);
}
return result;
};
// Porkbun's "name" only accepts the subdomain portion, without the zone (domain) itself.
const toSubdomain = (name: string, domain: string) => {
if (name === domain) {
return "";
}
const suffix = `.${domain}`;
return name.endsWith(suffix) ? name.slice(0, -suffix.length) : name;
};
interface PorkbunRecord {
id: string;
name: string;
type: string;
content: string;
ttl: string;
prio: string;
notes: string;
}
export const porkbunClient: DnsClient<PorkbunConfig> = {
async listZones(config) {
const result = await pbFetch<{ domains: { domain: string }[] }>(
config,
"/domain/listAll",
);
return result.domains.map((domain) => ({
id: domain.domain,
name: domain.domain,
}));
},
async listRecords(config, zoneId) {
const result = await pbFetch<{ records: PorkbunRecord[] }>(
config,
`/dns/retrieve/${zoneId}`,
);
return result.records.map((record) => ({
id: record.id,
type: record.type,
name: record.name,
content: record.content,
ttl: Number(record.ttl),
}));
},
async upsertRecord(config, record) {
const subdomain = toSubdomain(record.name, record.zoneId);
const existing = await pbFetch<{ records: PorkbunRecord[] }>(
config,
`/dns/retrieveByNameType/${record.zoneId}/${record.type}/${subdomain}`,
);
const payload = {
name: subdomain,
type: record.type,
content: record.content,
ttl: record.ttl ?? 600,
};
const existingRecord = existing.records[0];
if (existingRecord) {
await pbFetch(
config,
`/dns/edit/${record.zoneId}/${existingRecord.id}`,
payload,
);
return { id: existingRecord.id };
}
const created = await pbFetch<{ id: string }>(
config,
`/dns/create/${record.zoneId}`,
payload,
);
return { id: created.id };
},
async updateRecord(config, zoneId, recordId, record) {
await pbFetch(config, `/dns/edit/${zoneId}/${recordId}`, {
name: toSubdomain(record.name, zoneId),
type: record.type,
content: record.content,
ttl: record.ttl ?? 600,
});
return { id: recordId };
},
async deleteRecord(config, zoneId, recordId) {
await pbFetch(config, `/dns/delete/${zoneId}/${recordId}`);
},
async testConnection(config) {
await pbFetch(config, "/ping");
},
};
+30 -8
View File
@@ -5,7 +5,10 @@ import {
type ResourceRecordSet,
Route53Client,
} from "@aws-sdk/client-route-53";
import type { route53DnsConfigSchema } from "@dokploy/server/db/schema";
import type {
DnsRecordType,
route53DnsConfigSchema,
} from "@dokploy/server/db/schema";
import type { z } from "zod";
import type { DnsClient } from "./types";
@@ -65,16 +68,23 @@ const findExactRecordSet = async (
return undefined;
};
const formatValue = (type: DnsRecordType, value: string) =>
type === "TXT" && !value.startsWith('"') ? JSON.stringify(value) : value;
const buildRecordSet = (record: {
type: "A" | "CNAME";
type: DnsRecordType;
name: string;
content: string;
ttl?: number;
}): ResourceRecordSet => ({
Name: ensureTrailingDot(record.name),
Type: record.type,
Type: record.type as ResourceRecordSet["Type"],
TTL: record.ttl ?? 300,
ResourceRecords: [{ Value: record.content }],
ResourceRecords: record.content
.split("\n")
.map((value) => value.trim())
.filter(Boolean)
.map((value) => ({ Value: formatValue(record.type, value) })),
});
export const route53Client: DnsClient<Route53Config> = {
@@ -126,7 +136,7 @@ export const route53Client: DnsClient<Route53Config> = {
id: buildRecordId(set.Type, set.Name),
type: set.Type,
name: stripTrailingDot(set.Name),
content: set.ResourceRecords.map((r) => r.Value).join(", "),
content: set.ResourceRecords.map((r) => r.Value).join("\n"),
ttl: set.TTL ?? 300,
});
}
@@ -138,13 +148,25 @@ export const route53Client: DnsClient<Route53Config> = {
async upsertRecord(config, record) {
const client = createClient(config);
const existing = await findExactRecordSet(
config,
record.zoneId,
record.type,
record.name,
);
const recordSet = buildRecordSet(record);
if (existing?.ResourceRecords?.length) {
const values = new Set([
...existing.ResourceRecords.map((r) => r.Value),
...(recordSet.ResourceRecords ?? []).map((r) => r.Value),
]);
recordSet.ResourceRecords = [...values].map((Value) => ({ Value }));
}
await client.send(
new ChangeResourceRecordSetsCommand({
HostedZoneId: record.zoneId,
ChangeBatch: {
Changes: [
{ Action: "UPSERT", ResourceRecordSet: buildRecordSet(record) },
],
Changes: [{ Action: "UPSERT", ResourceRecordSet: recordSet }],
},
}),
);
+7 -2
View File
@@ -1,4 +1,7 @@
import type { DnsProviderConfig } from "@dokploy/server/db/schema";
import type {
DnsProviderConfig,
DnsRecordType,
} from "@dokploy/server/db/schema";
export interface DnsZone {
id: string;
@@ -7,10 +10,11 @@ export interface DnsZone {
export interface DnsRecordInput {
zoneId: string;
type: "A" | "CNAME";
type: DnsRecordType;
name: string;
content: string;
ttl?: number;
proxied?: boolean;
}
export interface DnsRecord {
@@ -19,6 +23,7 @@ export interface DnsRecord {
name: string;
content: string;
ttl: number;
proxied?: boolean;
}
export interface DnsClient<C extends DnsProviderConfig = DnsProviderConfig> {
+51
View File
@@ -934,6 +934,57 @@ const getSwarmServiceContainerId = async (
}
};
export class ServiceConvergenceError extends Error {}
export const waitForSwarmServiceConvergence = async (
appName: string,
serverId?: string | null,
options?: { timeoutMs?: number; intervalMs?: number },
): Promise<void> => {
const timeoutMs = options?.timeoutMs ?? 45_000;
const intervalMs = options?.intervalMs ?? 2_000;
const remoteDocker = await getRemoteDocker(serverId);
const service = remoteDocker.getService(appName);
const deadline = Date.now() + timeoutMs;
let lastState = "unknown";
while (true) {
const info = await service.inspect();
const desiredTasksCount = info.Spec?.Mode?.Replicated?.Replicas ?? 1;
const tasks = await remoteDocker.listTasks({
filters: JSON.stringify({ service: [appName] }),
});
const currentTasks = tasks.filter(
(task) => task.DesiredState === "running",
);
const runningTasksCount = currentTasks.filter(
(task) => task.Status?.State === "running",
).length;
if (runningTasksCount >= desiredTasksCount) {
return;
}
const failedTask = currentTasks.find((task) =>
["failed", "rejected"].includes(task.Status?.State ?? ""),
);
lastState =
failedTask?.Status?.Err ??
failedTask?.Status?.State ??
currentTasks[0]?.Status?.State ??
lastState;
if (Date.now() >= deadline) {
throw new ServiceConvergenceError(
`Service ${appName} did not converge within ${timeoutMs}ms: ${runningTasksCount}/${desiredTasksCount} tasks running (last state: ${lastState})`,
);
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
};
export const checkPostgresHealth = async (): Promise<ServiceHealthStatus> => {
const serviceCheck = await checkSwarmServiceRunning("dokploy-postgres");
if (serviceCheck.status === "unhealthy") {
@@ -369,22 +369,28 @@ export const sendServerThresholdNotifications = async (
`Server: ${payload.ServerName}\nType: ${payload.Type}\nCurrent: ${payload.Value.toFixed(2)}%\nThreshold: ${payload.Threshold.toFixed(2)}%\nMessage: ${payload.Message}\nTime: ${date.toLocaleString()}`,
);
}
if (teams) {
await sendTeamsNotification(teams, {
title: `⚠️ Server ${payload.Type} Alert`,
facts: [
{ name: "Server Name", value: payload.ServerName },
{ name: "Type", value: payload.Type },
{
name: "Current Value",
value: `${payload.Value.toFixed(2)}%`,
},
{
name: "Threshold",
value: `${payload.Threshold.toFixed(2)}%`,
},
{ name: "Time", value: date.toLocaleString() },
{ name: "Message", value: payload.Message },
],
});
}
} catch (error) {
console.log(error);
}
if (teams) {
await sendTeamsNotification(teams, {
title: `⚠️ Server ${payload.Type} Alert`,
facts: [
{ name: "Server Name", value: payload.ServerName },
{ name: "Type", value: payload.Type },
{ name: "Current Value", value: `${payload.Value.toFixed(2)}%` },
{ name: "Threshold", value: `${payload.Threshold.toFixed(2)}%` },
{ name: "Time", value: date.toLocaleString() },
{ name: "Message", value: payload.Message },
],
});
}
}
};
+76 -4
View File
@@ -4,6 +4,20 @@ import { findServerById } from "@dokploy/server/services/server";
import { Client } from "ssh2";
import { ExecError } from "./ExecError";
export class WriteFileRemoteError extends Error {
constructor(
message: string,
public readonly context: {
remotePath: string;
serverId: string;
originalError: Error;
},
) {
super(message);
this.name = "WriteFileRemoteError";
}
}
// Re-export ExecError for easier imports
export { ExecError } from "./ExecError";
@@ -21,11 +35,11 @@ export const execAsync = async (
};
} catch (error) {
if (error instanceof Error) {
// @ts-ignore - exec error has these properties
// @ts-expect-error - exec error has these properties
const exitCode = error.code;
// @ts-ignore
// @ts-expect-error
const stdout = error.stdout?.toString() || "";
// @ts-ignore
// @ts-expect-error
const stderr = error.stderr?.toString() || "";
throw new ExecError(`Command execution failed: ${error.message}`, {
@@ -61,7 +75,6 @@ export const execAsyncStream = (
command,
stdout: stdoutComplete,
stderr: stderrComplete,
// @ts-ignore
exitCode: error.code,
originalError: error,
}),
@@ -249,6 +262,65 @@ export const execAsyncRemote = async (
});
};
export const writeFileRemote = async (
serverId: string,
remotePath: string,
content: string,
): Promise<void> => {
const server = await findServerById(serverId);
if (!server.sshKeyId) throw new Error("No SSH key available for this server");
return new Promise((resolve, reject) => {
const conn = new Client();
conn
.once("ready", () => {
conn.sftp((err, sftp) => {
if (err) {
conn.end();
reject(
new WriteFileRemoteError(`SFTP session failed: ${err.message}`, {
remotePath,
serverId,
originalError: err,
}),
);
return;
}
sftp.writeFile(remotePath, content, (writeErr) => {
conn.end();
if (writeErr) {
reject(
new WriteFileRemoteError(
`Failed to write remote file ${remotePath}: ${writeErr.message}`,
{ remotePath, serverId, originalError: writeErr },
),
);
return;
}
resolve();
});
});
})
.on("error", (err) => {
conn.end();
reject(
new WriteFileRemoteError(`SSH connection error: ${err.message}`, {
remotePath,
serverId,
originalError: err,
}),
);
})
.connect({
host: server.ipAddress,
port: server.port,
username: server.username,
privateKey: server.sshKey?.privateKey,
timeout: 99999,
});
});
};
export const sleep = (ms: number) => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
@@ -5,8 +5,11 @@ import { paths } from "@dokploy/server/constants";
import type { Domain } from "@dokploy/server/services/domain";
import { quote } from "shell-quote";
import { parse, stringify } from "yaml";
import { encodeBase64 } from "../docker/utils";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import {
execAsync,
execAsyncRemote,
writeFileRemote,
} from "../process/execAsync";
import type { FileConfig, HttpLoadBalancerService } from "./file-types";
export const createTraefikConfig = (appName: string) => {
@@ -228,11 +231,7 @@ export const writeConfigRemote = async (
try {
const { DYNAMIC_TRAEFIK_PATH } = paths(true);
const configPath = path.join(DYNAMIC_TRAEFIK_PATH, `${appName}.yml`);
const encoded = encodeBase64(traefikConfig);
await execAsyncRemote(
serverId,
`echo "${encoded}" | base64 -d > ${quote([configPath])}`,
);
await writeFileRemote(serverId, configPath, traefikConfig);
} catch (e) {
console.error("Error saving the YAML config file:", e);
}
@@ -246,11 +245,7 @@ export const writeTraefikConfigInPath = async (
try {
const configPath = path.join(pathFile);
if (serverId) {
const encoded = encodeBase64(traefikConfig);
await execAsyncRemote(
serverId,
`echo "${encoded}" | base64 -d > ${quote([configPath])}`,
);
await writeFileRemote(serverId, configPath, traefikConfig);
} else {
fs.writeFileSync(configPath, traefikConfig, "utf8");
}
@@ -282,11 +277,7 @@ export const writeTraefikConfigRemote = async (
const { DYNAMIC_TRAEFIK_PATH } = paths(true);
const configPath = path.join(DYNAMIC_TRAEFIK_PATH, `${appName}.yml`);
const yamlStr = stringify(traefikConfig);
const encoded = encodeBase64(yamlStr);
await execAsyncRemote(
serverId,
`echo "${encoded}" | base64 -d > ${quote([configPath])}`,
);
await writeFileRemote(serverId, configPath, yamlStr);
} catch (e) {
console.error("Error saving the YAML config file:", e);
}
Binary file not shown.
+106
View File
@@ -0,0 +1,106 @@
import type { phaseVaultConfigSchema } from "@dokploy/server/db/schema";
import type { z } from "zod";
import { type VaultClient, vaultFetch } from "./types";
type PhaseConfig = z.infer<typeof phaseVaultConfigSchema>;
type PhaseSecret = {
key: string;
value: string;
path?: string;
};
const baseUrl = (config: PhaseConfig) => config.apiUrl.replace(/\/+$/, "");
const authHeaders = (config: PhaseConfig) => ({
Authorization: `Bearer ServiceAccount ${config.token}`,
Accept: "application/json",
});
const parseErrorDetail = async (response: Response) => {
try {
const body = (await response.json()) as {
error?: string;
message?: string;
};
return body.error ?? body.message ?? "";
} catch {
return "";
}
};
const request = async (
config: PhaseConfig,
path: string,
params?: Record<string, string>,
) => {
const url = new URL(`${baseUrl(config)}${path}`);
if (params) {
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
}
const response = await vaultFetch(url.toString(), {
headers: authHeaders(config),
});
if (response.ok) {
return response;
}
const detail = await parseErrorDetail(response);
const reason =
response.status === 401 || response.status === 403
? "authentication failed"
: "request failed";
throw new Error(
`Phase: ${reason} (status ${response.status}${detail ? `: ${detail}` : ""})`,
);
};
const fetchSecrets = async (config: PhaseConfig) => {
const response = await request(config, "/v1/secrets/", {
app_id: config.appId,
env: config.env,
path: config.path || "/",
});
const body = (await response.json()) as PhaseSecret[];
const secrets: Record<string, string> = {};
for (const secret of body ?? []) {
secrets[secret.key] = secret.value;
}
return secrets;
};
export const phaseClient: VaultClient<PhaseConfig> = {
async getSecrets(config, refs) {
const secrets = await fetchSecrets(config);
const result: Record<string, string> = {};
for (const ref of refs) {
if (secrets[ref] === undefined) {
throw new Error(
`Phase: secret "${ref}" not found in environment "${config.env}"`,
);
}
result[ref] = secrets[ref];
}
return result;
},
async testConnection(config) {
const appResponse = await request(config, `/v1/apps/${config.appId}/`);
const app = (await appResponse.json()) as { sseEnabled?: boolean };
if (app.sseEnabled === false) {
throw new Error(
"Phase: enable Server-side Encryption (SSE) on this Phase App to use the REST secrets API",
);
}
await fetchSecrets(config);
},
async listSecretNames(config) {
const secrets = await fetchSecrets(config);
return Object.keys(secrets);
},
};