diff --git a/apps/dokploy/__test__/domain-overview/acme.test.ts b/apps/dokploy/__test__/domain-overview/acme.test.ts new file mode 100644 index 00000000000..7866510a733 --- /dev/null +++ b/apps/dokploy/__test__/domain-overview/acme.test.ts @@ -0,0 +1,207 @@ +import { + buildAcmeReadCommand, + certificateCovers, + certificateTone, + daysUntil, + findCertificateFor, + parseAcmeCertificates, + parseCertificateChain, +} from "@dokploy/server/utils/domain-overview/acme"; +import { describe, expect, it } from "vitest"; + +const FIXTURE_CERTIFICATE = `-----BEGIN CERTIFICATE----- +MIIByzCCAXGgAwIBAgIUWlFvc+nf6dEY5hzHVUVViA92C6kwCgYIKoZIzj0EAwIw +NjEVMBMGA1UEAwwMZXhhbXBsZS50ZXN0MR0wGwYDVQQKDBRFeGFtcGxlIFRlc3Qg +Rml4dHVyZTAeFw0yNjAxMDEwMDAwMDBaFw0yNzA2MDExMjAwMDBaMDYxFTATBgNV +BAMMDGV4YW1wbGUudGVzdDEdMBsGA1UECgwURXhhbXBsZSBUZXN0IEZpeHR1cmUw +WTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAR8poBd9hdPZbBkGhYMU068RMvU5tEW +AGaG3Ak6Ia9ZJNXhgn2Rc8Kc1Xim/05OxNc7ubzinXH7XEBWyfLmI7Ylo10wWzAd +BgNVHQ4EFgQUkdsHQsjcvCmQo7qeXtOtA3bmJZAwDwYDVR0TAQH/BAUwAwEB/zAp +BgNVHREEIjAgggxleGFtcGxlLnRlc3SCEHd3dy5leGFtcGxlLnRlc3QwCgYIKoZI +zj0EAwIDSAAwRQIgWV3UtpalgH4fHv8AmFwlUfSdJjTLK5Li2K2OJKYZXrICIQCK +c9Rm3ru6I2cIJEIQwsANL6haSt6HrEbU14R9Zw3LWQ== +-----END CERTIFICATE----- +`; + +const FAKE_ENTRY_KEY = "FAKE-ENTRY-KEY-MATERIAL-FOR-TESTS-ONLY"; +const FAKE_ACCOUNT_KEY = "FAKE-ACCOUNT-KEY-MATERIAL-FOR-TESTS-ONLY"; + +const encode = (value: string) => Buffer.from(value, "utf8").toString("base64"); + +const acmeDocument = (certificates: unknown[]) => + JSON.stringify({ + letsencrypt: { + Account: { + Email: "ops@example.test", + PrivateKey: FAKE_ACCOUNT_KEY, + }, + Certificates: certificates, + }, + }); + +const fixtureEntry = { + domain: { main: "example.test", sans: ["www.example.test"] }, + certificate: encode(FIXTURE_CERTIFICATE), + key: FAKE_ENTRY_KEY, + Store: "default", +}; + +describe("acme.json parser", () => { + it("returns names, expiry and issuer and never the key material", () => { + const raw = acmeDocument([fixtureEntry]); + + const certificates = parseAcmeCertificates(raw); + + expect(certificates).toHaveLength(1); + expect(certificates[0]?.names).toEqual([ + "example.test", + "www.example.test", + ]); + expect(certificates[0]?.validTo.toISOString()).toBe( + "2027-06-01T12:00:00.000Z", + ); + expect(certificates[0]?.issuer).toBe("Example Test Fixture example.test"); + expect(Object.keys(certificates[0] ?? {}).sort()).toEqual([ + "issuer", + "names", + "validTo", + ]); + const serialized = JSON.stringify(certificates); + expect(serialized).not.toContain(FAKE_ENTRY_KEY); + expect(serialized).not.toContain(FAKE_ACCOUNT_KEY); + }); + + it("reads the reduced shape that the server-side jq filter prints", () => { + const reduced = JSON.stringify({ + letsencrypt: { + Certificates: [ + { + domain: { main: "Example.TEST.", sans: [] }, + certificate: encode(FIXTURE_CERTIFICATE), + }, + ], + }, + }); + + expect(parseAcmeCertificates(reduced)[0]?.names).toEqual(["example.test"]); + }); + + it("skips entries without a readable certificate", () => { + const raw = acmeDocument([ + { domain: { main: "example.test" }, certificate: encode("not a cert") }, + { domain: { main: "example.test" } }, + ]); + + expect(parseAcmeCertificates(raw)).toEqual([]); + }); + + it("returns no certificates when the file has no letsencrypt section", () => { + expect(parseAcmeCertificates("{}")).toEqual([]); + }); + + it("does not echo the file content when the JSON cannot be parsed", () => { + const broken = `{"letsencrypt": {"Account": {"PrivateKey": "${FAKE_ACCOUNT_KEY}"`; + + let thrown: unknown; + try { + parseAcmeCertificates(broken); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(Error); + expect((thrown as Error).message).toBe("acme.json could not be parsed"); + expect(String(thrown)).not.toContain(FAKE_ACCOUNT_KEY); + }); +}); + +describe("certificate expiry parser", () => { + it("reads validTo and the issuer from the first PEM block only", () => { + const parsed = parseCertificateChain( + encode( + `${FIXTURE_CERTIFICATE}${FIXTURE_CERTIFICATE.replace( + "MIIBy", + "MIIBz", + )}`, + ), + ); + + expect(parsed?.validTo.toISOString()).toBe("2027-06-01T12:00:00.000Z"); + expect(parsed?.issuer).toBe("Example Test Fixture example.test"); + }); + + it("returns null when the chain holds no certificate", () => { + expect(parseCertificateChain(encode("plain text"))).toBeNull(); + }); + + it("counts whole days left from a given moment", () => { + const validTo = new Date("2027-06-01T12:00:00.000Z"); + + expect(daysUntil(validTo, new Date("2027-05-19T12:00:00.000Z"))).toBe(13); + expect(daysUntil(validTo, new Date("2027-05-19T13:00:00.000Z"))).toBe(12); + expect(daysUntil(validTo, new Date("2027-06-02T12:00:00.000Z"))).toBe(-1); + }); + + it("marks under 14 days as critical and under 30 days as warning", () => { + expect(certificateTone(13)).toBe("critical"); + expect(certificateTone(0)).toBe("critical"); + expect(certificateTone(-3)).toBe("critical"); + expect(certificateTone(14)).toBe("warning"); + expect(certificateTone(29)).toBe("warning"); + expect(certificateTone(30)).toBe("ok"); + }); +}); + +describe("certificate names", () => { + it("matches exact names and one wildcard label", () => { + expect(certificateCovers(["example.test"], "example.test")).toBe(true); + expect(certificateCovers(["*.example.test"], "www.example.test")).toBe( + true, + ); + expect(certificateCovers(["*.example.test"], "example.test")).toBe(false); + expect(certificateCovers(["*.example.test"], "a.b.example.test")).toBe( + false, + ); + }); + + it("prefers the certificate with the latest expiry", () => { + const older = { + names: ["example.test"], + validTo: new Date("2026-12-01T00:00:00Z"), + issuer: "older", + }; + const newer = { + names: ["example.test"], + validTo: new Date("2027-06-01T00:00:00Z"), + issuer: "newer", + }; + + expect(findCertificateFor([older, newer], "example.test")?.issuer).toBe( + "newer", + ); + expect(findCertificateFor([older], "other.example.test")).toBeUndefined(); + }); +}); + +describe("acme.json read command", () => { + it("checks the file first and reduces it on the server when jq is available", () => { + const command = buildAcmeReadCommand( + "/etc/dokploy/traefik/dynamic/acme.json", + ); + + expect( + command.startsWith( + "[ -f '/etc/dokploy/traefik/dynamic/acme.json' ] || exit 3", + ), + ).toBe(true); + expect(command).toContain("jq -c"); + expect(command).toContain("certificate: .certificate?"); + expect(command).not.toMatch(/PrivateKey|\.key\b/); + }); + + it("quotes the path so that it cannot end the argument", () => { + const command = buildAcmeReadCommand("/srv/it's/acme.json"); + + expect(command).toContain("'/srv/it'\\''s/acme.json'"); + }); +}); diff --git a/apps/dokploy/__test__/domain-overview/dns-status.test.ts b/apps/dokploy/__test__/domain-overview/dns-status.test.ts new file mode 100644 index 00000000000..921b8a3ea35 --- /dev/null +++ b/apps/dokploy/__test__/domain-overview/dns-status.test.ts @@ -0,0 +1,189 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const dns = vi.hoisted(() => ({ + resolve4: vi.fn(), + resolve6: vi.fn(), +})); + +vi.mock("node:dns", () => ({ + promises: { + resolve4: dns.resolve4, + resolve6: dns.resolve6, + }, +})); + +const { + clearDnsLookupCache, + DNS_CACHE_TTL_MS, + DNS_LOOKUP_TIMEOUT_MS, + mapDnsStatus, + resolveDomainRecords, +} = await import("@dokploy/server/utils/domain-overview/dns-status"); + +const dnsError = (code: string) => Object.assign(new Error(code), { code }); + +const answer = (addresses: string[]) => ({ ok: true as const, addresses }); +const failure = (code: string) => ({ ok: false as const, code }); + +describe("DNS status mapper", () => { + it("says the name points to this server when an address matches", () => { + expect( + mapDnsStatus({ + records: { a: answer(["192.0.2.10"]), aaaa: failure("ENODATA") }, + serverAddress: "192.0.2.10", + }), + ).toBe("points_to_this_server"); + }); + + it("says the name points to another address when none matches", () => { + expect( + mapDnsStatus({ + records: { a: answer(["198.51.100.7"]), aaaa: failure("ENODATA") }, + serverAddress: "192.0.2.10", + }), + ).toBe("points_to_other_address"); + }); + + it("reports NXDOMAIN when both families say the name does not exist", () => { + expect( + mapDnsStatus({ + records: { a: failure("ENOTFOUND"), aaaa: failure("ENOTFOUND") }, + serverAddress: "192.0.2.10", + }), + ).toBe("nxdomain"); + }); + + it("reports a record without an address when both families have no data", () => { + expect( + mapDnsStatus({ + records: { a: failure("ENODATA"), aaaa: failure("ENODATA") }, + serverAddress: "192.0.2.10", + }), + ).toBe("no_address"); + }); + + it("reports a failed lookup when the lookup timed out", () => { + expect( + mapDnsStatus({ + records: { a: failure("ETIMEDOUT"), aaaa: failure("ENODATA") }, + serverAddress: "192.0.2.10", + }), + ).toBe("lookup_failed"); + }); + + it("does not call the name another address while the server's family is unknown", () => { + expect( + mapDnsStatus({ + records: { a: failure("ESERVFAIL"), aaaa: answer(["2001:db8::7"]) }, + serverAddress: "192.0.2.10", + }), + ).toBe("lookup_failed"); + }); + + it("reports an unknown server address when the name resolves", () => { + expect( + mapDnsStatus({ + records: { a: answer(["192.0.2.10"]), aaaa: failure("ENODATA") }, + serverAddress: null, + }), + ).toBe("server_address_unknown"); + }); +}); + +describe("DNS lookups", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + clearDnsLookupCache(); + }); + + it("maps a name that resolves to this server", async () => { + dns.resolve4.mockResolvedValue(["192.0.2.10"]); + dns.resolve6.mockRejectedValue(dnsError("ENODATA")); + + const records = await resolveDomainRecords("points-here.example.test"); + + expect(mapDnsStatus({ records, serverAddress: "192.0.2.10" })).toBe( + "points_to_this_server", + ); + }); + + it("maps a name that resolves to another address", async () => { + dns.resolve4.mockResolvedValue(["198.51.100.7"]); + dns.resolve6.mockRejectedValue(dnsError("ENODATA")); + + const records = await resolveDomainRecords("elsewhere.example.test"); + + expect(mapDnsStatus({ records, serverAddress: "192.0.2.10" })).toBe( + "points_to_other_address", + ); + }); + + it("maps a name with no DNS record", async () => { + dns.resolve4.mockRejectedValue(dnsError("ENOTFOUND")); + dns.resolve6.mockRejectedValue(dnsError("ENOTFOUND")); + + const records = await resolveDomainRecords("missing.example.test"); + + expect(mapDnsStatus({ records, serverAddress: "192.0.2.10" })).toBe( + "nxdomain", + ); + }); + + it("maps a name whose record has no address", async () => { + dns.resolve4.mockRejectedValue(dnsError("ENODATA")); + dns.resolve6.mockRejectedValue(dnsError("ENODATA")); + + const records = await resolveDomainRecords("empty.example.test"); + + expect(mapDnsStatus({ records, serverAddress: "192.0.2.10" })).toBe( + "no_address", + ); + }); + + it("maps a lookup that does not answer within the timeout", async () => { + vi.useFakeTimers(); + dns.resolve4.mockReturnValue(new Promise(() => {})); + dns.resolve6.mockRejectedValue(dnsError("ENODATA")); + + const pending = resolveDomainRecords("slow.example.test"); + await vi.advanceTimersByTimeAsync(DNS_LOOKUP_TIMEOUT_MS); + const records = await pending; + + expect(records.a.ok).toBe(false); + expect(mapDnsStatus({ records, serverAddress: "192.0.2.10" })).toBe( + "lookup_failed", + ); + }); + + it("maps a resolver error that is not NXDOMAIN or NODATA as a failed lookup", async () => { + dns.resolve4.mockRejectedValue(dnsError("ECONNREFUSED")); + dns.resolve6.mockRejectedValue(dnsError("ECONNREFUSED")); + + const records = await resolveDomainRecords("refused.example.test"); + + expect(mapDnsStatus({ records, serverAddress: "192.0.2.10" })).toBe( + "lookup_failed", + ); + }); + + it("answers repeat lookups from the cache for ten minutes", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-10-08T10:00:00.000Z")); + dns.resolve4.mockResolvedValue(["192.0.2.10"]); + dns.resolve6.mockRejectedValue(dnsError("ENODATA")); + + await resolveDomainRecords("cached.example.test"); + vi.setSystemTime( + new Date(Date.parse("2026-10-08T10:00:00.000Z") + DNS_CACHE_TTL_MS - 1), + ); + await resolveDomainRecords("cached.example.test"); + expect(dns.resolve4).toHaveBeenCalledTimes(1); + + vi.setSystemTime( + new Date(Date.parse("2026-10-08T10:00:00.000Z") + DNS_CACHE_TTL_MS), + ); + await resolveDomainRecords("cached.example.test"); + expect(dns.resolve4).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/dokploy/__test__/domain-overview/domain-overview-access.test.ts b/apps/dokploy/__test__/domain-overview/domain-overview-access.test.ts new file mode 100644 index 00000000000..826f25d0e14 --- /dev/null +++ b/apps/dokploy/__test__/domain-overview/domain-overview-access.test.ts @@ -0,0 +1,230 @@ +import type { DomainOverviewAccess } from "@dokploy/server/services/domain-overview"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const FIXTURE_CERTIFICATE = `-----BEGIN CERTIFICATE----- +MIIByzCCAXGgAwIBAgIUWlFvc+nf6dEY5hzHVUVViA92C6kwCgYIKoZIzj0EAwIw +NjEVMBMGA1UEAwwMZXhhbXBsZS50ZXN0MR0wGwYDVQQKDBRFeGFtcGxlIFRlc3Qg +Rml4dHVyZTAeFw0yNjAxMDEwMDAwMDBaFw0yNzA2MDExMjAwMDBaMDYxFTATBgNV +BAMMDGV4YW1wbGUudGVzdDEdMBsGA1UECgwURXhhbXBsZSBUZXN0IEZpeHR1cmUw +WTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAR8poBd9hdPZbBkGhYMU068RMvU5tEW +AGaG3Ak6Ia9ZJNXhgn2Rc8Kc1Xim/05OxNc7ubzinXH7XEBWyfLmI7Ylo10wWzAd +BgNVHQ4EFgQUkdsHQsjcvCmQo7qeXtOtA3bmJZAwDwYDVR0TAQH/BAUwAwEB/zAp +BgNVHREEIjAgggxleGFtcGxlLnRlc3SCEHd3dy5leGFtcGxlLnRlc3QwCgYIKoZI +zj0EAwIDSAAwRQIgWV3UtpalgH4fHv8AmFwlUfSdJjTLK5Li2K2OJKYZXrICIQCK +c9Rm3ru6I2cIJEIQwsANL6haSt6HrEbU14R9Zw3LWQ== +-----END CERTIFICATE----- +`; + +const KEY_MATERIAL = "KEY-MATERIAL-FOR-TESTS-ONLY"; + +const acmeDocument = JSON.stringify({ + letsencrypt: { + Account: { Email: "ops@example.test", PrivateKey: KEY_MATERIAL }, + Certificates: [ + { + domain: { main: "one.example.test", sans: [] }, + certificate: Buffer.from(FIXTURE_CERTIFICATE, "utf8").toString( + "base64", + ), + key: KEY_MATERIAL, + }, + ], + }, +}); + +const mocks = vi.hoisted(() => ({ + execAsync: vi.fn(), + execAsyncRemote: vi.fn(), + findServers: vi.fn(), + findSettings: vi.fn(), + getAllDomainsForOrganization: vi.fn(), + getPublicIpWithFallback: vi.fn(), + resolveDomainRecords: vi.fn(), + select: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + server: { findMany: mocks.findServers }, + webServerSettings: { findFirst: mocks.findSettings }, + }, + select: mocks.select, + }, +})); + +vi.mock("@dokploy/server/services/overview", () => ({ + getAllDomainsForOrganization: mocks.getAllDomainsForOrganization, +})); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: mocks.execAsync, + execAsyncRemote: mocks.execAsyncRemote, +})); + +vi.mock("@dokploy/server/wss/utils", () => ({ + getPublicIpWithFallback: mocks.getPublicIpWithFallback, +})); + +vi.mock( + "@dokploy/server/utils/domain-overview/dns-status", + async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@dokploy/server/utils/domain-overview/dns-status") + >()), + resolveDomainRecords: mocks.resolveDomainRecords, + }), +); + +const { getDomainOverview } = await import( + "@dokploy/server/services/domain-overview" +); +const { applications, compose } = await import("@dokploy/server/db/schema"); + +const workloadRows = new Map(); + +type SelectBuilder = { + from: (table: unknown) => SelectBuilder; + innerJoin: () => SelectBuilder; + where: () => Promise; +}; + +const selectWorkloads = (): SelectBuilder => { + let rows: unknown[] = []; + const builder: SelectBuilder = { + from: (table) => { + rows = workloadRows.get(table) ?? []; + return builder; + }, + innerJoin: () => builder, + where: () => Promise.resolve(rows), + }; + return builder; +}; + +const accessTo = (options: { + servers: string[]; + certificates: boolean; +}): DomainOverviewAccess => ({ + canReadHost: async (serverId) => + serverId !== null && options.servers.includes(serverId), + canReadCertificates: options.certificates, +}); + +describe("domain overview reads only what the caller may read", () => { + beforeEach(() => { + vi.clearAllMocks(); + workloadRows.clear(); + workloadRows.set(applications, [ + { + id: "app-1", + name: "one", + appName: "one-app", + serverId: "server-1", + environmentId: "env-1", + projectId: "proj-1", + }, + { + id: "app-2", + name: "other", + appName: "other-app", + serverId: "server-2", + environmentId: "env-1", + projectId: "proj-1", + }, + ]); + workloadRows.set(compose, []); + mocks.select.mockImplementation(() => selectWorkloads()); + mocks.findServers.mockResolvedValue([ + { serverId: "server-1", name: "Build host", ipAddress: "" }, + { serverId: "server-2", name: "Other host", ipAddress: "" }, + ]); + mocks.findSettings.mockResolvedValue(null); + mocks.getAllDomainsForOrganization.mockResolvedValue([ + { + host: "one.example.test", + serviceOwnerType: "application", + serviceOwnerId: "app-1", + serviceName: "one", + projectId: "proj-1", + environmentId: "env-1", + domainType: "application", + enabled: true, + }, + { + host: "other.example.test", + serviceOwnerType: "application", + serviceOwnerId: "app-2", + serviceName: "other", + projectId: "proj-1", + environmentId: "env-1", + domainType: "application", + enabled: true, + }, + ]); + mocks.resolveDomainRecords.mockResolvedValue({ + a: { ok: true, addresses: [] }, + aaaa: { ok: false, code: "ENODATA" }, + }); + mocks.execAsync.mockResolvedValue({ stdout: "" }); + mocks.execAsyncRemote.mockImplementation(async () => ({ stdout: "" })); + }); + + it("contacts and lists only the servers the caller can read", async () => { + const overview = await getDomainOverview( + "org-1", + accessTo({ servers: ["server-1"], certificates: false }), + ); + + expect(overview.servers.map((server) => server.serverId)).toEqual([ + "server-1", + ]); + expect(overview.rows.map((row) => row.domain)).toEqual([ + "one.example.test", + ]); + expect(mocks.execAsync).not.toHaveBeenCalled(); + expect(mocks.execAsyncRemote.mock.calls.length).toBeGreaterThan(0); + expect( + mocks.execAsyncRemote.mock.calls.every( + ([serverId]) => serverId === "server-1", + ), + ).toBe(true); + expect(mocks.resolveDomainRecords).not.toHaveBeenCalledWith( + "other.example.test", + ); + }); + + it("does not read acme.json while certificate access is closed", async () => { + const overview = await getDomainOverview( + "org-1", + accessTo({ servers: ["server-1"], certificates: false }), + ); + const commands = mocks.execAsyncRemote.mock.calls.map( + ([, command]) => command as string, + ); + + expect(commands.some((command) => command.includes("acme.json"))).toBe( + false, + ); + expect(overview.certificatesVisible).toBe(false); + expect(overview.rows.every((row) => row.certificate === null)).toBe(true); + }); + + it("returns certificate dates from acme.json and never the key material", async () => { + mocks.execAsyncRemote.mockImplementation(async (_serverId, command) => ({ + stdout: (command as string).includes("acme.json") ? acmeDocument : "", + })); + + const overview = await getDomainOverview( + "org-1", + accessTo({ servers: ["server-1"], certificates: true }), + ); + + expect(overview.certificatesVisible).toBe(true); + expect( + overview.rows.find((row) => row.domain === "one.example.test") + ?.certificate, + ).toMatchObject({ issuer: expect.any(String) }); + expect(JSON.stringify(overview)).not.toContain(KEY_MATERIAL); + }); +}); diff --git a/apps/dokploy/__test__/domain-overview/domain-overview-router.test.ts b/apps/dokploy/__test__/domain-overview/domain-overview-router.test.ts new file mode 100644 index 00000000000..66bb05d5f2f --- /dev/null +++ b/apps/dokploy/__test__/domain-overview/domain-overview-router.test.ts @@ -0,0 +1,245 @@ +import type { DomainOverviewAccess } from "@dokploy/server/services/domain-overview"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + checkProtectedResourceAccess: vi.fn(), + findMember: vi.fn(), + findServers: vi.fn(), + getDomainOverview: vi.fn(), + getTraefikLogs: vi.fn(), + hasValidLicense: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + member: { findFirst: mocks.findMember }, + server: { findMany: mocks.findServers }, + }, + }, +})); + +vi.mock("@dokploy/server/services/domain-overview", () => ({ + getDomainOverview: mocks.getDomainOverview, + getTraefikLogs: mocks.getTraefikLogs, +})); + +vi.mock("@dokploy/server/services/proprietary/license-key", () => ({ + hasValidLicense: mocks.hasValidLicense, +})); + +vi.mock("@dokploy/server/services/super-password", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@dokploy/server/services/super-password") + >()), + checkProtectedResourceAccess: mocks.checkProtectedResourceAccess, +})); + +vi.mock("@dokploy/server/services/known-secrets", async () => { + const { EMPTY_KNOWN_SECRET_MATCHER } = await import( + "@dokploy/server/utils/security/secret-scrub" + ); + return { + getKnownSecretMatcher: async () => EMPTY_KNOWN_SECRET_MATCHER, + resetKnownSecretMatcherCache: () => {}, + }; +}); + +const { appRouter } = await import("@/server/api/root"); + +type Role = "owner" | "admin" | "member"; + +const useMember = (role: Role, traefikFiles: boolean) => { + mocks.findMember.mockResolvedValue({ + role, + canAccessToTraefikFiles: traefikFiles, + accessedServers: [], + }); +}; + +const createCaller = ( + role: Role, + options: { traefikFiles?: boolean; authMethod?: "api-key" } = {}, +) => { + useMember(role, options.traefikFiles ?? false); + return appRouter.createCaller({ + db: {}, + req: {}, + res: {}, + session: { + id: "session-1", + userId: "user-1", + activeOrganizationId: "org-1", + authMethod: options.authMethod ?? "session", + }, + user: { + id: "user-1", + email: "operator@example.test", + role, + }, + } as never); +}; + +const overviewAccess = (): DomainOverviewAccess => { + const call = mocks.getDomainOverview.mock.calls[0]; + if (!call) { + throw new Error("getDomainOverview was not called"); + } + return call[1] as DomainOverviewAccess; +}; + +describe("domain overview router access", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.findServers.mockResolvedValue([ + { serverId: "server-1" }, + { serverId: "server-2" }, + ]); + mocks.hasValidLicense.mockResolvedValue(false); + mocks.checkProtectedResourceAccess.mockResolvedValue(null); + mocks.getDomainOverview.mockResolvedValue({ + rows: [], + serverErrors: [], + }); + mocks.getTraefikLogs.mockResolvedValue({ lines: [] }); + }); + + it("refuses the overview to a member without Traefik file access", async () => { + await expect( + createCaller("member").domainOverview.list(), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + expect(mocks.getDomainOverview).not.toHaveBeenCalled(); + }); + + it("refuses the Traefik logs to a member without Traefik file access", async () => { + const caller = createCaller("member"); + + await expect( + caller.domainOverview.traefikLogs({ serverId: "server-1" }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + await expect(caller.domainOverview.traefikLogs({})).rejects.toMatchObject({ + code: "UNAUTHORIZED", + }); + expect(mocks.getTraefikLogs).not.toHaveBeenCalled(); + }); + + it("refuses anonymous callers before reading anything", async () => { + const caller = appRouter.createCaller({ + db: {}, + req: {}, + res: {}, + session: null, + user: null, + } as never); + + await expect(caller.domainOverview.list()).rejects.toMatchObject({ + code: "UNAUTHORIZED", + }); + await expect(caller.domainOverview.traefikLogs({})).rejects.toMatchObject({ + code: "UNAUTHORIZED", + }); + expect(mocks.getDomainOverview).not.toHaveBeenCalled(); + expect(mocks.getTraefikLogs).not.toHaveBeenCalled(); + }); + + it("returns the overview to an admin for the active organization", async () => { + const result = await createCaller("admin").domainOverview.list(); + + expect(result).toEqual({ rows: [], serverErrors: [] }); + expect(mocks.getDomainOverview).toHaveBeenCalledWith( + "org-1", + expect.objectContaining({ + canReadCertificates: true, + canReadHost: expect.any(Function), + }), + ); + }); + + it("gives a member with Traefik file access the remote servers only", async () => { + await createCaller("member", { traefikFiles: true }).domainOverview.list(); + const access = overviewAccess(); + + await expect(access.canReadHost(null)).resolves.toBe(false); + await expect(access.canReadHost("server-1")).resolves.toBe(true); + await expect(access.canReadHost("server-9")).resolves.toBe(false); + }); + + it("keeps certificate data closed for a member even with an open super session", async () => { + await createCaller("member", { traefikFiles: true }).domainOverview.list(); + + expect(overviewAccess().canReadCertificates).toBe(false); + }); + + it("keeps certificate data closed while the owner's super session is closed", async () => { + mocks.checkProtectedResourceAccess.mockResolvedValue( + "super-session-required", + ); + + await createCaller("owner").domainOverview.list(); + + expect(overviewAccess().canReadCertificates).toBe(false); + }); + + it("opens certificate data to an owner with an open super session", async () => { + await createCaller("owner").domainOverview.list(); + + expect(overviewAccess().canReadCertificates).toBe(true); + expect(mocks.checkProtectedResourceAccess).toHaveBeenCalledWith({ + userId: "user-1", + viaApiKey: false, + }); + }); + + it("never opens certificate data through an API key", async () => { + mocks.checkProtectedResourceAccess.mockImplementation( + async ({ viaApiKey }: { viaApiKey: boolean }) => + viaApiKey ? "browser-session-required" : null, + ); + + await createCaller("owner", { + authMethod: "api-key", + }).domainOverview.list(); + + expect(overviewAccess().canReadCertificates).toBe(false); + expect(mocks.checkProtectedResourceAccess).toHaveBeenCalledWith({ + userId: "user-1", + viaApiKey: true, + }); + }); + + it("passes a remote server to the Traefik logs for a member with Traefik file access", async () => { + const caller = createCaller("member", { traefikFiles: true }); + + await caller.domainOverview.traefikLogs({ serverId: "server-1" }); + + expect(mocks.getTraefikLogs).toHaveBeenCalledWith("org-1", "server-1"); + }); + + it("refuses the panel's own host to a member with Traefik file access", async () => { + const caller = createCaller("member", { traefikFiles: true }); + + await expect(caller.domainOverview.traefikLogs({})).rejects.toMatchObject({ + code: "UNAUTHORIZED", + message: "Local host operations require owner or admin access", + }); + expect(mocks.getTraefikLogs).not.toHaveBeenCalled(); + }); + + it("refuses Traefik logs for a server outside the active organization", async () => { + await expect( + createCaller("admin").domainOverview.traefikLogs({ + serverId: "server-9", + }), + ).rejects.toMatchObject({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this server", + }); + expect(mocks.getTraefikLogs).not.toHaveBeenCalled(); + }); + + it("reads the local host when no server is chosen", async () => { + await createCaller("owner").domainOverview.traefikLogs({}); + + expect(mocks.getTraefikLogs).toHaveBeenCalledWith("org-1", null); + }); +}); diff --git a/apps/dokploy/__test__/domain-overview/traefik-errors.test.ts b/apps/dokploy/__test__/domain-overview/traefik-errors.test.ts new file mode 100644 index 00000000000..9ba50f99128 --- /dev/null +++ b/apps/dokploy/__test__/domain-overview/traefik-errors.test.ts @@ -0,0 +1,103 @@ +import { classifyTraefikError } from "@dokploy/server/utils/domain-overview/traefik-errors"; +import { describe, expect, it } from "vitest"; + +describe("traefik ACME error classifier", () => { + it("classifies a rate limit answer", () => { + const result = classifyTraefikError( + 'level=error msg="Unable to obtain ACME certificate for domains" error="acme: error: 429 :: urn:ietf:params:acme:error:rateLimited :: too many certificates (5) already issued for these domains: example.test" domain=example.test', + ); + + expect(result.cause).toBe("rate_limit"); + expect(result.explanation).toBe( + "Слишком много неудачных попыток у Let's Encrypt (429). Сначала устраните причину, и только потом ждите новую попытку.", + ); + }); + + it("classifies a name that has no DNS record", () => { + const result = classifyTraefikError( + "DNS problem: NXDOMAIN looking up A for example.test - check that a DNS record exists for this domain", + ); + + expect(result.cause).toBe("no_dns_record"); + expect(result.explanation).toBe( + "Для этого имени нет DNS-записи. Добавьте запись A с адресом сервера или удалите домен, если он больше не используется.", + ); + }); + + it("classifies a DNS record that has no address", () => { + const result = classifyTraefikError( + "DNS problem: no valid A records found for example.test", + ); + + expect(result.cause).toBe("no_address"); + expect(result.explanation).toBe( + "DNS-запись есть, но в ней нет адреса. Укажите в записи A адрес сервера.", + ); + }); + + it("classifies a TLS-ALPN challenge answered by something other than Traefik", () => { + const result = classifyTraefikError( + 'Cannot negotiate ALPN protocol "acme-tls/1" for tls-alpn-01 challenge', + ); + + expect(result.cause).toBe("port_443_answered_by_other"); + expect(result.explanation).toBe( + "На 443 отвечает не Traefik, обычно перед сервером стоит CDN или прокси, который завершает TLS. Отключите проксирование для этого имени или уберите его с прокси.", + ); + }); + + it("classifies a refused connection on port 443", () => { + const result = classifyTraefikError( + "urn:ietf:params:acme:error:connection :: 203.0.113.10: Connection refused", + ); + + expect(result.cause).toBe("port_443_closed"); + expect(result.explanation).toBe( + "Порт 443 закрыт файрволом или не ведет на Traefik. Проверьте файрвол и публикацию портов.", + ); + }); + + it("classifies an inbound timeout on port 443 as a closed port", () => { + const result = classifyTraefikError( + "urn:ietf:params:acme:error:connection :: 203.0.113.10:443: Timeout during connect (likely firewall problem)", + ); + + expect(result.cause).toBe("port_443_closed"); + }); + + it("classifies a timeout while talking to Let's Encrypt", () => { + const result = classifyTraefikError( + 'Get "https://acme-v02.example.test/directory": context deadline exceeded', + ); + + expect(result.cause).toBe("lets_encrypt_timeout"); + expect(result.explanation).toBe( + "Let's Encrypt не ответил вовремя. Часто это проходит само собой; проверьте позже.", + ); + }); + + it("keeps an outbound dial timeout to Let's Encrypt out of the port 443 class", () => { + const result = classifyTraefikError( + "dial tcp 203.0.113.99:443: i/o timeout", + ); + + expect(result.cause).toBe("lets_encrypt_timeout"); + }); + + it("does not call a Let's Encrypt host lookup failure a missing DNS record", () => { + const result = classifyTraefikError( + "dial tcp: lookup acme-v02.example.test: no such host", + ); + + expect(result.cause).toBe("unknown"); + }); + + it("falls back to the unknown explanation for any other message", () => { + const result = classifyTraefikError( + "something unexpected happened while renewing", + ); + + expect(result.cause).toBe("unknown"); + expect(result.explanation).toBe("Неизвестная ошибка, см. лог ниже."); + }); +}); diff --git a/apps/dokploy/__test__/domain-overview/traefik-logs.test.ts b/apps/dokploy/__test__/domain-overview/traefik-logs.test.ts new file mode 100644 index 00000000000..dabf99d0762 --- /dev/null +++ b/apps/dokploy/__test__/domain-overview/traefik-logs.test.ts @@ -0,0 +1,93 @@ +import { + extractLogDomain, + isErrorLine, + readLogLevel, + readLogTime, + selectAcmeLogLines, +} from "@dokploy/server/utils/domain-overview/traefik-logs"; +import { describe, expect, it } from "vitest"; + +const acmeLine = (index: number) => + `time="2026-10-08T10:00:${String(index % 60).padStart(2, "0")}Z" level=info msg="Certificate obtained for domains [host-${index}.example.test]"`; + +describe("Traefik log line selection", () => { + it("keeps only certificate and ACME lines and the last 300 of them", () => { + const lines = [ + 'time="2026-10-08T09:00:00Z" level=info msg="Starting provider"', + ]; + for (let index = 0; index < 350; index += 1) { + lines.push(acmeLine(index)); + lines.push('time="2026-10-08T09:00:01Z" level=debug msg="request"'); + } + + const selected = selectAcmeLogLines(lines.join("\n")); + + expect(selected).toHaveLength(300); + expect(selected[0]).toBe(acmeLine(50)); + expect(selected[299]).toBe(acmeLine(349)); + }); +}); + +describe("Traefik log fields", () => { + it("reads the level and time from logfmt lines", () => { + const line = + 'time="2026-10-08T10:00:00Z" level=error msg="Error renewing ACME certificate"'; + + expect(readLogLevel(line)).toBe("error"); + expect(readLogTime(line)).toBe("2026-10-08T10:00:00Z"); + expect(isErrorLine(line)).toBe(true); + }); + + it("reads the level and time from JSON lines", () => { + const line = + '{"level":"error","time":"2026-10-08T10:00:01Z","msg":"Unable to obtain ACME certificate"}'; + + expect(readLogLevel(line)).toBe("error"); + expect(readLogTime(line)).toBe("2026-10-08T10:00:01Z"); + }); + + it("does not treat an info line as an error", () => { + expect(isErrorLine('time="2026-10-08T10:00:00Z" level=info msg="ok"')).toBe( + false, + ); + }); +}); + +describe("Traefik log domain extraction", () => { + it("takes the domain from a domains field", () => { + const line = + 'time="2026-10-08T10:00:00Z" level=error msg="Unable to obtain ACME certificate for domains" ACME.domains=[example.test] error="timeout"'; + + expect(extractLogDomain(line, new Set())).toBe("example.test"); + }); + + it("prefers a host the panel already knows over a guess", () => { + const line = + 'level=error msg="Unable to obtain ACME certificate for domains" domains=[other.example.test www.example.test]'; + + expect(extractLogDomain(line, new Set(["www.example.test"]))).toBe( + "www.example.test", + ); + }); + + it("ignores the Let's Encrypt provider name even on a domains line", () => { + const line = + 'level=error msg="renewal failed for domains" providerName=letsencrypt.acme'; + + expect(extractLogDomain(line, new Set())).toBeNull(); + }); + + it("does not take the ACME directory host as the domain", () => { + const line = + 'level=error msg="Get \\"https://acme-v02.example.test/directory\\": context deadline exceeded"'; + + expect(extractLogDomain(line, new Set())).toBeNull(); + }); + + it("does not guess a domain on a line that does not say domain", () => { + const line = + 'level=error msg="acme: error 400 while reading response from host.example.test"'; + + expect(extractLogDomain(line, new Set())).toBeNull(); + }); +}); diff --git a/apps/dokploy/__test__/domain-overview/traefik-routes.test.ts b/apps/dokploy/__test__/domain-overview/traefik-routes.test.ts new file mode 100644 index 00000000000..579bf5c64f1 --- /dev/null +++ b/apps/dokploy/__test__/domain-overview/traefik-routes.test.ts @@ -0,0 +1,127 @@ +import { + buildTraefikRoutesCommand, + extractRuleHosts, + parseTraefikRoutes, +} from "@dokploy/server/utils/domain-overview/traefik-routes"; +import { describe, expect, it } from "vitest"; + +const inspectLine = (name: string, labels: Record | null) => + `/${name}|${JSON.stringify(labels)}`; + +describe("traefik route label parser", () => { + it("reads a single Host rule with its compose project", () => { + const output = inspectLine("shop-web-1", { + "traefik.enable": "true", + "traefik.http.routers.shop-web-router-1.rule": + "Host(`shop.example.test`)", + "com.docker.compose.project": "shop-abc123", + }); + + expect(parseTraefikRoutes(output)).toEqual([ + { + host: "shop.example.test", + containerName: "shop-web-1", + composeProject: "shop-abc123", + serviceName: null, + }, + ]); + }); + + it("splits a multi-host rule joined with || into one route per host", () => { + const output = inspectLine("api-1", { + "traefik.http.routers.api.rule": + "Host(`api.example.test`) || Host(`api2.example.test`)", + }); + + expect(parseTraefikRoutes(output).map((route) => route.host)).toEqual([ + "api.example.test", + "api2.example.test", + ]); + }); + + it("reads hosts listed inside a single Host matcher", () => { + expect( + extractRuleHosts("Host(`one.example.test`, `two.example.test`)"), + ).toEqual(["one.example.test", "two.example.test"]); + }); + + it("ignores containers whose labels have no HTTP router rule", () => { + const output = inspectLine("worker-1", { + "traefik.enable": "true", + "traefik.http.services.worker.loadbalancer.server.port": "3000", + "traefik.tcp.routers.worker.rule": "HostSNI(`tcp.example.test`)", + }); + + expect(parseTraefikRoutes(output)).toEqual([]); + }); + + it("skips containers that Traefik is told to ignore", () => { + const output = inspectLine("hidden-1", { + "traefik.enable": "false", + "traefik.http.routers.hidden.rule": "Host(`hidden.example.test`)", + }); + + expect(parseTraefikRoutes(output)).toEqual([]); + }); + + it("reads the swarm service name when there is no compose project", () => { + const output = inspectLine("swarm-app.1.abc", { + "traefik.http.routers.swarm-app.rule": "Host(`swarm.example.test`)", + "com.docker.swarm.service.name": "swarm-app", + }); + + expect(parseTraefikRoutes(output)).toEqual([ + { + host: "swarm.example.test", + containerName: "swarm-app.1.abc", + composeProject: null, + serviceName: "swarm-app", + }, + ]); + }); + + it("treats a container without labels as having no routes", () => { + expect(parseTraefikRoutes(inspectLine("bare-1", null))).toEqual([]); + }); + + it("reports a host once per container even when two routers repeat it", () => { + const output = inspectLine("dup-1", { + "traefik.http.routers.a.rule": "Host(`dup.example.test`)", + "traefik.http.routers.b.rule": "Host(`dup.example.test`)", + }); + + expect(parseTraefikRoutes(output)).toHaveLength(1); + }); + + it("attaches every host of a || rule to the same container", () => { + const output = inspectLine("pipe-1", { + "traefik.http.routers.pipe.rule": + "Host(`pipe.example.test`) || Host(`pipe2.example.test`)", + }); + + expect( + parseTraefikRoutes(output).map((route) => route.containerName), + ).toEqual(["pipe-1", "pipe-1"]); + }); + + it("ignores blank and malformed lines", () => { + const output = [ + "", + "no separator here", + "/broken-1|{not json", + inspectLine("ok-1", { + "traefik.http.routers.ok.rule": "Host(`ok.example.test`)", + }), + ].join("\n"); + + expect(parseTraefikRoutes(output).map((route) => route.host)).toEqual([ + "ok.example.test", + ]); + }); + + it("builds the docker inspect command with the label format", () => { + expect(buildTraefikRoutesCommand()).toBe( + "docker ps -q | xargs -r docker inspect --format '{{.Name}}|{{json .Config.Labels}}'", + ); + }); +}); diff --git a/apps/dokploy/components/dashboard/domains/show-domain-overview.tsx b/apps/dokploy/components/dashboard/domains/show-domain-overview.tsx new file mode 100644 index 00000000000..262b6604693 --- /dev/null +++ b/apps/dokploy/components/dashboard/domains/show-domain-overview.tsx @@ -0,0 +1,398 @@ +import { RefreshCw } from "lucide-react"; +import Link from "next/link"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { cn } from "@/lib/utils"; +import { api, type RouterOutputs } from "@/utils/api"; +import { ShowTraefikLogs } from "./show-traefik-logs"; + +type Overview = RouterOutputs["domainOverview"]["list"]; +type Row = Overview["rows"][number]; +type DnsStatus = Row["dns"]["status"]; + +const DNS_STATUS: Record = { + points_to_this_server: { + label: "Points to this server", + className: "text-emerald-600 dark:text-emerald-400", + }, + points_to_other_address: { + label: "Points to another address", + className: "text-yellow-600 dark:text-yellow-400", + }, + server_address_unknown: { + label: "Server address unknown", + className: "text-muted-foreground", + }, + nxdomain: { + label: "No DNS record (NXDOMAIN)", + className: "text-red-600 dark:text-red-400", + }, + no_address: { + label: "Record exists but has no address", + className: "text-red-600 dark:text-red-400", + }, + lookup_failed: { + label: "Lookup failed or timed out", + className: "text-muted-foreground", + }, + not_checked: { + label: "Not checked (wildcard)", + className: "text-muted-foreground", + }, +}; + +const formatTime = (value: string | null) => { + if (!value) { + return "Time unknown"; + } + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); +}; + +const serviceHref = ( + projectId: string, + environmentId: string, + ownerType: string, + ownerId: string, +) => + `/dashboard/project/${projectId}/environment/${environmentId}/services/${ownerType}/${ownerId}`; + +const dockerHref = (serverId: string | null) => + serverId ? `/dashboard/docker?serverId=${serverId}` : "/dashboard/docker"; + +const UsedBy = ({ row }: { row: Row }) => { + if (row.panelUses.length === 0 && row.routeUses.length === 0) { + return ( + + Not used by any route or panel domain + + ); + } + const panelOwnerIds = new Set(row.panelUses.map((use) => use.ownerId)); + const routeOnly = row.routeUses.filter( + (use) => !use.owner || !panelOwnerIds.has(use.owner.ownerId), + ); + + return ( +
+ {row.panelUses.map((use) => ( +
+ + {use.ownerName} + + {use.ownerType} + {use.domainType === "preview" && ( + preview + )} + {!use.enabled && disabled} +
+ ))} + {routeOnly.map((use, index) => + use.owner ? ( +
+ + {use.owner.ownerName} + + {use.owner.ownerType} + via route +
+ ) : ( +
+ + {use.containerName} + + {use.composeProject && ( + project {use.composeProject} + )} + + Docker containers + +
+ ), + )} +
+ ); +}; + +const DnsCell = ({ dns }: { dns: Row["dns"] }) => { + const status = DNS_STATUS[dns.status]; + return ( +
+ + {status.label} + + {dns.addresses.length > 0 && ( +
+ {dns.addresses.map((address) => ( + + {address} + + ))} +
+ )} +
+ ); +}; + +const CertificateCell = ({ + row, + certificatesVisible, +}: { + row: Row; + certificatesVisible: boolean; +}) => { + const { certificate } = row; + if (!certificatesVisible) { + return ( + + Hidden without a super session + + ); + } + if (!certificate) { + return ( + No certificate + ); + } + const date = certificate.validTo.slice(0, 10); + const toneClassName = + certificate.tone === "critical" + ? "text-red-600 dark:text-red-400" + : certificate.tone === "warning" + ? "text-yellow-600 dark:text-yellow-400" + : ""; + return ( +
+ {row.certificateOnly && ( + + certificate only, nothing uses it + + )} + + {certificate.daysLeft < 0 + ? `expired on ${date}` + : `expires on ${date}, ${certificate.daysLeft} days left`} + + + {certificate.issuer} + +
+ ); +}; + +const LastErrorCell = ({ error }: { error: Row["lastError"] }) => { + if (!error) { + return ( + + No error in the recent Traefik log + + ); + } + return ( +
+ + {formatTime(error.time)} + + + {error.message} + + {error.explanation} +
+ ); +}; + +const DomainRow = ({ + row, + certificatesVisible, +}: { + row: Row; + certificatesVisible: boolean; +}) => ( + + +
+ {row.domain} + {row.domain.startsWith("*.") && wildcard} +
+
+ +
+ {row.serverName} + {row.isLocal && Local} +
+
+ + + + + + + + + + + + +
+); + +const DomainsTable = ({ + rows, + certificatesVisible, +}: { + rows: Row[]; + certificatesVisible: boolean; +}) => ( +
+ + + + Domain + Server + Used by + DNS + Certificate + Last Traefik error + + + + {rows.length === 0 ? ( + + + No domains found on this instance. + + + ) : ( + rows.map((row) => ( + + )) + )} + +
+
+); + +export const ShowDomainOverview = () => { + const { data, error, isLoading, isFetching, refetch } = + api.domainOverview.list.useQuery(undefined, { + refetchOnWindowFocus: false, + }); + + return ( +
+ +
+
+
+

Domains

+

+ Every domain on this instance with its DNS record, certificate + and last Traefik error. Read only. +

+
+
+ {data && ( + + Checked {formatTime(data.generatedAt)} + + )} + +
+
+ + {error && {error.message}} + {data?.serverErrors.map((item) => ( + + {item.serverName}: {item.message} + + ))} + {isLoading && ( +

+ Checking DNS, certificates and Traefik logs. A slow server can + delay this by up to 15 seconds. +

+ )} + {data && !data.certificatesVisible && ( + + Certificate dates are hidden. Traefik keeps them in its + certificate store, which only an owner or admin with an open super + session can read. + + )} + {data && ( + + )} +
+
+ + {data?.servers.map((server) => ( + + ))} +
+ ); +}; diff --git a/apps/dokploy/components/dashboard/domains/show-traefik-logs.tsx b/apps/dokploy/components/dashboard/domains/show-traefik-logs.tsx new file mode 100644 index 00000000000..da34158600d --- /dev/null +++ b/apps/dokploy/components/dashboard/domains/show-traefik-logs.tsx @@ -0,0 +1,74 @@ +import { RefreshCw } from "lucide-react"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; +import { api } from "@/utils/api"; + +interface Props { + serverId: string | null; + serverName: string; +} + +export const ShowTraefikLogs = ({ serverId, serverName }: Props) => { + const { data, error, isFetching, refetch } = + api.domainOverview.traefikLogs.useQuery( + { serverId }, + { refetchOnWindowFocus: false }, + ); + + return ( + +
+
+
+

Traefik log: {serverName}

+

+ The last 300 lines that mention certificates or ACME, from the + last 7 days. Read only. +

+
+ +
+ + {error && {error.message}} + {data?.error && {data.error}} + {data && !data.containerFound && ( + + The dokploy-traefik container was not found on this server. + + )} + + {data && + data.lines.length === 0 && + data.containerFound && + !data.error && ( +

+ No certificate or ACME lines in the recent log. +

+ )} + + {data && data.lines.length > 0 && ( +
+						{data.lines.map((line, index) => (
+							
+ {line.text} +
+ ))} +
+ )} +
+
+ ); +}; diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index 3b8a54a8634..e3265678e0a 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -27,6 +27,7 @@ import { Loader2, LogIn, type LucideIcon, + Network, Package, Palette, Server, @@ -210,6 +211,14 @@ const MENU: Menu = { // Only enabled for users with access to Traefik files isEnabled: ({ permissions }) => !!permissions?.traefikFiles.read, }, + { + isSingle: true, + title: "Domains", + url: "/dashboard/domains", + icon: Network, + // Same gate as domainOverview: the page lists the routes and certificates that readTraefikFile guards + isEnabled: ({ permissions }) => !!permissions?.traefikFiles.read, + }, { isSingle: true, title: "Docker", diff --git a/apps/dokploy/pages/dashboard/domains.tsx b/apps/dokploy/pages/dashboard/domains.tsx new file mode 100644 index 00000000000..b375b18e1aa --- /dev/null +++ b/apps/dokploy/pages/dashboard/domains.tsx @@ -0,0 +1,51 @@ +import { validateRequest } from "@dokploy/server/lib/auth"; +import { createServerSideHelpers } from "@trpc/react-query/server"; +import type { GetServerSidePropsContext } from "next"; +import type { ReactElement } from "react"; +import superjson from "superjson"; +import { ShowDomainOverview } from "@/components/dashboard/domains/show-domain-overview"; +import { DashboardLayout } from "@/components/layouts/dashboard-layout"; +import { appRouter } from "@/server/api/root"; + +export default function Domains() { + return ; +} + +Domains.getLayout = (page: ReactElement) => { + return {page}; +}; + +export async function getServerSideProps(ctx: GetServerSidePropsContext) { + const { user, session } = await validateRequest(ctx.req); + const redirectHome = { + redirect: { + permanent: false, + destination: "/", + }, + }; + if (!user) { + return redirectHome; + } + + const helpers = createServerSideHelpers({ + router: appRouter, + ctx: { + req: ctx.req as any, + res: ctx.res as any, + db: null as any, + session: session as any, + user: user as any, + }, + transformer: superjson, + }); + const permissions = await helpers.user.getPermissions + .fetch() + .catch(() => null); + if (!permissions?.traefikFiles.read) { + return redirectHome; + } + + return { + props: {}, + }; +} diff --git a/apps/dokploy/server/api/root.ts b/apps/dokploy/server/api/root.ts index ff226348822..db75e3a855c 100644 --- a/apps/dokploy/server/api/root.ts +++ b/apps/dokploy/server/api/root.ts @@ -15,6 +15,7 @@ import { dockerDiskUsageRouter } from "./routers/docker-disk-usage"; import { dockerImageRouter } from "./routers/docker-image"; import { dockerVolumeRouter } from "./routers/docker-volume"; import { domainRouter } from "./routers/domain"; +import { domainOverviewRouter } from "./routers/domain-overview"; import { envRouter } from "./routers/env"; import { environmentRouter } from "./routers/environment"; import { gitProviderRouter } from "./routers/git-provider"; @@ -82,6 +83,7 @@ export const appRouter = createTRPCRouter({ dockerImage: dockerImageRouter, dockerVolume: dockerVolumeRouter, domain: domainRouter, + domainOverview: domainOverviewRouter, env: envRouter, gitea: giteaRouter, gitProvider: gitProviderRouter, diff --git a/apps/dokploy/server/api/routers/domain-overview.ts b/apps/dokploy/server/api/routers/domain-overview.ts new file mode 100644 index 00000000000..b3434379ed2 --- /dev/null +++ b/apps/dokploy/server/api/routers/domain-overview.ts @@ -0,0 +1,31 @@ +import { + getDomainOverview, + getTraefikLogs, +} from "@dokploy/server/services/domain-overview"; +import { checkPermission } from "@dokploy/server/services/permission"; +import { z } from "zod"; +import { hasProtectedResourceAccess } from "@/server/api/utils/super-session"; +import { + assertTraefikFilesHostAccess, + hasTraefikFilesHostAccess, +} from "@/server/api/utils/traefik-files-access"; +import { createTRPCRouter, protectedProcedure } from "../trpc"; + +export const domainOverviewRouter = createTRPCRouter({ + list: protectedProcedure.query(async ({ ctx }) => { + await checkPermission(ctx, { traefikFiles: ["read"] }); + return getDomainOverview(ctx.session.activeOrganizationId, { + canReadHost: (serverId) => + hasTraefikFilesHostAccess(ctx, serverId ?? undefined), + canReadCertificates: await hasProtectedResourceAccess(ctx), + }); + }), + traefikLogs: protectedProcedure + .input(z.object({ serverId: z.string().min(1).nullish() })) + .query(async ({ ctx, input }) => { + const serverId = input.serverId ?? null; + await checkPermission(ctx, { traefikFiles: ["read"] }); + await assertTraefikFilesHostAccess(ctx, serverId ?? undefined); + return getTraefikLogs(ctx.session.activeOrganizationId, serverId); + }), +}); diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts index 39e1f2b2b90..9c6df3643eb 100644 --- a/apps/dokploy/server/api/routers/settings.ts +++ b/apps/dokploy/server/api/routers/settings.ts @@ -19,7 +19,6 @@ import { dockerDiskUsageDetailLimitSchema, filterProtectedTraefikEntries, findServerById, - getAccessibleServerIds, getAgentHitsUpdateCommand, getAgentHitsUpdateImage, getDockerDiskUsage, @@ -88,6 +87,10 @@ import { assertProtectedResourceAccess, hasProtectedResourceAccess, } from "@/server/api/utils/super-session"; +import { + assertSettingsServerAccess, + assertTraefikFilesHostAccess, +} from "@/server/api/utils/traefik-files-access"; import { apiAssignDomain, apiEnableDashboard, @@ -114,46 +117,6 @@ import { publicProcedure, } from "../trpc"; -const assertSettingsServerAccess = async ( - ctx: { - session: { - userId: string; - activeOrganizationId: string; - }; - }, - serverId?: string, -) => { - if (!serverId) { - return; - } - - const accessibleIds = await getAccessibleServerIds(ctx.session); - if (!accessibleIds.has(serverId)) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "You are not authorized to access this server", - }); - } -}; - -const assertTraefikFilesHostAccess = async ( - ctx: { - user: { id: string }; - session: { - userId: string; - activeOrganizationId: string; - }; - }, - serverId?: string, -) => { - if (!serverId) { - await assertLocalHostAccess(ctx); - return; - } - - await assertSettingsServerAccess(ctx, serverId); -}; - export const settingsRouter = createTRPCRouter({ getWebServerSettings: protectedProcedure.query(async () => { if (IS_CLOUD) { diff --git a/apps/dokploy/server/api/utils/procedure-access.ts b/apps/dokploy/server/api/utils/procedure-access.ts index dba6d6da27e..2f3055e6815 100644 --- a/apps/dokploy/server/api/utils/procedure-access.ts +++ b/apps/dokploy/server/api/utils/procedure-access.ts @@ -270,6 +270,8 @@ export const PROCEDURE_ACCESS: Record = { "domain.toggleEnable": "write", "domain.update": "write", "domain.validateDomain": "write", + "domainOverview.list": "read", + "domainOverview.traefikLogs": "read", "env.applyChanges": "write", "env.keys": "read", "environment.byProjectId": "read", diff --git a/apps/dokploy/server/api/utils/traefik-files-access.ts b/apps/dokploy/server/api/utils/traefik-files-access.ts new file mode 100644 index 00000000000..99ead2d3fb1 --- /dev/null +++ b/apps/dokploy/server/api/utils/traefik-files-access.ts @@ -0,0 +1,55 @@ +import { getAccessibleServerIds } from "@dokploy/server"; +import { TRPCError } from "@trpc/server"; +import { assertLocalHostAccess } from "@/server/api/utils/local-host-access"; + +type ServerAccessCtx = { + session: { + userId: string; + activeOrganizationId: string; + }; +}; + +type TraefikFilesHostCtx = ServerAccessCtx & { + user: { id: string }; +}; + +export const assertSettingsServerAccess = async ( + ctx: ServerAccessCtx, + serverId?: string, +) => { + if (!serverId) { + return; + } + + const accessibleIds = await getAccessibleServerIds(ctx.session); + if (!accessibleIds.has(serverId)) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to access this server", + }); + } +}; + +export const assertTraefikFilesHostAccess = async ( + ctx: TraefikFilesHostCtx, + serverId?: string, +) => { + if (!serverId) { + await assertLocalHostAccess(ctx); + return; + } + + await assertSettingsServerAccess(ctx, serverId); +}; + +export const hasTraefikFilesHostAccess = async ( + ctx: TraefikFilesHostCtx, + serverId?: string, +) => { + try { + await assertTraefikFilesHostAccess(ctx, serverId); + return true; + } catch { + return false; + } +}; diff --git a/packages/server/src/services/domain-overview.ts b/packages/server/src/services/domain-overview.ts new file mode 100644 index 00000000000..29e8459de38 --- /dev/null +++ b/packages/server/src/services/domain-overview.ts @@ -0,0 +1,721 @@ +import { isIP } from "node:net"; +import { IS_CLOUD, paths } from "@dokploy/server/constants"; +import { db } from "@dokploy/server/db"; +import { + applications, + compose, + environments, + projects, + server, +} from "@dokploy/server/db/schema"; +import { getAllDomainsForOrganization } from "@dokploy/server/services/overview"; +import { + ACME_FILE_MISSING_EXIT_CODE, + ACME_FILE_NAME, + type AcmeCertificate, + AcmeFileFormatError, + buildAcmeReadCommand, + type CertificateTone, + certificateTone, + daysUntil, + findCertificateFor, + normalizeDomainName, + parseAcmeCertificates, +} from "@dokploy/server/utils/domain-overview/acme"; +import { + type DnsRecords, + type DnsStatus, + mapDnsStatus, + resolveDomainRecords, + resolvedAddresses, +} from "@dokploy/server/utils/domain-overview/dns-status"; +import { + classifyTraefikError, + type TraefikErrorCause, +} from "@dokploy/server/utils/domain-overview/traefik-errors"; +import { + buildTraefikLogsCommand, + extractLogDomain, + isErrorLine, + readLogLevel, + readLogTime, + selectAcmeLogLines, + TRAEFIK_CONTAINER_NAME, +} from "@dokploy/server/utils/domain-overview/traefik-logs"; +import { + buildTraefikRoutesCommand, + parseTraefikRoutes, + type TraefikRoute, +} from "@dokploy/server/utils/domain-overview/traefik-routes"; +import { + OperationTimeoutError, + withTimeout, +} from "@dokploy/server/utils/domain-overview/with-timeout"; +import { ExecError } from "@dokploy/server/utils/process/ExecError"; +import { + execAsync, + execAsyncRemote, +} from "@dokploy/server/utils/process/execAsync"; +import { getPublicIpWithFallback } from "@dokploy/server/wss/utils"; +import { TRPCError } from "@trpc/server"; +import { and, eq } from "drizzle-orm"; + +const LOCAL_KEY = "local"; +const LOCAL_NAME = "Dokploy server"; +const SERVER_COMMAND_TIMEOUT_MS = 15_000; +const LOCAL_ADDRESS_TIMEOUT_MS = 10_000; +const LOCAL_ADDRESS_CACHE_TTL_MS = 10 * 60 * 1000; +const LOG_LINE_MAX_LENGTH = 2000; + +export type DomainOverviewServer = { + serverId: string | null; + name: string; + address: string | null; + isLocal: boolean; +}; + +type ServerTarget = DomainOverviewServer & { key: string }; + +type ServiceOwner = { + ownerType: "application" | "compose"; + ownerId: string; + ownerName: string; + projectId: string; + environmentId: string; +}; + +export type DomainPanelUse = ServiceOwner & { + domainType: string | null; + enabled: boolean; +}; + +export type DomainRouteUse = { + containerName: string; + composeProject: string | null; + serviceName: string | null; + owner: ServiceOwner | null; +}; + +export type DomainCertificateView = { + validTo: string; + daysLeft: number; + tone: CertificateTone; + issuer: string; +}; + +export type DomainErrorView = { + time: string | null; + message: string; + cause: TraefikErrorCause; + explanation: string; +}; + +export type DomainOverviewRow = { + key: string; + domain: string; + serverId: string | null; + serverName: string; + isLocal: boolean; + panelUses: DomainPanelUse[]; + routeUses: DomainRouteUse[]; + dns: { status: DnsStatus; addresses: string[] }; + certificate: DomainCertificateView | null; + certificateOnly: boolean; + lastError: DomainErrorView | null; +}; + +export type DomainOverviewAccess = { + canReadHost: (serverId: string | null) => Promise; + canReadCertificates: boolean; +}; + +export type DomainOverview = { + generatedAt: string; + certificatesVisible: boolean; + servers: DomainOverviewServer[]; + serverErrors: { + serverId: string | null; + serverName: string; + message: string; + }[]; + rows: DomainOverviewRow[]; +}; + +export type TraefikLogLine = { + time: string | null; + level: string | null; + domain: string | null; + text: string; +}; + +export type TraefikLogsResult = { + serverId: string | null; + serverName: string; + checkedAt: string; + containerFound: boolean; + error: string | null; + lines: TraefikLogLine[]; +}; + +type Workload = ServiceOwner & { + serverKey: string; + appName: string; + type: "application" | "compose"; +}; + +type ServerReads = { + target: ServerTarget; + routes: TraefikRoute[]; + certificates: AcmeCertificate[]; + logLines: string[]; + errors: string[]; +}; + +type MutableRow = { + key: string; + domain: string; + target: ServerTarget; + panelUses: DomainPanelUse[]; + routeUses: DomainRouteUse[]; + lastError: DomainErrorView | null; +}; + +const truncate = (value: string) => + value.length > LOG_LINE_MAX_LENGTH + ? `${value.slice(0, LOG_LINE_MAX_LENGTH)}...` + : value; + +let localAddressCache: { address: string; expiresAt: number } | null = null; + +const readLocalAddress = async (): Promise => { + const settings = await db.query.webServerSettings.findFirst({ + columns: { serverIp: true }, + orderBy: (table, { asc }) => [asc(table.createdAt)], + }); + const configured = settings?.serverIp?.trim(); + if (configured) { + return isIP(configured) === 0 ? null : configured; + } + + const now = Date.now(); + if (localAddressCache && localAddressCache.expiresAt > now) { + return localAddressCache.address; + } + const detected = await withTimeout( + getPublicIpWithFallback(), + LOCAL_ADDRESS_TIMEOUT_MS, + ).catch(() => null); + if (detected) { + localAddressCache = { + address: detected, + expiresAt: now + LOCAL_ADDRESS_CACHE_TTL_MS, + }; + } + return detected; +}; + +const loadTargets = async (orgId: string): Promise => { + const servers = await db.query.server.findMany({ + where: eq(server.organizationId, orgId), + columns: { serverId: true, name: true, ipAddress: true }, + orderBy: (table, { asc }) => [asc(table.name)], + }); + const remote = servers.map( + (row): ServerTarget => ({ + key: row.serverId, + serverId: row.serverId, + name: row.name, + address: isIP(row.ipAddress) === 0 ? null : row.ipAddress, + isLocal: false, + }), + ); + if (IS_CLOUD) { + return remote; + } + return [ + { + key: LOCAL_KEY, + serverId: null, + name: LOCAL_NAME, + address: null, + isLocal: true, + }, + ...remote, + ]; +}; + +const loadWorkloads = async (orgId: string): Promise => { + const [applicationRows, composeRows] = await Promise.all([ + db + .select({ + id: applications.applicationId, + name: applications.name, + appName: applications.appName, + serverId: applications.serverId, + environmentId: environments.environmentId, + projectId: projects.projectId, + }) + .from(applications) + .innerJoin( + environments, + eq(applications.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where(eq(projects.organizationId, orgId)), + db + .select({ + id: compose.composeId, + name: compose.name, + appName: compose.appName, + serverId: compose.serverId, + environmentId: environments.environmentId, + projectId: projects.projectId, + }) + .from(compose) + .innerJoin( + environments, + eq(compose.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where(eq(projects.organizationId, orgId)), + ]); + + const toWorkload = ( + type: Workload["type"], + row: { + id: string; + name: string; + appName: string; + serverId: string | null; + environmentId: string; + projectId: string; + }, + ): Workload => ({ + type, + ownerType: type, + ownerId: row.id, + ownerName: row.name, + appName: row.appName, + serverKey: row.serverId ?? LOCAL_KEY, + projectId: row.projectId, + environmentId: row.environmentId, + }); + + return [ + ...applicationRows.map((row) => toWorkload("application", row)), + ...composeRows.map((row) => toWorkload("compose", row)), + ]; +}; + +const runOnServer = (target: ServerTarget, command: string) => + withTimeout( + target.isLocal + ? execAsync(command) + : execAsyncRemote(target.serverId, command), + SERVER_COMMAND_TIMEOUT_MS, + ); + +const describeFailure = (error: unknown, target: ServerTarget) => { + if (error instanceof OperationTimeoutError) { + return `no answer within ${SERVER_COMMAND_TIMEOUT_MS / 1000} s`; + } + if (error instanceof AcmeFileFormatError) { + return error.message; + } + if ( + error instanceof Error && + error.message === "No SSH key available for this server" + ) { + return "no SSH key is set for this server"; + } + if (error instanceof ExecError) { + if (error.exitCode !== undefined) { + return `command exited with code ${error.exitCode}`; + } + return target.isLocal + ? "command failed" + : "could not run the command over SSH"; + } + return "unexpected error"; +}; + +const readStage = async ( + target: ServerTarget, + stage: string, + fallback: T, + read: () => Promise, +) => { + try { + return { value: await read(), error: null }; + } catch (error) { + return { + value: fallback, + error: `${stage}: ${describeFailure(error, target)}`, + }; + } +}; + +const readRoutes = async (target: ServerTarget) => { + const { stdout } = await runOnServer(target, buildTraefikRoutesCommand()); + return parseTraefikRoutes(stdout); +}; + +const readCertificates = async (target: ServerTarget) => { + const dynamicPath = paths(!target.isLocal).DYNAMIC_TRAEFIK_PATH; + try { + const { stdout } = await runOnServer( + target, + buildAcmeReadCommand(`${dynamicPath}/${ACME_FILE_NAME}`), + ); + return parseAcmeCertificates(stdout); + } catch (error) { + if ( + error instanceof ExecError && + error.exitCode === ACME_FILE_MISSING_EXIT_CODE + ) { + return []; + } + throw error; + } +}; + +type LogOutput = { containerFound: boolean; output: string }; + +const readLogOutput = async (target: ServerTarget): Promise => { + try { + const { stdout } = await runOnServer(target, buildTraefikLogsCommand()); + return { containerFound: true, output: stdout }; + } catch (error) { + if ( + error instanceof ExecError && + /No such container/i.test(error.stdout ?? "") + ) { + return { containerFound: false, output: "" }; + } + throw error; + } +}; + +const readServer = async ( + target: ServerTarget, + includeCertificates: boolean, +): Promise => { + const [routes, certificates, logs] = await Promise.all([ + readStage(target, "Routes", [] as TraefikRoute[], () => readRoutes(target)), + includeCertificates + ? readStage(target, "Certificates", [] as AcmeCertificate[], () => + readCertificates(target), + ) + : { value: [] as AcmeCertificate[], error: null }, + readStage( + target, + "Traefik logs", + { containerFound: false, output: "" }, + () => readLogOutput(target), + ), + ]); + return { + target, + routes: routes.value, + certificates: certificates.value, + logLines: selectAcmeLogLines(logs.value.output), + errors: [routes.error, certificates.error, logs.error].filter( + (error): error is string => error !== null, + ), + }; +}; + +const isDnsCheckable = (domain: string) => !domain.includes("*"); + +export const getDomainOverview = async ( + orgId: string, + access: DomainOverviewAccess, +): Promise => { + const allTargets = await loadTargets(orgId); + const readable = await Promise.all( + allTargets.map((target) => access.canReadHost(target.serverId)), + ); + const serverTargets = allTargets.filter((_, index) => readable[index]); + const localAddressPromise = serverTargets.some((target) => target.isLocal) + ? readLocalAddress() + : Promise.resolve(null); + const snapshotsPromise = Promise.all( + serverTargets.map((target) => + readServer(target, access.canReadCertificates), + ), + ); + const [workloads, domainRecords, localAddress] = await Promise.all([ + loadWorkloads(orgId), + getAllDomainsForOrganization(orgId, null), + localAddressPromise, + ]); + const targets = serverTargets.map((target) => + target.isLocal ? { ...target, address: localAddress } : target, + ); + const snapshots = await snapshotsPromise; + const now = new Date(); + + const targetByKey = new Map(targets.map((target) => [target.key, target])); + const workloadById = new Map( + workloads.map((workload) => [workload.ownerId, workload]), + ); + const composeByAppName = new Map( + workloads + .filter((workload) => workload.type === "compose") + .map((workload) => [ + `${workload.serverKey}|${workload.appName}`, + workload, + ]), + ); + const applicationByAppName = new Map( + workloads + .filter((workload) => workload.type === "application") + .map((workload) => [ + `${workload.serverKey}|${workload.appName}`, + workload, + ]), + ); + + const rows = new Map(); + const rowFor = (target: ServerTarget, domain: string): MutableRow => { + const key = `${target.key}|${domain}`; + const existing = rows.get(key); + if (existing) { + return existing; + } + const row: MutableRow = { + key, + domain, + target, + panelUses: [], + routeUses: [], + lastError: null, + }; + rows.set(key, row); + return row; + }; + + for (const record of domainRecords) { + const owner = workloadById.get(record.serviceOwnerId); + const target = targetByKey.get(owner?.serverKey ?? LOCAL_KEY); + if (!target) { + continue; + } + rowFor(target, normalizeDomainName(record.host)).panelUses.push({ + ownerType: record.serviceOwnerType, + ownerId: record.serviceOwnerId, + ownerName: record.serviceName, + projectId: record.projectId, + environmentId: record.environmentId, + domainType: record.domainType, + enabled: record.enabled, + }); + } + + for (const snapshot of snapshots) { + const serverKey = snapshot.target.key; + for (const route of snapshot.routes) { + const composeOwner = route.composeProject + ? composeByAppName.get(`${serverKey}|${route.composeProject}`) + : undefined; + const applicationOwner = route.serviceName + ? applicationByAppName.get(`${serverKey}|${route.serviceName}`) + : undefined; + const owner = composeOwner ?? applicationOwner ?? null; + rowFor(snapshot.target, route.host).routeUses.push({ + containerName: route.containerName, + composeProject: route.composeProject, + serviceName: route.serviceName, + owner, + }); + } + for (const certificate of snapshot.certificates) { + for (const name of certificate.names) { + rowFor(snapshot.target, name); + } + } + } + + for (const snapshot of snapshots) { + const serverKey = snapshot.target.key; + const knownHosts = new Set( + [...rows.values()] + .filter((row) => row.target.key === serverKey) + .map((row) => row.domain), + ); + for (const line of snapshot.logLines) { + if (!isErrorLine(line)) { + continue; + } + const domain = extractLogDomain(line, knownHosts); + if (!domain) { + continue; + } + const classified = classifyTraefikError(line); + rowFor(snapshot.target, domain).lastError = { + time: readLogTime(line), + message: truncate(line), + cause: classified.cause, + explanation: classified.explanation, + }; + } + } + + const domains = [...new Set([...rows.values()].map((row) => row.domain))]; + const recordsByDomain = new Map( + await Promise.all( + domains + .filter(isDnsCheckable) + .map( + async (domain) => + [domain, await resolveDomainRecords(domain)] as const, + ), + ), + ); + + const certificatesByServer = new Map( + snapshots.map((snapshot) => [snapshot.target.key, snapshot.certificates]), + ); + const serverOrder = new Map( + targets.map((target, index) => [target.key, index]), + ); + + const outputRows: DomainOverviewRow[] = [...rows.values()] + .sort( + (a, b) => + (serverOrder.get(a.target.key) ?? 0) - + (serverOrder.get(b.target.key) ?? 0) || + a.domain.localeCompare(b.domain), + ) + .map((row) => { + const records = recordsByDomain.get(row.domain); + const dns = records + ? { + status: mapDnsStatus({ + records, + serverAddress: row.target.address, + }), + addresses: resolvedAddresses(records), + } + : { status: "not_checked" as const, addresses: [] }; + + const certificate = findCertificateFor( + certificatesByServer.get(row.target.key) ?? [], + row.domain, + ); + const daysLeft = certificate ? daysUntil(certificate.validTo, now) : null; + const isWildcard = row.domain.startsWith("*."); + const unused = row.panelUses.length === 0 && row.routeUses.length === 0; + + return { + key: row.key, + domain: row.domain, + serverId: row.target.serverId, + serverName: row.target.name, + isLocal: row.target.isLocal, + panelUses: row.panelUses, + routeUses: row.routeUses, + dns, + certificate: + certificate && daysLeft !== null + ? { + validTo: certificate.validTo.toISOString(), + daysLeft, + tone: certificateTone(daysLeft), + issuer: certificate.issuer, + } + : null, + certificateOnly: Boolean(certificate) && unused && !isWildcard, + lastError: row.lastError, + }; + }); + + return { + generatedAt: now.toISOString(), + certificatesVisible: access.canReadCertificates, + servers: targets.map(({ serverId, name, address, isLocal }) => ({ + serverId, + name, + address, + isLocal, + })), + serverErrors: snapshots.flatMap((snapshot) => + snapshot.errors.map((message) => ({ + serverId: snapshot.target.serverId, + serverName: snapshot.target.name, + message, + })), + ), + rows: outputRows, + }; +}; + +const resolveTarget = async ( + orgId: string, + serverId: string | null, +): Promise => { + if (serverId === null) { + if (IS_CLOUD) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "This instance has no local server", + }); + } + return { + key: LOCAL_KEY, + serverId: null, + name: LOCAL_NAME, + address: null, + isLocal: true, + }; + } + const found = await db.query.server.findFirst({ + where: and(eq(server.serverId, serverId), eq(server.organizationId, orgId)), + columns: { serverId: true, name: true }, + }); + if (!found) { + throw new TRPCError({ code: "NOT_FOUND", message: "Server not found" }); + } + return { + key: found.serverId, + serverId: found.serverId, + name: found.name, + address: null, + isLocal: false, + }; +}; + +export const getTraefikLogs = async ( + orgId: string, + serverId: string | null, +): Promise => { + const target = await resolveTarget(orgId, serverId); + const base = { + serverId, + serverName: target.name, + checkedAt: new Date().toISOString(), + }; + try { + const { stdout } = await runOnServer(target, buildTraefikLogsCommand()); + const lines = selectAcmeLogLines(stdout).map((text) => ({ + time: readLogTime(text), + level: readLogLevel(text), + domain: extractLogDomain(text, new Set()), + text: truncate(text), + })); + return { ...base, containerFound: true, error: null, lines }; + } catch (error) { + if ( + error instanceof ExecError && + /No such container/i.test(error.stdout ?? "") + ) { + return { ...base, containerFound: false, error: null, lines: [] }; + } + return { + ...base, + containerFound: true, + error: `${TRAEFIK_CONTAINER_NAME} logs: ${describeFailure(error, target)}`, + lines: [], + }; + } +}; diff --git a/packages/server/src/utils/domain-overview/acme.ts b/packages/server/src/utils/domain-overview/acme.ts new file mode 100644 index 00000000000..a9515214812 --- /dev/null +++ b/packages/server/src/utils/domain-overview/acme.ts @@ -0,0 +1,158 @@ +import { X509Certificate } from "node:crypto"; + +export const ACME_FILE_NAME = "acme.json"; +export const ACME_FILE_MISSING_EXIT_CODE = 3; +export const DAY_MS = 24 * 60 * 60 * 1000; + +export type AcmeCertificate = { + names: string[]; + validTo: Date; + issuer: string; +}; + +export type CertificateTone = "critical" | "warning" | "ok"; + +// jq drops the private keys on the server. Without jq the whole file comes +// over and parseAcmeCertificates drops the keys before anything reads the data. +const JQ_REDUCE_FILTER = + "{letsencrypt: {Certificates: [.letsencrypt?.Certificates[]? | {domain: {main: .domain?.main?, sans: (.domain?.sans? // [])}, certificate: .certificate?}]}}"; + +const shellQuote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`; + +export const buildAcmeReadCommand = (acmeFilePath: string) => { + const file = shellQuote(acmeFilePath); + return [ + `[ -f ${file} ] || exit ${ACME_FILE_MISSING_EXIT_CODE}`, + "if command -v jq >/dev/null 2>&1; then", + `jq -c '${JQ_REDUCE_FILTER}' ${file}`, + "else", + `cat ${file}`, + "fi", + ].join("\n"); +}; + +export class AcmeFileFormatError extends Error { + constructor() { + super("acme.json could not be parsed"); + this.name = "AcmeFileFormatError"; + } +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +export const normalizeDomainName = (name: string) => + name.trim().replace(/\.$/, "").toLowerCase(); + +const formatIssuer = (issuer: string) => { + const fields = new Map(); + for (const line of issuer.split("\n")) { + const separator = line.indexOf("="); + if (separator > 0) { + fields.set(line.slice(0, separator), line.slice(separator + 1)); + } + } + const label = [fields.get("O"), fields.get("CN")].filter(Boolean).join(" "); + return label || issuer.replace(/\n/g, ", "); +}; + +export const parseCertificateChain = (encoded: string) => { + const pem = Buffer.from(encoded, "base64").toString("utf8"); + const leaf = pem.match( + /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/, + )?.[0]; + if (!leaf) { + return null; + } + try { + const certificate = new X509Certificate(leaf); + const validTo = new Date(certificate.validTo); + if (Number.isNaN(validTo.getTime())) { + return null; + } + return { validTo, issuer: formatIssuer(certificate.issuer) }; + } catch { + return null; + } +}; + +const toAcmeCertificate = (entry: unknown): AcmeCertificate | null => { + if (!isRecord(entry)) { + return null; + } + const encoded = entry.certificate; + if (typeof encoded !== "string") { + return null; + } + const domain: Record = isRecord(entry.domain) + ? entry.domain + : {}; + const sans = Array.isArray(domain.sans) ? domain.sans : []; + const names = [domain.main, ...sans].flatMap((name) => + typeof name === "string" && name.trim() !== "" + ? [normalizeDomainName(name)] + : [], + ); + const parsed = parseCertificateChain(encoded); + if (!parsed || names.length === 0) { + return null; + } + return { names: [...new Set(names)], ...parsed }; +}; + +// Errors never carry the file content: JSON.parse messages can quote the +// input, so the original error is dropped here. +export const parseAcmeCertificates = (raw: string): AcmeCertificate[] => { + let document: unknown; + try { + document = JSON.parse(raw); + } catch { + throw new AcmeFileFormatError(); + } + const letsencrypt = isRecord(document) ? document.letsencrypt : undefined; + const entries = isRecord(letsencrypt) ? letsencrypt.Certificates : undefined; + if (!Array.isArray(entries)) { + return []; + } + return entries.flatMap((entry) => { + const certificate = toAcmeCertificate(entry); + return certificate ? [certificate] : []; + }); +}; + +export const daysUntil = (date: Date, now: Date) => + Math.floor((date.getTime() - now.getTime()) / DAY_MS); + +export const certificateTone = (daysLeft: number): CertificateTone => { + if (daysLeft < 14) { + return "critical"; + } + if (daysLeft < 30) { + return "warning"; + } + return "ok"; +}; + +export const certificateCovers = (names: readonly string[], domain: string) => + names.some((name) => { + if (name === domain) { + return true; + } + if (!name.startsWith("*.")) { + return false; + } + const suffix = name.slice(1); + if (!domain.endsWith(suffix)) { + return false; + } + const label = domain.slice(0, domain.length - suffix.length); + return label.length > 0 && !label.includes("."); + }); + +export const findCertificateFor = ( + certificates: readonly AcmeCertificate[], + domain: string, +) => + certificates + .filter((certificate) => certificateCovers(certificate.names, domain)) + .sort((a, b) => b.validTo.getTime() - a.validTo.getTime())[0]; diff --git a/packages/server/src/utils/domain-overview/dns-status.ts b/packages/server/src/utils/domain-overview/dns-status.ts new file mode 100644 index 00000000000..1b241888344 --- /dev/null +++ b/packages/server/src/utils/domain-overview/dns-status.ts @@ -0,0 +1,131 @@ +import { promises as dnsPromises } from "node:dns"; +import { isIP } from "node:net"; +import { withTimeout } from "./with-timeout"; + +export const DNS_LOOKUP_TIMEOUT_MS = 5_000; +export const DNS_CACHE_TTL_MS = 10 * 60 * 1000; + +export type DnsLookup = + | { ok: true; addresses: string[] } + | { ok: false; code: string }; + +export type DnsRecords = { + a: DnsLookup; + aaaa: DnsLookup; +}; + +export type DnsStatus = + | "points_to_this_server" + | "points_to_other_address" + | "server_address_unknown" + | "nxdomain" + | "no_address" + | "lookup_failed" + | "not_checked"; + +const addressesOf = (lookup: DnsLookup) => (lookup.ok ? lookup.addresses : []); + +export const resolvedAddresses = (records: DnsRecords) => [ + ...addressesOf(records.a), + ...addressesOf(records.aaaa), +]; + +// ENOTFOUND is NXDOMAIN and ENODATA is a name with no address record. Any other +// code (timeout, SERVFAIL, refused) means the answer is unknown, not absent. +const isTransientFailure = (lookup: DnsLookup) => + !lookup.ok && lookup.code !== "ENOTFOUND" && lookup.code !== "ENODATA"; + +const sameAddress = (left: string, right: string) => + left.toLowerCase() === right.toLowerCase(); + +export const mapDnsStatus = ({ + records, + serverAddress, +}: { + records: DnsRecords; + serverAddress: string | null; +}): DnsStatus => { + const { a, aaaa } = records; + const addresses = resolvedAddresses(records); + + if ( + serverAddress && + addresses.some((address) => sameAddress(address, serverAddress)) + ) { + return "points_to_this_server"; + } + + const serverFamily = serverAddress && isIP(serverAddress) === 6 ? aaaa : a; + if (serverAddress && isTransientFailure(serverFamily)) { + return "lookup_failed"; + } + + if (addresses.length > 0) { + return serverAddress ? "points_to_other_address" : "server_address_unknown"; + } + + if (isTransientFailure(a) || isTransientFailure(aaaa)) { + return "lookup_failed"; + } + if ( + (!a.ok && a.code === "ENOTFOUND") || + (!aaaa.ok && aaaa.code === "ENOTFOUND") + ) { + return "nxdomain"; + } + return "no_address"; +}; + +type ResolveAddresses = (name: string) => Promise; + +const errorCode = (error: unknown) => + typeof error === "object" && + error !== null && + "code" in error && + typeof error.code === "string" + ? error.code + : "UNKNOWN"; + +const lookupFamily = async ( + resolve: ResolveAddresses, + name: string, +): Promise => { + try { + const addresses = await withTimeout(resolve(name), DNS_LOOKUP_TIMEOUT_MS); + return { ok: true, addresses }; + } catch (error) { + return { ok: false, code: errorCode(error) }; + } +}; + +type CacheEntry = { + expiresAt: number; + records: Promise; +}; + +const cache = new Map(); + +export const clearDnsLookupCache = () => { + cache.clear(); +}; + +// Failed lookups are cached too, so a slow resolver is not asked again on every +// page load for ten minutes. +export const resolveDomainRecords = (domain: string): Promise => { + const now = Date.now(); + const cached = cache.get(domain); + if (cached && cached.expiresAt > now) { + return cached.records; + } + for (const [name, entry] of cache) { + if (entry.expiresAt <= now) { + cache.delete(name); + } + } + const records = Promise.all([ + lookupFamily((name) => dnsPromises.resolve4(name), domain), + lookupFamily((name) => dnsPromises.resolve6(name), domain), + ]).then(([a, aaaa]) => ({ a, aaaa })); + cache.set(domain, { expiresAt: now + DNS_CACHE_TTL_MS, records }); + return records; +}; diff --git a/packages/server/src/utils/domain-overview/traefik-errors.ts b/packages/server/src/utils/domain-overview/traefik-errors.ts new file mode 100644 index 00000000000..c3b09795069 --- /dev/null +++ b/packages/server/src/utils/domain-overview/traefik-errors.ts @@ -0,0 +1,89 @@ +export type TraefikErrorCause = + | "no_dns_record" + | "no_address" + | "lets_encrypt_timeout" + | "port_443_answered_by_other" + | "port_443_closed" + | "rate_limit" + | "unknown"; + +export type ClassifiedTraefikError = { + cause: TraefikErrorCause; + explanation: string; +}; + +// The texts are read by the panel operator, so they stay in Russian and reuse +// the wording of docs/certificates-and-ports.md where the case is the same. +const EXPLANATIONS: Record = { + no_dns_record: + "Для этого имени нет DNS-записи. Добавьте запись A с адресом сервера или удалите домен, если он больше не используется.", + no_address: + "DNS-запись есть, но в ней нет адреса. Укажите в записи A адрес сервера.", + lets_encrypt_timeout: + "Let's Encrypt не ответил вовремя. Часто это проходит само собой; проверьте позже.", + port_443_answered_by_other: + "На 443 отвечает не Traefik, обычно перед сервером стоит CDN или прокси, который завершает TLS. Отключите проксирование для этого имени или уберите его с прокси.", + port_443_closed: + "Порт 443 закрыт файрволом или не ведет на Traefik. Проверьте файрвол и публикацию портов.", + rate_limit: + "Слишком много неудачных попыток у Let's Encrypt (429). Сначала устраните причину, и только потом ждите новую попытку.", + unknown: "Неизвестная ошибка, см. лог ниже.", +}; + +const TIMEOUT = /timeout|timed out|deadline exceeded/i; +const PORT_443 = /:443\b|\bport 443\b/i; +const OUTBOUND_TO_LETS_ENCRYPT = + /dial tcp|letsencrypt\.org|acme-v0\d|Client\.Timeout|deadline exceeded/i; +const LETS_ENCRYPT_HOST = /letsencrypt\.org|acme-v0\d/i; + +// Order matters: a 429 line can also mention a timeout, and a timeout that +// names port 443 points at the panel's firewall unless the dial goes out to +// Let's Encrypt. +const RULES: ReadonlyArray<{ + cause: TraefikErrorCause; + matches: (message: string) => boolean; +}> = [ + { + cause: "rate_limit", + matches: (message) => /\b429\b|rate[ -]?limit/i.test(message), + }, + { + cause: "no_dns_record", + matches: (message) => + /NXDOMAIN|does not exist|no such host/i.test(message) && + !LETS_ENCRYPT_HOST.test(message), + }, + { + cause: "no_address", + matches: (message) => + /no valid (?:A|AAAA|A\/AAAA) records?|no (?:A|AAAA) records/i.test( + message, + ), + }, + { + cause: "port_443_answered_by_other", + matches: (message) => + /ALPN|tls-alpn|Incorrect validation certificate/i.test(message), + }, + { + cause: "port_443_closed", + matches: (message) => + /connection refused|Timeout during connect|firewall problem/i.test( + message, + ) || + (TIMEOUT.test(message) && + PORT_443.test(message) && + !OUTBOUND_TO_LETS_ENCRYPT.test(message)), + }, + { + cause: "lets_encrypt_timeout", + matches: (message) => TIMEOUT.test(message), + }, +]; + +export const classifyTraefikError = ( + message: string, +): ClassifiedTraefikError => { + const cause = RULES.find((rule) => rule.matches(message))?.cause ?? "unknown"; + return { cause, explanation: EXPLANATIONS[cause] }; +}; diff --git a/packages/server/src/utils/domain-overview/traefik-logs.ts b/packages/server/src/utils/domain-overview/traefik-logs.ts new file mode 100644 index 00000000000..3ccc62d2c33 --- /dev/null +++ b/packages/server/src/utils/domain-overview/traefik-logs.ts @@ -0,0 +1,57 @@ +export const TRAEFIK_CONTAINER_NAME = "dokploy-traefik"; +export const TRAEFIK_LOG_LINE_LIMIT = 300; + +// Docker parses --since with Go durations, which have no day unit. +export const buildTraefikLogsCommand = () => + `docker logs --since 168h --tail 2000 ${TRAEFIK_CONTAINER_NAME} 2>&1`; + +const ACME_LINE = /acme|certificate|letsencrypt|tls-alpn/i; + +export const selectAcmeLogLines = ( + output: string, + limit = TRAEFIK_LOG_LINE_LIMIT, +) => + output + .split("\n") + .map((line) => line.trimEnd()) + .filter((line) => line.length > 0 && ACME_LINE.test(line)) + .slice(-limit); + +export const readLogLevel = (line: string) => + line.match(/\blevel"?[=:]\s*"?([a-z]+)/i)?.[1]?.toLowerCase() ?? null; + +export const readLogTime = (line: string) => + line.match(/\btime"?[=:]\s*"?([^"\s,}]+)/)?.[1] ?? null; + +export const isErrorLine = (line: string) => readLogLevel(line) === "error"; + +const KEY_TOKEN = /[A-Za-z_][\w.-]*=/g; +const HOST_CANDIDATE = + /(? { + const hosts: string[] = []; + for (const match of line.replace(KEY_TOKEN, " ").matchAll(HOST_CANDIDATE)) { + const host = (match[1] ?? "").toLowerCase(); + if (!NOT_DOMAINS.has(host) && !host.endsWith(".letsencrypt.org")) { + hosts.push(host); + } + } + return hosts; +}; + +// Without a known host, a guess is only made on lines that say "domain", so +// file names and provider names in other messages do not become domains. +export const extractLogDomain = ( + line: string, + knownHosts: ReadonlySet, +) => { + const candidates = hostCandidates(line); + const known = candidates.find((host) => knownHosts.has(host)); + if (known) { + return known; + } + return /domain/i.test(line) ? (candidates[0] ?? null) : null; +}; diff --git a/packages/server/src/utils/domain-overview/traefik-routes.ts b/packages/server/src/utils/domain-overview/traefik-routes.ts new file mode 100644 index 00000000000..816ed7c1b9e --- /dev/null +++ b/packages/server/src/utils/domain-overview/traefik-routes.ts @@ -0,0 +1,83 @@ +export type TraefikRoute = { + host: string; + containerName: string; + composeProject: string | null; + serviceName: string | null; +}; + +export const buildTraefikRoutesCommand = () => + "docker ps -q | xargs -r docker inspect --format '{{.Name}}|{{json .Config.Labels}}'"; + +const HOST_MATCHER = /\bHost\(([^)]*)\)/g; +const QUOTED_ARGUMENT = /`([^`]*)`|"([^"]*)"|'([^']*)'/g; +const HTTP_RULE_LABEL = /^traefik\.http\.routers\..+\.rule$/; + +const normalizeHost = (value: string | undefined) => + (value ?? "").trim().replace(/\.$/, "").toLowerCase(); + +export const extractRuleHosts = (rule: string) => { + const hosts = new Set(); + for (const matcher of rule.matchAll(HOST_MATCHER)) { + for (const argument of (matcher[1] ?? "").matchAll(QUOTED_ARGUMENT)) { + const host = normalizeHost(argument[1] ?? argument[2] ?? argument[3]); + if (host) { + hosts.add(host); + } + } + } + return [...hosts]; +}; + +const parseLabels = (json: string): Record => { + try { + const value: unknown = JSON.parse(json); + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return {}; + } + return Object.fromEntries( + Object.entries(value).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); + } catch { + return {}; + } +}; + +const optionalLabel = (value: string | undefined) => value?.trim() || null; + +// The output is one `name|labels-json` line per container, so the split must +// happen at the first `|`: rule values contain `||` for multi-host matchers. +export const parseTraefikRoutes = (output: string): TraefikRoute[] => { + const routes: TraefikRoute[] = []; + for (const line of output.split("\n")) { + const separator = line.indexOf("|"); + if (separator <= 0) { + continue; + } + const containerName = line.slice(0, separator).trim().replace(/^\//, ""); + const labels = parseLabels(line.slice(separator + 1).trim()); + if (!containerName || labels["traefik.enable"] === "false") { + continue; + } + const composeProject = optionalLabel(labels["com.docker.compose.project"]); + const serviceName = optionalLabel( + labels["com.docker.swarm.service.name"] ?? + labels["com.docker.stack.namespace"], + ); + const seen = new Set(); + for (const [key, rule] of Object.entries(labels)) { + if (!HTTP_RULE_LABEL.test(key)) { + continue; + } + for (const host of extractRuleHosts(rule)) { + if (seen.has(host)) { + continue; + } + seen.add(host); + routes.push({ host, containerName, composeProject, serviceName }); + } + } + } + return routes; +}; diff --git a/packages/server/src/utils/domain-overview/with-timeout.ts b/packages/server/src/utils/domain-overview/with-timeout.ts new file mode 100644 index 00000000000..fb1727fb5d1 --- /dev/null +++ b/packages/server/src/utils/domain-overview/with-timeout.ts @@ -0,0 +1,32 @@ +export class OperationTimeoutError extends Error { + readonly code = "ETIMEDOUT"; + readonly timeoutMs: number; + + constructor(timeoutMs: number) { + super(`Operation timed out after ${timeoutMs} ms`); + this.name = "OperationTimeoutError"; + this.timeoutMs = timeoutMs; + } +} + +// The underlying ssh2 and dns calls cannot always be cancelled, so the timer +// only stops the page from waiting on them. +export const withTimeout = ( + promise: Promise, + timeoutMs: number, +): Promise => + new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new OperationTimeoutError(timeoutMs)); + }, timeoutMs); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error: unknown) => { + clearTimeout(timer); + reject(error); + }, + ); + });