Skip to content

Commit

Permalink
Fix reconciler TXT record handling (#465)
Browse files Browse the repository at this point in the history
* fix: Reconciler TXT record handling

* fix: typo

* feat: add tests, fix quotation mark handling

* fix: backslash handling

* fix

* fix: comment
  • Loading branch information
dadolhay authored Mar 28, 2023
1 parent eeaed98 commit 3c16e38
Show file tree
Hide file tree
Showing 4 changed files with 198 additions and 6 deletions.
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));
});
});

0 comments on commit 3c16e38

Please sign in to comment.