Skip to content

Commit

Permalink
fix: Add support for routing parcels to other Internet gateways (#217)
Browse files Browse the repository at this point in the history
  • Loading branch information
gnarea authored Aug 2, 2022
1 parent 57d2744 commit 4387a92
Show file tree
Hide file tree
Showing 5 changed files with 111 additions and 75 deletions.
24 changes: 12 additions & 12 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 6 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@
"publishConfig": {
"access": "public"
},
"dependencies": {
"@relaycorp/relaynet-core": "^1.81.10",
"axios": "^0.27.2",
"env-var": "^7.1.1"
},
"devDependencies": {
"@relaycorp/shared-config": "^1.9.1",
"@types/asn1js": "^3.0.7",
Expand All @@ -65,12 +70,7 @@
"typedoc": "^0.23.10",
"typescript": "^4.7.4"
},
"dependencies": {
"@relaycorp/relaynet-core": ">= 1.42.1 < 2",
"axios": "^0.27.2",
"env-var": "^7.1.1"
},
"peerDependencies": {
"@relaycorp/relaynet-core": ">= 1.42.1 < 2"
"@relaycorp/relaynet-core": "< 2"
}
}
2 changes: 1 addition & 1 deletion src/integration_tests/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ import { deliverParcel } from '..';

test('Real public gateway should refuse malformed parcel', async () => {
await expect(
deliverParcel('https://frankfurt.relaycorp.cloud', Buffer.from('hey')),
deliverParcel('frankfurt.relaycorp.cloud', Buffer.from('hey')),
).rejects.toMatchObject(expect.objectContaining({ message: expect.stringMatching('HTTP 400') }));
});
103 changes: 65 additions & 38 deletions src/lib/client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import PoHTTPInvalidParcelError from './PoHTTPInvalidParcelError';

jest.mock('@relaycorp/relaynet-core', () => {
const realRelaynet = jest.requireActual('@relaycorp/relaynet-core');
return { ...realRelaynet, resolvePublicAddress: jest.fn() };
return { ...realRelaynet, resolveInternetAddress: jest.fn() };
});

