diff --git a/.github/workflows/dokploy.yml b/.github/workflows/dokploy.yml index 529cd8f7fa..5429446114 100644 --- a/.github/workflows/dokploy.yml +++ b/.github/workflows/dokploy.yml @@ -138,6 +138,8 @@ jobs: needs: [combine-manifests] if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest + outputs: + version: ${{ steps.get_version.outputs.version }} steps: - name: Checkout uses: actions/checkout@v4 @@ -160,3 +162,80 @@ jobs: prerelease: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + sync-version: + needs: [generate-release] + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Sync version to MCP repository + run: | + git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo + cd /tmp/mcp-repo + + jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp + mv package.json.tmp package.json + + npm install -g pnpm + pnpm install + pnpm run fetch-openapi + pnpm run generate + + git config user.name "Dokploy Bot" + git config user.email "bot@dokploy.com" + git add -A + git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \ + -m "Source: ${{ github.repository }}@${{ github.sha }}" \ + --allow-empty + git push + + echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.version }}" + + - name: Sync version to CLI repository + run: | + git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo + cd /tmp/cli-repo + + jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp + mv package.json.tmp package.json + + cp ${{ github.workspace }}/openapi.json ./openapi.json + npm install -g pnpm + pnpm install + pnpm run generate + + git config user.name "Dokploy Bot" + git config user.email "bot@dokploy.com" + git add -A + git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \ + -m "Source: ${{ github.repository }}@${{ github.sha }}" \ + --allow-empty + git push + + echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.version }}" + + - name: Sync version to SDK repository + run: | + git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo + cd /tmp/sdk-repo + + jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp + mv package.json.tmp package.json + + cp ${{ github.workspace }}/openapi.json ./openapi.json + npm install -g pnpm + pnpm install + pnpm run generate + + git config user.name "Dokploy Bot" + git config user.email "bot@dokploy.com" + git add -A + git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \ + -m "Source: ${{ github.repository }}@${{ github.sha }}" \ + --allow-empty + git push + + echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.version }}" diff --git a/.github/workflows/sync-version.yml b/.github/workflows/sync-version.yml deleted file mode 100644 index 5e8ccb7067..0000000000 --- a/.github/workflows/sync-version.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Sync version to MCP and CLI repos - -on: - release: - types: [published] - push: - tags: - - 'v*' - workflow_dispatch: - -jobs: - sync-version: - name: Sync version to external repos - runs-on: ubuntu-latest - steps: - - name: Checkout Dokploy repository - uses: actions/checkout@v4 - - - name: Get version - id: get_version - run: | - VERSION=$(jq -r .version apps/dokploy/package.json | sed 's/^v//') - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "Version: $VERSION" - - - name: Sync version to MCP repository - run: | - git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo - cd /tmp/mcp-repo - - # Regenerate tools from latest OpenAPI spec - npm install -g pnpm - pnpm install - pnpm run fetch-openapi - pnpm run generate - - # Bump version after install so pnpm install doesn't overwrite it - jq --arg v "${{ steps.get_version.outputs.version }}" '.version = $v' package.json > package.json.tmp - mv package.json.tmp package.json - - git config user.name "Dokploy Bot" - git config user.email "bot@dokploy.com" - - git add -A - git commit -m "chore: bump version to ${{ steps.get_version.outputs.version }}" \ - -m "Source: ${{ github.repository }}@${{ github.sha }}" \ - -m "Release: ${{ github.event.release.html_url }}" \ - --allow-empty - - git push - - - - name: Sync version to CLI repository - run: | - git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo - - cd /tmp/cli-repo - - # Copy latest openapi spec and regenerate commands - cp ${{ github.workspace }}/openapi.json ./openapi.json - npm install -g pnpm - pnpm install - pnpm run generate - - # Bump version after install so pnpm install doesn't overwrite it - if [ -f package.json ]; then - jq --arg v "${{ steps.get_version.outputs.version }}" '.version = $v' package.json > package.json.tmp - mv package.json.tmp package.json - fi - - git config user.name "Dokploy Bot" - git config user.email "bot@dokploy.com" - - git add -A - git commit -m "chore: bump version to ${{ steps.get_version.outputs.version }}" \ - -m "Source: ${{ github.repository }}@${{ github.sha }}" \ - -m "Release: ${{ github.event.release.html_url }}" \ - --allow-empty - - git push - - echo "CLI repo synced to version ${{ steps.get_version.outputs.version }}" - diff --git a/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts b/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts index 6f843b8a81..48a873f655 100644 --- a/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts +++ b/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts @@ -34,6 +34,7 @@ describe("Host rule format regression tests", () => { stripPath: false, customEntrypoint: null, middlewares: null, + accessRules: [], }; describe("Host rule format validation", () => { diff --git a/apps/dokploy/__test__/compose/domain/labels.test.ts b/apps/dokploy/__test__/compose/domain/labels.test.ts index ec8e9edc70..cab8328eef 100644 --- a/apps/dokploy/__test__/compose/domain/labels.test.ts +++ b/apps/dokploy/__test__/compose/domain/labels.test.ts @@ -23,6 +23,7 @@ describe("createDomainLabels", () => { internalPath: "/", stripPath: false, middlewares: null, + accessRules: [], }; it("should create basic labels for web entrypoint", async () => { @@ -497,4 +498,30 @@ describe("createDomainLabels", () => { // Should not contain redirect-to-https since there's only one router expect(middlewareLabel).toBeUndefined(); }); + + it("should add router priority when access rules exist", async () => { + const labels = await createDomainLabels(appName, baseDomain, "web", { + hasAccessRules: true, + }); + + expect(labels).toContain("traefik.http.routers.test-app-1-web.priority=1"); + }); + + it("should add access rule middlewares and priority", async () => { + const labels = await createDomainLabels(appName, baseDomain, "websecure", { + additionalRule: "Path(`/redirect`)", + additionalMiddlewares: ["rule-auth", "rule-ipallow"], + priority: 220, + }); + + expect(labels).toContain( + "traefik.http.routers.test-app-1-websecure.rule=Host(`example.com`) && (Path(`/redirect`))", + ); + expect(labels).toContain( + "traefik.http.routers.test-app-1-websecure.middlewares=rule-auth,rule-ipallow", + ); + expect(labels).toContain( + "traefik.http.routers.test-app-1-websecure.priority=220", + ); + }); }); diff --git a/apps/dokploy/__test__/traefik/traefik.test.ts b/apps/dokploy/__test__/traefik/traefik.test.ts index 14d45f76c9..e1b4d00f9d 100644 --- a/apps/dokploy/__test__/traefik/traefik.test.ts +++ b/apps/dokploy/__test__/traefik/traefik.test.ts @@ -148,6 +148,7 @@ const baseDomain: Domain = { internalPath: "/", stripPath: false, middlewares: null, + accessRules: [], }; const baseRedirect: Redirect = { @@ -524,3 +525,24 @@ test("Subdomain with Russian IDN TLD converts non-ASCII part to punycode", async expect(router.rule).toContain("Host(`app.xn--e1aybc.xn--p1ai`)"); expect(router.rule).not.toContain("тест.рф"); }); + +test("Access rule adds exact path and priority", async () => { + const router = await createRouterConfig(baseApp, baseDomain, "websecure", { + additionalRule: "Path(`/redirect`)", + additionalMiddlewares: ["access-app-1-0-rule-auth"], + priority: 150, + }); + + expect(router.rule).toContain("Host(``)"); + expect(router.rule).toContain("Path(`/redirect`)"); + expect(router.priority).toBe(150); + expect(router.middlewares).toContain("access-app-1-0-rule-auth"); +}); + +test("Base router lowered when access rules exist", async () => { + const router = await createRouterConfig(baseApp, baseDomain, "websecure", { + hasAccessRules: true, + }); + + expect(router.priority).toBe(1); +}); diff --git a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx index 4edd6597f4..35e564c102 100644 --- a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx +++ b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx @@ -2,7 +2,7 @@ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/stand import { DatabaseZap, Dices, RefreshCw, X } from "lucide-react"; import Link from "next/link"; import { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; +import { useFieldArray, useForm } from "react-hook-form"; import { toast } from "sonner"; import z from "zod"; import { AlertBlock } from "@/components/shared/alert-block"; @@ -27,6 +27,7 @@ import { FormMessage, } from "@/components/ui/form"; import { Input, NumberInput } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; import { Select, SelectContent, @@ -35,6 +36,7 @@ import { SelectValue, } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; import { Tooltip, TooltipContent, @@ -45,6 +47,84 @@ import { api } from "@/utils/api"; export type CacheType = "fetch" | "cache"; +const accessRuleFormSchema = z + .object({ + enabled: z.boolean().optional(), + name: z.string().optional(), + priority: z.number().int().min(1).max(1000).optional(), + path: z.string().optional(), + pathType: z.enum(["exact", "prefix", "regexp"]).optional(), + matcherExpression: z.string().optional(), + basicAuthUsername: z.string().optional(), + basicAuthPassword: z.string().optional(), + basicAuthConfigured: z.boolean().optional(), + ipAllowList: z.array(z.string()).optional(), + ipStrategyDepth: z.number().int().min(0).optional(), + excludedIPs: z.array(z.string()).optional(), + }) + .superRefine((input, ctx) => { + if ( + input.path && + input.pathType !== "regexp" && + input.path !== "/" && + !input.path.startsWith("/") + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["path"], + message: "Rule path must start with '/'", + }); + } + + if (input.pathType === "regexp" && input.path) { + try { + new RegExp(input.path); + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["path"], + message: "Rule path must be a valid regular expression", + }); + } + } + + const hasBasicAuth = + !!input.basicAuthUsername?.trim() || !!input.basicAuthPassword?.trim(); + const hasConfiguredBasicAuth = + !!input.basicAuthUsername?.trim() && input.basicAuthConfigured === true; + const hasIpAllowList = (input.ipAllowList || []).length > 0; + + if (hasBasicAuth && !input.basicAuthUsername?.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["basicAuthUsername"], + message: "Username is required when basic auth is enabled", + }); + } + + if ( + hasBasicAuth && + !input.basicAuthPassword?.trim() && + !hasConfiguredBasicAuth + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["basicAuthPassword"], + message: "Password is required when basic auth is enabled", + }); + } + + if (!hasBasicAuth && !hasConfiguredBasicAuth && !hasIpAllowList) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["basicAuthUsername"], + message: "Add basic auth and/or IP allow list", + }); + } + }); + +type DomainAccessRule = z.infer; + export const domain = z .object({ host: z @@ -70,6 +150,7 @@ export const domain = z serviceName: z.string().optional(), domainType: z.enum(["application", "compose", "preview"]).optional(), middlewares: z.array(z.string()).optional(), + accessRules: z.array(accessRuleFormSchema).optional(), }) .superRefine((input, ctx) => { if (input.https && !input.certificateType) { @@ -216,10 +297,16 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { serviceName: undefined, domainType: type, middlewares: [], + accessRules: [], }, mode: "onChange", }); + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "accessRules", + }); + const certificateType = form.watch("certificateType"); const useCustomEntrypoint = form.watch("useCustomEntrypoint"); const https = form.watch("https"); @@ -243,6 +330,12 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { serviceName: data?.serviceName || undefined, domainType: data?.domainType || type, middlewares: data?.middlewares || [], + accessRules: + data?.accessRules?.map((rule) => ({ + ...rule, + basicAuthPassword: "", + basicAuthConfigured: !!rule.basicAuthConfigured, + })) || [], }); } @@ -260,10 +353,43 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { customCertResolver: undefined, domainType: type, middlewares: [], + accessRules: [], }); } }, [form, data, isPending, domainId]); + const addAccessRule = () => { + append({ + enabled: true, + name: "", + priority: 100, + path: "/", + pathType: "prefix", + matcherExpression: "", + basicAuthUsername: "", + basicAuthPassword: "", + basicAuthConfigured: false, + ipAllowList: [], + ipStrategyDepth: undefined, + excludedIPs: [], + }); + }; + + const updateListField = ( + index: number, + fieldName: "ipAllowList" | "excludedIPs", + value: string, + ) => { + const items = value + .split(/\r?\n|,/) + .map((item) => item.trim()) + .filter(Boolean); + form.setValue(`accessRules.${index}.${fieldName}`, items, { + shouldDirty: true, + shouldValidate: true, + }); + }; + // Separate effect for handling custom cert resolver validation useEffect(() => { if (certificateType === "custom") { @@ -318,6 +444,276 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { toast.error(dictionary.error); }); }; + + const renderAccessRuleCard = ( + field: { id: string } & DomainAccessRule, + index: number, + ) => ( +
+
+
+

