-
Notifications
You must be signed in to change notification settings - Fork 71
/
matrix-host-resolver.ts
305 lines (277 loc) · 11.3 KB
/
matrix-host-resolver.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
import { URL } from "url";
import { isIP } from "net";
import { promises as dns, SrvRecord } from "dns"
import { Logger } from "..";
interface MatrixServerWellKnown {
"m.server": string;
}
const OneMinute = 1000 * 60;
const OneHour = OneMinute * 60;
export const MinCacheForMs = OneMinute * 5;
export const MaxCacheForMs = OneHour * 48;
export const DefaultCacheForMs = OneHour * 24;
const CacheFailureForMS = MinCacheForMs;
const DefaultMatrixServerPort = 8448;
const MaxPortNumber = 65535;
const WellKnownTimeout = 10000;
const log = new Logger('MatrixHostResolver');
type CachedResult = {timestamp: number, result: HostResolveResult}|{timestamp: number, error: Error};
export interface HostResolveResult {
host: string;
hostname: string;
port: number;
cacheFor: number;
}
interface DnsInterface {
resolveSrv(hostname: string): Promise<SrvRecord[]>;
}
/**
* Class to lookup the hostname, port and host headers of a given Matrix servername
* according to the
* [server discovery section of the spec](https://spec.matrix.org/v1.1/server-server-api/#server-discovery).
*/
export class MatrixHostResolver {
private fetch: typeof fetch;
private dns: DnsInterface;
private resultCache = new Map<string, CachedResult>();
constructor(private readonly opts: {fetch?: typeof fetch, dns?: DnsInterface, currentTimeMs?: number} = {}) {
// To allow for easier mocking.
this.fetch = opts.fetch ?? fetch;
this.dns = opts.dns || dns;
}
get currentTime(): number {
return this.opts.currentTimeMs || Date.now();
}
private static sortSrvRecords(a: SrvRecord, b: SrvRecord): number {
// This algorithm is intentionally simple, as we're unlikely
// to encounter many Matrix servers that actually load balance this way.
const diffPrio = a.priority - b.priority;
if (diffPrio != 0) {
return diffPrio;
}
return a.weight - b.weight;
}
private static determineHostType(serverName: string): {type: 4|6|"unknown", host: string, port?: number} {
const hostPortPair = /(.+):(\d+)/.exec(serverName);
let host = serverName;
let port = undefined;
if (hostPortPair) {
port = parseInt(hostPortPair[2]);
if (host.startsWith('[') && host.endsWith(']')) {
host = host.slice(1, host.length - 2);
// IPv6 square bracket notation
if (isIP(host) !== 6) {
throw Error('Unknown IPv6 notation');
}
}
else if (isIP(serverName) === 6) {
// Address is IPv6, but it doesn't have a port
port = undefined;
host = serverName;
}
else {
host = hostPortPair[1];
}
}
const ipResult = isIP(host) as 4|6|0;
return {
type: ipResult === 0 ? "unknown" : ipResult,
port,
host,
}
}
private async getWellKnown(serverName: string): Promise<{mServer: string, cacheFor: number}> {
const url = `https://${serverName}/.well-known/matrix/server`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), WellKnownTimeout);
// Will throw on timeout.
const wellKnown = await this.fetch(url, {signal: controller.signal });
clearTimeout(timeout);
if (wellKnown.status !== 200) {
throw Error('Well known request returned non-200');
}
let wellKnownData: MatrixServerWellKnown;
try {
wellKnownData = await wellKnown.json() as MatrixServerWellKnown;
}
catch (ex) {
throw Error('Invalid datatype for well-known response');
}
const mServer = wellKnownData["m.server"];
if (typeof mServer !== "string") {
throw Error("Missing 'm.server' in well-known response");
}
const [host, portStr] = mServer.split(':');
const port = portStr ? parseInt(portStr, 10) : DefaultMatrixServerPort;
if (!host || (port && port < 1 || port > MaxPortNumber)) {
throw Error("'m.server' was not in the format of <delegated_hostname>[:<delegated_port>]")
}
let cacheFor = DefaultCacheForMs;
const expiresHeader = wellKnown.headers.get('Expires');
if (expiresHeader) {
try {
cacheFor = new Date(expiresHeader).getTime() - this.currentTime;
}
catch (ex) {
log.warn(`Expires header provided by ${url} could not be parsed`, ex);
}
}
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
const cacheControlHeader: string[] = wellKnown.headers.get('Cache-Control')?.toLowerCase()
.split(',')
.map(s => s.trim()) || [];
const maxAge = parseInt(
cacheControlHeader.find(s => s.startsWith('max-age'))?.substr("max-age=".length) || "NaN",
10
);
if (maxAge) {
cacheFor = Math.min(Math.max(maxAge * 1000, MinCacheForMs), MaxCacheForMs);
}
if (cacheControlHeader?.includes('no-cache') || cacheControlHeader?.includes('no-store')) {
cacheFor = 0;
}
return { cacheFor, mServer };
}
/**
* Resolves a Matrix serverName, fetching any delegated information.
* This request is NOT cached. For general use, please use `resolveMatrixServer`.
* @param hostname The Matrix `hostname` to resolve. e.g. `matrix.org`
* @returns An object describing the delegated details for the host.
*/
async resolveMatrixServerName(hostname: string): Promise<HostResolveResult> {
// https://spec.matrix.org/v1.1/server-server-api/#resolving-server-names
const { type, host, port } = MatrixHostResolver.determineHostType(hostname);
// Step 1 - IP literal / Step 2
if (type !== "unknown" || port) {
log.debug(`Resolved ${hostname} to be IP literal / non-ip literal with port`);
return {
host,
port: port || DefaultMatrixServerPort,
// Host header should include the port
hostname: hostname,
cacheFor: DefaultCacheForMs,
}
}
// Step 3 - Well-known
let wellKnownResponse: {mServer: string, cacheFor: number}|undefined = undefined;
try {
wellKnownResponse = await this.getWellKnown(hostname);
log.debug(`Resolved ${hostname} to be well-known`);
}
catch (ex) {
// Fall through to step 4.
log.debug(`No well-known found for ${hostname}: ${ex instanceof Error ? ex.message : ex}`);
}
if (wellKnownResponse) {
const { mServer, cacheFor } = wellKnownResponse;
const wkHost = MatrixHostResolver.determineHostType(mServer);
// 3.1 / 3.2
if (type !== "unknown" || wkHost.port) {
return {
host: wkHost.host,
port: wkHost.port || DefaultMatrixServerPort,
// Host header should include the port
hostname: mServer,
cacheFor,
}
}
// 3.3
try {
const [srvResult] = (await this.dns.resolveSrv(`_matrix._tcp.${hostname}`))
.sort(MatrixHostResolver.sortSrvRecords);
return {
host: srvResult.name,
port: srvResult.port,
hostname: mServer,
cacheFor,
};
}
catch (ex) {
log.debug(`No well-known SRV found for ${hostname}: ${ex instanceof Error ? ex.message : ex}`);
}
// 3.4
return {
host: wkHost.host,
port: wkHost.port || DefaultMatrixServerPort,
// Host header should include the port
hostname: mServer,
cacheFor,
}
}
// Step 4 - SRV
try {
const [srvResult] = (await this.dns.resolveSrv(`_matrix._tcp.${hostname}`))
.sort(MatrixHostResolver.sortSrvRecords);
return {
host: srvResult.name,
port: srvResult.port,
hostname: hostname,
cacheFor: DefaultCacheForMs,
};
}
catch (ex) {
log.debug(`No SRV found for ${hostname}: ${ex instanceof Error ? ex.message : ex}`);
}
// Step 5 - Normal resolve
return {
host,
port: port || DefaultMatrixServerPort,
// Host header should include the port
hostname: hostname,
cacheFor: DefaultCacheForMs,
}
}
/**
* Resolves a Matrix serverName into the baseURL for federated requests, and the
* `Host` header to use when serving requests.
*
* Results are cached by default. Please note that failures are cached, determined by
* the constant `CacheFailureForMS`.
* @param hostname The Matrix `hostname` to resolve. e.g. `matrix.org`
* @param skipCache Should the request be executed regardless of the cached value? Existing cached values will
* be overwritten.
* @returns The baseurl of the Matrix server (excluding /_matrix/federation suffix), and the hostHeader to be used.
*/
async resolveMatrixServer(hostname: string, skipCache = false): Promise<{url: URL, hostHeader: string}> {
const cachedResult = skipCache ? false : this.resultCache.get(hostname);
if (cachedResult) {
const cacheAge = this.currentTime - cachedResult.timestamp;
if ("result" in cachedResult && cacheAge <= cachedResult.result.cacheFor) {
const result = cachedResult.result;
log.debug(
`Cached result for ${hostname}, returning (alive for ${result.cacheFor - cacheAge}ms)`
);
return {
url: new URL(`https://${result.host}:${result.port}/`),
hostHeader: result.hostname,
};
}
else if ("error" in cachedResult && cacheAge <= CacheFailureForMS) {
log.debug(
`Cached error for ${hostname}, throwing (alive for ${CacheFailureForMS - cacheAge}ms)`
);
throw cachedResult.error;
}
// Otherwise expired entry.
}
try {
const result = await this.resolveMatrixServerName(hostname);
if (result.cacheFor) {
this.resultCache.set(hostname, { result, timestamp: this.currentTime});
}
log.debug(`No result cached for ${hostname}, caching result for ${result.cacheFor}ms`);
return {
url: new URL(`https://${result.host}:${result.port}/`),
hostHeader: result.hostname,
};
}
catch (error) {
this.resultCache.set(hostname, {
timestamp: this.currentTime,
error: error instanceof Error ? error : Error(String(error)),
});
log.debug(`No result cached for ${hostname}, caching error for ${CacheFailureForMS}ms`);
throw error;
}
}
}