describe('deliverParcel', () => {
Expand All @@ -29,8 +29,8 @@ describe('deliverParcel', () => {
});

beforeEach(() => {
getMockInstance(relaynet.resolvePublicAddress).mockReset();
getMockInstance(relaynet.resolvePublicAddress).mockResolvedValue({
getMockInstance(relaynet.resolveInternetAddress).mockReset();
getMockInstance(relaynet.resolveInternetAddress).mockResolvedValue({
host: targetHost,
port: targetPort,
});
Expand All @@ -40,24 +40,35 @@ describe('deliverParcel', () => {
jest.restoreAllMocks();
});

test('Target URL should be resolved', async () => {
test('Recipient should be used as is if it is a URL already', async () => {
await deliverParcel(url, body);

expect(stubAxiosPost).toBeCalledWith(url, expect.anything(), expect.anything());
});

test('Recipient should be resolved if it is an Awala Internet address', async () => {
await deliverParcel(host, body);

expect(stubAxiosPost).toBeCalledWith(
`https://${targetHost}:${targetPort}`,
expect.anything(),
expect.anything(),
);

expect(relaynet.resolvePublicAddress).toBeCalledWith(host, relaynet.BindingType.PDC);
expect(relaynet.resolveInternetAddress).toBeCalledWith(host, relaynet.BindingType.PDC);
});

test('Target URL should be used as is if public address record does not exist', async () => {
getMockInstance(relaynet.resolvePublicAddress).mockResolvedValue(null);
test('Recipient should be used as is if resolution returned nothing', async () => {
getMockInstance(relaynet.resolveInternetAddress).mockResolvedValue(null);
const ipAddress = '192.88.99.1';

await deliverParcel(url, body);
await deliverParcel(ipAddress, body);

expect(stubAxiosPost).toBeCalledWith(url, expect.anything(), expect.anything());
expect(stubAxiosPost).toBeCalledWith(
`https://${ipAddress}`,
expect.anything(),
expect.anything(),
);
});

test('Parcel should be request body', async () => {
Expand All @@ -68,10 +79,10 @@ describe('deliverParcel', () => {

test('Public address resolution errors should be wrapped', async () => {
const error = new Error('DNSSEC failed');
getMockInstance(relaynet.resolvePublicAddress).mockRejectedValue(error);
getMockInstance(relaynet.resolveInternetAddress).mockRejectedValue(error);

await expectPromiseToReject(
deliverParcel(url, body),
deliverParcel(host, body),
new PoHTTPError(`Public address resolution failed: ${error.message}`),
);

Expand All @@ -91,25 +102,6 @@ describe('deliverParcel', () => {
);
});

describe('Relay address', () => {
test('Relay address should be included if specified', async () => {
const relayAddress = 'relay-address';
await deliverParcel(url, body, { gatewayAddress: relayAddress });

expect(stubAxiosPost).toBeCalledTimes(1);
const postCallArgs = getMockContext(stubAxiosPost).calls[0];
expect(postCallArgs[2]).toHaveProperty('headers.X-Awala-Gateway', relayAddress);
});

test('Relay address should be absent by default', async () => {
await deliverParcel(url, body);

expect(stubAxiosPost).toBeCalledTimes(1);
const postCallArgs = getMockContext(stubAxiosPost).calls[0];
expect(postCallArgs[2]).not.toHaveProperty('headers.X-Awala-Gateway');
});
});

test('Axios response should be returned', async () => {
const response = await deliverParcel(url, body);

Expand All @@ -128,32 +120,68 @@ describe('deliverParcel', () => {
expect(agent).toHaveProperty('keepAlive', true);
});

describe('TLS enablement option', () => {
test('URL resolution should use HTTPS if option is unspecified', async () => {
await deliverParcel(host, body);

expect(stubAxiosPost).toBeCalledWith(
expect.stringMatching(/^https:/),
expect.anything(),
expect.anything(),
);
});

test('URL resolution should use HTTPS if option is enabled', async () => {
await deliverParcel(host, body, { enableTls: true });

expect(stubAxiosPost).toBeCalledWith(
expect.stringMatching(/^https:/),
expect.anything(),
expect.anything(),
);
});

test('URL resolution should use HTTP if option is disabled', async () => {
mockEnvVars({ POHTTP_TLS_REQUIRED: 'false' });

await deliverParcel(host, body, { enableTls: false });

expect(stubAxiosPost).toBeCalledWith(
expect.stringMatching(/^http:/),
expect.anything(),
expect.anything(),
);
});
});

describe('POHTTP_TLS_REQUIRED', () => {
const nonTlsUrl = 'http://example.com';

test('Non-TLS URLs should be refused if POHTTP_TLS_REQUIRED is undefined', async () => {
mockEnvVars({});

await expectPromiseToReject(
deliverParcel('http://example.com', body),
new Error(`Can only POST to HTTPS URLs (got http://${targetHost}:${targetPort})`),
deliverParcel(nonTlsUrl, body),
new Error(`Can only POST to HTTPS URLs (got ${nonTlsUrl})`),
);
});

test('Non-TLS URLs should be allowed if POHTTP_TLS_REQUIRED=false', async () => {
mockEnvVars({ POHTTP_TLS_REQUIRED: 'false' });

await deliverParcel('http://example.com', body);
await deliverParcel(nonTlsUrl, body);

expect(stubAxiosPost).toBeCalledTimes(1);
const postCallArgs = getMockContext(stubAxiosPost).calls[0];
expect(postCallArgs[0]).toEqual(`http://${targetHost}:${targetPort}`);
expect(postCallArgs[0]).toEqual(nonTlsUrl);
});

test('Non-TLS URLs should be refused if POHTTP_TLS_REQUIRED=true', async () => {
mockEnvVars({ POHTTP_TLS_REQUIRED: 'true' });

await expectPromiseToReject(
deliverParcel('http://example.com', body),
new Error(`Can only POST to HTTPS URLs (got http://${targetHost}:${targetPort})`),
deliverParcel(nonTlsUrl, body),
new Error(`Can only POST to HTTPS URLs (got ${nonTlsUrl})`),
);
});
});
Expand Down Expand Up @@ -227,14 +255,13 @@ describe('deliverParcel', () => {
stubAxiosPost.mockRejectedValueOnce({ response: stubRedirectResponse });
stubAxiosPost.mockResolvedValueOnce({ status: 202 });

const options: Partial<DeliveryOptions> = { gatewayAddress: 'the address', timeout: 2 };
const options: Partial<DeliveryOptions> = { timeout: 2 };
await deliverParcel(url, body, options);

expect(stubAxiosPost).toBeCalledTimes(2);
const postCall2Args = getMockContext(stubAxiosPost).calls[1];
expect(postCall2Args[1]).toEqual(body);
expect(postCall2Args[2]).toEqual({
headers: { 'X-Awala-Gateway': options.gatewayAddress },
maxRedirects: 0,
timeout: options.timeout,
});
Expand Down
45 changes: 27 additions & 18 deletions src/lib/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BindingType, PublicNodeAddress, resolvePublicAddress } from '@relaycorp/relaynet-core';
import { BindingType, PublicNodeAddress, resolveInternetAddress } from '@relaycorp/relaynet-core';
import axios, { AxiosInstance, AxiosResponse } from 'axios';
import { get as getEnvVar } from 'env-var';
import * as https from 'https';
Expand All @@ -8,34 +8,33 @@ import PoHTTPError from './PoHTTPError';
import PoHTTPInvalidParcelError from './PoHTTPInvalidParcelError';

export interface DeliveryOptions {
readonly gatewayAddress: string;
readonly enableTls: boolean;
readonly maxRedirects: number;
readonly timeout: number;
}

/**
* Deliver the parcel to the specified node endpoint.
*
* @param targetNodeUrl The URL of the target node endpoint.
* @param parcelSerialized The RAMF serialization of the parcel.
* @param recipientInternetAddressOrURL The Awala Internet address or URL of the recipient
* @param parcelSerialized The RAMF serialization of the parcel
* @param options
* @throws [[PoHTTPError]] when there's a networking error.
* @throws {PoHTTPError} when there's a networking error
*/
export async function deliverParcel(
targetNodeUrl: string,
recipientInternetAddressOrURL: string,
parcelSerialized: ArrayBuffer | Buffer,
options: Partial<DeliveryOptions> = {},
): Promise<AxiosResponse> {
const axiosOptions = {
headers: options.gatewayAddress ? { 'X-Awala-Gateway': options.gatewayAddress } : {},
maxRedirects: options.maxRedirects ?? 3,
timeout: options.timeout ?? 3000,
};
const axiosInstance = axios.create({
headers: { 'Content-Type': 'application/vnd.awala.parcel' },
httpsAgent: new https.Agent({ keepAlive: true }),
});
const url = await resolveURL(targetNodeUrl);
const url = await resolveURL(recipientInternetAddressOrURL, options.enableTls);
const response = await postRequest(url, parcelSerialized, axiosInstance, axiosOptions);
if (response.status === 307 || response.status === 308) {
throw new PoHTTPError(`Reached maximum number of redirects (${axiosOptions.maxRedirects})`);
Expand All @@ -44,20 +43,34 @@ export async function deliverParcel(
}

interface SupportedAxiosRequestConfig {
readonly headers: { readonly [key: string]: any };
readonly maxRedirects: number;
readonly timeout: number;
}

async function resolveURL(targetNodeUrl: string): Promise<string> {
const urlParts = new URL(targetNodeUrl);
async function resolveURL(targetNodeUrl: string, enableTls?: boolean): Promise<string> {
if (isURL(targetNodeUrl)) {
return targetNodeUrl;
}

let address: PublicNodeAddress | null;
try {
address = await resolvePublicAddress(urlParts.host, BindingType.PDC);
address = await resolveInternetAddress(targetNodeUrl, BindingType.PDC);
} catch (err) {
throw new PoHTTPError(err as Error, 'Public address resolution failed');
}
return address ? `${urlParts.protocol}//${address.host}:${address.port}` : targetNodeUrl;
const hostAndPort = address ? `${address.host}:${address.port}` : targetNodeUrl;
const scheme = enableTls !== false ? 'https' : 'http';
return `${scheme}://${hostAndPort}`;
}

function isURL(url: string): boolean {
try {
// tslint:disable-next-line:no-unused-expression
new URL(url);
} catch (_) {
return false;
}
return true;
}

async function postRequest(
Expand All @@ -72,11 +85,7 @@ async function postRequest(
}
let response;
try {
response = await axiosInstance.post(url, body, {
headers: options.headers,
maxRedirects: 0,
timeout: options.timeout,
});
response = await axiosInstance.post(url, body, { maxRedirects: 0, timeout: options.timeout });
} catch (error: any) {
if (!error.response) {
throw new PoHTTPError(`Connection error: ${error.message}`);
Expand Down

0 comments on commit 4387a92

Please sign in to comment.