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

feat: add experimental IPv6 support #235

Merged
merged 16 commits into from
Jun 6, 2024
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
13 changes: 12 additions & 1 deletion src/command/dns-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export type DnsOptions = {
query: {
type: string;
};
ipVersion: number;
};

export type DnsParseResponseJson = DnsParseResponseClassicJson | DnsParseResponseTraceJson;
Expand All @@ -44,6 +45,7 @@ const isTrace = (output: unknown): output is DnsParseResponseTrace => Array.isAr

const allowedTypes = [ 'A', 'AAAA', 'ANY', 'CNAME', 'DNSKEY', 'DS', 'HTTPS', 'MX', 'NS', 'NSEC', 'PTR', 'RRSIG', 'SOA', 'TXT', 'SRV' ];
const allowedProtocols = [ 'UDP', 'TCP' ];
const allowedIpVersions = [ 4, 6 ];

const dnsOptionsSchema = Joi.object<DnsOptions>({
type: Joi.string().valid('dns'),
Expand All @@ -56,6 +58,15 @@ const dnsOptionsSchema = Joi.object<DnsOptions>({
query: Joi.object({
type: Joi.string().valid(...allowedTypes).optional().default('A'),
}),
ipVersion: Joi.when(Joi.ref('resolver'), {
is: Joi.string().domain(),
then: Joi.valid(...allowedIpVersions).default(4),
otherwise: Joi.when(Joi.ref('resolver'), {
is: Joi.string().ip({ version: [ 'ipv6' ], cidr: 'forbidden' }),
then: Joi.valid(6).default(6),
otherwise: Joi.valid(4).default(4),
}),
}),
});

export const argBuilder = (options: DnsOptions): string[] => {
Expand All @@ -69,7 +80,7 @@ export const argBuilder = (options: DnsOptions): string[] => {
options.target,
resolverArg,
[ '-p', String(options.port) ],
'-4',
`-${options.ipVersion}`,
'+timeout=3',
'+tries=2',
'+nocookie',
Expand Down
15 changes: 13 additions & 2 deletions src/command/http-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export type HttpOptions = {
query: string;
headers?: Record<string, string>;
};
ipVersion: number;
};

type Cert = {
Expand Down Expand Up @@ -107,6 +108,7 @@ const getInitialResult = () => ({

const allowedHttpProtocols = [ 'HTTP', 'HTTPS', 'HTTP2' ];
const allowedHttpMethods = [ 'GET', 'HEAD' ];
const allowedIpVersions = [ 4, 6 ];

export const httpOptionsSchema = Joi.object<HttpOptions>({
type: Joi.string().valid('http').insensitive().required(),
Expand All @@ -122,6 +124,15 @@ export const httpOptionsSchema = Joi.object<HttpOptions>({
query: Joi.string().allow('').optional().default(''),
headers: Joi.object().default({}),
}).required(),
ipVersion: Joi.when(Joi.ref('target'), {
is: Joi.string().domain(),
then: Joi.valid(...allowedIpVersions).default(4),
otherwise: Joi.when(Joi.ref('target'), {
is: Joi.string().ip({ version: [ 'ipv6' ], cidr: 'forbidden' }),
then: Joi.valid(6).default(6),
otherwise: Joi.valid(4).default(4),
}),
}),
});

export const urlBuilder = (options: HttpOptions): string => {
Expand All @@ -134,7 +145,7 @@ export const urlBuilder = (options: HttpOptions): string => {
return url;
};

export const httpCmd = (options: HttpOptions, resolverFn?: ResolverType): Request => {
export const httpCmd = (options: HttpOptions, resolverFn?: ResolverType): Request => {
const url = urlBuilder(options);
const dnsResolver = callbackify(dnsLookup(options.resolver, resolverFn), true);

Expand All @@ -143,7 +154,7 @@ export const httpCmd = (options: HttpOptions, resolverFn?: ResolverType): Reques
followRedirect: false,
cache: false,
dnsLookup: dnsResolver,
dnsLookupIpVersion: 4 as DnsLookupIpVersion,
dnsLookupIpVersion: (options.ipVersion ?? 4) as DnsLookupIpVersion,
http2: options.protocol === 'HTTP2',
timeout: {
request: 10_000,
Expand Down
15 changes: 13 additions & 2 deletions src/command/mtr-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@ export type MtrOptions = {
protocol: string;
port: number;
packets: number;
ipVersion: number;
};

type DnsResolver = (addr: string, rrtype?: string) => Promise<string[]>;

const logger = scopedLogger('mtr-command');
const allowedIpVersions = [ 4, 6 ];

const mtrOptionsSchema = Joi.object<MtrOptions>({
type: Joi.string().valid('mtr'),
Expand All @@ -38,6 +40,15 @@ const mtrOptionsSchema = Joi.object<MtrOptions>({
protocol: Joi.string().lowercase().insensitive(),
packets: Joi.number().min(1).max(16).default(3),
port: Joi.number(),
ipVersion: Joi.when(Joi.ref('target'), {
is: Joi.string().domain(),
then: Joi.valid(...allowedIpVersions).default(4),
otherwise: Joi.when(Joi.ref('target'), {
is: Joi.string().ip({ version: [ 'ipv6' ], cidr: 'forbidden' }),
then: Joi.valid(6).default(6),
otherwise: Joi.valid(4).default(4),
}),
}),
});

export const getResultInitState = (): ResultType => ({ status: 'finished', hops: [], rawOutput: '', data: [] });
Expand All @@ -48,8 +59,8 @@ export const argBuilder = (options: MtrOptions): string[] => {
const packetsArg = String(options.packets);

const args = [
// Ipv4
'-4',
// Ipv4 or IPv6
`-${options.ipVersion}`,
intervalArg,
[ '--gracetime', '3' ],
[ '--max-ttl', '30' ],
Expand Down
14 changes: 13 additions & 1 deletion src/command/ping-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,25 @@ export type PingOptions = {
inProgressUpdates: boolean;
target: string;
packets: number;
ipVersion: number;
};

const allowedIpVersions = [ 4, 6 ];

const pingOptionsSchema = Joi.object<PingOptions>({
type: Joi.string().valid('ping'),
inProgressUpdates: Joi.boolean(),
target: Joi.string(),
packets: Joi.number().min(1).max(16).default(3),
ipVersion: Joi.when(Joi.ref('target'), {
is: Joi.string().domain(),
then: Joi.valid(...allowedIpVersions).default(4),
otherwise: Joi.when(Joi.ref('target'), {
is: Joi.string().ip({ version: [ 'ipv6' ], cidr: 'forbidden' }),
then: Joi.valid(6).default(6),
otherwise: Joi.valid(4).default(4),
}),
}),
});

/* eslint-disable @typescript-eslint/ban-types */
Expand Down Expand Up @@ -49,7 +61,7 @@ const logger = scopedLogger('ping-command');

export const argBuilder = (options: PingOptions): string[] => {
const args = [
'-4',
`-${options.ipVersion}`,
'-O',
[ '-c', options.packets.toString() ],
[ '-i', '0.5' ],
Expand Down
18 changes: 15 additions & 3 deletions src/command/traceroute-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { isIpPrivate } from '../lib/private-ip.js';
import { scopedLogger } from '../lib/logger.js';
import { InvalidOptionsException } from './exception/invalid-options-exception.js';

const reHost = /(\S+)\s+\((?:((?:\d+\.){3}\d+)|([\da-fA-F:]))\)/;
const reHost = /(\S+?)(?:%\w+)?\s+\(((?:\d+\.){3}\d+|[\da-fA-F:]+)(?:%\w+)?\)/;
const reRtt = /(\d+(?:\.?\d+)?)\s+ms(!\S*)?/g;

export type TraceOptions = {
Expand All @@ -17,6 +17,7 @@ export type TraceOptions = {
target: string;
protocol: string;
port: number;
ipVersion: number;
};

type ParsedLine = {
Expand Down Expand Up @@ -48,20 +49,31 @@ type ParsedOutputJson = {

const logger = scopedLogger('traceroute-command');

const allowedIpVersions = [ 4, 6 ];

const traceOptionsSchema = Joi.object<TraceOptions>({
type: Joi.string().valid('traceroute'),
inProgressUpdates: Joi.boolean(),
target: Joi.string(),
protocol: Joi.string(),
port: Joi.number(),
ipVersion: Joi.when(Joi.ref('target'), {
is: Joi.string().domain(),
then: Joi.valid(...allowedIpVersions).default(4),
otherwise: Joi.when(Joi.ref('target'), {
is: Joi.string().ip({ version: [ 'ipv6' ], cidr: 'forbidden' }),
then: Joi.valid(6).default(6),
otherwise: Joi.valid(4).default(4),
}),
}),
});

export const argBuilder = (options: TraceOptions): string[] => {
const port = options.protocol === 'TCP' ? [ '-p', `${options.port}` ] : [];

const args = [
// Ipv4
'-4',
// Ipv4 or IPv6
`-${options.ipVersion}`,
// Max ttl
[ '-m', '20' ],
// Max timeout
Expand Down
10 changes: 3 additions & 7 deletions src/lib/dns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,10 @@ export const getDnsServers = (getServers: () => string[] = dns.getServers): stri
const servers = getServers();

return servers
// Filter out ipv6
.filter((addr: string) => {
const ipv6Match = /^\[(.*)]/g.exec(addr); // Nested with port
return !isIPv6(addr) && !isIPv6(ipv6Match?.[1] ?? '');
})
// Hide private ips
.map((addr: string) => {
const ip = addr.replace(/:\d{1,5}$/, '');
return isIpPrivate(ip) ? 'private' : addr;
let ip = addr.replace('[', '').replace(/]:\d{1,5}$/, ''); // removes port number if it is ipv6
ip = isIPv6(ip) ? ip : ip.replace(/:\d{1,5}$/, ''); // removes port number if it is not ipv6
return isIpPrivate(ip) ? 'private' : ip;
});
};
1 change: 1 addition & 0 deletions src/lib/private-ip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ privateBlockList.addSubnet('::1', 128, 'ipv6');
privateBlockList.addSubnet('64:ff9b:1::', 48, 'ipv6');
privateBlockList.addSubnet('100::', 64, 'ipv6');
privateBlockList.addSubnet('2001::', 32, 'ipv6');
privateBlockList.addSubnet('2001:10::', 28, 'ipv6');
privateBlockList.addSubnet('2001:20::', 28, 'ipv6');
privateBlockList.addSubnet('2001:db8::', 32, 'ipv6');
privateBlockList.addSubnet('2002::', 16, 'ipv6');
Expand Down
92 changes: 65 additions & 27 deletions src/lib/status-manager.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import config from 'config';
import type { ExecaChildProcess, ExecaError, ExecaReturnValue } from 'execa';
import type { ExecaChildProcess, ExecaError } from 'execa';
import type { Socket } from 'socket.io-client';
import parse, { PingParseOutput } from '../command/handlers/ping/parse.js';
import type { PingOptions } from '../command/ping-command.js';
Expand All @@ -12,6 +12,8 @@ const INTERVAL_TIME = 10 * 60 * 1000; // 10 mins

export class StatusManager {
private status: 'initializing' | 'ready' | 'unbuffer-missing' | 'ping-test-failed' | 'sigterm' = 'initializing';
private isIPv4Supported: boolean = false;
private isIPv6Supported: boolean = false;
private timer?: NodeJS.Timeout;

constructor (
Expand Down Expand Up @@ -39,69 +41,105 @@ export class StatusManager {
return this.status;
}

public getIsIPv4Supported () {
return this.isIPv4Supported;
}

public getIsIPv6Supported () {
return this.isIPv6Supported;
}

public updateStatus (status: StatusManager['status']) {
this.status = status;
this.sendStatus();
}

public updateIsIPv4Supported (isIPv4Supported : boolean) {
this.isIPv4Supported = isIPv4Supported;
this.sendIsIPv4Supported();
}

public updateIsIPv6Supported (isIPv6Supported : boolean) {
this.isIPv6Supported = isIPv6Supported;
this.sendIsIPv6Supported();
}

public sendStatus () {
this.socket.emit('probe:status:update', this.status);
}

public sendIsIPv4Supported () {
this.socket.emit('probe:isIPv4Supported:update', this.isIPv4Supported);
}

public sendIsIPv6Supported () {
this.socket.emit('probe:isIPv6Supported:update', this.isIPv6Supported);
}

private async runTest () {
const result = await this.pingTest();
const [ resultIPv4, resultIPv6 ] = await Promise.all([
this.pingTest(4),
this.pingTest(6),
]);

if (result) {
if (resultIPv4 || resultIPv6) {
this.updateStatus('ready');
} else {
this.updateStatus('ping-test-failed');
logger.warn(`Both ping tests failed due to bad internet connection. Retrying in 10 minutes. Probe temporarily disconnected.`);
}

this.updateIsIPv4Supported(resultIPv4);
this.updateIsIPv6Supported(resultIPv6);

// eslint-disable-next-line @typescript-eslint/no-misused-promises
this.timer = setTimeout(async () => {
await this.runTest();
}, INTERVAL_TIME);
}

private async pingTest () {
private async pingTest (ipVersion: number) {
const packets = config.get<number>('status.numberOfPackets');
const targets = [ 'ns1.registry.in', 'k.root-servers.net', 'ns1.dns.nl' ];
const results = await Promise.allSettled(targets.map(target => this.pingCmd({ type: 'ping', target, packets, inProgressUpdates: false })));
const results = await Promise.allSettled(targets.map(target => this.pingCmd({ type: 'ping', ipVersion, target, packets, inProgressUpdates: false })));

const fulfilledPromises = results.filter((promise): promise is PromiseFulfilledResult<ExecaReturnValue> => promise.status === 'fulfilled');
const cmdResults = fulfilledPromises.map(promise => promise.value).map(result => parse(result.stdout));
const nonSuccessfulResults: Record<string, PingParseOutput> = {};
const successfulResults = cmdResults.filter((result, index) => {
const isSuccessful = result.status === 'finished' && result.stats?.loss === 0;
const rejectedResults: Array<{ target: string, reason: ExecaError }> = [];
const successfulResults: Array<{ target: string, result: PingParseOutput }> = [];
const unSuccessfulResults: Array<{ target: string, result: PingParseOutput }> = [];

if (!isSuccessful) {
nonSuccessfulResults[targets[index]!] = result;
for (const [ index, result ] of results.entries()) {
if (result.status === 'rejected') {
rejectedResults.push({ target: targets[index]!, reason: result.reason as ExecaError });
} else {
const parsed = parse(result.value.stdout);
const isSuccessful = parsed.stats?.loss === 0;

if (isSuccessful) {
successfulResults.push({ target: targets[index]!, result: parsed });
} else {
unSuccessfulResults.push({ target: targets[index]!, result: parsed });
}
}

return isSuccessful;
});
}

const isPassingTest = successfulResults.length >= 2;
const testPassText = isPassingTest ? '. Test pass' : '';

const rejectedPromises = results.filter((promise): promise is PromiseRejectedResult => promise.status === 'rejected');
rejectedPromises.forEach((promise) => {
const reason = promise.reason as ExecaError;
const testPassText = isPassingTest ? `. IPv${ipVersion} tests pass` : '';

if (reason?.exitCode === 1) {
const output = (reason).stdout || (reason).stderr || '';
logger.warn(`Quality control ping test result is unsuccessful: ${output}${testPassText}.`);
rejectedResults.forEach(({ reason }) => {
if (!reason.exitCode) {
logger.warn(`IPv${ipVersion} ping test unsuccessful${testPassText}:`, reason);
} else {
logger.warn(`Quality control ping test result is unsuccessful${testPassText}:`, reason);
const output = (reason).stdout || (reason).stderr || '';
logger.warn(`IPv${ipVersion} ping test unsuccessful: ${output}${testPassText}.`);
}
});

Object.entries(nonSuccessfulResults).forEach(([ target, result ]) => {
logger.warn(`Quality control ping test result is unsuccessful: ${target} ${result.stats?.loss?.toString() || ''}% packet loss${testPassText}.`);
unSuccessfulResults.forEach(({ target, result }) => {
logger.warn(`IPv${ipVersion} ping test unsuccessful for ${target}: ${result.stats?.loss?.toString() || ''}% packet loss${testPassText}.`);
});

if (!isPassingTest) {
logger.warn('Quality control ping tests failed due to bad internet connection. Retrying in 10 minutes. Probe temporarily disconnected.');
logger.warn(`IPv${ipVersion} ping tests failed. Retrying in 10 minutes. Probe marked as not supporting IPv${ipVersion}.`);
}

return isPassingTest;
Expand Down
Loading
Loading