1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const USER_AGENT = 'MatrixWellKnownAuditor/0.1 (+https://apify.com)';
7const DEFAULT_TIMEOUT_SECONDS = 10;
8const MAX_BODY_BYTES = 1 * 1024 * 1024;
9const DEFAULT_FEDERATION_PORT = 8448;
10
11
12
13
14
15
16
17
18
19
20
21function isPrivateIPv4(ip) {
22 const parts = ip.split('.').map(Number);
23 if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return false;
24 const [a, b] = parts;
25 return a === 10
26 || (a === 172 && b >= 16 && b <= 31)
27 || (a === 192 && b === 168)
28 || a === 127
29 || a === 0
30 || (a === 169 && b === 254);
31}
32
33function isPrivateIPv6(ip) {
34 const normalized = ip.toLowerCase();
35 return normalized === '::1'
36 || normalized.startsWith('fc')
37 || normalized.startsWith('fd')
38 || normalized.startsWith('fe80:');
39}
40
41export async function normalizeAndValidateUrl(rawUrl) {
42 if (!rawUrl || typeof rawUrl !== 'string') throw new Error('domain or URL is required');
43 if (/^[a-z][a-z0-9+.-]*:/i.test(rawUrl) && !/^https?:\/\//i.test(rawUrl)) {
44 throw new Error('Only HTTP and HTTPS URLs are supported');
45 }
46
47 const withScheme = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`;
48 const url = new URL(withScheme);
49 if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported');
50 if (!url.hostname || url.username || url.password) throw new Error('URL must be public and must not include credentials');
51
52 const hostname = url.hostname.replace(/^\[|\]$/g, '');
53 const literalType = net.isIP(hostname);
54 if (literalType === 4 && isPrivateIPv4(hostname)) throw new Error('Private IPv4 targets are blocked');
55 if (literalType === 6 && isPrivateIPv6(hostname)) throw new Error('Private IPv6 targets are blocked');
56
57 const records = literalType ? [{ address: hostname, family: literalType }] : await dns.lookup(url.hostname, { all: true });
58 for (const record of records) {
59 if (record.family === 4 && isPrivateIPv4(record.address)) throw new Error('DNS resolves to a private IPv4 address; blocked for SSRF safety');
60 if (record.family === 6 && isPrivateIPv6(record.address)) throw new Error('DNS resolves to a private IPv6 address; blocked for SSRF safety');
61 }
62 return url;
63}
64
65
66
67
68
69async function fetchOnce(targetUrl, timeoutSeconds, redirectsRemaining = 3) {
70 await normalizeAndValidateUrl(targetUrl.href);
71 const controller = new AbortController();
72 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
73 try {
74 const response = await fetch(targetUrl, {
75 method: 'GET',
76 redirect: 'manual',
77 signal: controller.signal,
78 headers: {
79 'user-agent': USER_AGENT,
80 accept: 'application/json, */*;q=0.1',
81 },
82 });
83
84 if ([301, 302, 303, 307, 308].includes(response.status)) {
85 if (redirectsRemaining <= 0) throw new Error('Too many redirects');
86 const location = response.headers.get('location');
87 if (!location) throw new Error('Redirect without Location header');
88 const nextUrl = new URL(location, targetUrl.href);
89 await normalizeAndValidateUrl(nextUrl.href);
90 return fetchOnce(nextUrl, timeoutSeconds, redirectsRemaining - 1);
91 }
92
93 let text = '';
94 try {
95 const buffer = await response.arrayBuffer();
96 text = buffer.byteLength > MAX_BODY_BYTES
97 ? new TextDecoder().decode(buffer.slice(0, MAX_BODY_BYTES))
98 : new TextDecoder().decode(buffer);
99 } catch {
100 text = '';
101 }
102
103 return {
104 ok: response.ok,
105 status: response.status,
106 finalUrl: response.url || targetUrl.href,
107 contentType: response.headers.get('content-type') || '',
108 accessControlAllowOrigin: response.headers.get('access-control-allow-origin') || null,
109 https: (response.url || targetUrl.href).startsWith('https://'),
110 body: text,
111 error: null,
112 };
113 } catch (error) {
114 return {
115 ok: false,
116 status: null,
117 finalUrl: targetUrl.href,
118 contentType: '',
119 accessControlAllowOrigin: null,
120 https: targetUrl.protocol === 'https:',
121 body: '',
122 error: error.message,
123 };
124 } finally {
125 clearTimeout(timeout);
126 }
127}
128
129
130
131
132
133
134
135export function parseMServer(raw) {
136 if (typeof raw !== 'string' || !raw.trim()) {
137 return { hostFormatValid: false, host: null, port: null, isIpLiteral: false, error: "'m.server' is missing or not a string" };
138 }
139 const value = raw.trim();
140
141
142 if (value.startsWith('[')) {
143 const closeIdx = value.indexOf(']');
144 if (closeIdx === -1) {
145 return { hostFormatValid: false, host: null, port: null, isIpLiteral: false, error: 'Unterminated IPv6 literal (missing closing bracket)' };
146 }
147 const host = value.slice(1, closeIdx);
148 const rest = value.slice(closeIdx + 1);
149 if (net.isIP(host) !== 6) {
150 return { hostFormatValid: false, host, port: null, isIpLiteral: false, error: 'Bracketed host is not a valid IPv6 literal' };
151 }
152 if (rest === '') {
153 return { hostFormatValid: true, host, port: null, isIpLiteral: true, error: null };
154 }
155 const portMatch = /^:(\d+)$/.exec(rest);
156 if (!portMatch) {
157 return { hostFormatValid: false, host, port: null, isIpLiteral: true, error: 'Malformed port suffix after IPv6 literal' };
158 }
159 const port = Number(portMatch[1]);
160 if (port < 1 || port > 65535) {
161 return { hostFormatValid: false, host, port: null, isIpLiteral: true, error: 'Port out of range (1-65535)' };
162 }
163 return { hostFormatValid: true, host, port, isIpLiteral: true, error: null };
164 }
165
166 const colonCount = value.split(':').length - 1;
167 if (colonCount > 1) {
168
169 return { hostFormatValid: false, host: null, port: null, isIpLiteral: false, error: 'Unbracketed value contains multiple colons; IPv6 literals must be bracketed' };
170 }
171
172 let host = value;
173 let port = null;
174 if (colonCount === 1) {
175 const idx = value.lastIndexOf(':');
176 host = value.slice(0, idx);
177 const portStr = value.slice(idx + 1);
178 if (!/^\d+$/.test(portStr)) {
179 return { hostFormatValid: false, host, port: null, isIpLiteral: false, error: 'Port must be numeric' };
180 }
181 port = Number(portStr);
182 if (port < 1 || port > 65535) {
183 return { hostFormatValid: false, host, port: null, isIpLiteral: false, error: 'Port out of range (1-65535)' };
184 }
185 }
186
187 if (!host || /\s/.test(host)) {
188 return { hostFormatValid: false, host: host || null, port, isIpLiteral: false, error: 'Host is empty or contains whitespace' };
189 }
190
191 const literalType = net.isIP(host);
192 const isIpLiteral = literalType !== 0;
193
194 if (!isIpLiteral && !/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}\.)*[a-zA-Z0-9][a-zA-Z0-9-]{0,62}$/.test(host) && !/^[a-zA-Z0-9-]{1,63}$/.test(host)) {
195 return { hostFormatValid: false, host, port, isIpLiteral, error: 'Host is not a syntactically valid hostname or IP literal' };
196 }
197
198 return { hostFormatValid: true, host, port, isIpLiteral, error: null };
199}
200
201export function effectiveFederationPort(parsedServer) {
202 return parsedServer.port ?? DEFAULT_FEDERATION_PORT;
203}
204
205function buildAuthority(host, port, isIpLiteral) {
206 const hostPart = isIpLiteral && net.isIP(host) === 6 ? `[${host}]` : host;
207 return `${hostPart}:${port}`;
208}
209
210
211
212
213
214
215export function parseClientDoc(doc) {
216 const issues = [];
217 if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) {
218 return {
219 homeserverBaseUrl: null, homeserverBaseUrlValid: false, homeserverBaseUrlHttps: false,
220 identityServerBaseUrl: null, identityServerPresent: false, identityServerBaseUrlValid: true,
221 issues: ['Client well-known response is not a JSON object.'],
222 };
223 }
224
225 const homeserver = doc['m.homeserver'];
226 let homeserverBaseUrl = null;
227 let homeserverBaseUrlValid = false;
228 let homeserverBaseUrlHttps = false;
229 if (!homeserver || typeof homeserver !== 'object' || typeof homeserver.base_url !== 'string') {
230 issues.push("'m.homeserver.base_url' is missing or not a string (Client-Server API spec requires it).");
231 } else {
232 homeserverBaseUrl = homeserver.base_url;
233 try {
234 const u = new URL(homeserverBaseUrl);
235 homeserverBaseUrlValid = ['http:', 'https:'].includes(u.protocol);
236 homeserverBaseUrlHttps = u.protocol === 'https:';
237 if (!homeserverBaseUrlValid) issues.push("'m.homeserver.base_url' must be an HTTP or HTTPS URL.");
238 else if (!homeserverBaseUrlHttps) issues.push("'m.homeserver.base_url' uses http:// instead of https://.");
239 } catch {
240 homeserverBaseUrlValid = false;
241 issues.push("'m.homeserver.base_url' is not a syntactically valid URL.");
242 }
243 }
244
245 const identityServer = doc['m.identity_server'];
246 let identityServerBaseUrl = null;
247 let identityServerPresent = false;
248 let identityServerBaseUrlValid = true;
249 if (identityServer !== undefined) {
250 identityServerPresent = true;
251 if (!identityServer || typeof identityServer !== 'object' || typeof identityServer.base_url !== 'string') {
252 identityServerBaseUrlValid = false;
253 issues.push("'m.identity_server' is present but 'base_url' is missing or not a string.");
254 } else {
255 identityServerBaseUrl = identityServer.base_url;
256 try {
257 const u = new URL(identityServerBaseUrl);
258 if (!['http:', 'https:'].includes(u.protocol)) {
259 identityServerBaseUrlValid = false;
260 issues.push("'m.identity_server.base_url' must be an HTTP or HTTPS URL.");
261 }
262 } catch {
263 identityServerBaseUrlValid = false;
264 issues.push("'m.identity_server.base_url' is not a syntactically valid URL.");
265 }
266 }
267 }
268
269 return {
270 homeserverBaseUrl, homeserverBaseUrlValid, homeserverBaseUrlHttps,
271 identityServerBaseUrl, identityServerPresent, identityServerBaseUrlValid,
272 issues,
273 };
274}
275
276export function corsAllowsOrigin(headerValue, originHost) {
277 if (!headerValue) return false;
278 if (headerValue.trim() === '*') return true;
279 try {
280 const allowed = new URL(headerValue.trim());
281 return allowed.hostname.toLowerCase() === originHost.toLowerCase();
282 } catch {
283 return false;
284 }
285}
286
287
288
289
290
291function evalServerFetch({ serverFetch }) {
292 if (serverFetch.error) {
293 return { status: 'missing', note: `/.well-known/matrix/server request failed: ${serverFetch.error}`, recommendation: 'Publish /.well-known/matrix/server over HTTPS per the Server-Server API spec so federation delegation can be resolved.' };
294 }
295 if (serverFetch.status !== 200) {
296 return { status: 'missing', note: `/.well-known/matrix/server returned HTTP ${serverFetch.status} instead of 200.`, recommendation: 'Serve a 200 response with a JSON body ({"m.server": "host[:port]"}) from /.well-known/matrix/server. Without it, federation falls back to SRV records or the default 8448 port, which is fragile.' };
297 }
298 return { status: 'good', note: '/.well-known/matrix/server responded 200.' };
299}
300
301function evalServerHttps({ serverFetch }) {
302 if (serverFetch.error) return { status: 'info', note: 'No response to evaluate.' };
303 if (!serverFetch.https) {
304 return { status: 'missing', note: '/.well-known/matrix/server was not served over HTTPS.', recommendation: 'Serve /.well-known/matrix/server over HTTPS.' };
305 }
306 return { status: 'good', note: '/.well-known/matrix/server served over HTTPS.' };
307}
308
309function evalServerJson({ serverFetch, serverDoc, serverParseError }) {
310 if (serverFetch.error || serverFetch.status !== 200) return { status: 'info', note: 'No response to evaluate.' };
311 if (serverDoc === null) {
312 return { status: 'missing', note: `/.well-known/matrix/server body is not valid JSON${serverParseError ? `: ${serverParseError}` : ''}.`, recommendation: 'Serve a valid JSON object from /.well-known/matrix/server.' };
313 }
314 return { status: 'good', note: '/.well-known/matrix/server body is valid JSON.' };
315}
316
317function evalServerHostPort({ serverFetch, parsedServer }) {
318 if (serverFetch.error || serverFetch.status !== 200) return { status: 'info', note: 'No response to evaluate.' };
319 if (!parsedServer.hostFormatValid) {
320 return { status: 'missing', note: `'m.server' value is malformed: ${parsedServer.error}.`, recommendation: "Set 'm.server' to a syntactically valid '<hostname>[:<port>]' value per the Server-Server API spec; bracket IPv6 literals." };
321 }
322 return { status: 'good', note: `'m.server' delegates federation to '${parsedServer.host}' on port ${effectiveFederationPort(parsedServer)}${parsedServer.port ? '' : ' (default)'}.` };
323}
324
325function evalServerFederationReachable({ checkFederationReachability, parsedServer, federationReachable, federationCheckError }) {
326 if (!checkFederationReachability) return { status: 'info', note: 'Federation reachability check disabled.' };
327 if (!parsedServer.hostFormatValid) return { status: 'info', note: 'Cannot test reachability; m.server is malformed.' };
328 if (federationReachable === true) {
329 return { status: 'good', note: `Federation endpoint at ${parsedServer.host}:${effectiveFederationPort(parsedServer)} responded to /_matrix/key/v2/server.` };
330 }
331 return { status: 'missing', note: `Federation endpoint at ${parsedServer.host}:${effectiveFederationPort(parsedServer)} did not respond to /_matrix/key/v2/server${federationCheckError ? `: ${federationCheckError}` : ''}.`, recommendation: 'Ensure the delegated host:port is reachable and serves /_matrix/key/v2/server per the Server-Server API spec; other homeservers cannot federate with you otherwise.' };
332}
333
334const SERVER_CHECKS = [
335 { name: 'server-endpoint', title: 'Server well-known endpoint response', weight: 15, fn: evalServerFetch },
336 { name: 'server-https', title: 'Server well-known HTTPS transport', weight: 5, fn: evalServerHttps },
337 { name: 'server-json', title: 'Server well-known JSON validity', weight: 10, fn: evalServerJson },
338 { name: 'server-hostport', title: "'m.server' host:port syntax", weight: 10, fn: evalServerHostPort },
339 { name: 'server-federation-reachable', title: 'Federation endpoint reachability', weight: 10, fn: evalServerFederationReachable },
340];
341
342
343
344
345
346function evalClientFetch({ clientFetch }) {
347 if (clientFetch.error) {
348 return { status: 'missing', note: `/.well-known/matrix/client request failed: ${clientFetch.error}`, recommendation: 'Publish /.well-known/matrix/client over HTTPS per the Client-Server API spec so clients like Element can auto-discover the homeserver.' };
349 }
350 if (clientFetch.status !== 200) {
351 return { status: 'missing', note: `/.well-known/matrix/client returned HTTP ${clientFetch.status} instead of 200.`, recommendation: 'Serve a 200 response with a JSON body ({"m.homeserver": {"base_url": "https://..."}}) from /.well-known/matrix/client.' };
352 }
353 return { status: 'good', note: '/.well-known/matrix/client responded 200.' };
354}
355
356function evalClientHttps({ clientFetch }) {
357 if (clientFetch.error) return { status: 'info', note: 'No response to evaluate.' };
358 if (!clientFetch.https) {
359 return { status: 'missing', note: '/.well-known/matrix/client was not served over HTTPS.', recommendation: 'Serve /.well-known/matrix/client over HTTPS.' };
360 }
361 return { status: 'good', note: '/.well-known/matrix/client served over HTTPS.' };
362}
363
364function evalClientJson({ clientFetch, clientDoc, clientParseError }) {
365 if (clientFetch.error || clientFetch.status !== 200) return { status: 'info', note: 'No response to evaluate.' };
366 if (clientDoc === null) {
367 return { status: 'missing', note: `/.well-known/matrix/client body is not valid JSON${clientParseError ? `: ${clientParseError}` : ''}.`, recommendation: 'Serve a valid JSON object from /.well-known/matrix/client.' };
368 }
369 return { status: 'good', note: '/.well-known/matrix/client body is valid JSON.' };
370}
371
372function evalClientBaseUrl({ clientFetch, parsedClient }) {
373 if (clientFetch.error || clientFetch.status !== 200) return { status: 'info', note: 'No response to evaluate.' };
374 if (!parsedClient.homeserverBaseUrlValid) {
375 return { status: 'missing', note: "'m.homeserver.base_url' is missing or malformed.", recommendation: "Set 'm.homeserver.base_url' to a valid HTTPS URL pointing at the homeserver's Client-Server API." };
376 }
377 if (!parsedClient.homeserverBaseUrlHttps) {
378 return { status: 'warn', note: "'m.homeserver.base_url' uses http:// instead of https://.", recommendation: "Use https:// for 'm.homeserver.base_url' so clients do not send credentials over plaintext." };
379 }
380 return { status: 'good', note: `'m.homeserver.base_url' is '${parsedClient.homeserverBaseUrl}'.` };
381}
382
383function evalClientReachable({ checkClientApiReachability, parsedClient, clientApiReachable, clientApiCheckError }) {
384 if (!checkClientApiReachability) return { status: 'info', note: 'Client API reachability check disabled.' };
385 if (!parsedClient.homeserverBaseUrlValid) return { status: 'info', note: 'Cannot test reachability; homeserver base_url is malformed.' };
386 if (clientApiReachable === true) {
387 return { status: 'good', note: `${parsedClient.homeserverBaseUrl} responded to /_matrix/client/versions.` };
388 }
389 return { status: 'missing', note: `${parsedClient.homeserverBaseUrl} did not respond to /_matrix/client/versions${clientApiCheckError ? `: ${clientApiCheckError}` : ''}.`, recommendation: "Ensure 'm.homeserver.base_url' actually serves the Matrix Client-Server API (/_matrix/client/versions must respond)." };
390}
391
392function evalClientCors({ clientFetch, serverName }) {
393 if (clientFetch.error || clientFetch.status !== 200) return { status: 'info', note: 'No response to evaluate.' };
394 const ok = corsAllowsOrigin(clientFetch.accessControlAllowOrigin, serverName);
395 if (ok) {
396 return { status: 'good', note: `Access-Control-Allow-Origin header present ('${clientFetch.accessControlAllowOrigin}'), enabling browser-based clients.` };
397 }
398 return { status: 'warn', note: 'No permissive Access-Control-Allow-Origin header on /.well-known/matrix/client.', recommendation: 'Set Access-Control-Allow-Origin: * on /.well-known/matrix/client per the Matrix spec so browser-based clients like Element Web can read it.' };
399}
400
401const CLIENT_CHECKS = [
402 { name: 'client-endpoint', title: 'Client well-known endpoint response', weight: 15, fn: evalClientFetch },
403 { name: 'client-https', title: 'Client well-known HTTPS transport', weight: 5, fn: evalClientHttps },
404 { name: 'client-json', title: 'Client well-known JSON validity', weight: 10, fn: evalClientJson },
405 { name: 'client-baseurl', title: "'m.homeserver.base_url' validity", weight: 5, fn: evalClientBaseUrl },
406 { name: 'client-reachable', title: 'Client API reachability', weight: 10, fn: evalClientReachable },
407 { name: 'client-cors', title: 'CORS on client well-known file', weight: 5, fn: evalClientCors },
408];
409
410
411
412
413
414
415
416
417function runChecks(checks, context) {
418 const reports = [];
419 let earned = 0;
420 let possible = 0;
421 for (const check of checks) {
422 const evaluation = check.fn(context);
423 if (evaluation.status !== 'info') possible += check.weight;
424 if (evaluation.status === 'good') earned += check.weight;
425 else if (evaluation.status === 'warn') earned += Math.round(check.weight * 0.5);
426 reports.push({
427 name: check.title,
428 check: check.name,
429 status: evaluation.status,
430 note: evaluation.note,
431 weight: check.weight,
432 recommendation: evaluation.recommendation || null,
433 });
434 }
435 return { reports, earned, possible };
436}
437
438export function halfScore(earned, possible, cap) {
439 if (possible === 0) return 0;
440 return Math.min(cap, Math.max(0, Math.round((earned / possible) * cap)));
441}
442
443export function combineScores(serverPoints, clientPoints) {
444
445
446 return Math.min(100, Math.max(0, serverPoints + clientPoints));
447}
448
449export function gradeFromScore(score) {
450 if (score >= 95) return 'A+';
451 if (score >= 85) return 'A';
452 if (score >= 75) return 'B';
453 if (score >= 65) return 'C';
454 if (score >= 50) return 'D';
455 if (score >= 30) return 'E';
456 return 'F';
457}
458
459function collectRecommendations(reports) {
460 const recs = [];
461 for (const r of reports) {
462 if (r.recommendation && !recs.includes(r.recommendation)) recs.push(r.recommendation);
463 }
464 return recs;
465}
466
467
468
469
470
471export async function auditMatrixWellKnown(input) {
472 const serverNameRaw = input.serverName || input.startUrl;
473 const checkFederationReachability = input.checkFederationReachability !== false;
474 const checkClientApiReachability = input.checkClientApiReachability !== false;
475 const timeoutSeconds = Math.min(Math.max(Number(input.timeoutSeconds || DEFAULT_TIMEOUT_SECONDS), 3), 30);
476
477 const originUrl = await normalizeAndValidateUrl(serverNameRaw);
478 const serverName = originUrl.hostname;
479
480 const serverWellKnownUrl = new URL(`https://${serverName}/.well-known/matrix/server`);
481 const clientWellKnownUrl = new URL(`https://${serverName}/.well-known/matrix/client`);
482
483 const serverFetch = await fetchOnce(serverWellKnownUrl, timeoutSeconds);
484 const clientFetch = await fetchOnce(clientWellKnownUrl, timeoutSeconds);
485
486 let serverDoc = null;
487 let serverParseError = null;
488 if (serverFetch.body) {
489 try {
490 serverDoc = JSON.parse(serverFetch.body);
491 } catch (err) {
492 serverParseError = err.message;
493 }
494 }
495
496 let clientDoc = null;
497 let clientParseError = null;
498 if (clientFetch.body) {
499 try {
500 clientDoc = JSON.parse(clientFetch.body);
501 } catch (err) {
502 clientParseError = err.message;
503 }
504 }
505
506 const mServerRaw = serverDoc && typeof serverDoc === 'object' && !Array.isArray(serverDoc) ? serverDoc['m.server'] : null;
507 const parsedServer = parseMServer(mServerRaw);
508
509 let federationReachable = null;
510 let federationCheckError = null;
511 if (checkFederationReachability && serverFetch.status === 200 && parsedServer.hostFormatValid) {
512 try {
513 const port = effectiveFederationPort(parsedServer);
514 const authority = buildAuthority(parsedServer.host, port, parsedServer.isIpLiteral);
515 const target = new URL(`https://${authority}/_matrix/key/v2/server`);
516 const result = await fetchOnce(target, timeoutSeconds);
517 federationReachable = result.ok && result.status === 200;
518 if (!federationReachable) federationCheckError = result.error || `HTTP ${result.status}`;
519 } catch (err) {
520 federationReachable = false;
521 federationCheckError = err.message;
522 }
523 }
524
525 const parsedClient = clientFetch.status === 200
526 ? parseClientDoc(clientDoc)
527 : { homeserverBaseUrl: null, homeserverBaseUrlValid: false, homeserverBaseUrlHttps: false, identityServerBaseUrl: null, identityServerPresent: false, identityServerBaseUrlValid: true, issues: [] };
528
529 let clientApiReachable = null;
530 let clientApiCheckError = null;
531 if (checkClientApiReachability && clientFetch.status === 200 && parsedClient.homeserverBaseUrlValid) {
532 try {
533 const base = await normalizeAndValidateUrl(parsedClient.homeserverBaseUrl);
534 const target = new URL('/_matrix/client/versions', base);
535 const result = await fetchOnce(target, timeoutSeconds);
536 clientApiReachable = result.ok && result.status === 200;
537 if (!clientApiReachable) clientApiCheckError = result.error || `HTTP ${result.status}`;
538 } catch (err) {
539 clientApiReachable = false;
540 clientApiCheckError = err.message;
541 }
542 }
543
544 const sharedContext = {
545 serverFetch, serverDoc, serverParseError, parsedServer,
546 clientFetch, clientDoc, clientParseError, parsedClient,
547 checkFederationReachability, federationReachable, federationCheckError,
548 checkClientApiReachability, clientApiReachable, clientApiCheckError,
549 serverName,
550 };
551
552 const serverResult = runChecks(SERVER_CHECKS, sharedContext);
553 const clientResult = runChecks(CLIENT_CHECKS, sharedContext);
554
555 const serverPoints = halfScore(serverResult.earned, serverResult.possible, 50);
556 const clientPoints = halfScore(clientResult.earned, clientResult.possible, 50);
557 const score = combineScores(serverPoints, clientPoints);
558 const grade = gradeFromScore(score);
559
560 const serverIssues = serverResult.reports.filter((r) => r.status === 'warn' || r.status === 'missing').map((r) => `${r.name}: ${r.note}`);
561 const clientIssues = clientResult.reports.filter((r) => r.status === 'warn' || r.status === 'missing').map((r) => `${r.name}: ${r.note}`);
562
563 const serverRecommendations = collectRecommendations(serverResult.reports);
564 const clientRecommendations = collectRecommendations(clientResult.reports);
565
566 if (serverFetch.error && clientFetch.error) {
567 serverRecommendations.push('Neither well-known file was reachable; federation may still work via SRV record fallback (per the Server-Server API spec) or the default port 8448, but well-known delegation is the recommended, more flexible mechanism and should be published.');
568 }
569
570 const combinedIssues = [...serverIssues, ...clientIssues];
571 const combinedRecommendations = [...serverRecommendations, ...clientRecommendations];
572 if (combinedRecommendations.length === 0) {
573 combinedRecommendations.push('Matrix well-known federation and client discovery look spec-conformant. Re-run this audit after homeserver upgrades to catch regressions.');
574 }
575
576 return {
577 serverName,
578 checkedAt: new Date().toISOString(),
579 server: {
580 url: serverWellKnownUrl.href,
581 finalUrl: serverFetch.finalUrl,
582 httpStatus: serverFetch.status,
583 https: serverFetch.https,
584 contentType: serverFetch.contentType || null,
585 found: serverFetch.ok && serverFetch.status === 200,
586 jsonValid: serverDoc !== null,
587 parseError: serverParseError,
588 mServerRaw: typeof mServerRaw === 'string' ? mServerRaw : null,
589 mServerHost: parsedServer.host,
590 mServerPort: parsedServer.port,
591 hostFormatValid: parsedServer.hostFormatValid,
592 isIpLiteral: parsedServer.isIpLiteral,
593 federationReachable,
594 federationCheckError,
595 issues: serverIssues,
596 },
597 client: {
598 url: clientWellKnownUrl.href,
599 finalUrl: clientFetch.finalUrl,
600 httpStatus: clientFetch.status,
601 https: clientFetch.https,
602 contentType: clientFetch.contentType || null,
603 found: clientFetch.ok && clientFetch.status === 200,
604 jsonValid: clientDoc !== null,
605 parseError: clientParseError,
606 homeserverBaseUrl: parsedClient.homeserverBaseUrl,
607 homeserverBaseUrlHttps: parsedClient.homeserverBaseUrlHttps,
608 identityServerBaseUrl: parsedClient.identityServerBaseUrl,
609 identityServerPresent: parsedClient.identityServerPresent,
610 corsAllowOrigin: clientFetch.accessControlAllowOrigin,
611 corsOk: corsAllowsOrigin(clientFetch.accessControlAllowOrigin, serverName),
612 clientApiReachable,
613 clientApiCheckError,
614 issues: clientIssues,
615 },
616 score,
617 grade,
618 issues: combinedIssues,
619 recommendations: combinedRecommendations,
620 };
621}
622
623
624
625
626
627const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
628
629if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
630 await Actor.init();
631 try {
632 const input = await Actor.getInput();
633 const result = await auditMatrixWellKnown(input || {});
634 await Actor.pushData(result);
635 await Actor.setValue('OUTPUT', result);
636 Actor.log.info('Matrix well-known audit complete', { serverName: result.serverName, score: result.score, grade: result.grade });
637 } finally {
638 await Actor.exit();
639 }
640}