Rule {index + 1}

+

+ Match path and apply basic auth and/or IP allow list. +

+
+
+ ( + + )} + /> + +
+
+ +
+ ( + + Name + + + + + + )} + /> + ( + + Priority + + + + + + )} + /> +
+ +
+ ( + + Path Match + + + + )} + /> + ( + + Path + + + + + + )} + /> +
+ + ( + + Advanced Matchers + + Optional Traefik matcher expression. Example: + ClientIP(`10.0.0.0/8`) || Method(`POST`) + + + + + + + )} + /> + +
+ ( + + Basic Auth Username + {form.watch(`accessRules.${index}.basicAuthConfigured`) && ( + + Password already configured. Enter new password only if you + want to rotate it. + + )} + + { + field.onChange(event); + form.setValue( + `accessRules.${index}.basicAuthConfigured`, + !!event.target.value && + !!form.getValues( + `accessRules.${index}.basicAuthConfigured`, + ), + ); + }} + /> + + + + )} + /> + ( + + Basic Auth Password + + Leave empty to keep existing password. + + + { + field.onChange(event); + if (event.target.value) { + form.setValue( + `accessRules.${index}.basicAuthConfigured`, + true, + ); + } + }} + /> + + + + )} + /> +
+ +
+ ( + + IP Allow List + + One CIDR or IP per line. Example: 192.168.1.0/24 + + +