From 5433c420ffee6d76c723d6ff6a6af879aee003d2 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:41:12 -0400 Subject: [PATCH] feat(domains): add Domains page with DNS, certificate and Traefik error status Adds a Domains section (sidebar item and /dashboard/domains) that lists the domains of the active organization on the servers the caller can read, with each domain's DNS record, certificate and last Traefik error. It is an information page with no state changes: no buttons edit DNS, Traefik configuration, certificates or containers, and nothing restarts. For each server the page shows one row per domain: - the domain and the server it is on; - where it is used: links to the application or compose service that owns it (panel domains, including preview domains), or the container name and a link to the Docker containers view for a route that no panel service owns; - DNS status from node:dns (A and AAAA, 5 s timeout, cached for 10 minutes), with the resolved addresses, compared with the server's public address; - the certificate from Traefik's acme.json: expiry date and days left, red under 14 days and yellow under 30 days; a certificate that no route or panel domain uses is marked "certificate only, nothing uses it"; - the last Traefik error for the domain, with its time, the original line, a plain-language explanation and advice. Causes: no DNS record, no address record, Let's Encrypt timeout, port 443 answered by something other than Traefik (ALPN), port 443 closed or not reaching Traefik, rate limit, unknown. Explanations are in Russian and reuse docs/certificates-and-ports.md wording. Each server also has a Traefik log block with the last 300 certificate and ACME lines and a refresh button. Access: the same gate as the Traefik File System page and readTraefikFile. - domainOverview.list and domainOverview.traefikLogs are protectedProcedure and call checkPermission(traefikFiles: read), as settings.readTraefikFile does. - Each server gets the check readTraefikFile applies to it: the panel's own host needs owner or admin (assertLocalHostAccess); a remote server needs access to that server (assertSettingsServerAccess). Both helpers moved from settings.ts to server/api/utils/traefik-files-access.ts so the two routers share one implementation. A server the caller cannot read is not contacted, and its domains and routes are not listed. - acme.json is a protected file for readTraefikFile, so list reads it only when hasProtectedResourceAccess passes: owner or admin, browser session, open super session. Without that the page shows no certificate data and says so; the other columns still load. - The sidebar item and the page (getServerSideProps) check traefikFiles.read, as the Traefik page does. Both procedures are classified "read" in procedure-access.ts, the same as readTraefikFile. Private keys: acme.json contains keys. On the server, jq keeps only the domain names and the certificate field; without jq the whole file is read and the panel drops the keys before any other step touches the data. The keys are never returned, logged or put into an error message. Limits: - Every server read has a 15 s timeout and a server that does not answer shows an error entry without blocking the page. A slow server can still delay the first load by up to about 15 seconds. - docker logs is called with --since 168h (seven days), because Docker does not accept a day unit. - Wildcard names are not checked in DNS. - Domains that appear only in error lines of the Traefik log are shown as rows too, so the operator sees them. - The panel's own address comes from the Web Server IP setting when it is set, otherwise from public address detection (cached for 10 minutes). - Panel domains and route owners are not filtered by a member's service access list, and container names from route labels are shown without the docker read permission that guards the Docker view. The page follows the Traefik file gate only, not the overview's per-service scope. - Not verified against a running panel or against live servers; the tests mock every command, DNS call and database read. Co-Authored-By: Claude Opus 5.5 --- .../__test__/domain-overview/acme.test.ts | 207 +++++ .../domain-overview/dns-status.test.ts | 189 +++++ .../domain-overview-access.test.ts | 230 ++++++ .../domain-overview-router.test.ts | 245 ++++++ .../domain-overview/traefik-errors.test.ts | 103 +++ .../domain-overview/traefik-logs.test.ts | 93 +++ .../domain-overview/traefik-routes.test.ts | 127 +++ .../domains/show-domain-overview.tsx | 398 ++++++++++ .../dashboard/domains/show-traefik-logs.tsx | 74 ++ apps/dokploy/components/layouts/side.tsx | 9 + apps/dokploy/pages/dashboard/domains.tsx | 51 ++ apps/dokploy/server/api/root.ts | 2 + .../server/api/routers/domain-overview.ts | 31 + apps/dokploy/server/api/routers/settings.ts | 45 +- .../server/api/utils/procedure-access.ts | 2 + .../server/api/utils/traefik-files-access.ts | 55 ++ .../server/src/services/domain-overview.ts | 721 ++++++++++++++++++ .../server/src/utils/domain-overview/acme.ts | 158 ++++ .../src/utils/domain-overview/dns-status.ts | 131 ++++ .../utils/domain-overview/traefik-errors.ts | 89 +++ .../src/utils/domain-overview/traefik-logs.ts | 57 ++ .../utils/domain-overview/traefik-routes.ts | 83 ++ .../src/utils/domain-overview/with-timeout.ts | 32 + 23 files changed, 3091 insertions(+), 41 deletions(-) create mode 100644 apps/dokploy/__test__/domain-overview/acme.test.ts create mode 100644 apps/dokploy/__test__/domain-overview/dns-status.test.ts create mode 100644 apps/dokploy/__test__/domain-overview/domain-overview-access.test.ts create mode 100644 apps/dokploy/__test__/domain-overview/domain-overview-router.test.ts create mode 100644 apps/dokploy/__test__/domain-overview/traefik-errors.test.ts create mode 100644 apps/dokploy/__test__/domain-overview/traefik-logs.test.ts create mode 100644 apps/dokploy/__test__/domain-overview/traefik-routes.test.ts create mode 100644 apps/dokploy/components/dashboard/domains/show-domain-overview.tsx create mode 100644 apps/dokploy/components/dashboard/domains/show-traefik-logs.tsx create mode 100644 apps/dokploy/pages/dashboard/domains.tsx create mode 100644 apps/dokploy/server/api/routers/domain-overview.ts create mode 100644 apps/dokploy/server/api/utils/traefik-files-access.ts create mode 100644 packages/server/src/services/domain-overview.ts create mode 100644 packages/server/src/utils/domain-overview/acme.ts create mode 100644 packages/server/src/utils/domain-overview/dns-status.ts create mode 100644 packages/server/src/utils/domain-overview/traefik-errors.ts create mode 100644 packages/server/src/utils/domain-overview/traefik-logs.ts create mode 100644 packages/server/src/utils/domain-overview/traefik-routes.ts create mode 100644 packages/server/src/utils/domain-overview/with-timeout.ts 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); + }, + ); + });