Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix reconciler TXT record handling #465

Merged
6 commits merged into from Mar 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions app/queues/reconciler/Route53CompareStructureGenerator.server.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { set } from 'lodash';
import { getDnsRecordSetPage } from '~/lib/dns.server';
import { fromRoute53RecordValue } from './route53Utils.server';

// Using this in JS code later, cannot `import type`
import { DnsRecordType } from '@prisma/client';
import type { ReconcilerCompareStructure } from './ReconcilerTypes';
import type { ResourceRecordSet, ListResourceRecordSetsResponse } from '@aws-sdk/client-route-53';
import type { ReconcilerCompareStructure } from './ReconcilerTypes';

// Validate `[subdomain].[username].starchart.com.`
// escape `.` characters in ROOT_DOMAIN also
Expand All @@ -29,9 +30,12 @@ class Route53CompareStructureGenerator {
return;
}

const value = recordSet.ResourceRecords?.map(({ Value }) => Value).filter(
(Value) => !!Value
) as string[];
const value = recordSet.ResourceRecords?.map(({ Value }) => Value)
.filter((value) => !!value)
.map((value) =>
// Convert from special Route53 TXT record format. Details in route53Utils.server.ts
fromRoute53RecordValue(recordSet.Type as DnsRecordType, value!)
) as string[];

