diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c334213c3..a8c5fd156 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -86,3 +86,5 @@ export * from './url'; export { makeUnsignedRequest } from './utils/makeRequest'; export { fetch } from './utils/fetch'; + +export * from './server-discovery/discovery'; diff --git a/packages/core/src/procedures/getPublicKeyFromServer.spec.ts b/packages/core/src/procedures/getPublicKeyFromServer.spec.ts index 839ba1bbe..bc3bf2add 100644 --- a/packages/core/src/procedures/getPublicKeyFromServer.spec.ts +++ b/packages/core/src/procedures/getPublicKeyFromServer.spec.ts @@ -1,10 +1,12 @@ -import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; import nacl from 'tweetnacl'; import { EncryptionValidAlgorithm } from '../types'; import { generateKeyPairs } from '../utils/keys'; import { encodeCanonicalJson } from '../utils/signJson'; import { getPublicKeyFromRemoteServer } from './getPublicKeyFromServer'; +import { type getHomeserverFinalAddress } from '../server-discovery/discovery'; + describe('getPublicKeyFromRemoteServer', () => { let originalFetch: typeof globalThis.fetch; let mockServerKeys: { @@ -68,6 +70,16 @@ describe('getPublicKeyFromRemoteServer', () => { }; mockFetch.preconnect = async () => undefined; globalThis.fetch = mockFetch; + + await mock.module('../server-discovery/discovery', () => ({ + resolveHostname: (): ReturnType => + Promise.resolve([ + 'https://127.0.0.1:443' as const, + { + Host: '127.0.0.1:443', + } as const, + ]), + })); }); afterEach(() => { diff --git a/packages/federation-sdk/src/server-discovery/_multi-error.ts b/packages/core/src/server-discovery/_multi-error.ts similarity index 100% rename from packages/federation-sdk/src/server-discovery/_multi-error.ts rename to packages/core/src/server-discovery/_multi-error.ts diff --git a/packages/federation-sdk/src/server-discovery/_resolver.ts b/packages/core/src/server-discovery/_resolver.ts similarity index 100% rename from packages/federation-sdk/src/server-discovery/_resolver.ts rename to packages/core/src/server-discovery/_resolver.ts diff --git a/packages/federation-sdk/src/server-discovery/_url.ts b/packages/core/src/server-discovery/_url.ts similarity index 100% rename from packages/federation-sdk/src/server-discovery/_url.ts rename to packages/core/src/server-discovery/_url.ts diff --git a/packages/core/src/server-discovery/discovery.spec.ts b/packages/core/src/server-discovery/discovery.spec.ts index 091fc8ab0..80d09cb29 100644 --- a/packages/core/src/server-discovery/discovery.spec.ts +++ b/packages/core/src/server-discovery/discovery.spec.ts @@ -1,598 +1,258 @@ -import { - afterAll, - afterEach, - describe, - expect, - it, - jest, - mock, -} from 'bun:test'; - -/** - * This is a workaround restore the original module, since bun module mock is a bit weird - */ -import ResolverModule from 'node:dns/promises'; - -const { Resolver } = ResolverModule; - -import { - addressWithDefaultPort, - getAddressFromTargetWellKnownEndpoint, - getWellKnownCachedAddress, - isIpLiteral, - resolveHostAddressByServerName, - resolveUsingSRVRecordsOrFallbackToOtherRecords, - resolveWhenServerNameIsAddressWithPort, - resolveWhenServerNameIsIpAddress, - wellKnownCache, -} from './discovery'; - -const mockResolver = { - resolveAny: jest.fn(), - resolveSrv: jest.fn(), -}; - -mock.module('node:dns/promises', () => ({ - Resolver: jest.fn(() => mockResolver), -})); +import { describe, expect, it, mock } from 'bun:test'; +import sinon from 'sinon'; -afterAll(() => { - // restore the original module - mock.module('node:dns/promises', () => ({ - Resolver, - })); -}); +const stubs = { + fetch: sinon.stub(), -describe('#isIpLiteral()', () => { - it('should return true for valid IPv4 addresses', () => { - expect(isIpLiteral('192.168.1.1')).toBe(true); - expect(isIpLiteral('127.0.0.1')).toBe(true); - }); + resolveHostname: sinon.stub(), + resolveSrv: sinon.stub(), +} as const; - it('should return true for valid IPv6 addresses', () => { - expect(isIpLiteral('::1')).toBe(true); - expect(isIpLiteral('2001:0db8:85a3:0000:0000:8a2e:0370:7334')).toBe(true); - }); +await mock.module('./discovery', () => ({ + resolveHostname: stubs.resolveHostname, +})); - it('should return false for invalid IP addresses', () => { - expect(isIpLiteral('999.999.999.999')).toBe(false); - expect(isIpLiteral('invalid-ip')).toBe(false); - }); +await mock.module('./_resolver', () => ({ + resolver: { + resolveSrv: stubs.resolveSrv, + }, +})); - it('should return false for empty string', () => { - expect(isIpLiteral('')).toBe(false); - }); +import { _URL } from './_url'; +import { getHomeserverFinalAddress } from './discovery'; - it('should return false for non-IP strings', () => { - expect(isIpLiteral('hello world')).toBe(false); - }); +const mockFetch = stubs.fetch as unknown as typeof fetch; +// const originalFetch = globalThis.fetch; +globalThis.fetch = mockFetch; - it('should return true for IP addresses with ports', () => { - expect(isIpLiteral('192.168.1.1:8080')).toBe(true); - expect(isIpLiteral('[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:443')).toBe( - true, - ); - }); -}); +// each function describes a stage of the spec to test spec conformity +// function returns the set of inputs to test with. +// each step should behave the same way so the modifications to the stub returns should not change. +// -describe('#addressWithDefaultPort()', () => { - it('should append default port 8448 to IPv4 address', () => { - expect(addressWithDefaultPort('192.168.1.1')).toBe('192.168.1.1:8448'); - }); +type INPUT = string; +type OUTPUT = [`https://${string}:${string | number}`, { Host: string }]; - it('should append default port 8448 to IPv6 address', () => { - expect( - addressWithDefaultPort('[2001:0db8:85a3:0000:0000:8a2e:0370:7334]'), - ).toBe('[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:8448'); - }); +/* + * 1. If the hostname is an IP literal, then that IP address should be used, together with the given port number, or 8448 if no port is given. The target server must present a valid certificate for the IP address. The Host header in the request should be set to the server name, including the port if the server name included one. + */ - it('should append default port 8448 to domain name', () => { - expect(addressWithDefaultPort('example.com')).toBe('example.com:8448'); - }); -}); +function spec_1__1(): [INPUT[], OUTPUT[]] { + return [ + ['11.0.0.1', '11.0.0.1:45'], + [ + ['https://11.0.0.1:8448' as const, { Host: '11.0.0.1' }], + ['https://11.0.0.1:45' as const, { Host: '11.0.0.1:45' }], + ], + ]; +} + +function spec_1__2(): [INPUT[], OUTPUT[]] { + return [ + ['[::1]', '[::1]:45'], + [ + ['https://[::1]:8448' as const, { Host: '[::1]' }], + ['https://[::1]:45' as const, { Host: '[::1]:45' }], + ], + ]; +} + +/* + * SPEC: + * 2. If the hostname is not an IP literal, and the server name includes an explicit port, resolve the hostname to an IP address using CNAME, AAAA or A records. Requests are made to the resolved IP address and given port with a Host header of the original server name (with port). The target server must present a valid certificate for the hostname. + */ -describe('#resolveWhenServerNameIsIpAddress()', () => { - it('should return the same address with port if IPv4 address has a port', async () => { - const result = await resolveWhenServerNameIsIpAddress('192.168.1.1:8080'); - expect(result).toBe('192.168.1.1:8080'); - }); +function spec_2__1(): [INPUT[], OUTPUT[]] { + stubs.resolveHostname.resolves('11.0.0.1'); + return [ + ['example.com:45'], + [['https://11.0.0.1:45' as const, { Host: 'example.com:45' }]], + ]; +} - it('should return the same address with port if IPv6 address has a port', async () => { - const result = await resolveWhenServerNameIsIpAddress( - '[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:443', - ); - expect(result).toBe('[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:443'); - }); +function spec_2__2(): [INPUT[], OUTPUT[]] { + stubs.resolveHostname.resolves('[::1]'); + return [ + ['example.com:45'], + [['https://[::1]:45' as const, { Host: 'example.com:45' }]], + ]; +} - it('should append default port 8448 if IPv4 address does not have a port', async () => { - const result = await resolveWhenServerNameIsIpAddress('192.168.1.1'); - expect(result).toBe('192.168.1.1:8448'); - }); +// wellknown +// If is an IP literal, then that IP address should be used together with the or 8448 if no port is provided. The target server must present a valid TLS certificate for the IP address. Requests must be made with a Host header containing the IP address, including the port if one was provided. +function spec_3_1__1(): [INPUT[], OUTPUT[]] { + // If the hostname is not an IP literal and no port is provided + const inputs = ['example.com']; - it('should append default port 8448 if IPv6 address does not have a port', async () => { - const result = await resolveWhenServerNameIsIpAddress( - '2001:0db8:85a3:0000:0000:8a2e:0370:7334', - ); - expect(result).toBe('[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:8448'); - }); + stubs.resolveHostname.resolves('11.0.0.1'); - it('should return the same address if it is a valid IPv4 address with default port', async () => { - const result = await resolveWhenServerNameIsIpAddress('192.168.1.1:8448'); - expect(result).toBe('192.168.1.1:8448'); + // Mock the .well-known response + stubs.fetch.resolves({ + ok: true, + json: () => Promise.resolve({ 'm.server': '11.0.0.1:45' }), + headers: new Headers({ + 'cache-control': 'max-age=3600', + }), }); - it('should return the same address if it is a valid IPv6 address with default port', async () => { - const result = await resolveWhenServerNameIsIpAddress( - '[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:8448', - ); - expect(result).toBe('[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:8448'); - }); + return [inputs, [['https://11.0.0.1:45' as const, { Host: '11.0.0.1:45' }]]]; +} - it('should return the provided string with the default port for invalid IP addresses', async () => { - const result = await resolveWhenServerNameIsIpAddress('invalid'); - expect(result).toBe('invalid:8448'); - }); +function spec_3_1__2(): [INPUT[], OUTPUT[]] { + const inputs = ['example.com']; - it('should throw an error if the URL is not parseable', async () => { - await expect( - resolveWhenServerNameIsIpAddress('::invalid'), - ).rejects.toThrow(); - }); -}); + stubs.resolveHostname.resolves('[::1]'); -describe('#resolveWhenServerNameIsAddressWithPort()', () => { - afterEach(() => { - jest.clearAllMocks(); + stubs.fetch.resolves({ + ok: true, + json: () => Promise.resolve({ 'm.server': '[::1]:45' }), }); - it('should resolve to CNAME record with port', async () => { - mockResolver.resolveAny.mockResolvedValueOnce([ - { type: 'CNAME', value: 'alias.example.com' }, - ]); + return [inputs, [['https://[::1]:45' as const, { Host: '[::1]:45' }]]]; +} - const result = - await resolveWhenServerNameIsAddressWithPort('example.com:8080'); - expect(result).toBe('alias.example.com:8080'); - }); +/* 3.2. If is not an IP literal, and is present, an IP address is discovered by looking up CNAME, AAAA or A records for . The resulting IP address is used, alongside the . Requests must be made with a Host header of :. The target server must present a valid certificate for . + */ +function spec_3_2(): [INPUT[], OUTPUT[]] { + const inputs = ['example.com']; - it('should resolve to AAAA record with port', async () => { - mockResolver.resolveAny.mockResolvedValueOnce([ - { - type: 'AAAA', - address: '2001:0db8:85a3:0000:0000:8a2e:0370:7334', - ttl: 300, - }, - ]); - - const result = - await resolveWhenServerNameIsAddressWithPort('example.com:8080'); - expect(result).toBe('[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:8080'); - }); + stubs.resolveHostname.reset(); - it('should resolve to A record with port', async () => { - mockResolver.resolveAny.mockResolvedValueOnce([ - { type: 'A', address: '192.168.1.1', ttl: 300 }, - ]); + // for some reason onFirstCall and onSecondCall is not working + stubs.resolveHostname.callsFake((hostname: string) => { + if (hostname === 'example.com') { + return Promise.resolve('11.0.0.1'); + } - const result = - await resolveWhenServerNameIsAddressWithPort('example.com:8080'); - expect(result).toBe('192.168.1.1:8080'); + if (hostname === 'example2.com') { + return Promise.resolve('[::1]'); + } }); - it('should return the same address with its port if no records are found', async () => { - mockResolver.resolveAny.mockResolvedValueOnce([]); - - const result = - await resolveWhenServerNameIsAddressWithPort('example.com:8080'); - expect(result).toBe('example.com:8080'); + stubs.fetch.resolves({ + ok: true, + json: () => Promise.resolve({ 'm.server': 'example2.com:45' }), // delegatedPort is present }); - it('should throw an error if an error occurs during resolution', async () => { - mockResolver.resolveAny.mockRejectedValueOnce( - new Error('DNS resolution error'), - ); + return [inputs, [['https://[::1]:45' as const, { Host: 'example2.com:45' }]]]; +} - await expect( - resolveWhenServerNameIsAddressWithPort('example.com:8080'), - ).rejects.toThrow('DNS resolution error'); - }); -}); - -describe('#resolveUsingSRVRecordsOrFallbackToOtherRecords()', () => { - afterEach(() => { - jest.clearAllMocks(); - }); +/* If is not an IP literal and no is present, an SRV record is looked up for _matrix-fed._tcp.. This may result in another hostname (to be resolved using AAAA or A records) and port. Requests should be made to the resolved IP address and port with a Host header containing the . The target server must present a valid certificate for .*/ +function spec_3_3__1(): [INPUT[], OUTPUT[]] { + const inputs = ['example.com']; - it('should resolve to SRV record target with default port', async () => { - mockResolver.resolveSrv = jest - .fn() - .mockResolvedValue([ - { name: 'srv.example.com', port: 8448, priority: 10, weight: 5 }, - ]); - mockResolver.resolveAny.mockResolvedValueOnce([ - { type: 'A', address: '192.168.1.1', ttl: 300 }, - ]); - - const result = - await resolveUsingSRVRecordsOrFallbackToOtherRecords('example.com'); - expect(result).toBe('192.168.1.1:8448'); - }); + stubs.resolveHostname.resolves('11.0.0.1'); - it('should resolve to SRV record target with specified port', async () => { - mockResolver.resolveSrv = jest - .fn() - .mockResolvedValue([ - { name: 'srv.example.com', port: 8448, priority: 10, weight: 5 }, - ]); - mockResolver.resolveAny.mockResolvedValueOnce([ - { type: 'A', address: '192.168.1.1', ttl: 300 }, - ]); - - const result = - await resolveUsingSRVRecordsOrFallbackToOtherRecords('example.com'); - expect(result).toBe('192.168.1.1:8448'); + stubs.fetch.resolves({ + ok: true, + json: () => Promise.resolve({ 'm.server': 'example2.com' }), // no delegatedPort is present, delegatedHostname is present and not ip }); - it('should resolve to AAAA record if no SRV records are found', async () => { - mockResolver.resolveSrv = jest.fn().mockResolvedValue([]); - mockResolver.resolveAny.mockResolvedValueOnce([ - { - type: 'AAAA', - address: '2001:0db8:85a3:0000:0000:8a2e:0370:7334', - ttl: 300, - }, - ]); - - const result = - await resolveUsingSRVRecordsOrFallbackToOtherRecords('example.com'); - expect(result).toBe('[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:8448'); - }); + stubs.resolveSrv.resolves([{ name: '::1', port: 45 }]); - it('should resolve to A record if no SRV records are found', async () => { - mockResolver.resolveSrv = jest.fn().mockResolvedValue([]); - mockResolver.resolveAny.mockResolvedValueOnce([ - { type: 'A', address: '192.168.1.1', ttl: 300 }, - ]); + return [inputs, [['https://[::1]:45' as const, { Host: 'example2.com' }]]]; +} - const result = - await resolveUsingSRVRecordsOrFallbackToOtherRecords('example.com'); - expect(result).toBe('192.168.1.1:8448'); - }); +function spec_3_3__2(): [INPUT[], OUTPUT[]] { + const inputs = ['example.com']; - it('should return the same address with default port if no records are found', async () => { - mockResolver.resolveSrv = jest.fn().mockResolvedValue([]); - mockResolver.resolveAny.mockResolvedValueOnce([]); + stubs.resolveHostname.callsFake((name) => { + if (name === 'exmaple.com') return '11.0.0.1'; - const result = - await resolveUsingSRVRecordsOrFallbackToOtherRecords('example.com'); - expect(result).toBe('example.com:8448'); + if (name === 'example3.com') return '[::1]'; }); - it('should throw an error if an error occurs during SRV resolution', async () => { - mockResolver.resolveSrv = jest - .fn() - .mockRejectedValue(new Error('DNS resolution error')); - - await expect( - resolveUsingSRVRecordsOrFallbackToOtherRecords('example.com'), - ).rejects.toThrow('DNS resolution error'); + stubs.fetch.resolves({ + ok: true, + json: () => Promise.resolve({ 'm.server': 'example2.com' }), // no delegatedPort is present, delegatedHostname is present and not ip }); - it('should throw an error if an error if an error occurs during ANY resolution', async () => { - mockResolver.resolveSrv = jest.fn().mockResolvedValue([]); - mockResolver.resolveAny.mockRejectedValueOnce( - new Error('DNS resolution error'), - ); + stubs.resolveSrv.resolves([{ name: 'example3.com', port: 45 }]); // another hostname + // now should do another resolveHostname - await expect( - resolveUsingSRVRecordsOrFallbackToOtherRecords('example.com'), - ).rejects.toThrow('DNS resolution error'); - }); -}); + return [inputs, [['https://[::1]:45' as const, { Host: 'example2.com' }]]]; +} -describe('#getAddressFromTargetWellKnownEndpoint()', () => { - afterEach(() => { - jest.clearAllMocks(); - }); +/* If the /.well-known request returned an error response, and no SRV records were found, an IP address is resolved using CNAME, AAAA and A records. Requests are made to the resolved IP address using port 8448 and a Host header containing the . The target server must present a valid certificate for . */ +function spec_3_4__1(): [INPUT[], OUTPUT[]] { + const inputs = ['example.com']; - it('should return the address and maxAge from the well-known response', async () => { - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValueOnce({ 'm.server': 'example.com:8448' }), - headers: { - get: jest.fn().mockReturnValue('max-age=3600'), - }, - }; - global.fetch = jest.fn().mockResolvedValueOnce(mockResponse) as any; - - const result = await getAddressFromTargetWellKnownEndpoint('example.com'); - expect(result).toEqual({ address: 'example.com:8448', maxAge: 3600 }); - }); + stubs.resolveHostname.resolves('11.0.0.1'); - it('should return the address with default maxAge if cache-control header is not present', async () => { - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue({ 'm.server': 'example.com:8448' }), - headers: { - get: jest.fn().mockReturnValue(null), - }, - }; - global.fetch = jest.fn().mockResolvedValueOnce(mockResponse) as any; - - const result = await getAddressFromTargetWellKnownEndpoint('example.com'); - expect(result).toEqual({ address: 'example.com:8448', maxAge: 86400 }); + // wellknown no + stubs.fetch.resolves({ + ok: false, }); - it('should throw an error if the well-known response is not ok', async () => { - const mockResponse = { - ok: false, - json: jest.fn(), - headers: { - get: jest.fn(), - }, - }; - global.fetch = jest.fn().mockResolvedValueOnce(mockResponse) as any; - - await expect( - getAddressFromTargetWellKnownEndpoint('example.com'), - ).rejects.toThrow('No address found'); - }); + // srv no + stubs.resolveSrv.resolves([]); - it('should throw an error if json() throws', async () => { - const mockResponse = { - ok: true, - json: jest.fn().mockRejectedValue(new Error('JSON error')), - headers: { - get: jest.fn(), - }, - }; - global.fetch = jest.fn().mockResolvedValueOnce(mockResponse) as any; - - await expect( - getAddressFromTargetWellKnownEndpoint('example.com'), - ).rejects.toThrow('No address found'); - }); + return [ + inputs, + [['https://11.0.0.1:8448' as const, { Host: 'example.com' }]], + ]; +} - it('should throw an error if the well-known response does not contain m.server', async () => { - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue({}), - headers: { - get: jest.fn(), - }, - }; - global.fetch = jest.fn().mockResolvedValueOnce(mockResponse) as any; - - await expect( - getAddressFromTargetWellKnownEndpoint('example.com'), - ).rejects.toThrow('No address found'); - }); +async function runTest(inputs: INPUT[], outputs: OUTPUT[]) { + for (let i = 0; i < inputs.length; i++) { + const input = inputs[i]; + const output = outputs[i]; - it('should limit maxAge to 48 hours if cache-control header exceeds the limit', async () => { - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue({ 'm.server': 'example.com:8448' }), - headers: { - get: jest.fn().mockReturnValue('max-age=200000'), - }, - }; - global.fetch = jest.fn().mockResolvedValueOnce(mockResponse) as any; - - const result = await getAddressFromTargetWellKnownEndpoint('example.com'); - expect(result).toEqual({ address: 'example.com:8448', maxAge: 172800 }); - }); -}); + const [address, headers] = await getHomeserverFinalAddress(input); -describe('#getWellKnownCachedAddress()', () => { - afterEach(() => { - jest.clearAllMocks(); - }); + expect(address).toBe(output[0]); + expect(headers).toEqual(output[1]); + } +} - it('should return the cached address if it is still valid', () => { - const serverName = 'example.com'; - const cachedData = { - address: 'cached.example.com:8448', - maxAge: 3600, - timestamp: Date.now() - 1000, // 1 second ago - }; - wellKnownCache.set(serverName, cachedData); - - const result = getWellKnownCachedAddress(serverName); - expect(result).toBe('cached.example.com:8448'); +describe('_URL', () => { + it('should mention port if specified even if standard port is used, unlike node:url', () => { + const url = new _URL('https://example.com:443'); + expect(url.port).toBe('443'); + expect(url.origin).toBe('https://example.com'); }); - it('should return null if the cached address has expired', () => { - const serverName = 'example.com'; - const cachedData = { - address: 'cached.example.com:8448', - maxAge: 3600, - timestamp: Date.now() - 4000 * 1000, // 4000 seconds ago - }; - wellKnownCache.set(serverName, cachedData); - - const result = getWellKnownCachedAddress(serverName); - expect(result).toBeNull(); + it('should not mention port if not specified, like node:url', () => { + const url = new _URL('https://example.com'); + expect(url.port).toBe(''); + expect(url.origin).toBe('https://example.com'); }); - it('should return null if there is no cached address for the server name', () => { - const serverName = 'nonexistent.com'; - - const result = getWellKnownCachedAddress(serverName); - expect(result).toBeNull(); + it('should parse url without protocol part', () => { + const url = new _URL('example.com'); + expect(url.origin).toBe('https://example.com'); }); }); -describe('#resolveHostAddressByServerName()', () => { - const localHomeServerName = 'rc1'; - const localHomeServerNameWithPort = 'rc1:443'; - - afterEach(() => { - wellKnownCache.clear(); +describe('[Server Discovery 2.1 - resolve final address] https://spec.matrix.org/v1.12/server-server-api/#resolving-server-names', () => { + it('2.1.1 (ipv4)', async () => { + return runTest(...spec_1__1()); }); - - it('should resolve IP literal addresses directly', async () => { - const { address, headers } = await resolveHostAddressByServerName( - '192.168.1.1', - localHomeServerName, - ); - expect(address).toBe('192.168.1.1:8448'); - expect(headers).toEqual({ Host: localHomeServerNameWithPort }); + it('2.1.1 (ipv6)', async () => { + return runTest(...spec_1__2()); }); - - it('should resolve addresses with explicit ports directly', async () => { - mockResolver.resolveAny.mockResolvedValueOnce([ - { - type: 'AAAA', - address: '2001:0db8:85a3:0000:0000:8a2e:0370:7334', - ttl: 300, - }, - ]); - const { address, headers } = await resolveHostAddressByServerName( - 'example.com:8080', - localHomeServerName, - ); - expect(address).toBe('[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:8080'); - expect(headers).toEqual({ Host: localHomeServerNameWithPort }); + it('2.1.2 (ipv4)', async () => { + return runTest(...spec_2__1()); }); - - // TODO: Make it pass again - it.skip('should return cached address if available and valid', async () => { - mockResolver.resolveAny.mockResolvedValueOnce([ - { - type: 'AAAA', - address: '2001:0db8:85a3:0000:0000:8a2e:0370:7334', - ttl: 300, - }, - ]); - const serverName = 'example.com'; - const cachedData = { - address: 'cached.example.com:8448', - maxAge: 3600, - timestamp: Date.now() - 1000, // 1 second ago - }; - wellKnownCache.set(serverName, cachedData); - - const { address, headers } = await resolveHostAddressByServerName( - serverName, - localHomeServerName, - ); - expect(address).toBe('[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:8448'); - expect(headers).toEqual({ Host: 'cached.example.com:8448' }); + it('2.1.2 (ipv6)', async () => { + return runTest(...spec_2__2()); }); - - // TODO: Make it pass again - it.skip('should resolve using well-known address if not cached', async () => { - mockResolver.resolveAny.mockResolvedValueOnce([ - { - type: 'AAAA', - address: '2001:0db8:85a3:0000:0000:8a2e:0370:7334', - ttl: 300, - }, - ]); - const mockResponse = { - ok: true, - json: jest.fn().mockResolvedValue({ 'm.server': 'example.com:8448' }), - headers: { - get: jest.fn().mockReturnValue('max-age=3600'), - }, - }; - global.fetch = jest.fn().mockResolvedValueOnce(mockResponse) as any; - - const { address, headers } = await resolveHostAddressByServerName( - 'example.com', - localHomeServerName, - ); - expect(address).toBe('[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:8448'); - expect(headers).toEqual({ Host: 'example.com:8448' }); + it('3.1.1 (well-known delegation - ip4)', async () => { + return runTest(...spec_3_1__1()); }); - - it('should fallback to SRV records if well-known address is not available', async () => { - global.fetch = jest - .fn() - .mockRejectedValueOnce(new Error('Fetch error')) as any; - mockResolver.resolveSrv = jest.fn().mockResolvedValue([]); - mockResolver.resolveAny.mockResolvedValueOnce([ - { type: 'A', address: '192.168.1.1', ttl: 300 }, - ]); - - const { address, headers } = await resolveHostAddressByServerName( - 'example.com', - localHomeServerName, - ); - expect(address).toBe('192.168.1.1:8448'); - expect(headers).toEqual({ Host: 'example.com' }); + it('3.1.1 (well-known delegation - ip6)', async () => { + return runTest(...spec_3_1__2()); }); - - it('should return the provided address with the default port when the request did not throw but ok is false', async () => { - mockResolver.resolveSrv = jest.fn().mockResolvedValue([]); - mockResolver.resolveAny.mockResolvedValueOnce([]); - - const mockResponse = { - ok: false, - json: jest.fn(), - headers: { - get: jest.fn(), - }, - }; - global.fetch = jest.fn().mockResolvedValueOnce(mockResponse) as any; - - const { address, headers } = await resolveHostAddressByServerName( - 'example.com', - localHomeServerName, - ); - expect(address).toBe('example.com:8448'); - expect(headers).toEqual({ Host: 'example.com' }); + it('3.2.1 (well-known delegation)', async () => { + return runTest(...spec_3_2()); }); - - it('should return the provided address with the default port when the request did not throw but json() threw', async () => { - mockResolver.resolveSrv = jest.fn().mockResolvedValue([]); - mockResolver.resolveAny.mockResolvedValueOnce([]); - - const mockResponse = { - ok: true, - json: jest.fn().mockRejectedValue(new Error('JSON error')), - headers: { - get: jest.fn(), - }, - }; - global.fetch = jest.fn().mockResolvedValueOnce(mockResponse) as any; - - const { address, headers } = await resolveHostAddressByServerName( - 'example.com', - localHomeServerName, - ); - expect(address).toBe('example.com:8448'); - expect(headers).toEqual({ Host: 'example.com' }); + it('3.3.1 (well-known delegation)', async () => { + return runTest(...spec_3_3__1()); }); - - it('should fallback to default port if no SRV, CNAME, AAAA, not A records are found', async () => { - global.fetch = jest - .fn() - .mockRejectedValueOnce(new Error('Fetch error')) as any; - mockResolver.resolveSrv = jest.fn().mockResolvedValue([]); - mockResolver.resolveAny.mockResolvedValueOnce([]); - - const { address, headers } = await resolveHostAddressByServerName( - 'example.com', - localHomeServerName, - ); - expect(address).toBe('example.com:8448'); - expect(headers).toEqual({ Host: 'example.com' }); + it('3.3.2 (well-known delegation)', async () => { + return runTest(...spec_3_3__2()); }); - - it('should handle errors gracefully and return address with default port', async () => { - global.fetch = jest - .fn() - .mockRejectedValueOnce(new Error('Fetch error')) as any; - mockResolver.resolveSrv = jest - .fn() - .mockRejectedValue(new Error('DNS resolution error')); - - const { address, headers } = await resolveHostAddressByServerName( - 'example.com', - localHomeServerName, - ); - expect(address).toBe('example.com:8448'); - expect(headers).toEqual({ Host: 'example.com' }); + it('3.4.1 (well-known delegation - no wellknown, no srv)', async () => { + return runTest(...spec_3_4__1()); }); }); diff --git a/packages/core/src/server-discovery/discovery.ts b/packages/core/src/server-discovery/discovery.ts index 74d110503..6696333d6 100644 --- a/packages/core/src/server-discovery/discovery.ts +++ b/packages/core/src/server-discovery/discovery.ts @@ -1,252 +1,307 @@ -import { Resolver } from 'node:dns/promises'; -import { isIPv6 } from 'node:net'; - -const ipv4Regex = - /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(:\d{1,5})?$/; -const ipv6Regex = /^(\[([a-fA-F0-9:]+)\]|([a-fA-F0-9:]+))(?::(\d{1,5}))?$/; -const DEFAULT_SECURE_PORT = 8448; -const MAX_AGE_HOURS_IN_SECONDS = 86400; //24 hours -const MAX_CACHE_ALLOWED_IN_SECONDS = 172800; //48 hours - -export const isIpLiteral = (ip: string): boolean => { - if (ipv4Regex.test(ip)) { - return true; - } +import { isIPv4, isIPv6 } from 'node:net'; +import { MultiError } from './_multi-error'; +import { resolver } from './_resolver'; +import { _URL } from './_url'; - const ipv6Match = ip.match(ipv6Regex); +// typing below are purely to document and make sure we conform to how we are returning the address +// ge4tting typescript to help me not return wrong stuff - return Boolean(ipv6Match && isIPv6(ipv6Match[2] || ipv6Match[3])); -}; +type PortString = string; -export const addressWithDefaultPort = (address: string): string => { - return `${address}:${DEFAULT_SECURE_PORT}`; -}; +type IP4or6String = string | `[${string}]`; +type AddressString = string; -export const resolveWhenServerNameIsIpAddress = async ( - serverName: string, -): Promise => { - if (ipv6Regex.test(serverName)) { - // Ensure IPv6 addresses are enclosed in square brackets - const ipv6Match = serverName.match(ipv6Regex); - const ipv6Address = ipv6Match?.[2] || ipv6Match?.[3]; - const port = ipv6Match?.[4]; +type AddressWithPortString = `${AddressString}:${PortString | number}`; +type IP4or6WithPortString = `${IP4or6String}:${PortString | number}`; - return port ? serverName : addressWithDefaultPort(`[${ipv6Address}]`); - } +// type AddressWithPortAndProtocolString = `${ +// | 'http' +// | 'https'}://${AddressWithPortString}`; - const url = new URL(`http://${serverName}`); +type IP4or6WithPortAndProtocolString = `${ + | 'http' + | 'https'}://${IP4or6WithPortString}`; - return url.port ? serverName : addressWithDefaultPort(serverName); +type HostHeaders = { + Host: AddressString | AddressWithPortString | IP4or6WithPortString; }; -const formatIpAddressWithOptionalPort = ( - address: string, - port = '', -): string => { - return isIPv6(address) ? `[${address}]${port}` : `${address}${port}`; -}; +const DEFAULT_PORT = '8448'; -export const resolveWhenServerNameIsAddressWithPort = async ( - serverName: string, -): Promise => { - const [hostname, port] = serverName.split(':'); +// should only be needed if input is from a dns server +function fix6(addr: string): `[${string}]` { + return /^\[.+\]$/.test(addr) ? (addr as `[${string}]`) : `[${addr}]`; +} - const resolver = new Resolver(); +export async function resolveHostname( + hostname: string, + resolveCname: boolean, +): Promise { + const errors = new MultiError(); - const addresses = await resolver.resolveAny(hostname); + // in order as in spec + // CNAME, AAAA, A + const promises = []; - if (addresses.length === 0) { - return `${hostname}:${port}`; + if (resolveCname) { + promises.push(resolver.resolveCname(hostname)); } - for (const address of addresses) { - if (address.type === 'CNAME') { - return formatIpAddressWithOptionalPort(address.value, `:${port}`); + promises.push(resolver.resolve4And6(hostname)); + + const results = await Promise.allSettled(promises); + + for (const result of results) { + if (result.status === 'rejected') { + errors.append('', result.reason); + continue; } - if (address.type === 'AAAA' || address.type === 'A') { - return formatIpAddressWithOptionalPort(address.address, `:${port}`); + + const ips = result.value; // array of ips + + if (ips.length > 0) { + return isIPv6(ips[0]) ? fix6(ips[0]) : ips[0]; } } - return addressWithDefaultPort(hostname); -}; + throw errors; +} -export const resolveUsingSRVRecordsOrFallbackToOtherRecords = async ( - serverName: string, -): Promise => { - const resolver = new Resolver(); - const srvRecords = await resolver.resolveSrv( - `_matrix-fed._tcp.${serverName}`, - ); - - if (srvRecords.length > 0) { - for (const srv of srvRecords) { - const addresses = await resolver.resolveAny(srv.name); - for (const address of addresses) { - if (address.type === 'AAAA' || address.type === 'A') { - const ipAddress = isIPv6(address.address) - ? `[${address.address}]` - : address.address; - - return srv.port - ? `${ipAddress}:${srv.port}` - : addressWithDefaultPort(`[${ipAddress}]`); - } - } - } +// SPEC: https://spec.matrix.org/v1.12/server-server-api/#resolving-server-names + +/* + * Server names are resolved to an IP address and port to connect to, and have various conditions affecting which certificates and Host headers to send. + */ - return addressWithDefaultPort(serverName); +export async function getHomeserverFinalAddress( + addr: AddressString, +): Promise<[IP4or6WithPortAndProtocolString, HostHeaders]> { + const url = new _URL(addr); + + const { hostname, port } = url; + + /* + * SPEC: + * 1. If the hostname is an IP literal, then that IP address should be used, together with the given port number, or 8448 if no port is given. The target server must present a valid certificate for the IP address. The Host header in the request should be set to the server name, including the port if the server name included one. + */ + + if (url.isIP()) { + const finalIp = hostname; // should already be wrapped in [] if it is ipv6 + const finalPort = port || DEFAULT_PORT; + // 'Target server must present a valid certificate for the IP address', i.e. always https + const finalAddress = `https://${finalIp}:${finalPort}` as const; + const hostHeader = { + Host: `${hostname}${ + /* only include port if it was included already */ + port ? `:${port}` : '' + }`, + }; + + return [finalAddress, hostHeader]; } - const addresses = await resolver.resolveAny(serverName); - for (const address of addresses) { - if ( - address.type === 'CNAME' || - address.type === 'AAAA' || - address.type === 'A' - ) { - const ipAddress = - address.type === 'CNAME' ? address.value : address.address; - const formattedIpAddress = formatIpAddressWithOptionalPort(ipAddress); - - return addressWithDefaultPort(formattedIpAddress); - } + /* + * SPEC: + * 2. If the hostname is not an IP literal, and the server name includes an explicit port, resolve the hostname to an IP address using CNAME, AAAA or A records. Requests are made to the resolved IP address and given port with a Host header of the original server name (with port). The target server must present a valid certificate for the hostname. + */ + + // includes explicit port + if (port) { + const hostHeaders = { Host: `${hostname}:${port}` as const }; // original serverName and port + + const address = await resolveHostname(hostname, true); // intentional auto-throw + + return [`https://${address}:${port}` as const, hostHeaders]; } - return addressWithDefaultPort(serverName); -}; + /* + * SPEC: + * 3. wellknown delegation + */ -export const getAddressFromTargetWellKnownEndpoint = async ( - serverName: string, -): Promise<{ address: string; maxAge: number }> => { - let response: Response | undefined; - let data: { 'm.server': string } | undefined; try { - response = await fetch(`https://${serverName}/.well-known/matrix/server`); + const [addr, hostHeaders] = await fromWellKnownDelegation(hostname); - if (!response.ok) { - throw new Error(); - } + // found one - + return [addr, hostHeaders]; + } catch (e: unknown) { + // didn't find a suitable result from wellknnown - data = await response.json(); - } catch { - throw new Error('No address found'); - } + try { + const [addr, hostHeaders] = + await fromSRVResolutionWithBasicFallback(hostname); - if (!data?.['m.server']) { - throw new Error('No address found'); - } + return [`https://${addr}` as const, hostHeaders]; + } catch (e2: unknown) { + if (MultiError.isMultiError(e) && MultiError.isMultiError(e2)) { + throw e.concat(e2); + } + + console.log(e, e2); - // Cache control headers - const cacheControl = response.headers.get('cache-control'); - let maxAge = MAX_AGE_HOURS_IN_SECONDS; - - if (cacheControl) { - const match = cacheControl.match(/max-age=(\d+)/); - if (match) { - maxAge = Math.min( - Number.parseInt(match[1], 10), - MAX_CACHE_ALLOWED_IN_SECONDS, - ); + throw new Error(`failed to resolve ${hostname}`); } } +} - const address = data['m.server']; - - return { address, maxAge }; +type WellKnownResponse = { + 'm.server': string; }; -const addressHasExplicitPort = (address: string): boolean => - !isIpLiteral(address) && new URL(`http://${address}`).port !== ''; +// error must be caught and handled by the caller +async function fromWellKnownDelegation( + host: string, +): Promise<[IP4or6WithPortAndProtocolString, HostHeaders]> { + const isWellKnownResponse = ( + response: unknown, + ): response is WellKnownResponse => { + return ( + typeof response === 'object' && + response !== null && + 'm.server' in response && + typeof response['m.server'] === 'string' + ); + }; + + // SPEC: 3. If the hostname is not an IP literal, a regular HTTPS request is made to https:///.well-known/matrix/server, + + const response = await fetch(`https://${host}/.well-known/matrix/server`, { + headers: { + Accept: 'application/json', + }, + // SPEC: 30x redirects should be followed + redirect: 'follow', + }); + + // SPEC: Errors are recommended to be cached for up to an hour, and servers are encouraged to exponentially back off for repeated failures. + // TODO: ^^^ + + // SPEC: If the response is invalid (bad JSON, missing properties, non-200 response, etc), skip to step 4. + // + if (!response.ok) { + const [addr, hostHeaders] = await fromSRVResolutionWithBasicFallback(host); + return [`https://${addr}` as const, hostHeaders]; + } -export const wellKnownCache = new Map< - string, - { address: string; maxAge: number; timestamp: number } ->(); + const data = await response.json(); -export const getWellKnownCachedAddress = ( - serverName: string, -): string | null => { - const cached = wellKnownCache.get(serverName); - if (cached && Date.now() < cached.timestamp + cached.maxAge * 1000) { - return cached.address; + if (!isWellKnownResponse(data)) { + const [addr, hostHeaders] = await fromSRVResolutionWithBasicFallback(host); + return [`https://${addr}` as const, hostHeaders]; + } + + if (!data['m.server']) { + // TODO: should this be like this? + const [addr, hostHeaders] = await fromSRVResolutionWithBasicFallback(host); + return [`https://${addr}` as const, hostHeaders]; } - return null; -}; -// const resolveFollowingWellKnownRules = async ( -// serverName: string, -// ): Promise => { -// try { -// if (isIpLiteral(serverName)) { -// return resolveWhenServerNameIsIpAddress(serverName); -// } - -// if (addressHasExplicitPort(serverName)) { -// return resolveWhenServerNameIsAddressWithPort(serverName); -// } -// } catch { -// return addressWithDefaultPort(serverName); -// } - -// return resolveUsingSRVRecordsOrFallbackToOtherRecords(serverName); -// }; - -const getAddressFromWellKnownData = async ( - serverName: string, -): Promise => { - const cachedAddress = getWellKnownCachedAddress(serverName); - if (cachedAddress) { - return cachedAddress; + const url = new _URL(data['m.server']); + + const { hostname: delegatedHostname, port: delegatedPort } = url; + + // SPEC: 3.1. If is an IP literal, then that IP address should be used together with the or 8448 if no port is provided. The target server must present a valid TLS certificate for the IP address. + + if (url.isIP()) { + // compiler should take care of this redundant reassignment + const delegatedIp = delegatedHostname; + const finalAddress = `https://${delegatedIp}:${ + delegatedPort || DEFAULT_PORT + }` as const; + return [ + finalAddress, + { + /* SPEC: Requests must be made with a Host header containing the IP address, including the port if one was provided. */ + Host: `${delegatedIp}${delegatedPort ? `:${delegatedPort}` : ''}`, + }, + ]; } - const { address, maxAge } = - await getAddressFromTargetWellKnownEndpoint(serverName); - wellKnownCache.set(serverName, { address, maxAge, timestamp: Date.now() }); + // SPEC: 3.2. If is not an IP literal, and is present, an IP address is discovered by looking up CNAME, AAAA or A records for . The resulting IP address is used, alongside the . - return address; -}; + if (delegatedPort) { + const addr = await resolveHostname(delegatedHostname, true); -const defaultOwnServerAddress = (ownServerName: string): string => { - return `${ownServerName}:443`; -}; + return [ + `https://${addr}:${delegatedPort}`, + // SPEC: Requests must be made with a Host header of :. The target server must present a valid certificate for . + { Host: `${delegatedHostname}:${delegatedPort}` }, + ]; + } -export const resolveHostAddressByServerName = async ( - serverName: string, - ownServerName: string, -): Promise<{ address: string; headers: { Host: string } }> => { - try { - if (isIpLiteral(serverName)) { - const address = await resolveWhenServerNameIsIpAddress(serverName); - return { - address, - headers: { Host: defaultOwnServerAddress(ownServerName) }, - }; - } + // SPEC: 3.3. If is not an IP literal and no is present, an SRV record is looked up for _matrix-fed._tcp.. This may result in another hostname (to be resolved using AAAA or A records) and port. Requests should be made to the resolved IP address and port with a Host header containing the . The target server must present a valid certificate for . + const [addr, hostHeaders] = + await fromSRVResolutionWithBasicFallback(delegatedHostname); + return [`https://${addr}` as const, hostHeaders]; +} + +// SPEC: If the /.well-known request resulted in an error response, a server is found by resolving an SRV record for _matrix-fed._tcp.. This may result in a hostname (to be resolved using AAAA or A records) and port. Requests are made to the resolved IP address and port, with a Host header of . The target server must present a valid certificate for . +async function fromSRVDelegation( + hostname: string, +): Promise<[IP4or6WithPortString, HostHeaders]> { + const _do = async ( + name: string, + ): Promise> | undefined> => { + const srvs = await resolver.resolveSrv(name); + + for (const srv of srvs) { + const _is4 = isIPv4(srv.name); + const _is6 = isIPv6(srv.name); + + if (_is4 || _is6) { + // use as is + const finalAddress = `${_is6 ? fix6(srv.name) : srv.name}:${ + srv.port + }` as const; + + return [finalAddress, { Host: hostname }]; + } - if (addressHasExplicitPort(serverName)) { - const address = await resolveWhenServerNameIsAddressWithPort(serverName); - return { - address, - headers: { Host: defaultOwnServerAddress(ownServerName) }, - }; + try { + const _addr = await resolveHostname(srv.name, false); + const addr = isIPv6(_addr) ? fix6(_addr) : _addr; + return [`${addr}:${srv.port}` as const, { Host: hostname }]; + } catch (_e) { + // noop + } } + }; + + const result = await _do(`_matrix-fed._tcp.${hostname}`); + if (result) { + return result; + } - const rawAddress = await getAddressFromWellKnownData(serverName); - // const address = await resolveFollowingWellKnownRules(rawAddress); + // SPEC: If is not an IP literal, no is present, and a _matrix-fed._tcp. SRV record was not found, an SRV record is looked up for _matrix._tcp.. This may result in another hostname (to be resolved using AAAA or A records) and port. Requests should be made to the resolved IP address and port with a Host header containing the . The target server must present a valid certificate for . + // ^^^ IS DEPRECATED, but implementing anyway for now - // TODO: Check it later... only way I found to make the request work - return { address: rawAddress, headers: { Host: rawAddress } }; - } catch (error) { - if (error instanceof Error && error.message === 'No address found') { - const address = await resolveUsingSRVRecordsOrFallbackToOtherRecords( - serverName, - ).catch(() => addressWithDefaultPort(serverName)); + const result2 = await _do(`_matrix._tcp.${hostname}`); + if (result2) { + return result2; + } - return { address, headers: { Host: serverName } }; - } - const address = await addressWithDefaultPort(serverName); + throw new Error(`no srv address found for ${hostname}`); +} - return { address, headers: { Host: address } }; +async function fromSRVResolutionWithBasicFallback( + hostname: AddressString, +): Promise<[IP4or6WithPortString, HostHeaders]> { + // SPEC: 6. If the /.well-known request returned an error response, and no SRV records were found, an IP address is resolved using CNAME, AAAA and A records. Requests are made to the resolved IP address using port 8448 and a Host header containing the . The target server must present a valid certificate for . + try { + return await fromSRVDelegation(hostname); + } catch (e: unknown) { + try { + const resolved = await resolveHostname(hostname, true); + + return [`${resolved}:${DEFAULT_PORT}` as const, { Host: hostname }]; + } catch (e2: unknown) { + if (MultiError.isMultiError(e) && MultiError.isMultiError(e2)) { + throw e.concat(e2); + } + + console.log(e, e2); + + throw new Error(`failed to resolve ${hostname}`); + } } -}; +} diff --git a/packages/core/src/utils/makeRequest.ts b/packages/core/src/utils/makeRequest.ts index ba505722d..40bd96a44 100644 --- a/packages/core/src/utils/makeRequest.ts +++ b/packages/core/src/utils/makeRequest.ts @@ -1,4 +1,4 @@ -import { resolveHostAddressByServerName } from '../server-discovery/discovery'; +import { getHomeserverFinalAddress } from '../server-discovery/discovery'; import type { SigningKey } from '../types'; import { extractURIfromURL } from '../url'; import { authorizationHeaders, computeAndMergeHash } from './authentication'; @@ -24,11 +24,8 @@ export const makeSignedRequest = async >({ signingName: string; queryString?: string; }): Promise => { - const { address, headers } = await resolveHostAddressByServerName( - domain, - signingName, - ); - const url = new URL(`https://${address}${uri}`); + const [address, headers] = await getHomeserverFinalAddress(domain); + const url = new URL(`${address}${uri}`); if (queryString) { url.search = queryString; } @@ -80,7 +77,6 @@ export const makeRequest = async >({ domain, uri, body, - signingName, options = {}, queryString, }: { @@ -92,11 +88,8 @@ export const makeRequest = async >({ options?: Record; queryString?: string; }): Promise => { - const { address, headers } = await resolveHostAddressByServerName( - domain, - signingName, - ); - const url = new URL(`https://${address}${uri}`); + const [address, headers] = await getHomeserverFinalAddress(domain); + const url = new URL(`${address}${uri}`); if (queryString) { url.search = queryString; } @@ -144,11 +137,8 @@ export const makeUnsignedRequest = async >({ body, ); - const { address, headers } = await resolveHostAddressByServerName( - domain, - signingName, - ); - const url = new URL(`https://${address}${uri}`); + const [address, headers] = await getHomeserverFinalAddress(domain); + const url = new URL(`${address}${uri}`); if (queryString) { url.search = queryString; } diff --git a/packages/federation-sdk/src/server-discovery/discovery.spec.ts b/packages/federation-sdk/src/server-discovery/discovery.spec.ts deleted file mode 100644 index 80d09cb29..000000000 --- a/packages/federation-sdk/src/server-discovery/discovery.spec.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { describe, expect, it, mock } from 'bun:test'; -import sinon from 'sinon'; - -const stubs = { - fetch: sinon.stub(), - - resolveHostname: sinon.stub(), - resolveSrv: sinon.stub(), -} as const; - -await mock.module('./discovery', () => ({ - resolveHostname: stubs.resolveHostname, -})); - -await mock.module('./_resolver', () => ({ - resolver: { - resolveSrv: stubs.resolveSrv, - }, -})); - -import { _URL } from './_url'; -import { getHomeserverFinalAddress } from './discovery'; - -const mockFetch = stubs.fetch as unknown as typeof fetch; -// const originalFetch = globalThis.fetch; -globalThis.fetch = mockFetch; - -// each function describes a stage of the spec to test spec conformity -// function returns the set of inputs to test with. -// each step should behave the same way so the modifications to the stub returns should not change. -// - -type INPUT = string; -type OUTPUT = [`https://${string}:${string | number}`, { Host: string }]; - -/* - * 1. If the hostname is an IP literal, then that IP address should be used, together with the given port number, or 8448 if no port is given. The target server must present a valid certificate for the IP address. The Host header in the request should be set to the server name, including the port if the server name included one. - */ - -function spec_1__1(): [INPUT[], OUTPUT[]] { - return [ - ['11.0.0.1', '11.0.0.1:45'], - [ - ['https://11.0.0.1:8448' as const, { Host: '11.0.0.1' }], - ['https://11.0.0.1:45' as const, { Host: '11.0.0.1:45' }], - ], - ]; -} - -function spec_1__2(): [INPUT[], OUTPUT[]] { - return [ - ['[::1]', '[::1]:45'], - [ - ['https://[::1]:8448' as const, { Host: '[::1]' }], - ['https://[::1]:45' as const, { Host: '[::1]:45' }], - ], - ]; -} - -/* - * SPEC: - * 2. If the hostname is not an IP literal, and the server name includes an explicit port, resolve the hostname to an IP address using CNAME, AAAA or A records. Requests are made to the resolved IP address and given port with a Host header of the original server name (with port). The target server must present a valid certificate for the hostname. - */ - -function spec_2__1(): [INPUT[], OUTPUT[]] { - stubs.resolveHostname.resolves('11.0.0.1'); - return [ - ['example.com:45'], - [['https://11.0.0.1:45' as const, { Host: 'example.com:45' }]], - ]; -} - -function spec_2__2(): [INPUT[], OUTPUT[]] { - stubs.resolveHostname.resolves('[::1]'); - return [ - ['example.com:45'], - [['https://[::1]:45' as const, { Host: 'example.com:45' }]], - ]; -} - -// wellknown -// If is an IP literal, then that IP address should be used together with the or 8448 if no port is provided. The target server must present a valid TLS certificate for the IP address. Requests must be made with a Host header containing the IP address, including the port if one was provided. -function spec_3_1__1(): [INPUT[], OUTPUT[]] { - // If the hostname is not an IP literal and no port is provided - const inputs = ['example.com']; - - stubs.resolveHostname.resolves('11.0.0.1'); - - // Mock the .well-known response - stubs.fetch.resolves({ - ok: true, - json: () => Promise.resolve({ 'm.server': '11.0.0.1:45' }), - headers: new Headers({ - 'cache-control': 'max-age=3600', - }), - }); - - return [inputs, [['https://11.0.0.1:45' as const, { Host: '11.0.0.1:45' }]]]; -} - -function spec_3_1__2(): [INPUT[], OUTPUT[]] { - const inputs = ['example.com']; - - stubs.resolveHostname.resolves('[::1]'); - - stubs.fetch.resolves({ - ok: true, - json: () => Promise.resolve({ 'm.server': '[::1]:45' }), - }); - - return [inputs, [['https://[::1]:45' as const, { Host: '[::1]:45' }]]]; -} - -/* 3.2. If is not an IP literal, and is present, an IP address is discovered by looking up CNAME, AAAA or A records for . The resulting IP address is used, alongside the . Requests must be made with a Host header of :. The target server must present a valid certificate for . - */ -function spec_3_2(): [INPUT[], OUTPUT[]] { - const inputs = ['example.com']; - - stubs.resolveHostname.reset(); - - // for some reason onFirstCall and onSecondCall is not working - stubs.resolveHostname.callsFake((hostname: string) => { - if (hostname === 'example.com') { - return Promise.resolve('11.0.0.1'); - } - - if (hostname === 'example2.com') { - return Promise.resolve('[::1]'); - } - }); - - stubs.fetch.resolves({ - ok: true, - json: () => Promise.resolve({ 'm.server': 'example2.com:45' }), // delegatedPort is present - }); - - return [inputs, [['https://[::1]:45' as const, { Host: 'example2.com:45' }]]]; -} - -/* If is not an IP literal and no is present, an SRV record is looked up for _matrix-fed._tcp.. This may result in another hostname (to be resolved using AAAA or A records) and port. Requests should be made to the resolved IP address and port with a Host header containing the . The target server must present a valid certificate for .*/ -function spec_3_3__1(): [INPUT[], OUTPUT[]] { - const inputs = ['example.com']; - - stubs.resolveHostname.resolves('11.0.0.1'); - - stubs.fetch.resolves({ - ok: true, - json: () => Promise.resolve({ 'm.server': 'example2.com' }), // no delegatedPort is present, delegatedHostname is present and not ip - }); - - stubs.resolveSrv.resolves([{ name: '::1', port: 45 }]); - - return [inputs, [['https://[::1]:45' as const, { Host: 'example2.com' }]]]; -} - -function spec_3_3__2(): [INPUT[], OUTPUT[]] { - const inputs = ['example.com']; - - stubs.resolveHostname.callsFake((name) => { - if (name === 'exmaple.com') return '11.0.0.1'; - - if (name === 'example3.com') return '[::1]'; - }); - - stubs.fetch.resolves({ - ok: true, - json: () => Promise.resolve({ 'm.server': 'example2.com' }), // no delegatedPort is present, delegatedHostname is present and not ip - }); - - stubs.resolveSrv.resolves([{ name: 'example3.com', port: 45 }]); // another hostname - // now should do another resolveHostname - - return [inputs, [['https://[::1]:45' as const, { Host: 'example2.com' }]]]; -} - -/* If the /.well-known request returned an error response, and no SRV records were found, an IP address is resolved using CNAME, AAAA and A records. Requests are made to the resolved IP address using port 8448 and a Host header containing the . The target server must present a valid certificate for . */ -function spec_3_4__1(): [INPUT[], OUTPUT[]] { - const inputs = ['example.com']; - - stubs.resolveHostname.resolves('11.0.0.1'); - - // wellknown no - stubs.fetch.resolves({ - ok: false, - }); - - // srv no - stubs.resolveSrv.resolves([]); - - return [ - inputs, - [['https://11.0.0.1:8448' as const, { Host: 'example.com' }]], - ]; -} - -async function runTest(inputs: INPUT[], outputs: OUTPUT[]) { - for (let i = 0; i < inputs.length; i++) { - const input = inputs[i]; - const output = outputs[i]; - - const [address, headers] = await getHomeserverFinalAddress(input); - - expect(address).toBe(output[0]); - expect(headers).toEqual(output[1]); - } -} - -describe('_URL', () => { - it('should mention port if specified even if standard port is used, unlike node:url', () => { - const url = new _URL('https://example.com:443'); - expect(url.port).toBe('443'); - expect(url.origin).toBe('https://example.com'); - }); - - it('should not mention port if not specified, like node:url', () => { - const url = new _URL('https://example.com'); - expect(url.port).toBe(''); - expect(url.origin).toBe('https://example.com'); - }); - - it('should parse url without protocol part', () => { - const url = new _URL('example.com'); - expect(url.origin).toBe('https://example.com'); - }); -}); - -describe('[Server Discovery 2.1 - resolve final address] https://spec.matrix.org/v1.12/server-server-api/#resolving-server-names', () => { - it('2.1.1 (ipv4)', async () => { - return runTest(...spec_1__1()); - }); - it('2.1.1 (ipv6)', async () => { - return runTest(...spec_1__2()); - }); - it('2.1.2 (ipv4)', async () => { - return runTest(...spec_2__1()); - }); - it('2.1.2 (ipv6)', async () => { - return runTest(...spec_2__2()); - }); - it('3.1.1 (well-known delegation - ip4)', async () => { - return runTest(...spec_3_1__1()); - }); - it('3.1.1 (well-known delegation - ip6)', async () => { - return runTest(...spec_3_1__2()); - }); - it('3.2.1 (well-known delegation)', async () => { - return runTest(...spec_3_2()); - }); - it('3.3.1 (well-known delegation)', async () => { - return runTest(...spec_3_3__1()); - }); - it('3.3.2 (well-known delegation)', async () => { - return runTest(...spec_3_3__2()); - }); - it('3.4.1 (well-known delegation - no wellknown, no srv)', async () => { - return runTest(...spec_3_4__1()); - }); -}); diff --git a/packages/federation-sdk/src/server-discovery/discovery.ts b/packages/federation-sdk/src/server-discovery/discovery.ts deleted file mode 100644 index 6696333d6..000000000 --- a/packages/federation-sdk/src/server-discovery/discovery.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { isIPv4, isIPv6 } from 'node:net'; -import { MultiError } from './_multi-error'; -import { resolver } from './_resolver'; -import { _URL } from './_url'; - -// typing below are purely to document and make sure we conform to how we are returning the address -// ge4tting typescript to help me not return wrong stuff - -type PortString = string; - -type IP4or6String = string | `[${string}]`; -type AddressString = string; - -type AddressWithPortString = `${AddressString}:${PortString | number}`; -type IP4or6WithPortString = `${IP4or6String}:${PortString | number}`; - -// type AddressWithPortAndProtocolString = `${ -// | 'http' -// | 'https'}://${AddressWithPortString}`; - -type IP4or6WithPortAndProtocolString = `${ - | 'http' - | 'https'}://${IP4or6WithPortString}`; - -type HostHeaders = { - Host: AddressString | AddressWithPortString | IP4or6WithPortString; -}; - -const DEFAULT_PORT = '8448'; - -// should only be needed if input is from a dns server -function fix6(addr: string): `[${string}]` { - return /^\[.+\]$/.test(addr) ? (addr as `[${string}]`) : `[${addr}]`; -} - -export async function resolveHostname( - hostname: string, - resolveCname: boolean, -): Promise { - const errors = new MultiError(); - - // in order as in spec - // CNAME, AAAA, A - const promises = []; - - if (resolveCname) { - promises.push(resolver.resolveCname(hostname)); - } - - promises.push(resolver.resolve4And6(hostname)); - - const results = await Promise.allSettled(promises); - - for (const result of results) { - if (result.status === 'rejected') { - errors.append('', result.reason); - continue; - } - - const ips = result.value; // array of ips - - if (ips.length > 0) { - return isIPv6(ips[0]) ? fix6(ips[0]) : ips[0]; - } - } - - throw errors; -} - -// SPEC: https://spec.matrix.org/v1.12/server-server-api/#resolving-server-names - -/* - * Server names are resolved to an IP address and port to connect to, and have various conditions affecting which certificates and Host headers to send. - */ - -export async function getHomeserverFinalAddress( - addr: AddressString, -): Promise<[IP4or6WithPortAndProtocolString, HostHeaders]> { - const url = new _URL(addr); - - const { hostname, port } = url; - - /* - * SPEC: - * 1. If the hostname is an IP literal, then that IP address should be used, together with the given port number, or 8448 if no port is given. The target server must present a valid certificate for the IP address. The Host header in the request should be set to the server name, including the port if the server name included one. - */ - - if (url.isIP()) { - const finalIp = hostname; // should already be wrapped in [] if it is ipv6 - const finalPort = port || DEFAULT_PORT; - // 'Target server must present a valid certificate for the IP address', i.e. always https - const finalAddress = `https://${finalIp}:${finalPort}` as const; - const hostHeader = { - Host: `${hostname}${ - /* only include port if it was included already */ - port ? `:${port}` : '' - }`, - }; - - return [finalAddress, hostHeader]; - } - - /* - * SPEC: - * 2. If the hostname is not an IP literal, and the server name includes an explicit port, resolve the hostname to an IP address using CNAME, AAAA or A records. Requests are made to the resolved IP address and given port with a Host header of the original server name (with port). The target server must present a valid certificate for the hostname. - */ - - // includes explicit port - if (port) { - const hostHeaders = { Host: `${hostname}:${port}` as const }; // original serverName and port - - const address = await resolveHostname(hostname, true); // intentional auto-throw - - return [`https://${address}:${port}` as const, hostHeaders]; - } - - /* - * SPEC: - * 3. wellknown delegation - */ - - try { - const [addr, hostHeaders] = await fromWellKnownDelegation(hostname); - - // found one - - return [addr, hostHeaders]; - } catch (e: unknown) { - // didn't find a suitable result from wellknnown - - try { - const [addr, hostHeaders] = - await fromSRVResolutionWithBasicFallback(hostname); - - return [`https://${addr}` as const, hostHeaders]; - } catch (e2: unknown) { - if (MultiError.isMultiError(e) && MultiError.isMultiError(e2)) { - throw e.concat(e2); - } - - console.log(e, e2); - - throw new Error(`failed to resolve ${hostname}`); - } - } -} - -type WellKnownResponse = { - 'm.server': string; -}; - -// error must be caught and handled by the caller -async function fromWellKnownDelegation( - host: string, -): Promise<[IP4or6WithPortAndProtocolString, HostHeaders]> { - const isWellKnownResponse = ( - response: unknown, - ): response is WellKnownResponse => { - return ( - typeof response === 'object' && - response !== null && - 'm.server' in response && - typeof response['m.server'] === 'string' - ); - }; - - // SPEC: 3. If the hostname is not an IP literal, a regular HTTPS request is made to https:///.well-known/matrix/server, - - const response = await fetch(`https://${host}/.well-known/matrix/server`, { - headers: { - Accept: 'application/json', - }, - // SPEC: 30x redirects should be followed - redirect: 'follow', - }); - - // SPEC: Errors are recommended to be cached for up to an hour, and servers are encouraged to exponentially back off for repeated failures. - // TODO: ^^^ - - // SPEC: If the response is invalid (bad JSON, missing properties, non-200 response, etc), skip to step 4. - // - if (!response.ok) { - const [addr, hostHeaders] = await fromSRVResolutionWithBasicFallback(host); - return [`https://${addr}` as const, hostHeaders]; - } - - const data = await response.json(); - - if (!isWellKnownResponse(data)) { - const [addr, hostHeaders] = await fromSRVResolutionWithBasicFallback(host); - return [`https://${addr}` as const, hostHeaders]; - } - - if (!data['m.server']) { - // TODO: should this be like this? - const [addr, hostHeaders] = await fromSRVResolutionWithBasicFallback(host); - return [`https://${addr}` as const, hostHeaders]; - } - - const url = new _URL(data['m.server']); - - const { hostname: delegatedHostname, port: delegatedPort } = url; - - // SPEC: 3.1. If is an IP literal, then that IP address should be used together with the or 8448 if no port is provided. The target server must present a valid TLS certificate for the IP address. - - if (url.isIP()) { - // compiler should take care of this redundant reassignment - const delegatedIp = delegatedHostname; - const finalAddress = `https://${delegatedIp}:${ - delegatedPort || DEFAULT_PORT - }` as const; - return [ - finalAddress, - { - /* SPEC: Requests must be made with a Host header containing the IP address, including the port if one was provided. */ - Host: `${delegatedIp}${delegatedPort ? `:${delegatedPort}` : ''}`, - }, - ]; - } - - // SPEC: 3.2. If is not an IP literal, and is present, an IP address is discovered by looking up CNAME, AAAA or A records for . The resulting IP address is used, alongside the . - - if (delegatedPort) { - const addr = await resolveHostname(delegatedHostname, true); - - return [ - `https://${addr}:${delegatedPort}`, - // SPEC: Requests must be made with a Host header of :. The target server must present a valid certificate for . - { Host: `${delegatedHostname}:${delegatedPort}` }, - ]; - } - - // SPEC: 3.3. If is not an IP literal and no is present, an SRV record is looked up for _matrix-fed._tcp.. This may result in another hostname (to be resolved using AAAA or A records) and port. Requests should be made to the resolved IP address and port with a Host header containing the . The target server must present a valid certificate for . - const [addr, hostHeaders] = - await fromSRVResolutionWithBasicFallback(delegatedHostname); - return [`https://${addr}` as const, hostHeaders]; -} - -// SPEC: If the /.well-known request resulted in an error response, a server is found by resolving an SRV record for _matrix-fed._tcp.. This may result in a hostname (to be resolved using AAAA or A records) and port. Requests are made to the resolved IP address and port, with a Host header of . The target server must present a valid certificate for . -async function fromSRVDelegation( - hostname: string, -): Promise<[IP4or6WithPortString, HostHeaders]> { - const _do = async ( - name: string, - ): Promise> | undefined> => { - const srvs = await resolver.resolveSrv(name); - - for (const srv of srvs) { - const _is4 = isIPv4(srv.name); - const _is6 = isIPv6(srv.name); - - if (_is4 || _is6) { - // use as is - const finalAddress = `${_is6 ? fix6(srv.name) : srv.name}:${ - srv.port - }` as const; - - return [finalAddress, { Host: hostname }]; - } - - try { - const _addr = await resolveHostname(srv.name, false); - const addr = isIPv6(_addr) ? fix6(_addr) : _addr; - return [`${addr}:${srv.port}` as const, { Host: hostname }]; - } catch (_e) { - // noop - } - } - }; - - const result = await _do(`_matrix-fed._tcp.${hostname}`); - if (result) { - return result; - } - - // SPEC: If is not an IP literal, no is present, and a _matrix-fed._tcp. SRV record was not found, an SRV record is looked up for _matrix._tcp.. This may result in another hostname (to be resolved using AAAA or A records) and port. Requests should be made to the resolved IP address and port with a Host header containing the . The target server must present a valid certificate for . - // ^^^ IS DEPRECATED, but implementing anyway for now - - const result2 = await _do(`_matrix._tcp.${hostname}`); - if (result2) { - return result2; - } - - throw new Error(`no srv address found for ${hostname}`); -} - -async function fromSRVResolutionWithBasicFallback( - hostname: AddressString, -): Promise<[IP4or6WithPortString, HostHeaders]> { - // SPEC: 6. If the /.well-known request returned an error response, and no SRV records were found, an IP address is resolved using CNAME, AAAA and A records. Requests are made to the resolved IP address using port 8448 and a Host header containing the . The target server must present a valid certificate for . - try { - return await fromSRVDelegation(hostname); - } catch (e: unknown) { - try { - const resolved = await resolveHostname(hostname, true); - - return [`${resolved}:${DEFAULT_PORT}` as const, { Host: hostname }]; - } catch (e2: unknown) { - if (MultiError.isMultiError(e) && MultiError.isMultiError(e2)) { - throw e.concat(e2); - } - - console.log(e, e2); - - throw new Error(`failed to resolve ${hostname}`); - } - } -} diff --git a/packages/federation-sdk/src/services/federation-request.service.spec.ts b/packages/federation-sdk/src/services/federation-request.service.spec.ts index 0592b02a8..8a72927c3 100644 --- a/packages/federation-sdk/src/services/federation-request.service.spec.ts +++ b/packages/federation-sdk/src/services/federation-request.service.spec.ts @@ -33,13 +33,11 @@ describe('FederationRequestService', async () => { }, ]; - const { getHomeserverFinalAddress } = await import( - '../server-discovery/discovery' - ); + const { getHomeserverFinalAddress } = await import('@hs/core'); const { fetch: originalFetch } = await import('@hs/core'); - await mock.module('../server-discovery/discovery', () => ({ + await mock.module('@hs/core', () => ({ getHomeserverFinalAddress: () => mockDiscoveryResult, })); @@ -56,7 +54,7 @@ describe('FederationRequestService', async () => { afterAll(() => { mock.restore(); - mock.module('../server-discovery/discovery', () => ({ + mock.module('@hs/core', () => ({ getHomeserverFinalAddress, })); mock.module('@hs/core', () => ({ diff --git a/packages/federation-sdk/src/services/federation-request.service.ts b/packages/federation-sdk/src/services/federation-request.service.ts index 87fe25468..b9e1936db 100644 --- a/packages/federation-sdk/src/services/federation-request.service.ts +++ b/packages/federation-sdk/src/services/federation-request.service.ts @@ -6,7 +6,7 @@ import { signJson } from '@hs/core'; import { createLogger } from '@hs/core'; import { singleton } from 'tsyringe'; import * as nacl from 'tweetnacl'; -import { getHomeserverFinalAddress } from '../server-discovery/discovery'; +import { getHomeserverFinalAddress } from '@hs/core'; import { FederationConfigService } from './federation-config.service'; import { fetch } from '@hs/core'; diff --git a/packages/federation-sdk/tsconfig.json b/packages/federation-sdk/tsconfig.json index 91ec6e497..b3bdc837c 100644 --- a/packages/federation-sdk/tsconfig.json +++ b/packages/federation-sdk/tsconfig.json @@ -8,6 +8,13 @@ "verbatimModuleSyntax": false, "tsBuildInfoFile": "./.tsbuildinfo" }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] -} + "include": [ + "src/**/*", + ], + "exclude": [ + "node_modules", + "dist", + "**/*.spec.ts", + "**/*.test.ts" + ] +} \ No newline at end of file