Skip to content

Commit 6d3b7da

Browse files
agentHitsclaude
andauthored
feat(domains): add Domains page with DNS, certificate and Traefik error status (#60)
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 <noreply@anthropic.com>
1 parent 42610ac commit 6d3b7da

23 files changed

Lines changed: 3091 additions & 41 deletions
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import {
2+
buildAcmeReadCommand,
3+
certificateCovers,
4+
certificateTone,
5+
daysUntil,
6+
findCertificateFor,
7+
parseAcmeCertificates,
8+
parseCertificateChain,
9+
} from "@dokploy/server/utils/domain-overview/acme";
10+
import { describe, expect, it } from "vitest";
11+
12+
const FIXTURE_CERTIFICATE = `-----BEGIN CERTIFICATE-----
13+
MIIByzCCAXGgAwIBAgIUWlFvc+nf6dEY5hzHVUVViA92C6kwCgYIKoZIzj0EAwIw
14+
NjEVMBMGA1UEAwwMZXhhbXBsZS50ZXN0MR0wGwYDVQQKDBRFeGFtcGxlIFRlc3Qg
15+
Rml4dHVyZTAeFw0yNjAxMDEwMDAwMDBaFw0yNzA2MDExMjAwMDBaMDYxFTATBgNV
16+
BAMMDGV4YW1wbGUudGVzdDEdMBsGA1UECgwURXhhbXBsZSBUZXN0IEZpeHR1cmUw
17+
WTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAR8poBd9hdPZbBkGhYMU068RMvU5tEW
18+
AGaG3Ak6Ia9ZJNXhgn2Rc8Kc1Xim/05OxNc7ubzinXH7XEBWyfLmI7Ylo10wWzAd
19+
BgNVHQ4EFgQUkdsHQsjcvCmQo7qeXtOtA3bmJZAwDwYDVR0TAQH/BAUwAwEB/zAp
20+
BgNVHREEIjAgggxleGFtcGxlLnRlc3SCEHd3dy5leGFtcGxlLnRlc3QwCgYIKoZI
21+
zj0EAwIDSAAwRQIgWV3UtpalgH4fHv8AmFwlUfSdJjTLK5Li2K2OJKYZXrICIQCK
22+
c9Rm3ru6I2cIJEIQwsANL6haSt6HrEbU14R9Zw3LWQ==
23+
-----END CERTIFICATE-----
24+
`;
25+
26+
const FAKE_ENTRY_KEY = "FAKE-ENTRY-KEY-MATERIAL-FOR-TESTS-ONLY";
27+
const FAKE_ACCOUNT_KEY = "FAKE-ACCOUNT-KEY-MATERIAL-FOR-TESTS-ONLY";
28+
29+
const encode = (value: string) => Buffer.from(value, "utf8").toString("base64");
30+
31+
const acmeDocument = (certificates: unknown[]) =>
32+
JSON.stringify({
33+
letsencrypt: {
34+
Account: {
35+
Email: "ops@example.test",
36+
PrivateKey: FAKE_ACCOUNT_KEY,
37+
},
38+
Certificates: certificates,
39+
},
40+
});
41+
42+
const fixtureEntry = {
43+
domain: { main: "example.test", sans: ["www.example.test"] },
44+
certificate: encode(FIXTURE_CERTIFICATE),
45+
key: FAKE_ENTRY_KEY,
46+
Store: "default",
47+
};
48+
49+
describe("acme.json parser", () => {
50+
it("returns names, expiry and issuer and never the key material", () => {
51+
const raw = acmeDocument([fixtureEntry]);
52+
53+
const certificates = parseAcmeCertificates(raw);
54+
55+
expect(certificates).toHaveLength(1);
56+
expect(certificates[0]?.names).toEqual([
57+
"example.test",
58+
"www.example.test",
59+
]);
60+
expect(certificates[0]?.validTo.toISOString()).toBe(
61+
"2027-06-01T12:00:00.000Z",
62+
);
63+
expect(certificates[0]?.issuer).toBe("Example Test Fixture example.test");
64+
expect(Object.keys(certificates[0] ?? {}).sort()).toEqual([
65+
"issuer",
66+
"names",
67+
"validTo",
68+
]);
69+
const serialized = JSON.stringify(certificates);
70+
expect(serialized).not.toContain(FAKE_ENTRY_KEY);
71+
expect(serialized).not.toContain(FAKE_ACCOUNT_KEY);
72+
});
73+
74+
it("reads the reduced shape that the server-side jq filter prints", () => {
75+
const reduced = JSON.stringify({
76+
letsencrypt: {
77+
Certificates: [
78+
{
79+
domain: { main: "Example.TEST.", sans: [] },
80+
certificate: encode(FIXTURE_CERTIFICATE),
81+
},
82+
],
83+
},
84+
});
85+
86+
expect(parseAcmeCertificates(reduced)[0]?.names).toEqual(["example.test"]);
87+
});
88+
89+
it("skips entries without a readable certificate", () => {
90+
const raw = acmeDocument([
91+
{ domain: { main: "example.test" }, certificate: encode("not a cert") },
92+
{ domain: { main: "example.test" } },
93+
]);
94+
95+
expect(parseAcmeCertificates(raw)).toEqual([]);
96+
});
97+
98+
it("returns no certificates when the file has no letsencrypt section", () => {
99+
expect(parseAcmeCertificates("{}")).toEqual([]);
100+
});
101+
102+
it("does not echo the file content when the JSON cannot be parsed", () => {
103+
const broken = `{"letsencrypt": {"Account": {"PrivateKey": "${FAKE_ACCOUNT_KEY}"`;
104+
105+
let thrown: unknown;
106+
try {
107+
parseAcmeCertificates(broken);
108+
} catch (error) {
109+
thrown = error;
110+
}
111+
112+
expect(thrown).toBeInstanceOf(Error);
113+
expect((thrown as Error).message).toBe("acme.json could not be parsed");
114+
expect(String(thrown)).not.toContain(FAKE_ACCOUNT_KEY);
115+
});
116+
});
117+
118+
describe("certificate expiry parser", () => {
119+
it("reads validTo and the issuer from the first PEM block only", () => {
120+
const parsed = parseCertificateChain(
121+
encode(
122+
`${FIXTURE_CERTIFICATE}${FIXTURE_CERTIFICATE.replace(
123+
"MIIBy",
124+
"MIIBz",
125+
)}`,
126+
),
127+
);
128+
129+
expect(parsed?.validTo.toISOString()).toBe("2027-06-01T12:00:00.000Z");
130+
expect(parsed?.issuer).toBe("Example Test Fixture example.test");
131+
});
132+
133+
it("returns null when the chain holds no certificate", () => {
134+
expect(parseCertificateChain(encode("plain text"))).toBeNull();
135+
});
136+
137+
it("counts whole days left from a given moment", () => {
138+
const validTo = new Date("2027-06-01T12:00:00.000Z");
139+
140+
expect(daysUntil(validTo, new Date("2027-05-19T12:00:00.000Z"))).toBe(13);
141+
expect(daysUntil(validTo, new Date("2027-05-19T13:00:00.000Z"))).toBe(12);
142+
expect(daysUntil(validTo, new Date("2027-06-02T12:00:00.000Z"))).toBe(-1);
143+
});
144+
145+
it("marks under 14 days as critical and under 30 days as warning", () => {
146+
expect(certificateTone(13)).toBe("critical");
147+
expect(certificateTone(0)).toBe("critical");
148+
expect(certificateTone(-3)).toBe("critical");
149+
expect(certificateTone(14)).toBe("warning");
150+
expect(certificateTone(29)).toBe("warning");
151+
expect(certificateTone(30)).toBe("ok");
152+
});
153+
});
154+
155+
describe("certificate names", () => {
156+
it("matches exact names and one wildcard label", () => {
157+
expect(certificateCovers(["example.test"], "example.test")).toBe(true);
158+
expect(certificateCovers(["*.example.test"], "www.example.test")).toBe(
159+
true,
160+
);
161+
expect(certificateCovers(["*.example.test"], "example.test")).toBe(false);
162+
expect(certificateCovers(["*.example.test"], "a.b.example.test")).toBe(
163+
false,
164+
);
165+
});
166+
167+
it("prefers the certificate with the latest expiry", () => {
168+
const older = {
169+
names: ["example.test"],
170+
validTo: new Date("2026-12-01T00:00:00Z"),
171+
issuer: "older",
172+
};
173+
const newer = {
174+
names: ["example.test"],
175+
validTo: new Date("2027-06-01T00:00:00Z"),
176+
issuer: "newer",
177+
};
178+
179+
expect(findCertificateFor([older, newer], "example.test")?.issuer).toBe(
180+
"newer",
181+
);
182+
expect(findCertificateFor([older], "other.example.test")).toBeUndefined();
183+
});
184+
});
185+
186+
describe("acme.json read command", () => {
187+
it("checks the file first and reduces it on the server when jq is available", () => {
188+
const command = buildAcmeReadCommand(
189+
"/etc/dokploy/traefik/dynamic/acme.json",
190+
);
191+
192+
expect(
193+
command.startsWith(
194+
"[ -f '/etc/dokploy/traefik/dynamic/acme.json' ] || exit 3",
195+
),
196+
).toBe(true);
197+
expect(command).toContain("jq -c");
198+
expect(command).toContain("certificate: .certificate?");
199+
expect(command).not.toMatch(/PrivateKey|\.key\b/);
200+
});
201+
202+
it("quotes the path so that it cannot end the argument", () => {
203+
const command = buildAcmeReadCommand("/srv/it's/acme.json");
204+
205+
expect(command).toContain("'/srv/it'\\''s/acme.json'");
206+
});
207+
});
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const dns = vi.hoisted(() => ({
4+
resolve4: vi.fn(),
5+
resolve6: vi.fn(),
6+
}));
7+
8+
vi.mock("node:dns", () => ({
9+
promises: {
10+
resolve4: dns.resolve4,
11+
resolve6: dns.resolve6,
12+
},
13+
}));
14+
15+
const {
16+
clearDnsLookupCache,
17+
DNS_CACHE_TTL_MS,
18+
DNS_LOOKUP_TIMEOUT_MS,
19+
mapDnsStatus,
20+
resolveDomainRecords,
21+
} = await import("@dokploy/server/utils/domain-overview/dns-status");
22+
23+
const dnsError = (code: string) => Object.assign(new Error(code), { code });
24+
25+
const answer = (addresses: string[]) => ({ ok: true as const, addresses });
26+
const failure = (code: string) => ({ ok: false as const, code });
27+
28+
describe("DNS status mapper", () => {
29+
it("says the name points to this server when an address matches", () => {
30+
expect(
31+
mapDnsStatus({
32+
records: { a: answer(["192.0.2.10"]), aaaa: failure("ENODATA") },
33+
serverAddress: "192.0.2.10",
34+
}),
35+
).toBe("points_to_this_server");
36+
});
37+
38+
it("says the name points to another address when none matches", () => {
39+
expect(
40+
mapDnsStatus({
41+
records: { a: answer(["198.51.100.7"]), aaaa: failure("ENODATA") },
42+
serverAddress: "192.0.2.10",
43+
}),
44+
).toBe("points_to_other_address");
45+
});
46+
47+
it("reports NXDOMAIN when both families say the name does not exist", () => {
48+
expect(
49+
mapDnsStatus({
50+
records: { a: failure("ENOTFOUND"), aaaa: failure("ENOTFOUND") },
51+
serverAddress: "192.0.2.10",
52+
}),
53+
).toBe("nxdomain");
54+
});
55+
56+
it("reports a record without an address when both families have no data", () => {
57+
expect(
58+
mapDnsStatus({
59+
records: { a: failure("ENODATA"), aaaa: failure("ENODATA") },
60+
serverAddress: "192.0.2.10",
61+
}),
62+
).toBe("no_address");
63+
});
64+
65+
it("reports a failed lookup when the lookup timed out", () => {
66+
expect(
67+
mapDnsStatus({
68+
records: { a: failure("ETIMEDOUT"), aaaa: failure("ENODATA") },
69+
serverAddress: "192.0.2.10",
70+
}),
71+
).toBe("lookup_failed");
72+
});
73+
74+
it("does not call the name another address while the server's family is unknown", () => {
75+
expect(
76+
mapDnsStatus({
77+
records: { a: failure("ESERVFAIL"), aaaa: answer(["2001:db8::7"]) },
78+
serverAddress: "192.0.2.10",
79+
}),
80+
).toBe("lookup_failed");
81+
});
82+
83+
it("reports an unknown server address when the name resolves", () => {
84+
expect(
85+
mapDnsStatus({
86+
records: { a: answer(["192.0.2.10"]), aaaa: failure("ENODATA") },
87+
serverAddress: null,
88+
}),
89+
).toBe("server_address_unknown");
90+
});
91+
});
92+
93+
describe("DNS lookups", () => {
94+
beforeEach(() => {
95+
vi.clearAllMocks();
96+
vi.useRealTimers();
97+
clearDnsLookupCache();
98+
});
99+
100+
it("maps a name that resolves to this server", async () => {
101+
dns.resolve4.mockResolvedValue(["192.0.2.10"]);
102+
dns.resolve6.mockRejectedValue(dnsError("ENODATA"));
103+
104+
const records = await resolveDomainRecords("points-here.example.test");
105+
106+
expect(mapDnsStatus({ records, serverAddress: "192.0.2.10" })).toBe(
107+
"points_to_this_server",
108+
);
109+
});
110+
111+
it("maps a name that resolves to another address", async () => {
112+
dns.resolve4.mockResolvedValue(["198.51.100.7"]);
113+
dns.resolve6.mockRejectedValue(dnsError("ENODATA"));
114+
115+
const records = await resolveDomainRecords("elsewhere.example.test");
116+
117+
expect(mapDnsStatus({ records, serverAddress: "192.0.2.10" })).toBe(
118+
"points_to_other_address",
119+
);
120+
});
121+
122+
it("maps a name with no DNS record", async () => {
123+
dns.resolve4.mockRejectedValue(dnsError("ENOTFOUND"));
124+
dns.resolve6.mockRejectedValue(dnsError("ENOTFOUND"));
125+
126+
const records = await resolveDomainRecords("missing.example.test");
127+
128+
expect(mapDnsStatus({ records, serverAddress: "192.0.2.10" })).toBe(
129+
"nxdomain",
130+
);
131+
});
132+
133+
it("maps a name whose record has no address", async () => {
134+
dns.resolve4.mockRejectedValue(dnsError("ENODATA"));
135+
dns.resolve6.mockRejectedValue(dnsError("ENODATA"));
136+
137+
const records = await resolveDomainRecords("empty.example.test");
138+
139+
expect(mapDnsStatus({ records, serverAddress: "192.0.2.10" })).toBe(
140+
"no_address",
141+
);
142+
});
143+
144+
it("maps a lookup that does not answer within the timeout", async () => {
145+
vi.useFakeTimers();
146+
dns.resolve4.mockReturnValue(new Promise<string[]>(() => {}));
147+
dns.resolve6.mockRejectedValue(dnsError("ENODATA"));
148+
149+
const pending = resolveDomainRecords("slow.example.test");
150+
await vi.advanceTimersByTimeAsync(DNS_LOOKUP_TIMEOUT_MS);
151+
const records = await pending;
152+
153+
expect(records.a.ok).toBe(false);
154+
expect(mapDnsStatus({ records, serverAddress: "192.0.2.10" })).toBe(
155+
"lookup_failed",
156+
);
157+
});
158+
159+
it("maps a resolver error that is not NXDOMAIN or NODATA as a failed lookup", async () => {
160+
dns.resolve4.mockRejectedValue(dnsError("ECONNREFUSED"));
161+
dns.resolve6.mockRejectedValue(dnsError("ECONNREFUSED"));
162+
163+
const records = await resolveDomainRecords("refused.example.test");
164+
165+
expect(mapDnsStatus({ records, serverAddress: "192.0.2.10" })).toBe(
166+
"lookup_failed",
167+
);
168+
});
169+
170+
it("answers repeat lookups from the cache for ten minutes", async () => {
171+
vi.useFakeTimers();
172+
vi.setSystemTime(new Date("2026-10-08T10:00:00.000Z"));
173+
dns.resolve4.mockResolvedValue(["192.0.2.10"]);
174+
dns.resolve6.mockRejectedValue(dnsError("ENODATA"));
175+
176+
await resolveDomainRecords("cached.example.test");
177+
vi.setSystemTime(
178+
new Date(Date.parse("2026-10-08T10:00:00.000Z") + DNS_CACHE_TTL_MS - 1),
179+
);
180+
await resolveDomainRecords("cached.example.test");
181+
expect(dns.resolve4).toHaveBeenCalledTimes(1);
182+
183+
vi.setSystemTime(
184+
new Date(Date.parse("2026-10-08T10:00:00.000Z") + DNS_CACHE_TTL_MS),
185+
);
186+
await resolveDomainRecords("cached.example.test");
187+
expect(dns.resolve4).toHaveBeenCalledTimes(2);
188+
});
189+
});

0 commit comments

Comments
 (0)