// and set that value on our compare structure
set(this.#MUTATEDcompareStructure, [recordSet.Name, recordSet.Type], value);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { DnsRecordType } from '@prisma/client';

import type { Change } from '@aws-sdk/client-route-53';
import type { ReconcilerCompareStructure } from './ReconcilerTypes';
import { toRoute53RecordValue } from './route53Utils.server';

interface CompareStructures {
dbStructure: ReconcilerCompareStructure;
Expand Down Expand Up @@ -36,7 +37,10 @@ export const createRemovedChangeSetFromCompareStructures = ({
ResourceRecordSet: {
Name: fqdn,
Type: type,
ResourceRecords: route53Value.map((Value) => ({ Value })),
ResourceRecords: route53Value.map((value) => ({
// Convert to special Route53 TXT record format. Details in route53Utils.server.ts
Value: toRoute53RecordValue(type as DnsRecordType, value),
})),
TTL: 60 * 5,
},
});
Expand Down Expand Up @@ -90,7 +94,10 @@ export const createUpsertedChangeSetFromCompareStructures = ({
ResourceRecordSet: {
Name: fqdn,
Type: type,
ResourceRecords: dbValue.map((Value) => ({ Value })),
ResourceRecords: dbValue.map((value) => ({
// Convert to special Route53 TXT record format. Details in route53Utils.server.ts
Value: toRoute53RecordValue(type as DnsRecordType, value),
})),
TTL: 60 * 5,
},
});
Expand Down
77 changes: 77 additions & 0 deletions app/queues/reconciler/route53Utils.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { DnsRecordType } from '@prisma/client';

/**
* We need to use some special structures when sending / receiving recordSets
* from Route53
*
* https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/ResourceRecordTypes.html#TXTFormat
*
* `"String 1" "String 2" "String 3"` where the original value is cut to 255 char long strings
* Note, TXT records should not include quotation marks
*/

/**
* in each segment, replace characters that are not between \o040 - \o176
* use a replace fn, to generate our 3 digit octal codes, i.e., '!' => '\041'
* \o040 = \d33 = ascii `!` and \o176 = \d126 = '~'
* `\` needs to become `\\` and `"` has to be `\"`
*/
const escapeFn = (char: string): string => {
if (char === '\\') {
return '\\\\';
}
if (char === '"') {
return '\\"';
}

return `\\${char.charCodeAt(0).toString(8).padStart(3, '0')}`;
};

/**
* convert back the escaped octal values i.e., '\041' => '!'
* also unescape \ and " characters
*/
const unescapeFn = (match: string, selection: string): string => {
if (selection === '\\' || selection === '"') {
return selection;
}

return String.fromCharCode(parseInt(selection, 8));
};

export const toRoute53RecordValue = (type: DnsRecordType, value: string): string => {
if (type !== DnsRecordType.TXT) {
return value;
}

// Create an uninitialized array with the length to hold our split up strings (max 255 chars)
const segments = new Array(Math.ceil(value.length / 255))
// Initialize with undefined
.fill(undefined)
// Loop through, using the index split out the appropriate parts from the original string
.map((segment, index) => value.substring(index * 255, (index + 1) * 255))
// escape
.map((segment) => segment.replace(/([^!-~]|[\\"])/g, escapeFn))
// add quotation marks around the segments
.map((segment) => `"${segment}"`);

// Finally join the segments together with a white space
return segments.join(' ');
};

export const fromRoute53RecordValue = (type: DnsRecordType, value: string): string => {
if (type !== DnsRecordType.TXT) {
return value;
}

// Since space characters are octally escaped, I can use whitespace to split
const segments = value
.split(' ')
// Strip out the leading and trailing parenthesis
.map((segment) => segment.substring(1, segment.length - 1))
// convert back the escaped octal values i.e., '\041' => '!'
// also unescape \ and " characters
.map((segment) => segment.replace(/\\(\d{3}|\\|")/g, unescapeFn));

return segments.join('');
};
104 changes: 104 additions & 0 deletions test/unit/route53.server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import {
toRoute53RecordValue,
fromRoute53RecordValue,
} from '~/queues/reconciler/route53Utils.server';
import { DnsRecordType } from '@prisma/client';

describe('Route53 functionality test raw => Route53 format', () => {
test('Ignores a non-TXT record', () => {
const result = toRoute53RecordValue(DnsRecordType.A, '1.2.3.4');

expect(result).toEqual('1.2.3.4');
});

test('Handles input with no special chars, and < 255 length', () => {
// Adding characters from the edge of range
const result = toRoute53RecordValue(DnsRecordType.TXT, '!Hello~');

expect(result).toEqual('"!Hello~"');
});

test('Handles special characters correctly (space, quotation mark, backslash)', () => {
const result = toRoute53RecordValue(
DnsRecordType.TXT,
// Using a sequence of chars, trying to mislead parsing
[' ', '\\', '"', '\\', ' ', 'Hello', ' ', '"', 'World', '"', ' '].join('')
);

expect(result).toEqual(
[
'"',
'\\040',
'\\\\',
'\\"',
'\\\\',
'\\040',
'Hello',
'\\040',
'\\"',
'World',
'\\"',
'\\040',
'"',
].join('')
);
});

test('Handles long strings (> 255 char)', () => {
const result = toRoute53RecordValue(DnsRecordType.TXT, 'a'.repeat(1000));

expect(result).toEqual(
`"${'a'.repeat(255)}" "${'a'.repeat(255)}" "${'a'.repeat(255)}" "${'a'.repeat(235)}"`
);
});
});

describe('Route53 functionality test Route53 => raw format', () => {
test('Ignores a non-TXT record', () => {
const result = fromRoute53RecordValue(DnsRecordType.A, '1.2.3.4');

expect(result).toEqual('1.2.3.4');
});

test('Handles input with no special chars, and < 255 length', () => {
// Adding characters from the edge of range
const result = fromRoute53RecordValue(DnsRecordType.TXT, '"!Hello~"');

expect(result).toEqual('!Hello~');
});

test('Handles special characters correctly (space quotation mark and backslash)', () => {
const result = fromRoute53RecordValue(
DnsRecordType.TXT,
// Using a sequence of chars, trying to mislead parsing (do not double unescape escape characters)
[
'"',
'\\040',
'\\\\',
'\\"',
'\\\\',
'\\040',
'Hello',
'\\040',
'\\"',
'World',
'\\"',
'\\040',
'"',
].join('')
);

expect(result).toEqual(
[' ', '\\', '"', '\\', ' ', 'Hello', ' ', '"', 'World', '"', ' '].join('')
);
});

test('Handles long strings (> 255 char)', () => {
const result = fromRoute53RecordValue(
DnsRecordType.TXT,
`"${'a'.repeat(255)}" "${'a'.repeat(255)}" "${'a'.repeat(255)}" "${'a'.repeat(235)}"`
);

expect(result).toEqual('a'.repeat(1000));
});
});