diff --git a/.github/workflows/alpha-release.yml b/.github/workflows/alpha-release.yml index 2a48560..e7a3e92 100644 --- a/.github/workflows/alpha-release.yml +++ b/.github/workflows/alpha-release.yml @@ -8,7 +8,7 @@ on: jobs: publish: if: ${{ github.repository == 'homebridge/ciao' }} - uses: homebridge/.github/.github/workflows/npm-publish.yml@latest + uses: homebridge/.github/.github/workflows/npm-publish-esm.yml@latest with: tag: alpha dynamically_adjust_version: true diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml index 9cb1ad9..61dce6e 100644 --- a/.github/workflows/beta-release.yml +++ b/.github/workflows/beta-release.yml @@ -9,7 +9,7 @@ jobs: build_and_test: uses: homebridge/.github/.github/workflows/nodejs-build-and-test.yml@latest with: - enable_coverage: true + # enable_coverage: true # will fail until https://github.com/bcoe/v8-coverage/pull/2 is merged secrets: token: ${{ secrets.GITHUB_TOKEN }} lint: @@ -21,7 +21,7 @@ jobs: if: ${{ github.repository == 'homebridge/ciao' }} - uses: homebridge/.github/.github/workflows/npm-publish.yml@latest + uses: homebridge/.github/.github/workflows/npm-publish-esm.yml@latest with: tag: beta dynamically_adjust_version: true diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4f13822..a1a89dc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,7 +10,7 @@ jobs: build_and_test: uses: homebridge/.github/.github/workflows/nodejs-build-and-test.yml@latest with: - enable_coverage: true + # enable_coverage: true # will fail until https://github.com/bcoe/v8-coverage/pull/2 is merged secrets: token: ${{ secrets.GITHUB_TOKEN }} lint: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ef72bc4..7069bdc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,7 +10,7 @@ jobs: build_and_test: uses: homebridge/.github/.github/workflows/nodejs-build-and-test.yml@latest with: - enable_coverage: true + # enable_coverage: true # will fail until https://github.com/bcoe/v8-coverage/pull/2 is merged secrets: token: ${{ secrets.GITHUB_TOKEN }} @@ -19,6 +19,6 @@ jobs: if: ${{ github.repository == 'homebridge/ciao' }} - uses: homebridge/.github/.github/workflows/npm-publish.yml@latest + uses: homebridge/.github/.github/workflows/npm-publish-esm.yml@latest secrets: npm_auth_token: ${{ secrets.npm_token }} diff --git a/package-lock.json b/package-lock.json index 46bb621..ef85a78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "devDependencies": { "@antfu/eslint-config": "^3.0.0", "@types/debug": "^4.1.12", - "@types/node": "^22.5.1", + "@types/node": "^22.5.2", "@types/source-map-support": "^0.5.10", "@vitest/coverage-v8": "^2.0.5", "eslint": "^9.9.1", @@ -1598,9 +1598,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.5.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.1.tgz", - "integrity": "sha512-KkHsxej0j9IW1KKOOAA/XBA0z08UFSrRQHErzEfA3Vgq57eXIMYboIlHJuYIfd+lwCQjtKqUu3UnmKbtUc9yRw==", + "version": "22.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.2.tgz", + "integrity": "sha512-acJsPTEqYqulZS/Yp/S3GgeE6GZ0qYODUR8aVr/DkhHQ8l9nd4j5x1/ZJy9/gHrRlFMqkO6i0I3E27Alu4jjPg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 12fbb8a..be9ea70 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "devDependencies": { "@antfu/eslint-config": "^3.0.0", "@types/debug": "^4.1.12", - "@types/node": "^22.5.1", + "@types/node": "^22.5.2", "@types/source-map-support": "^0.5.10", "@vitest/coverage-v8": "^2.0.5", "eslint": "^9.9.1", diff --git a/src/CiaoService.ts b/src/CiaoService.ts index 6d095cf..8b67a46 100644 --- a/src/CiaoService.ts +++ b/src/CiaoService.ts @@ -1,52 +1,60 @@ -/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ -import assert from "assert"; -import createDebug from "debug"; -import { EventEmitter } from "events"; -import net from "net"; -import { DNSResponseDefinition, RType } from "./coder/DNSPacket"; -import { AAAARecord } from "./coder/records/AAAARecord"; -import { ARecord } from "./coder/records/ARecord"; -import { NSECRecord } from "./coder/records/NSECRecord"; -import { PTRRecord } from "./coder/records/PTRRecord"; -import { SRVRecord } from "./coder/records/SRVRecord"; -import { TXTRecord } from "./coder/records/TXTRecord"; -import { ResourceRecord } from "./coder/ResourceRecord"; -import { Protocol, Responder } from "./index"; -import { InterfaceName, IPAddress, NetworkManager, NetworkUpdate } from "./NetworkManager"; -import { Announcer } from "./responder/Announcer"; -import { dnsLowerCase } from "./util/dns-equal"; -import * as domainFormatter from "./util/domain-formatter"; -import { formatReverseAddressPTRName } from "./util/domain-formatter"; -import Timeout = NodeJS.Timeout; - -const debug = createDebug("ciao:CiaoService"); - -const numberedServiceNamePattern = /^(.*) \((\d+)\)$/; // matches a name lik "My Service (2)" -const numberedHostnamePattern = /^(.*)-\((\d+)\)(\.\w{2,}.)$/; // matches a hostname like "My-Computer-(2).local." +/* global NodeJS */ +import type { DNSResponseDefinition } from './coder/DNSPacket' +import type { InterfaceName, IPAddress, NetworkUpdate } from './NetworkManager' +import type { Announcer } from './responder/Announcer' + +import assert from 'node:assert' +import { Buffer } from 'node:buffer' +import { EventEmitter } from 'node:events' +import net from 'node:net' +import process from 'node:process' + +import createDebug from 'debug' + +import { RType } from './coder/DNSPacket.js' +import { AAAARecord } from './coder/records/AAAARecord.js' +import { ARecord } from './coder/records/ARecord.js' +import { NSECRecord } from './coder/records/NSECRecord.js' +import { PTRRecord } from './coder/records/PTRRecord.js' +import { SRVRecord } from './coder/records/SRVRecord.js' +import { TXTRecord } from './coder/records/TXTRecord.js' +import { ResourceRecord } from './coder/ResourceRecord.js' +import { Protocol, Responder } from './index.js' +import { NetworkManager } from './NetworkManager.js' +import { dnsLowerCase } from './util/dns-equal.js' +import * as domainFormatter from './util/domain-formatter.js' +import { formatReverseAddressPTRName } from './util/domain-formatter.js' + +import Timeout = NodeJS.Timeout + +const debug = createDebug('ciao:CiaoService') + +const numberedServiceNamePattern = /^(.*) \((\d+)\)$/ // matches a name lik "My Service (2)" +const numberedHostnamePattern = /^(.*)-\((\d+)\)(\.\w{2,}.)$/ // matches a hostname like "My-Computer-(2).local." /** * This enum defines some commonly used service types. * This is also referred to as service name (as of RFC 6763). * A service name must not be longer than 15 characters (RFC 6763 7.2). */ +// eslint-disable-next-line no-restricted-syntax export const enum ServiceType { - // noinspection JSUnusedGlobalSymbols - AIRDROP = "airdrop", - AIRPLAY = "airplay", - AIRPORT = "airport", - COMPANION_LINK = "companion-link", - DACP = "dacp", // digital audio control protocol (iTunes) - HAP = "hap", // used by HomeKit accessories - HOMEKIT = "homekit", // used by home hubs - HTTP = "http", - HTTP_ALT = "http_alt", // http alternate - IPP = "ipp", // internet printing protocol - IPPS = "ipps", // internet printing protocol over https - RAOP = "raop", // remote audio output protocol - scanner = "scanner", // bonjour scanning - TOUCH_ABLE = "touch-able", // iPhone and iPod touch remote controllable - DNS_SD = "dns-sd", - PRINTER = "printer", + AIRDROP = 'airdrop', + AIRPLAY = 'airplay', + AIRPORT = 'airport', + COMPANION_LINK = 'companion-link', + DACP = 'dacp', // digital audio control protocol (iTunes) + HAP = 'hap', // used by HomeKit accessories + HOMEKIT = 'homekit', // used by home hubs + HTTP = 'http', + HTTP_ALT = 'http_alt', // http alternate + IPP = 'ipp', // internet printing protocol + IPPS = 'ipps', // internet printing protocol over https + RAOP = 'raop', // remote audio output protocol + scanner = 'scanner', // bonjour scanning + TOUCH_ABLE = 'touch-able', // iPhone and iPod touch remote controllable + DNS_SD = 'dns-sd', + PRINTER = 'printer', } /** @@ -56,42 +64,42 @@ export interface ServiceOptions { /** * Instance name of the service */ - name: string; + name: string /** * Type of the service. */ - type: ServiceType | string; + type: ServiceType | string /** * Optional array of subtypes of the service. * Refer to {@link ServiceType} for some known examples. */ - subtypes?: (ServiceType | string)[]; + subtypes?: (ServiceType | string)[] /** * Port of the service. * If not supplied it must be set later via {@link CiaoService.updatePort} BEFORE advertising the service. */ - port?: number; + port?: number /** * The protocol the service uses. Default is TCP. */ - protocol?: Protocol; + protocol?: Protocol /** * Defines a hostname under which the service can be reached. * The specified hostname must not include the TLD. * If undefined the service name will be used as default. */ - hostname?: string; + hostname?: string /** * If defined, a txt record will be published with the given service. */ - txt?: ServiceTxt; + txt?: ServiceTxt /** * Adds ability to set custom domain. Will default to "local". * The domain will also be automatically appended to the hostname. */ - domain?: string; + domain?: string /** * If defined it restricts the service to be advertised on the specified @@ -107,56 +115,57 @@ export interface ServiceOptions { * If the service is set to advertise on a given interface, though the MDNSServer is * configured to ignore this interface, the service won't be advertised on the interface. */ - restrictedAddresses?: (InterfaceName | IPAddress)[]; + restrictedAddresses?: (InterfaceName | IPAddress)[] /** * The service won't advertise ipv6 address records. * This can be used to simulate binding on 0.0.0.0. * May be combined with {@link restrictedAddresses}. */ - disabledIpv6?: boolean; + disabledIpv6?: boolean } /** * A service txt consist of multiple key=value pairs, * which get advertised on the network. */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type ServiceTxt = Record; +export type ServiceTxt = Record /** * @private */ +// eslint-disable-next-line no-restricted-syntax export const enum ServiceState { - UNANNOUNCED = "unannounced", - PROBING = "probing", - PROBED = "probed", // service was probed to be unique - ANNOUNCING = "announcing", // service is in the process of being announced - ANNOUNCED = "announced", + UNANNOUNCED = 'unannounced', + PROBING = 'probing', + PROBED = 'probed', // service was probed to be unique + ANNOUNCING = 'announcing', // service is in the process of being announced + ANNOUNCED = 'announced', } /** * @private */ export interface ServiceRecords { - ptr: PTRRecord; // this is the main type ptr record - subtypePTRs?: PTRRecord[]; - metaQueryPtr: PTRRecord; // pointer for the "_services._dns-sd._udp.local" meta query - - srv: SRVRecord; - txt: TXTRecord; - serviceNSEC: NSECRecord; - - a: Record; - aaaa: Record; // link-local - aaaaR: Record; // routable AAAA - aaaaULA: Record; // unique local address - reverseAddressPTRs: Record; // indexed by address - addressNSEC: NSECRecord; + ptr: PTRRecord // this is the main type ptr record + subtypePTRs?: PTRRecord[] + metaQueryPtr: PTRRecord // pointer for the "_services._dns-sd._udp.local" meta query + + srv: SRVRecord + txt: TXTRecord + serviceNSEC: NSECRecord + + a: Record + aaaa: Record // link-local + aaaaR: Record // routable AAAA + aaaaULA: Record // unique local address + reverseAddressPTRs: Record // indexed by address + addressNSEC: NSECRecord } /** * Events thrown by a CiaoService */ +// eslint-disable-next-line no-restricted-syntax export const enum ServiceEvent { /** * Event is called when the Prober identifies that the name for the service is already used @@ -164,7 +173,7 @@ export const enum ServiceEvent { * This change must be persisted and thus a listener must hook up to this event * in order for the name to be persisted. */ - NAME_CHANGED = "name-change", + NAME_CHANGED = 'name-change', /** * Event is called when the Prober identifies that the hostname for the service is already used * and thus resolve the name conflict by adjusting the hostname (e.g. adding '(2)' to the hostname). @@ -173,89 +182,44 @@ export const enum ServiceEvent { * If you supply a custom hostname (not automatically derived from the service name) you must * hook up a listener to this event in order for the hostname to be persisted. */ - HOSTNAME_CHANGED = "hostname-change", + HOSTNAME_CHANGED = 'hostname-change', } /** * Events thrown by a CiaoService, internal use only! * @private */ +// eslint-disable-next-line no-restricted-syntax export const enum InternalServiceEvent { - PUBLISH = "publish", - UNPUBLISH = "unpublish", - REPUBLISH = "republish", - RECORD_UPDATE = "records-update", - RECORD_UPDATE_ON_INTERFACE = "records-update-interface", + PUBLISH = 'publish', + UNPUBLISH = 'unpublish', + REPUBLISH = 'republish', + RECORD_UPDATE = 'records-update', + RECORD_UPDATE_ON_INTERFACE = 'records-update-interface', } /** * @private */ -export type PublishCallback = (error?: Error) => void; +export type PublishCallback = (error?: Error) => void /** * @private */ -export type UnpublishCallback = (error?: Error) => void; +export type UnpublishCallback = (error?: Error) => void /** * @private */ -export type RecordsUpdateCallback = (error?: Error | null) => void; +export type RecordsUpdateCallback = (error?: Error | null) => void +// eslint-disable-next-line ts/no-unsafe-declaration-merging export declare interface CiaoService { - on(event: "name-change", listener: (name: string) => void): this; - on(event: "hostname-change", listener: (hostname: string) => void): this; + on: ((event: 'name-change', listener: (name: string) => void) => this) & ((event: 'hostname-change', listener: (hostname: string) => void) => this) & ((event: InternalServiceEvent.PUBLISH, listener: (callback: PublishCallback) => void) => this) & ((event: InternalServiceEvent.UNPUBLISH, listener: (callback: UnpublishCallback) => void) => this) & ((event: InternalServiceEvent.REPUBLISH, listener: (callback: PublishCallback) => void) => this) & ((event: InternalServiceEvent.RECORD_UPDATE, listener: (response: DNSResponseDefinition, callback?: (error?: Error | null) => void) => void) => this) & ((event: InternalServiceEvent.RECORD_UPDATE_ON_INTERFACE, listener: (name: InterfaceName, records: ResourceRecord[], callback?: RecordsUpdateCallback) => void) => this) /** * @private */ - on(event: InternalServiceEvent.PUBLISH, listener: (callback: PublishCallback) => void): this; - /** - * @private - */ - on(event: InternalServiceEvent.UNPUBLISH, listener: (callback: UnpublishCallback) => void): this; - /** - * @private - */ - on(event: InternalServiceEvent.REPUBLISH, listener: (callback: PublishCallback) => void): this; - /** - * @private - */ - on(event: InternalServiceEvent.RECORD_UPDATE, listener: (response: DNSResponseDefinition, callback?: (error?: Error | null) => void) => void): this; - /** - * @private - */ - on(event: InternalServiceEvent.RECORD_UPDATE_ON_INTERFACE, listener: (name: InterfaceName, records: ResourceRecord[], callback?: RecordsUpdateCallback) => void): this; - - /** - * @private - */ - emit(event: ServiceEvent.NAME_CHANGED, name: string): boolean; - /** - * @private - */ - emit(event: ServiceEvent.HOSTNAME_CHANGED, hostname: string): boolean; - - /** - * @private - */ - emit(event: InternalServiceEvent.PUBLISH, callback: PublishCallback): boolean; - /** - * @private - */ - emit(event: InternalServiceEvent.UNPUBLISH, callback: UnpublishCallback): boolean; - /** - * @private - */ - emit(event: InternalServiceEvent.REPUBLISH, callback?: PublishCallback): boolean; - /** - * @private - */ - emit(event: InternalServiceEvent.RECORD_UPDATE, response: DNSResponseDefinition, callback?: (error?: Error | null) => void): boolean; - /** - * @private - */ - emit(event: InternalServiceEvent.RECORD_UPDATE_ON_INTERFACE, name: InterfaceName, records: ResourceRecord[], callback?: RecordsUpdateCallback): boolean; + emit: ((event: ServiceEvent.NAME_CHANGED, name: string) => boolean) & ((event: ServiceEvent.HOSTNAME_CHANGED, hostname: string) => boolean) & ((event: InternalServiceEvent.PUBLISH, callback: PublishCallback) => boolean) & ((event: InternalServiceEvent.UNPUBLISH, callback: UnpublishCallback) => boolean) & ((event: InternalServiceEvent.REPUBLISH, callback?: PublishCallback) => boolean) & ((event: InternalServiceEvent.RECORD_UPDATE, response: DNSResponseDefinition, callback?: (error?: Error | null) => void) => boolean) & ((event: InternalServiceEvent.RECORD_UPDATE_ON_INTERFACE, name: InterfaceName, records: ResourceRecord[], callback?: RecordsUpdateCallback) => boolean) } @@ -274,129 +238,129 @@ export declare interface CiaoService { * {@link Responder.createService} method in the Responder class. * Once the instance is created, {@link advertise} can be called to announce the service on the network. */ +// eslint-disable-next-line ts/no-unsafe-declaration-merging export class CiaoService extends EventEmitter { + private readonly networkManager: NetworkManager - private readonly networkManager: NetworkManager; + private name: string + private readonly type: ServiceType | string + private readonly subTypes?: string[] + private readonly protocol: Protocol + private readonly serviceDomain: string // remember: can't be named "domain" => conflicts with EventEmitter - private name: string; - private readonly type: ServiceType | string; - private readonly subTypes?: string[]; - private readonly protocol: Protocol; - private readonly serviceDomain: string; // remember: can't be named "domain" => conflicts with EventEmitter + private fqdn: string // fully qualified domain name + private loweredFqdn: string + private readonly typePTR: string + private readonly loweredTypePTR: string + private readonly subTypePTRs?: string[] - private fqdn: string; // fully qualified domain name - private loweredFqdn: string; - private readonly typePTR: string; - private readonly loweredTypePTR: string; - private readonly subTypePTRs?: string[]; + private hostname: string // formatted hostname + private loweredHostname: string + private port?: number - private hostname: string; // formatted hostname - private loweredHostname: string; - private port?: number; + private readonly restrictedAddresses?: Map + private readonly disableIpv6?: boolean - private readonly restrictedAddresses?: Map; - private readonly disableIpv6?: boolean; - - private txt: Buffer[]; - private txtTimer?: Timeout; + private txt: Buffer[] + private txtTimer?: Timeout /** * this field is entirely controlled by the Responder class - * @private use by the Responder to set the current service state + * @private */ - serviceState = ServiceState.UNANNOUNCED; + serviceState = ServiceState.UNANNOUNCED /** * If service is in state {@link ServiceState.ANNOUNCING} the {@link Announcer} responsible for the * service will be linked here. This is need to cancel announcing when for example the service * should be terminated, and we still aren't fully announced yet. - * @private is controlled by the {@link Responder} instance + * @private */ - currentAnnouncer?: Announcer; - private serviceRecords?: ServiceRecords; + currentAnnouncer?: Announcer + private serviceRecords?: ServiceRecords - private destroyed = false; + private destroyed = false /** * Constructs a new service. Please use {@link Responder.createService} to create new service. * When calling the constructor a callee must listen to certain events in order to provide * correct functionality. - * @private used by the Responder instance to create a new service + * @private */ constructor(networkManager: NetworkManager, options: ServiceOptions) { - super(); - assert(networkManager, "networkManager is required"); - assert(options, "parameters options is required"); - assert(options.name, "service options parameter 'name' is required"); - assert(options.type, "service options parameter 'type' is required"); - assert(options.type.length <= 15, "service options parameter 'type' must not be longer than 15 characters"); + super() + assert(networkManager, 'networkManager is required') + assert(options, 'parameters options is required') + assert(options.name, 'service options parameter \'name\' is required') + assert(options.type, 'service options parameter \'type\' is required') + assert(options.type.length <= 15, 'service options parameter \'type\' must not be longer than 15 characters') - this.networkManager = networkManager; + this.networkManager = networkManager - this.name = options.name; - this.type = options.type; - this.subTypes = options.subtypes; - this.protocol = options.protocol || Protocol.TCP; - this.serviceDomain = options.domain || "local"; + this.name = options.name + this.type = options.type + this.subTypes = options.subtypes + this.protocol = options.protocol || Protocol.TCP + this.serviceDomain = options.domain || 'local' - this.fqdn = this.formatFQDN(); - this.loweredFqdn = dnsLowerCase(this.fqdn); + this.fqdn = this.formatFQDN() + this.loweredFqdn = dnsLowerCase(this.fqdn) this.typePTR = domainFormatter.stringify({ // something like '_hap._tcp.local' type: this.type, protocol: this.protocol, domain: this.serviceDomain, - }); - this.loweredTypePTR = dnsLowerCase(this.typePTR); + }) + this.loweredTypePTR = dnsLowerCase(this.typePTR) if (this.subTypes) { this.subTypePTRs = this.subTypes.map(subtype => domainFormatter.stringify({ - subtype: subtype, + subtype, type: this.type, protocol: this.protocol, domain: this.serviceDomain, - })).map(dnsLowerCase); + })).map(dnsLowerCase) } this.hostname = domainFormatter.formatHostname(options.hostname || this.name, this.serviceDomain) - .replace(/ /g, "-"); // replacing all spaces with dashes in the hostname - this.loweredHostname = dnsLowerCase(this.hostname); - this.port = options.port; + .replace(/ /g, '-') // replacing all spaces with dashes in the hostname + this.loweredHostname = dnsLowerCase(this.hostname) + this.port = options.port if (options.restrictedAddresses) { - assert(options.restrictedAddresses.length, "The service property 'restrictedAddresses' cannot be an empty array!"); - this.restrictedAddresses = new Map(); + assert(options.restrictedAddresses.length, 'The service property \'restrictedAddresses\' cannot be an empty array!') + this.restrictedAddresses = new Map() for (const entry of options.restrictedAddresses) { if (net.isIP(entry)) { - if (entry === "0.0.0.0" || entry === "::") { - throw new Error(`[${this.fqdn}] Unspecified ip address (${entry}) cannot be used to restrict on to!`); + if (entry === '0.0.0.0' || entry === '::') { + throw new Error(`[${this.fqdn}] Unspecified ip address (${entry}) cannot be used to restrict on to!`) } - const interfaceName = NetworkManager.resolveInterface(entry); + const interfaceName = NetworkManager.resolveInterface(entry) if (!interfaceName) { - throw new Error(`[${this.fqdn}] Could not restrict service to address ${entry} as we could not resolve it to an interface name!`); + throw new Error(`[${this.fqdn}] Could not restrict service to address ${entry} as we could not resolve it to an interface name!`) } - const current = this.restrictedAddresses.get(interfaceName); + const current = this.restrictedAddresses.get(interfaceName) if (current) { // empty interface signals "catch all" was already configured for this if (current.length && !current.includes(entry)) { - current.push(entry); + current.push(entry) } } else { - this.restrictedAddresses.set(interfaceName, [entry]); + this.restrictedAddresses.set(interfaceName, [entry]) } } else { - this.restrictedAddresses.set(entry, []); // empty array signals "use all addresses for interface" + this.restrictedAddresses.set(entry, []) // empty array signals "use all addresses for interface" } } } - this.disableIpv6 = options.disabledIpv6; + this.disableIpv6 = options.disabledIpv6 - this.txt = options.txt? CiaoService.txtBuffersFromRecord(options.txt): []; + this.txt = options.txt ? CiaoService.txtBuffersFromRecord(options.txt) : [] // checks if hostname or name are already numbered and adjusts the numbers if necessary - this.incrementName(true); + this.incrementName(true) } /** @@ -411,15 +375,15 @@ export class CiaoService extends EventEmitter { * - One of the announcement packets could not be sent successfully */ public advertise(): Promise { - assert(!this.destroyed, "Cannot publish destroyed service!"); - assert(this.port, "Service port must be defined before advertising the service on the network!"); + assert(!this.destroyed, 'Cannot publish destroyed service!') + assert(this.port, 'Service port must be defined before advertising the service on the network!') if (this.listeners(ServiceEvent.NAME_CHANGED).length === 0) { - debug("[%s] WARN: No listeners found for a potential name change on the 'name-change' event!", this.name); + debug('[%s] WARN: No listeners found for a potential name change on the \'name-change\' event!', this.name) } return new Promise((resolve, reject) => { - this.emit(InternalServiceEvent.PUBLISH, error => error? reject(error): resolve()); - }); + this.emit(InternalServiceEvent.PUBLISH, error => error ? reject(error) : resolve()) + }) } /** @@ -429,14 +393,14 @@ export class CiaoService extends EventEmitter { * @returns Promise will resolve once the last goodbye packet was sent out */ public end(): Promise { - assert(!this.destroyed, "Cannot end destroyed service!"); + assert(!this.destroyed, 'Cannot end destroyed service!') if (this.serviceState === ServiceState.UNANNOUNCED) { - return Promise.resolve(); + return Promise.resolve() } return new Promise((resolve, reject) => { - this.emit(InternalServiceEvent.UNPUBLISH, error => error? reject(error): resolve()); - }); + this.emit(InternalServiceEvent.UNPUBLISH, error => error ? reject(error) : resolve()) + }) } /** @@ -446,41 +410,41 @@ export class CiaoService extends EventEmitter { * If the service is still announced, the service will first be removed * from the network by calling {@link end}. * - * @returns + * @returns Promise - Resolves once the service is fully destroyed. */ public async destroy(): Promise { - await this.end(); + await this.end() - this.destroyed = true; - this.removeAllListeners(); + this.destroyed = true + this.removeAllListeners() } /** * @returns The fully qualified domain name of the service, used to identify the service. */ public getFQDN(): string { - return this.fqdn; + return this.fqdn } /** * @returns The service type pointer. */ public getTypePTR(): string { - return this.typePTR; + return this.typePTR } /** * @returns Array of subtype pointers (undefined if no subtypes are specified). */ public getLowerCasedSubtypePTRs(): string[] | undefined { - return this.subTypePTRs; + return this.subTypePTRs } /** * @returns The current hostname of the service. */ public getHostname(): string { - return this.hostname; + return this.hostname } /** @@ -488,36 +452,36 @@ export class CiaoService extends EventEmitter { * {@code -1} is returned when the port is not yet set. */ public getPort(): number { - return this.port || -1; + return this.port || -1 } /** * @returns The current TXT of the service represented as Buffer array. - * @private There is not need for this to be public API + * @private */ public getTXT(): Buffer[] { - return this.txt; + return this.txt } /** - * @private used for internal comparison {@link dnsLowerCase} + * @private */ public getLowerCasedFQDN(): string { - return this.loweredFqdn; + return this.loweredFqdn } /** - * @private used for internal comparison {@link dnsLowerCase} + * @private */ public getLowerCasedTypePTR(): string { - return this.loweredTypePTR; + return this.loweredTypePTR } /** - * @private used for internal comparison {@link dnsLowerCase} + * @private */ public getLowerCasedHostname(): string { - return this.loweredHostname; + return this.loweredHostname } /** @@ -527,55 +491,55 @@ export class CiaoService extends EventEmitter { * @param {boolean} silent - If set to true no announcement is sent for the updated record. */ public updateTxt(txt: ServiceTxt, silent: boolean = false): void { - assert(!this.destroyed, "Cannot update destroyed service!"); - assert(txt, "txt cannot be undefined"); + assert(!this.destroyed, 'Cannot update destroyed service!') + assert(txt, 'txt cannot be undefined') - this.txt = CiaoService.txtBuffersFromRecord(txt); - debug("[%s] Updating txt record%s...", this.name, silent? " silently": ""); + this.txt = CiaoService.txtBuffersFromRecord(txt) + debug('[%s] Updating txt record%s...', this.name, silent ? ' silently' : '') if (this.serviceState === ServiceState.ANNOUNCING) { - this.rebuildServiceRecords(); + this.rebuildServiceRecords() if (silent) { - return; + return } if (this.currentAnnouncer!.hasSentLastAnnouncement()) { // if the announcer hasn't sent the last announcement, the above call of rebuildServiceRecords will // result in updated records on the next announcement. Otherwise, we still need to announce the updated records this.currentAnnouncer!.awaitAnnouncement().then(() => { - this.queueTxtUpdate(); - }); + this.queueTxtUpdate() + }) } } else if (this.serviceState === ServiceState.ANNOUNCED) { - this.rebuildServiceRecords(); + this.rebuildServiceRecords() if (silent) { - return; + return } - this.queueTxtUpdate(); + this.queueTxtUpdate() } } private queueTxtUpdate(): void { if (this.txtTimer) { - return; + // nothing } else { // we debounce txt updates, otherwise if api users would spam txt updates, we would receive the txt record // while we already update our txt to the next call, thus causing a conflict being detected. // We would then continue with Probing (to ensure uniqueness) and could then receive following spammed updates as conflicts, // and we would change our name without it being necessary this.txtTimer = setTimeout(() => { - this.txtTimer = undefined; + this.txtTimer = undefined if (this.serviceState !== ServiceState.ANNOUNCED) { // stuff changed in the last 50 milliseconds - return; + return } this.emit(InternalServiceEvent.RECORD_UPDATE, { - answers: [ this.txtRecord() ], - additionals: [ this.serviceNSECRecord() ], - }); - }, 50); + answers: [this.txtRecord()], + additionals: [this.serviceNSECRecord()], + }) + }, 50) } } @@ -587,43 +551,43 @@ export class CiaoService extends EventEmitter { * @param {number} port - The new port number. */ public updatePort(port: number): void { - assert(this.serviceState === ServiceState.UNANNOUNCED, "Port number cannot be changed when service is already advertised!"); - this.port = port; + assert(this.serviceState === ServiceState.UNANNOUNCED, 'Port number cannot be changed when service is already advertised!') + this.port = port } /** * This method updates the name of the service. * @param name - The new service name. - * @private Currently not public API and only used for bonjour conformance testing. + * @private */ public updateName(name: string): Promise { if (this.serviceState === ServiceState.UNANNOUNCED) { - this.name = name; - this.fqdn = this.formatFQDN(); - this.loweredFqdn = dnsLowerCase(this.fqdn); - return Promise.resolve(); + this.name = name + this.fqdn = this.formatFQDN() + this.loweredFqdn = dnsLowerCase(this.fqdn) + return Promise.resolve() } else { return this.end() // send goodbye packets for the current name .then(() => { - this.name = name; - this.fqdn = this.formatFQDN(); - this.loweredFqdn = dnsLowerCase(this.fqdn); + this.name = name + this.fqdn = this.formatFQDN() + this.loweredFqdn = dnsLowerCase(this.fqdn) // service records are going to be rebuilt on the 'advertise' step - return this.advertise(); - }); + return this.advertise() + }) } } private static txtBuffersFromRecord(txt: ServiceTxt): Buffer[] { - const result: Buffer[] = []; + const result: Buffer[] = [] Object.entries(txt).forEach(([key, value]) => { - const entry = key + "=" + value; - result.push(Buffer.from(entry)); - }); + const entry = `${key}=${value}` + result.push(Buffer.from(entry)) + }) - return result; + return result } /** @@ -631,28 +595,28 @@ export class CiaoService extends EventEmitter { * @private */ handleNetworkInterfaceUpdate(networkUpdate: NetworkUpdate): void { - assert(!this.destroyed, "Cannot update network of destroyed service!"); + assert(!this.destroyed, 'Cannot update network of destroyed service!') // this will currently only be called when service is ANNOUNCED or in ANNOUNCING state if (this.serviceState !== ServiceState.ANNOUNCED) { if (this.serviceState === ServiceState.ANNOUNCING) { - this.rebuildServiceRecords(); + this.rebuildServiceRecords() if (this.currentAnnouncer!.hasSentLastAnnouncement()) { // if the announcer hasn't sent the last announcement, the above call of rebuildServiceRecords will // result in updated records on the next announcement. Otherwise, we still need to announce the updated records this.currentAnnouncer!.awaitAnnouncement().then(() => { - this.handleNetworkInterfaceUpdate(networkUpdate); - }); + this.handleNetworkInterfaceUpdate(networkUpdate) + }) } } - return; // service records are rebuilt short before the 'announce' step + return // service records are rebuilt short before the 'announce' step } // we don't care about removed interfaces. We can't send goodbye records on a non-existing interface - this.rebuildServiceRecords(); + this.rebuildServiceRecords() // records for a removed interface are now no longer present after the call above // records for a new interface got now built by the call above @@ -718,15 +682,18 @@ export class CiaoService extends EventEmitter { // at this moment the new socket won't be bound. Though probing steps are delayed, // thus, when sending the first request, the socket will be bound, and we don't need to wait here - this.emit(InternalServiceEvent.REPUBLISH, error => { + this.emit(InternalServiceEvent.REPUBLISH, (error) => { if (error) { - console.log("FATAL Error occurred trying to re-announce service " + this.fqdn + "! We can't recover from this!"); - console.log(error.stack); - process.exit(1); // we have a service which should be announced, though we failed to re-announce. + // eslint-disable-next-line no-console + console.log(`FATAL Error occurred trying to re-announce service ${this.fqdn}! We can't recover from this!`) + + // eslint-disable-next-line no-console + console.log(error.stack) + process.exit(1) // we have a service which should be announced, though we failed to re-announce. // if this should ever happen in reality, whe might want to introduce a more sophisticated recovery // for situations where it makes sense } - }); + }) } } @@ -734,98 +701,97 @@ export class CiaoService extends EventEmitter { * This method is called by the Prober when encountering a conflict on the network. * It advises the service to change its name, like incrementing a number appended to the name. * So "My Service" will become "My Service (2)", and "My Service (2)" would become "My Service (3)" - * @private must only be called by the {@link Prober} + * @private */ incrementName(nameCheckOnly?: boolean): void { if (this.serviceState !== ServiceState.UNANNOUNCED) { - throw new Error("Service name can only be incremented when in state UNANNOUNCED!"); + throw new Error('Service name can only be incremented when in state UNANNOUNCED!') } - const oldName = this.name; - const oldHostname = this.hostname; + const oldName = this.name + const oldHostname = this.hostname - let nameBase; - let nameNumber; + let nameBase + let nameNumber - let hostnameBase; - let hostnameTLD; - let hostnameNumber; + let hostnameBase + let hostnameTLD + let hostnameNumber - const nameMatcher = this.name.match(numberedServiceNamePattern); + const nameMatcher = this.name.match(numberedServiceNamePattern) if (nameMatcher) { // if it matched. Extract the current nameNumber - nameBase = nameMatcher[1]; - nameNumber = parseInt(nameMatcher[2]); + nameBase = nameMatcher[1] + nameNumber = Number.parseInt(nameMatcher[2]) - assert(nameNumber, `Failed to extract name number from ${this.name}. Resulted in ${nameNumber}`); + assert(nameNumber, `Failed to extract name number from ${this.name}. Resulted in ${nameNumber}`) } else { - nameBase = this.name; - nameNumber = 1; + nameBase = this.name + nameNumber = 1 } - const hostnameMatcher = this.hostname.match(numberedHostnamePattern); + const hostnameMatcher = this.hostname.match(numberedHostnamePattern) if (hostnameMatcher) { // if it matched. Extract the current nameNumber - hostnameBase = hostnameMatcher[1]; - hostnameTLD = hostnameMatcher[3]; - hostnameNumber = parseInt(hostnameMatcher[2]); + hostnameBase = hostnameMatcher[1] + hostnameTLD = hostnameMatcher[3] + hostnameNumber = Number.parseInt(hostnameMatcher[2]) - assert(hostnameNumber, `Failed to extract hostname number from ${this.hostname}. Resulted in ${hostnameNumber}`); + assert(hostnameNumber, `Failed to extract hostname number from ${this.hostname}. Resulted in ${hostnameNumber}`) } else { // we need to substring, to not match the root label "." - const lastDot = this.hostname.substring(0, this.hostname.length - 1).lastIndexOf("."); + const lastDot = this.hostname.substring(0, this.hostname.length - 1).lastIndexOf('.') - hostnameBase = this.hostname.slice(0, lastDot); - hostnameTLD = this.hostname.slice(lastDot); - hostnameNumber = 1; + hostnameBase = this.hostname.slice(0, lastDot) + hostnameTLD = this.hostname.slice(lastDot) + hostnameNumber = 1 } if (!nameCheckOnly) { // increment the numbers - nameNumber++; - hostnameNumber++; + nameNumber++ + hostnameNumber++ } - const newNumber = Math.max(nameNumber, hostnameNumber); + const newNumber = Math.max(nameNumber, hostnameNumber) // reassemble the name - this.name = newNumber === 1? nameBase: `${nameBase} (${newNumber})`; - this.hostname = newNumber === 1? `${hostnameBase}${hostnameTLD}`: `${hostnameBase}-(${newNumber})${hostnameTLD}`; - this.loweredHostname = dnsLowerCase(this.hostname); + this.name = newNumber === 1 ? nameBase : `${nameBase} (${newNumber})` + this.hostname = newNumber === 1 ? `${hostnameBase}${hostnameTLD}` : `${hostnameBase}-(${newNumber})${hostnameTLD}` + this.loweredHostname = dnsLowerCase(this.hostname) - this.fqdn = this.formatFQDN(); // update the fqdn - this.loweredFqdn = dnsLowerCase(this.fqdn); + this.fqdn = this.formatFQDN() // update the fqdn + this.loweredFqdn = dnsLowerCase(this.fqdn) // we must inform the user that the names changed, so the new names can be persisted // This is done after the Probing finish, as multiple name changes could happen in one probing session // It is the responsibility of the Prober to call the informAboutNameUpdates function if (this.name !== oldName || this.hostname !== oldHostname) { - debug("[%s] Service changed name '%s' -> '%s', '%s' -> '%s'", this.name, oldName, this.name, oldHostname, this.hostname); + debug('[%s] Service changed name \'%s\' -> \'%s\', \'%s\' -> \'%s\'', this.name, oldName, this.name, oldHostname, this.hostname) } if (!nameCheckOnly) { - this.rebuildServiceRecords(); // rebuild all services + this.rebuildServiceRecords() // rebuild all services } } /** - * @private called by the Prober once finished with probing to signal a (or more) - * name change(s) happened {@see incrementName}. + * @private */ informAboutNameUpdates(): void { // we trust the prober that this function is only called when the name was actually changed - const nameCalled = this.emit(ServiceEvent.NAME_CHANGED, this.name); - const hostnameCalled = this.emit(ServiceEvent.HOSTNAME_CHANGED, domainFormatter.removeTLD(this.hostname)); + const nameCalled = this.emit(ServiceEvent.NAME_CHANGED, this.name) + const hostnameCalled = this.emit(ServiceEvent.HOSTNAME_CHANGED, domainFormatter.removeTLD(this.hostname)) // at least one event should be listened to. We can figure out the number from one or another if (!nameCalled && !hostnameCalled) { - console.warn(`CIAO: [${this.name}] Service changed name but nobody was listening on the 'name-change' event!`); + console.warn(`CIAO: [${this.name}] Service changed name but nobody was listening on the 'name-change' event!`) } } private formatFQDN(): string { if (this.serviceState !== ServiceState.UNANNOUNCED) { - throw new Error("Name can't be changed after service was already announced!"); + throw new Error('Name can\'t be changed after service was already announced!') } const fqdn = domainFormatter.stringify({ @@ -833,67 +799,67 @@ export class CiaoService extends EventEmitter { type: this.type, protocol: this.protocol, domain: this.serviceDomain, - }); + }) - assert(fqdn.length <= 255, "A fully qualified domain name cannot be longer than 255 characters"); - return fqdn; + assert(fqdn.length <= 255, 'A fully qualified domain name cannot be longer than 255 characters') + return fqdn } /** - * @private called once the service data/state is updated and the records should be updated with the new data + * @private */ rebuildServiceRecords(): void { - assert(this.port, "port must be set before building records"); - debug("[%s] Rebuilding service records...", this.name); + assert(this.port, 'port must be set before building records') + debug('[%s] Rebuilding service records...', this.name) - const aRecordMap: Record = {}; - const aaaaRecordMap: Record = {}; - const aaaaRoutableRecordMap: Record = {}; - const aaaaUniqueLocalRecordMap: Record = {}; - const reverseAddressMap: Record = {}; - let subtypePTRs: PTRRecord[] | undefined = undefined; + const aRecordMap: Record = {} + const aaaaRecordMap: Record = {} + const aaaaRoutableRecordMap: Record = {} + const aaaaUniqueLocalRecordMap: Record = {} + const reverseAddressMap: Record = {} + let subtypePTRs: PTRRecord[] | undefined for (const [name, networkInterface] of this.networkManager.getInterfaceMap()) { if (!this.advertisesOnInterface(name, true)) { - continue; + continue } - let restrictedAddresses: IPAddress[] | undefined = this.restrictedAddresses? this.restrictedAddresses.get(name): undefined; + let restrictedAddresses: IPAddress[] | undefined = this.restrictedAddresses ? this.restrictedAddresses.get(name) : undefined if (restrictedAddresses && restrictedAddresses.length === 0) { - restrictedAddresses = undefined; + restrictedAddresses = undefined } if (networkInterface.ipv4 && (!restrictedAddresses || restrictedAddresses.includes(networkInterface.ipv4))) { - aRecordMap[name] = new ARecord(this.hostname, networkInterface.ipv4, true); - reverseAddressMap[networkInterface.ipv4] = new PTRRecord(formatReverseAddressPTRName(networkInterface.ipv4), this.hostname); + aRecordMap[name] = new ARecord(this.hostname, networkInterface.ipv4, true) + reverseAddressMap[networkInterface.ipv4] = new PTRRecord(formatReverseAddressPTRName(networkInterface.ipv4), this.hostname) } if (networkInterface.ipv6 && !this.disableIpv6 && (!restrictedAddresses || restrictedAddresses.includes(networkInterface.ipv6))) { - aaaaRecordMap[name] = new AAAARecord(this.hostname, networkInterface.ipv6, true); - reverseAddressMap[networkInterface.ipv6] = new PTRRecord(formatReverseAddressPTRName(networkInterface.ipv6), this.hostname); + aaaaRecordMap[name] = new AAAARecord(this.hostname, networkInterface.ipv6, true) + reverseAddressMap[networkInterface.ipv6] = new PTRRecord(formatReverseAddressPTRName(networkInterface.ipv6), this.hostname) } if (networkInterface.globallyRoutableIpv6 && !this.disableIpv6 && (!restrictedAddresses || restrictedAddresses.includes(networkInterface.globallyRoutableIpv6))) { - aaaaRoutableRecordMap[name] = new AAAARecord(this.hostname, networkInterface.globallyRoutableIpv6, true); - reverseAddressMap[networkInterface.globallyRoutableIpv6] = new PTRRecord(formatReverseAddressPTRName(networkInterface.globallyRoutableIpv6), this.hostname); + aaaaRoutableRecordMap[name] = new AAAARecord(this.hostname, networkInterface.globallyRoutableIpv6, true) + reverseAddressMap[networkInterface.globallyRoutableIpv6] = new PTRRecord(formatReverseAddressPTRName(networkInterface.globallyRoutableIpv6), this.hostname) } if (networkInterface.uniqueLocalIpv6 && !this.disableIpv6 && (!restrictedAddresses || restrictedAddresses.includes(networkInterface.uniqueLocalIpv6))) { - aaaaUniqueLocalRecordMap[name] = new AAAARecord(this.hostname, networkInterface.uniqueLocalIpv6, true); - reverseAddressMap[networkInterface.uniqueLocalIpv6] = new PTRRecord(formatReverseAddressPTRName(networkInterface.uniqueLocalIpv6), this.hostname); + aaaaUniqueLocalRecordMap[name] = new AAAARecord(this.hostname, networkInterface.uniqueLocalIpv6, true) + reverseAddressMap[networkInterface.uniqueLocalIpv6] = new PTRRecord(formatReverseAddressPTRName(networkInterface.uniqueLocalIpv6), this.hostname) } } if (this.subTypePTRs) { - subtypePTRs = []; + subtypePTRs = [] for (const ptr of this.subTypePTRs) { - subtypePTRs.push(new PTRRecord(ptr, this.fqdn)); + subtypePTRs.push(new PTRRecord(ptr, this.fqdn)) } } this.serviceRecords = { ptr: new PTRRecord(this.typePTR, this.fqdn), - subtypePTRs: subtypePTRs, // possibly undefined + subtypePTRs, // possibly undefined metaQueryPtr: new PTRRecord(Responder.SERVICE_TYPE_ENUMERATION_NAME, this.typePTR), srv: new SRVRecord(this.fqdn, this.hostname, this.port!, true), @@ -906,7 +872,7 @@ export class CiaoService extends EventEmitter { aaaaULA: aaaaUniqueLocalRecordMap, reverseAddressPTRs: reverseAddressMap, addressNSEC: new NSECRecord(this.hostname, this.hostname, [RType.A, RType.AAAA], 120, true), // 120 TTL of A and AAAA records - }; + } } /** @@ -914,131 +880,131 @@ export class CiaoService extends EventEmitter { * * @param name - The desired interface name. * @param skipAddressCheck - If true it is not checked if the service actually has - * an address record for the given interface. - * @private returns if the service should be advertised on the given service + * an address record for the given interface. + * @private */ advertisesOnInterface(name: InterfaceName, skipAddressCheck?: boolean): boolean { - return !this.restrictedAddresses || this.restrictedAddresses.has(name) && ( - skipAddressCheck || + return !this.restrictedAddresses || (this.restrictedAddresses.has(name) && ( + skipAddressCheck // must have at least one address record on the given interface - !!this.serviceRecords?.a[name] || !!this.serviceRecords?.aaaa[name] + || !!this.serviceRecords?.a[name] || !!this.serviceRecords?.aaaa[name] || !!this.serviceRecords?.aaaaR[name] || !!this.serviceRecords?.aaaaULA[name] - ); + )) } /** - * @private used to get a copy of the main PTR record + * @private */ ptrRecord(): PTRRecord { - return this.serviceRecords!.ptr.clone(); + return this.serviceRecords!.ptr.clone() } /** - * @private used to get a copy of the array of subtype PTR records + * @private */ subtypePtrRecords(): PTRRecord[] { - return this.serviceRecords!.subtypePTRs? ResourceRecord.clone(this.serviceRecords!.subtypePTRs): []; + return this.serviceRecords!.subtypePTRs ? ResourceRecord.clone(this.serviceRecords!.subtypePTRs) : [] } /** - * @private used to get a copy of the meta-query PTR record + * @private */ metaQueryPtrRecord(): PTRRecord { - return this.serviceRecords!.metaQueryPtr.clone(); + return this.serviceRecords!.metaQueryPtr.clone() } /** - * @private used to get a copy of the SRV record + * @private */ srvRecord(): SRVRecord { - return this.serviceRecords!.srv.clone(); + return this.serviceRecords!.srv.clone() } /** - * @private used to get a copy of the TXT record + * @private */ txtRecord(): TXTRecord { - return this.serviceRecords!.txt.clone(); + return this.serviceRecords!.txt.clone() } /** - * @private used to get a copy of the A record + * @private */ aRecord(name: InterfaceName): ARecord | undefined { - const record = this.serviceRecords!.a[name]; - return record? record.clone(): undefined; + const record = this.serviceRecords!.a[name] + return record ? record.clone() : undefined } /** - * @private used to get a copy of the AAAA record for the link-local ipv6 address + * @private */ aaaaRecord(name: InterfaceName): AAAARecord | undefined { - const record = this.serviceRecords!.aaaa[name]; - return record? record.clone(): undefined; + const record = this.serviceRecords!.aaaa[name] + return record ? record.clone() : undefined } /** - * @private used to get a copy of the AAAA record for the routable ipv6 address + * @private */ aaaaRoutableRecord(name: InterfaceName): AAAARecord | undefined { - const record = this.serviceRecords!.aaaaR[name]; - return record? record.clone(): undefined; + const record = this.serviceRecords!.aaaaR[name] + return record ? record.clone() : undefined } /** - * @private used to get a copy of the AAAA for the unique local ipv6 address + * @private */ aaaaUniqueLocalRecord(name: InterfaceName): AAAARecord | undefined { - const record = this.serviceRecords!.aaaaULA[name]; - return record? record.clone(): undefined; + const record = this.serviceRecords!.aaaaULA[name] + return record ? record.clone() : undefined } /** - * @private used to get a copy of the A and AAAA records + * @private */ allAddressRecords(): (ARecord | AAAARecord)[] { - const records: (ARecord | AAAARecord)[] = []; - - Object.values(this.serviceRecords!.a).forEach(record => { - records.push(record.clone()); - }); - Object.values(this.serviceRecords!.aaaa).forEach(record => { - records.push(record.clone()); - }); - Object.values(this.serviceRecords!.aaaaR).forEach(record => { - records.push(record.clone()); - }); - Object.values(this.serviceRecords!.aaaaULA).forEach(record => { - records.push(record.clone()); - }); - - return records; + const records: (ARecord | AAAARecord)[] = [] + + Object.values(this.serviceRecords!.a).forEach((record) => { + records.push(record.clone()) + }) + Object.values(this.serviceRecords!.aaaa).forEach((record) => { + records.push(record.clone()) + }) + Object.values(this.serviceRecords!.aaaaR).forEach((record) => { + records.push(record.clone()) + }) + Object.values(this.serviceRecords!.aaaaULA).forEach((record) => { + records.push(record.clone()) + }) + + return records } /** - * @private used to get a copy of the address NSEC record + * @private */ addressNSECRecord(): NSECRecord { - return this.serviceRecords!.addressNSEC.clone(); + return this.serviceRecords!.addressNSEC.clone() } /** - * @private user to get a copy of the service NSEC record + * @private */ serviceNSECRecord(shortenTTL = false): NSECRecord { - const record = this.serviceRecords!.serviceNSEC.clone(); + const record = this.serviceRecords!.serviceNSEC.clone() if (shortenTTL) { - record.ttl = 120; + record.ttl = 120 } - return record; + return record } /** * @param address - The IP address to check. - * @private used to check if given address is exposed by this service + * @private */ hasAddress(address: IPAddress): boolean { - return !!this.serviceRecords!.reverseAddressPTRs[address]; + return !!this.serviceRecords!.reverseAddressPTRs[address] } /* reverseAddressMapping(address: string): PTRRecord | undefined { @@ -1076,5 +1042,4 @@ export class CiaoService extends EventEmitter { return ResourceRecord.clone(Object.values(this.serviceRecords.reverseAddressPTRs)); } */ - } diff --git a/src/MDNSServer.spec.ts b/src/MDNSServer.spec.ts index 3b8c8ad..681f12d 100644 --- a/src/MDNSServer.spec.ts +++ b/src/MDNSServer.spec.ts @@ -1,31 +1,34 @@ -import { MDNSServer, SendResultFailedRatio } from "./MDNSServer"; +import { describe, expect, it } from 'vitest' + +import './coder/records/index.js' +import { MDNSServer, SendResultFailedRatio } from './MDNSServer.js' describe(MDNSServer, () => { - it("SendResultFailedRatio", () => { + it('sendResultFailedRatio', () => { expect(SendResultFailedRatio([ - { status: "fulfilled", interface: "eth0"}, - { status: "fulfilled", interface: "eth0"}, - { status: "fulfilled", interface: "eth0"}, - { status: "fulfilled", interface: "eth0"}, - { status: "fulfilled", interface: "eth0"}, - ])).toBe(0); + { status: 'fulfilled', interface: 'eth0' }, + { status: 'fulfilled', interface: 'eth0' }, + { status: 'fulfilled', interface: 'eth0' }, + { status: 'fulfilled', interface: 'eth0' }, + { status: 'fulfilled', interface: 'eth0' }, + ])).toBe(0) expect(SendResultFailedRatio([ - { status: "rejected", interface: "eth0", reason: new Error()}, - { status: "fulfilled", interface: "eth0"}, - { status: "rejected", interface: "eth0", reason: new Error()}, - { status: "fulfilled", interface: "eth0"}, - { status: "timeout", interface: "eth0"}, - ])).toBe(0.6); + { status: 'rejected', interface: 'eth0', reason: new Error() }, // eslint-disable-line unicorn/error-message + { status: 'fulfilled', interface: 'eth0' }, + { status: 'rejected', interface: 'eth0', reason: new Error() }, // eslint-disable-line unicorn/error-message + { status: 'fulfilled', interface: 'eth0' }, + { status: 'timeout', interface: 'eth0' }, + ])).toBe(0.6) expect(SendResultFailedRatio([ - { status: "rejected", interface: "eth0", reason: new Error()}, - { status: "rejected", interface: "eth0", reason: new Error()}, - { status: "timeout", interface: "eth0"}, - { status: "rejected", interface: "eth0", reason: new Error()}, - { status: "rejected", interface: "eth0", reason: new Error()}, - ])).toBe(1); + { status: 'rejected', interface: 'eth0', reason: new Error() }, // eslint-disable-line unicorn/error-message + { status: 'rejected', interface: 'eth0', reason: new Error() }, // eslint-disable-line unicorn/error-message + { status: 'timeout', interface: 'eth0' }, + { status: 'rejected', interface: 'eth0', reason: new Error() }, // eslint-disable-line unicorn/error-message + { status: 'rejected', interface: 'eth0', reason: new Error() }, // eslint-disable-line unicorn/error-message + ])).toBe(1) - expect(SendResultFailedRatio([])).toBe(0); - }); -}); + expect(SendResultFailedRatio([])).toBe(0) + }) +}) diff --git a/src/MDNSServer.ts b/src/MDNSServer.ts index 4c1ad9e..c8eacd0 100644 --- a/src/MDNSServer.ts +++ b/src/MDNSServer.ts @@ -1,57 +1,50 @@ -import assert from "assert"; -import createDebug from "debug"; -import dgram, { Socket } from "dgram"; -import { AddressInfo } from "net"; -import { CiaoService } from "./CiaoService"; -import { - DNSPacket, - DNSProbeQueryDefinition, - DNSQueryDefinition, - DNSResponseDefinition, - OpCode, - PacketType, - RCode, -} from "./coder/DNSPacket"; -import { - InterfaceName, - IPFamily, - NetworkInterface, - NetworkManager, - NetworkManagerEvent, - NetworkUpdate, -} from "./NetworkManager"; -import { getNetAddress } from "./util/domain-formatter"; -import { InterfaceNotFoundError, ServerClosedError } from "./util/errors"; -import { PromiseTimeout } from "./util/promise-utils"; - -const debug = createDebug("ciao:MDNSServer"); +import type { Buffer } from 'node:buffer' +import type { Socket } from 'node:dgram' +import type { AddressInfo } from 'node:net' + +import type { CiaoService } from './CiaoService' +import type { DNSProbeQueryDefinition, DNSQueryDefinition, DNSResponseDefinition } from './coder/DNSPacket' +import type { InterfaceName, NetworkInterface, NetworkUpdate } from './NetworkManager' + +import assert from 'node:assert' +import dgram from 'node:dgram' + +import createDebug from 'debug' + +import { DNSPacket, OpCode, PacketType, RCode } from './coder/DNSPacket.js' +import { IPFamily, NetworkManager, NetworkManagerEvent } from './NetworkManager.js' +import { getNetAddress } from './util/domain-formatter.js' +import { InterfaceNotFoundError, ServerClosedError } from './util/errors.js' +import { PromiseTimeout } from './util/promise-utils.js' + +const debug = createDebug('ciao:MDNSServer') export interface EndpointInfo { - address: string; - port: number; - interface: string; + address: string + port: number + interface: string } export interface SendFulfilledResult { - status: "fulfilled", - interface: InterfaceName, + status: 'fulfilled' + interface: InterfaceName } export interface SendRejectedResult { - status: "rejected", - interface: InterfaceName, - reason: Error, + status: 'rejected' + interface: InterfaceName + reason: Error } export interface SendTimeoutResult { - status: "timeout", - interface: InterfaceName, + status: 'timeout' + interface: InterfaceName } -export type SendResult = SendFulfilledResult | SendRejectedResult; -export type TimedSendResult = SendFulfilledResult | SendRejectedResult | SendTimeoutResult; +export type SendResult = SendFulfilledResult | SendRejectedResult +export type TimedSendResult = SendFulfilledResult | SendRejectedResult | SendTimeoutResult -export type SendCallback = (error?: Error | null) => void; +export type SendCallback = (error?: Error | null) => void /** * Returns the ration of rejected SendResults in the array. @@ -63,66 +56,66 @@ export type SendCallback = (error?: Error | null) => void; */ export function SendResultFailedRatio(results: SendResult[] | TimedSendResult[]): number { if (results.length === 0) { - return 0; + return 0 } - let failedCount = 0; + let failedCount = 0 for (const result of results) { - if (result.status !== "fulfilled") { - failedCount++; + if (result.status !== 'fulfilled') { + failedCount++ } } - return failedCount / results.length; + return failedCount / results.length } -export function SendResultFormatError(results: SendResult[] | TimedSendResult[], prefix?: string, includeStack = false): string { - let failedCount = 0; +export function sendResultFormatError(results: SendResult[] | TimedSendResult[], prefix?: string, includeStack = false): string { + let failedCount = 0 for (const result of results) { - if (result.status !== "fulfilled") { - failedCount++; + if (result.status !== 'fulfilled') { + failedCount++ } } if (!prefix) { - prefix = "Failed to send packets"; + prefix = 'Failed to send packets' } if (failedCount < results.length) { - prefix += ` (${failedCount}/${results.length}):`; + prefix += ` (${failedCount}/${results.length}):` } else { - prefix += ":"; + prefix += ':' } if (includeStack) { - let string = "=============================\n" + prefix; + let string = `=============================\n${prefix}` for (const result of results) { - if (result.status === "rejected") { - string += "\n--------------------\n" + - "Failed to send packet on interface " + result.interface + ": " + result.reason.stack; - } else if (result.status === "timeout") { - string += "\n--------------------\n" + - "Sending packet on interface " + result.interface + " timed out!"; + if (result.status === 'rejected') { + string += `\n--------------------\n` + + `Failed to send packet on interface ${result.interface}: ${result.reason.stack}` + } else if (result.status === 'timeout') { + string += `\n--------------------\n` + + `Sending packet on interface ${result.interface} timed out!` } } - string += "\n============================="; + string += '\n=============================' - return string; + return string } else { - let string = prefix; + let string = prefix for (const result of results) { - if (result.status === "rejected") { - string += "\n- Failed to send packet on interface " + result.interface + ": " + result.reason.message; - } else if (result.status === "timeout") { - string += "\n- Sending packet on interface " + result.interface + " timed out!"; + if (result.status === 'rejected') { + string += `\n- Failed to send packet on interface ${result.interface}: ${result.reason.message}` + } else if (result.status === 'timeout') { + string += `\n- Sending packet on interface ${result.interface} timed out!` } } - return string; + return string } } @@ -137,33 +130,33 @@ export interface MDNSServerOptions { * The interface can be defined by specifying the interface name (like 'en0') or * by specifying an ip address. */ - interface?: string | string[]; + interface?: string | string[] /** * If specified, the mdns server will not include any IPv6 (AAAA) address records. * This option does not affect advertising on IPv6. Defaults to false. */ - disableIpv6?: boolean; + disableIpv6?: boolean /** * Do not advertise on IPv6-only networks. Defaults to false. */ - excludeIpv6Only?: boolean; + excludeIpv6Only?: boolean /** * If specified, the mDNS server will advertise on IPv4. * Defaults to true. */ - advertiseIpv4?: boolean; + advertiseIpv4?: boolean /** * If specified, the mDNS server will advertise on IPv6. * Defaults to true. */ - advertiseIpv6?: boolean; + advertiseIpv6?: boolean } export interface PacketHandler { - handleQuery(packet: DNSPacket, rinfo: EndpointInfo): void; + handleQuery: (packet: DNSPacket, rinfo: EndpointInfo) => void - handleResponse(packet: DNSPacket, rinfo: EndpointInfo): void; + handleResponse: (packet: DNSPacket, rinfo: EndpointInfo) => void } @@ -173,387 +166,387 @@ export interface PacketHandler { * Currently only udp4 sockets will be advertised. */ export class MDNSServer { + public static readonly DEFAULT_IP4_HEADER = 20 + public static readonly DEFAULT_IP6_HEADER = 40 + public static readonly UDP_HEADER = 8 - public static readonly DEFAULT_IP4_HEADER = 20; - public static readonly DEFAULT_IP6_HEADER = 40; - public static readonly UDP_HEADER = 8; - - public static readonly MDNS_PORT = 5353; - public static readonly MDNS_TTL = 255; - public static readonly MULTICAST_IPV4 = "224.0.0.251"; - public static readonly MULTICAST_IPV6 = "FF02::FB"; + public static readonly MDNS_PORT = 5353 + public static readonly MDNS_TTL = 255 + public static readonly MULTICAST_IPV4 = '224.0.0.251' + public static readonly MULTICAST_IPV6 = 'FF02::FB' - public static readonly SEND_TIMEOUT = 200; // milliseconds + public static readonly SEND_TIMEOUT = 200 // milliseconds - private readonly handler: PacketHandler; - private readonly networkManager: NetworkManager; + private readonly handler: PacketHandler + private readonly networkManager: NetworkManager - private readonly sockets: Map = new Map(); - private readonly sentPackets: Map = new Map(); + private readonly sockets: Map = new Map() + private readonly sentPackets: Map = new Map() // RFC 6762 15.1. If we are not the first responder bound to 5353 we can't receive unicast responses // thus the QU flag must not be used in queries. Responders are only affected when sending probe queries. // Probe queries should be sent with QU set, though can't be sent with QU when we can't receive unicast responses. - private suppressUnicastResponseFlag = false; + private suppressUnicastResponseFlag = false - private bound = false; - private closed = false; + private bound = false + private closed = false - private advertiseFamilies: Array = []; + private advertiseFamilies: Array = [] constructor(handler: PacketHandler, options?: MDNSServerOptions) { - assert(handler, "handler cannot be undefined"); - this.handler = handler; + assert(handler, 'handler cannot be undefined') + this.handler = handler this.networkManager = new NetworkManager({ interface: options && options.interface, excludeIpv6: options && options.disableIpv6, excludeIpv6Only: options && options.excludeIpv6Only, - }); + }) if (!(options && options.advertiseIpv4 === false)) { // IPv4 advertisements default to on - this.advertiseFamilies.push(IPFamily.IPv4); + this.advertiseFamilies.push(IPFamily.IPv4) } if (options && options.advertiseIpv6) { // IPv6 advertisements default to off - this.advertiseFamilies.push(IPFamily.IPv6); + this.advertiseFamilies.push(IPFamily.IPv6) } - this.networkManager.on(NetworkManagerEvent.NETWORK_UPDATE, this.handleUpdatedNetworkInterfaces.bind(this)); + this.networkManager.on(NetworkManagerEvent.NETWORK_UPDATE, this.handleUpdatedNetworkInterfaces.bind(this)) } public getNetworkManager(): NetworkManager { - return this.networkManager; + return this.networkManager } public getBoundInterfaceNames(): IterableIterator { - return this.sockets.keys(); + return this.sockets.keys() } public async bind(): Promise { if (this.closed) { - throw new Error("Cannot rebind closed server!"); + throw new Error('Cannot rebind closed server!') } // RFC 6762 15.1. suggest that we probe if we are not the only socket. // though as ciao will probably always be installed besides an existing mdns responder, we just assume that without probing // As it only affects probe queries, impact isn't that big. - this.suppressUnicastResponseFlag = true; + this.suppressUnicastResponseFlag = true // wait for the first network interfaces to be discovered - await this.networkManager.waitForInit(); + await this.networkManager.waitForInit() - const promises: Promise[] = []; + const promises: Promise[] = [] for (const [name, networkInterface] of this.networkManager.getInterfaceMap()) { this.advertiseFamilies.forEach((family: IPFamily) => { - const socket = this.createDgramSocket(name, true, family === IPFamily.IPv6 ? "udp6" : "udp4"); + const socket = this.createDgramSocket(name, true, family === IPFamily.IPv6 ? 'udp6' : 'udp4') const promise = this.bindSocket(socket, networkInterface, family) - .catch(reason => { + .catch((reason) => { // TODO if bind errors we probably will never bind again - console.log("Could not bind detected network interface: " + reason.stack); - }); - promises.push(promise); - }); + // eslint-disable-next-line no-console + console.log(`Could not bind detected network interface: ${reason.stack}`) + }) + promises.push(promise) + }) } return Promise.all(promises).then(() => { - this.bound = true; + this.bound = true // map void[] to void - }); + }) } public shutdown(): void { - this.networkManager.shutdown(); + this.networkManager.shutdown() for (const socket of this.sockets.values()) { - socket.close(); + socket.close() } - this.bound = false; - this.closed = true; + this.bound = false + this.closed = true - this.sockets.clear(); + this.sockets.clear() } public sendQueryBroadcast(query: DNSQueryDefinition | DNSProbeQueryDefinition, service: CiaoService): Promise { - const packets = DNSPacket.createDNSQueryPackets(query); + const packets = DNSPacket.createDNSQueryPackets(query) if (packets.length > 1) { - debug("Query broadcast is split into %d packets!", packets.length); + debug('Query broadcast is split into %d packets!', packets.length) } - const promises: Promise[] = []; + const promises: Promise[] = [] for (const packet of packets) { - promises.push(this.sendOnAllNetworksForService(packet, service)); + promises.push(this.sendOnAllNetworksForService(packet, service)) } return Promise.all(promises).then((values: TimedSendResult[][]) => { - const results: TimedSendResult[] = []; + const results: TimedSendResult[] = [] for (const value of values) { // replace with .flat method when we have node >= 11.0.0 requirement - results.concat(value); + results.concat(value) } - return results; - }); + return results + }) } public sendResponseBroadcast(response: DNSResponseDefinition, service: CiaoService): Promise { - const packet = DNSPacket.createDNSResponsePacketsFromRRSet(response); - return this.sendOnAllNetworksForService(packet, service); + const packet = DNSPacket.createDNSResponsePacketsFromRRSet(response) + return this.sendOnAllNetworksForService(packet, service) } - public sendResponse(response: DNSPacket, endpoint: EndpointInfo, callback?: SendCallback): void; - public sendResponse(response: DNSPacket, interfaceName: InterfaceName, callback?: SendCallback): void; + public sendResponse(response: DNSPacket, endpoint: EndpointInfo, callback?: SendCallback): void + public sendResponse(response: DNSPacket, interfaceName: InterfaceName, callback?: SendCallback): void public sendResponse(response: DNSPacket, endpointOrInterface: EndpointInfo | InterfaceName, callback?: SendCallback): void { - this.send(response, endpointOrInterface).then(result => { - if (result.status === "rejected") { + this.send(response, endpointOrInterface).then((result) => { + if (result.status === 'rejected') { if (callback) { - callback(new Error("Encountered socket error on " + result.reason.name + ": " + result.reason.message)); + callback(new Error(`Encountered socket error on ${result.reason.name}: ${result.reason.message}`)) } else { - MDNSServer.logSocketError(result.interface, result.reason); + MDNSServer.logSocketError(result.interface, result.reason) } } else if (callback) { - callback(); + callback() } - }); + }) } private sendOnAllNetworksForService(packet: DNSPacket, service: CiaoService): Promise { - this.checkUnicastResponseFlag(packet); + this.checkUnicastResponseFlag(packet) - const message = packet.encode(); - this.assertBeforeSend(message, IPFamily.IPv4); + const message = packet.encode() + this.assertBeforeSend(message, IPFamily.IPv4) - const promises: Promise[] = []; + const promises: Promise[] = [] for (const [name, socket] of this.sockets) { if (!service.advertisesOnInterface(name)) { // I don't like the fact that we put the check inside the MDNSServer, as it should be independent of the above layer. // Though I think this is currently the easiest approach. - continue; + continue } - const isIPv6 = name.endsWith("/6"); + const isIPv6 = name.endsWith('/6') - const promise = new Promise(resolve => { - socket.send(message, MDNSServer.MDNS_PORT, isIPv6 ? MDNSServer.MULTICAST_IPV6 : MDNSServer.MULTICAST_IPV4, error => { + const promise = new Promise((resolve) => { + socket.send(message, MDNSServer.MDNS_PORT, isIPv6 ? MDNSServer.MULTICAST_IPV6 : MDNSServer.MULTICAST_IPV4, (error) => { if (error) { if (!MDNSServer.isSilencedSocketError(error)) { resolve({ - status: "rejected", + status: 'rejected', interface: name, reason: error, - }); - return; + }) + return } } else { - this.maintainSentPacketsInterface(name, message); + this.maintainSentPacketsInterface(name, message) } resolve({ - status: "fulfilled", + status: 'fulfilled', interface: name, - }); - }); - }); + }) + }) + }) promises.push(Promise.race([ promise, PromiseTimeout(MDNSServer.SEND_TIMEOUT).then(() => { - status: "timeout", + status: 'timeout', interface: name, }), - ])); + ])) } - return Promise.all(promises); + return Promise.all(promises) } public send(packet: DNSPacket, endpointOrInterface: EndpointInfo | InterfaceName): Promise { - this.checkUnicastResponseFlag(packet); + this.checkUnicastResponseFlag(packet) - const message = packet.encode(); + const message = packet.encode() - let address: string; - let port: number; - let name: string; + let address: string + let port: number + let name: string - let isIPv6; + let isIPv6 - if (typeof endpointOrInterface === "string") { // its a network interface name - isIPv6 = endpointOrInterface.endsWith("/6"); - address = isIPv6 ? MDNSServer.MULTICAST_IPV6 : MDNSServer.MULTICAST_IPV4; - port = MDNSServer.MDNS_PORT; - name = endpointOrInterface; + if (typeof endpointOrInterface === 'string') { // its a network interface name + isIPv6 = endpointOrInterface.endsWith('/6') + address = isIPv6 ? MDNSServer.MULTICAST_IPV6 : MDNSServer.MULTICAST_IPV4 + port = MDNSServer.MDNS_PORT + name = endpointOrInterface } else { - isIPv6 = endpointOrInterface.interface.endsWith("/6"); - address = endpointOrInterface.address; - port = endpointOrInterface.port; - name = endpointOrInterface.interface; + isIPv6 = endpointOrInterface.interface.endsWith('/6') + address = endpointOrInterface.address + port = endpointOrInterface.port + name = endpointOrInterface.interface } - this.assertBeforeSend(message, isIPv6 ? IPFamily.IPv6 : IPFamily.IPv4); + this.assertBeforeSend(message, isIPv6 ? IPFamily.IPv6 : IPFamily.IPv4) - const socket = this.sockets.get(name); + const socket = this.sockets.get(name) if (!socket) { - throw new InterfaceNotFoundError(`Could not find socket for given network interface '${name}'`); + throw new InterfaceNotFoundError(`Could not find socket for given network interface '${name}'`) } - return new Promise(resolve => { - socket!.send(message, port, address, error => { + return new Promise((resolve) => { + socket!.send(message, port, address, (error) => { if (error) { if (!MDNSServer.isSilencedSocketError(error)) { resolve({ - status: "rejected", + status: 'rejected', interface: name, reason: error, - }); - return; + }) + return } } else { - this.maintainSentPacketsInterface(name, message); + this.maintainSentPacketsInterface(name, message) } resolve({ - status: "fulfilled", + status: 'fulfilled', interface: name, - }); - }); - }); + }) + }) + }) } private checkUnicastResponseFlag(packet: DNSPacket): void { if (this.suppressUnicastResponseFlag && packet.type === PacketType.QUERY) { - packet.questions.forEach(record => record.unicastResponseFlag = false); + packet.questions.forEach(record => record.unicastResponseFlag = false) } } private assertBeforeSend(message: Buffer, family: IPFamily): void { if (this.closed) { - throw new ServerClosedError("Cannot send packets on a closed mdns server!"); + throw new ServerClosedError('Cannot send packets on a closed mdns server!') } - assert(this.bound, "Cannot send packets before server is not bound!"); + assert(this.bound, 'Cannot send packets before server is not bound!') - const ipHeaderSize = family === IPFamily.IPv4? MDNSServer.DEFAULT_IP4_HEADER: MDNSServer.DEFAULT_IP6_HEADER; + const ipHeaderSize = family === IPFamily.IPv4 ? MDNSServer.DEFAULT_IP4_HEADER : MDNSServer.DEFAULT_IP6_HEADER // RFC 6762 17. - assert(ipHeaderSize + MDNSServer.UDP_HEADER + message.length <= 9000, - "DNS cannot exceed the size of 9000 bytes even with IP Fragmentation!"); + assert(ipHeaderSize + MDNSServer.UDP_HEADER + message.length <= 9000, 'DNS cannot exceed the size of 9000 bytes even with IP Fragmentation!') } private maintainSentPacketsInterface(name: InterfaceName, packet: Buffer): void { - const base64 = packet.toString("base64"); - const packets = this.sentPackets.get(name); + const base64 = packet.toString('base64') + const packets = this.sentPackets.get(name) if (!packets) { - this.sentPackets.set(name, [base64]); + this.sentPackets.set(name, [base64]) } else { - packets.push(base64); + packets.push(base64) } } private checkIfPacketWasPreviouslySentFromUs(name: InterfaceName, packet: Buffer): boolean { - const base64 = packet.toString("base64"); - const packets = this.sentPackets.get(name); + const base64 = packet.toString('base64') + const packets = this.sentPackets.get(name) if (packets) { - const index = packets.indexOf(base64); + const index = packets.indexOf(base64) if (index !== -1) { - packets.splice(index, 1); - return true; + packets.splice(index, 1) + return true } } - return false; + return false } - private createDgramSocket(name: InterfaceName, reuseAddr = false, type: "udp4" | "udp6" = "udp4"): Socket { + private createDgramSocket(name: InterfaceName, reuseAddr = false, type: 'udp4' | 'udp6' = 'udp4'): Socket { const socket = dgram.createSocket({ - type: type, - reuseAddr: reuseAddr, - }); + type, + reuseAddr, + }) - socket.on("message", (data: Buffer, rinfo: AddressInfo) => this.handleMessage(name, data, rinfo, type === "udp6" ? IPFamily.IPv6 : IPFamily.IPv4)); - socket.on("error", error => { + socket.on('message', (data: Buffer, rinfo: AddressInfo) => this.handleMessage(name, data, rinfo, type === 'udp6' ? IPFamily.IPv6 : IPFamily.IPv4)) + socket.on('error', (error) => { if (!MDNSServer.isSilencedSocketError(error)) { - MDNSServer.logSocketError(name, error); + MDNSServer.logSocketError(name, error) } - }); + }) - return socket; + return socket } private bindSocket(socket: Socket, networkInterface: NetworkInterface, family: IPFamily): Promise { return new Promise((resolve, reject) => { - const errorHandler = (error: Error): void => reject(new Error("Failed to bind on interface " + networkInterface.name + ": " + error.message)); - const isIPv6 = family === IPFamily.IPv6; + const errorHandler = (error: Error): void => reject(new Error(`Failed to bind on interface ${networkInterface.name}: ${error.message}`)) + const isIPv6 = family === IPFamily.IPv6 - socket.once("error", errorHandler); + socket.once('error', errorHandler) - socket.on("close", () => { - this.sockets.delete(networkInterface.name + (isIPv6 ? "/6" : "")); - }); + socket.on('close', () => { + this.sockets.delete(networkInterface.name + (isIPv6 ? '/6' : '')) + }) socket.bind(MDNSServer.MDNS_PORT, () => { - socket.setRecvBufferSize(800*1024); // setting max recv buffer size to 800KiB (Pi will max out at 352KiB) - socket.removeListener("error", errorHandler); + socket.setRecvBufferSize(800 * 1024) // setting max recv buffer size to 800KiB (Pi will max out at 352KiB) + socket.removeListener('error', errorHandler) - const multicastAddress = isIPv6 ? MDNSServer.MULTICAST_IPV6 : MDNSServer.MULTICAST_IPV4; - const interfaceAddress = isIPv6 ? networkInterface.ipv6 : networkInterface.ipv4; + const multicastAddress = isIPv6 ? MDNSServer.MULTICAST_IPV6 : MDNSServer.MULTICAST_IPV4 + const interfaceAddress = isIPv6 ? networkInterface.ipv6 : networkInterface.ipv4 // assert(interfaceAddress, "Interface address for " + networkInterface.name + " cannot be undefined!"); if (!interfaceAddress) { // There isn't necessarily an IPv4 and IPv6 address assigned to every interface even on dual-stack systems - console.log("Warning: no " + (isIPv6 ? "IPv6" : "IPv4") + " address available on " + networkInterface.name); + // eslint-disable-next-line no-console + console.log(`Warning: no ${isIPv6 ? 'IPv6' : 'IPv4'} address available on ${networkInterface.name}`) try { - socket.close(); - } catch (error) { + socket.close() + } catch (error: any) { // Ignore } - resolve(); - return; + resolve() + return } try { - socket.addMembership(multicastAddress, interfaceAddress!); + socket.addMembership(multicastAddress, interfaceAddress!) // socket.setMulticastInterface(isIPv6 ? "::%" + networkInterface.name : interfaceAddress!); - socket.setMulticastInterface(isIPv6 ? interfaceAddress + "%" + networkInterface.name : interfaceAddress!); + socket.setMulticastInterface(isIPv6 ? `${interfaceAddress}%${networkInterface.name}` : interfaceAddress!) - socket.setMulticastTTL(MDNSServer.MDNS_TTL); // outgoing multicast datagrams - socket.setTTL(MDNSServer.MDNS_TTL); // outgoing unicast datagrams + socket.setMulticastTTL(MDNSServer.MDNS_TTL) // outgoing multicast datagrams + socket.setTTL(MDNSServer.MDNS_TTL) // outgoing unicast datagrams - socket.setMulticastLoopback(true); // We can't disable multicast loopback, as otherwise queriers on the same host won't receive our packets + socket.setMulticastLoopback(true) // We can't disable multicast loopback, as otherwise queriers on the same host won't receive our packets - this.sockets.set(isIPv6 ? networkInterface.name + "/6" : networkInterface.name, socket); - resolve(); - } catch (error) { + this.sockets.set(isIPv6 ? `${networkInterface.name}/6` : networkInterface.name, socket) + resolve() + } catch (error: any) { try { - socket.close(); - } catch (error) { - debug("Error while closing socket which failed to bind. Error may be expected: " + error.message); + socket.close() + } catch (error: any) { + debug(`Error while closing socket which failed to bind. Error may be expected: ${error.message}`) } - reject(new Error("Error binding socket on " + networkInterface.name + ": " + error.stack)); + reject(new Error(`Error binding socket on ${networkInterface.name}: ${error.stack}`)) } - }); - }); + }) + }) } private handleMessage(name: InterfaceName, buffer: Buffer, rinfo: AddressInfo, family: IPFamily): void { if (!this.bound) { - return; + return } - const networkInterface = this.networkManager.getInterface(name); + const networkInterface = this.networkManager.getInterface(name) if (!networkInterface) { - debug("Received packet on non existing network interface: %s!", name); - return; + debug('Received packet on non existing network interface: %s!', name) + return } if (this.checkIfPacketWasPreviouslySentFromUs(networkInterface.name, buffer)) { // multicastLoopback is enabled for every interface, meaning we would receive our own response // packets here. Thus, we silence them. We can't disable multicast loopback, as otherwise // queriers on the same host won't receive our packets - return; + return } // We have the following problem on linux based platforms: @@ -570,75 +563,74 @@ export class MDNSServer { // With that we at least ensure that the loopback address is never sent out to the network. // This is what we do below: - const isIPv6 = family === IPFamily.IPv6; + const isIPv6 = family === IPFamily.IPv6 if (isIPv6) { - if (networkInterface.loopback !== rinfo.address.includes("%lo")) { + if (networkInterface.loopback !== rinfo.address.includes('%lo')) { debug( - "Received packet on a %s interface (%s) which is coming from a %s interface (%s)", - networkInterface.loopback ? "loopback" : "non-loopback", + 'Received packet on a %s interface (%s) which is coming from a %s interface (%s)', + networkInterface.loopback ? 'loopback' : 'non-loopback', name, - rinfo.address.includes("%lo") ? "loopback" : "non-loopback", + rinfo.address.includes('%lo') ? 'loopback' : 'non-loopback', rinfo.address, - ); + ) // return; } } else { - const ip4Netaddress = getNetAddress(rinfo.address, networkInterface.ip4Netmask!); + const ip4Netaddress = getNetAddress(rinfo.address, networkInterface.ip4Netmask!) if (networkInterface.loopback) { if (ip4Netaddress !== networkInterface.ipv4Netaddress) { - return; + return } } else if (this.networkManager.isLoopbackNetaddressV4(ip4Netaddress)) { - debug("Received packet on interface '%s' which is not coming from the same subnet: %o", name, - {address: rinfo.address, netaddress: ip4Netaddress, interface: networkInterface.ipv4}); - return; + debug('Received packet on interface \'%s\' which is not coming from the same subnet: %o', name, { address: rinfo.address, netaddress: ip4Netaddress, interface: networkInterface.ipv4 }) + return } } - let packet: DNSPacket; + let packet: DNSPacket try { - packet = DNSPacket.decode(rinfo, buffer); - } catch (error) { - debug("Received a malformed packet from %o on interface %s. This might or might not be a problem. " + - "Here is the received packet for debugging purposes '%s'. " + - "Packet decoding failed with %s", rinfo, name, buffer.toString("base64"), error.stack); - return; + packet = DNSPacket.decode(rinfo, buffer) + } catch (error: any) { + debug('Received a malformed packet from %o on interface %s. This might or might not be a problem. ' + + 'Here is the received packet for debugging purposes \'%s\'. ' + + 'Packet decoding failed with %s', rinfo, name, buffer.toString('base64'), error.stack) + return } if (packet.opcode !== OpCode.QUERY) { // RFC 6762 18.3 we MUST ignore messages with opcodes other than zero (QUERY) - return; + return } if (packet.rcode !== RCode.NoError) { // RFC 6762 18.3 we MUST ignore messages with response code other than zero (NoError) - return; + return } const endpoint: EndpointInfo = { address: rinfo.address, port: rinfo.port, - interface: name + (isIPv6 ? "/6" : ""), - }; + interface: name + (isIPv6 ? '/6' : ''), + } if (packet.type === PacketType.QUERY) { try { - this.handler.handleQuery(packet, endpoint); - } catch (error) { - console.warn("Error occurred handling incoming (on " + name + ") dns query packet: " + error.stack); + this.handler.handleQuery(packet, endpoint) + } catch (error: any) { + console.warn(`Error occurred handling incoming (on ${name}) dns query packet: ${error.stack}`) } } else if (packet.type === PacketType.RESPONSE) { if (rinfo.port !== MDNSServer.MDNS_PORT) { // RFC 6762 6. Multicast DNS implementations MUST silently ignore any Multicast DNS responses // they receive where the source UDP port is not 5353. - return; + return } try { - this.handler.handleResponse(packet, endpoint); - } catch (error) { - console.warn("Error occurred handling incoming (on " + name + ") dns response packet: " + error.stack); + this.handler.handleResponse(packet, endpoint) + } catch (error: any) { + console.warn(`Error occurred handling incoming (on ${name}) dns response packet: ${error.stack}`) } } } @@ -648,35 +640,34 @@ export class MDNSServer { // they happen when the host is not reachable (EADDRNOTAVAIL for 224.0.0.251 or EHOSTDOWN for any unicast traffic) // caused by yet undetected network changes. // as we listen to 0.0.0.0 and the socket stays valid, this is not a problem - const silenced = error.message.includes("EADDRNOTAVAIL") || error.message.includes("EHOSTDOWN") - || error.message.includes("ENETUNREACH") || error.message.includes("EHOSTUNREACH") - || error.message.includes("EPERM") || error.message.includes("EINVAL"); + const silenced = error.message.includes('EADDRNOTAVAIL') || error.message.includes('EHOSTDOWN') + || error.message.includes('ENETUNREACH') || error.message.includes('EHOSTUNREACH') + || error.message.includes('EPERM') || error.message.includes('EINVAL') if (silenced) { - debug ("Silenced and ignored error (This is/should not be a problem, this message is only for informational purposes): " + error.message); + debug (`Silenced and ignored error (This is/should not be a problem, this message is only for informational purposes): ${error.message}`) } - return silenced; + return silenced } private static logSocketError(name: InterfaceName, error: Error): void { - console.warn(`Encountered MDNS socket error on socket '${name}' : ${error.stack}`); - return; + console.warn(`Encountered MDNS socket error on socket '${name}' : ${error.stack}`) } private handleUpdatedNetworkInterfaces(networkUpdate: NetworkUpdate): void { if (networkUpdate.removed) { for (const networkInterface of networkUpdate.removed) { // Handle IPv4 - let socket = this.sockets.get(networkInterface.name); - this.sockets.delete(networkInterface.name); + let socket = this.sockets.get(networkInterface.name) + this.sockets.delete(networkInterface.name) if (socket) { - socket.close(); + socket.close() } // Handle IPv6 - socket = this.sockets.get(networkInterface.name + "/6"); - this.sockets.delete(networkInterface.name + "/6"); + socket = this.sockets.get(`${networkInterface.name}/6`) + this.sockets.delete(`${networkInterface.name}/6`) if (socket) { - socket.close(); + socket.close() } } } @@ -684,42 +675,42 @@ export class MDNSServer { if (networkUpdate.changes) { for (const change of networkUpdate.changes) { // Handle IPv4 - let socket = this.sockets.get(change.name); + let socket = this.sockets.get(change.name) if (!change.outdatedIpv4 && change.updatedIpv4) { // this does currently not happen, as we exclude ipv6 only interfaces // thus such a change would be happening through the ADDED array - assert.fail("Reached illegal state! IPv4 address changed from undefined to defined!"); + assert.fail('Reached illegal state! IPv4 address changed from undefined to defined!') } else if (change.outdatedIpv4 && !change.updatedIpv4) { // this does currently not happen, as we exclude ipv6 only interfaces // thus such a change would be happening through the REMOVED array - assert.fail("Reached illegal state! IPV4 address change from defined to undefined!"); + assert.fail('Reached illegal state! IPV4 address change from defined to undefined!') } else if (socket && change.outdatedIpv4 && change.updatedIpv4) { try { - socket!.dropMembership(MDNSServer.MULTICAST_IPV4, change.outdatedIpv4); - } catch (error) { - debug("Thrown unexpected error when dropping outdated address membership: " + error.message); + socket!.dropMembership(MDNSServer.MULTICAST_IPV4, change.outdatedIpv4) + } catch (error: any) { + debug(`Thrown unexpected error when dropping outdated address membership: ${error.message}`) } try { - socket!.addMembership(MDNSServer.MULTICAST_IPV4, change.updatedIpv4); - } catch (error) { - debug("Thrown unexpected error when adding new address membership: " + error.message); + socket!.addMembership(MDNSServer.MULTICAST_IPV4, change.updatedIpv4) + } catch (error: any) { + debug(`Thrown unexpected error when adding new address membership: ${error.message}`) } - socket!.setMulticastInterface(change.updatedIpv4); + socket!.setMulticastInterface(change.updatedIpv4) } // Handle IPv6 - socket = this.sockets.get(change.name + "/6"); + socket = this.sockets.get(`${change.name}/6`) if (socket && change.outdatedIpv6 && change.updatedIpv6) { try { - socket!.dropMembership(MDNSServer.MULTICAST_IPV6, change.outdatedIpv6); - } catch (error) { - debug("Thrown unexpected error when dropping outdated address membership: " + error.message); + socket!.dropMembership(MDNSServer.MULTICAST_IPV6, change.outdatedIpv6) + } catch (error: any) { + debug(`Thrown unexpected error when dropping outdated address membership: ${error.message}`) } try { - socket!.addMembership(MDNSServer.MULTICAST_IPV6, change.updatedIpv6); - } catch (error) { - debug("Thrown unexpected error when adding new address membership: " + error.message); + socket!.addMembership(MDNSServer.MULTICAST_IPV6, change.updatedIpv6) + } catch (error: any) { + debug(`Thrown unexpected error when adding new address membership: ${error.message}`) } } } @@ -728,14 +719,14 @@ export class MDNSServer { if (networkUpdate.added) { for (const networkInterface of networkUpdate.added) { this.advertiseFamilies.forEach((family: IPFamily) => { - const socket = this.createDgramSocket(networkInterface.name, true, family === IPFamily.IPv6 ? "udp6" : "udp4"); - this.bindSocket(socket, networkInterface, family).catch(reason => { + const socket = this.createDgramSocket(networkInterface.name, true, family === IPFamily.IPv6 ? 'udp6' : 'udp4') + this.bindSocket(socket, networkInterface, family).catch((reason) => { // TODO if bind errors we probably will never bind again - console.log("Could not bind detected network interface: " + reason.stack); - }); - }); + // eslint-disable-next-line no-console + console.log(`Could not bind detected network interface: ${reason.stack}`) + }) + }) } } } - } diff --git a/src/NetworkManager.spec.ts b/src/NetworkManager.spec.ts index d9e2f2a..d24b70c 100644 --- a/src/NetworkManager.spec.ts +++ b/src/NetworkManager.spec.ts @@ -1,84 +1,83 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment */ -import { NetworkManager } from "./NetworkManager"; -import childProcess, { ExecException } from "child_process"; +import type { ExecException } from 'node:child_process' -const execMock = jest.spyOn(childProcess, "exec"); +import childProcess from 'node:child_process' -// @ts-expect-error -const getLinuxNetworkInterfaces = NetworkManager.getLinuxNetworkInterfaces; +import { describe, expect, it, vi } from 'vitest' + +import { NetworkManager } from './NetworkManager.js' + +const execMock = vi.spyOn(childProcess, 'exec') + +const getLinuxNetworkInterfaces = NetworkManager.getLinuxNetworkInterfaces describe(NetworkManager, () => { describe(getLinuxNetworkInterfaces, () => { - it("should parse interfaces from arp cache", async () => { - // @ts-expect-error + it('should parse interfaces from arp cache', async () => { execMock.mockImplementationOnce((command: string, callback: (error: ExecException | null, stdout: string, stderr: string) => void) => { - if (command !== "ip neigh show") { - console.warn("Command for getLinuxNetworkInterfaces differs from the expected input!"); + if (command !== 'ip neigh show') { + console.warn('Command for getLinuxNetworkInterfaces differs from the expected input!') } - callback(null, "192.168.0.1 dev eth0 lladdr 00:00:00:00:00:00 STALE\n" + - "192.168.0.2 dev eth0 lladdr 00:00:00:00:00:00 STALE\n" + - "192.168.0.3 dev asdf lladdr 00:00:00:00:00:00 REACHABLE\n" + - "192.168.0.4 dev eth0 lladdr 00:00:00:00:00:00 STALE\n" + - "192.168.0.5 dev eth0 lladdr 00:00:00:00:00:00 STALE\n" + - "2003::1 dev eth1 lladdr 00:00:00:00:00:00 STALE\n" + - "2003::1 dev eth0 lladdr 00:00:00:00:00:00 REACHABLE\n" + - "fe80::1 dev eth3 lladdr 00:00:00:00:00:00 STALE\n" + - "2003::1 dev eth0 lladdr 00:00:00:00:00:00 STALE\n" + - "fd00::1 dev eth6 lladdr 00:00:00:00:00:00 STALE\n", ""); - }); + callback(null, '192.168.0.1 dev eth0 lladdr 00:00:00:00:00:00 STALE\n' + + '192.168.0.2 dev eth0 lladdr 00:00:00:00:00:00 STALE\n' + + '192.168.0.3 dev asdf lladdr 00:00:00:00:00:00 REACHABLE\n' + + '192.168.0.4 dev eth0 lladdr 00:00:00:00:00:00 STALE\n' + + '192.168.0.5 dev eth0 lladdr 00:00:00:00:00:00 STALE\n' + + '2003::1 dev eth1 lladdr 00:00:00:00:00:00 STALE\n' + + '2003::1 dev eth0 lladdr 00:00:00:00:00:00 REACHABLE\n' + + 'fe80::1 dev eth3 lladdr 00:00:00:00:00:00 STALE\n' + + '2003::1 dev eth0 lladdr 00:00:00:00:00:00 STALE\n' + + 'fd00::1 dev eth6 lladdr 00:00:00:00:00:00 STALE\n', '') + }) - const names = await getLinuxNetworkInterfaces(); - expect(names).toStrictEqual(["eth0", "asdf", "eth1", "eth3", "eth6"]); - }); + const names = await getLinuxNetworkInterfaces() + expect(names).toStrictEqual(['eth0', 'asdf', 'eth1', 'eth3', 'eth6']) + }) - it("should handle error caused by exec", () => { - // @ts-expect-error + it('should handle error caused by exec', () => { execMock.mockImplementationOnce((command: string, callback: (error: ExecException | null, stdout: string, stderr: string) => void) => { - callback(new Error("test"), "192.168.0.3 dev asdf lladdr 00:00:00:00:00:00 REACHABLE\n", ""); - }); + callback(new Error('test'), '192.168.0.3 dev asdf lladdr 00:00:00:00:00:00 REACHABLE\n', '') + }) return getLinuxNetworkInterfaces().then(() => { - fail("Should not parse names when error is received!"); - }, reason => { - expect(reason.message).toBe("test"); - }); - }); + throw new Error('Should not parse names when error is received!') + }, (reason) => { + expect(reason.message).toBe('test') + }) + }) - it("should handle double spaces correctly", async () => { - // @ts-expect-error + it('should handle double spaces correctly', async () => { execMock.mockImplementationOnce((command: string, callback: (error: ExecException | null, stdout: string, stderr: string) => void) => { - if (command !== "ip neigh show") { - console.warn("Command for getLinuxNetworkInterfaces differs from the expected input!"); + if (command !== 'ip neigh show') { + console.warn('Command for getLinuxNetworkInterfaces differs from the expected input!') } - callback(null, "192.168.0.1 dev eth0 lladdr 00:00:00:00:00:00 STALE\n" + - "192.168.0.2 dev eth0 lladdr 00:00:00:00:00:00 STALE\n" + - "192.168.0.3 dev asdf lladdr 00:00:00:00:00:00 REACHABLE\n" + - "192.168.0.4 dev eth0 lladdr 00:00:00:00:00:00 STALE\n" + - "192.168.0.5 dev eth0 lladdr 00:00:00:00:00:00 STALE\n" + - "2003::1 dev eth1 lladdr 00:00:00:00:00:00 STALE\n" + - "2003::1 dev eth0 lladdr 00:00:00:00:00:00 REACHABLE\n" + - "fe80::1 dev eth3 lladdr 00:00:00:00:00:00 STALE\n" + - "2003::1 dev eth0 lladdr 00:00:00:00:00:00 STALE\n" + - "fd00::1 dev eth6 lladdr 00:00:00:00:00:00 STALE\n", ""); - }); + callback(null, '192.168.0.1 dev eth0 lladdr 00:00:00:00:00:00 STALE\n' + + '192.168.0.2 dev eth0 lladdr 00:00:00:00:00:00 STALE\n' + + '192.168.0.3 dev asdf lladdr 00:00:00:00:00:00 REACHABLE\n' + + '192.168.0.4 dev eth0 lladdr 00:00:00:00:00:00 STALE\n' + + '192.168.0.5 dev eth0 lladdr 00:00:00:00:00:00 STALE\n' + + '2003::1 dev eth1 lladdr 00:00:00:00:00:00 STALE\n' + + '2003::1 dev eth0 lladdr 00:00:00:00:00:00 REACHABLE\n' + + 'fe80::1 dev eth3 lladdr 00:00:00:00:00:00 STALE\n' + + '2003::1 dev eth0 lladdr 00:00:00:00:00:00 STALE\n' + + 'fd00::1 dev eth6 lladdr 00:00:00:00:00:00 STALE\n', '') + }) - const names = await getLinuxNetworkInterfaces(); - expect(names).toStrictEqual(["eth0", "asdf", "eth1", "eth3", "eth6"]); - }); + const names = await getLinuxNetworkInterfaces() + expect(names).toStrictEqual(['eth0', 'asdf', 'eth1', 'eth3', 'eth6']) + }) - it("should handle empty arp cache", () => { - // @ts-expect-error + it('should handle empty arp cache', () => { execMock.mockImplementationOnce((command: string, callback: (error: ExecException | null, stdout: string, stderr: string) => void) => { - callback(null, "", ""); - }); + callback(null, '', '') + }) return getLinuxNetworkInterfaces().then(() => { - fail("Should not parse names when error is received!"); - }, reason => { - expect(reason).toBeDefined(); - }); - }); - }); -}); + throw new Error('Should not parse names when error is received!') + }, (reason) => { + expect(reason).toBeDefined() + }) + }) + }) +}) diff --git a/src/NetworkManager.ts b/src/NetworkManager.ts index 4a26b91..f0c9db7 100644 --- a/src/NetworkManager.ts +++ b/src/NetworkManager.ts @@ -1,28 +1,35 @@ -/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ -import assert from "assert"; -import childProcess from "child_process"; -import createDebug from "debug"; -import { EventEmitter } from "events"; -import deepEqual from "fast-deep-equal"; -import net from "net"; -import os, { NetworkInterfaceInfo } from "os"; -import { getNetAddress } from "./util/domain-formatter"; -import Timeout = NodeJS.Timeout; - -const debug = createDebug("ciao:NetworkManager"); - -export type InterfaceName = string; -export type MacAddress = string; - -export type IPv4Address = string; -export type IPv6Address = string; -export type IPAddress = IPv4Address | IPv6Address; +/* global NodeJS */ +import type { NetworkInterfaceInfo } from 'node:os' +import assert from 'node:assert' +import childProcess from 'node:child_process' +import { EventEmitter } from 'node:events' +import net from 'node:net' +import os from 'node:os' + +import createDebug from 'debug' +import deepEqual from 'fast-deep-equal' + +import { getNetAddress } from './util/domain-formatter.js' + +import Timeout = NodeJS.Timeout + +const debug = createDebug('ciao:NetworkManager') + +export type InterfaceName = string +export type MacAddress = string + +export type IPv4Address = string +export type IPv6Address = string +export type IPAddress = IPv4Address | IPv6Address + +// eslint-disable-next-line no-restricted-syntax export const enum IPFamily { - IPv4 = "IPv4", - IPv6 = "IPv6", + IPv4 = 'IPv4', + IPv6 = 'IPv6', } +// eslint-disable-next-line no-restricted-syntax export const enum WifiState { UNDEFINED, NOT_A_WIFI_INTERFACE, @@ -31,61 +38,63 @@ export const enum WifiState { } export interface NetworkInterface { - name: InterfaceName; - loopback: boolean; - mac: MacAddress; + name: InterfaceName + loopback: boolean + mac: MacAddress // one of ipv4 or ipv6 will be present, most of the time even both - ipv4?: IPv4Address; - ip4Netmask?: IPv4Address; - ipv4Netaddress?: IPv4Address; - ipv6?: IPv6Address; // link-local ipv6 fe80::/10 - ipv6Netmask?: IPv6Address; + ipv4?: IPv4Address + ip4Netmask?: IPv4Address + ipv4Netaddress?: IPv4Address + ipv6?: IPv6Address // link-local ipv6 fe80::/10 + ipv6Netmask?: IPv6Address - globallyRoutableIpv6?: IPv6Address; // first routable ipv6 address - globallyRoutableIpv6Netmask?: IPv6Address; + globallyRoutableIpv6?: IPv6Address // first routable ipv6 address + globallyRoutableIpv6Netmask?: IPv6Address - uniqueLocalIpv6?: IPv6Address; // fc00::/7 (those are the fd ula addresses; fc prefix isn't really used, used for globally assigned ula) - uniqueLocalIpv6Netmask?: IPv6Address; + uniqueLocalIpv6?: IPv6Address // fc00::/7 (those are the fd ula addresses; fc prefix isn't really used, used for globally assigned ula) + uniqueLocalIpv6Netmask?: IPv6Address } export interface NetworkUpdate { - added?: NetworkInterface[]; - removed?: NetworkInterface[]; - changes?: InterfaceChange[]; + added?: NetworkInterface[] + removed?: NetworkInterface[] + changes?: InterfaceChange[] } export interface InterfaceChange { - name: InterfaceName; + name: InterfaceName - outdatedIpv4?: IPv4Address; - updatedIpv4?: IPv4Address; + outdatedIpv4?: IPv4Address + updatedIpv4?: IPv4Address - outdatedIpv6?: IPv6Address; - updatedIpv6?: IPv6Address; + outdatedIpv6?: IPv6Address + updatedIpv6?: IPv6Address - outdatedGloballyRoutableIpv6?: IPv6Address; - updatedGloballyRoutableIpv6?: IPv6Address; + outdatedGloballyRoutableIpv6?: IPv6Address + updatedGloballyRoutableIpv6?: IPv6Address - outdatedUniqueLocalIpv6?: IPv6Address; - updatedUniqueLocalIpv6?: IPv6Address; + outdatedUniqueLocalIpv6?: IPv6Address + updatedUniqueLocalIpv6?: IPv6Address } export interface NetworkManagerOptions { - interface?: (InterfaceName | IPAddress) | (InterfaceName | IPAddress)[]; - excludeIpv6?: boolean; - excludeIpv6Only?: boolean; + interface?: (InterfaceName | IPAddress) | (InterfaceName | IPAddress)[] + excludeIpv6?: boolean + excludeIpv6Only?: boolean } +// eslint-disable-next-line no-restricted-syntax export const enum NetworkManagerEvent { - NETWORK_UPDATE = "network-update", + NETWORK_UPDATE = 'network-update', } +// eslint-disable-next-line ts/no-unsafe-declaration-merging export declare interface NetworkManager { - on(event: "network-update", listener: (networkUpdate: NetworkUpdate) => void): this; + on: (event: 'network-update', listener: (networkUpdate: NetworkUpdate) => void) => this - emit(event: "network-update", networkUpdate: NetworkUpdate): boolean; + emit: (event: 'network-update', networkUpdate: NetworkUpdate) => boolean } @@ -94,214 +103,216 @@ export declare interface NetworkManager { * It periodically checks for updated network information. * * The NetworkManager makes the following decision when checking for interfaces: - * * First of all it gathers the default network interface of the system (by checking the routing table of the os) - * * The following interfaces are going to be tracked: - * * The loopback interface - * * All interfaces which match the subnet of the default interface - * * All interfaces which contain a globally unique (aka globally routable) ipv6 address + * First of all it gathers the default network interface of the system (by checking the routing table of the os) + * The following interfaces are going to be tracked: + * The loopback interface + * All interfaces which match the subnet of the default interface + * All interfaces which contain a globally unique (aka globally routable) ipv6 address */ +// eslint-disable-next-line ts/no-unsafe-declaration-merging export class NetworkManager extends EventEmitter { + private static readonly SPACE_PATTERN = /\s+/g + private static readonly NOTHING_FOUND_MESSAGE = 'no interfaces found' - private static readonly SPACE_PATTERN = /\s+/g; - private static readonly NOTHING_FOUND_MESSAGE = "no interfaces found"; - - private static readonly POLLING_TIME = 15 * 1000; // 15 seconds + private static readonly POLLING_TIME = 15 * 1000 // 15 seconds - private readonly restrictedInterfaces?: InterfaceName[]; - private readonly excludeIpv6: boolean; // if defined, we only pick ipv4 address records from an available network interface - private readonly excludeIpv6Only: boolean; + private readonly restrictedInterfaces?: InterfaceName[] + private readonly excludeIpv6: boolean // if defined, we only pick ipv4 address records from an available network interface + private readonly excludeIpv6Only: boolean - private currentInterfaces: Map = new Map(); + private currentInterfaces: Map = new Map() /** * A subset of our network interfaces, holding only loopback interfaces (or what node considers "internal"). */ - private loopbackInterfaces: Map = new Map(); - private initPromise?: Promise; + private loopbackInterfaces: Map = new Map() + private initPromise?: Promise - private currentTimer?: Timeout; + private currentTimer?: Timeout constructor(options?: NetworkManagerOptions) { - super(); - this.setMaxListeners(100); // we got one listener for every Responder, 100 should be fine for now + super() + this.setMaxListeners(100) // we got one listener for every Responder, 100 should be fine for now if (options && options.interface) { - let interfaces: (InterfaceName | IPAddress)[]; + let interfaces: (InterfaceName | IPAddress)[] - if (typeof options.interface === "string") { - interfaces = [options.interface]; + if (typeof options.interface === 'string') { + interfaces = [options.interface] } else if (Array.isArray(options.interface)) { - interfaces = options.interface; + interfaces = options.interface } else { - throw new Error("Found invalid type for 'interfaces' NetworkManager option!"); + throw new TypeError('Found invalid type for \'interfaces\' NetworkManager option!') } - const restrictedInterfaces: InterfaceName[] = []; + const restrictedInterfaces: InterfaceName[] = [] for (const iface of interfaces) { if (net.isIP(iface)) { - const interfaceName = NetworkManager.resolveInterface(iface); + const interfaceName = NetworkManager.resolveInterface(iface) if (interfaceName) { - restrictedInterfaces.push(interfaceName); + restrictedInterfaces.push(interfaceName) } else { - console.log("CIAO: Interface was specified as ip (%s), though couldn't find a matching interface for the given address.", options.interface); + // eslint-disable-next-line no-console + console.log('CIAO: Interface was specified as ip (%s), though couldn\'t find a matching interface for the given address.', options.interface) } } else { - restrictedInterfaces.push(iface); + restrictedInterfaces.push(iface) } } if (restrictedInterfaces.length === 0) { - console.log("CIAO: 'restrictedInterfaces' array was empty. Going to fallback to bind on all available interfaces."); + // eslint-disable-next-line no-console + console.log('CIAO: \'restrictedInterfaces\' array was empty. Going to fallback to bind on all available interfaces.') } else { - this.restrictedInterfaces = restrictedInterfaces; + this.restrictedInterfaces = restrictedInterfaces } } - this.excludeIpv6 = !!(options && options.excludeIpv6); - this.excludeIpv6Only = this.excludeIpv6 || !!(options && options.excludeIpv6Only); + this.excludeIpv6 = !!(options && options.excludeIpv6) + this.excludeIpv6Only = this.excludeIpv6 || !!(options && options.excludeIpv6Only) if (options) { - debug("Created NetworkManager with options: %s", JSON.stringify(options)); + debug('Created NetworkManager with options: %s', JSON.stringify(options)) } - this.initPromise = new Promise(resolve => { - this.getCurrentNetworkInterfaces().then(map => { - this.currentInterfaces = map; + this.initPromise = new Promise((resolve) => { + this.getCurrentNetworkInterfaces().then((map) => { + this.currentInterfaces = map - const otherInterfaces: InterfaceName[] = Object.keys(os.networkInterfaces()); + const otherInterfaces: InterfaceName[] = Object.keys(os.networkInterfaces()) - const interfaceNames: InterfaceName[] = []; + const interfaceNames: InterfaceName[] = [] for (const name of this.currentInterfaces.keys()) { - interfaceNames.push(name); + interfaceNames.push(name) - const index = otherInterfaces.indexOf(name); + const index = otherInterfaces.indexOf(name) if (index !== -1) { - otherInterfaces.splice(index, 1); + otherInterfaces.splice(index, 1) } } - debug("Initial networks [%s] ignoring [%s]", interfaceNames.join(", "), otherInterfaces.join(", ")); + debug('Initial networks [%s] ignoring [%s]', interfaceNames.join(', '), otherInterfaces.join(', ')) - this.initPromise = undefined; - resolve(); + this.initPromise = undefined + resolve() - this.scheduleNextJob(); - }); - }); + this.scheduleNextJob() + }) + }) } public async waitForInit(): Promise { if (this.initPromise) { - await this.initPromise; + await this.initPromise } } public shutdown(): void { if (this.currentTimer) { - clearTimeout(this.currentTimer); - this.currentTimer = undefined; + clearTimeout(this.currentTimer) + this.currentTimer = undefined } - this.removeAllListeners(); + this.removeAllListeners() } public getInterfaceMap(): Map { if (this.initPromise) { - assert.fail("Not yet initialized!"); + assert.fail('Not yet initialized!') } - return this.currentInterfaces; + return this.currentInterfaces } public getInterface(name: InterfaceName): NetworkInterface | undefined { if (this.initPromise) { - assert.fail("Not yet initialized!"); + assert.fail('Not yet initialized!') } - return this.currentInterfaces.get(name); + return this.currentInterfaces.get(name) } public isLoopbackNetaddressV4(netaddress: IPv4Address): boolean { for (const networkInterface of this.loopbackInterfaces.values()) { if (networkInterface.ipv4Netaddress === netaddress) { - return true; + return true } } - return false; + return false } private scheduleNextJob(): void { - this.currentTimer = setTimeout(this.checkForNewInterfaces.bind(this), NetworkManager.POLLING_TIME); - this.currentTimer.unref(); // this timer won't prevent shutdown + this.currentTimer = setTimeout(this.checkForNewInterfaces.bind(this), NetworkManager.POLLING_TIME) + this.currentTimer.unref() // this timer won't prevent shutdown } private async checkForNewInterfaces(): Promise { - const latestInterfaces = await this.getCurrentNetworkInterfaces(); + const latestInterfaces = await this.getCurrentNetworkInterfaces() if (!this.currentTimer) { // if the timer is undefined, NetworkManager was shut down - return; + return } - let added: NetworkInterface[] | undefined = undefined; - let removed: NetworkInterface[] | undefined = undefined; - let changes: InterfaceChange[] | undefined = undefined; + let added: NetworkInterface[] | undefined + let removed: NetworkInterface[] | undefined + let changes: InterfaceChange[] | undefined for (const [name, networkInterface] of latestInterfaces) { - const currentInterface = this.currentInterfaces.get(name); + const currentInterface = this.currentInterfaces.get(name) if (currentInterface) { // the interface could potentially have changed if (!deepEqual(currentInterface, networkInterface)) { // indeed the interface changed const change: InterfaceChange = { - name: name, - }; + name, + } if (currentInterface.ipv4 !== networkInterface.ipv4) { // check for changed ipv4 if (currentInterface.ipv4) { - change.outdatedIpv4 = currentInterface.ipv4; + change.outdatedIpv4 = currentInterface.ipv4 } if (networkInterface.ipv4) { - change.updatedIpv4 = networkInterface.ipv4; + change.updatedIpv4 = networkInterface.ipv4 } } if (currentInterface.ipv6 !== networkInterface.ipv6) { // check for changed link-local ipv6 if (currentInterface.ipv6) { - change.outdatedIpv6 = currentInterface.ipv6; + change.outdatedIpv6 = currentInterface.ipv6 } if (networkInterface.ipv6) { - change.updatedIpv6 = networkInterface.ipv6; + change.updatedIpv6 = networkInterface.ipv6 } } if (currentInterface.globallyRoutableIpv6 !== networkInterface.globallyRoutableIpv6) { // check for changed routable ipv6 if (currentInterface.globallyRoutableIpv6) { - change.outdatedGloballyRoutableIpv6 = currentInterface.globallyRoutableIpv6; + change.outdatedGloballyRoutableIpv6 = currentInterface.globallyRoutableIpv6 } if (networkInterface.globallyRoutableIpv6) { - change.updatedGloballyRoutableIpv6 = networkInterface.globallyRoutableIpv6; + change.updatedGloballyRoutableIpv6 = networkInterface.globallyRoutableIpv6 } } if (currentInterface.uniqueLocalIpv6 !== networkInterface.uniqueLocalIpv6) { // check for changed ula if (currentInterface.uniqueLocalIpv6) { - change.outdatedUniqueLocalIpv6 = currentInterface.uniqueLocalIpv6; + change.outdatedUniqueLocalIpv6 = currentInterface.uniqueLocalIpv6 } if (networkInterface.uniqueLocalIpv6) { - change.updatedUniqueLocalIpv6 = networkInterface.uniqueLocalIpv6; + change.updatedUniqueLocalIpv6 = networkInterface.uniqueLocalIpv6 } } - this.currentInterfaces.set(name, networkInterface); + this.currentInterfaces.set(name, networkInterface) if (networkInterface.loopback) { - this.loopbackInterfaces.set(name, networkInterface); + this.loopbackInterfaces.set(name, networkInterface) } - (changes ??= []).push(change); + (changes ??= []).push(change) } } else { // new interface was added/started - this.currentInterfaces.set(name, networkInterface); + this.currentInterfaces.set(name, networkInterface) if (networkInterface.loopback) { - this.currentInterfaces.set(name, networkInterface); + this.currentInterfaces.set(name, networkInterface) } - (added ??= []).push(networkInterface); + (added ??= []).push(networkInterface) } } @@ -311,293 +322,289 @@ export class NetworkManager extends EventEmitter { if (this.currentInterfaces.size !== latestInterfaces.size) { for (const [name, networkInterface] of this.currentInterfaces) { if (!latestInterfaces.has(name)) { // interface was removed - this.currentInterfaces.delete(name); + this.currentInterfaces.delete(name) this.loopbackInterfaces.delete(name); - (removed ??= []).push(networkInterface); - + (removed ??= []).push(networkInterface) } } } if (added || removed || changes) { // emit an event only if anything changed - const addedString = added? added.map(iface => iface.name).join(","): ""; - const removedString = removed? removed.map(iface => iface.name).join(","): ""; - const changesString = changes? changes.map(iface => { - let string = `{ name: ${iface.name} `; - if (iface.outdatedIpv4 || iface.updatedIpv4) { - string += `, ${iface.outdatedIpv4} -> ${iface.updatedIpv4} `; - } - if (iface.outdatedIpv6 || iface.updatedIpv6) { - string += `, ${iface.outdatedIpv6} -> ${iface.updatedIpv6} `; - } - if (iface.outdatedGloballyRoutableIpv6 || iface.updatedGloballyRoutableIpv6) { - string += `, ${iface.outdatedGloballyRoutableIpv6} -> ${iface.updatedGloballyRoutableIpv6} `; - } - if (iface.outdatedUniqueLocalIpv6 || iface.updatedUniqueLocalIpv6) { - string += `, ${iface.outdatedUniqueLocalIpv6} -> ${iface.updatedUniqueLocalIpv6} `; - } - return string + "}"; - }).join(","): ""; + const addedString = added ? added.map(iface => iface.name).join(',') : '' + const removedString = removed ? removed.map(iface => iface.name).join(',') : '' + const changesString = changes + ? changes.map((iface) => { + let string = `{ name: ${iface.name} ` + if (iface.outdatedIpv4 || iface.updatedIpv4) { + string += `, ${iface.outdatedIpv4} -> ${iface.updatedIpv4} ` + } + if (iface.outdatedIpv6 || iface.updatedIpv6) { + string += `, ${iface.outdatedIpv6} -> ${iface.updatedIpv6} ` + } + if (iface.outdatedGloballyRoutableIpv6 || iface.updatedGloballyRoutableIpv6) { + string += `, ${iface.outdatedGloballyRoutableIpv6} -> ${iface.updatedGloballyRoutableIpv6} ` + } + if (iface.outdatedUniqueLocalIpv6 || iface.updatedUniqueLocalIpv6) { + string += `, ${iface.outdatedUniqueLocalIpv6} -> ${iface.updatedUniqueLocalIpv6} ` + } + return `${string}}` + }).join(',') + : '' - debug("Detected network changes: added: [%s], removed: [%s], changes: [%s]!", addedString, removedString, changesString); + debug('Detected network changes: added: [%s], removed: [%s], changes: [%s]!', addedString, removedString, changesString) this.emit(NetworkManagerEvent.NETWORK_UPDATE, { - added: added, - removed: removed, - changes: changes, - }); + added, + removed, + changes, + }) } - this.scheduleNextJob(); + this.scheduleNextJob() } private async getCurrentNetworkInterfaces(): Promise> { - let names: InterfaceName[]; + let names: InterfaceName[] if (this.restrictedInterfaces) { - names = this.restrictedInterfaces; + names = this.restrictedInterfaces - const loopback = NetworkManager.getLoopbackInterface(); + const loopback = NetworkManager.getLoopbackInterface() if (!names.includes(loopback)) { - names.push(loopback); + names.push(loopback) } } else { try { - names = await NetworkManager.getNetworkInterfaceNames(); - } catch (error) { - debug(`WARNING Detecting network interfaces for platform '${os.platform()}' failed. Trying to assume network interfaces! (${error.message})`); + names = await NetworkManager.getNetworkInterfaceNames() + } catch (error: any) { + debug(`WARNING Detecting network interfaces for platform '${os.platform()}' failed. Trying to assume network interfaces! (${error.message})`) // fallback way of gathering network interfaces (remember, there are docker images where the arp command is not installed) - names = NetworkManager.assumeNetworkInterfaceNames(); + names = NetworkManager.assumeNetworkInterfaceNames() } } - - const interfaces: Map = new Map(); - const networkInterfaces = os.networkInterfaces(); + const interfaces: Map = new Map() + const networkInterfaces = os.networkInterfaces() for (const name of names) { - const infos = networkInterfaces[name]; + const infos = networkInterfaces[name] if (!infos) { - continue; + continue } - let ipv4Info: NetworkInterfaceInfo | undefined = undefined; - let ipv6Info: NetworkInterfaceInfo | undefined = undefined; - let routableIpv6Info: NetworkInterfaceInfo | undefined = undefined; - let uniqueLocalIpv6Info: NetworkInterfaceInfo | undefined = undefined; - let internal = false; + let ipv4Info: NetworkInterfaceInfo | undefined + let ipv6Info: NetworkInterfaceInfo | undefined + let routableIpv6Info: NetworkInterfaceInfo | undefined + let uniqueLocalIpv6Info: NetworkInterfaceInfo | undefined + let internal = false for (const info of infos) { if (info.internal) { - internal = true; + internal = true } - // @ts-expect-error Nodejs 18+ uses the number 4 instead of the string "IPv4" - if ((info.family === "IPv4" || info.family === 4) && !ipv4Info) { - ipv4Info = info; - // @ts-expect-error Nodejs 18+ uses the number 4 instead of the string "IPv4" - } else if (info.family === "IPv6" || info.family === 6) { + // @ts-expect-error Node.js 18+ uses the number 4 instead of the string "IPv4" + if ((info.family === 'IPv4' || info.family === 4) && !ipv4Info) { + ipv4Info = info + // @ts-expect-error Node.js 18+ uses the number 4 instead of the string "IPv4" + } else if (info.family === 'IPv6' || info.family === 6) { if (this.excludeIpv6) { - continue; + continue } if (info.scopeid && !ipv6Info) { // we only care about non zero scope (aka link-local ipv6) - ipv6Info = info; + ipv6Info = info } else if (info.scopeid === 0) { // global routable ipv6 - if (info.address.startsWith("fc") || info.address.startsWith("fd")) { + if (info.address.startsWith('fc') || info.address.startsWith('fd')) { if (!uniqueLocalIpv6Info) { - uniqueLocalIpv6Info = info; + uniqueLocalIpv6Info = info } } else if (!routableIpv6Info) { - routableIpv6Info = info; + routableIpv6Info = info } } } if (ipv4Info && ipv6Info && routableIpv6Info && uniqueLocalIpv6Info) { - break; + break } } - assert(ipv4Info || ipv6Info, "Could not find valid addresses for interface '" + name + "'"); + assert(ipv4Info || ipv6Info, `Could not find valid addresses for interface '${name}'`) if (this.excludeIpv6Only && !ipv4Info) { - continue; + continue } const networkInterface: NetworkInterface = { - name: name, + name, loopback: internal, mac: (ipv4Info?.mac || ipv6Info?.mac)!, - }; + } if (ipv4Info) { - networkInterface.ipv4 = ipv4Info.address; - networkInterface.ip4Netmask = ipv4Info.netmask; - networkInterface.ipv4Netaddress = getNetAddress(ipv4Info.address, ipv4Info.netmask); + networkInterface.ipv4 = ipv4Info.address + networkInterface.ip4Netmask = ipv4Info.netmask + networkInterface.ipv4Netaddress = getNetAddress(ipv4Info.address, ipv4Info.netmask) } if (ipv6Info) { - networkInterface.ipv6 = ipv6Info.address; - networkInterface.ipv6Netmask = ipv6Info.netmask; + networkInterface.ipv6 = ipv6Info.address + networkInterface.ipv6Netmask = ipv6Info.netmask } if (routableIpv6Info) { - networkInterface.globallyRoutableIpv6 = routableIpv6Info.address; - networkInterface.globallyRoutableIpv6Netmask = routableIpv6Info.netmask; + networkInterface.globallyRoutableIpv6 = routableIpv6Info.address + networkInterface.globallyRoutableIpv6Netmask = routableIpv6Info.netmask } if (uniqueLocalIpv6Info) { - networkInterface.uniqueLocalIpv6 = uniqueLocalIpv6Info.address; - networkInterface.uniqueLocalIpv6Netmask = uniqueLocalIpv6Info.netmask; + networkInterface.uniqueLocalIpv6 = uniqueLocalIpv6Info.address + networkInterface.uniqueLocalIpv6Netmask = uniqueLocalIpv6Info.netmask } - interfaces.set(name, networkInterface); + interfaces.set(name, networkInterface) } - return interfaces; + return interfaces } public static resolveInterface(address: IPAddress): InterfaceName | undefined { - let interfaceName: InterfaceName | undefined; - - outer: for (const [name, infoArray] of Object.entries(os.networkInterfaces())) { + for (const [name, infoArray] of Object.entries(os.networkInterfaces())) { for (const info of infoArray ?? []) { if (info.address === address) { - interfaceName = name; - break outer; // exit out of both loops + return name // exit out of both loops } } } - return interfaceName; + return undefined } private static async getNetworkInterfaceNames(): Promise { // this function will always include the loopback interface - let promise: Promise; + let promise: Promise switch (os.platform()) { - case "win32": - promise = NetworkManager.getWindowsNetworkInterfaces(); - break; - case "linux": { - promise = NetworkManager.getLinuxNetworkInterfaces(); - break; + case 'win32': + promise = NetworkManager.getWindowsNetworkInterfaces() + break + case 'linux': { + promise = NetworkManager.getLinuxNetworkInterfaces() + break } - case "darwin": - promise = NetworkManager.getDarwinNetworkInterfaces(); - break; - case "freebsd": { - promise = NetworkManager.getFreeBSDNetworkInterfaces(); - break; + case 'darwin': + promise = NetworkManager.getDarwinNetworkInterfaces() + break + case 'freebsd': { + promise = NetworkManager.getFreeBSDNetworkInterfaces() + break } - case "openbsd": - case "sunos": { - promise = NetworkManager.getOpenBSD_SUNOS_NetworkInterfaces(); - break; + case 'openbsd': + case 'sunos': { + promise = NetworkManager.getOpenBSD_SUNOS_NetworkInterfaces() + break } default: - debug("Found unsupported platform %s", os.platform()); - return Promise.reject(new Error("unsupported platform!")); + debug('Found unsupported platform %s', os.platform()) + return Promise.reject(new Error('unsupported platform!')) } - let names: InterfaceName[]; + let names: InterfaceName[] try { - names = await promise; - } catch (error) { + names = await promise + } catch (error: any) { if (error.message !== NetworkManager.NOTHING_FOUND_MESSAGE) { - throw error; + throw error } - names = []; + names = [] } - const loopback = NetworkManager.getLoopbackInterface(); + const loopback = NetworkManager.getLoopbackInterface() if (!names.includes(loopback)) { - names.unshift(loopback); + names.unshift(loopback) } - return promise; + return promise } private static assumeNetworkInterfaceNames(): InterfaceName[] { // this method is a fallback trying to calculate network related interfaces in an platform independent way - const names: InterfaceName[] = []; + const names: InterfaceName[] = [] Object.entries(os.networkInterfaces()).forEach(([name, infos]) => { for (const info of infos ?? []) { // we add the loopback interface or interfaces which got a unique (global or local) ipv6 address // we currently don't just add all interfaces with ipv4 addresses as are often interfaces like VPNs, container/vms related // unique global or unique local ipv6 addresses give an indication that we are truly connected to "the Internet" - // as something like SLAAC must be going on - // in the end - // @ts-expect-error Nodejs 18+ uses the number 4/6 instead of the string "IPv4"/"IPv6" - if (info.internal || (info.family === "IPv4" || info.family === 4) || (info.family === "IPv6" || info.family === 6) && info.scopeid === 0) { + // as something like SLAAC must be going on in the end + // @ts-expect-error Node.js 18+ uses the number 4/6 instead of the string "IPv4"/"IPv6" + if (info.internal || ((info.family === 'IPv4' || info.family === 4) || ((info.family === 'IPv6' || info.family === 6) && info.scopeid === 0))) { if (!names.includes(name)) { - names.push(name); + names.push(name) } - break; + break } } - }); + }) - return names; + return names } private static getLoopbackInterface(): InterfaceName { for (const [name, infos] of Object.entries(os.networkInterfaces())) { for (const info of infos ?? []) { if (info.internal) { - return name; + return name } } } - throw new Error("Could not detect loopback interface!"); + throw new Error('Could not detect loopback interface!') } private static getWindowsNetworkInterfaces(): Promise { // does not return loopback interface return new Promise((resolve, reject) => { - childProcess.exec("arp -a | findstr /C:\"---\"", (error, stdout) => { + childProcess.exec('arp -a | findstr /C:"---"', (error, stdout) => { if (error) { - reject(error); - return; + reject(error) + return } - const lines = stdout.split(os.EOL); + const lines = stdout.split(os.EOL) - const addresses: IPv4Address[] = []; + const addresses: IPv4Address[] = [] for (let i = 0; i < lines.length - 1; i++) { - const line = lines[i].trim().split(" "); + const line = lines[i].trim().split(' ') if (line[line.length - 3]) { - addresses.push(line[line.length - 3]); + addresses.push(line[line.length - 3]) } else { - debug(`WINDOWS: Failed to read interface name from line ${i}: '${lines[i]}'`); + debug(`WINDOWS: Failed to read interface name from line ${i}: '${lines[i]}'`) } } - const names: InterfaceName[] = []; + const names: InterfaceName[] = [] for (const address of addresses) { - const name = NetworkManager.resolveInterface(address); + const name = NetworkManager.resolveInterface(address) if (name) { if (!names.includes(name)) { - names.push(name); + names.push(name) } } else { - debug(`WINDOWS: Couldn't resolve to an interface name from '${address}'`); + debug(`WINDOWS: Couldn't resolve to an interface name from '${address}'`) } } if (names.length) { - resolve(names); + resolve(names) } else { - reject(new Error(NetworkManager.NOTHING_FOUND_MESSAGE)); + reject(new Error(NetworkManager.NOTHING_FOUND_MESSAGE)) } - }); - }); + }) + }) } private static getDarwinNetworkInterfaces(): Promise { @@ -611,51 +618,51 @@ export class NetworkManager extends EventEmitter { // does not return loopback interface return new Promise((resolve, reject) => { // for ipv6 "ndp -a -n |grep -v permanent" with filtering for "expired" - childProcess.exec("arp -a -n -l", async (error, stdout) => { + childProcess.exec('arp -a -n -l', async (error, stdout) => { if (error) { - reject(error); - return; + reject(error) + return } - const lines = stdout.split(os.EOL); - const names: InterfaceName[] = []; + const lines = stdout.split(os.EOL) + const names: InterfaceName[] = [] for (let i = 1; i < lines.length - 1; i++) { - const interfaceName = lines[i].trim().split(NetworkManager.SPACE_PATTERN)[4]; + const interfaceName = lines[i].trim().split(NetworkManager.SPACE_PATTERN)[4] if (!interfaceName) { - debug(`DARWIN: Failed to read interface name from line ${i}: '${lines[i]}'`); - continue; + debug(`DARWIN: Failed to read interface name from line ${i}: '${lines[i]}'`) + continue } if (!names.includes(interfaceName)) { - names.push(interfaceName); + names.push(interfaceName) } } - const promises: Promise[] = []; + const promises: Promise[] = [] for (const name of names) { - const promise = NetworkManager.getDarwinWifiNetworkState(name).then(state => { + const promise = NetworkManager.getDarwinWifiNetworkState(name).then((state) => { if (state !== WifiState.NOT_A_WIFI_INTERFACE && state !== WifiState.CONNECTED) { // removing wifi networks which are not connected to any networks - const index = names.indexOf(name); + const index = names.indexOf(name) if (index !== -1) { - names.splice(index, 1); + names.splice(index, 1) } } - }); + }) - promises.push(promise); + promises.push(promise) } - await Promise.all(promises); + await Promise.all(promises) if (names.length) { - resolve(names); + resolve(names) } else { - reject(new Error(NetworkManager.NOTHING_FOUND_MESSAGE)); + reject(new Error(NetworkManager.NOTHING_FOUND_MESSAGE)) } - }); - }); + }) + }) } private static getLinuxNetworkInterfaces(): Promise { @@ -663,128 +670,127 @@ export class NetworkManager extends EventEmitter { return new Promise((resolve, reject) => { // we use "ip neigh" here instead of the aliases like "ip neighbour" or "ip neighbor" // as those were only added like 5 years ago https://github.com/shemminger/iproute2/commit/ede723964a065992bf9d0dbe3f780e65ca917872 - childProcess.exec("ip neigh show", (error, stdout) => { + childProcess.exec('ip neigh show', (error, stdout) => { if (error) { - if (error.message.includes("ip: not found")) { - debug("LINUX: ip was not found on the system. Falling back to assuming network interfaces!"); - resolve(NetworkManager.assumeNetworkInterfaceNames()); - return; + if (error.message.includes('ip: not found')) { + debug('LINUX: ip was not found on the system. Falling back to assuming network interfaces!') + resolve(NetworkManager.assumeNetworkInterfaceNames()) + return } - reject(error); - return; + reject(error) + return } - const lines = stdout.split(os.EOL); - const names: InterfaceName[] = []; + const lines = stdout.split(os.EOL) + const names: InterfaceName[] = [] for (let i = 0; i < lines.length - 1; i++) { - const parts = lines[i].trim().split(NetworkManager.SPACE_PATTERN); + const parts = lines[i].trim().split(NetworkManager.SPACE_PATTERN) - let devIndex = 0; + let devIndex = 0 for (; devIndex < parts.length; devIndex++) { - if (parts[devIndex] === "dev") { + if (parts[devIndex] === 'dev') { // the next index marks the interface name - break; + break } } if (devIndex >= parts.length) { - debug(`LINUX: Out of bounds when reading interface name from line ${i}: '${lines[i]}'`); - continue; + debug(`LINUX: Out of bounds when reading interface name from line ${i}: '${lines[i]}'`) + continue } - const interfaceName = parts[devIndex + 1]; + const interfaceName = parts[devIndex + 1] if (!interfaceName) { - debug(`LINUX: Failed to read interface name from line ${i}: '${lines[i]}'`); - continue; + debug(`LINUX: Failed to read interface name from line ${i}: '${lines[i]}'`) + continue } if (!names.includes(interfaceName)) { - names.push(interfaceName); + names.push(interfaceName) } } if (names.length) { - resolve(names); + resolve(names) } else { - reject(new Error(NetworkManager.NOTHING_FOUND_MESSAGE)); + reject(new Error(NetworkManager.NOTHING_FOUND_MESSAGE)) } - }); - }); + }) + }) } private static getFreeBSDNetworkInterfaces(): Promise { // does not return loopback interface return new Promise((resolve, reject) => { - childProcess.exec("arp -a -n", (error, stdout) => { + childProcess.exec('arp -a -n', (error, stdout) => { if (error) { - reject(error); - return; + reject(error) + return } - const lines = stdout.split(os.EOL); - const names: InterfaceName[] = []; - + const lines = stdout.split(os.EOL) + const names: InterfaceName[] = [] for (let i = 0; i < lines.length - 1; i++) { - const interfaceName = lines[i].trim().split(NetworkManager.SPACE_PATTERN)[5]; + const interfaceName = lines[i].trim().split(NetworkManager.SPACE_PATTERN)[5] if (!interfaceName) { - debug(`FreeBSD: Failed to read interface name from line ${i}: '${lines[i]}'`); - continue; + debug(`FreeBSD: Failed to read interface name from line ${i}: '${lines[i]}'`) + continue } if (!names.includes(interfaceName)) { - names.push(interfaceName); + names.push(interfaceName) } } if (names.length) { - resolve(names); + resolve(names) } else { - reject(new Error(NetworkManager.NOTHING_FOUND_MESSAGE)); + reject(new Error(NetworkManager.NOTHING_FOUND_MESSAGE)) } - }); - }); + }) + }) } private static getOpenBSD_SUNOS_NetworkInterfaces(): Promise { // does not return loopback interface return new Promise((resolve, reject) => { // for ipv6 something like "ndp -a -n | grep R" (grep for reachable; maybe exclude permanent?) - childProcess.exec("arp -a -n", (error, stdout) => { + childProcess.exec('arp -a -n', (error, stdout) => { if (error) { - reject(error); - return; + reject(error) + return } - const interfaceArrayOffset = os.platform() === "sunos" ? 0 : 2; - const lines = stdout.split(os.EOL); - const names: InterfaceName[] = []; + const interfaceArrayOffset = os.platform() === 'sunos' ? 0 : 2 + const lines = stdout.split(os.EOL) + const names: InterfaceName[] = [] for (let i = 1; i < lines.length - 1; i++) { - const interfaceName = lines[i].trim().split(NetworkManager.SPACE_PATTERN)[interfaceArrayOffset]; + const interfaceName = lines[i].trim().split(NetworkManager.SPACE_PATTERN)[interfaceArrayOffset] if (!interfaceName) { - debug(`${os.platform()}: Failed to read interface name from line ${i}: '${lines[i]}'`); - continue; + debug(`${os.platform()}: Failed to read interface name from line ${i}: '${lines[i]}'`) + continue } if (!names.includes(interfaceName)) { - names.push(interfaceName); + names.push(interfaceName) } } if (names.length) { - resolve(names); + resolve(names) } else { - reject(new Error(NetworkManager.NOTHING_FOUND_MESSAGE)); + reject(new Error(NetworkManager.NOTHING_FOUND_MESSAGE)) } - }); - }); + }) + }) } private static getDarwinWifiNetworkState(name: InterfaceName): Promise { - return new Promise(resolve => { + return new Promise((resolve) => { /* * networksetup outputs the following in the listed scenarios: * @@ -805,35 +811,36 @@ export class NetworkManager extends EventEmitter { * Other messages handled here. * "All Wi-Fi network services are disabled": encountered on macOS VM machines */ - childProcess.exec("networksetup -getairportnetwork " + name, (error, stdout) => { + childProcess.exec(`networksetup -getairportnetwork ${name}`, (error, stdout) => { if (error) { - if (stdout.includes("not a Wi-Fi interface")) { - resolve(WifiState.NOT_A_WIFI_INTERFACE); - return; + if (stdout.includes('not a Wi-Fi interface')) { + resolve(WifiState.NOT_A_WIFI_INTERFACE) + return } - console.log(`CIAO WARN: While checking networksetup for ${name} encountered an error (${error.message}) with output: ${stdout.replace(os.EOL, "; ")}`); - resolve(WifiState.UNDEFINED); - return; + // eslint-disable-next-line no-console + console.log(`CIAO WARN: While checking networksetup for ${name} encountered an error (${error.message}) with output: ${stdout.replace(os.EOL, '; ')}`) + resolve(WifiState.UNDEFINED) + return } - let wifiState = WifiState.UNDEFINED; - if (stdout.includes("not a Wi-Fi interface")) { - wifiState = WifiState.NOT_A_WIFI_INTERFACE; - } else if (stdout.includes("Current Wi-Fi Network")) { - wifiState = WifiState.CONNECTED; - } else if (stdout.includes("not associated")) { - wifiState = WifiState.NOT_ASSOCIATED; - } else if (stdout.includes("All Wi-Fi network services are disabled")) { + let wifiState = WifiState.UNDEFINED + if (stdout.includes('not a Wi-Fi interface')) { + wifiState = WifiState.NOT_A_WIFI_INTERFACE + } else if (stdout.includes('Current Wi-Fi Network')) { + wifiState = WifiState.CONNECTED + } else if (stdout.includes('not associated')) { + wifiState = WifiState.NOT_ASSOCIATED + } else if (stdout.includes('All Wi-Fi network services are disabled')) { // typically encountered on a macOS VM or something not having a WiFi card - wifiState = WifiState.NOT_A_WIFI_INTERFACE; + wifiState = WifiState.NOT_A_WIFI_INTERFACE } else { - console.log(`CIAO WARN: While checking networksetup for ${name} encountered an unknown output: ${stdout.replace(os.EOL, "; ")}`); + // eslint-disable-next-line no-console + console.log(`CIAO WARN: While checking networksetup for ${name} encountered an unknown output: ${stdout.replace(os.EOL, '; ')}`) } - resolve(wifiState); - }); - }); + resolve(wifiState) + }) + }) } - } diff --git a/src/Responder.ts b/src/Responder.ts index e12c4c6..a6153d6 100644 --- a/src/Responder.ts +++ b/src/Responder.ts @@ -1,48 +1,50 @@ -import assert from "assert"; -import createDebug from "debug"; -import { - CiaoService, - InternalServiceEvent, +/* global NodeJS */ +import type { PublishCallback, RecordsUpdateCallback, ServiceOptions, - ServiceState, UnpublishCallback, -} from "./CiaoService"; -import { DNSPacket, DNSResponseDefinition, QClass, QType, RType } from "./coder/DNSPacket"; -import { Question } from "./coder/Question"; -import { AAAARecord } from "./coder/records/AAAARecord"; -import { ARecord } from "./coder/records/ARecord"; -import { OPTRecord } from "./coder/records/OPTRecord"; -import { PTRRecord } from "./coder/records/PTRRecord"; -import { SRVRecord } from "./coder/records/SRVRecord"; -import { TXTRecord } from "./coder/records/TXTRecord"; -import { ResourceRecord } from "./coder/ResourceRecord"; -import { - EndpointInfo, - MDNSServer, - MDNSServerOptions, - PacketHandler, - SendResultFailedRatio, - SendResultFormatError, -} from "./MDNSServer"; -import { InterfaceName, NetworkManagerEvent, NetworkUpdate } from "./NetworkManager"; -import { Announcer } from "./responder/Announcer"; -import { Prober } from "./responder/Prober"; -import { QueryResponse, RecordAddMethod } from "./responder/QueryResponse"; -import { QueuedResponse } from "./responder/QueuedResponse"; -import { TruncatedQuery, TruncatedQueryEvent, TruncatedQueryResult } from "./responder/TruncatedQuery"; -import { ERR_INTERFACE_NOT_FOUND, ERR_SERVER_CLOSED } from "./util/errors"; -import { PromiseTimeout } from "./util/promise-utils"; -import { sortedInsert } from "./util/sorted-array"; -import Timeout = NodeJS.Timeout; - -const debug = createDebug("ciao:Responder"); - -const queuedResponseComparator = (a: QueuedResponse, b: QueuedResponse) => { - return a.estimatedTimeToBeSent - b.estimatedTimeToBeSent; -}; +} from './CiaoService' +import type { DNSResponseDefinition } from './coder/DNSPacket' +import type { OPTRecord } from './coder/records/OPTRecord' +import type { ResourceRecord } from './coder/ResourceRecord' +import type { EndpointInfo, MDNSServerOptions, PacketHandler } from './MDNSServer' +import type { InterfaceName, NetworkUpdate } from './NetworkManager' +import type { RecordAddMethod } from './responder/QueryResponse' + +import assert from 'node:assert' +import process from 'node:process' + +import createDebug from 'debug' + +import { CiaoService, InternalServiceEvent, ServiceState } from './CiaoService.js' +import { DNSPacket, QClass, QType, RType } from './coder/DNSPacket.js' +import { Question } from './coder/Question.js' +import { AAAARecord } from './coder/records/AAAARecord.js' +import { ARecord } from './coder/records/ARecord.js' +import { PTRRecord } from './coder/records/PTRRecord.js' +import { SRVRecord } from './coder/records/SRVRecord.js' +import { TXTRecord } from './coder/records/TXTRecord.js' +import { MDNSServer, SendResultFailedRatio, sendResultFormatError } from './MDNSServer.js' +import { NetworkManagerEvent } from './NetworkManager.js' +import { Announcer } from './responder/Announcer.js' +import { Prober } from './responder/Prober.js' +import { QueryResponse } from './responder/QueryResponse.js' +import { QueuedResponse } from './responder/QueuedResponse.js' +import { TruncatedQuery, TruncatedQueryEvent, TruncatedQueryResult } from './responder/TruncatedQuery.js' +import { ERR_INTERFACE_NOT_FOUND, ERR_SERVER_CLOSED } from './util/errors.js' +import { PromiseTimeout } from './util/promise-utils.js' +import { sortedInsert } from './util/sorted-array.js' + +import Timeout = NodeJS.Timeout + +const debug = createDebug('ciao:Responder') + +function queuedResponseComparator(a: QueuedResponse, b: QueuedResponse) { + return a.estimatedTimeToBeSent - b.estimatedTimeToBeSent +} +// eslint-disable-next-line no-restricted-syntax const enum ConflictType { // RFC 6762 6.6. NO_CONFLICT, CONFLICTING_RDATA, // we must do conflict resolution @@ -53,12 +55,12 @@ export type ResponderOptions = { /** * @private */ - periodicBroadcasts?: boolean; + periodicBroadcasts?: boolean /** * @private */ - ignoreUnicastResponseFlag?: boolean; -} & MDNSServerOptions; + ignoreUnicastResponseFlag?: boolean +} & MDNSServerOptions /** * A Responder instance represents a running MDNSServer and a set of advertised services. @@ -67,26 +69,25 @@ export type ResponderOptions = { * It handles answering questions arriving at the multicast address. */ export class Responder implements PacketHandler { - /** * @private */ - public static readonly SERVICE_TYPE_ENUMERATION_NAME = "_services._dns-sd._udp.local."; + public static readonly SERVICE_TYPE_ENUMERATION_NAME = '_services._dns-sd._udp.local.' - private static readonly INSTANCES: Map = new Map(); + private static readonly INSTANCES: Map = new Map() - private readonly server: MDNSServer; - private promiseChain: Promise; + private readonly server: MDNSServer + private promiseChain: Promise - private refCount = 1; - private optionsString = ""; - private bound = false; + private refCount = 1 + private optionsString = '' + private bound = false /** * Announced services is indexed by the {@link dnsLowerCase} if the fqdn (as of RFC 1035 3.1). * As soon as the probing step is finished the service is added to the announced services Map. */ - private readonly announcedServices: Map = new Map(); + private readonly announcedServices: Map = new Map() /** * map representing all our shared PTR records. * Typically, we hold stuff like '_services._dns-sd._udp.local' (RFC 6763 9.), '_hap._tcp.local'. @@ -95,88 +96,89 @@ export class Responder implements PacketHandler { * For every pointer we may hold multiple entries (like multiple services can advertise on _hap._tcp.local). * The key as well as all values are {@link dnsLowerCase} */ - private readonly servicePointer: Map = new Map(); + private readonly servicePointer: Map = new Map() - private readonly truncatedQueries: Record = {}; // indexed by : - private readonly delayedMulticastResponses: QueuedResponse[] = []; + private readonly truncatedQueries: Record = {} // indexed by : + private readonly delayedMulticastResponses: QueuedResponse[] = [] - private currentProber?: Prober; + private currentProber?: Prober - private readonly ignoreUnicastResponseFlag?: boolean; - private broadcastInterval?: Timeout; + private readonly ignoreUnicastResponseFlag?: boolean + private broadcastInterval?: Timeout /** * Refer to {@link getResponder} in the index file * - * @private should not be used directly. Please use the getResponder method defined in index file. + * @private */ public static getResponder(options?: ResponderOptions): Responder { - const optionsString = options? JSON.stringify(options): ""; + const optionsString = options ? JSON.stringify(options) : '' - const responder = this.INSTANCES.get(optionsString); + const responder = this.INSTANCES.get(optionsString) if (responder) { - responder.refCount++; - return responder; + responder.refCount++ + return responder } else { - const responder = new Responder(options); - this.INSTANCES.set(optionsString, responder); - responder.optionsString = optionsString; - return responder; + const responder = new Responder(options) + this.INSTANCES.set(optionsString, responder) + responder.optionsString = optionsString + return responder } } private constructor(options?: ResponderOptions) { - this.server = new MDNSServer(this, options); - this.promiseChain = this.start(); + this.server = new MDNSServer(this, options) + this.promiseChain = this.start() - this.server.getNetworkManager().on(NetworkManagerEvent.NETWORK_UPDATE, this.handleNetworkUpdate.bind(this)); + this.server.getNetworkManager().on(NetworkManagerEvent.NETWORK_UPDATE, this.handleNetworkUpdate.bind(this)) - this.ignoreUnicastResponseFlag = options?.ignoreUnicastResponseFlag; + this.ignoreUnicastResponseFlag = options?.ignoreUnicastResponseFlag if (options?.periodicBroadcasts) { - this.broadcastInterval = setTimeout(this.handlePeriodicBroadcasts.bind(this), 30000).unref(); + this.broadcastInterval = setTimeout(this.handlePeriodicBroadcasts.bind(this), 30000).unref() } } private handlePeriodicBroadcasts() { - this.broadcastInterval = undefined; + this.broadcastInterval = undefined - debug("Sending periodic announcement on " + Array.from(this.server.getNetworkManager().getInterfaceMap().keys()).join(", ")); + debug(`Sending periodic announcement on ${Array.from(this.server.getNetworkManager().getInterfaceMap().keys()).join(', ')}`) - const boundInterfaceNames = Array.from(this.server.getBoundInterfaceNames()); + const boundInterfaceNames = Array.from(this.server.getBoundInterfaceNames()) for (const networkInterface of this.server.getNetworkManager().getInterfaceMap().values()) { - const question = new Question("_hap._tcp.local.", QType.PTR, false); + const question = new Question('_hap._tcp.local.', QType.PTR, false) - let responses4: QueryResponse[] = [], responses6: QueryResponse[] = []; + let responses4: QueryResponse[] = [] + let responses6: QueryResponse[] = [] if (boundInterfaceNames.includes(networkInterface.name)) { responses4 = this.answerQuestion(question, { port: 5353, address: networkInterface.ipv4Netaddress!, interface: networkInterface.name, - }); + }) } - if (boundInterfaceNames.includes(networkInterface.name + "/6")) { + if (boundInterfaceNames.includes(`${networkInterface.name}/6`)) { responses6 = this.answerQuestion(question, { port: 5353, address: networkInterface.ipv6!, - interface: networkInterface.name + "/6", - }); + interface: `${networkInterface.name}/6`, + }) } - const responses = [...responses4, ...responses6]; - QueryResponse.combineResponses(responses); + const responses = [...responses4, ...responses6] + QueryResponse.combineResponses(responses) for (const response of responses) { if (!response.hasAnswers()) { - continue; + continue } - this.server.sendResponse(response.asPacket(), networkInterface.name); + this.server.sendResponse(response.asPacket(), networkInterface.name) } } - this.broadcastInterval = setTimeout(this.handlePeriodicBroadcasts.bind(this), Math.random() * 3000 + 27000).unref(); + this.broadcastInterval = setTimeout(this.handlePeriodicBroadcasts.bind(this), Math.random() * 3000 + 27000).unref() } /** @@ -186,15 +188,15 @@ export class Responder implements PacketHandler { * @returns The newly created {@link CiaoService} instance can be used to advertise and manage the created service. */ public createService(options: ServiceOptions): CiaoService { - const service = new CiaoService(this.server.getNetworkManager(), options); + const service = new CiaoService(this.server.getNetworkManager(), options) - service.on(InternalServiceEvent.PUBLISH, this.advertiseService.bind(this, service)); - service.on(InternalServiceEvent.UNPUBLISH, this.unpublishService.bind(this, service)); - service.on(InternalServiceEvent.REPUBLISH, this.republishService.bind(this, service)); - service.on(InternalServiceEvent.RECORD_UPDATE, this.handleServiceRecordUpdate.bind(this, service)); - service.on(InternalServiceEvent.RECORD_UPDATE_ON_INTERFACE, this.handleServiceRecordUpdateOnInterface.bind(this, service)); + service.on(InternalServiceEvent.PUBLISH, this.advertiseService.bind(this, service)) + service.on(InternalServiceEvent.UNPUBLISH, this.unpublishService.bind(this, service)) + service.on(InternalServiceEvent.REPUBLISH, this.republishService.bind(this, service)) + service.on(InternalServiceEvent.RECORD_UPDATE, this.handleServiceRecordUpdate.bind(this, service)) + service.on(InternalServiceEvent.RECORD_UPDATE_ON_INTERFACE, this.handleServiceRecordUpdateOnInterface.bind(this, service)) - return service; + return service } /** @@ -209,68 +211,68 @@ export class Responder implements PacketHandler { * (or immediately if any other users have a reference to this Responder instance). */ public shutdown(): Promise { - this.refCount--; // we trust the user here, that the shutdown will not be executed twice or something :thinking: + this.refCount-- // we trust the user here, that the shutdown will not be executed twice or something :thinking: if (this.refCount > 0) { - return Promise.resolve(); + return Promise.resolve() } if (this.currentProber) { // Services which are in Probing step aren't included in announcedServices Map // thus we need to cancel them as well - this.currentProber.cancel(); + this.currentProber.cancel() } if (this.broadcastInterval) { - clearTimeout(this.broadcastInterval); + clearTimeout(this.broadcastInterval) } - Responder.INSTANCES.delete(this.optionsString); + Responder.INSTANCES.delete(this.optionsString) - debug("Shutting down Responder..."); + debug('Shutting down Responder...') - const promises: Promise[] = []; + const promises: Promise[] = [] for (const service of this.announcedServices.values()) { - promises.push(this.unpublishService(service)); + promises.push(this.unpublishService(service)) } return Promise.all(promises).then(() => { - this.server.shutdown(); - this.bound = false; - }); + this.server.shutdown() + this.bound = false + }) } public getAnnouncedServices(): IterableIterator { - return this.announcedServices.values(); + return this.announcedServices.values() } private start(): Promise { if (this.bound) { - throw new Error("Server is already bound!"); + throw new Error('Server is already bound!') } - this.bound = true; - return this.server.bind(); + this.bound = true + return this.server.bind() } private advertiseService(service: CiaoService, callback: PublishCallback): Promise { if (service.serviceState === ServiceState.ANNOUNCED) { - throw new Error("Can't publish a service that is already announced. Received " + service.serviceState + " for service " + service.getFQDN()); + throw new Error(`Can't publish a service that is already announced. Received ${service.serviceState} for service ${service.getFQDN()}`) } else if (service.serviceState === ServiceState.PROBING) { return this.promiseChain.then(() => { if (service.currentAnnouncer) { - return service.currentAnnouncer.awaitAnnouncement(); + return service.currentAnnouncer.awaitAnnouncement() } - }); + }) } else if (service.serviceState === ServiceState.ANNOUNCING) { - assert(service.currentAnnouncer, "Service is in state ANNOUNCING though has no linked announcer!"); + assert(service.currentAnnouncer, 'Service is in state ANNOUNCING though has no linked announcer!') if (service.currentAnnouncer!.isSendingGoodbye()) { - return service.currentAnnouncer!.awaitAnnouncement().then(() => this.advertiseService(service, callback)); + return service.currentAnnouncer!.awaitAnnouncement().then(() => this.advertiseService(service, callback)) } else { - return service.currentAnnouncer!.cancel().then(() => this.advertiseService(service, callback)); + return service.currentAnnouncer!.cancel().then(() => this.advertiseService(service, callback)) } } - debug("[%s] Going to advertise service...", service.getFQDN()); // TODO include restricted addresses and stuff + debug('[%s] Going to advertise service...', service.getFQDN()) // TODO include restricted addresses and stuff // multicast loopback is not enabled for our sockets, though we do some stuff, so Prober will handle potential // name conflicts with our own services: @@ -280,20 +282,21 @@ export class Responder implements PacketHandler { this.promiseChain = this.promiseChain // we synchronize all ongoing probes here .then(() => service.rebuildServiceRecords()) // build the records the first time for the prober - .then(() => this.probe(service)); // probe errors are catch below + .then(() => this.probe(service)) // probe errors are catch below return this.promiseChain.then(() => { // we are not returning the promise returned by announced here, only PROBING is synchronized - this.announce(service).catch(reason => { + this.announce(service).catch((reason) => { // handle announce errors - console.log(`[${service.getFQDN()}] failed announcing with reason: ${reason}. Trying again in 2 seconds!`); + // eslint-disable-next-line no-console + console.log(`[${service.getFQDN()}] failed announcing with reason: ${reason}. Trying again in 2 seconds!`) return PromiseTimeout(2000).then(() => this.advertiseService(service, () => { // empty - })); - }); + })) + }) - callback(); // service is considered announced. After the call to the announce() method the service state is set to ANNOUNCING - }, reason => { + callback() // service is considered announced. After the call to the announce() method the service state is set to ANNOUNCING + }, (reason) => { /* * I know seems unintuitive to place the probe error handling below here, miles away from the probe method call. * Trust me it makes sense (encountered regression now two times in a row). @@ -305,346 +308,346 @@ export class Responder implements PacketHandler { // handle probe error if (reason === Prober.CANCEL_REASON) { - callback(); + callback() } else { // other errors are only thrown when sockets error occur - console.log(`[${service.getFQDN()}] failed probing with reason: ${reason}. Trying again in 2 seconds!`); - return PromiseTimeout(2000).then(() => this.advertiseService(service, callback)); + // eslint-disable-next-line no-console + console.log(`[${service.getFQDN()}] failed probing with reason: ${reason}. Trying again in 2 seconds!`) + return PromiseTimeout(2000).then(() => this.advertiseService(service, callback)) } - }); + }) } private async republishService(service: CiaoService, callback: PublishCallback, delayAnnounce = false): Promise { if (service.serviceState !== ServiceState.ANNOUNCED && service.serviceState !== ServiceState.ANNOUNCING) { - throw new Error("Can't unpublish a service which isn't announced yet. Received " + service.serviceState + " for service " + service.getFQDN()); + throw new Error(`Can't unpublish a service which isn't announced yet. Received ${service.serviceState} for service ${service.getFQDN()}`) } - debug("[%s] Readvertising service...", service.getFQDN()); + debug('[%s] Readvertising service...', service.getFQDN()) if (service.serviceState === ServiceState.ANNOUNCING) { - assert(service.currentAnnouncer, "Service is in state ANNOUNCING though has no linked announcer!"); + assert(service.currentAnnouncer, 'Service is in state ANNOUNCING though has no linked announcer!') const promise = service.currentAnnouncer!.isSendingGoodbye() ? service.currentAnnouncer!.awaitAnnouncement() - : service.currentAnnouncer!.cancel(); + : service.currentAnnouncer!.cancel() - return promise.then(() => this.advertiseService(service, callback)); + return promise.then(() => this.advertiseService(service, callback)) } // first of all remove it from our advertisedService Map and remove all the maintained PTRs - this.clearService(service); - service.serviceState = ServiceState.UNANNOUNCED; // the service is now considered unannounced + this.clearService(service) + service.serviceState = ServiceState.UNANNOUNCED // the service is now considered unannounced // and now we basically just announce the service by doing probing and the 'announce' step if (delayAnnounce) { return PromiseTimeout(1000) - .then(() => this.advertiseService(service, callback)); + .then(() => this.advertiseService(service, callback)) } else { - return this.advertiseService(service, callback); + return this.advertiseService(service, callback) } } private unpublishService(service: CiaoService, callback?: UnpublishCallback): Promise { if (service.serviceState === ServiceState.UNANNOUNCED) { - throw new Error("Can't unpublish a service which isn't announced yet. Received " + service.serviceState + " for service " + service.getFQDN()); + throw new Error(`Can't unpublish a service which isn't announced yet. Received ${service.serviceState} for service ${service.getFQDN()}`) } if (service.serviceState === ServiceState.ANNOUNCED || service.serviceState === ServiceState.ANNOUNCING) { if (service.serviceState === ServiceState.ANNOUNCING) { - assert(service.currentAnnouncer, "Service is in state ANNOUNCING though has no linked announcer!"); + assert(service.currentAnnouncer, 'Service is in state ANNOUNCING though has no linked announcer!') if (service.currentAnnouncer!.isSendingGoodbye()) { - return service.currentAnnouncer!.awaitAnnouncement(); // we are already sending a goodbye + return service.currentAnnouncer!.awaitAnnouncement() // we are already sending a goodbye } return service.currentAnnouncer!.cancel().then(() => { - service.serviceState = ServiceState.ANNOUNCED; // unpublishService requires announced state - return this.unpublishService(service, callback); - }); + service.serviceState = ServiceState.ANNOUNCED // unpublishService requires announced state + return this.unpublishService(service, callback) + }) } - debug("[%s] Removing service from the network", service.getFQDN()); - this.clearService(service); - service.serviceState = ServiceState.UNANNOUNCED; + debug('[%s] Removing service from the network', service.getFQDN()) + this.clearService(service) + service.serviceState = ServiceState.UNANNOUNCED - let promise = this.goodbye(service); + let promise = this.goodbye(service) if (callback) { - promise = promise.then(() => callback(), reason => { - console.log(`[${service.getFQDN()}] failed goodbye with reason: ${reason}.`); - callback(); - }); + promise = promise.then(() => callback(), (reason) => { + // eslint-disable-next-line no-console + console.log(`[${service.getFQDN()}] failed goodbye with reason: ${reason}.`) + callback() + }) } - return promise; + return promise } else if (service.serviceState === ServiceState.PROBING) { - debug("[%s] Canceling probing", service.getFQDN()); + debug('[%s] Canceling probing', service.getFQDN()) if (this.currentProber && this.currentProber.getService() === service) { - this.currentProber.cancel(); - this.currentProber = undefined; + this.currentProber.cancel() + this.currentProber = undefined } - service.serviceState = ServiceState.UNANNOUNCED; + service.serviceState = ServiceState.UNANNOUNCED } - if (typeof callback === "function") { - callback(); + if (typeof callback === 'function') { + callback() } - return Promise.resolve(); + return Promise.resolve() } private clearService(service: CiaoService): void { - const serviceFQDN = service.getLowerCasedFQDN(); - const typePTR = service.getLowerCasedTypePTR(); - const subtypePTRs = service.getLowerCasedSubtypePTRs(); // possibly undefined + const serviceFQDN = service.getLowerCasedFQDN() + const typePTR = service.getLowerCasedTypePTR() + const subtypePTRs = service.getLowerCasedSubtypePTRs() // possibly undefined - this.removePTR(Responder.SERVICE_TYPE_ENUMERATION_NAME, typePTR); - this.removePTR(typePTR, serviceFQDN); + this.removePTR(Responder.SERVICE_TYPE_ENUMERATION_NAME, typePTR) + this.removePTR(typePTR, serviceFQDN) if (subtypePTRs) { for (const ptr of subtypePTRs) { - this.removePTR(ptr, serviceFQDN); + this.removePTR(ptr, serviceFQDN) } } - this.announcedServices.delete(service.getLowerCasedFQDN()); + this.announcedServices.delete(service.getLowerCasedFQDN()) } private addPTR(ptr: string, name: string): void { // we don't call lower case here, as we expect the caller to have done that already // name = dnsLowerCase(name); // worst case is that the meta query ptr record contains lower cased destination - const names = this.servicePointer.get(ptr); + const names = this.servicePointer.get(ptr) if (names) { if (!names.includes(name)) { - names.push(name); + names.push(name) } } else { - this.servicePointer.set(ptr, [name]); + this.servicePointer.set(ptr, [name]) } } private removePTR(ptr: string, name: string): void { - const names = this.servicePointer.get(ptr); + const names = this.servicePointer.get(ptr) if (names) { - const index = names.indexOf(name); + const index = names.indexOf(name) if (index !== -1) { - names.splice(index, 1); + names.splice(index, 1) } if (names.length === 0) { - this.servicePointer.delete(ptr); + this.servicePointer.delete(ptr) } } } private probe(service: CiaoService): Promise { if (service.serviceState !== ServiceState.UNANNOUNCED) { - throw new Error("Can't probe for a service which is announced already. Received " + service.serviceState + " for service " + service.getFQDN()); + throw new Error(`Can't probe for a service which is announced already. Received ${service.serviceState} for service ${service.getFQDN()}`) } - service.serviceState = ServiceState.PROBING; + service.serviceState = ServiceState.PROBING - assert(this.currentProber === undefined, "Tried creating new Prober when there already was one active!"); - this.currentProber = new Prober(this, this.server, service); + assert(this.currentProber === undefined, 'Tried creating new Prober when there already was one active!') + this.currentProber = new Prober(this, this.server, service) return this.currentProber.probe() .then(() => { - this.currentProber = undefined; - service.serviceState = ServiceState.PROBED; - }, reason => { - service.serviceState = ServiceState.UNANNOUNCED; - this.currentProber = undefined; - return Promise.reject(reason); // forward reason - }); + this.currentProber = undefined + service.serviceState = ServiceState.PROBED + }, (reason) => { + service.serviceState = ServiceState.UNANNOUNCED + this.currentProber = undefined + return Promise.reject(reason) // forward reason + }) } private announce(service: CiaoService): Promise { if (service.serviceState !== ServiceState.PROBED) { - throw new Error("Cannot announce service which was not probed unique. Received " + service.serviceState + " for service " + service.getFQDN()); + throw new Error(`Cannot announce service which was not probed unique. Received ${service.serviceState} for service ${service.getFQDN()}`) } - assert(service.currentAnnouncer === undefined, "Service " + service.getFQDN() + " is already announcing!"); + assert(service.currentAnnouncer === undefined, `Service ${service.getFQDN()} is already announcing!`) - service.serviceState = ServiceState.ANNOUNCING; + service.serviceState = ServiceState.ANNOUNCING const announcer = new Announcer(this.server, service, { repetitions: 3, - }); - service.currentAnnouncer = announcer; + }) + service.currentAnnouncer = announcer - const serviceFQDN = service.getLowerCasedFQDN(); - const typePTR = service.getLowerCasedTypePTR(); - const subtypePTRs = service.getLowerCasedSubtypePTRs(); // possibly undefined + const serviceFQDN = service.getLowerCasedFQDN() + const typePTR = service.getLowerCasedTypePTR() + const subtypePTRs = service.getLowerCasedSubtypePTRs() // possibly undefined - this.addPTR(Responder.SERVICE_TYPE_ENUMERATION_NAME, typePTR); - this.addPTR(typePTR, serviceFQDN); + this.addPTR(Responder.SERVICE_TYPE_ENUMERATION_NAME, typePTR) + this.addPTR(typePTR, serviceFQDN) if (subtypePTRs) { for (const ptr of subtypePTRs) { - this.addPTR(ptr, serviceFQDN); + this.addPTR(ptr, serviceFQDN) } } - this.announcedServices.set(serviceFQDN, service); + this.announcedServices.set(serviceFQDN, service) return announcer.announce().then(() => { - service.serviceState = ServiceState.ANNOUNCED; - service.currentAnnouncer = undefined; - }, reason => { - service.serviceState = ServiceState.UNANNOUNCED; - service.currentAnnouncer = undefined; + service.serviceState = ServiceState.ANNOUNCED + service.currentAnnouncer = undefined + }, (reason) => { + service.serviceState = ServiceState.UNANNOUNCED + service.currentAnnouncer = undefined - this.clearService(service); // also removes entry from announcedServices + this.clearService(service) // also removes entry from announcedServices if (reason !== Announcer.CANCEL_REASON) { // forward reason if it is not a cancellation. // We do not forward cancel reason. Announcements only get cancelled if we have something "better" to do. // So the race is already handled by us. - return Promise.reject(reason); + return Promise.reject(reason) } - }); + }) } private handleServiceRecordUpdate(service: CiaoService, response: DNSResponseDefinition, callback?: RecordsUpdateCallback): void { // when updating we just repeat the 'announce' step if (service.serviceState !== ServiceState.ANNOUNCED) { // different states are already handled in CiaoService where this event handler is fired - throw new Error("Cannot update txt of service which is not announced yet. Received " + service.serviceState + " for service " + service.getFQDN()); + throw new Error(`Cannot update txt of service which is not announced yet. Received ${service.serviceState} for service ${service.getFQDN()}`) } - debug("[%s] Updating %d record(s) for given service!", service.getFQDN(), response.answers.length + (response.additionals?.length || 0)); + debug('[%s] Updating %d record(s) for given service!', service.getFQDN(), response.answers.length + (response.additionals?.length || 0)) // TODO we should do a announcement at this point "in theory" - this.server.sendResponseBroadcast(response, service).then(results => { - const failRatio = SendResultFailedRatio(results); + this.server.sendResponseBroadcast(response, service).then((results) => { + const failRatio = SendResultFailedRatio(results) if (failRatio === 1) { - console.log(SendResultFormatError(results, `Failed to send records update for '${service.getFQDN()}'`), true); + // eslint-disable-next-line no-console + console.log(sendResultFormatError(results, `Failed to send records update for '${service.getFQDN()}'`), true) if (callback) { - callback(new Error("Updating records failed as of socket errors!")); + callback(new Error('Updating records failed as of socket errors!')) } - return; // all failed => updating failed + return // all failed => updating failed } if (failRatio > 0) { // some queries on some interfaces failed, but not all. We log that but consider that to be a success // at this point we are not responsible for removing stale network interfaces or something - debug(SendResultFormatError(results, `Some of the record updates for '${service.getFQDN()}' failed`)); + debug(sendResultFormatError(results, `Some of the record updates for '${service.getFQDN()}' failed`)) // SEE no return here } if (callback) { - callback(); + callback() } - }); + }) } private handleServiceRecordUpdateOnInterface(service: CiaoService, name: InterfaceName, records: ResourceRecord[], callback?: RecordsUpdateCallback): void { // when updating we just repeat the 'announce' step if (service.serviceState !== ServiceState.ANNOUNCED) { // different states are already handled in CiaoService where this event handler is fired - throw new Error("Cannot update txt of service which is not announced yet. Received " + service.serviceState + " for service " + service.getFQDN()); + throw new Error(`Cannot update txt of service which is not announced yet. Received ${service.serviceState} for service ${service.getFQDN()}`) } - debug("[%s] Updating %d record(s) for given service on interface %s!", service.getFQDN(), records.length, name); + debug('[%s] Updating %d record(s) for given service on interface %s!', service.getFQDN(), records.length, name) - const packet = DNSPacket.createDNSResponsePacketsFromRRSet({ answers: records }); - this.server.sendResponse(packet, name, callback); + const packet = DNSPacket.createDNSResponsePacketsFromRRSet({ answers: records }) + this.server.sendResponse(packet, name, callback) } private goodbye(service: CiaoService): Promise { - assert(service.currentAnnouncer === undefined, "Service " + service.getFQDN() + " is already announcing!"); + assert(service.currentAnnouncer === undefined, `Service ${service.getFQDN()} is already announcing!`) - service.serviceState = ServiceState.ANNOUNCING; + service.serviceState = ServiceState.ANNOUNCING const announcer = new Announcer(this.server, service, { repetitions: 1, goodbye: true, - }); - service.currentAnnouncer = announcer; + }) + service.currentAnnouncer = announcer return announcer.announce().then(() => { - service.serviceState = ServiceState.UNANNOUNCED; - service.currentAnnouncer = undefined; - }, reason => { + service.serviceState = ServiceState.UNANNOUNCED + service.currentAnnouncer = undefined + }, (reason) => { // just assume unannounced. we won't be answering anymore, so the record will be flushed from cache sometime. - service.serviceState = ServiceState.UNANNOUNCED; - service.currentAnnouncer = undefined; - return Promise.reject(reason); - }); + service.serviceState = ServiceState.UNANNOUNCED + service.currentAnnouncer = undefined + return Promise.reject(reason) + }) } private handleNetworkUpdate(change: NetworkUpdate): void { for (const service of this.announcedServices.values()) { - service.handleNetworkInterfaceUpdate(change); + service.handleNetworkInterfaceUpdate(change) } } /** - * @private method called by the MDNSServer when an incoming query needs ot be handled + * @private */ handleQuery(packet: DNSPacket, endpoint: EndpointInfo): void { - const start = new Date().getTime(); + const start = new Date().getTime() - const endpointId = endpoint.address + ":" + endpoint.port + ":" + endpoint.interface; // used to match truncated queries + const endpointId = `${endpoint.address}:${endpoint.port}:${endpoint.interface}` // used to match truncated queries - const previousQuery = this.truncatedQueries[endpointId]; + const previousQuery = this.truncatedQueries[endpointId] if (previousQuery) { - const truncatedQueryResult = previousQuery.appendDNSPacket(packet); + const truncatedQueryResult = previousQuery.appendDNSPacket(packet) switch (truncatedQueryResult) { case TruncatedQueryResult.ABORT: // returned when we detect, that continuously TC queries are sent - delete this.truncatedQueries[endpointId]; - debug("[%s] Aborting to wait for more truncated queries. Waited a total of %d ms receiving %d queries", - endpointId, previousQuery.getTotalWaitTime(), previousQuery.getArrivedPacketCount()); - return; + delete this.truncatedQueries[endpointId] + debug('[%s] Aborting to wait for more truncated queries. Waited a total of %d ms receiving %d queries', endpointId, previousQuery.getTotalWaitTime(), previousQuery.getArrivedPacketCount()) + return case TruncatedQueryResult.AGAIN_TRUNCATED: - debug("[%s] Received a query marked as truncated, waiting for more to arrive", endpointId); - return; // wait for the next packet + debug('[%s] Received a query marked as truncated, waiting for more to arrive', endpointId) + return // wait for the next packet case TruncatedQueryResult.FINISHED: - delete this.truncatedQueries[endpointId]; - packet = previousQuery.getPacket(); // replace packet with the complete deal + delete this.truncatedQueries[endpointId] + packet = previousQuery.getPacket() // replace packet with the complete deal - debug("[%s] Last part of the truncated query arrived. Received %d packets taking a total of %d ms", - endpointId, previousQuery.getArrivedPacketCount(), previousQuery.getTotalWaitTime()); - break; + debug('[%s] Last part of the truncated query arrived. Received %d packets taking a total of %d ms', endpointId, previousQuery.getArrivedPacketCount(), previousQuery.getTotalWaitTime()) + break } } else if (packet.flags.truncation) { // RFC 6763 18.5 truncate flag indicates that additional known-answer records follow shortly - debug("Received truncated query from " + JSON.stringify(endpoint) + " waiting for more to come!"); + debug(`Received truncated query from ${JSON.stringify(endpoint)} waiting for more to come!`) - const truncatedQuery = new TruncatedQuery(packet); - this.truncatedQueries[endpointId] = truncatedQuery; + const truncatedQuery = new TruncatedQuery(packet) + this.truncatedQueries[endpointId] = truncatedQuery truncatedQuery.on(TruncatedQueryEvent.TIMEOUT, () => { // called when more than 400-500ms pass until the next packet arrives - debug("[%s] Timeout passed since the last truncated query was received. Discarding %d packets received in %d ms.", - endpointId, truncatedQuery.getArrivedPacketCount(), truncatedQuery.getTotalWaitTime()); - delete this.truncatedQueries[endpointId]; - }); + debug('[%s] Timeout passed since the last truncated query was received. Discarding %d packets received in %d ms.', endpointId, truncatedQuery.getArrivedPacketCount(), truncatedQuery.getTotalWaitTime()) + delete this.truncatedQueries[endpointId] + }) - return; // wait for the next query + return // wait for the next query } - const isUnicastQuerier = endpoint.port !== MDNSServer.MDNS_PORT; // explained below - const isProbeQuery = packet.authorities.size > 0; + const isUnicastQuerier = endpoint.port !== MDNSServer.MDNS_PORT // explained below + const isProbeQuery = packet.authorities.size > 0 - let udpPayloadSize: number | undefined = undefined; // payload size supported by the querier + let udpPayloadSize: number | undefined // payload size supported by the querier for (const record of packet.additionals.values()) { if (record.type === RType.OPT) { - udpPayloadSize = (record as OPTRecord).udpPayloadSize; - break; + udpPayloadSize = (record as OPTRecord).udpPayloadSize + break } } // responses must not include questions RFC 6762 6. // known answer suppression according to RFC 6762 7.1. - const multicastResponses: QueryResponse[] = []; - const unicastResponses: QueryResponse[] = []; + const multicastResponses: QueryResponse[] = [] + const unicastResponses: QueryResponse[] = [] // gather answers for all the questions - packet.questions.forEach(question => { - const responses = this.answerQuestion(question, endpoint, packet.answers); + packet.questions.forEach((question) => { + const responses = this.answerQuestion(question, endpoint, packet.answers) - if (isUnicastQuerier || question.unicastResponseFlag && !this.ignoreUnicastResponseFlag) { - unicastResponses.push(...responses); + if (isUnicastQuerier || (question.unicastResponseFlag && !this.ignoreUnicastResponseFlag)) { + unicastResponses.push(...responses) } else { - multicastResponses.push(...responses); + multicastResponses.push(...responses) } - }); + }) if (this.currentProber) { - this.currentProber.handleQuery(packet, endpoint); + this.currentProber.handleQuery(packet, endpoint) } if (isUnicastQuerier) { @@ -653,10 +656,10 @@ export class Responder implements PacketHandler { // * SHOULDS: ttls should not be greater than 10s as legacy resolvers don't take part in the cache coherency mechanism for (let i = 0; i < unicastResponses.length; i++) { - const response = unicastResponses[i]; + const response = unicastResponses[i] // only add questions to the first packet (will be combined anyway) and we must ensure // each packet stays unique in its records - response.markLegacyUnicastResponse(packet.id, i === 0? Array.from(packet.questions.values()): undefined); + response.markLegacyUnicastResponse(packet.id, i === 0 ? Array.from(packet.questions.values()) : undefined) } } @@ -668,8 +671,8 @@ export class Responder implements PacketHandler { // interval, then earlier responses SHOULD be delayed by up to an // additional 500 ms if that will permit them to be aggregated with // other responses scheduled to go out a little later. - QueryResponse.combineResponses(multicastResponses, udpPayloadSize); - QueryResponse.combineResponses(unicastResponses, udpPayloadSize); + QueryResponse.combineResponses(multicastResponses, udpPayloadSize) + QueryResponse.combineResponses(unicastResponses, udpPayloadSize) if (isUnicastQuerier && unicastResponses.length > 1) { // RFC 6762 18.5. In legacy unicast response messages, the TC bit has the same meaning @@ -677,23 +680,23 @@ export class Responder implements PacketHandler { // large to fit in a single packet, so the querier SHOULD reissue its // query using TCP in order to receive the larger response. - unicastResponses.splice(1, unicastResponses.length - 1); // discard all other - unicastResponses[0].markTruncated(); + unicastResponses.splice(1, unicastResponses.length - 1) // discard all other + unicastResponses[0].markTruncated() } for (const unicastResponse of unicastResponses) { if (!unicastResponse.hasAnswers()) { - continue; + continue } - this.server.sendResponse(unicastResponse.asPacket(), endpoint); - const time = new Date().getTime() - start; - debug("Sending response via unicast to %s (took %d ms): %s", JSON.stringify(endpoint), time, unicastResponse.asString(udpPayloadSize)); + this.server.sendResponse(unicastResponse.asPacket(), endpoint) + const time = new Date().getTime() - start + debug('Sending response via unicast to %s (took %d ms): %s', JSON.stringify(endpoint), time, unicastResponse.asString(udpPayloadSize)) } for (const multicastResponse of multicastResponses) { if (!multicastResponse.hasAnswers()) { - continue; + continue } if ((multicastResponse.containsSharedAnswer() || packet.questions.size > 1) && !isProbeQuery) { @@ -702,109 +705,110 @@ export class Responder implements PacketHandler { // we probably could not answer them all (because not all of them were directed to us). // All those conditions are overridden if this is a probe query. To those queries we must respond instantly! - const time = new Date().getTime() - start; - this.enqueueDelayedMulticastResponse(multicastResponse.asPacket(), endpoint.interface, time); + const time = new Date().getTime() - start + this.enqueueDelayedMulticastResponse(multicastResponse.asPacket(), endpoint.interface, time) } else { // otherwise the response is sent immediately, if there isn't any packet in the queue // so first step is, check if there is a packet in the queue we are about to send out // which can be combined with our current packet without adding a delay > 500ms - let sentWithLaterPacket = false; + let sentWithLaterPacket = false for (let i = 0; i < this.delayedMulticastResponses.length; i++) { - const delayedResponse = this.delayedMulticastResponses[i]; + const delayedResponse = this.delayedMulticastResponses[i] if (delayedResponse.getTimeTillSent() > QueuedResponse.MAX_DELAY) { // all packets following won't be compatible either - break; + break } if (delayedResponse.combineWithUniqueResponseIfPossible(multicastResponse, endpoint.interface)) { - const time = new Date().getTime() - start; - sentWithLaterPacket = true; - debug("Multicast response on interface %s containing unique records (took %d ms) was combined with response which is sent out later", endpoint.interface, time); - break; + const time = new Date().getTime() - start + sentWithLaterPacket = true + debug('Multicast response on interface %s containing unique records (took %d ms) was combined with response which is sent out later', endpoint.interface, time) + break } } if (!sentWithLaterPacket) { - this.server.sendResponse(multicastResponse.asPacket(), endpoint.interface); - const time = new Date().getTime() - start; - debug("Sending response via multicast on network %s (took %d ms): %s", endpoint.interface, time, multicastResponse.asString(udpPayloadSize)); + this.server.sendResponse(multicastResponse.asPacket(), endpoint.interface) + const time = new Date().getTime() - start + debug('Sending response via multicast on network %s (took %d ms): %s', endpoint.interface, time, multicastResponse.asString(udpPayloadSize)) } } } } /** - * @private method called by the MDNSServer when an incoming response needs to be handled + * @private */ handleResponse(packet: DNSPacket, endpoint: EndpointInfo): void { // any questions in a response must be ignored RFC 6762 6. if (this.currentProber) { // if there is a probing process running currently, just forward all messages to it - this.currentProber.handleResponse(packet, endpoint); + this.currentProber.handleResponse(packet, endpoint) } for (const service of this.announcedServices.values()) { - let conflictingRData = false; - let ttlConflicts = 0; // we currently do a full-blown announcement with all records, we could in the future track which records have invalid ttl + let conflictingRData = false + let ttlConflicts = 0 // we currently do a full-blown announcement with all records, we could in the future track which records have invalid ttl for (const record of packet.answers.values()) { - const type = Responder.checkRecordConflictType(service, record, endpoint); + const type = Responder.checkRecordConflictType(service, record, endpoint) if (type === ConflictType.CONFLICTING_RDATA) { - conflictingRData = true; - break; // we will republish in any case + conflictingRData = true + break // we will republish in any case } else if (type === ConflictType.CONFLICTING_TTL) { - ttlConflicts++; + ttlConflicts++ } } if (!conflictingRData) { for (const record of packet.additionals.values()) { - const type = Responder.checkRecordConflictType(service, record, endpoint); + const type = Responder.checkRecordConflictType(service, record, endpoint) if (type === ConflictType.CONFLICTING_RDATA) { - conflictingRData = true; - break; // we will republish in any case + conflictingRData = true + break // we will republish in any case } else if (type === ConflictType.CONFLICTING_TTL) { - ttlConflicts++; + ttlConflicts++ } } } - if (conflictingRData) { - // noinspection JSIgnoredPromiseFromCall - this.republishService(service, error => { + this.republishService(service, (error) => { if (error) { - console.log(`FATAL Error occurred trying to resolve conflict for service ${service.getFQDN()}! We can't recover from this!`); - console.log(error.stack); - process.exit(1); // we have a service which should be announced, though we failed to reannounce. + // eslint-disable-next-line no-console + console.log(`FATAL Error occurred trying to resolve conflict for service ${service.getFQDN()}! We can't recover from this!`) + + // eslint-disable-next-line no-console + console.log(error.stack) + process.exit(1) // we have a service which should be announced, though we failed to reannounce. // if this should ever happen in reality, whe might want to introduce a more sophisticated recovery // for situations where it makes sense } - }, true); + }, true) } else if (ttlConflicts && !service.currentAnnouncer) { - service.serviceState = ServiceState.ANNOUNCING; // all code above doesn't expect an Announcer object in state ANNOUNCED + service.serviceState = ServiceState.ANNOUNCING // all code above doesn't expect an Announcer object in state ANNOUNCED const announcer = new Announcer(this.server, service, { repetitions: 1, // we send exactly one packet to correct any ttl values in neighbouring caches - }); - service.currentAnnouncer = announcer; + }) + service.currentAnnouncer = announcer announcer.announce().then(() => { - service.currentAnnouncer = undefined; - service.serviceState = ServiceState.ANNOUNCED; - }, reason => { - service.currentAnnouncer = undefined; - service.serviceState = ServiceState.ANNOUNCED; + service.currentAnnouncer = undefined + service.serviceState = ServiceState.ANNOUNCED + }, (reason) => { + service.currentAnnouncer = undefined + service.serviceState = ServiceState.ANNOUNCED if (reason === Announcer.CANCEL_REASON) { - return; // nothing to worry about + return // nothing to worry about } - console.warn("When trying to resolve a ttl conflict on the network, we were unable to send our response packet: " + reason.message); - }); + console.warn(`When trying to resolve a ttl conflict on the network, we were unable to send our response packet: ${reason.message}`) + }) } } } @@ -837,125 +841,125 @@ export class Responder implements PacketHandler { // the Probing phase will determine a winner and a loser, and the loser // MUST cease using the name, and reconfigure. if (!service.advertisesOnInterface(endpoint.interface)) { - return ConflictType.NO_CONFLICT; + return ConflictType.NO_CONFLICT } - const recordName = record.getLowerCasedName(); + const recordName = record.getLowerCasedName() if (recordName === service.getLowerCasedFQDN()) { if (record.type === RType.SRV) { - const srvRecord = record as SRVRecord; + const srvRecord = record as SRVRecord if (srvRecord.getLowerCasedHostname() !== service.getLowerCasedHostname()) { - debug("[%s] Noticed conflicting record on the network. SRV with hostname: %s", service.getFQDN(), srvRecord.hostname); - return ConflictType.CONFLICTING_RDATA; + debug('[%s] Noticed conflicting record on the network. SRV with hostname: %s', service.getFQDN(), srvRecord.hostname) + return ConflictType.CONFLICTING_RDATA } else if (srvRecord.port !== service.getPort()) { - debug("[%s] Noticed conflicting record on the network. SRV with port: %s", service.getFQDN(), srvRecord.port); - return ConflictType.CONFLICTING_RDATA; + debug('[%s] Noticed conflicting record on the network. SRV with port: %s', service.getFQDN(), srvRecord.port) + return ConflictType.CONFLICTING_RDATA } - if (srvRecord.ttl < SRVRecord.DEFAULT_TTL/2) { - return ConflictType.CONFLICTING_TTL; + if (srvRecord.ttl < SRVRecord.DEFAULT_TTL / 2) { + return ConflictType.CONFLICTING_TTL } } else if (record.type === RType.TXT) { - const txtRecord = record as TXTRecord; - const txt = service.getTXT(); + const txtRecord = record as TXTRecord + const txt = service.getTXT() if (txt.length !== txtRecord.txt.length) { // length differs, can't be the same data - debug("[%s] Noticed conflicting record on the network. TXT with differing data length", service.getFQDN()); - return ConflictType.CONFLICTING_RDATA; + debug('[%s] Noticed conflicting record on the network. TXT with differing data length', service.getFQDN()) + return ConflictType.CONFLICTING_RDATA } for (let i = 0; i < txt.length; i++) { - const buffer0 = txt[i]; - const buffer1 = txtRecord.txt[i]; + const buffer0 = txt[i] + const buffer1 = txtRecord.txt[i] - if (buffer0.length !== buffer1.length || buffer0.toString("hex") !== buffer1.toString("hex")) { - debug("[%s] Noticed conflicting record on the network. TXT with differing data.", service.getFQDN()); - return ConflictType.CONFLICTING_RDATA; + if (buffer0.length !== buffer1.length || buffer0.toString('hex') !== buffer1.toString('hex')) { + debug('[%s] Noticed conflicting record on the network. TXT with differing data.', service.getFQDN()) + return ConflictType.CONFLICTING_RDATA } } - if (txtRecord.ttl < TXTRecord.DEFAULT_TTL/2) { - return ConflictType.CONFLICTING_TTL; + if (txtRecord.ttl < TXTRecord.DEFAULT_TTL / 2) { + return ConflictType.CONFLICTING_TTL } } } else if (recordName === service.getLowerCasedHostname()) { if (record.type === RType.A) { - const aRecord = record as ARecord; + const aRecord = record as ARecord if (!service.hasAddress(aRecord.ipAddress)) { // if the service doesn't expose the listed address we have a conflict - debug("[%s] Noticed conflicting record on the network. A with ip address: %s", service.getFQDN(), aRecord.ipAddress); - return ConflictType.CONFLICTING_RDATA; + debug('[%s] Noticed conflicting record on the network. A with ip address: %s', service.getFQDN(), aRecord.ipAddress) + return ConflictType.CONFLICTING_RDATA } - if (aRecord.ttl < ARecord.DEFAULT_TTL/2) { - return ConflictType.CONFLICTING_TTL; + if (aRecord.ttl < ARecord.DEFAULT_TTL / 2) { + return ConflictType.CONFLICTING_TTL } } else if (record.type === RType.AAAA) { - const aaaaRecord = record as AAAARecord; + const aaaaRecord = record as AAAARecord if (!service.hasAddress(aaaaRecord.ipAddress)) { // if the service doesn't expose the listed address we have a conflict - debug("[%s] Noticed conflicting record on the network. AAAA with ip address: %s", service.getFQDN(), aaaaRecord.ipAddress); - return ConflictType.CONFLICTING_RDATA; + debug('[%s] Noticed conflicting record on the network. AAAA with ip address: %s', service.getFQDN(), aaaaRecord.ipAddress) + return ConflictType.CONFLICTING_RDATA } - if (aaaaRecord.ttl < AAAARecord.DEFAULT_TTL/2) { - return ConflictType.CONFLICTING_TTL; + if (aaaaRecord.ttl < AAAARecord.DEFAULT_TTL / 2) { + return ConflictType.CONFLICTING_TTL } } } else if (record.type === RType.PTR) { - const ptrRecord = record as PTRRecord; + const ptrRecord = record as PTRRecord if (recordName === service.getLowerCasedTypePTR()) { - if (ptrRecord.getLowerCasedPTRName() === service.getLowerCasedFQDN() && ptrRecord.ttl < PTRRecord.DEFAULT_TTL/2) { - return ConflictType.CONFLICTING_TTL; + if (ptrRecord.getLowerCasedPTRName() === service.getLowerCasedFQDN() && ptrRecord.ttl < PTRRecord.DEFAULT_TTL / 2) { + return ConflictType.CONFLICTING_TTL } } else if (recordName === Responder.SERVICE_TYPE_ENUMERATION_NAME) { // nothing to do here, I guess } else { - const subTypes = service.getLowerCasedSubtypePTRs(); + const subTypes = service.getLowerCasedSubtypePTRs() if (subTypes && subTypes.includes(recordName) - && ptrRecord.getLowerCasedPTRName() === service.getLowerCasedFQDN() && ptrRecord.ttl < PTRRecord.DEFAULT_TTL/2) { - return ConflictType.CONFLICTING_TTL; + && ptrRecord.getLowerCasedPTRName() === service.getLowerCasedFQDN() && ptrRecord.ttl < PTRRecord.DEFAULT_TTL / 2) { + return ConflictType.CONFLICTING_TTL } } } - return ConflictType.NO_CONFLICT; + return ConflictType.NO_CONFLICT } private enqueueDelayedMulticastResponse(packet: DNSPacket, interfaceName: InterfaceName, time: number): void { - const response = new QueuedResponse(packet, interfaceName); - response.calculateRandomDelay(); + const response = new QueuedResponse(packet, interfaceName) + response.calculateRandomDelay() - sortedInsert(this.delayedMulticastResponses, response, queuedResponseComparator); + sortedInsert(this.delayedMulticastResponses, response, queuedResponseComparator) // run combine/delay checks for (let i = 0; i < this.delayedMulticastResponses.length; i++) { - const response0 = this.delayedMulticastResponses[i]; + const response0 = this.delayedMulticastResponses[i] // search for any packets sent out after this packet for (let j = i + 1; j < this.delayedMulticastResponses.length; j++) { - const response1 = this.delayedMulticastResponses[j]; + const response1 = this.delayedMulticastResponses[j] if (!response0.delayWouldBeInTimelyManner(response1)) { // all packets following won't be compatible either - break; + break } if (response0.combineWithNextPacketIfPossible(response1)) { // combine was a success and the packet got delay // remove the packet from the queue - const index = this.delayedMulticastResponses.indexOf(response0); + const index = this.delayedMulticastResponses.indexOf(response0) if (index !== -1) { - this.delayedMulticastResponses.splice(index, 1); + this.delayedMulticastResponses.splice(index, 1) } - i--; // reduce i, as one element got removed from the queue + i-- // reduce i, as one element got removed from the queue - break; + break } // otherwise we continue with maybe some packets further ahead @@ -966,27 +970,24 @@ export class Responder implements PacketHandler { // only set timer if packet got not delayed response.scheduleResponse(() => { - const index = this.delayedMulticastResponses.indexOf(response); + const index = this.delayedMulticastResponses.indexOf(response) if (index !== -1) { - this.delayedMulticastResponses.splice(index, 1); + this.delayedMulticastResponses.splice(index, 1) } try { - this.server.sendResponse(response.getPacket(), interfaceName); - debug("Sending (delayed %dms) response via multicast on network interface %s (took %d ms): %s", - Math.round(response.getTimeSinceCreation()), interfaceName, time, response.getPacket().asLoggingString()); - } catch (error) { + this.server.sendResponse(response.getPacket(), interfaceName) + debug('Sending (delayed %dms) response via multicast on network interface %s (took %d ms): %s', Math.round(response.getTimeSinceCreation()), interfaceName, time, response.getPacket().asLoggingString()) + } catch (error: any) { if (error.name === ERR_INTERFACE_NOT_FOUND) { - debug("Multicast response (delayed %dms) was cancelled as the network interface %s is no longer available!", - Math.round(response.getTimeSinceCreation()), interfaceName); + debug('Multicast response (delayed %dms) was cancelled as the network interface %s is no longer available!', Math.round(response.getTimeSinceCreation()), interfaceName) } else if (error.name === ERR_SERVER_CLOSED) { - debug("Multicast response (delayed %dms) was cancelled as the server is about to be shutdown!", - Math.round(response.getTimeSinceCreation())); + debug('Multicast response (delayed %dms) was cancelled as the server is about to be shutdown!', Math.round(response.getTimeSinceCreation())) } else { - throw error; + throw error } } - }); + }) } } @@ -999,45 +1000,45 @@ export class Responder implements PacketHandler { if (question.class !== QClass.IN && question.class !== QClass.ANY) { // We just publish answers with IN class. So only IN or ANY questions classes will match - return []; + return [] } - const serviceResponses: QueryResponse[] = []; - let metaQueryResponse: QueryResponse | undefined = undefined; + const serviceResponses: QueryResponse[] = [] + let metaQueryResponse: QueryResponse | undefined if (question.type === QType.PTR || question.type === QType.ANY || question.type === QType.CNAME) { - const destinations = this.servicePointer.get(question.getLowerCasedName()); // look up the pointer, all entries are dnsLowerCased + const destinations = this.servicePointer.get(question.getLowerCasedName()) // look up the pointer, all entries are dnsLowerCased if (destinations) { // if it's a pointer name, we handle it here for (const data of destinations) { // check if the PTR is pointing towards a service, like in questions for PTR '_hap._tcp.local' // if that's the case, let the question be answered by the service itself - const service = this.announcedServices.get(data); + const service = this.announcedServices.get(data) if (service) { if (service.advertisesOnInterface(endpoint.interface)) { // call the method for original question, so additionals get added properly - const response = Responder.answerServiceQuestion(service, question, endpoint, knownAnswers); + const response = Responder.answerServiceQuestion(service, question, endpoint, knownAnswers) if (response.hasAnswers()) { - serviceResponses.push(response); + serviceResponses.push(response) } } } else { if (!metaQueryResponse) { - metaQueryResponse = new QueryResponse(knownAnswers); - serviceResponses.unshift(metaQueryResponse); + metaQueryResponse = new QueryResponse(knownAnswers) + serviceResponses.unshift(metaQueryResponse) } // it's probably question for PTR '_services._dns-sd._udp.local' // the PTR will just point to something like '_hap._tcp.local' thus no additional records need to be included - metaQueryResponse.addAnswer(new PTRRecord(question.name, data)); + metaQueryResponse.addAnswer(new PTRRecord(question.name, data)) // we may send out meta queries on interfaces where there aren't any services, because they are // restricted to other interfaces. } } - return serviceResponses; // if we got in this if-body, it was a pointer name and we handled it correctly + return serviceResponses // if we got in this if-body, it was a pointer name and we handled it correctly } /* else if (loweredQuestionName.endsWith(".in-addr.arpa") || loweredQuestionName.endsWith(".ip6.arpa")) { // reverse address lookup const address = ipAddressFromReversAddressName(loweredQuestionName); @@ -1055,16 +1056,16 @@ export class Responder implements PacketHandler { for (const service of this.announcedServices.values()) { if (!service.advertisesOnInterface(endpoint.interface)) { - continue; + continue } - const response = Responder.answerServiceQuestion(service, question, endpoint, knownAnswers); + const response = Responder.answerServiceQuestion(service, question, endpoint, knownAnswers) if (response.hasAnswers()) { - serviceResponses.push(response); + serviceResponses.push(response) } } - return serviceResponses; + return serviceResponses } private static answerServiceQuestion(service: CiaoService, question: Question, endpoint: EndpointInfo, knownAnswers?: Map): QueryResponse { @@ -1072,13 +1073,13 @@ export class Responder implements PacketHandler { // preconditions or special cases are already covered. // For one, we assume classes are already matched. - const response = new QueryResponse(knownAnswers); + const response = new QueryResponse(knownAnswers) - const loweredQuestionName = question.getLowerCasedName(); - const askingAny = question.type === QType.ANY || question.type === QType.CNAME; + const loweredQuestionName = question.getLowerCasedName() + const askingAny = question.type === QType.ANY || question.type === QType.CNAME - const addAnswer = response.addAnswer.bind(response); - const addAdditional = response.addAdditional.bind(response); + const addAnswer = response.addAnswer.bind(response) + const addAdditional = response.addAdditional.bind(response) // RFC 6762 6.2. In the event that a device has only IPv4 addresses but no IPv6 // addresses, or vice versa, then the appropriate NSEC record SHOULD be @@ -1087,95 +1088,95 @@ export class Responder implements PacketHandler { if (loweredQuestionName === service.getLowerCasedTypePTR()) { if (askingAny || question.type === QType.PTR) { - const added = response.addAnswer(service.ptrRecord()); + const added = response.addAnswer(service.ptrRecord()) if (added) { // only add additionals if answer is not suppressed by the known answer section // RFC 6763 12.1: include additionals: srv, txt, a, aaaa - response.addAdditional(service.txtRecord(), service.srvRecord()); - this.addAddressRecords(service, endpoint, RType.A, addAdditional); - this.addAddressRecords(service, endpoint, RType.AAAA, addAdditional); - response.addAdditional(service.serviceNSECRecord(), service.addressNSECRecord()); + response.addAdditional(service.txtRecord(), service.srvRecord()) + this.addAddressRecords(service, endpoint, RType.A, addAdditional) + this.addAddressRecords(service, endpoint, RType.AAAA, addAdditional) + response.addAdditional(service.serviceNSECRecord(), service.addressNSECRecord()) } } } else if (loweredQuestionName === service.getLowerCasedFQDN()) { if (askingAny) { - response.addAnswer(service.txtRecord()); - const addedSrv = response.addAnswer(service.srvRecord()); + response.addAnswer(service.txtRecord()) + const addedSrv = response.addAnswer(service.srvRecord()) if (addedSrv) { // RFC 6763 12.2: include additionals: a, aaaa - this.addAddressRecords(service, endpoint, RType.A, addAdditional); - this.addAddressRecords(service, endpoint, RType.AAAA, addAdditional); - response.addAdditional(service.serviceNSECRecord(), service.addressNSECRecord()); + this.addAddressRecords(service, endpoint, RType.A, addAdditional) + this.addAddressRecords(service, endpoint, RType.AAAA, addAdditional) + response.addAdditional(service.serviceNSECRecord(), service.addressNSECRecord()) } } else if (question.type === QType.SRV) { - const added = response.addAnswer(service.srvRecord()); + const added = response.addAnswer(service.srvRecord()) if (added) { // RFC 6763 12.2: include additionals: a, aaaa - this.addAddressRecords(service, endpoint, RType.A, addAdditional); - this.addAddressRecords(service, endpoint, RType.AAAA, addAdditional); - response.addAdditional(service.serviceNSECRecord(true), service.addressNSECRecord()); + this.addAddressRecords(service, endpoint, RType.A, addAdditional) + this.addAddressRecords(service, endpoint, RType.AAAA, addAdditional) + response.addAdditional(service.serviceNSECRecord(true), service.addressNSECRecord()) } } else if (question.type === QType.TXT) { - response.addAnswer(service.txtRecord()); - response.addAdditional(service.serviceNSECRecord()); + response.addAnswer(service.txtRecord()) + response.addAdditional(service.serviceNSECRecord()) // RFC 6763 12.3: not any other additionals } - } else if (loweredQuestionName === service.getLowerCasedHostname() || loweredQuestionName + "local." === service.getLowerCasedHostname()) { + } else if (loweredQuestionName === service.getLowerCasedHostname() || `${loweredQuestionName}local.` === service.getLowerCasedHostname()) { if (askingAny) { - this.addAddressRecords(service, endpoint, RType.A, addAnswer); - this.addAddressRecords(service, endpoint, RType.AAAA, addAnswer); - response.addAdditional(service.addressNSECRecord()); + this.addAddressRecords(service, endpoint, RType.A, addAnswer) + this.addAddressRecords(service, endpoint, RType.AAAA, addAnswer) + response.addAdditional(service.addressNSECRecord()) } else if (question.type === QType.A) { // RFC 6762 6.2 When a Multicast DNS responder places an IPv4 or IPv6 address record // (rrtype "A" or "AAAA") into a response message, it SHOULD also place // any records of the other address type with the same name into the // additional section, if there is space in the message. - const added = this.addAddressRecords(service, endpoint, RType.A, addAnswer); + const added = this.addAddressRecords(service, endpoint, RType.A, addAnswer) if (added) { - this.addAddressRecords(service, endpoint, RType.AAAA, addAdditional); + this.addAddressRecords(service, endpoint, RType.AAAA, addAdditional) } - response.addAdditional(service.addressNSECRecord()); // always add the negative response, always assert dominance + response.addAdditional(service.addressNSECRecord()) // always add the negative response, always assert dominance } else if (question.type === QType.AAAA) { // RFC 6762 6.2 When a Multicast DNS responder places an IPv4 or IPv6 address record // (rrtype "A" or "AAAA") into a response message, it SHOULD also place // any records of the other address type with the same name into the // additional section, if there is space in the message. - const added = this.addAddressRecords(service, endpoint, RType.AAAA, addAnswer); + const added = this.addAddressRecords(service, endpoint, RType.AAAA, addAnswer) if (added) { - this.addAddressRecords(service, endpoint, RType.A, addAdditional); + this.addAddressRecords(service, endpoint, RType.A, addAdditional) } - response.addAdditional(service.addressNSECRecord()); // always add the negative response, always assert dominance + response.addAdditional(service.addressNSECRecord()) // always add the negative response, always assert dominance } } else if (service.getLowerCasedSubtypePTRs()) { if (askingAny || question.type === QType.PTR) { - const dnsLowerSubTypes = service.getLowerCasedSubtypePTRs()!; - const index = dnsLowerSubTypes.indexOf(loweredQuestionName); + const dnsLowerSubTypes = service.getLowerCasedSubtypePTRs()! + const index = dnsLowerSubTypes.indexOf(loweredQuestionName) if (index !== -1) { // we have a subtype for the question - const records = service.subtypePtrRecords(); - const record = records![index]; - assert(loweredQuestionName === record.name, "Question Name didn't match selected sub type ptr record!"); + const records = service.subtypePtrRecords() + const record = records![index] + assert(loweredQuestionName === record.name, 'Question Name didn\'t match selected sub type ptr record!') - const added = response.addAnswer(record); + const added = response.addAnswer(record) if (added) { // RFC 6763 12.1: include additionals: srv, txt, a, aaaa - response.addAdditional(service.txtRecord(), service.srvRecord()); - this.addAddressRecords(service, endpoint, RType.A, addAdditional); - this.addAddressRecords(service, endpoint, RType.AAAA, addAdditional); - response.addAdditional(service.serviceNSECRecord(), service.addressNSECRecord()); + response.addAdditional(service.txtRecord(), service.srvRecord()) + this.addAddressRecords(service, endpoint, RType.A, addAdditional) + this.addAddressRecords(service, endpoint, RType.AAAA, addAdditional) + response.addAdditional(service.serviceNSECRecord(), service.addressNSECRecord()) } } } } - return response; + return response } /** @@ -1191,33 +1192,32 @@ export class Responder implements PacketHandler { * @returns true if any records got added */ private static addAddressRecords(service: CiaoService, endpoint: EndpointInfo, type: RType.A | RType.AAAA, dest: RecordAddMethod): boolean { - const endpointInterface = endpoint.interface.endsWith("/6") ? endpoint.interface.substr(0, endpoint.interface.length - 2) : endpoint.interface; + const endpointInterface = endpoint.interface.endsWith('/6') ? endpoint.interface.substr(0, endpoint.interface.length - 2) : endpoint.interface if (type === RType.A) { - const record = service.aRecord(endpointInterface); - return record? dest(record): false; + const record = service.aRecord(endpointInterface) + return record ? dest(record) : false } else if (type === RType.AAAA) { - const record = service.aaaaRecord(endpointInterface); - const routableRecord = service.aaaaRoutableRecord(endpointInterface); - const ulaRecord = service.aaaaUniqueLocalRecord(endpointInterface); + const record = service.aaaaRecord(endpointInterface) + const routableRecord = service.aaaaRoutableRecord(endpointInterface) + const ulaRecord = service.aaaaUniqueLocalRecord(endpointInterface) - let addedAny = false; + let addedAny = false if (record) { - addedAny = dest(record); + addedAny = dest(record) } if (routableRecord) { - const added = dest(routableRecord); - addedAny = addedAny || added; + const added = dest(routableRecord) + addedAny = addedAny || added } if (ulaRecord) { - const added = dest(ulaRecord); - addedAny = addedAny || added; + const added = dest(ulaRecord) + addedAny = addedAny || added } - return addedAny; + return addedAny } else { - assert.fail("Illegal argument!"); + assert.fail('Illegal argument!') } } - } diff --git a/src/bonjour-conformance-testing.ts b/src/bonjour-conformance-testing.ts index 2802316..b9052d1 100644 --- a/src/bonjour-conformance-testing.ts +++ b/src/bonjour-conformance-testing.ts @@ -1,53 +1,55 @@ -process.env.BCT = "yes"; // set bonjour conformance testing env (used to enable debug output) +import http from 'node:http' +import process from 'node:process' +import readline from 'node:readline' -import http from "http"; -import readline from "readline"; -import ciao, { ServiceType } from "."; +import ciao, { ServiceType } from './index.js' + +process.env.BCT = 'yes' // set bonjour conformance testing env (used to enable debug output) const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true, -}); +}) // bct checks if the advertised service also has a tcp socket bound to const server = http.createServer((req, res) => { - res.writeHead(200, "OK"); - res.end("Hello world!\n"); -}); -server.listen(8085); + res.writeHead(200, 'OK') + res.end('Hello world!\n') +}) +server.listen(8085) -const responder = ciao.getResponder(); +const responder = ciao.getResponder() const service = responder.createService({ - name: "My Test Service", + name: 'My Test Service', port: 8085, type: ServiceType.HTTP, txt: { - "test": "key", - "test2": "key2", - "ver": 1, + test: 'key', + test2: 'key2', + ver: 1, }, -}); +}) -server.on("listening", () => { +server.on('listening', () => { service.advertise().then(() => { - rl.question("What should the service name be changed to? [N to close]: ", answer => { - if (!answer || answer.toLowerCase() === "n") { - rl.close(); + rl.question('What should the service name be changed to? [N to close]: ', (answer) => { + if (!answer || answer.toLowerCase() === 'n') { + rl.close() } else { - service.updateName(answer); + service.updateName(answer) } - }); - }); -}); + }) + }) +}) -const exitHandler = (signal: number): void => { - rl.close(); +function exitHandler(signal: number): void { + rl.close() responder.shutdown().then(() => { - process.exit(128 + signal); - }); -}; -process.on("SIGINT", exitHandler.bind(undefined, 2)); -process.on("SIGTERM", exitHandler.bind(undefined, 15)); + process.exit(128 + signal) + }) +} +process.on('SIGINT', exitHandler.bind(undefined, 2)) +process.on('SIGTERM', exitHandler.bind(undefined, 15)) diff --git a/src/coder/DNSLabelCoder.spec.ts b/src/coder/DNSLabelCoder.spec.ts index 3f4e058..8a111d7 100644 --- a/src/coder/DNSLabelCoder.spec.ts +++ b/src/coder/DNSLabelCoder.spec.ts @@ -1,96 +1,131 @@ -import { DNSLabelCoder } from "./DNSLabelCoder"; +import { Buffer } from 'node:buffer' + +import { describe, expect, it } from 'vitest' + +import { DNSLabelCoder } from './DNSLabelCoder.js' function bufferFromArrayMix(data: (string | number)[]): Buffer { - const bufferArray: number[] = []; + const bufferArray: number[] = [] for (let i = 0; i < data.length; i++) { - const d0 = data[i]; - if (typeof d0 === "number") { - bufferArray[i] = d0; + const d0 = data[i] + if (typeof d0 === 'number') { + bufferArray[i] = d0 } else { - bufferArray[i] = Buffer.from(d0).readUInt8(0); + bufferArray[i] = Buffer.from(d0).readUInt8(0) } } - return Buffer.from(bufferArray); + return Buffer.from(bufferArray) } describe(DNSLabelCoder, () => { - describe("name compression", () => { - it("should encode name compression", () => { - const coder = new DNSLabelCoder(); - const previous = DNSLabelCoder.DISABLE_COMPRESSION; - DNSLabelCoder.DISABLE_COMPRESSION = false; - - let length = 0; - - length += coder.getNameLength("c.b.a."); // #7 - expect(length).toBe(7); - length += coder.getNameLength("f.b.a."); // #4 - expect(length).toBe(11); - length += coder.getNameLength("x.c.b.a."); // #4 - expect(length).toBe(15); - length += coder.getNameLength("s.x.c.b.a."); // #4 - expect(length).toBe(19); - length += coder.getNameLength("."); // #1 - expect(length).toBe(20); - - const buffer = Buffer.alloc(length); - coder.initBuf(buffer); - - coder.encodeName("c.b.a.", 0); - coder.encodeName("f.b.a.", 7); - coder.encodeName("x.c.b.a.", 11); - coder.encodeName("s.x.c.b.a.", 15); - coder.encodeName(".", 19); + describe('name compression', () => { + it('should encode name compression', () => { + const coder = new DNSLabelCoder() + const previous = DNSLabelCoder.DISABLE_COMPRESSION + DNSLabelCoder.DISABLE_COMPRESSION = false + + let length = 0 + + length += coder.getNameLength('c.b.a.') // #7 + expect(length).toBe(7) + length += coder.getNameLength('f.b.a.') // #4 + expect(length).toBe(11) + length += coder.getNameLength('x.c.b.a.') // #4 + expect(length).toBe(15) + length += coder.getNameLength('s.x.c.b.a.') // #4 + expect(length).toBe(19) + length += coder.getNameLength('.') // #1 + expect(length).toBe(20) + + const buffer = Buffer.alloc(length) + coder.initBuf(buffer) + + coder.encodeName('c.b.a.', 0) + coder.encodeName('f.b.a.', 7) + coder.encodeName('x.c.b.a.', 11) + coder.encodeName('s.x.c.b.a.', 15) + coder.encodeName('.', 19) const expected = bufferFromArrayMix([ - 1, "c", 1, "b", 1, "a", 0, - 1, "f", 0xC0, 2, - 1, "x", 0xC0, 0, - 1, "s", 0xC0, 11, + 1, + 'c', + 1, + 'b', + 1, + 'a', + 0, + 1, + 'f', + 0xC0, + 2, + 1, + 'x', + 0xC0, 0, - ]); - expect(buffer.toString("hex")).toBe(expected.toString("hex")); + 1, + 's', + 0xC0, + 11, + 0, + ]) + expect(buffer.toString('hex')).toBe(expected.toString('hex')) - DNSLabelCoder.DISABLE_COMPRESSION = previous; - }); + DNSLabelCoder.DISABLE_COMPRESSION = previous + }) - it("should decode name compression", () => { - const coder = new DNSLabelCoder(); + it('should decode name compression', () => { + const coder = new DNSLabelCoder() // example from RFC 1035 4.1.4. - const buf = Buffer.alloc(94, "X"); + const buf = Buffer.alloc(94, 'X') const fIsiArpa = bufferFromArrayMix([ - 1, "F", 3, "I", "S", "I", 4, "A", "R", "P", "A", 0, - ]); + 1, + 'F', + 3, + 'I', + 'S', + 'I', + 4, + 'A', + 'R', + 'P', + 'A', + 0, + ]) const fooFIsiArpa = bufferFromArrayMix([ - 3, "F", "O", "O", 0xC0, 20, - ]); - const arpa = bufferFromArrayMix([0xC0, 26]); - const root = Buffer.alloc(1); - - fIsiArpa.copy(buf, 20); - fooFIsiArpa.copy(buf, 40); - arpa.copy(buf, 64); - root.copy(buf, 92); - - coder.initBuf(buf); - - const decodedFIsiArpa = coder.decodeName(20); - const decodedFooFIsiArpa = coder.decodeName(40); - const decodedArpa = coder.decodeName(64); - const decodedRoot = coder.decodeName(92); - - expect(decodedFIsiArpa.data).toBe("F.ISI.ARPA."); - expect(decodedFIsiArpa.readBytes).toBe(12); - expect(decodedFooFIsiArpa.data).toBe("FOO.F.ISI.ARPA."); - expect(decodedFooFIsiArpa.readBytes).toBe(6); - expect(decodedArpa.data).toBe("ARPA."); - expect(decodedArpa.readBytes).toBe(2); - expect(decodedRoot.data).toBe("."); - expect(decodedRoot.readBytes).toBe(1); - }); - }); -}); + 3, + 'F', + 'O', + 'O', + 0xC0, + 20, + ]) + const arpa = bufferFromArrayMix([0xC0, 26]) + const root = Buffer.alloc(1) + + fIsiArpa.copy(buf, 20) + fooFIsiArpa.copy(buf, 40) + arpa.copy(buf, 64) + root.copy(buf, 92) + + coder.initBuf(buf) + + const decodedFIsiArpa = coder.decodeName(20) + const decodedFooFIsiArpa = coder.decodeName(40) + const decodedArpa = coder.decodeName(64) + const decodedRoot = coder.decodeName(92) + + expect(decodedFIsiArpa.data).toBe('F.ISI.ARPA.') + expect(decodedFIsiArpa.readBytes).toBe(12) + expect(decodedFooFIsiArpa.data).toBe('FOO.F.ISI.ARPA.') + expect(decodedFooFIsiArpa.readBytes).toBe(6) + expect(decodedArpa.data).toBe('ARPA.') + expect(decodedArpa.readBytes).toBe(2) + expect(decodedRoot.data).toBe('.') + expect(decodedRoot.readBytes).toBe(1) + }) + }) +}) diff --git a/src/coder/DNSLabelCoder.ts b/src/coder/DNSLabelCoder.ts index 2290800..5e16c56 100644 --- a/src/coder/DNSLabelCoder.ts +++ b/src/coder/DNSLabelCoder.ts @@ -1,20 +1,21 @@ -import assert from "assert"; -import { DecodedData } from "./DNSPacket"; +import type { DecodedData } from './DNSPacket' + +import assert from 'node:assert' +import { Buffer } from 'node:buffer' interface WrittenName { - name: string; - writtenLabels: number[]; // array of indices where the corresponding labels are written in the buffer + name: string + writtenLabels: number[] // array of indices where the corresponding labels are written in the buffer } interface NameLength { - name: string; - length: number; // full length in bytes needed to encode this name - labelLengths: number[]; // array of byte lengths for every individual label. will always end with root label with length of 1 + name: string + length: number // full length in bytes needed to encode this name + labelLengths: number[] // array of byte lengths for every individual label. will always end with root label with length of 1 } export class DNSLabelCoder { - - static DISABLE_COMPRESSION = false; + static DISABLE_COMPRESSION = false // RFC 1035 4.1.4. Message compression: // In order to reduce the size of messages, the domain system utilizes a @@ -31,99 +32,99 @@ export class DNSLabelCoder { // RFC 6762 name compression for rdata should be used in: NS, CNAME, PTR, DNAME, SOA, MX, AFSDB, RT, KX, RP, PX, SRV, NSEC - private static readonly POINTER_MASK = 0xC000; // 2 bytes, starting with 11 - private static readonly POINTER_MASK_ONE_BYTE = 0xC0; // same deal as above, just on a 1 byte level - private static readonly LOCAL_COMPRESSION_ONE_BYTE = 0x80; // "10" label type https://tools.ietf.org/html/draft-ietf-dnsind-local-compression-05#section-4 - private static readonly EXTENDED_LABEL_TYPE_ONE_BYTE = 0x40; // "01" edns extended label type https://tools.ietf.org/html/rfc6891#section-4.2 - private static readonly NOT_POINTER_MASK = 0x3FFF; - private static readonly NOT_POINTER_MASK_ONE_BYTE = 0x3F; + private static readonly POINTER_MASK = 0xC000 // 2 bytes, starting with 11 + private static readonly POINTER_MASK_ONE_BYTE = 0xC0 // same deal as above, just on a 1 byte level + private static readonly LOCAL_COMPRESSION_ONE_BYTE = 0x80 // "10" label type https://tools.ietf.org/html/draft-ietf-dnsind-local-compression-05#section-4 + private static readonly EXTENDED_LABEL_TYPE_ONE_BYTE = 0x40 // "01" edns extended label type https://tools.ietf.org/html/rfc6891#section-4.2 + private static readonly NOT_POINTER_MASK = 0x3FFF + private static readonly NOT_POINTER_MASK_ONE_BYTE = 0x3F - private buffer?: Buffer; - readonly legacyUnicastEncoding: boolean; - private startOfRR?: number; - private startOfRData?: number; - private rDataLength?: number; + private buffer?: Buffer + readonly legacyUnicastEncoding: boolean + private startOfRR?: number + private startOfRData?: number + private rDataLength?: number - private readonly trackedLengths: NameLength[] = []; - private readonly writtenNames: WrittenName[] = []; + private readonly trackedLengths: NameLength[] = [] + private readonly writtenNames: WrittenName[] = [] constructor(legacyUnicastEncoding?: boolean) { - this.legacyUnicastEncoding = legacyUnicastEncoding || false; + this.legacyUnicastEncoding = legacyUnicastEncoding || false } public initBuf(buffer?: Buffer): void { - this.buffer = buffer; + this.buffer = buffer } public initRRLocation(recordOffset: number, rDataOffset: number, rDataLength: number): void { - this.startOfRR = recordOffset; - this.startOfRData = rDataOffset; - this.rDataLength = rDataLength; + this.startOfRR = recordOffset + this.startOfRData = rDataOffset + this.rDataLength = rDataLength } public clearRRLocation(): void { - this.startOfRR = undefined; - this.startOfRData = undefined; - this.rDataLength = undefined; + this.startOfRR = undefined + this.startOfRData = undefined + this.rDataLength = undefined } public getUncompressedNameLength(name: string): number { - if (name === ".") { - return 1; // root label takes one zero byte + if (name === '.') { + return 1 // root label takes one zero byte } - assert(name.endsWith("."), "Supplied illegal name which doesn't end with the root label!"); + assert(name.endsWith('.'), 'Supplied illegal name which doesn\'t end with the root label!') - let length = 0; - const labels: string[] = name.split("."); + let length = 0 + const labels: string[] = name.split('.') for (let i = 0; i < labels.length; i++) { - const label = labels[i]; + const label = labels[i] if (!label && i < labels.length - 1) { - assert.fail("Label " + i + " in name '" + name + "' was empty"); + assert.fail(`Label ${i} in name '${name}' was empty`) } - length += DNSLabelCoder.getLabelLength(label); + length += DNSLabelCoder.getLabelLength(label) } - return length; + return length } public getNameLength(name: string): number { if (DNSLabelCoder.DISABLE_COMPRESSION) { - return this.getUncompressedNameLength(name); + return this.getUncompressedNameLength(name) } - if (name === ".") { - return 1; // root label takes one zero byte and is not compressible + if (name === '.') { + return 1 // root label takes one zero byte and is not compressible } - assert(name.endsWith("."), "Supplied illegal name which doesn't end with the root label!"); + assert(name.endsWith('.'), 'Supplied illegal name which doesn\'t end with the root label!') - const labelLengths: number[] = name.split(".") - .map(label => DNSLabelCoder.getLabelLength(label)); + const labelLengths: number[] = name.split('.') + .map(label => DNSLabelCoder.getLabelLength(label)) const nameLength: NameLength = { - name: name, + name, length: 0, // total length needed for encoding (with compression enabled) - labelLengths: labelLengths, - }; + labelLengths, + } - let candidateSharingLongestSuffix: NameLength | undefined = undefined; - let longestSuffixLength = 0; // amount of labels which are identical + let candidateSharingLongestSuffix: NameLength | undefined + let longestSuffixLength = 0 // amount of labels which are identical // pointers MUST only point to PRIOR label locations for (let i = 0; i < this.trackedLengths.length; i++) { - const element = this.trackedLengths[i]; - const suffixLength = DNSLabelCoder.computeLabelSuffixLength(element.name, name); + const element = this.trackedLengths[i] + const suffixLength = DNSLabelCoder.computeLabelSuffixLength(element.name, name) // it is very important that this is an GREATER and not just a GREATER EQUAL!!!! // don't change anything unless you fully understand all implications (0, and big comment block below) if (suffixLength > longestSuffixLength) { - candidateSharingLongestSuffix = element; - longestSuffixLength = suffixLength; + candidateSharingLongestSuffix = element + longestSuffixLength = suffixLength } } - let length = 0; + let length = 0 if (candidateSharingLongestSuffix) { // in theory, it is possible that the candidate has a pointer which "fromIndex" is smaller than // the "toIndex" we are pointing to below. This could result in that we point to a location which @@ -131,93 +132,94 @@ export class DNSLabelCoder { // But as we always start in order (with the first element in our array; see for loop above) // we will always find the label first, which such a theoretical candidate is also pointing at - const pointingFromIndex = labelLengths.length - 1 - longestSuffixLength; // -1 as the empty root label is always included + const pointingFromIndex = labelLengths.length - 1 - longestSuffixLength // -1 as the empty root label is always included for (let i = 0; i < pointingFromIndex; i++) { - length += labelLengths[i]; + length += labelLengths[i] } - length += 2; // 2 byte for the pointer + length += 2 // 2 byte for the pointer } else { for (let i = 0; i < labelLengths.length; i++) { - length += labelLengths[i]; + length += labelLengths[i] } } - nameLength.length = length; - this.trackedLengths.push(nameLength); + nameLength.length = length + this.trackedLengths.push(nameLength) - return nameLength.length; + return nameLength.length } public encodeUncompressedName(name: string, offset: number): number { if (!this.buffer) { - assert.fail("Illegal state. Buffer not initialized!"); + assert.fail('Illegal state. Buffer not initialized!') } - return DNSLabelCoder.encodeUncompressedName(name, this.buffer, offset); + return DNSLabelCoder.encodeUncompressedName(name, this.buffer, offset) } public static encodeUncompressedName(name: string, buffer: Buffer, offset: number): number { - assert(name.endsWith("."), "Name does not end with the root label"); - const oldOffset = offset; + assert(name.endsWith('.'), 'Name does not end with the root label') + const oldOffset = offset - const labels = name === "." - ? [""] - : name.split("."); + const labels = name === '.' + ? [''] + : name.split('.') for (let i = 0; i < labels.length; i++) { - const label = labels[i]; + const label = labels[i] - if (label === "") { - assert(i === labels.length - 1, "Encountered root label being not at the end of the domain name"); + if (label === '') { + assert(i === labels.length - 1, 'Encountered root label being not at the end of the domain name') - buffer.writeUInt8(0, offset++); // write a terminating zero - break; + buffer.writeUInt8(0, offset++) // write a terminating zero + break } // write length byte followed by the label data - const length = buffer.write(label, offset + 1); - buffer.writeUInt8(length, offset); - offset += length + 1; + const length = buffer.write(label, offset + 1) + buffer.writeUInt8(length, offset) + offset += length + 1 } - return offset - oldOffset; // written bytes + return offset - oldOffset // written bytes } public encodeName(name: string, offset: number): number { if (DNSLabelCoder.DISABLE_COMPRESSION) { - return this.encodeUncompressedName(name, offset); + return this.encodeUncompressedName(name, offset) } if (!this.buffer) { - assert.fail("Illegal state. Buffer not initialized!"); + assert.fail('Illegal state. Buffer not initialized!') } - if (name === ".") { - this.buffer.writeUInt8(0, offset); // write a terminating zero - return 1; + if (name === '.') { + this.buffer.writeUInt8(0, offset) // write a terminating zero + return 1 } - const oldOffset = offset; + const oldOffset = offset - const labels: string[] = name.split("."); + const labels: string[] = name.split('.') const writtenName: WrittenName = { - name: name, - writtenLabels: new Array(labels.length).fill(-1), // init with "-1" meaning unknown location - }; + name, + // init with "-1" meaning unknown location + writtenLabels: Array.from({ length: labels.length }).fill(-1) as number[], + } - let candidateSharingLongestSuffix: WrittenName | undefined = undefined; - let longestSuffixLength = 0; // amount of labels which are identical + let candidateSharingLongestSuffix: WrittenName | undefined + let longestSuffixLength = 0 // amount of labels which are identical for (let i = 0; i < this.writtenNames.length; i++) { - const element = this.writtenNames[i]; - const suffixLength = DNSLabelCoder.computeLabelSuffixLength(element.name, name); + const element = this.writtenNames[i] + const suffixLength = DNSLabelCoder.computeLabelSuffixLength(element.name, name) // it is very important that this is an GREATER and not just a GREATER EQUAL!!!! // don't change anything unless you fully understand all implications (0, and big comment block below) if (suffixLength > longestSuffixLength) { - candidateSharingLongestSuffix = element; - longestSuffixLength = suffixLength; + candidateSharingLongestSuffix = element + longestSuffixLength = suffixLength } } @@ -228,184 +230,181 @@ export class DNSLabelCoder { // But as we always start in order (with the first element in our array; see for loop above) // we will always find the label first, which such a theoretical candidate is also pointing at - const pointingFromIndex = labels.length - 1 - longestSuffixLength; // -1 as the empty root label is always included - const pointingToIndex = candidateSharingLongestSuffix.writtenLabels.length - 1 - longestSuffixLength; + const pointingFromIndex = labels.length - 1 - longestSuffixLength // -1 as the empty root label is always included + const pointingToIndex = candidateSharingLongestSuffix.writtenLabels.length - 1 - longestSuffixLength for (let i = 0; i < pointingFromIndex; i++) { - writtenName.writtenLabels[i] = offset; - offset += DNSLabelCoder.writeLabel(labels[i], this.buffer, offset); + writtenName.writtenLabels[i] = offset + offset += DNSLabelCoder.writeLabel(labels[i], this.buffer, offset) } - const pointerDestination = candidateSharingLongestSuffix.writtenLabels[pointingToIndex]; - assert(pointerDestination !== -1, "Label which was pointed at wasn't yet written to the buffer!"); - assert(pointerDestination <= DNSLabelCoder.NOT_POINTER_MASK, "Pointer exceeds to length of a maximum of 14 bits"); - assert(pointerDestination < offset, "Pointer can only point to a prior location"); + const pointerDestination = candidateSharingLongestSuffix.writtenLabels[pointingToIndex] + assert(pointerDestination !== -1, 'Label which was pointed at wasn\'t yet written to the buffer!') + assert(pointerDestination <= DNSLabelCoder.NOT_POINTER_MASK, 'Pointer exceeds to length of a maximum of 14 bits') + assert(pointerDestination < offset, 'Pointer can only point to a prior location') - const pointer = DNSLabelCoder.POINTER_MASK | pointerDestination; - this.buffer.writeUInt16BE(pointer, offset); - offset += 2; + const pointer = DNSLabelCoder.POINTER_MASK | pointerDestination + this.buffer.writeUInt16BE(pointer, offset) + offset += 2 } else { for (let i = 0; i < labels.length; i++) { - writtenName.writtenLabels[i] = offset; - offset += DNSLabelCoder.writeLabel(labels[i], this.buffer, offset); + writtenName.writtenLabels[i] = offset + offset += DNSLabelCoder.writeLabel(labels[i], this.buffer, offset) } } - this.writtenNames.push(writtenName); + this.writtenNames.push(writtenName) - return offset - oldOffset; // written bytes + return offset - oldOffset // written bytes } public decodeName(offset: number, resolvePointers = true): DecodedData { if (!this.buffer) { - assert.fail("Illegal state. Buffer not initialized!"); + assert.fail('Illegal state. Buffer not initialized!') } - const oldOffset = offset; + const oldOffset = offset - let name = ""; + let name = '' for (;;) { - const length: number = this.buffer.readUInt8(offset++); + const length: number = this.buffer.readUInt8(offset++) if (length === 0) { // zero byte to terminate the name - name += "."; - break; // root label marks end of name + name += '.' + break // root label marks end of name } - const labelTypePattern: number = length & DNSLabelCoder.POINTER_MASK_ONE_BYTE; + const labelTypePattern: number = length & DNSLabelCoder.POINTER_MASK_ONE_BYTE if (labelTypePattern) { if (labelTypePattern === DNSLabelCoder.POINTER_MASK_ONE_BYTE) { // we got a pointer here - const pointer = this.buffer.readUInt16BE(offset - 1) & DNSLabelCoder.NOT_POINTER_MASK; // extract the offset - offset++; // increment for the second byte of the pointer + const pointer = this.buffer.readUInt16BE(offset - 1) & DNSLabelCoder.NOT_POINTER_MASK // extract the offset + offset++ // increment for the second byte of the pointer if (!resolvePointers) { - name += name? ".~": "~"; - break; + name += name ? '.~' : '~' + break } // if we would allow pointers to a later location, we MUST ensure that we don't end up in an endless loop - assert(pointer < oldOffset, "Pointer at " + (offset - 2) + " MUST point to a prior location!"); + assert(pointer < oldOffset, `Pointer at ${offset - 2} MUST point to a prior location!`) - name += (name? ".": "") + this.decodeName(pointer).data; // recursively decode the rest of the name - break; // pointer marks end of name - } else if (labelTypePattern === DNSLabelCoder.LOCAL_COMPRESSION_ONE_BYTE) { - let localPointer = this.buffer.readUInt16BE(offset - 1) & DNSLabelCoder.NOT_POINTER_MASK; - offset++; // increment for the second byte of the pointer; + name += (name ? '.' : '') + this.decodeName(pointer).data // recursively decode the rest of the name + break // pointer marks end of name + } else if (labelTypePattern === DNSLabelCoder.LOCAL_COMPRESSION_ONE_BYTE) { + let localPointer = this.buffer.readUInt16BE(offset - 1) & DNSLabelCoder.NOT_POINTER_MASK + offset++ // increment for the second byte of the pointer; if (!resolvePointers) { - name += name? ".~": "~"; - break; + name += name ? '.~' : '~' + break } if (localPointer >= 0 && localPointer < 255) { // 255 is reserved - assert(this.startOfRR !== undefined, "Cannot decompress locally compressed name as record is not initialized!"); - localPointer += this.startOfRR!; + assert(this.startOfRR !== undefined, 'Cannot decompress locally compressed name as record is not initialized!') + localPointer += this.startOfRR! - assert(localPointer < oldOffset, "LocalPointer <255 at " + (offset - 2) + " MUST point to a prior location!"); + assert(localPointer < oldOffset, `LocalPointer <255 at ${offset - 2} MUST point to a prior location!`) - name += (name? ".": "") + this.decodeName(localPointer).data; // recursively decode the rest of the name + name += (name ? '.' : '') + this.decodeName(localPointer).data // recursively decode the rest of the name } else if (localPointer >= 256) { - assert(this.startOfRData !== undefined && this.rDataLength !== undefined, "Cannot decompress locally compressed name as record is not initialized!"); - localPointer -= -256; // subtract the offset 256 + assert(this.startOfRData !== undefined && this.rDataLength !== undefined, 'Cannot decompress locally compressed name as record is not initialized!') + localPointer -= -256 // subtract the offset 256 - localPointer += this.startOfRData!; + localPointer += this.startOfRData! - assert(localPointer < oldOffset, "LocationPoint >265 at " + (offset + 2) + " MUST point to a prior location!"); + assert(localPointer < oldOffset, `LocationPoint >265 at ${offset + 2} MUST point to a prior location!`) - name += (name? ".": "") + this.decodeName(localPointer).data; // recursively decode the rest of the name + name += (name ? '.' : '') + this.decodeName(localPointer).data // recursively decode the rest of the name } else { - assert.fail("Encountered unknown pointer range " + localPointer); + assert.fail(`Encountered unknown pointer range ${localPointer}`) } - break; // pointer marks end of name + break // pointer marks end of name } else if (labelTypePattern === DNSLabelCoder.EXTENDED_LABEL_TYPE_ONE_BYTE) { - const extendedLabelType = length & DNSLabelCoder.NOT_POINTER_MASK_ONE_BYTE; + const extendedLabelType = length & DNSLabelCoder.NOT_POINTER_MASK_ONE_BYTE - assert.fail("Received extended label type " + extendedLabelType + " at " + (offset - 1)); + assert.fail(`Received extended label type ${extendedLabelType} at ${offset - 1}`) } else { - assert.fail("Encountered unknown pointer type: " + Buffer.from([labelTypePattern >> 6]).toString("hex") + " (with original byte " + - Buffer.from([length]).toString("hex") + ")"); + assert.fail(`Encountered unknown pointer type: ${Buffer.from([labelTypePattern >> 6]).toString('hex')} (with original byte ${ + Buffer.from([length]).toString('hex')})`) } } - const label = this.buffer.toString("utf-8", offset, offset + length); - offset += length; + const label = this.buffer.toString('utf-8', offset, offset + length) + offset += length - name += (name? ".": "") + label; + name += (name ? '.' : '') + label } return { data: name, readBytes: offset - oldOffset, - }; + } } private static getLabelLength(label: string): number { if (!label) { // empty label aka root label - return 1; // root label takes one zero byte + return 1 // root label takes one zero byte } else { - const byteLength = Buffer.byteLength(label); - assert(byteLength <= 63, "Label cannot be longer than 63 bytes (" + label + ")"); - return 1 + byteLength; // length byte + label data + const byteLength = Buffer.byteLength(label) + assert(byteLength <= 63, `Label cannot be longer than 63 bytes (${label})`) + return 1 + byteLength // length byte + label data } } private static writeLabel(label: string, buffer: Buffer, offset: number): number { if (!label) { - buffer.writeUInt8(0, offset); - return 1; + buffer.writeUInt8(0, offset) + return 1 } else { - const length = buffer.write(label, offset + 1); - buffer.writeUInt8(length, offset); + const length = buffer.write(label, offset + 1) + buffer.writeUInt8(length, offset) - return length + 1; + return length + 1 } } private static computeLabelSuffixLength(a: string, b: string): number { - assert(a.length !== 0 && b.length !== 0, "Encountered empty name when comparing suffixes!"); - const lastAIndex = a.length - 1; - const lastBIndex = b.length - 1; + assert(a.length !== 0 && b.length !== 0, 'Encountered empty name when comparing suffixes!') + const lastAIndex = a.length - 1 + const lastBIndex = b.length - 1 - let equalLabels = 0; - let exitByBreak = false; + let equalLabels = 0 + let exitByBreak = false // we start with i=1 as the last character will always be the root label terminator "." for (let i = 1; i <= lastAIndex && i <= lastBIndex; i++) { // we are comparing both strings backwards - const aChar = a.charAt(lastAIndex - i); - const bChar = b.charAt(lastBIndex - i); - assert(!!aChar && !!bChar, "Seemingly encountered out of bounds trying to calculate suffixes"); + const aChar = a.charAt(lastAIndex - i) + const bChar = b.charAt(lastBIndex - i) + assert(!!aChar && !!bChar, 'Seemingly encountered out of bounds trying to calculate suffixes') if (aChar !== bChar) { - exitByBreak = true; - break; // encountered the first character to differ - } else if (aChar === ".") { + exitByBreak = true + break // encountered the first character to differ + } else if (aChar === '.') { // we reached the label terminator, thus we count up the labels which are equal - equalLabels++; + equalLabels++ } } if (!exitByBreak) { - equalLabels++; // accommodate for the top level label (fqdn doesn't start with a dot) + equalLabels++ // accommodate for the top level label (fqdn doesn't start with a dot) } - return equalLabels; + return equalLabels } - } export class NonCompressionLabelCoder extends DNSLabelCoder { - - public static readonly INSTANCE = new NonCompressionLabelCoder(); + public static readonly INSTANCE = new NonCompressionLabelCoder() public getNameLength(name: string): number { - return this.getUncompressedNameLength(name); + return this.getUncompressedNameLength(name) } public encodeName(name: string, offset: number): number { - return this.encodeUncompressedName(name, offset); + return this.encodeUncompressedName(name, offset) } - } diff --git a/src/coder/DNSPacket.spec.ts b/src/coder/DNSPacket.spec.ts index 43a1d81..f437bdb 100644 --- a/src/coder/DNSPacket.spec.ts +++ b/src/coder/DNSPacket.spec.ts @@ -1,54 +1,61 @@ -import { DNSPacket, QClass, QType, RType } from "./DNSPacket"; -import { Question } from "./Question"; -import { AAAARecord } from "./records/AAAARecord"; -import { ARecord } from "./records/ARecord"; -import { CNAMERecord } from "./records/CNAMERecord"; -import { NSECRecord } from "./records/NSECRecord"; -import { PTRRecord } from "./records/PTRRecord"; -import { SRVRecord } from "./records/SRVRecord"; -import { TXTRecord } from "./records/TXTRecord"; -import { runPacketEncodingTest } from "./test-utils"; +import { Buffer } from 'node:buffer' + +import { describe, it } from 'vitest' + +import './records/index.js' + +// eslint-disable-next-line perfectionist/sort-imports +import { DNSPacket, QClass, QType, RType } from './DNSPacket.js' +import { Question } from './Question.js' +import { AAAARecord } from './records/AAAARecord.js' +import { ARecord } from './records/ARecord.js' +import { CNAMERecord } from './records/CNAMERecord.js' +import { NSECRecord } from './records/NSECRecord.js' +import { PTRRecord } from './records/PTRRecord.js' +import { SRVRecord } from './records/SRVRecord.js' +import { TXTRecord } from './records/TXTRecord.js' +import { runPacketEncodingTest } from './test-utils.js' describe(DNSPacket, () => { - it("should encode responses", () => { - const aRecord = new ARecord("example.org", "192.168.0.0"); - aRecord.flushFlag = true; + it('should encode responses', () => { + const aRecord = new ARecord('example.org', '192.168.0.0') + aRecord.flushFlag = true runPacketEncodingTest(DNSPacket.createDNSResponsePacketsFromRRSet({ answers: [ aRecord, - new AAAARecord("example.org", "::1"), - new CNAMERecord("eg.org", "example.org"), - new NSECRecord("test.local", "test.local", [RType.SRV], 120), + new AAAARecord('example.org', '::1'), + new CNAMERecord('eg.org', 'example.org'), + new NSECRecord('test.local', 'test.local', [RType.SRV], 120), ], additionals: [ - new PTRRecord("test.pointer", "test.local"), - new SRVRecord("super secret.service", "example.org", 80), - new TXTRecord("my txt.local", [Buffer.from("key=value")]), + new PTRRecord('test.pointer', 'test.local'), + new SRVRecord('super secret.service', 'example.org', 80), + new TXTRecord('my txt.local', [Buffer.from('key=value')]), ], - })); - }); + })) + }) - it ("should encode queries", () => { - const question = new Question("test.local", QType.ANY, true, QClass.ANY); + it ('should encode queries', () => { + const question = new Question('test.local', QType.ANY, true, QClass.ANY) runPacketEncodingTest(DNSPacket.createDNSQueryPackets({ questions: [ question, - new Question("test._hap._tcp.local", QType.PTR, false, QClass.IN), + new Question('test._hap._tcp.local', QType.PTR, false, QClass.IN), ], answers: [ - new ARecord("test.local.", "192.168.178.1"), + new ARecord('test.local.', '192.168.178.1'), ], - })[0]); + })[0]) runPacketEncodingTest(DNSPacket.createDNSQueryPackets({ questions: [ - new Question("test.local", QType.ANY, false, QClass.ANY), + new Question('test.local', QType.ANY, false, QClass.ANY), ], authorities: [ - new ARecord("test.local.", "192.168.178.1"), + new ARecord('test.local.', '192.168.178.1'), ], - })[0]); - }); -}); + })[0]) + }) +}) diff --git a/src/coder/DNSPacket.ts b/src/coder/DNSPacket.ts index 5a6142e..c4bf888 100644 --- a/src/coder/DNSPacket.ts +++ b/src/coder/DNSPacket.ts @@ -1,23 +1,30 @@ -import assert from "assert"; -import deepEqual from "fast-deep-equal"; -import { AddressInfo } from "net"; -import { dnsTypeToString } from "./dns-string-utils"; -import { DNSLabelCoder, NonCompressionLabelCoder } from "./DNSLabelCoder"; -import { Question } from "./Question"; -import "./records"; -import { ResourceRecord } from "./ResourceRecord"; +import type { AddressInfo } from 'node:net' +import assert from 'node:assert' +import { Buffer } from 'node:buffer' +import process from 'node:process' + +import deepEqual from 'fast-deep-equal' + +import { dnsTypeToString } from './dns-string-utils.js' +import { DNSLabelCoder, NonCompressionLabelCoder } from './DNSLabelCoder.js' +import { Question } from './Question.js' +import './records/index.js' +import { ResourceRecord } from './ResourceRecord.js' + +// eslint-disable-next-line no-restricted-syntax export const enum OpCode { // RFC 6895 2.2. QUERY = 0, // incomplete list } +// eslint-disable-next-line no-restricted-syntax export const enum RCode { // RFC 6895 2.3. NoError = 0, // incomplete list } -export const enum RType { // RFC 1035 3.2.2. +export enum RType { // RFC 1035 3.2.2. A = 1, CNAME = 5, PTR = 12, @@ -29,6 +36,7 @@ export const enum RType { // RFC 1035 3.2.2. // incomplete list } +// eslint-disable-next-line no-restricted-syntax export const enum QType { // RFC 1035 3.2.2. 3.2.3. A = 1, CNAME = 5, @@ -42,206 +50,203 @@ export const enum QType { // RFC 1035 3.2.2. 3.2.3. // incomplete list } +// eslint-disable-next-line no-restricted-syntax export const enum RClass { // RFC 1035 3.2.4. IN = 1, // the internet // incomplete list } +// eslint-disable-next-line no-restricted-syntax export const enum QClass { // RFC 1035 3.2.4. 3.2.5. IN = 1, // the internet ANY = 255, // incomplete list } +// eslint-disable-next-line no-restricted-syntax export const enum PacketType { QUERY = 0, RESPONSE = 1, // 16th bit set } export interface DecodedData { - data: T; - readBytes: number; + data: T + readBytes: number } export interface OptionalDecodedData { - data?: T; - readBytes: number; + data?: T + readBytes: number } export interface DNSQueryDefinition { - questions: Question[]; - answers?: ResourceRecord[]; // list of known-answers - additionals?: ResourceRecord[]; // only really used to include the OPT record + questions: Question[] + answers?: ResourceRecord[] // list of known-answers + additionals?: ResourceRecord[] // only really used to include the OPT record } export interface DNSProbeQueryDefinition { - questions: Question[]; - authorities?: ResourceRecord[]; // use when sending probe queries to indicate what records we want to publish + questions: Question[] + authorities?: ResourceRecord[] // use when sending probe queries to indicate what records we want to publish } export interface DNSResponseDefinition { - id?: number; // must be zero, except when responding to unicast queries we need to match the supplied id - questions?: Question[]; // must not be defined, though for unicast queries we MUST repeat the question - answers: ResourceRecord[]; - additionals?: ResourceRecord[]; - legacyUnicast?: boolean, // used to define that we address and legacy unicast querier and thus need to handle that in encoding + id?: number // must be zero, except when responding to unicast queries we need to match the supplied id + questions?: Question[] // must not be defined, though for unicast queries we MUST repeat the question + answers: ResourceRecord[] + additionals?: ResourceRecord[] + legacyUnicast?: boolean // used to define that we address and legacy unicast querier and thus need to handle that in encoding } function isQuery(query: DNSQueryDefinition | DNSProbeQueryDefinition): query is DNSQueryDefinition { - return "answers" in query; + return 'answers' in query } function isProbeQuery(query: DNSQueryDefinition | DNSProbeQueryDefinition): query is DNSProbeQueryDefinition { - return "authorities" in query; + return 'authorities' in query } export interface PacketFlags { - authoritativeAnswer?: boolean; - truncation?: boolean; + authoritativeAnswer?: boolean + truncation?: boolean // below flags are all not used with mdns - recursionDesired?: boolean; - recursionAvailable?: boolean; - zero?: boolean; - authenticData?: boolean; - checkingDisabled?: boolean; + recursionDesired?: boolean + recursionAvailable?: boolean + zero?: boolean + authenticData?: boolean + checkingDisabled?: boolean } export interface PacketDefinition { - id?: number; - legacyUnicast?: boolean; - - type: PacketType; - opcode?: OpCode; // default QUERY - flags?: PacketFlags; - rCode?: RCode; // default NoError - - questions?: Question[]; - answers?: ResourceRecord[]; - authorities?: ResourceRecord[]; - additionals?: ResourceRecord[]; + id?: number + legacyUnicast?: boolean + + type: PacketType + opcode?: OpCode // default QUERY + flags?: PacketFlags + rCode?: RCode // default NoError + + questions?: Question[] + answers?: ResourceRecord[] + authorities?: ResourceRecord[] + additionals?: ResourceRecord[] } export interface DNSRecord { - - getEncodingLength(coder: DNSLabelCoder): number; - - encode(coder: DNSLabelCoder, buffer: Buffer, offset: number): number; - - asString(): string; - + getEncodingLength: (coder: DNSLabelCoder) => number + encode: (coder: DNSLabelCoder, buffer: Buffer, offset: number) => number + asString: () => string } export class DNSPacket { + public static readonly UDP_PAYLOAD_SIZE_IPV4 = (process.env.CIAO_UPS ? Number.parseInt(process.env.CIAO_UPS) : 1440) + public static readonly UDP_PAYLOAD_SIZE_IPV6 = (process.env.CIAO_UPS ? Number.parseInt(process.env.CIAO_UPS) : 1440) - public static readonly UDP_PAYLOAD_SIZE_IPV4 = (process.env.CIAO_UPS? parseInt(process.env.CIAO_UPS): 1440); - // noinspection JSUnusedGlobalSymbols - public static readonly UDP_PAYLOAD_SIZE_IPV6 = (process.env.CIAO_UPS? parseInt(process.env.CIAO_UPS): 1440); - - private static readonly AUTHORITATIVE_ANSWER_MASK = 0x400; - private static readonly TRUNCATION_MASK = 0x200; - private static readonly RECURSION_DESIRED_MASK = 0x100; - private static readonly RECURSION_AVAILABLE_MASK = 0x80; - private static readonly ZERO_HEADER_MASK = 0x40; - private static readonly AUTHENTIC_DATA_MASK = 0x20; - private static readonly CHECKING_DISABLED_MASK = 0x10; + private static readonly AUTHORITATIVE_ANSWER_MASK = 0x400 + private static readonly TRUNCATION_MASK = 0x200 + private static readonly RECURSION_DESIRED_MASK = 0x100 + private static readonly RECURSION_AVAILABLE_MASK = 0x80 + private static readonly ZERO_HEADER_MASK = 0x40 + private static readonly AUTHENTIC_DATA_MASK = 0x20 + private static readonly CHECKING_DISABLED_MASK = 0x10 // 2 bytes ID, 2 bytes flags, 2 bytes question count, 2 bytes answer count, 2 bytes authorities count; 2 bytes additionals count - private static readonly DNS_PACKET_HEADER_SIZE = 12; + private static readonly DNS_PACKET_HEADER_SIZE = 12 - id: number; - private legacyUnicastEncoding: boolean; + id: number + private legacyUnicastEncoding: boolean - readonly type: PacketType; - readonly opcode: OpCode; - readonly flags: PacketFlags; - readonly rcode: RCode; + readonly type: PacketType + readonly opcode: OpCode + readonly flags: PacketFlags + readonly rcode: RCode - readonly questions: Map = new Map(); - readonly answers: Map = new Map(); - readonly authorities: Map = new Map(); - readonly additionals: Map = new Map(); + readonly questions: Map = new Map() + readonly answers: Map = new Map() + readonly authorities: Map = new Map() + readonly additionals: Map = new Map() - private estimatedEncodingLength = 0; // upper bound for the resulting encoding length, should only be called via the getter - private lastCalculatedLength = 0; - private lengthDirty = true; + private estimatedEncodingLength = 0 // upper bound for the resulting encoding length, should only be called via the getter + private lastCalculatedLength = 0 + private lengthDirty = true constructor(definition: PacketDefinition) { - this.id = definition.id || 0; - this.legacyUnicastEncoding = definition.legacyUnicast || false; + this.id = definition.id || 0 + this.legacyUnicastEncoding = definition.legacyUnicast || false - this.type = definition.type; - this.opcode = definition.opcode || OpCode.QUERY; - this.flags = definition.flags || {}; - this.rcode = definition.rCode || RCode.NoError; + this.type = definition.type + this.opcode = definition.opcode || OpCode.QUERY + this.flags = definition.flags || {} + this.rcode = definition.rCode || RCode.NoError if (this.type === PacketType.RESPONSE) { - this.flags.authoritativeAnswer = true; // RFC 6763 18.4 AA is always set for responses in mdns + this.flags.authoritativeAnswer = true // RFC 6763 18.4 AA is always set for responses in mdns } if (definition.questions) { - this.addQuestions(...definition.questions); + this.addQuestions(...definition.questions) } if (definition.answers) { - this.addAnswers(...definition.answers); + this.addAnswers(...definition.answers) } if (definition.authorities) { - this.addAuthorities(...definition.authorities); + this.addAuthorities(...definition.authorities) } if (definition.additionals) { - this.addAdditionals(...definition.additionals); + this.addAdditionals(...definition.additionals) } } public static createDNSQueryPacket(definition: DNSQueryDefinition | DNSProbeQueryDefinition, udpPayloadSize = this.UDP_PAYLOAD_SIZE_IPV4): DNSPacket { - const packets = this.createDNSQueryPackets(definition, udpPayloadSize); - assert(packets.length === 1, "Cannot user short method createDNSQueryPacket when query packets are more than one: is " + packets.length); - return packets[0]; + const packets = this.createDNSQueryPackets(definition, udpPayloadSize) + assert(packets.length === 1, `Cannot user short method createDNSQueryPacket when query packets are more than one: is ${packets.length}`) + return packets[0] } public static createDNSQueryPackets(definition: DNSQueryDefinition | DNSProbeQueryDefinition, udpPayloadSize = this.UDP_PAYLOAD_SIZE_IPV4): DNSPacket[] { - const packets: DNSPacket[] = []; + const packets: DNSPacket[] = [] // packet is like the "main" packet const packet = new DNSPacket({ type: PacketType.QUERY, questions: definition.questions, - additionals: isQuery(definition)? definition.additionals: undefined, // OPT record is included in additionals section - }); - packets.push(packet); + additionals: isQuery(definition) ? definition.additionals : undefined, // OPT record is included in additionals section + }) + packets.push(packet) if (packet.getEstimatedEncodingLength() > udpPayloadSize) { - const compressedLength = packet.getEncodingLength(); // calculating the real length will update the estimated property as well + const compressedLength = packet.getEncodingLength() // calculating the real length will update the estimated property as well if (compressedLength > udpPayloadSize) { // if we are still above the payload size we have a problem - assert.fail("Cannot send query where already the query section is exceeding the udpPayloadSize (" + compressedLength + ">" + udpPayloadSize +")!"); + assert.fail(`Cannot send query where already the query section is exceeding the udpPayloadSize (${compressedLength}>${udpPayloadSize})!`) } } // related https://en.wikipedia.org/wiki/Knapsack_problem if (isQuery(definition) && definition.answers) { - let currentPacket = packet; - let i = 0; - const answers = definition.answers.concat([]); // concat basically creates a copy of the array + let currentPacket = packet + let i = 0 + const answers = definition.answers.concat([]) // concat basically creates a copy of the array // sort the answers ascending on their encoding length; otherwise we would need to check if a packets fits in a previously created packet answers.sort((a, b) => { - return a.getEncodingLength(NonCompressionLabelCoder.INSTANCE) - b.getEncodingLength(NonCompressionLabelCoder.INSTANCE); - }); + return a.getEncodingLength(NonCompressionLabelCoder.INSTANCE) - b.getEncodingLength(NonCompressionLabelCoder.INSTANCE) + }) // in the loop below, we check if we need to truncate the list of known-answers in the query while (i < answers.length) { for (; i < answers.length; i++) { - const answer = answers[i]; - const estimatedSize = answer.getEncodingLength(NonCompressionLabelCoder.INSTANCE); + const answer = answers[i] + const estimatedSize = answer.getEncodingLength(NonCompressionLabelCoder.INSTANCE) if (packet.getEstimatedEncodingLength() + estimatedSize <= udpPayloadSize) { // size check on estimated calculations - currentPacket.addAnswers(answer); + currentPacket.addAnswers(answer) } else if (packet.getEncodingLength() + estimatedSize <= udpPayloadSize) { // check if the record may fit when message compression is used. // we may still have a false positive here, as they currently can't compute the REAL encoding for the answer // record, thus we rely on the estimated size - currentPacket.addAnswers(answer); + currentPacket.addAnswers(answer) } else { if (currentPacket.questions.size === 0 && currentPacket.answers.size === 0) { // we encountered a record which is too big and can't fit in a udpPayloadSize sized packet @@ -250,29 +255,29 @@ export class DNSPacket { // large to fit in a single MTU-sized multicast response packet, a // Multicast DNS responder SHOULD send the resource record alone, in a // single IP datagram, using multiple IP fragments. - packet.addAnswers(answer); + packet.addAnswers(answer) } - break; + break } } if (i < answers.length) { // if there are more records left, we need to truncate the packet again - currentPacket.flags.truncation = true; // first of all, mark the previous packet as truncated - currentPacket = new DNSPacket({ type: PacketType.QUERY }); - packets.push(currentPacket); + currentPacket.flags.truncation = true // first of all, mark the previous packet as truncated + currentPacket = new DNSPacket({ type: PacketType.QUERY }) + packets.push(currentPacket) } } } else if (isProbeQuery(definition) && definition.authorities) { - packet.addAuthorities(...definition.authorities); - const compressedLength = packet.getEncodingLength(); + packet.addAuthorities(...definition.authorities) + const compressedLength = packet.getEncodingLength() if (compressedLength > udpPayloadSize) { - assert.fail(`Probe query packet exceeds the mtu size (${compressedLength}>${udpPayloadSize}). Can't split probe queries at the moment!`); + assert.fail(`Probe query packet exceeds the mtu size (${compressedLength}>${udpPayloadSize}). Can't split probe queries at the moment!`) } } // otherwise, the packet consist of only questions - return packets; + return packets } public static createDNSResponsePacketsFromRRSet(definition: DNSResponseDefinition, udpPayloadSize = this.UDP_PAYLOAD_SIZE_IPV4): DNSPacket { @@ -286,13 +291,13 @@ export class DNSPacket { questions: definition.questions, answers: definition.answers, additionals: definition.additionals, - }); + }) if (packet.getEncodingLength() > udpPayloadSize) { - assert.fail("Couldn't construct a dns response packet from a rr set which fits in an udp payload sized packet!"); + assert.fail('Couldn\'t construct a dns response packet from a rr set which fits in an udp payload sized packet!') } - return packet; + return packet } public canBeCombinedWith(packet: DNSPacket, udpPayloadSize = DNSPacket.UDP_PAYLOAD_SIZE_IPV4): boolean { @@ -301,315 +306,312 @@ export class DNSPacket { && this.opcode === packet.opcode && deepEqual(this.flags, packet.flags) && this.rcode === packet.rcode // and the data must fit into a udpPayloadSize sized packet - && this.getEncodingLength() + packet.getEncodingLength() <= udpPayloadSize; + && this.getEncodingLength() + packet.getEncodingLength() <= udpPayloadSize } public combineWith(packet: DNSPacket): void { - this.setLegacyUnicastEncoding(this.legacyUnicastEncoding || packet.legacyUnicastEncoding); + this.setLegacyUnicastEncoding(this.legacyUnicastEncoding || packet.legacyUnicastEncoding) - this.addRecords(this.questions, packet.questions.values()); - this.addRecords(this.answers, packet.answers.values(), this.additionals); - this.addRecords(this.authorities, packet.authorities.values()); - this.addRecords(this.additionals, packet.additionals.values()); + this.addRecords(this.questions, packet.questions.values()) + this.addRecords(this.answers, packet.answers.values(), this.additionals) + this.addRecords(this.authorities, packet.authorities.values()) + this.addRecords(this.additionals, packet.additionals.values()) } public addQuestions(...questions: Question[]): boolean { - return this.addRecords(this.questions, questions); + return this.addRecords(this.questions, questions) } public addAnswers(...answers: ResourceRecord[]): boolean { - return this.addRecords(this.answers, answers, this.additionals); + return this.addRecords(this.answers, answers, this.additionals) } public addAuthorities(...authorities: ResourceRecord[]): boolean { - return this.addRecords(this.authorities, authorities); + return this.addRecords(this.authorities, authorities) } public addAdditionals(...additionals: ResourceRecord[]): boolean { - return this.addRecords(this.additionals, additionals); + return this.addRecords(this.additionals, additionals) } private addRecords(recordList: Map, added: DNSRecord[] | IterableIterator, removeFromWhenAdded?: Map): boolean { - let addedAny = false; + let addedAny = false for (const record of added) { if (recordList.has(record.asString())) { - continue; + continue } if (this.estimatedEncodingLength) { - this.estimatedEncodingLength += record.getEncodingLength(NonCompressionLabelCoder.INSTANCE); + this.estimatedEncodingLength += record.getEncodingLength(NonCompressionLabelCoder.INSTANCE) } - recordList.set(record.asString(), record); + recordList.set(record.asString(), record) - addedAny = true; - this.lengthDirty = true; + addedAny = true + this.lengthDirty = true if (removeFromWhenAdded) { - removeFromWhenAdded.delete(record.asString()); + removeFromWhenAdded.delete(record.asString()) } } - return addedAny; + return addedAny } public setLegacyUnicastEncoding(legacyUnicastEncoding: boolean): void { if (this.legacyUnicastEncoding !== legacyUnicastEncoding) { - this.lengthDirty = true; // above option changes length of SRV records + this.lengthDirty = true // above option changes length of SRV records } - this.legacyUnicastEncoding = legacyUnicastEncoding; + this.legacyUnicastEncoding = legacyUnicastEncoding } public legacyUnicastEncodingEnabled(): boolean { - return this.legacyUnicastEncoding; + return this.legacyUnicastEncoding } private getEstimatedEncodingLength(): number { if (this.estimatedEncodingLength) { - return this.estimatedEncodingLength; + return this.estimatedEncodingLength } - const labelCoder = NonCompressionLabelCoder.INSTANCE; - let length = DNSPacket.DNS_PACKET_HEADER_SIZE; + const labelCoder = NonCompressionLabelCoder.INSTANCE + let length = DNSPacket.DNS_PACKET_HEADER_SIZE for (const record of this.questions.values()) { - length += record.getEncodingLength(labelCoder); + length += record.getEncodingLength(labelCoder) } for (const record of this.answers.values()) { - length += record.getEncodingLength(labelCoder); + length += record.getEncodingLength(labelCoder) } for (const record of this.authorities.values()) { - length += record.getEncodingLength(labelCoder); + length += record.getEncodingLength(labelCoder) } for (const record of this.additionals.values()) { - length += record.getEncodingLength(labelCoder); + length += record.getEncodingLength(labelCoder) } - this.estimatedEncodingLength = length; + this.estimatedEncodingLength = length - return length; + return length } private getEncodingLength(coder?: DNSLabelCoder): number { if (!this.lengthDirty) { - return this.lastCalculatedLength; + return this.lastCalculatedLength } - const labelCoder = coder || new DNSLabelCoder(this.legacyUnicastEncoding); + const labelCoder = coder || new DNSLabelCoder(this.legacyUnicastEncoding) - let length = DNSPacket.DNS_PACKET_HEADER_SIZE; + let length = DNSPacket.DNS_PACKET_HEADER_SIZE for (const record of this.questions.values()) { - length += record.getEncodingLength(labelCoder); + length += record.getEncodingLength(labelCoder) } for (const record of this.answers.values()) { - length += record.getEncodingLength(labelCoder); + length += record.getEncodingLength(labelCoder) } for (const record of this.authorities.values()) { - length += record.getEncodingLength(labelCoder); + length += record.getEncodingLength(labelCoder) } for (const record of this.additionals.values()) { - length += record.getEncodingLength(labelCoder); + length += record.getEncodingLength(labelCoder) } - this.lengthDirty = false; // reset dirty flag - this.lastCalculatedLength = length; - this.estimatedEncodingLength = length; + this.lengthDirty = false // reset dirty flag + this.lastCalculatedLength = length + this.estimatedEncodingLength = length - return length; + return length } public encode(): Buffer { - const labelCoder = new DNSLabelCoder(this.legacyUnicastEncoding); + const labelCoder = new DNSLabelCoder(this.legacyUnicastEncoding) - const length = this.getEncodingLength(labelCoder); - const buffer = Buffer.allocUnsafe(length); + const length = this.getEncodingLength(labelCoder) + const buffer = Buffer.allocUnsafe(length) - labelCoder.initBuf(buffer); + labelCoder.initBuf(buffer) - let offset = 0; + let offset = 0 - buffer.writeUInt16BE(this.id, offset); - offset += 2; + buffer.writeUInt16BE(this.id, offset) + offset += 2 - let flags = (this.type << 15) | (this.opcode << 11) | this.rcode; + let flags = (this.type << 15) | (this.opcode << 11) | this.rcode if (this.flags.authoritativeAnswer) { - flags |= DNSPacket.AUTHORITATIVE_ANSWER_MASK; + flags |= DNSPacket.AUTHORITATIVE_ANSWER_MASK } if (this.flags.truncation) { - flags |= DNSPacket.TRUNCATION_MASK; + flags |= DNSPacket.TRUNCATION_MASK } if (this.flags.recursionDesired) { - flags |= DNSPacket.RECURSION_DESIRED_MASK; + flags |= DNSPacket.RECURSION_DESIRED_MASK } if (this.flags.recursionAvailable) { - flags |= DNSPacket.RECURSION_AVAILABLE_MASK; + flags |= DNSPacket.RECURSION_AVAILABLE_MASK } if (this.flags.zero) { - flags |= DNSPacket.ZERO_HEADER_MASK; + flags |= DNSPacket.ZERO_HEADER_MASK } if (this.flags.authenticData) { - flags |= DNSPacket.AUTHENTIC_DATA_MASK; + flags |= DNSPacket.AUTHENTIC_DATA_MASK } if (this.flags.checkingDisabled) { - flags |= DNSPacket.CHECKING_DISABLED_MASK; + flags |= DNSPacket.CHECKING_DISABLED_MASK } - buffer.writeUInt16BE(flags, offset); - offset += 2; + buffer.writeUInt16BE(flags, offset) + offset += 2 - buffer.writeUInt16BE(this.questions.size, offset); - offset += 2; - buffer.writeUInt16BE(this.answers.size, offset); - offset += 2; - buffer.writeUInt16BE(this.authorities.size, offset); - offset += 2; - buffer.writeUInt16BE(this.additionals.size, offset); - offset += 2; + buffer.writeUInt16BE(this.questions.size, offset) + offset += 2 + buffer.writeUInt16BE(this.answers.size, offset) + offset += 2 + buffer.writeUInt16BE(this.authorities.size, offset) + offset += 2 + buffer.writeUInt16BE(this.additionals.size, offset) + offset += 2 for (const question of this.questions.values()) { - const length = question.encode(labelCoder, buffer, offset); - offset += length; + const length = question.encode(labelCoder, buffer, offset) + offset += length } for (const record of this.answers.values()) { - const length = record.encode(labelCoder, buffer, offset); - offset += length; + const length = record.encode(labelCoder, buffer, offset) + offset += length } for (const record of this.authorities.values()) { - const length = record.encode(labelCoder, buffer, offset); - offset += length; + const length = record.encode(labelCoder, buffer, offset) + offset += length } for (const record of this.additionals.values()) { - const length = record.encode(labelCoder, buffer, offset); - offset += length; + const length = record.encode(labelCoder, buffer, offset) + offset += length } - assert(offset === buffer.length, "Bytes written didn't match the buffer size!"); + assert(offset === buffer.length, 'Bytes written didn\'t match the buffer size!') - return buffer; + return buffer } public static decode(context: AddressInfo, buffer: Buffer, offset = 0): DNSPacket { - const labelCoder = new DNSLabelCoder(); - labelCoder.initBuf(buffer); + const labelCoder = new DNSLabelCoder() + labelCoder.initBuf(buffer) - const id = buffer.readUInt16BE(offset); - offset += 2; + const id = buffer.readUInt16BE(offset) + offset += 2 - const flags = buffer.readUInt16BE(offset); - offset += 2; + const flags = buffer.readUInt16BE(offset) + offset += 2 - const questionLength = buffer.readUInt16BE(offset); - offset += 2; - const answerLength = buffer.readUInt16BE(offset); - offset += 2; - const authoritiesLength = buffer.readUInt16BE(offset); - offset += 2; - const additionalsLength = buffer.readUInt16BE(offset); - offset += 2; + const questionLength = buffer.readUInt16BE(offset) + offset += 2 + const answerLength = buffer.readUInt16BE(offset) + offset += 2 + const authoritiesLength = buffer.readUInt16BE(offset) + offset += 2 + const additionalsLength = buffer.readUInt16BE(offset) + offset += 2 - const questions: Question[] = []; - const answers: ResourceRecord[] = []; - const authorities: ResourceRecord[] = []; - const additionals: ResourceRecord[] = []; + const questions: Question[] = [] + const answers: ResourceRecord[] = [] + const authorities: ResourceRecord[] = [] + const additionals: ResourceRecord[] = [] + offset += DNSPacket.decodeList(context, labelCoder, buffer, offset, questionLength, Question.decode.bind(Question), questions) + offset += DNSPacket.decodeList(context, labelCoder, buffer, offset, answerLength, ResourceRecord.decode.bind(ResourceRecord), answers) + offset += DNSPacket.decodeList(context, labelCoder, buffer, offset, authoritiesLength, ResourceRecord.decode.bind(ResourceRecord), authorities) + offset += DNSPacket.decodeList(context, labelCoder, buffer, offset, additionalsLength, ResourceRecord.decode.bind(ResourceRecord), additionals) - offset += DNSPacket.decodeList(context, labelCoder, buffer, offset, questionLength, Question.decode.bind(Question), questions); - offset += DNSPacket.decodeList(context, labelCoder, buffer, offset, answerLength, ResourceRecord.decode.bind(ResourceRecord), answers); - offset += DNSPacket.decodeList(context, labelCoder, buffer, offset, authoritiesLength, ResourceRecord.decode.bind(ResourceRecord), authorities); - offset += DNSPacket.decodeList(context, labelCoder, buffer, offset, additionalsLength, ResourceRecord.decode.bind(ResourceRecord), additionals); + assert(offset === buffer.length, `Didn't read the full buffer (offset=${offset}, length=${buffer.length})`) - assert(offset === buffer.length, "Didn't read the full buffer (offset=" + offset +", length=" + buffer.length +")"); + const qr = (flags >> 15) as PacketType + const opcode = ((flags >> 11) & 0xF) as OpCode + const rCode = (flags & 0xF) as RCode + const packetFlags: PacketFlags = {} - const qr = (flags >> 15) as PacketType; - const opcode = ((flags >> 11) & 0xf) as OpCode; - const rCode = (flags & 0xf) as RCode; - const packetFlags: PacketFlags = {}; - - if (flags & this.AUTHORITATIVE_ANSWER_MASK) { - packetFlags.authoritativeAnswer = true; + if (Math.floor(flags / this.AUTHORITATIVE_ANSWER_MASK) % 2 === 1) { + packetFlags.authoritativeAnswer = true } - if (flags & this.TRUNCATION_MASK) { - packetFlags.truncation = true; + if (Math.floor(flags / this.TRUNCATION_MASK) % 2 === 1) { + packetFlags.truncation = true } - if (flags & this.RECURSION_DESIRED_MASK) { - packetFlags.recursionDesired = true; + if (Math.floor(flags / this.RECURSION_DESIRED_MASK) % 2 === 1) { + packetFlags.recursionDesired = true } - if (flags & this.RECURSION_AVAILABLE_MASK) { - packetFlags.recursionAvailable = true; + if (Math.floor(flags / this.RECURSION_AVAILABLE_MASK) % 2 === 1) { + packetFlags.recursionAvailable = true } - if (flags & this.ZERO_HEADER_MASK) { - packetFlags.zero = true; + if (Math.floor(flags / this.ZERO_HEADER_MASK) % 2 === 1) { + packetFlags.zero = true } - if (flags & this.AUTHENTIC_DATA_MASK) { - packetFlags.authenticData = true; + if (Math.floor(flags / this.AUTHENTIC_DATA_MASK) % 2 === 1) { + packetFlags.authenticData = true } - if (flags & this.CHECKING_DISABLED_MASK) { - packetFlags.checkingDisabled = true; + if (Math.floor(flags / this.CHECKING_DISABLED_MASK) % 2 === 1) { + packetFlags.checkingDisabled = true } return new DNSPacket({ - id: id, + id, type: qr, - opcode: opcode, - rCode: rCode, + opcode, + rCode, flags: packetFlags, - questions: questions, - answers: answers, - authorities: authorities, - additionals: additionals, - }); + questions, + answers, + authorities, + additionals, + }) } - private static decodeList(context: AddressInfo, coder: DNSLabelCoder, buffer: Buffer, offset: number, - length: number, decoder: (context: AddressInfo, coder: DNSLabelCoder, buffer: Buffer, offset: number) => OptionalDecodedData, destination: T[]): number { - const oldOffset = offset; + private static decodeList(context: AddressInfo, coder: DNSLabelCoder, buffer: Buffer, offset: number, length: number, decoder: (context: AddressInfo, coder: DNSLabelCoder, buffer: Buffer, offset: number) => OptionalDecodedData, destination: T[]): number { + const oldOffset = offset for (let i = 0; i < length; i++) { - const decoded = decoder(context, coder, buffer, offset); - offset += decoded.readBytes; + const decoded = decoder(context, coder, buffer, offset) + offset += decoded.readBytes if (decoded.data) { // if the rdata is not supported by us, or we encountered a parsing error, we ignore the record - destination.push(decoded.data); + destination.push(decoded.data) } } - return offset - oldOffset; + return offset - oldOffset } public asLoggingString(udpPayloadSize?: number): string { - let answerString = ""; - let additionalsString = ""; + let answerString = '' + let additionalsString = '' for (const record of this.answers.values()) { if (answerString) { - answerString += ","; + answerString += ',' } - answerString += dnsTypeToString(record.type); + answerString += dnsTypeToString(record.type) } for (const record of this.additionals.values()) { if (additionalsString) { - additionalsString += ","; + additionalsString += ',' } - additionalsString += dnsTypeToString(record.type); + additionalsString += dnsTypeToString(record.type) } - const optionsStrings: string[] = []; + const optionsStrings: string[] = [] if (this.legacyUnicastEncodingEnabled()) { - optionsStrings.push("U"); + optionsStrings.push('U') } if (udpPayloadSize) { - optionsStrings.push("UPS: " + udpPayloadSize); + optionsStrings.push(`UPS: ${udpPayloadSize}`) } - const optionsString = optionsStrings.length !== 0? ` (${optionsStrings})`: ""; + const optionsString = optionsStrings.length !== 0 ? ` (${optionsStrings})` : '' - return `[${answerString}] answers and [${additionalsString}] additionals with size ${this.getEncodingLength()}B${optionsString}`; + return `[${answerString}] answers and [${additionalsString}] additionals with size ${this.getEncodingLength()}B${optionsString}` } - } diff --git a/src/coder/Question.spec.ts b/src/coder/Question.spec.ts index 0b698d9..cf8574d 100644 --- a/src/coder/Question.spec.ts +++ b/src/coder/Question.spec.ts @@ -1,18 +1,21 @@ -import { QType } from "./DNSPacket"; -import { Question } from "./Question"; -import { runRecordEncodingTest } from "./test-utils"; +import { describe, it } from 'vitest' +import './records/index.js' -describe(Question, () => { - it("should run automatic coder tests", () => { - runRecordEncodingTest(new Question("test.local.", QType.AAAA)); - runRecordEncodingTest(new Question("asfdf._test._tcp.local.", QType.A)); - runRecordEncodingTest(new Question("asdkjd._ajsdj.local.", QType.CNAME)); - runRecordEncodingTest(new Question("testasd.local.", QType.NSEC)); - runRecordEncodingTest(new Question("testff.wsdasd.f.local.", QType.PTR)); - runRecordEncodingTest(new Question("testasd.lasd.asd.local.", QType.SRV)); - runRecordEncodingTest(new Question("test 2asdf.asdkasd.local.", QType.TXT)); - runRecordEncodingTest(new Question("testasd.local.", QType.ANY)); - }); +// eslint-disable-next-line perfectionist/sort-imports +import { QType } from './DNSPacket.js' +import { Question } from './Question.js' +import { runRecordEncodingTest } from './test-utils.js' -}); +describe(Question, () => { + it('should run automatic coder tests', () => { + runRecordEncodingTest(new Question('test.local.', QType.AAAA)) + runRecordEncodingTest(new Question('asfdf._test._tcp.local.', QType.A)) + runRecordEncodingTest(new Question('asdkjd._ajsdj.local.', QType.CNAME)) + runRecordEncodingTest(new Question('testasd.local.', QType.NSEC)) + runRecordEncodingTest(new Question('testff.wsdasd.f.local.', QType.PTR)) + runRecordEncodingTest(new Question('testasd.lasd.asd.local.', QType.SRV)) + runRecordEncodingTest(new Question('test 2asdf.asdkasd.local.', QType.TXT)) + runRecordEncodingTest(new Question('testasd.local.', QType.ANY)) + }) +}) diff --git a/src/coder/Question.ts b/src/coder/Question.ts index 5b2f6a5..ee195e2 100644 --- a/src/coder/Question.ts +++ b/src/coder/Question.ts @@ -1,87 +1,89 @@ -import { AddressInfo } from "net"; -import { dnsLowerCase } from "../util/dns-equal"; -import { DNSLabelCoder } from "./DNSLabelCoder"; -import { DecodedData, DNSRecord, QClass, QType } from "./DNSPacket"; +import type { Buffer } from 'node:buffer' +import type { AddressInfo } from 'node:net' -export class Question implements DNSRecord { +import type { DNSLabelCoder } from './DNSLabelCoder' +import type { DecodedData, DNSRecord, QType } from './DNSPacket' + +import { dnsLowerCase } from '../util/dns-equal.js' +import { QClass } from './DNSPacket.js' - private static readonly QU_MASK = 0x8000; // 2 bytes, first bit set - private static readonly NOT_QU_MASK = 0x7FFF; +export class Question implements DNSRecord { + private static readonly QU_MASK = 0x8000 // 2 bytes, first bit set + private static readonly NOT_QU_MASK = 0x7FFF - readonly name: string; - private lowerCasedName?: string; - readonly type: QType; - readonly class: QClass; + readonly name: string + private lowerCasedName?: string + readonly type: QType + readonly class: QClass - unicastResponseFlag = false; + unicastResponseFlag = false constructor(name: string, type: QType, unicastResponseFlag = false, clazz = QClass.IN) { - if (!name.endsWith(".")) { - name += "."; + if (!name.endsWith('.')) { + name += '.' } - this.name = name; - this.type = type; - this.class = clazz; + this.name = name + this.type = type + this.class = clazz - this.unicastResponseFlag = unicastResponseFlag; + this.unicastResponseFlag = unicastResponseFlag } public getLowerCasedName(): string { - return this.lowerCasedName || (this.lowerCasedName = dnsLowerCase(this.name)); + return this.lowerCasedName || (this.lowerCasedName = dnsLowerCase(this.name)) } public getEncodingLength(coder: DNSLabelCoder): number { - return coder.getNameLength(this.name) + 4; // 2 bytes type; 2 bytes class + return coder.getNameLength(this.name) + 4 // 2 bytes type; 2 bytes class } public encode(coder: DNSLabelCoder, buffer: Buffer, offset: number): number { - const oldOffset = offset; + const oldOffset = offset - const nameLength = coder.encodeName(this.name, offset); - offset += nameLength; + const nameLength = coder.encodeName(this.name, offset) + offset += nameLength - buffer.writeUInt16BE(this.type, offset); - offset += 2; + buffer.writeUInt16BE(this.type, offset) + offset += 2 - let qClass = this.class; + let qClass = this.class if (this.unicastResponseFlag) { - qClass |= Question.QU_MASK; + qClass |= Question.QU_MASK } - buffer.writeUInt16BE(qClass, offset); - offset += 2; + buffer.writeUInt16BE(qClass, offset) + offset += 2 - return offset - oldOffset; // written bytes + return offset - oldOffset // written bytes } public clone(): Question { - return new Question(this.name, this.type, this.unicastResponseFlag, this.class); + return new Question(this.name, this.type, this.unicastResponseFlag, this.class) } public asString(): string { - return `Q ${this.name} ${this.type} ${this.class}`; + return `Q ${this.name} ${this.type} ${this.class}` } public static decode(context: AddressInfo, coder: DNSLabelCoder, buffer: Buffer, offset: number): DecodedData { - const oldOffset = offset; + const oldOffset = offset - const decodedName = coder.decodeName(offset); - offset += decodedName.readBytes; + const decodedName = coder.decodeName(offset) + offset += decodedName.readBytes - const type = buffer.readUInt16BE(offset) as QType; - offset += 2; + const type = buffer.readUInt16BE(offset) as QType + offset += 2 - const qClass = buffer.readUInt16BE(offset); - offset += 2; - const clazz = (qClass & this.NOT_QU_MASK) as QClass; - const quFlag = !!(qClass & this.QU_MASK); + const qClass = buffer.readUInt16BE(offset) + offset += 2 + const clazz = (qClass & this.NOT_QU_MASK) as QClass + const quFlag = !!(qClass & this.QU_MASK) - const question = new Question(decodedName.data, type, quFlag, clazz); + const question = new Question(decodedName.data, type, quFlag, clazz) return { data: question, readBytes: offset - oldOffset, - }; + } } - } diff --git a/src/coder/ResourceRecord.spec.ts b/src/coder/ResourceRecord.spec.ts index af98752..d721e8f 100644 --- a/src/coder/ResourceRecord.spec.ts +++ b/src/coder/ResourceRecord.spec.ts @@ -1,67 +1,75 @@ -import { RType } from "./DNSPacket"; -import { AAAARecord } from "./records/AAAARecord"; -import { ARecord } from "./records/ARecord"; -import { CNAMERecord } from "./records/CNAMERecord"; -import { NSECRecord } from "./records/NSECRecord"; -import { OPTOption, OPTRecord } from "./records/OPTRecord"; -import { PTRRecord } from "./records/PTRRecord"; -import { SRVRecord } from "./records/SRVRecord"; -import { TXTRecord } from "./records/TXTRecord"; -import { ResourceRecord } from "./ResourceRecord"; -import { runRecordEncodingTest } from "./test-utils"; +import type { OPTOption } from './records/OPTRecord' + +import { Buffer } from 'node:buffer' + +import { describe, it } from 'vitest' + +import './records/index.js' + +// eslint-disable-next-line perfectionist/sort-imports +import { RType } from './DNSPacket.js' +import { AAAARecord } from './records/AAAARecord.js' +import { ARecord } from './records/ARecord.js' +import { CNAMERecord } from './records/CNAMERecord.js' +import { NSECRecord } from './records/NSECRecord.js' +import { OPTRecord } from './records/OPTRecord.js' +import { PTRRecord } from './records/PTRRecord.js' +import { SRVRecord } from './records/SRVRecord.js' +import { TXTRecord } from './records/TXTRecord.js' +import { ResourceRecord } from './ResourceRecord.js' +import { runRecordEncodingTest } from './test-utils.js' describe(ResourceRecord, () => { - it("should encode AAAA", () => { - runRecordEncodingTest(new AAAARecord("test.local.", "::1")); - runRecordEncodingTest(new AAAARecord("sub.test.local.", "fe80::14b0:44f7:b2ae:18e5")); - }); + it('should encode AAAA', () => { + runRecordEncodingTest(new AAAARecord('test.local.', '::1')) + runRecordEncodingTest(new AAAARecord('sub.test.local.', 'fe80::14b0:44f7:b2ae:18e5')) + }) - it("should encode A", () => { - runRecordEncodingTest(new ARecord("test.local.", "192.168.178.1")); - runRecordEncodingTest(new ARecord("sub.test.local.", "192.168.0.1")); - }); + it('should encode A', () => { + runRecordEncodingTest(new ARecord('test.local.', '192.168.178.1')) + runRecordEncodingTest(new ARecord('sub.test.local.', '192.168.0.1')) + }) - /*it("should encode IPv4-mapped IPv6 addresses in AAAA records", () => { + /* it("should encode IPv4-mapped IPv6 addresses in AAAA records", () => { runRecordEncodingTest(new AAAARecord("v4mapped.local.", "::ffff:192.168.178.1")); runRecordEncodingTest(new AAAARecord("sub.v4mapped.local.", "::ffff:192.168.0.1")); - });*/ - - it("should encode CNAME", () => { - runRecordEncodingTest(new CNAMERecord("test.local.", "test2.local.")); - runRecordEncodingTest(new CNAMERecord("sub.test.local.", "test2.local.")); - }); + }); */ - it("should encode NSEC", () => { - runRecordEncodingTest(new NSECRecord("test.local.", "test.local.", [RType.TXT, RType.SRV, RType.A], 120)); - runRecordEncodingTest(new NSECRecord("sub.test.local.", "sub.test.local.", [RType.CNAME, RType.AAAA], 120)); - }); + it('should encode CNAME', () => { + runRecordEncodingTest(new CNAMERecord('test.local.', 'test2.local.')) + runRecordEncodingTest(new CNAMERecord('sub.test.local.', 'test2.local.')) + }) - it("should encode OPT", () => { - const options: OPTOption[] = [{ code: 1337, data: Buffer.from("hello world")}, { code: 123, data: Buffer.from("456")}]; + it('should encode NSEC', () => { + runRecordEncodingTest(new NSECRecord('test.local.', 'test.local.', [RType.TXT, RType.SRV, RType.A], 120)) + runRecordEncodingTest(new NSECRecord('sub.test.local.', 'sub.test.local.', [RType.CNAME, RType.AAAA], 120)) + }) - runRecordEncodingTest(new OPTRecord(1472)); - runRecordEncodingTest(new OPTRecord(1472, options)); - runRecordEncodingTest(new OPTRecord(1472, [], 13, { dnsSecOK: true })); - runRecordEncodingTest(new OPTRecord(1472, options, 13, { dnsSecOK: true }, 1)); - }); + it('should encode OPT', () => { + const options: OPTOption[] = [{ code: 1337, data: Buffer.from('hello world') }, { code: 123, data: Buffer.from('456') }] - it("should encode PTR", () => { - runRecordEncodingTest(new PTRRecord("test.local.", "test2.local.")); - runRecordEncodingTest(new PTRRecord("sub.test.local.", "test2.local.")); - }); + runRecordEncodingTest(new OPTRecord(1472)) + runRecordEncodingTest(new OPTRecord(1472, options)) + runRecordEncodingTest(new OPTRecord(1472, [], 13, { dnsSecOK: true })) + runRecordEncodingTest(new OPTRecord(1472, options, 13, { dnsSecOK: true }, 1)) + }) - it("should encode SRV", () => { - const unicastSRV = new SRVRecord("My Great Service._hap._tcp.local.", "test.local.", 8080); - runRecordEncodingTest(unicastSRV, true); + it('should encode PTR', () => { + runRecordEncodingTest(new PTRRecord('test.local.', 'test2.local.')) + runRecordEncodingTest(new PTRRecord('sub.test.local.', 'test2.local.')) + }) - runRecordEncodingTest(new SRVRecord("My Great Service._hap._tcp.local.", "test.local.", 8080)); - runRecordEncodingTest(new SRVRecord("My Great Service2._hap._tcp.local.", "test2.local", 8081)); - }); + it('should encode SRV', () => { + const unicastSRV = new SRVRecord('My Great Service._hap._tcp.local.', 'test.local.', 8080) + runRecordEncodingTest(unicastSRV, true) - it("should encode TXT", () => { - runRecordEncodingTest(new TXTRecord("test.local.", [])); - runRecordEncodingTest(new TXTRecord("test.local.", [Buffer.from("key=value")])); - runRecordEncodingTest(new TXTRecord("test.local.", [Buffer.from("key=value"), Buffer.from("key2=value2")])); - }); + runRecordEncodingTest(new SRVRecord('My Great Service._hap._tcp.local.', 'test.local.', 8080)) + runRecordEncodingTest(new SRVRecord('My Great Service2._hap._tcp.local.', 'test2.local', 8081)) + }) -}); + it('should encode TXT', () => { + runRecordEncodingTest(new TXTRecord('test.local.', [])) + runRecordEncodingTest(new TXTRecord('test.local.', [Buffer.from('key=value')])) + runRecordEncodingTest(new TXTRecord('test.local.', [Buffer.from('key=value'), Buffer.from('key2=value2')])) + }) +}) diff --git a/src/coder/ResourceRecord.ts b/src/coder/ResourceRecord.ts index f298430..f72991f 100644 --- a/src/coder/ResourceRecord.ts +++ b/src/coder/ResourceRecord.ts @@ -1,140 +1,145 @@ -import assert from "assert"; -import createDebug from "debug"; -import { AddressInfo } from "net"; -import { dnsLowerCase } from "../util/dns-equal"; -import { dnsTypeToString } from "./dns-string-utils"; -import { DNSLabelCoder, NonCompressionLabelCoder } from "./DNSLabelCoder"; -import { DecodedData, DNSRecord, OptionalDecodedData, RClass, RType } from "./DNSPacket"; +import type { AddressInfo } from 'node:net' -const debug = createDebug("ciao:decoder"); +import type { DNSLabelCoder } from './DNSLabelCoder' +import type { DecodedData, DNSRecord, OptionalDecodedData } from './DNSPacket' + +import assert from 'node:assert' +import { Buffer } from 'node:buffer' + +import createDebug from 'debug' + +import { dnsLowerCase } from '../util/dns-equal.js' +import { dnsTypeToString } from './dns-string-utils.js' +import { NonCompressionLabelCoder } from './DNSLabelCoder.js' +import { RClass, RType } from './DNSPacket.js' + +const debug = createDebug('ciao:decoder') export interface RecordRepresentation { - name: string; - type: RType; - class: RClass; - ttl: number; - flushFlag: boolean; + name: string + type: RType + class: RClass + ttl: number + flushFlag: boolean } interface RecordHeaderData extends RecordRepresentation { - rDataLength: number; + rDataLength: number } -export type RRDecoder = (coder: DNSLabelCoder, header: RecordRepresentation, buffer: Buffer, offset: number) => DecodedData; +export type RRDecoder = (coder: DNSLabelCoder, header: RecordRepresentation, buffer: Buffer, offset: number) => DecodedData export abstract class ResourceRecord implements DNSRecord { // RFC 1035 4.1.3. + public static readonly typeToRecordDecoder: Map = new Map() - public static readonly typeToRecordDecoder: Map = new Map(); - - private static readonly FLUSH_MASK = 0x8000; // 2 bytes, first bit set - private static readonly NOT_FLUSH_MASK = 0x7FFF; + private static readonly FLUSH_MASK = 0x8000 // 2 bytes, first bit set + private static readonly NOT_FLUSH_MASK = 0x7FFF - public static readonly RR_DEFAULT_TTL_SHORT = 120; // 120 seconds - public static readonly RR_DEFAULT_TTL = 4500; // 75 minutes + public static readonly RR_DEFAULT_TTL_SHORT = 120 // 120 seconds + public static readonly RR_DEFAULT_TTL = 4500 // 75 minutes - readonly name: string; - private lowerCasedName?: string; - readonly type: RType; - readonly class: RClass; - ttl: number; + readonly name: string + private lowerCasedName?: string + readonly type: RType + readonly class: RClass + ttl: number - flushFlag = false; + flushFlag = false - protected constructor(headerData: RecordRepresentation); - protected constructor(name: string, type: RType, ttl?: number, flushFlag?: boolean, clazz?: RClass); + protected constructor(headerData: RecordRepresentation) + protected constructor(name: string, type: RType, ttl?: number, flushFlag?: boolean, clazz?: RClass) protected constructor(name: string | RecordRepresentation, type?: RType, ttl: number = ResourceRecord.RR_DEFAULT_TTL, flushFlag = false, clazz: RClass = RClass.IN) { - if (typeof name === "string") { - if (!name.endsWith(".")) { - name = name + "."; + if (typeof name === 'string') { + if (!name.endsWith('.')) { + name = `${name}.` } - this.name = name; - this.type = type!; - this.class = clazz; - this.ttl = ttl; - this.flushFlag = flushFlag; + this.name = name + this.type = type! + this.class = clazz + this.ttl = ttl + this.flushFlag = flushFlag } else { - this.name = name.name; - this.type = name.type; - this.class = name.class; - this.ttl = name.ttl; - this.flushFlag = name.flushFlag; + this.name = name.name + this.type = name.type + this.class = name.class + this.ttl = name.ttl + this.flushFlag = name.flushFlag } } public getLowerCasedName(): string { - return this.lowerCasedName || (this.lowerCasedName = dnsLowerCase(this.name)); + return this.lowerCasedName || (this.lowerCasedName = dnsLowerCase(this.name)) } public getEncodingLength(coder: DNSLabelCoder): number { return coder.getNameLength(this.name) + 10 // 2 bytes TYPE; 2 bytes class, 4 bytes TTL, 2 bytes RDLength - + this.getRDataEncodingLength(coder); + + this.getRDataEncodingLength(coder) } public encode(coder: DNSLabelCoder, buffer: Buffer, offset: number): number { - const oldOffset = offset; + const oldOffset = offset - const nameLength = coder.encodeName(this.name, offset); - offset += nameLength; + const nameLength = coder.encodeName(this.name, offset) + offset += nameLength - buffer.writeUInt16BE(this.type, offset); - offset += 2; + buffer.writeUInt16BE(this.type, offset) + offset += 2 - let rClass = this.class; + let rClass = this.class if (this.flushFlag) { // for pseudo records like OPT, TSIG, TKEY, SIG0 the top bit should not be interpreted as the flush flag // though we do not support those (OPT seems to be the only used, though no idea for what [by Apple for mdns]) - rClass |= ResourceRecord.FLUSH_MASK; + rClass |= ResourceRecord.FLUSH_MASK } - buffer.writeUInt16BE(rClass, offset); - offset += 2; + buffer.writeUInt16BE(rClass, offset) + offset += 2 - buffer.writeUInt32BE(this.ttl, offset); - offset += 4; + buffer.writeUInt32BE(this.ttl, offset) + offset += 4 - const dataLength = this.encodeRData(coder, buffer, offset + 2); + const dataLength = this.encodeRData(coder, buffer, offset + 2) - buffer.writeUInt16BE(dataLength, offset); - offset += 2 + dataLength; + buffer.writeUInt16BE(dataLength, offset) + offset += 2 + dataLength - return offset - oldOffset; // written bytes + return offset - oldOffset // written bytes } public getRawData(): Buffer { // returns the rData as a buffer without any message compression (used for probe tiebreaking) - const coder = NonCompressionLabelCoder.INSTANCE; // this forces uncompressed names + const coder = NonCompressionLabelCoder.INSTANCE // this forces uncompressed names - const length = this.getRDataEncodingLength(coder); - const buffer = Buffer.allocUnsafe(length); + const length = this.getRDataEncodingLength(coder) + const buffer = Buffer.allocUnsafe(length) - coder.initBuf(buffer); + coder.initBuf(buffer) - const writtenBytes = this.encodeRData(coder, buffer, 0); - assert(writtenBytes === buffer.length, "Didn't completely write to the buffer! (" + writtenBytes + "!=" + buffer.length +")"); + const writtenBytes = this.encodeRData(coder, buffer, 0) + assert(writtenBytes === buffer.length, `Didn't completely write to the buffer! (${writtenBytes}!=${buffer.length})`) - coder.initBuf(); // reset buffer to undefined + coder.initBuf() // reset buffer to undefined - return buffer; + return buffer } - protected abstract getRDataEncodingLength(coder: DNSLabelCoder): number; + protected abstract getRDataEncodingLength(coder: DNSLabelCoder): number - protected abstract encodeRData(coder: DNSLabelCoder, buffer: Buffer, offset: number): number; + protected abstract encodeRData(coder: DNSLabelCoder, buffer: Buffer, offset: number): number - public abstract dataAsString(): string; + public abstract dataAsString(): string - public abstract clone(): ResourceRecord; + public abstract clone(): ResourceRecord /** * Evaluates if the data section of the record is equal to the supplied record * @param record */ - public abstract dataEquals(record: ResourceRecord): boolean; + public abstract dataEquals(record: ResourceRecord): boolean public static clone(records: T[]): T[] { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - return records.map(record => record.clone()); + // @ts-expect-error ResourceRecord[] is not assignable to type T[] + return records.map(record => record.clone()) } protected getRecordRepresentation(): RecordRepresentation { @@ -144,7 +149,7 @@ export abstract class ResourceRecord implements DNSRecord { // RFC 1035 4.1.3. class: this.class, ttl: this.ttl, flushFlag: this.flushFlag, - }; + } } /** @@ -153,96 +158,95 @@ export abstract class ResourceRecord implements DNSRecord { // RFC 1035 4.1.3. */ public aboutEqual(record: ResourceRecord): boolean { return this.type === record.type && this.name === record.name && this.class === record.class - && this.dataEquals(record); + && this.dataEquals(record) } public representsSameData(record: ResourceRecord): boolean { - return this.type === record.type && this.name === record.name && this.class === record.class; + return this.type === record.type && this.name === record.name && this.class === record.class } public asString(): string { // same as aboutEqual, ttl is not included - return `RR ${this.name} ${this.type} ${this.class} ${this.dataAsString()}`; + return `RR ${this.name} ${this.type} ${this.class} ${this.dataAsString()}` } public static decode(context: AddressInfo, coder: DNSLabelCoder, buffer: Buffer, offset: number): OptionalDecodedData { - const oldOffset = offset; + const oldOffset = offset - const decodedHeader = this.decodeRecordHeader(coder, buffer, offset); - offset += decodedHeader.readBytes; + const decodedHeader = this.decodeRecordHeader(coder, buffer, offset) + offset += decodedHeader.readBytes - const header = decodedHeader.data; - const rrDecoder = this.typeToRecordDecoder.get(header.type); + const header = decodedHeader.data + const rrDecoder = this.typeToRecordDecoder.get(header.type) if (!rrDecoder) { - return { readBytes: (offset + header.rDataLength) - oldOffset }; + return { readBytes: (offset + header.rDataLength) - oldOffset } } - coder.initRRLocation(oldOffset, offset, header.rDataLength); // defines record offset and rdata offset for local compression + coder.initRRLocation(oldOffset, offset, header.rDataLength) // defines record offset and rdata offset for local compression - const rdata = buffer.subarray(0, offset + header.rDataLength); + const rdata = buffer.subarray(0, offset + header.rDataLength) - let decodedRecord; + let decodedRecord try { // we slice the buffer (below), so out of bounds error are instantly detected - decodedRecord = rrDecoder(coder, header, rdata, offset); - } catch (error) { + decodedRecord = rrDecoder(coder, header, rdata, offset) + } catch (error: any) { debug(`Received malformed rdata section for ${dnsTypeToString(header.type)} ${header.name} ${header.ttl} \ -from ${context.address}:${context.port} with data '${rdata.subarray(offset).toString("hex")}': ${error.stack}`); +from ${context.address}:${context.port} with data '${rdata.subarray(offset).toString('hex')}': ${error.stack}`) - return { readBytes: (offset + header.rDataLength) - oldOffset }; + return { readBytes: (offset + header.rDataLength) - oldOffset } } - offset += decodedRecord.readBytes; + offset += decodedRecord.readBytes - coder.clearRRLocation(); + coder.clearRRLocation() return { data: decodedRecord.data, readBytes: offset - oldOffset, - }; + } } protected static decodeRecordHeader(coder: DNSLabelCoder, buffer: Buffer, offset: number): DecodedData { - const oldOffset = offset; + const oldOffset = offset - const decodedName = coder.decodeName(offset); - offset += decodedName.readBytes; + const decodedName = coder.decodeName(offset) + offset += decodedName.readBytes - const type = buffer.readUInt16BE(offset) as RType; - offset += 2; + const type = buffer.readUInt16BE(offset) as RType + offset += 2 - const rClass = buffer.readUInt16BE(offset); - offset += 2; + const rClass = buffer.readUInt16BE(offset) + offset += 2 - let clazz; - let flushFlag = false; + let clazz + let flushFlag = false if (type !== RType.OPT) { - clazz = (rClass & this.NOT_FLUSH_MASK) as RClass; - flushFlag = !!(rClass & this.FLUSH_MASK); + clazz = (rClass & this.NOT_FLUSH_MASK) as RClass + flushFlag = !!(rClass & this.FLUSH_MASK) } else { // OPT class field encodes udpPayloadSize field - clazz = rClass; + clazz = rClass } - const ttl = buffer.readUInt32BE(offset); - offset += 4; + const ttl = buffer.readUInt32BE(offset) + offset += 4 - const rDataLength = buffer.readUInt16BE(offset); - offset += 2; + const rDataLength = buffer.readUInt16BE(offset) + offset += 2 const rHeader: RecordHeaderData = { name: decodedName.data, - type: type, + type, class: clazz, - ttl: ttl, - flushFlag: flushFlag, - rDataLength: rDataLength, - }; + ttl, + flushFlag, + rDataLength, + } return { data: rHeader, readBytes: offset - oldOffset, - }; + } } - } diff --git a/src/coder/dns-string-utils.ts b/src/coder/dns-string-utils.ts index d88d495..9566fe6 100644 --- a/src/coder/dns-string-utils.ts +++ b/src/coder/dns-string-utils.ts @@ -1,25 +1,25 @@ -import { QType, RType } from "./DNSPacket"; +import type { QType, RType } from './DNSPacket' export function dnsTypeToString(type: RType | QType): string { switch (type) { case 1: - return "A"; + return 'A' case 5: - return "CNAME"; + return 'CNAME' case 12: - return "PTR"; + return 'PTR' case 16: - return "TXT"; + return 'TXT' case 28: - return "AAAA"; + return 'AAAA' case 33: - return "SRV"; + return 'SRV' case 41: - return "OPT"; + return 'OPT' case 47: - return "NSEC"; + return 'NSEC' case 255: - return "ANY"; + return 'ANY' } - return "UNSUPPORTED_" + type; + return `UNSUPPORTED_${type}` } diff --git a/src/coder/records/AAAARecord.ts b/src/coder/records/AAAARecord.ts index 3c5fc6c..4c9d822 100644 --- a/src/coder/records/AAAARecord.ts +++ b/src/coder/records/AAAARecord.ts @@ -1,80 +1,84 @@ -import assert from "assert"; -import net from "net"; -import { enlargeIPv6, shortenIPv6 } from "../../util/domain-formatter"; -import { DNSLabelCoder } from "../DNSLabelCoder"; -import { DecodedData, RType } from "../DNSPacket"; -import { RecordRepresentation, ResourceRecord } from "../ResourceRecord"; +import type { Buffer } from 'node:buffer' -export class AAAARecord extends ResourceRecord { +import type { DNSLabelCoder } from '../DNSLabelCoder' +import type { DecodedData } from '../DNSPacket' +import type { RecordRepresentation } from '../ResourceRecord' + +import assert from 'node:assert' +import net from 'node:net' + +import { enlargeIPv6, shortenIPv6 } from '../../util/domain-formatter.js' +import { RType } from '../DNSPacket.js' +import { ResourceRecord } from '../ResourceRecord.js' - public static readonly DEFAULT_TTL = AAAARecord.RR_DEFAULT_TTL_SHORT; +export class AAAARecord extends ResourceRecord { + public static readonly DEFAULT_TTL = AAAARecord.RR_DEFAULT_TTL_SHORT - readonly ipAddress: string; + readonly ipAddress: string - constructor(name: string, ipAddress: string, flushFlag?: boolean, ttl?: number, ); - constructor(header: RecordRepresentation, ipAddress: string); + constructor(name: string, ipAddress: string, flushFlag?: boolean, ttl?: number,) + constructor(header: RecordRepresentation, ipAddress: string) constructor(name: string | RecordRepresentation, ipAddress: string, flushFlag?: boolean, ttl?: number) { - if (typeof name === "string") { - super(name, RType.AAAA, ttl || AAAARecord.DEFAULT_TTL, flushFlag); + if (typeof name === 'string') { + super(name, RType.AAAA, ttl || AAAARecord.DEFAULT_TTL, flushFlag) } else { - assert(name.type === RType.AAAA); - super(name); + assert(name.type === RType.AAAA) + super(name) } - assert(net.isIPv6(ipAddress), "IP address is not in v6 format!"); - this.ipAddress = ipAddress; + assert(net.isIPv6(ipAddress), 'IP address is not in v6 format!') + this.ipAddress = ipAddress } protected getRDataEncodingLength(): number { - return 16; // 16 byte ipv6 address + return 16 // 16 byte ipv6 address } protected encodeRData(coder: DNSLabelCoder, buffer: Buffer, offset: number): number { - const oldOffset = offset; + const oldOffset = offset - const address = enlargeIPv6(this.ipAddress); - const hextets = address.split(":"); - assert(hextets.length === 8, "invalid IP address"); + const address = enlargeIPv6(this.ipAddress) + const hextets = address.split(':') + assert(hextets.length === 8, 'invalid IP address') for (const hextet of hextets) { - const number = parseInt(hextet, 16); - buffer.writeUInt16BE(number, offset); - offset += 2; + const number = Number.parseInt(hextet, 16) + buffer.writeUInt16BE(number, offset) + offset += 2 } - return offset - oldOffset; // written bytes + return offset - oldOffset // written bytes } public static decodeData(coder: DNSLabelCoder, header: RecordRepresentation, buffer: Buffer, offset: number): DecodedData { - const oldOffset = offset; + const oldOffset = offset - const ipBytes: string[] = new Array(8); + const ipBytes: string[] = Array.from({ length: 8 }) for (let i = 0; i < 8; i++) { - const number = buffer.readUInt16BE(offset); - offset += 2; + const number = buffer.readUInt16BE(offset) + offset += 2 - ipBytes[i] = number.toString(16); + ipBytes[i] = number.toString(16) } - const ipAddress = shortenIPv6(ipBytes.join(":")); + const ipAddress = shortenIPv6(ipBytes.join(':')) return { data: new AAAARecord(header, ipAddress), readBytes: offset - oldOffset, - }; + } } public clone(): AAAARecord { - return new AAAARecord(this.getRecordRepresentation(), this.ipAddress); + return new AAAARecord(this.getRecordRepresentation(), this.ipAddress) } public dataAsString(): string { - return this.ipAddress; + return this.ipAddress } public dataEquals(record: AAAARecord): boolean { - return this.ipAddress === record.ipAddress; + return this.ipAddress === record.ipAddress } - } diff --git a/src/coder/records/ARecord.ts b/src/coder/records/ARecord.ts index 8883e10..bbc90f8 100644 --- a/src/coder/records/ARecord.ts +++ b/src/coder/records/ARecord.ts @@ -1,76 +1,80 @@ -import assert from "assert"; -import net from "net"; -import { DNSLabelCoder } from "../DNSLabelCoder"; -import { DecodedData, RType } from "../DNSPacket"; -import { RecordRepresentation, ResourceRecord } from "../ResourceRecord"; +import type { Buffer } from 'node:buffer' -export class ARecord extends ResourceRecord { +import type { DNSLabelCoder } from '../DNSLabelCoder' +import type { DecodedData } from '../DNSPacket' +import type { RecordRepresentation } from '../ResourceRecord' + +import assert from 'node:assert' +import net from 'node:net' + +import { RType } from '../DNSPacket.js' +import { ResourceRecord } from '../ResourceRecord.js' - public static readonly DEFAULT_TTL = ARecord.RR_DEFAULT_TTL_SHORT; +export class ARecord extends ResourceRecord { + public static readonly DEFAULT_TTL = ARecord.RR_DEFAULT_TTL_SHORT - readonly ipAddress: string; + readonly ipAddress: string - constructor(name: string, ipAddress: string, flushFlag?: boolean, ttl?: number); - constructor(header: RecordRepresentation, ipAddress: string); + constructor(name: string, ipAddress: string, flushFlag?: boolean, ttl?: number) + constructor(header: RecordRepresentation, ipAddress: string) constructor(name: string | RecordRepresentation, ipAddress: string, flushFlag?: boolean, ttl?: number) { - if (typeof name === "string") { - super(name, RType.A, ttl || ARecord.DEFAULT_TTL, flushFlag); + if (typeof name === 'string') { + super(name, RType.A, ttl || ARecord.DEFAULT_TTL, flushFlag) } else { - assert(name.type === RType.A); - super(name); + assert(name.type === RType.A) + super(name) } - assert(net.isIPv4(ipAddress), "IP address is not in IPv4 format!"); + assert(net.isIPv4(ipAddress), 'IP address is not in IPv4 format!') - this.ipAddress = ipAddress; + this.ipAddress = ipAddress } protected getRDataEncodingLength(): number { - return 4; // 4 byte ipv4 address + return 4 // 4 byte ipv4 address } protected encodeRData(coder: DNSLabelCoder, buffer: Buffer, offset: number): number { - const oldOffset = offset; + const oldOffset = offset - const bytes = this.ipAddress.split("."); - assert(bytes.length === 4, "invalid ip address"); + const bytes = this.ipAddress.split('.') + assert(bytes.length === 4, 'invalid ip address') for (const byte of bytes) { - const number = parseInt(byte, 10); - buffer.writeUInt8(number, offset++); + const number = Number.parseInt(byte, 10) + buffer.writeUInt8(number, offset++) } - return offset - oldOffset; // written bytes + return offset - oldOffset // written bytes } public static decodeData(coder: DNSLabelCoder, header: RecordRepresentation, buffer: Buffer, offset: number): DecodedData { - const oldOffset = offset; + const oldOffset = offset - const ipBytes: string[] = new Array(4); + const ipBytes: string[] = Array.from({ length: 4 }) for (let i = 0; i < 4; i++) { - const byte = buffer.readUInt8(offset++); - ipBytes[i] = byte.toString(10); + const byte = buffer.readUInt8(offset++) + ipBytes[i] = byte.toString(10) } - const ipAddress = ipBytes.join("."); + const ipAddress = ipBytes.join('.') return { data: new ARecord(header, ipAddress), readBytes: offset - oldOffset, - }; + } } public clone(): ARecord { - return new ARecord(this.getRecordRepresentation(), this.ipAddress); + return new ARecord(this.getRecordRepresentation(), this.ipAddress) } public dataAsString(): string { - return this.ipAddress; + return this.ipAddress } public dataEquals(record: ARecord): boolean { - return this.ipAddress === record.ipAddress; + return this.ipAddress === record.ipAddress } - } diff --git a/src/coder/records/CNAMERecord.ts b/src/coder/records/CNAMERecord.ts index f8645f7..4957098 100644 --- a/src/coder/records/CNAMERecord.ts +++ b/src/coder/records/CNAMERecord.ts @@ -1,72 +1,76 @@ -import assert from "assert"; -import { dnsLowerCase } from "../../util/dns-equal"; -import { DNSLabelCoder } from "../DNSLabelCoder"; -import { DecodedData, RType } from "../DNSPacket"; -import { RecordRepresentation, ResourceRecord } from "../ResourceRecord"; +import type { Buffer } from 'node:buffer' -export class CNAMERecord extends ResourceRecord { +import type { DNSLabelCoder } from '../DNSLabelCoder' +import type { DecodedData } from '../DNSPacket' +import type { RecordRepresentation } from '../ResourceRecord' + +import assert from 'node:assert' + +import { dnsLowerCase } from '../../util/dns-equal.js' +import { RType } from '../DNSPacket.js' +import { ResourceRecord } from '../ResourceRecord.js' - public static readonly DEFAULT_TTL = ResourceRecord.RR_DEFAULT_TTL; +export class CNAMERecord extends ResourceRecord { + public static readonly DEFAULT_TTL = ResourceRecord.RR_DEFAULT_TTL - readonly cname: string; - private lowerCasedCName?: string; + readonly cname: string + private lowerCasedCName?: string - constructor(name: string, cname: string, flushFlag?: boolean, ttl?: number); - constructor(header: RecordRepresentation, cname: string); + constructor(name: string, cname: string, flushFlag?: boolean, ttl?: number) + constructor(header: RecordRepresentation, cname: string) constructor(name: string | RecordRepresentation, cname: string, flushFlag?: boolean, ttl?: number) { - if (typeof name === "string") { - super(name, RType.CNAME, ttl, flushFlag); + if (typeof name === 'string') { + super(name, RType.CNAME, ttl, flushFlag) } else { - assert(name.type === RType.CNAME); - super(name); + assert(name.type === RType.CNAME) + super(name) } - if (!cname.endsWith(".")) { - cname += "."; + if (!cname.endsWith('.')) { + cname += '.' } - this.cname = cname; + this.cname = cname } public getLowerCasedCName(): string { - return this.lowerCasedCName || (this.lowerCasedCName = dnsLowerCase(this.cname)); + return this.lowerCasedCName || (this.lowerCasedCName = dnsLowerCase(this.cname)) } protected getRDataEncodingLength(coder: DNSLabelCoder): number { - return coder.getNameLength(this.cname); + return coder.getNameLength(this.cname) } protected encodeRData(coder: DNSLabelCoder, buffer: Buffer, offset: number): number { - const oldOffset = offset; + const oldOffset = offset - const cnameLength = coder.encodeName(this.cname, offset); - offset += cnameLength; + const cnameLength = coder.encodeName(this.cname, offset) + offset += cnameLength - return offset - oldOffset; // written bytes + return offset - oldOffset // written bytes } public static decodeData(coder: DNSLabelCoder, header: RecordRepresentation, buffer: Buffer, offset: number): DecodedData { - const oldOffset = offset; + const oldOffset = offset - const decodedName = coder.decodeName(offset); - offset += decodedName.readBytes; + const decodedName = coder.decodeName(offset) + offset += decodedName.readBytes return { data: new CNAMERecord(header, decodedName.data), readBytes: offset - oldOffset, - }; + } } public clone(): CNAMERecord { - return new CNAMERecord(this.getRecordRepresentation(), this.cname); + return new CNAMERecord(this.getRecordRepresentation(), this.cname) } public dataAsString(): string { - return this.cname; + return this.cname } public dataEquals(record: CNAMERecord): boolean { - return this.getLowerCasedCName() === record.getLowerCasedCName(); + return this.getLowerCasedCName() === record.getLowerCasedCName() } - } diff --git a/src/coder/records/NSECRecord.ts b/src/coder/records/NSECRecord.ts index c46506e..b8474fb 100644 --- a/src/coder/records/NSECRecord.ts +++ b/src/coder/records/NSECRecord.ts @@ -1,55 +1,60 @@ -import assert from "assert"; -import deepEqual from "fast-deep-equal"; -import { dnsLowerCase } from "../../util/dns-equal"; -import { DNSLabelCoder } from "../DNSLabelCoder"; -import { DecodedData, RType } from "../DNSPacket"; -import { RecordRepresentation, ResourceRecord } from "../ResourceRecord"; +import type { DNSLabelCoder } from '../DNSLabelCoder' +import type { DecodedData } from '../DNSPacket' +import type { RecordRepresentation } from '../ResourceRecord' + +import assert from 'node:assert' +import { Buffer } from 'node:buffer' + +import deepEqual from 'fast-deep-equal' + +import { dnsLowerCase } from '../../util/dns-equal.js' +import { RType } from '../DNSPacket.js' +import { ResourceRecord } from '../ResourceRecord.js' interface RRTypeWindow { - windowId: number; - bitMapSize: number; - rrtypes: RType[]; + windowId: number + bitMapSize: number + rrtypes: RType[] } export class NSECRecord extends ResourceRecord { + readonly nextDomainName: string + private lowerCasedNextDomainName?: string + readonly rrTypeWindows: RRTypeWindow[] - readonly nextDomainName: string; - private lowerCasedNextDomainName?: string; - readonly rrTypeWindows: RRTypeWindow[]; - - constructor(name: string, nextDomainName: string, rrtypes: RType[], ttl: number, flushFlag?: boolean); - constructor(header: RecordRepresentation, nextDomainName: string, rrtypes: RType[]); + constructor(name: string, nextDomainName: string, rrtypes: RType[], ttl: number, flushFlag?: boolean) + constructor(header: RecordRepresentation, nextDomainName: string, rrtypes: RType[]) constructor(name: string | RecordRepresentation, nextDomainName: string, rrtypes: RType[], ttl?: number, flushFlag?: boolean) { - if (typeof name === "string") { - super(name, RType.NSEC, ttl || NSECRecord.RR_DEFAULT_TTL_SHORT, flushFlag); + if (typeof name === 'string') { + super(name, RType.NSEC, ttl || NSECRecord.RR_DEFAULT_TTL_SHORT, flushFlag) } else { - assert(name.type === RType.NSEC); - super(name); + assert(name.type === RType.NSEC) + super(name) } - if (!nextDomainName.endsWith(".")) { - nextDomainName += "."; + if (!nextDomainName.endsWith('.')) { + nextDomainName += '.' } - this.nextDomainName = nextDomainName; - this.rrTypeWindows = NSECRecord.rrTypesToWindowMap(rrtypes); + this.nextDomainName = nextDomainName + this.rrTypeWindows = NSECRecord.rrTypesToWindowMap(rrtypes) } public getLowerCasedNextDomainName(): string { - return this.lowerCasedNextDomainName || (this.lowerCasedNextDomainName = dnsLowerCase(this.nextDomainName)); + return this.lowerCasedNextDomainName || (this.lowerCasedNextDomainName = dnsLowerCase(this.nextDomainName)) } private getRRTypesBitMapEncodingLength(): number { - let rrTypesBitMapLength = 0; + let rrTypesBitMapLength = 0 for (const window of this.rrTypeWindows) { - assert(window.rrtypes.length > 0, "types array for windowId " + window.windowId + " cannot be empty!"); + assert(window.rrtypes.length > 0, `types array for windowId ${window.windowId} cannot be empty!`) rrTypesBitMapLength += 2 // 1 byte for windowId; 1 byte for bitmap length - + window.bitMapSize; + + window.bitMapSize } - return rrTypesBitMapLength; + return rrTypesBitMapLength } protected getRDataEncodingLength(coder: DNSLabelCoder): number { @@ -57,44 +62,44 @@ export class NSECRecord extends ResourceRecord { return (coder.legacyUnicastEncoding ? coder.getUncompressedNameLength(this.nextDomainName) : coder.getUncompressedNameLength(this.nextDomainName)) // we skip compression for NSEC records for now, as Ubiquiti mdns forward can't handle that - + this.getRRTypesBitMapEncodingLength(); + + this.getRRTypesBitMapEncodingLength() } protected encodeRData(coder: DNSLabelCoder, buffer: Buffer, offset: number): number { - const oldOffset = offset; + const oldOffset = offset const length = coder.legacyUnicastEncoding ? coder.encodeUncompressedName(this.nextDomainName, offset) - : coder.encodeUncompressedName(this.nextDomainName, offset); // we skip compression for NSEC records for now, as Ubiquiti mdns forward can't handle that - offset += length; + : coder.encodeUncompressedName(this.nextDomainName, offset) // we skip compression for NSEC records for now, as Ubiquiti mdns forward can't handle that + offset += length // RFC 4034 4.1.2. type bit maps field has the following format ( Window Block # | Bitmap Length | Bitmap )+ (with | concatenation) // e.g. 0x00 0x01 0x40 => defines the window 0; bitmap length 1; and the bitmap 10000000, meaning the first bit is // set for the 0th window => rrTypes = [A]. The bitmap length depends on the rtype with the highest value for the // given value (max 32 bytes per bitmap) for (const window of this.rrTypeWindows) { - buffer.writeUInt8(window.windowId, offset++); - buffer.writeUInt8(window.bitMapSize, offset++); + buffer.writeUInt8(window.windowId, offset++) + buffer.writeUInt8(window.bitMapSize, offset++) - const bitmap = Buffer.alloc(window.bitMapSize); + const bitmap = Buffer.alloc(window.bitMapSize) for (const type of window.rrtypes) { - const byteNum = (type & 0xFF) >> 3; // basically floored division by 8 + const byteNum = (type & 0xFF) >> 3 // basically floored division by 8 - let mask = bitmap.readUInt8(byteNum); - mask |= 1 << (7 - (type & 0x7)); // OR with 1 shifted according to the lowest 3 bits + let mask = bitmap.readUInt8(byteNum) + mask |= 1 << (7 - (type & 0x7)) // OR with 1 shifted according to the lowest 3 bits - bitmap.writeUInt8(mask, byteNum); + bitmap.writeUInt8(mask, byteNum) } - bitmap.copy(buffer, offset); - offset += bitmap.length; + bitmap.copy(buffer, offset) + offset += bitmap.length } - return offset - oldOffset; + return offset - oldOffset } public static decodeData(coder: DNSLabelCoder, header: RecordRepresentation, buffer: Buffer, offset: number): DecodedData { - const oldOffset = offset; + const oldOffset = offset /** * Quick note to the line below. We base "false" as the second argument to decodeName, telling @@ -104,23 +109,25 @@ export class NSECRecord extends ResourceRecord { * Those pointers simply point to random points in the record data, resulting in decoding to fail. * As the field doesn't have any meaning, and we simply don't use it, we just skip decoding for now. */ - const decodedNextDomainName = coder.decodeName(offset, false); - offset += decodedNextDomainName.readBytes; + const decodedNextDomainName = coder.decodeName(offset, false) + offset += decodedNextDomainName.readBytes - const rrTypes: RType[] = []; + const rrTypes: RType[] = [] while (offset < buffer.length) { - const windowId = buffer.readUInt8(offset++); - const bitMapLength = buffer.readUInt8(offset++); + const windowId = buffer.readUInt8(offset++) + const bitMapLength = buffer.readUInt8(offset++) - const upperRType = windowId << 8; + const upperRType = windowId << 8 for (let block = 0; block < bitMapLength; block++) { - const byte = buffer.readUInt8(offset++); + let byte = buffer.readUInt8(offset++) for (let bit = 0; bit < 8; bit++) { // iterate over every bit - if (byte & (1 << (7 - bit))) { // check if bit is set - const rType = upperRType | (block << 3) | bit; // OR upperWindowNum | basically block * 8 | bit number - rrTypes.push(rType); + const bitValue = 2 ** (7 - bit) + if (byte >= bitValue) { // check if bit is set + byte -= bitValue + const rType = upperRType + (block * 8) + bit // calculate rType using arithmetic operations + rrTypes.push(rType) } } } @@ -129,67 +136,66 @@ export class NSECRecord extends ResourceRecord { return { data: new NSECRecord(header, decodedNextDomainName.data, rrTypes), readBytes: offset - oldOffset, - }; + } } public clone(): NSECRecord { - return new NSECRecord(this.getRecordRepresentation(), this.nextDomainName, NSECRecord.windowsToRRTypes(this.rrTypeWindows)); + return new NSECRecord(this.getRecordRepresentation(), this.nextDomainName, NSECRecord.windowsToRRTypes(this.rrTypeWindows)) } public dataAsString(): string { - return `${this.nextDomainName} [${NSECRecord.windowsToRRTypes(this.rrTypeWindows).map(rtype => ""+rtype).join(",")}]`; + return `${this.nextDomainName} [${NSECRecord.windowsToRRTypes(this.rrTypeWindows).map(rtype => `${rtype}`).join(',')}]` } public dataEquals(record: NSECRecord): boolean { - return this.getLowerCasedNextDomainName() === record.getLowerCasedNextDomainName() && deepEqual(this.rrTypeWindows, record.rrTypeWindows); + return this.getLowerCasedNextDomainName() === record.getLowerCasedNextDomainName() && deepEqual(this.rrTypeWindows, record.rrTypeWindows) } private static rrTypesToWindowMap(rrtypes: RType[]): RRTypeWindow[] { - const rrTypeWindows: RRTypeWindow[] = []; + const rrTypeWindows: RRTypeWindow[] = [] for (const rrtype of rrtypes) { - const windowId = rrtype >> 8; + const windowId = rrtype >> 8 - let window: RRTypeWindow | undefined = undefined; + let window: RRTypeWindow | undefined for (const window0 of rrTypeWindows) { if (window0.windowId === windowId) { - window = window0; - break; + window = window0 + break } } if (!window) { window = { - windowId: windowId, + windowId, bitMapSize: Math.ceil((rrtype & 0xFF) / 8), rrtypes: [rrtype], - }; - rrTypeWindows.push(window); + } + rrTypeWindows.push(window) } else { - window.rrtypes.push(rrtype); + window.rrtypes.push(rrtype) - const bitMapSize = Math.ceil((rrtype & 0xFF) / 8); + const bitMapSize = Math.ceil((rrtype & 0xFF) / 8) if (bitMapSize > window.bitMapSize) { - window.bitMapSize = bitMapSize; + window.bitMapSize = bitMapSize } } } // sort by windowId - rrTypeWindows.sort((a, b) => a.windowId - b.windowId); - rrTypeWindows.forEach(window => window.rrtypes.sort((a, b) => a - b)); + rrTypeWindows.sort((a, b) => a.windowId - b.windowId) + rrTypeWindows.forEach(window => window.rrtypes.sort((a, b) => a - b)) - return rrTypeWindows; + return rrTypeWindows } private static windowsToRRTypes(windows: RRTypeWindow[]): RType[] { - const rrtypes: RType[] = []; + const rrtypes: RType[] = [] for (const window of windows) { - rrtypes.push(...window.rrtypes); + rrtypes.push(...window.rrtypes) } - return rrtypes; + return rrtypes } - } diff --git a/src/coder/records/OPTRecord.ts b/src/coder/records/OPTRecord.ts index e24dff1..95c4dd0 100644 --- a/src/coder/records/OPTRecord.ts +++ b/src/coder/records/OPTRecord.ts @@ -1,168 +1,172 @@ -import assert from "assert"; -import deepEquals from "fast-deep-equal"; -import { DNSLabelCoder } from "../DNSLabelCoder"; -import { DecodedData, RType } from "../DNSPacket"; -import { RecordRepresentation, ResourceRecord } from "../ResourceRecord"; +import type { Buffer } from 'node:buffer' + +import type { DNSLabelCoder } from '../DNSLabelCoder' +import type { DecodedData } from '../DNSPacket' +import type { RecordRepresentation } from '../ResourceRecord' + +import assert from 'node:assert' + +import deepEquals from 'fast-deep-equal' + +import { RType } from '../DNSPacket.js' +import { ResourceRecord } from '../ResourceRecord.js' export interface OPTOption { - code: number, - data: Buffer, + code: number + data: Buffer } export interface OPTFlags { - dnsSecOK?: boolean, - zero?: number, + dnsSecOK?: boolean + zero?: number } export class OPTRecord extends ResourceRecord { + private static readonly EDNS_VERSION = 0 + private static readonly DNS_SEC_OK_MASK = 0x8000 // 2 bytes, first bit set + private static readonly NOT_DNS_SEC_OK_MASK = 0x7FFF - private static readonly EDNS_VERSION = 0; - private static readonly DNS_SEC_OK_MASK = 0x8000; // 2 bytes, first bit set - private static readonly NOT_DNS_SEC_OK_MASK = 0x7FFF; - - readonly udpPayloadSize: number; - readonly extendedRCode: number; - readonly ednsVersion: number; - readonly flags: OPTFlags; - readonly options: OPTOption[]; + readonly udpPayloadSize: number + readonly extendedRCode: number + readonly ednsVersion: number + readonly flags: OPTFlags + readonly options: OPTOption[] - constructor(udpPayloadSize: number, options?: OPTOption[], extendedRCode?: number, flags?: OPTFlags, ednsVersion?: number, ttl?: number); + constructor(udpPayloadSize: number, options?: OPTOption[], extendedRCode?: number, flags?: OPTFlags, ednsVersion?: number, ttl?: number) constructor(header: RecordRepresentation, options?: OPTOption[], extendedRCode?: number, flags?: OPTFlags, ednsVersion?: number, ttl?: number) constructor(udpPayloadSize: number | RecordRepresentation, options?: OPTOption[], extendedRCode?: number, flags?: OPTFlags, ednsVersion?: number, ttl?: number) { - if (typeof udpPayloadSize === "number") { - super(".", RType.OPT, ttl, false, udpPayloadSize); - this.udpPayloadSize = udpPayloadSize; + if (typeof udpPayloadSize === 'number') { + super('.', RType.OPT, ttl, false, udpPayloadSize) + this.udpPayloadSize = udpPayloadSize } else { - assert(udpPayloadSize.type === RType.OPT); - super(udpPayloadSize); - this.udpPayloadSize = udpPayloadSize.class; + assert(udpPayloadSize.type === RType.OPT) + super(udpPayloadSize) + this.udpPayloadSize = udpPayloadSize.class } - this.extendedRCode = extendedRCode || 0; - this.ednsVersion = ednsVersion || OPTRecord.EDNS_VERSION; + this.extendedRCode = extendedRCode || 0 + this.ednsVersion = ednsVersion || OPTRecord.EDNS_VERSION this.flags = { dnsSecOK: flags?.dnsSecOK || false, zero: flags?.zero || 0, ...flags, - }; - this.options = options || []; + } + this.options = options || [] } protected getRDataEncodingLength(): number { - let length = 0; + let length = 0 for (const option of this.options) { - length += 2 + 2 + option.data.length; // 2 byte code; 2 byte length prefix; binary data + length += 2 + 2 + option.data.length // 2 byte code; 2 byte length prefix; binary data } - return length; + return length } protected encodeRData(coder: DNSLabelCoder, buffer: Buffer, offset: number): number { - const oldOffset = offset; + const oldOffset = offset - const classOffset = offset - 8; - const ttlOffset = offset - 6; + const classOffset = offset - 8 + const ttlOffset = offset - 6 // just to be sure - buffer.writeUInt16BE(this.udpPayloadSize, classOffset); + buffer.writeUInt16BE(this.udpPayloadSize, classOffset) - buffer.writeUInt8(this.extendedRCode, ttlOffset); - buffer.writeUInt8(this.ednsVersion, ttlOffset + 1); + buffer.writeUInt8(this.extendedRCode, ttlOffset) + buffer.writeUInt8(this.ednsVersion, ttlOffset + 1) - let flags = this.flags.zero || 0; + let flags = this.flags.zero || 0 if (this.flags.dnsSecOK) { - flags |= OPTRecord.DNS_SEC_OK_MASK; + flags |= OPTRecord.DNS_SEC_OK_MASK } - buffer.writeUInt16BE(flags, ttlOffset + 2); - + buffer.writeUInt16BE(flags, ttlOffset + 2) for (const option of this.options) { - buffer.writeUInt16BE(option.code, offset); - offset += 2; + buffer.writeUInt16BE(option.code, offset) + offset += 2 - buffer.writeUInt16BE(option.data.length, offset); - offset += 2; + buffer.writeUInt16BE(option.data.length, offset) + offset += 2 - option.data.copy(buffer, offset); - offset += option.data.length; + option.data.copy(buffer, offset) + offset += option.data.length } - return offset - oldOffset; // written bytes + return offset - oldOffset // written bytes } public static decodeData(coder: DNSLabelCoder, header: RecordRepresentation, buffer: Buffer, offset: number): DecodedData { - const oldOffset = offset; + const oldOffset = offset - const classOffset = offset - 8; - const ttlOffset = offset - 6; + const classOffset = offset - 8 + const ttlOffset = offset - 6 - const udpPayloadSize = buffer.readUInt16BE(classOffset); - const extendedRCode = buffer.readUInt8(ttlOffset); - const ednsVersion = buffer.readUInt8(ttlOffset + 1); + const udpPayloadSize = buffer.readUInt16BE(classOffset) + const extendedRCode = buffer.readUInt8(ttlOffset) + const ednsVersion = buffer.readUInt8(ttlOffset + 1) - const flagsField = buffer.readUInt16BE(ttlOffset + 2); + const flagsField = buffer.readUInt16BE(ttlOffset + 2) const flags: OPTFlags = { dnsSecOK: !!(flagsField & OPTRecord.DNS_SEC_OK_MASK), zero: flagsField & OPTRecord.NOT_DNS_SEC_OK_MASK, - }; + } - const options: OPTOption[] = []; + const options: OPTOption[] = [] while (offset < buffer.length) { - const code = buffer.readUInt16BE(offset); - offset += 2; + const code = buffer.readUInt16BE(offset) + offset += 2 - const length = buffer.readUInt16BE(offset); - offset += 2; + const length = buffer.readUInt16BE(offset) + offset += 2 - const data = buffer.subarray(offset, offset + length); - offset += length; + const data = buffer.subarray(offset, offset + length) + offset += length options.push({ - code: code, - data: data, - }); + code, + data, + }) } - header.class = udpPayloadSize; - header.ttl = 4500; // default + header.class = udpPayloadSize + header.ttl = 4500 // default return { data: new OPTRecord(header, options, extendedRCode, flags, ednsVersion), readBytes: offset - oldOffset, - }; + } } public clone(): ResourceRecord { - return new OPTRecord(this.getRecordRepresentation(), this.options, this.extendedRCode, this.flags, this.ednsVersion); + return new OPTRecord(this.getRecordRepresentation(), this.options, this.extendedRCode, this.flags, this.ednsVersion) } public dataAsString(): string { return `${this.udpPayloadSize} ${this.extendedRCode} ${this.ednsVersion} ${JSON.stringify(this.flags)} [${this.options - .map(opt => `${opt.code} ${opt.data.toString("base64")}`).join(",")}]`; + .map(opt => `${opt.code} ${opt.data.toString('base64')}`).join(',')}]` } public dataEquals(record: OPTRecord): boolean { return this.udpPayloadSize === record.udpPayloadSize && this.extendedRCode === record.extendedRCode && this.ednsVersion === record.ednsVersion - && OPTRecord.optionsEquality(this.options, record.options) && deepEquals(this.flags, record.flags); + && OPTRecord.optionsEquality(this.options, record.options) && deepEquals(this.flags, record.flags) } private static optionsEquality(a: OPTOption[], b: OPTOption[]): boolean { // deepEquals on buffers doesn't really work if (a.length !== b.length) { - return false; + return false } for (let i = 0; i < a.length; i++) { if (a[i].code !== b[i].code) { - return false; - } else if (a[i].data.toString("hex") !== b[i].data.toString("hex")) { - return false; + return false + } else if (a[i].data.toString('hex') !== b[i].data.toString('hex')) { + return false } } - return true; + return true } - } diff --git a/src/coder/records/PTRRecord.ts b/src/coder/records/PTRRecord.ts index 3652dbe..1c7dc55 100644 --- a/src/coder/records/PTRRecord.ts +++ b/src/coder/records/PTRRecord.ts @@ -1,72 +1,76 @@ -import assert from "assert"; -import { dnsLowerCase } from "../../util/dns-equal"; -import { DNSLabelCoder } from "../DNSLabelCoder"; -import { DecodedData, RType } from "../DNSPacket"; -import { RecordRepresentation, ResourceRecord } from "../ResourceRecord"; +import type { Buffer } from 'node:buffer' -export class PTRRecord extends ResourceRecord { +import type { DNSLabelCoder } from '../DNSLabelCoder' +import type { DecodedData } from '../DNSPacket' +import type { RecordRepresentation } from '../ResourceRecord' + +import assert from 'node:assert' + +import { dnsLowerCase } from '../../util/dns-equal.js' +import { RType } from '../DNSPacket.js' +import { ResourceRecord } from '../ResourceRecord.js' - public static readonly DEFAULT_TTL = ResourceRecord.RR_DEFAULT_TTL; +export class PTRRecord extends ResourceRecord { + public static readonly DEFAULT_TTL = ResourceRecord.RR_DEFAULT_TTL - readonly ptrName: string; - private lowerCasedPtrName?: string; + readonly ptrName: string + private lowerCasedPtrName?: string - constructor(name: string, ptrName: string, flushFlag?: boolean, ttl?: number); - constructor(header: RecordRepresentation, ptrName: string); + constructor(name: string, ptrName: string, flushFlag?: boolean, ttl?: number) + constructor(header: RecordRepresentation, ptrName: string) constructor(name: string | RecordRepresentation, ptrName: string, flushFlag?: boolean, ttl?: number) { - if (typeof name === "string") { - super(name, RType.PTR, ttl, flushFlag); + if (typeof name === 'string') { + super(name, RType.PTR, ttl, flushFlag) } else { - assert(name.type === RType.PTR); - super(name); + assert(name.type === RType.PTR) + super(name) } - if (!ptrName.endsWith(".")) { - ptrName += "."; + if (!ptrName.endsWith('.')) { + ptrName += '.' } - this.ptrName = ptrName; + this.ptrName = ptrName } public getLowerCasedPTRName(): string { - return this.lowerCasedPtrName || (this.lowerCasedPtrName = dnsLowerCase(this.ptrName)); + return this.lowerCasedPtrName || (this.lowerCasedPtrName = dnsLowerCase(this.ptrName)) } protected getRDataEncodingLength(coder: DNSLabelCoder): number { - return coder.getNameLength(this.ptrName); + return coder.getNameLength(this.ptrName) } protected encodeRData(coder: DNSLabelCoder, buffer: Buffer, offset: number): number { - const oldOffset = offset; + const oldOffset = offset - const ptrNameLength = coder.encodeName(this.ptrName, offset); - offset += ptrNameLength; + const ptrNameLength = coder.encodeName(this.ptrName, offset) + offset += ptrNameLength - return offset - oldOffset; // written bytes + return offset - oldOffset // written bytes } public static decodeData(coder: DNSLabelCoder, header: RecordRepresentation, buffer: Buffer, offset: number): DecodedData { - const oldOffset = offset; + const oldOffset = offset - const decodedName = coder.decodeName(offset); - offset += decodedName.readBytes; + const decodedName = coder.decodeName(offset) + offset += decodedName.readBytes return { data: new PTRRecord(header, decodedName.data), readBytes: offset - oldOffset, - }; + } } public clone(): PTRRecord { - return new PTRRecord(this.getRecordRepresentation(), this.ptrName); + return new PTRRecord(this.getRecordRepresentation(), this.ptrName) } public dataAsString(): string { - return this.ptrName; + return this.ptrName } public dataEquals(record: PTRRecord): boolean { - return this.getLowerCasedPTRName() === record.getLowerCasedPTRName(); + return this.getLowerCasedPTRName() === record.getLowerCasedPTRName() } - } diff --git a/src/coder/records/SRVRecord.ts b/src/coder/records/SRVRecord.ts index 96670dc..aceb467 100644 --- a/src/coder/records/SRVRecord.ts +++ b/src/coder/records/SRVRecord.ts @@ -1,43 +1,48 @@ -import assert from "assert"; -import { dnsLowerCase } from "../../util/dns-equal"; -import { DNSLabelCoder } from "../DNSLabelCoder"; -import { DecodedData, RType } from "../DNSPacket"; -import { RecordRepresentation, ResourceRecord } from "../ResourceRecord"; +import type { Buffer } from 'node:buffer' -export class SRVRecord extends ResourceRecord { +import type { DNSLabelCoder } from '../DNSLabelCoder' +import type { DecodedData } from '../DNSPacket' +import type { RecordRepresentation } from '../ResourceRecord' + +import assert from 'node:assert' + +import { dnsLowerCase } from '../../util/dns-equal.js' +import { RType } from '../DNSPacket.js' +import { ResourceRecord } from '../ResourceRecord.js' - public static readonly DEFAULT_TTL = 120; +export class SRVRecord extends ResourceRecord { + public static readonly DEFAULT_TTL = 120 - readonly hostname: string; - private lowerCasedHostname?: string; - readonly port: number; - private readonly priority: number; - private readonly weight: number; + readonly hostname: string + private lowerCasedHostname?: string + readonly port: number + private readonly priority: number + private readonly weight: number - constructor(name: string, hostname: string, port: number, flushFlag?: boolean, ttl?: number); + constructor(name: string, hostname: string, port: number, flushFlag?: boolean, ttl?: number) constructor(header: RecordRepresentation, hostname: string, port: number) constructor(name: string | RecordRepresentation, hostname: string, port: number, flushFlag?: boolean, ttl?: number) { - if (typeof name === "string") { - super(name, RType.SRV, ttl || SRVRecord.RR_DEFAULT_TTL_SHORT, flushFlag); + if (typeof name === 'string') { + super(name, RType.SRV, ttl || SRVRecord.RR_DEFAULT_TTL_SHORT, flushFlag) } else { - assert(name.type === RType.SRV); - super(name); + assert(name.type === RType.SRV) + super(name) } - if (!hostname.endsWith(".")) { - this.hostname = hostname + "."; + if (!hostname.endsWith('.')) { + this.hostname = `${hostname}.` } else { - this.hostname = hostname; + this.hostname = hostname } - this.port = port; + this.port = port // priority and weight are not supported to encode or read - this.priority = 0; - this.weight = 0; + this.priority = 0 + this.weight = 0 } public getLowerCasedHostname(): string { - return this.lowerCasedHostname || (this.lowerCasedHostname = dnsLowerCase(this.hostname)); + return this.lowerCasedHostname || (this.lowerCasedHostname = dnsLowerCase(this.hostname)) } protected getRDataEncodingLength(coder: DNSLabelCoder): number { @@ -45,60 +50,59 @@ export class SRVRecord extends ResourceRecord { // as of RFC 2782 name compression MUST NOT be used for the hostname, though RFC 6762 18.14 specifies it should + (coder.legacyUnicastEncoding ? coder.getUncompressedNameLength(this.hostname) - : coder.getNameLength(this.hostname)); + : coder.getNameLength(this.hostname)) } protected encodeRData(coder: DNSLabelCoder, buffer: Buffer, offset: number): number { - const oldOffset = offset; + const oldOffset = offset - buffer.writeUInt16BE(this.priority, offset); - offset += 2; + buffer.writeUInt16BE(this.priority, offset) + offset += 2 - buffer.writeUInt16BE(this.weight, offset); - offset += 2; + buffer.writeUInt16BE(this.weight, offset) + offset += 2 - buffer.writeUInt16BE(this.port, offset); - offset += 2; + buffer.writeUInt16BE(this.port, offset) + offset += 2 const hostnameLength = coder.legacyUnicastEncoding ? coder.encodeUncompressedName(this.hostname, offset) - : coder.encodeName(this.hostname, offset); - offset += hostnameLength; + : coder.encodeName(this.hostname, offset) + offset += hostnameLength - return offset - oldOffset; // written bytes + return offset - oldOffset // written bytes } public static decodeData(coder: DNSLabelCoder, header: RecordRepresentation, buffer: Buffer, offset: number): DecodedData { - const oldOffset = offset; + const oldOffset = offset - //const priority = buffer.readUInt16BE(offset); - offset += 2; + // const priority = buffer.readUInt16BE(offset); + offset += 2 - //const weight = buffer.readUInt16BE(offset); - offset += 2; + // const weight = buffer.readUInt16BE(offset); + offset += 2 - const port = buffer.readUInt16BE(offset); - offset += 2; + const port = buffer.readUInt16BE(offset) + offset += 2 - const decodedHostname = coder.decodeName(offset); - offset += decodedHostname.readBytes; + const decodedHostname = coder.decodeName(offset) + offset += decodedHostname.readBytes return { data: new SRVRecord(header, decodedHostname.data, port), readBytes: offset - oldOffset, - }; + } } public clone(): SRVRecord { - return new SRVRecord(this.getRecordRepresentation(), this.hostname, this.port); + return new SRVRecord(this.getRecordRepresentation(), this.hostname, this.port) } public dataAsString(): string { - return `${this.hostname} ${this.port} ${this.priority} ${this.weight}`; + return `${this.hostname} ${this.port} ${this.priority} ${this.weight}` } public dataEquals(record: SRVRecord): boolean { - return this.getLowerCasedHostname() === record.getLowerCasedHostname() && this.port === record.port && this.weight === record.weight && this.priority === record.priority; + return this.getLowerCasedHostname() === record.getLowerCasedHostname() && this.port === record.port && this.weight === record.weight && this.priority === record.priority } - } diff --git a/src/coder/records/TXTRecord.ts b/src/coder/records/TXTRecord.ts index fd5d7ab..54fe6fc 100644 --- a/src/coder/records/TXTRecord.ts +++ b/src/coder/records/TXTRecord.ts @@ -1,89 +1,92 @@ -import assert from "assert"; -import { DNSLabelCoder } from "../DNSLabelCoder"; -import { DecodedData, RType } from "../DNSPacket"; -import { RecordRepresentation, ResourceRecord } from "../ResourceRecord"; +import type { DNSLabelCoder } from '../DNSLabelCoder' +import type { DecodedData } from '../DNSPacket' +import type { RecordRepresentation } from '../ResourceRecord' -export class TXTRecord extends ResourceRecord { +import assert from 'node:assert' +import { Buffer } from 'node:buffer' + +import { RType } from '../DNSPacket.js' +import { ResourceRecord } from '../ResourceRecord.js' - public static readonly DEFAULT_TTL = ResourceRecord.RR_DEFAULT_TTL; +export class TXTRecord extends ResourceRecord { + public static readonly DEFAULT_TTL = ResourceRecord.RR_DEFAULT_TTL - readonly txt: Buffer[]; + readonly txt: Buffer[] - constructor(name: string, txt: Buffer[], flushFlag?: boolean, ttl?: number); - constructor(header: RecordRepresentation, txt: Buffer[]); + constructor(name: string, txt: Buffer[], flushFlag?: boolean, ttl?: number) + constructor(header: RecordRepresentation, txt: Buffer[]) constructor(name: string | RecordRepresentation, txt: Buffer[], flushFlag?: boolean, ttl?: number) { - if (typeof name === "string") { - super(name, RType.TXT, ttl, flushFlag); + if (typeof name === 'string') { + super(name, RType.TXT, ttl, flushFlag) } else { - assert(name.type === RType.TXT); - super(name); + assert(name.type === RType.TXT) + super(name) } - this.txt = txt.length === 0 ? [Buffer.from([])] : txt; + this.txt = txt.length === 0 ? [Buffer.from([])] : txt } protected getRDataEncodingLength(): number { - let length = 0; + let length = 0 for (const buffer of this.txt) { - length += 1 + buffer.length; - assert(buffer.length <= 255, "One txt character-string can only have a length of 255 chars"); + length += 1 + buffer.length + assert(buffer.length <= 255, 'One txt character-string can only have a length of 255 chars') } - return length; + return length } protected encodeRData(coder: DNSLabelCoder, buffer: Buffer, offset: number): number { - const oldOffset = offset; + const oldOffset = offset for (const txt of this.txt) { - buffer.writeUInt8(txt.length, offset++); - txt.copy(buffer, offset); - offset += txt.length; + buffer.writeUInt8(txt.length, offset++) + txt.copy(buffer, offset) + offset += txt.length } - return offset - oldOffset; // written bytes + return offset - oldOffset // written bytes } public clone(): TXTRecord { - return new TXTRecord(this.getRecordRepresentation(), this.txt); + return new TXTRecord(this.getRecordRepresentation(), this.txt) } public dataAsString(): string { - return `[${this.txt.map(line => `${line.toString("base64")}`).join(",")}]`; + return `[${this.txt.map(line => `${line.toString('base64')}`).join(',')}]` } public dataEquals(record: TXTRecord): boolean { // deepEquals on buffers doesn't really work if (this.txt.length !== record.txt.length) { - return false; + return false } for (let i = 0; i < this.txt.length; i++) { - if (this.txt[i].toString("hex") !== record.txt[i].toString("hex")) { - return false; + if (this.txt[i].toString('hex') !== record.txt[i].toString('hex')) { + return false } } - return true; + return true } public static decodeData(coder: DNSLabelCoder, header: RecordRepresentation, buffer: Buffer, offset: number): DecodedData { - const oldOffset = offset; + const oldOffset = offset - const txtData: Buffer[] = []; + const txtData: Buffer[] = [] while (offset < buffer.length) { - const length = buffer.readUInt8(offset++); + const length = buffer.readUInt8(offset++) - txtData.push(buffer.subarray(offset, offset + length)); - offset += length; + txtData.push(buffer.subarray(offset, offset + length)) + offset += length } return { data: new TXTRecord(header, txtData), readBytes: offset - oldOffset, - }; + } } - } diff --git a/src/coder/records/index.ts b/src/coder/records/index.ts index aee9a37..2ff160f 100644 --- a/src/coder/records/index.ts +++ b/src/coder/records/index.ts @@ -1,19 +1,19 @@ -import { RType } from "../DNSPacket"; -import { ResourceRecord } from "../ResourceRecord"; -import { AAAARecord } from "./AAAARecord"; -import { ARecord } from "./ARecord"; -import { CNAMERecord } from "./CNAMERecord"; -import { NSECRecord } from "./NSECRecord"; -import { OPTRecord } from "./OPTRecord"; -import { PTRRecord } from "./PTRRecord"; -import { SRVRecord } from "./SRVRecord"; -import { TXTRecord } from "./TXTRecord"; +import { RType } from '../DNSPacket.js' +import { ResourceRecord } from '../ResourceRecord.js' +import { AAAARecord } from './AAAARecord.js' +import { ARecord } from './ARecord.js' +import { CNAMERecord } from './CNAMERecord.js' +import { NSECRecord } from './NSECRecord.js' +import { OPTRecord } from './OPTRecord.js' +import { PTRRecord } from './PTRRecord.js' +import { SRVRecord } from './SRVRecord.js' +import { TXTRecord } from './TXTRecord.js' -ResourceRecord.typeToRecordDecoder.set(RType.AAAA, AAAARecord.decodeData); -ResourceRecord.typeToRecordDecoder.set(RType.A, ARecord.decodeData); -ResourceRecord.typeToRecordDecoder.set(RType.CNAME, CNAMERecord.decodeData); -ResourceRecord.typeToRecordDecoder.set(RType.NSEC, NSECRecord.decodeData); -ResourceRecord.typeToRecordDecoder.set(RType.PTR, PTRRecord.decodeData); -ResourceRecord.typeToRecordDecoder.set(RType.SRV, SRVRecord.decodeData); -ResourceRecord.typeToRecordDecoder.set(RType.OPT, OPTRecord.decodeData); -ResourceRecord.typeToRecordDecoder.set(RType.TXT, TXTRecord.decodeData); +ResourceRecord.typeToRecordDecoder.set(RType.AAAA, AAAARecord.decodeData) +ResourceRecord.typeToRecordDecoder.set(RType.A, ARecord.decodeData) +ResourceRecord.typeToRecordDecoder.set(RType.CNAME, CNAMERecord.decodeData) +ResourceRecord.typeToRecordDecoder.set(RType.NSEC, NSECRecord.decodeData) +ResourceRecord.typeToRecordDecoder.set(RType.PTR, PTRRecord.decodeData) +ResourceRecord.typeToRecordDecoder.set(RType.SRV, SRVRecord.decodeData) +ResourceRecord.typeToRecordDecoder.set(RType.OPT, OPTRecord.decodeData) +ResourceRecord.typeToRecordDecoder.set(RType.TXT, TXTRecord.decodeData) diff --git a/src/coder/test-utils.ts b/src/coder/test-utils.ts index 2729888..ed3f76a 100644 --- a/src/coder/test-utils.ts +++ b/src/coder/test-utils.ts @@ -1,68 +1,72 @@ -import { AddressInfo } from "net"; -import { DNSLabelCoder } from "./DNSLabelCoder"; -import { DNSPacket } from "./DNSPacket"; -import { Question } from "./Question"; -import { ResourceRecord } from "./ResourceRecord"; +import type { AddressInfo } from 'node:net' + +import { Buffer } from 'node:buffer' + +import { expect } from 'vitest' + +import { DNSLabelCoder } from './DNSLabelCoder.js' +import { DNSPacket } from './DNSPacket.js' +import { Question } from './Question.js' +import { ResourceRecord } from './ResourceRecord.js' // Adjusted decodeContext to use the utility function for the address const decodeContext: AddressInfo = { - address: "0.0.0.0", - family: "ipv4", + address: '0.0.0.0', + family: 'ipv4', port: 5353, -}; +} export function runRecordEncodingTest(record: Question | ResourceRecord, legacyUnicast = false): void { - let coder = new DNSLabelCoder(legacyUnicast); - - const length = record.getEncodingLength(coder); - const buffer = Buffer.alloc(length); - coder.initBuf(buffer); + let coder = new DNSLabelCoder(legacyUnicast) - const written = record.encode(coder, buffer, 0); - expect(written).toBe(buffer.length); + const length = record.getEncodingLength(coder) + const buffer = Buffer.alloc(length) + coder.initBuf(buffer) - coder = new DNSLabelCoder(legacyUnicast); - coder.initBuf(buffer); + const written = record.encode(coder, buffer, 0) + expect(written).toBe(buffer.length) + coder = new DNSLabelCoder(legacyUnicast) + coder.initBuf(buffer) // test the decodeRecord method const decodedRecord = record instanceof Question ? Question.decode(decodeContext, coder, buffer, 0) - : ResourceRecord.decode(decodeContext, coder, buffer, 0); - expect(decodedRecord.readBytes).toBe(buffer.length); + : ResourceRecord.decode(decodeContext, coder, buffer, 0) + expect(decodedRecord.readBytes).toBe(buffer.length) - const record2 = decodedRecord.data!; - expect(record2).toBeDefined(); + const record2 = decodedRecord.data! + expect(record2).toBeDefined() - coder = new DNSLabelCoder(legacyUnicast); + coder = new DNSLabelCoder(legacyUnicast) - const length2 = record2.getEncodingLength(coder); - const buffer2 = Buffer.allocUnsafe(length2); - coder.initBuf(buffer2); + const length2 = record2.getEncodingLength(coder) + const buffer2 = Buffer.allocUnsafe(length2) + coder.initBuf(buffer2) - const written2 = record2.encode(coder, buffer2, 0); - expect(written2).toBe(buffer2.length); + const written2 = record2.encode(coder, buffer2, 0) + expect(written2).toBe(buffer2.length) - expect(buffer2).toEqual(buffer); - expect(record2).toEqual(record); + expect(buffer2).toEqual(buffer) + expect(record2).toEqual(record) if (record2 instanceof ResourceRecord && record instanceof ResourceRecord) { // test the equals method - expect(record2.aboutEqual(record)).toBe(true); + expect(record2.aboutEqual(record)).toBe(true) // test the clone method - const clone = record.clone(); - expect(clone.aboutEqual(record2)).toBe(true); - expect(clone).toEqual(record2); + const clone = record.clone() + expect(clone.aboutEqual(record2)).toBe(true) + expect(clone).toEqual(record2) } } export function runPacketEncodingTest(packet: DNSPacket): void { - const buffer = packet.encode(); - const decodedPacket = DNSPacket.decode(decodeContext, buffer); + const buffer = packet.encode() + const decodedPacket = DNSPacket.decode(decodeContext, buffer) - const buffer2 = decodedPacket.encode(); + const buffer2 = decodedPacket.encode() - expect(buffer).toEqual(buffer2); - expect(decodedPacket).toEqual(packet); + expect(buffer).toEqual(buffer2) + expect(decodedPacket).toEqual(packet) } diff --git a/src/index.ts b/src/index.ts index 1238df0..661d236 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,31 +1,38 @@ -import "source-map-support/register"; // registering node-source-map-support for typescript stack traces -import createDebug from "debug"; - -// eslint-disable-next-line @typescript-eslint/no-require-imports -const version: string = require("../package.json").version; -if (version.includes("beta") || process.env.BCT) { // enable debug output if beta version or running bonjour conformance testing - const debug = process.env.DEBUG; - if (!debug || !debug.includes("ciao")) { +import 'source-map-support/register.js' // registering node-source-map-support for typescript stack traces +import type { ResponderOptions } from './Responder' + +import { createRequire } from 'node:module' +import process from 'node:process' + +import createDebug from 'debug' + +import './coder/records/index.js' +import { Responder } from './Responder.js' + +const require = createRequire(import.meta.url) + +const version: string = require('../package.json').version + +if (version.includes('beta') || process.env.BCT) { // enable debug output if beta version or running bonjour conformance testing + const debug = process.env.DEBUG + if (!debug || !debug.includes('ciao')) { if (!debug) { - createDebug.enable("ciao:*"); + createDebug.enable('ciao:*') } else { - createDebug.enable(debug + ",ciao:*"); + createDebug.enable(`${debug},ciao:*`) } } } -import "./coder/records/index"; -import { Responder, ResponderOptions } from "./Responder"; - -export * from "./CiaoService"; -export * from "./Responder"; -export { MDNSServerOptions } from "./MDNSServer"; +export * from './CiaoService.js' +export { MDNSServerOptions } from './MDNSServer.js' +export * from './Responder.js' function printInitInfo() { - const debug = createDebug("ciao:init"); - debug("Loading ciao v" + version + "..."); + const debug = createDebug('ciao:init') + debug(`Loading ciao v${version}...`) } -printInitInfo(); +printInitInfo() /** * Defines the transport protocol of a service. @@ -34,9 +41,10 @@ printInitInfo(); * For applications using any other transport protocol UDP must be used. * This applies to all other transport protocols like SCTP, DCCP, RTMFP, etc */ +// eslint-disable-next-line no-restricted-syntax export const enum Protocol { - TCP = "tcp", - UDP = "udp", + TCP = 'tcp', + UDP = 'udp', } /** @@ -49,9 +57,9 @@ export const enum Protocol { * @returns A Responder instance for the given options. Might be shared with others using the same options. */ export function getResponder(options?: ResponderOptions): Responder { - return Responder.getResponder(options); + return Responder.getResponder(options) } -export default { - getResponder: getResponder, -}; +export default { + getResponder, +} diff --git a/src/responder/Announcer.ts b/src/responder/Announcer.ts index 4118b7f..52e26ab 100644 --- a/src/responder/Announcer.ts +++ b/src/responder/Announcer.ts @@ -1,29 +1,30 @@ -import assert from "assert"; -import createDebug from "debug"; -import { CiaoService, ServiceState } from "../CiaoService"; -import { DNSPacket } from "../coder/DNSPacket"; -import { ResourceRecord } from "../coder/ResourceRecord"; -import { - MDNSServer, - SendResultFailedRatio, - SendResultFormatError, - SendTimeoutResult, - TimedSendResult, -} from "../MDNSServer"; -import { PromiseTimeout } from "../util/promise-utils"; -import Timeout = NodeJS.Timeout; - -const debug = createDebug("ciao:Announcer"); +/* global NodeJS */ +import type { CiaoService } from '../CiaoService' +import type { ResourceRecord } from '../coder/ResourceRecord' +import type { SendTimeoutResult, TimedSendResult } from '../MDNSServer' + +import assert from 'node:assert' + +import createDebug from 'debug' + +import { ServiceState } from '../CiaoService.js' +import { DNSPacket } from '../coder/DNSPacket.js' +import { MDNSServer, SendResultFailedRatio, sendResultFormatError } from '../MDNSServer.js' +import { PromiseTimeout } from '../util/promise-utils.js' + +import Timeout = NodeJS.Timeout + +const debug = createDebug('ciao:Announcer') export interface AnnouncerOptions { /** * Defines how often the announcement should be sent. */ - repetitions?: number; + repetitions?: number /** * If set to true, goodbye packets will be sent (ttl will be set to zero on all records) */ - goodbye?: boolean; + goodbye?: boolean } /** @@ -37,195 +38,195 @@ export interface AnnouncerOptions { * */ export class Announcer { + public static readonly CANCEL_REASON = 'CIAO ANNOUNCEMENT CANCELLED' - public static readonly CANCEL_REASON = "CIAO ANNOUNCEMENT CANCELLED"; - - private readonly server: MDNSServer; - private readonly service: CiaoService; + private readonly server: MDNSServer + private readonly service: CiaoService - private readonly repetitions: number = 1; - private readonly announceIntervalIncreaseFactor = 2; // RFC states a factor of AT LEAST two (could be higher as it seems) - private readonly goodbye: boolean = false; + private readonly repetitions: number = 1 + private readonly announceIntervalIncreaseFactor = 2 // RFC states a factor of AT LEAST two (could be higher as it seems) + private readonly goodbye: boolean = false - private timer?: Timeout; - private promise?: Promise; - private promiseResolve?: (value?: void | PromiseLike) => void; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private promiseReject?: (reason?: any) => void; + private timer?: Timeout + private promise?: Promise + private promiseResolve?: (value?: void | PromiseLike) => void + private promiseReject?: (reason?: any) => void - private sentAnnouncements = 0; - private sentLastAnnouncement = false; - private nextInterval = 1000; - public nextAnnouncementTime = 0; + private sentAnnouncements = 0 + private sentLastAnnouncement = false + private nextInterval = 1000 + public nextAnnouncementTime = 0 constructor(server: MDNSServer, service: CiaoService, options: AnnouncerOptions) { - assert(server, "server must be defined"); - assert(service, "service must be defined"); - this.server = server; - this.service = service; + assert(server, 'server must be defined') + assert(service, 'service must be defined') + this.server = server + this.service = service if (options) { if (options.repetitions !== undefined) { - this.repetitions = options.repetitions; + this.repetitions = options.repetitions } if (options.goodbye) { - this.goodbye = true; + this.goodbye = true } } - assert(this.repetitions > 0 && this.repetitions <= 8, "repetitions must in [1;8]"); + assert(this.repetitions > 0 && this.repetitions <= 8, 'repetitions must in [1;8]') } public announce(): Promise { - debug("[%s] Sending %s for service", this.service.getFQDN(), this.goodbye? "goodbye": "announcement"); + debug('[%s] Sending %s for service', this.service.getFQDN(), this.goodbye ? 'goodbye' : 'announcement') if (!this.goodbye) { // could happen that the txt record was updated while probing. // just to be sure to announce all the latest data, we will rebuild the services. - this.service.rebuildServiceRecords(); + this.service.rebuildServiceRecords() } return (this.promise = new Promise((resolve, reject) => { - this.promiseResolve = resolve; - this.promiseReject = reject; + this.promiseResolve = resolve + this.promiseReject = reject - this.timer = setTimeout(this.sendAnnouncement.bind(this), 0); - this.timer.unref(); + this.timer = setTimeout(this.sendAnnouncement.bind(this), 0) + this.timer.unref() - this.nextAnnouncementTime = new Date().getTime(); - })); + this.nextAnnouncementTime = new Date().getTime() + })) } public async cancel(): Promise { - debug("[%s] Canceling %s", this.service.getFQDN(), this.goodbye? "goodbye": "announcement"); + debug('[%s] Canceling %s', this.service.getFQDN(), this.goodbye ? 'goodbye' : 'announcement') if (this.timer) { - clearTimeout(this.timer); - this.timer = undefined; + clearTimeout(this.timer) + this.timer = undefined } - this.promiseReject!(Announcer.CANCEL_REASON); + this.promiseReject!(Announcer.CANCEL_REASON) // the promise handlers are not called instantly, thus we give the opportunity to wait for the // program originally doing the announcement to clean up - return this.awaitAnnouncement().catch(reason => { + return this.awaitAnnouncement().catch((reason) => { if (reason !== Announcer.CANCEL_REASON) { - return Promise.reject(reason); + return Promise.reject(reason) } - }); + }) } public hasSentLastAnnouncement(): boolean { - return this.sentLastAnnouncement; + return this.sentLastAnnouncement } public async awaitAnnouncement(): Promise { - await this.promise; + await this.promise } public isSendingGoodbye(): boolean { - return this.goodbye; + return this.goodbye } private sendAnnouncement() { // minimum required is to send two unsolicited responses, one second apart // we could announce up to 8 times in total (time between messages must increase by two every message) - debug("[%s] Sending %s number %d", this.service.getFQDN(), this.goodbye? "goodbye": "announcement", this.sentAnnouncements + 1); + debug('[%s] Sending %s number %d', this.service.getFQDN(), this.goodbye ? 'goodbye' : 'announcement', this.sentAnnouncements + 1) // we rebuild every time, const records = [ - this.service.ptrRecord(), ...this.service.subtypePtrRecords(), - this.service.srvRecord(), this.service.txtRecord(), + this.service.ptrRecord(), + ...this.service.subtypePtrRecords(), + this.service.srvRecord(), + this.service.txtRecord(), // A and AAAA records are added below when sending. Which records get added depends on the network the announcement happens for - ]; + ] if (this.goodbye) { for (const record of records) { - record.ttl = 0; // setting ttl to zero to indicate "goodbye" + record.ttl = 0 // setting ttl to zero to indicate "goodbye" } } else { - records.push(this.service.metaQueryPtrRecord()); + records.push(this.service.metaQueryPtrRecord()) } if (this.sentAnnouncements + 1 >= this.repetitions) { - this.sentLastAnnouncement = true; + this.sentLastAnnouncement = true } - Announcer.sendResponseAddingAddressRecords(this.server, this.service, records, this.goodbye).then(results => { - const failRatio = SendResultFailedRatio(results); + Announcer.sendResponseAddingAddressRecords(this.server, this.service, records, this.goodbye).then((results) => { + const failRatio = SendResultFailedRatio(results) if (failRatio === 1) { - console.error(SendResultFormatError(results, `[${this.service.getFQDN()}] Failed to send ${this.goodbye? "goodbye": "announcement"} requests`), true); - this.promiseReject!(new Error(`${this.goodbye? "Goodbye": "Announcement"} failed as of socket errors!`)); - return; // all failed => thus announcement failed + console.error(sendResultFormatError(results, `[${this.service.getFQDN()}] Failed to send ${this.goodbye ? 'goodbye' : 'announcement'} requests`), true) + this.promiseReject!(new Error(`${this.goodbye ? 'Goodbye' : 'Announcement'} failed as of socket errors!`)) + return // all failed => thus announcement failed } if (failRatio > 0) { // some queries on some interfaces failed, but not all. We log that but consider that to be a success // at this point we are not responsible for removing stale network interfaces or something - debug(SendResultFormatError(results, `Some of the ${this.goodbye? "goodbye": "announcement"} requests for '${this.service.getFQDN()}' encountered an error`)); + debug(sendResultFormatError(results, `Some of the ${this.goodbye ? 'goodbye' : 'announcement'} requests for '${this.service.getFQDN()}' encountered an error`)) // SEE no return here } if (this.service.serviceState !== ServiceState.ANNOUNCING) { - debug("[%s] Service is no longer in announcing state. Stopping. (Received %s)", this.service.getFQDN(), this.service.serviceState); - return; + debug('[%s] Service is no longer in announcing state. Stopping. (Received %s)', this.service.getFQDN(), this.service.serviceState) + return } - this.sentAnnouncements++; + this.sentAnnouncements++ if (this.sentAnnouncements >= this.repetitions) { - this.promiseResolve!(); + this.promiseResolve!() } else { - this.timer = setTimeout(this.sendAnnouncement.bind(this), this.nextInterval); - this.timer.unref(); + this.timer = setTimeout(this.sendAnnouncement.bind(this), this.nextInterval) + this.timer.unref() - this.nextAnnouncementTime = new Date().getTime() + this.nextInterval; - this.nextInterval *= this.announceIntervalIncreaseFactor; + this.nextAnnouncementTime = new Date().getTime() + this.nextInterval + this.nextInterval *= this.announceIntervalIncreaseFactor } - }); + }) } private static sendResponseAddingAddressRecords(server: MDNSServer, service: CiaoService, records: ResourceRecord[], goodbye: boolean): Promise { - const promises: Promise[] = []; + const promises: Promise[] = [] for (const name of server.getBoundInterfaceNames()) { if (!service.advertisesOnInterface(name)) { - continue; + continue } - const answer: ResourceRecord[] = records.concat([]); + const answer: ResourceRecord[] = records.concat([]) - const aRecord = service.aRecord(name); - const aaaaRecord = service.aaaaRecord(name); - const aaaaRoutableRecord = service.aaaaRoutableRecord(name); - const aaaaUniqueLocalRecord = service.aaaaUniqueLocalRecord(name); - //const reversMappings: PTRRecord[] = service.reverseAddressMappings(networkInterface); - const nsecRecord = service.addressNSECRecord(); - const serviceNsecRecord = service.serviceNSECRecord(); + const aRecord = service.aRecord(name) + const aaaaRecord = service.aaaaRecord(name) + const aaaaRoutableRecord = service.aaaaRoutableRecord(name) + const aaaaUniqueLocalRecord = service.aaaaUniqueLocalRecord(name) + // const reversMappings: PTRRecord[] = service.reverseAddressMappings(networkInterface); + const nsecRecord = service.addressNSECRecord() + const serviceNsecRecord = service.serviceNSECRecord() if (aRecord) { if (goodbye) { - aRecord.ttl = 0; + aRecord.ttl = 0 } - answer.push(aRecord); + answer.push(aRecord) } if (aaaaRecord) { if (goodbye) { - aaaaRecord.ttl = 0; + aaaaRecord.ttl = 0 } - answer.push(aaaaRecord); + answer.push(aaaaRecord) } if (aaaaRoutableRecord) { if (goodbye) { - aaaaRoutableRecord.ttl = 0; + aaaaRoutableRecord.ttl = 0 } - answer.push(aaaaRoutableRecord); + answer.push(aaaaRoutableRecord) } if (aaaaUniqueLocalRecord) { if (goodbye) { - aaaaUniqueLocalRecord.ttl = 0; + aaaaUniqueLocalRecord.ttl = 0 } - answer.push(aaaaUniqueLocalRecord); + answer.push(aaaaUniqueLocalRecord) } /* @@ -238,29 +239,28 @@ export class Announcer { */ if (goodbye) { - nsecRecord.ttl = 0; - serviceNsecRecord.ttl = 0; + nsecRecord.ttl = 0 + serviceNsecRecord.ttl = 0 } - const additionals: ResourceRecord[] = []; - additionals.push(nsecRecord, serviceNsecRecord); + const additionals: ResourceRecord[] = [] + additionals.push(nsecRecord, serviceNsecRecord) const packet = DNSPacket.createDNSResponsePacketsFromRRSet({ answers: answer, - additionals: additionals, - }); + additionals, + }) promises.push(Promise.race([ server.send(packet, name), PromiseTimeout(MDNSServer.SEND_TIMEOUT).then(() => { - status: "timeout", + status: 'timeout', interface: name, }), - ])); + ])) } - return Promise.all(promises); + return Promise.all(promises) } - } diff --git a/src/responder/Prober.ts b/src/responder/Prober.ts index 3e1a2af..f9a73a5 100644 --- a/src/responder/Prober.ts +++ b/src/responder/Prober.ts @@ -1,18 +1,26 @@ -import assert from "assert"; -import createDebug from "debug"; -import { CiaoService, ServiceState } from "../CiaoService"; -import { DNSPacket, QType } from "../coder/DNSPacket"; -import { Question } from "../coder/Question"; -import { ResourceRecord } from "../coder/ResourceRecord"; -import { EndpointInfo, MDNSServer, SendResultFailedRatio, SendResultFormatError } from "../MDNSServer"; -import { Responder } from "../Responder"; -import * as tiebreaking from "../util/tiebreaking"; -import { rrComparator, TiebreakingResult } from "../util/tiebreaking"; -import Timeout = NodeJS.Timeout; - -const PROBE_INTERVAL = 250; // 250ms as defined in RFC 6762 8.1. -const LIMITED_PROBE_INTERVAL = 1000; -const debug = createDebug("ciao:Prober"); +/* global NodeJS */ +import type { CiaoService } from '../CiaoService' +import type { DNSPacket } from '../coder/DNSPacket' +import type { ResourceRecord } from '../coder/ResourceRecord' +import type { EndpointInfo, MDNSServer } from '../MDNSServer' +import type { Responder } from '../Responder' + +import assert from 'node:assert' + +import createDebug from 'debug' + +import { ServiceState } from '../CiaoService.js' +import { QType } from '../coder/DNSPacket.js' +import { Question } from '../coder/Question.js' +import { SendResultFailedRatio, sendResultFormatError } from '../MDNSServer.js' +import * as tiebreaking from '../util/tiebreaking.js' +import { rrComparator, TiebreakingResult } from '../util/tiebreaking.js' + +import Timeout = NodeJS.Timeout + +const PROBE_INTERVAL = 250 // 250ms as defined in RFC 6762 8.1. +const LIMITED_PROBE_INTERVAL = 1000 +const debug = createDebug('ciao:Prober') /** * This class is used to execute the probing process for a given service as defined @@ -22,37 +30,35 @@ const debug = createDebug("ciao:Prober"); * for the same name are detected. */ export class Prober { + public static readonly CANCEL_REASON = 'CIAO PROBING CANCELLED' - public static readonly CANCEL_REASON = "CIAO PROBING CANCELLED"; - - private readonly responder: Responder; - private readonly server: MDNSServer; - private readonly service: CiaoService; + private readonly responder: Responder + private readonly server: MDNSServer + private readonly service: CiaoService - private records: ResourceRecord[] = []; + private records: ResourceRecord[] = [] - private timer?: Timeout; - private currentInterval: number = PROBE_INTERVAL; - private promiseResolve?: (value?: void | PromiseLike) => void; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private promiseReject?: (reason?: any) => void; + private timer?: Timeout + private currentInterval: number = PROBE_INTERVAL + private promiseResolve?: (value?: void | PromiseLike) => void + private promiseReject?: (reason?: any) => void - private serviceEncounteredNameChange = false; - private sentFirstProbeQuery = false; // we MUST ignore responses received BEFORE the first probe is sent - private sentQueriesForCurrentTry = 0; - private sentQueries = 0; + private serviceEncounteredNameChange = false + private sentFirstProbeQuery = false // we MUST ignore responses received BEFORE the first probe is sent + private sentQueriesForCurrentTry = 0 + private sentQueries = 0 constructor(responder: Responder, server: MDNSServer, service: CiaoService) { - assert(responder, "responder must be defined"); - assert(server, "server must be defined"); - assert(service, "service must be defined"); - this.responder = responder; - this.server = server; - this.service = service; + assert(responder, 'responder must be defined') + assert(server, 'server must be defined') + assert(service, 'service must be defined') + this.responder = responder + this.server = server + this.service = service } public getService(): CiaoService { - return this.service; + return this.service } /** @@ -72,32 +78,32 @@ export class Prober { * and continue with announcing our service. */ - debug("Starting to probe for '%s'...", this.service.getFQDN()); + debug('Starting to probe for \'%s\'...', this.service.getFQDN()) return new Promise((resolve, reject) => { - this.promiseResolve = resolve; - this.promiseReject = reject; + this.promiseResolve = resolve + this.promiseReject = reject - this.timer = setTimeout(this.sendProbeRequest.bind(this), Math.random() * PROBE_INTERVAL); - this.timer.unref(); - }); + this.timer = setTimeout(this.sendProbeRequest.bind(this), Math.random() * PROBE_INTERVAL) + this.timer.unref() + }) } public cancel(): void { - this.clear(); + this.clear() - this.promiseReject!(Prober.CANCEL_REASON); + this.promiseReject!(Prober.CANCEL_REASON) } private clear(): void { if (this.timer) { - clearTimeout(this.timer); - this.timer = undefined; + clearTimeout(this.timer) + this.timer = undefined } // reset all values to default (so the Prober can be reused if it wasn't successful) - this.sentFirstProbeQuery = false; - this.sentQueriesForCurrentTry = 0; + this.sentFirstProbeQuery = false + this.sentQueriesForCurrentTry = 0 } /** @@ -106,14 +112,14 @@ export class Prober { */ private endProbing(success: boolean): void { // reset all values to default (so the Prober can be reused if it wasn't successful) - this.clear(); + this.clear() if (success) { - debug("Probing for '%s' finished successfully", this.service.getFQDN()); - this.promiseResolve!(); + debug('Probing for \'%s\' finished successfully', this.service.getFQDN()) + this.promiseResolve!() if (this.serviceEncounteredNameChange) { - this.service.informAboutNameUpdates(); + this.service.informAboutNameUpdates() } } } @@ -134,185 +140,185 @@ export class Prober { // algorithm can work correctly. Otherwise, we would conflict with ourselves in a situation were // a device is connected to the same network via Wi-Fi and Ethernet. this.records = [ - this.service.srvRecord(), this.service.txtRecord(), - this.service.ptrRecord(), ...this.service.subtypePtrRecords(), - ...this.service.allAddressRecords(), //...this.service.allReverseAddressMappings(), - ].sort(rrComparator); // we sort them for the tiebreaking algorithm - this.records.forEach(record => record.flushFlag = false); + this.service.srvRecord(), + this.service.txtRecord(), + this.service.ptrRecord(), + ...this.service.subtypePtrRecords(), + ...this.service.allAddressRecords(), // ...this.service.allReverseAddressMappings(), + ].sort(rrComparator) // we sort them for the tiebreaking algorithm + this.records.forEach(record => record.flushFlag = false) } if (this.sentQueriesForCurrentTry >= 3) { // we sent three requests, and it seems like we weren't canceled, so we have a success right here - this.endProbing(true); - return; + this.endProbing(true) + return } if (this.sentQueries >= 15) { - this.currentInterval = LIMITED_PROBE_INTERVAL; + this.currentInterval = LIMITED_PROBE_INTERVAL } - debug("Sending prober query number %d for '%s'...", this.sentQueriesForCurrentTry + 1, this.service.getFQDN()); + debug('Sending prober query number %d for \'%s\'...', this.sentQueriesForCurrentTry + 1, this.service.getFQDN()) - assert(this.records.length > 0, "Tried sending probing request for zero record length!"); + assert(this.records.length > 0, 'Tried sending probing request for zero record length!') const questions = [ // probes SHOULD be sent with unicast response flag as of the RFC // MDNServer might overwrite the QU flag to false, as we can't use unicast if there is another responder on the machine new Question(this.service.getFQDN(), QType.ANY, true), new Question(this.service.getHostname(), QType.ANY, true), - ]; + ] this.server.sendQueryBroadcast({ - questions: questions, + questions, // TODO certified homekit accessories only include the main service PTR record authorities: this.records, // include records we want to announce in authorities to support Simultaneous Probe Tiebreaking (RFC 6762 8.2.) - }, this.service).then(results => { - const failRatio = SendResultFailedRatio(results); + }, this.service).then((results) => { + const failRatio = SendResultFailedRatio(results) if (failRatio === 1) { - console.error(SendResultFormatError(results, `Failed to send probe queries for '${this.service.getFQDN()}'`), true); - this.endProbing(false); - this.promiseReject!(new Error("Probing failed as of socket errors!")); - return; // all failed => thus probing failed + console.error(sendResultFormatError(results, `Failed to send probe queries for '${this.service.getFQDN()}'`), true) + this.endProbing(false) + this.promiseReject!(new Error('Probing failed as of socket errors!')) + return // all failed => thus probing failed } if (failRatio > 0) { // some queries on some interfaces failed, but not all. We log that but consider that to be a success // at this point we are not responsible for removing stale network interfaces or something - debug(SendResultFormatError(results, `Some of the probe queries for '${this.service.getFQDN()}' encountered an error`)); + debug(sendResultFormatError(results, `Some of the probe queries for '${this.service.getFQDN()}' encountered an error`)) // SEE no return here } if (this.service.serviceState !== ServiceState.PROBING) { - debug("Service '%s' is no longer in probing state. Stopping.", this.service.getFQDN()); - return; + debug('Service \'%s\' is no longer in probing state. Stopping.', this.service.getFQDN()) + return } - this.sentFirstProbeQuery = true; - this.sentQueriesForCurrentTry++; - this.sentQueries++; + this.sentFirstProbeQuery = true + this.sentQueriesForCurrentTry++ + this.sentQueries++ - this.timer = setTimeout(this.sendProbeRequest.bind(this), this.currentInterval); - this.timer.unref(); + this.timer = setTimeout(this.sendProbeRequest.bind(this), this.currentInterval) + this.timer.unref() - this.checkLocalConflicts(); - }); + this.checkLocalConflicts() + }) } private checkLocalConflicts() { - let containsAnswer = false; + let containsAnswer = false for (const service of this.responder.getAnnouncedServices()) { if (service.getLowerCasedFQDN() === this.service.getLowerCasedFQDN() || service.getLowerCasedHostname() === this.service.getLowerCasedHostname()) { - containsAnswer = true; - break; + containsAnswer = true + break } } if (containsAnswer) { - debug("Probing for '%s' failed as of local service. Doing a name change", this.service.getFQDN()); - this.handleNameChange(); + debug('Probing for \'%s\' failed as of local service. Doing a name change', this.service.getFQDN()) + this.handleNameChange() } } handleResponse(packet: DNSPacket, endpoint: EndpointInfo): void { if (!this.sentFirstProbeQuery || !this.service.advertisesOnInterface(endpoint.interface)) { - return; + return } - let containsAnswer = false; + let containsAnswer = false // search answers and additionals for answers to our probe queries for (const record of packet.answers.values()) { if (record.getLowerCasedName() === this.service.getLowerCasedFQDN() || record.getLowerCasedName() === this.service.getLowerCasedHostname()) { - containsAnswer = true; - break; + containsAnswer = true + break } } for (const record of packet.additionals.values()) { if (record.getLowerCasedName() === this.service.getLowerCasedFQDN() || record.getLowerCasedName() === this.service.getLowerCasedHostname()) { - containsAnswer = true; - break; + containsAnswer = true + break } } if (containsAnswer) { // abort and cancel probes - debug("Probing for '%s' failed. Doing a name change", this.service.getFQDN()); - this.handleNameChange(); + debug('Probing for \'%s\' failed. Doing a name change', this.service.getFQDN()) + this.handleNameChange() } } private handleNameChange() { - this.endProbing(false); // reset the prober - this.service.serviceState = ServiceState.UNANNOUNCED; - this.service.incrementName(); - this.service.serviceState = ServiceState.PROBING; + this.endProbing(false) // reset the prober + this.service.serviceState = ServiceState.UNANNOUNCED + this.service.incrementName() + this.service.serviceState = ServiceState.PROBING - this.serviceEncounteredNameChange = true; + this.serviceEncounteredNameChange = true - this.timer = setTimeout(this.sendProbeRequest.bind(this), 1000); - this.timer.unref(); + this.timer = setTimeout(this.sendProbeRequest.bind(this), 1000) + this.timer.unref() } handleQuery(packet: DNSPacket, endpoint: EndpointInfo): void { if (!this.sentFirstProbeQuery || !this.service.advertisesOnInterface(endpoint.interface)) { - return; + return } // if we are currently probing and receiving a query which is also a probing query // which matches the desired name we run the tiebreaking algorithm to decide on the winner - let needsTiebreaking = false; + let needsTiebreaking = false for (const question of packet.questions.values()) { if (question.getLowerCasedName() === this.service.getLowerCasedFQDN() || question.getLowerCasedName() === this.service.getLowerCasedHostname()) { - needsTiebreaking = true; - break; + needsTiebreaking = true + break } } - if (needsTiebreaking) { - this.doTiebreaking(packet); + this.doTiebreaking(packet) } } private doTiebreaking(packet: DNSPacket): void { if (!this.sentFirstProbeQuery) { // ignore queries if we are not sending - return; + return } // first of all check if the contents of authorities answers our query - let conflict = packet.authorities.size === 0; + let conflict = packet.authorities.size === 0 for (const record of packet.authorities.values()) { if (record.getLowerCasedName() === this.service.getLowerCasedFQDN() || record.getLowerCasedName() === this.service.getLowerCasedHostname()) { - conflict = true; - break; + conflict = true + break } } if (!conflict) { - return; + return } // now run the actual tiebreaking algorithm to decide the winner // tiebreaking is actually run pretty often, as we always receive our own packets // first of all build our own records - const answers = this.records; // already sorted - const opponent = Array.from(packet.authorities.values()).sort(tiebreaking.rrComparator); + const answers = this.records // already sorted + const opponent = Array.from(packet.authorities.values()).sort(tiebreaking.rrComparator) - const result = tiebreaking.runTiebreaking(answers, opponent); + const result = tiebreaking.runTiebreaking(answers, opponent) if (result === TiebreakingResult.HOST) { - debug("'%s' won the tiebreak. We gonna ignore the other probing request!", this.service.getFQDN()); + debug('\'%s\' won the tiebreak. We gonna ignore the other probing request!', this.service.getFQDN()) } else if (result === TiebreakingResult.OPPONENT) { - debug("'%s' lost the tiebreak. We are waiting a second and try to probe again...", this.service.getFQDN()); + debug('\'%s\' lost the tiebreak. We are waiting a second and try to probe again...', this.service.getFQDN()) - this.endProbing(false); // cancel the current probing + this.endProbing(false) // cancel the current probing // wait 1 second and probe again (this is to guard against stale probe packets) // If it wasn't a stale probe packet, the other host will correctly respond to our probe queries by then - this.timer = setTimeout(this.sendProbeRequest.bind(this), 1000); - this.timer.unref(); + this.timer = setTimeout(this.sendProbeRequest.bind(this), 1000) + this.timer.unref() } else { - //debug("Tiebreaking for '%s' detected exact same records on the network. There is actually no conflict!", this.service.getFQDN()); + // debug("Tiebreaking for '%s' detected exact same records on the network. There is actually no conflict!", this.service.getFQDN()); } } - } diff --git a/src/responder/QueryResponse.ts b/src/responder/QueryResponse.ts index e53a853..c7dc8b2 100644 --- a/src/responder/QueryResponse.ts +++ b/src/responder/QueryResponse.ts @@ -1,140 +1,139 @@ -import { DNSPacket, PacketType } from "../coder/DNSPacket"; -import { Question } from "../coder/Question"; -import { ResourceRecord } from "../coder/ResourceRecord"; +import type { Question } from '../coder/Question' +import type { ResourceRecord } from '../coder/ResourceRecord' -export type RecordAddMethod = (...records: ResourceRecord[]) => boolean; +import { DNSPacket, PacketType } from '../coder/DNSPacket.js' -export class QueryResponse { +export type RecordAddMethod = (...records: ResourceRecord[]) => boolean - private readonly dnsPacket: DNSPacket; +export class QueryResponse { + private readonly dnsPacket: DNSPacket // known answer suppression according to RFC 6762 7.1. - readonly knownAnswers?: Map; - private sharedAnswer = false; + readonly knownAnswers?: Map + private sharedAnswer = false constructor(knownAnswers?: Map) { - this.dnsPacket = new DNSPacket({ type: PacketType.RESPONSE }); - this.knownAnswers = knownAnswers; + this.dnsPacket = new DNSPacket({ type: PacketType.RESPONSE }) + this.knownAnswers = knownAnswers } public asPacket(): DNSPacket { - return this.dnsPacket; + return this.dnsPacket } public asString(udpPayloadSize?: number): string { - return this.dnsPacket.asLoggingString(udpPayloadSize); + return this.dnsPacket.asLoggingString(udpPayloadSize) } public containsSharedAnswer(): boolean { - return this.sharedAnswer; + return this.sharedAnswer } public addAnswer(...records: ResourceRecord[]): boolean { - let addedAny = false; + let addedAny = false for (const record of records) { if (this.isKnownAnswer(record)) { // record is a known answer to the querier - continue; + continue } - const added = this.dnsPacket.addAnswers(record); + const added = this.dnsPacket.addAnswers(record) if (added) { - addedAny = true; + addedAny = true if (!record.flushFlag) { - this.sharedAnswer = true; + this.sharedAnswer = true } } } - return addedAny; + return addedAny } public addAdditional(...records: ResourceRecord[]): boolean { - let addedAny = false; + let addedAny = false for (const record of records) { if (this.isKnownAnswer(record)) { // check if the additional record is a known answer, otherwise there is no need to send it - continue; + continue } if (this.dnsPacket.answers.has(record.asString())) { - continue; // if it is already in the answer section, don't include it in additionals + continue // if it is already in the answer section, don't include it in additionals } - const added = this.dnsPacket.addAdditionals(record); + const added = this.dnsPacket.addAdditionals(record) if (added) { - addedAny = true; + addedAny = true } } - return addedAny; + return addedAny } public markLegacyUnicastResponse(id: number, questions?: Question[]): void { // we are dealing with a legacy unicast dns query (RFC 6762 6.7.) // * MUSTS: response via unicast, repeat query ID, repeat questions (actually it should just be one), clear cache flush bit // * SHOULDS: ttls should not be greater than 10s as legacy resolvers don't take part in the cache coherency mechanism - this.dnsPacket.id = id; + this.dnsPacket.id = id if (questions) { - this.dnsPacket.addQuestions(...questions); + this.dnsPacket.addQuestions(...questions) } - this.dnsPacket.answers.forEach(answers => { - answers.flushFlag = false; - answers.ttl = 10; - }); - this.dnsPacket.additionals.forEach(answers => { - answers.flushFlag = false; - answers.ttl = 10; - }); + this.dnsPacket.answers.forEach((answers) => { + answers.flushFlag = false + answers.ttl = 10 + }) + this.dnsPacket.additionals.forEach((answers) => { + answers.flushFlag = false + answers.ttl = 10 + }) - this.dnsPacket.setLegacyUnicastEncoding(true); // legacy unicast also affects the encoder (must not use compression for the SRV record) so we need to tell him + this.dnsPacket.setLegacyUnicastEncoding(true) // legacy unicast also affects the encoder (must not use compression for the SRV record) so we need to tell him } public markTruncated(): void { - this.dnsPacket.flags.truncation = true; + this.dnsPacket.flags.truncation = true } public hasAnswers(): boolean { // we may still have additionals, though there is no reason when answers is empty // removeKnownAnswer may have removed all answers and only additionals are known. - return this.dnsPacket.answers.size > 0; + return this.dnsPacket.answers.size > 0 } private isKnownAnswer(record: ResourceRecord): boolean { if (!this.knownAnswers) { - return false; + return false } - const knownAnswer = this.knownAnswers.get(record.asString()); + const knownAnswer = this.knownAnswers.get(record.asString()) // we will still send the response if the known answer has half of the original ttl according to RFC 6762 7.1. // so only if the ttl is more than half than the original ttl we consider it a valid known answer - return knownAnswer !== undefined && knownAnswer.ttl > record.ttl / 2; + return knownAnswer !== undefined && knownAnswer.ttl > record.ttl / 2 } public static combineResponses(responses: QueryResponse[], udpPayloadSize?: number): void { for (let i = 0; i < responses.length - 1; i++) { - const current = responses[i]; - const currentPacket = current.dnsPacket; - const next = responses[i + 1]; - const nextPacket = next.dnsPacket; + const current = responses[i] + const currentPacket = current.dnsPacket + const next = responses[i + 1] + const nextPacket = next.dnsPacket if (currentPacket.canBeCombinedWith(nextPacket, udpPayloadSize)) { // combine the packet with next one - currentPacket.combineWith(nextPacket); + currentPacket.combineWith(nextPacket) // remove next from the array - responses.splice(i + 1, 1); + responses.splice(i + 1, 1) // we won't combine the known answer section, with current implementation they will always be the same - current.sharedAnswer = current.sharedAnswer || next.sharedAnswer; + current.sharedAnswer = current.sharedAnswer || next.sharedAnswer // decrement i, so we check again if the "current" packet can be combined with the packet after "next" - i--; + i-- } } } - } diff --git a/src/responder/QueuedResponse.ts b/src/responder/QueuedResponse.ts index c2024f4..06343af 100644 --- a/src/responder/QueuedResponse.ts +++ b/src/responder/QueuedResponse.ts @@ -1,32 +1,33 @@ -import { DNSPacket } from "../coder/DNSPacket"; -import { InterfaceName } from "../NetworkManager"; -import { QueryResponse } from "./QueryResponse"; -import Timeout = NodeJS.Timeout; +/* global NodeJS */ +import type { DNSPacket } from '../coder/DNSPacket' +import type { InterfaceName } from '../NetworkManager' +import type { QueryResponse } from './QueryResponse' + +import Timeout = NodeJS.Timeout /** * Represents a delay response packet which is going to be sent over multicast. */ export class QueuedResponse { + public static readonly MAX_DELAY = 500 // milliseconds - public static readonly MAX_DELAY = 500; // milliseconds - - private readonly packet: DNSPacket; - private readonly interfaceName: InterfaceName; + private readonly packet: DNSPacket + private readonly interfaceName: InterfaceName - private timeOfCreation = new Date().getTime(); // epoch time millis - estimatedTimeToBeSent = 0; // epoch time millis - private delay = -1; - private timer?: Timeout; + private timeOfCreation = new Date().getTime() // epoch time millis + estimatedTimeToBeSent = 0 // epoch time millis + private delay = -1 + private timer?: Timeout - delayed?: boolean; // indicates that this object is invalid, got delayed (combined with another object) + delayed?: boolean // indicates that this object is invalid, got delayed (combined with another object) constructor(packet: DNSPacket, interfaceName: InterfaceName) { - this.packet = packet; - this.interfaceName = interfaceName; + this.packet = packet + this.interfaceName = interfaceName } public getPacket(): DNSPacket { - return this.packet; + return this.packet } /** @@ -38,26 +39,26 @@ export class QueuedResponse { * @returns The total delay. */ public getTimeSinceCreation(): number { - return new Date().getTime() - this.timeOfCreation; + return new Date().getTime() - this.timeOfCreation } public getTimeTillSent(): number { - return Math.max(0, this.estimatedTimeToBeSent - new Date().getTime()); + return Math.max(0, this.estimatedTimeToBeSent - new Date().getTime()) } public calculateRandomDelay(): void { - this.delay = Math.random() * 100 + 20; // delay of 20ms - 120ms - this.estimatedTimeToBeSent = new Date().getTime() + this.delay; + this.delay = Math.random() * 100 + 20 // delay of 20ms - 120ms + this.estimatedTimeToBeSent = new Date().getTime() + this.delay } public scheduleResponse(callback: () => void): void { - this.timer = setTimeout(callback, this.delay); - this.timer.unref(); // timer doesn't prevent termination + this.timer = setTimeout(callback, this.delay) + this.timer.unref() // timer doesn't prevent termination } public delayWouldBeInTimelyManner(next: QueuedResponse): boolean { - const delay = next.estimatedTimeToBeSent - this.timeOfCreation; - return delay <= QueuedResponse.MAX_DELAY; + const delay = next.estimatedTimeToBeSent - this.timeOfCreation + return delay <= QueuedResponse.MAX_DELAY } /** @@ -78,39 +79,38 @@ export class QueuedResponse { */ if (this.interfaceName !== next.interfaceName) { // can't combine packets which get sent via different interfaces - return false; + return false } if (!next.packet.canBeCombinedWith(this.packet)) { // packets can't be combined - return false; + return false } - next.packet.combineWith(this.packet); - next.timeOfCreation = Math.min(this.timeOfCreation, next.timeOfCreation); + next.packet.combineWith(this.packet) + next.timeOfCreation = Math.min(this.timeOfCreation, next.timeOfCreation) if (this.timer) { - clearTimeout(this.timer); - this.timer = undefined; + clearTimeout(this.timer) + this.timer = undefined } - this.delayed = true; + this.delayed = true - return true; + return true } public combineWithUniqueResponseIfPossible(response: QueryResponse, interfaceName: string): boolean { if (this.interfaceName !== interfaceName) { // can't combine packets which get sent via different interfaces - return false; + return false } if (!this.packet.canBeCombinedWith(response.asPacket())) { - return false; // packets can't be combined + return false // packets can't be combined } - this.packet.combineWith(response.asPacket()); - return true; + this.packet.combineWith(response.asPacket()) + return true } - } diff --git a/src/responder/TruncatedQuery.spec.ts b/src/responder/TruncatedQuery.spec.ts index 6af515d..705ce28 100644 --- a/src/responder/TruncatedQuery.spec.ts +++ b/src/responder/TruncatedQuery.spec.ts @@ -1,55 +1,60 @@ -import { DNSPacket, QType } from "../coder/DNSPacket"; -import { Question } from "../coder/Question"; -import { ARecord } from "../coder/records/ARecord"; -import { PromiseTimeout } from "../util/promise-utils"; -import { TruncatedQuery, TruncatedQueryEvent, TruncatedQueryResult } from "./TruncatedQuery"; +import { describe, expect, it } from 'vitest' -const answerA1 = () => { - return new ARecord("test.local", "1.1.1.1", true); -}; -const answerA2 = () => { - return new ARecord("test.local", "1.1.1.2", true); -}; -const answerA3 = () => { - return new ARecord("test.local", "1.1.1.3", true); -}; -const answerA4 = () => { - return new ARecord("test.local", "1.1.1.4", true); -}; -const answerA5 = () => { - return new ARecord("test.local", "1.1.1.5", true); -}; +import '../coder/records/index.js' + +// eslint-disable-next-line perfectionist/sort-imports +import { DNSPacket, QType } from '../coder/DNSPacket.js' +import { Question } from '../coder/Question.js' +import { ARecord } from '../coder/records/ARecord.js' +import { PromiseTimeout } from '../util/promise-utils.js' +import { TruncatedQuery, TruncatedQueryEvent, TruncatedQueryResult } from './TruncatedQuery.js' + +function answerA1() { + return new ARecord('test.local', '1.1.1.1', true) +} +function answerA2() { + return new ARecord('test.local', '1.1.1.2', true) +} +function answerA3() { + return new ARecord('test.local', '1.1.1.3', true) +} +function answerA4() { + return new ARecord('test.local', '1.1.1.4', true) +} +function answerA5() { + return new ARecord('test.local', '1.1.1.5', true) +} describe(TruncatedQuery, () => { - it("should timeout truncated query without a followup query", callback => { + it('should timeout truncated query without a followup query', () => new Promise((callback) => { const packet = DNSPacket.createDNSQueryPacket({ - questions: [new Question("test.local", QType.A)], + questions: [new Question('test.local', QType.A)], answers: [ answerA1(), answerA2(), ], - }); - packet.flags.truncation = true; + }) + packet.flags.truncation = true - const truncatedQuery = new TruncatedQuery(packet); - expect(truncatedQuery.getPacket()).toBe(packet); + const truncatedQuery = new TruncatedQuery(packet) + expect(truncatedQuery.getPacket()).toBe(packet) truncatedQuery.on(TruncatedQueryEvent.TIMEOUT, () => { - expect(truncatedQuery.getArrivedPacketCount()).toBe(1); - expect(truncatedQuery.getTotalWaitTime() < 1000).toBe(true); - callback(); - }); - }, 1000); + expect(truncatedQuery.getArrivedPacketCount()).toBe(1) + expect(truncatedQuery.getTotalWaitTime() < 1000).toBe(true) + callback(0) + }) + }), 1000) - it("should assemble truncated queries", async () => { + it('should assemble truncated queries', async () => { const packet0 = DNSPacket.createDNSQueryPacket({ - questions: [new Question("test.local", QType.A)], + questions: [new Question('test.local', QType.A)], answers: [ answerA1(), answerA2(), ], - }); - packet0.flags.truncation = true; + }) + packet0.flags.truncation = true const packet1 = DNSPacket.createDNSQueryPacket({ questions: [], @@ -57,8 +62,8 @@ describe(TruncatedQuery, () => { answerA2(), answerA3(), ], - }); - packet1.flags.truncation = true; + }) + packet1.flags.truncation = true const packet2 = DNSPacket.createDNSQueryPacket({ questions: [], @@ -66,32 +71,32 @@ describe(TruncatedQuery, () => { answerA4(), answerA5(), ], - }); + }) - const query = new TruncatedQuery(packet0); + const query = new TruncatedQuery(packet0) query.on(TruncatedQueryEvent.TIMEOUT, () => { - fail(new Error("Truncated Query timed when waiting for second packet")); - }); + throw new Error('Truncated Query timed when waiting for second packet') + }) await PromiseTimeout(60).then(async () => { - const result = query.appendDNSPacket(packet1); - expect(result).toBe(TruncatedQueryResult.AGAIN_TRUNCATED); - expect(query.getArrivedPacketCount()).toBe(2); + const result = query.appendDNSPacket(packet1) + expect(result).toBe(TruncatedQueryResult.AGAIN_TRUNCATED) + expect(query.getArrivedPacketCount()).toBe(2) - await PromiseTimeout(60); - const result_2 = query.appendDNSPacket(packet2); - expect(result_2).toBe(TruncatedQueryResult.FINISHED); - expect(query.getArrivedPacketCount()).toBe(3); - const packet_1 = query.getPacket(); - expect(packet_1.questions.size).toBe(1); - expect(packet_1.answers.size).toBe(5); - expect(packet_1.additionals.size).toBe(0); - expect(packet_1.authorities.size).toBe(0); - expect(packet_1.answers.has(answerA1().asString())).toBe(true); - expect(packet_1.answers.has(answerA2().asString())).toBe(true); - expect(packet_1.answers.has(answerA3().asString())).toBe(true); - expect(packet_1.answers.has(answerA4().asString())).toBe(true); - expect(packet_1.answers.has(answerA5().asString())).toBe(true); - }); - }); -}); + await PromiseTimeout(60) + const result_2 = query.appendDNSPacket(packet2) + expect(result_2).toBe(TruncatedQueryResult.FINISHED) + expect(query.getArrivedPacketCount()).toBe(3) + const packet_1 = query.getPacket() + expect(packet_1.questions.size).toBe(1) + expect(packet_1.answers.size).toBe(5) + expect(packet_1.additionals.size).toBe(0) + expect(packet_1.authorities.size).toBe(0) + expect(packet_1.answers.has(answerA1().asString())).toBe(true) + expect(packet_1.answers.has(answerA2().asString())).toBe(true) + expect(packet_1.answers.has(answerA3().asString())).toBe(true) + expect(packet_1.answers.has(answerA4().asString())).toBe(true) + expect(packet_1.answers.has(answerA5().asString())).toBe(true) + }) + }) +}) diff --git a/src/responder/TruncatedQuery.ts b/src/responder/TruncatedQuery.ts index 017fed1..78f297c 100644 --- a/src/responder/TruncatedQuery.ts +++ b/src/responder/TruncatedQuery.ts @@ -1,88 +1,92 @@ -/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ -import { EventEmitter } from "events"; -import { DNSPacket } from "../coder/DNSPacket"; -import Timeout = NodeJS.Timeout; +/* global NodeJS */ +import type { DNSPacket } from '../coder/DNSPacket' +import { EventEmitter } from 'node:events' + +import Timeout = NodeJS.Timeout + +// eslint-disable-next-line no-restricted-syntax export const enum TruncatedQueryResult { ABORT = 1, AGAIN_TRUNCATED = 2, FINISHED = 3, } +// eslint-disable-next-line no-restricted-syntax export const enum TruncatedQueryEvent { - TIMEOUT = "timeout", + TIMEOUT = 'timeout', } +// eslint-disable-next-line ts/no-unsafe-declaration-merging export declare interface TruncatedQuery { - on(event: "timeout", listener: () => void): this; + on: (event: 'timeout', listener: () => void) => this - emit(event: "timeout"): boolean; + emit: (event: 'timeout') => boolean } +// eslint-disable-next-line ts/no-unsafe-declaration-merging export class TruncatedQuery extends EventEmitter { + private readonly timeOfArrival: number + private readonly packet: DNSPacket + private arrivedPackets = 1 // just for the stats - private readonly timeOfArrival: number; - private readonly packet: DNSPacket; - private arrivedPackets = 1; // just for the stats - - private timer: Timeout; + private timer: Timeout constructor(packet: DNSPacket) { - super(); - this.timeOfArrival = new Date().getTime(); - this.packet = packet; + super() + this.timeOfArrival = new Date().getTime() + this.packet = packet - this.timer = this.resetTimer(); + this.timer = this.resetTimer() } public getPacket(): DNSPacket { - return this.packet; + return this.packet } public getArrivedPacketCount(): number { - return this.arrivedPackets; + return this.arrivedPackets } public getTotalWaitTime(): number { - return new Date().getTime() - this.timeOfArrival; + return new Date().getTime() - this.timeOfArrival } public appendDNSPacket(packet: DNSPacket): TruncatedQueryResult { - this.packet.combineWith(packet); + this.packet.combineWith(packet) - this.arrivedPackets++; + this.arrivedPackets++ if (packet.flags.truncation) { // if the appended packet is again truncated, restart the timeout - const time = new Date().getTime(); + const time = new Date().getTime() if (time - this.timeOfArrival > 5 * 1000) { // if the first packet, is more than 5 seconds old, we abort - return TruncatedQueryResult.ABORT; + return TruncatedQueryResult.ABORT } - this.resetTimer(); - return TruncatedQueryResult.AGAIN_TRUNCATED; + this.resetTimer() + return TruncatedQueryResult.AGAIN_TRUNCATED } else { - clearTimeout(this.timer); - this.removeAllListeners(); + clearTimeout(this.timer) + this.removeAllListeners() - return TruncatedQueryResult.FINISHED; + return TruncatedQueryResult.FINISHED } } private resetTimer(): Timeout { if (this.timer) { - clearTimeout(this.timer); + clearTimeout(this.timer) } // timeout in time interval between 400-500ms - return this.timer = setTimeout(this.timeout.bind(this), 400 + Math.random() * 100); + return this.timer = setTimeout(this.timeout.bind(this), 400 + Math.random() * 100) } private timeout(): void { - this.emit(TruncatedQueryEvent.TIMEOUT); - this.removeAllListeners(); + this.emit(TruncatedQueryEvent.TIMEOUT) + this.removeAllListeners() } - } diff --git a/src/util/dns-equal.spec.ts b/src/util/dns-equal.spec.ts index 9a666ff..33a7812 100644 --- a/src/util/dns-equal.spec.ts +++ b/src/util/dns-equal.spec.ts @@ -1,18 +1,20 @@ -import { dnsLowerCase } from "./dns-equal"; +import { describe, expect, it } from 'vitest' + +import { dnsLowerCase } from './dns-equal.js' function dnsEqual(string0: string, string1: string) { - return dnsLowerCase(string0) === dnsLowerCase(string1); + return dnsLowerCase(string0) === dnsLowerCase(string1) } describe(dnsEqual, () => { - it("should run positive tests", () => { - expect(dnsEqual("Foo", "foo")).toBe(true); - expect(dnsEqual("FooÆØÅ", "fooÆØÅ")).toBe(true); - }); + it('should run positive tests', () => { + expect(dnsEqual('Foo', 'foo')).toBe(true) + expect(dnsEqual('FooÆØÅ', 'fooÆØÅ')).toBe(true) + }) - it("should run negative tests", () => { - expect(dnsEqual("Foo", "bar")).toBe(false); - expect(dnsEqual("FooÆØÅ", "fooæøå")).toBe(false); - expect(dnsEqual("café", "cafe")).toBe(false); - }); -}); + it('should run negative tests', () => { + expect(dnsEqual('Foo', 'bar')).toBe(false) + expect(dnsEqual('FooÆØÅ', 'fooæøå')).toBe(false) + expect(dnsEqual('café', 'cafe')).toBe(false) + }) +}) diff --git a/src/util/dns-equal.ts b/src/util/dns-equal.ts index 2d329c0..6605efa 100644 --- a/src/util/dns-equal.ts +++ b/src/util/dns-equal.ts @@ -1,7 +1,7 @@ // name equality checks according to RFC 1035 3.1 -const asciiPattern = /[A-Z]/g; +const asciiPattern = /[A-Z]/g export function dnsLowerCase(value: string): string { - return value.replace(asciiPattern, s => s.toLowerCase()); + return value.replace(asciiPattern, s => s.toLowerCase()) } diff --git a/src/util/domain-formatter.spec.ts b/src/util/domain-formatter.spec.ts index c43f69e..a81fa29 100644 --- a/src/util/domain-formatter.spec.ts +++ b/src/util/domain-formatter.spec.ts @@ -1,5 +1,10 @@ -import { ServiceType } from "../CiaoService"; -import { Protocol } from "../index"; +import { describe, expect, it } from 'vitest' + +import '../coder/records/index.js' + +// eslint-disable-next-line perfectionist/sort-imports +import { ServiceType } from '../CiaoService.js' +import { Protocol } from '../index.js' import { enlargeIPv6, formatHostname, @@ -10,232 +15,227 @@ import { removeTLD, shortenIPv6, stringify, -} from "./domain-formatter"; +} from './domain-formatter.js' -describe("domain-formatter", () => { +describe('domain-formatter', () => { describe(parseFQDN, () => { - - it("should parse tcp ptr query domain", () => { - const parsed = parseFQDN("_hap._tcp.local"); + it('should parse tcp ptr query domain', () => { + const parsed = parseFQDN('_hap._tcp.local') expect(parsed).toStrictEqual({ - domain: "local", + domain: 'local', protocol: Protocol.TCP, type: ServiceType.HAP, - }); - }); + }) + }) - it("should parse udp ptr query domain", () => { - const parsed = parseFQDN("_hap._udp.local"); + it('should parse udp ptr query domain', () => { + const parsed = parseFQDN('_hap._udp.local') expect(parsed).toStrictEqual({ - domain: "local", + domain: 'local', protocol: Protocol.UDP, type: ServiceType.HAP, - }); - }); + }) + }) - - it("should parse instance domain", () => { - const parsed = parseFQDN("My Great Device._hap._tcp.local"); + it('should parse instance domain', () => { + const parsed = parseFQDN('My Great Device._hap._tcp.local') expect(parsed).toStrictEqual({ - domain: "local", + domain: 'local', protocol: Protocol.TCP, type: ServiceType.HAP, - name: "My Great Device", - }); - }); + name: 'My Great Device', + }) + }) - it("should parse instance domain with dotted name", () => { - const parsed = parseFQDN("My.Great.Device._hap._tcp.local"); + it('should parse instance domain with dotted name', () => { + const parsed = parseFQDN('My.Great.Device._hap._tcp.local') expect(parsed).toStrictEqual({ - domain: "local", + domain: 'local', protocol: Protocol.TCP, type: ServiceType.HAP, - name: "My.Great.Device", - }); - }); + name: 'My.Great.Device', + }) + }) - it("should parse service type enumeration query", () => { - const parsed = parseFQDN("_services._dns-sd._udp.local"); + it('should parse service type enumeration query', () => { + const parsed = parseFQDN('_services._dns-sd._udp.local') expect(parsed).toStrictEqual({ - domain: "local", + domain: 'local', protocol: Protocol.UDP, type: ServiceType.DNS_SD, - name: "services", - }); - }); - + name: 'services', + }) + }) - it("should parse sub typed domain", () => { - const parsed = parseFQDN("_printer._sub._http._tcp.local"); + it('should parse sub typed domain', () => { + const parsed = parseFQDN('_printer._sub._http._tcp.local') expect(parsed).toStrictEqual({ - domain: "local", + domain: 'local', protocol: Protocol.TCP, type: ServiceType.HTTP, subtype: ServiceType.PRINTER, - }); - }); + }) + }) - it("should reject illegal protocol type", () => { - expect(() => parseFQDN("_hap._asd.local")).toThrow(); - }); + it('should reject illegal protocol type', () => { + expect(() => parseFQDN('_hap._asd.local')).toThrow() + }) - it("should reject to short domain", () => { - expect(() => parseFQDN(".local")).toThrow(); - }); - - }); + it('should reject to short domain', () => { + expect(() => parseFQDN('.local')).toThrow() + }) + }) describe(stringify, () => { - it("should stringify basic domain with defaults", () => { + it('should stringify basic domain with defaults', () => { expect(stringify({ - name: "My Device", + name: 'My Device', type: ServiceType.HAP, - })).toStrictEqual("My Device._hap._tcp.local."); - }); + })).toStrictEqual('My Device._hap._tcp.local.') + }) - it("should stringify basic domain ", () => { + it('should stringify basic domain ', () => { expect(stringify({ - name: "My Device", + name: 'My Device', type: ServiceType.AIRPLAY, protocol: Protocol.UDP, - domain: "custom", - })).toStrictEqual("My Device._airplay._udp.custom."); - }); + domain: 'custom', + })).toStrictEqual('My Device._airplay._udp.custom.') + }) - it("should stringy sub typed ptr domain name" , () => { + it('should stringy sub typed ptr domain name', () => { expect(stringify({ type: ServiceType.HAP, subtype: ServiceType.AIRPLAY, protocol: Protocol.UDP, - domain: "custom", - })).toStrictEqual("_airplay._sub._hap._udp.custom."); - }); + domain: 'custom', + })).toStrictEqual('_airplay._sub._hap._udp.custom.') + }) - it("should stringify ptr domain name", () => { + it('should stringify ptr domain name', () => { expect(stringify({ type: ServiceType.HAP, - })).toStrictEqual("_hap._tcp.local."); - }); - }); + })).toStrictEqual('_hap._tcp.local.') + }) + }) - describe("hostname", () => { - it("should format hostname", () => { - expect(formatHostname("MYComputer")).toStrictEqual("MYComputer.local."); - }); + describe('hostname', () => { + it('should format hostname', () => { + expect(formatHostname('MYComputer')).toStrictEqual('MYComputer.local.') + }) - it("should remove tld", () => { - expect(removeTLD("test.local")).toBe("test"); - expect(removeTLD("test.local.")).toBe("test"); - }); - }); + it('should remove tld', () => { + expect(removeTLD('test.local')).toBe('test') + expect(removeTLD('test.local.')).toBe('test') + }) + }) describe(enlargeIPv6, () => { - it("should enlarge ipv6", () => { - expect(enlargeIPv6("ffff:ffff:ffff:ffff::")).toBe("ffff:ffff:ffff:ffff:0000:0000:0000:0000"); - expect(enlargeIPv6("fe80::72ee:50ff:fe63:d1a0")).toBe("fe80:0000:0000:0000:72ee:50ff:fe63:d1a0"); - expect(enlargeIPv6("::1")).toBe("0000:0000:0000:0000:0000:0000:0000:0001"); - expect(enlargeIPv6("2001:db8::ff00:42:8329")).toBe("2001:0db8:0000:0000:0000:ff00:0042:8329"); - }); - - it("should properly enlarge ipv6 with ending double colon", () => { - expect(enlargeIPv6("2001:db8:aaaa:a4c:305:ff:fe00::")).toBe("2001:0db8:aaaa:0a4c:0305:00ff:fe00:0000"); - expect(enlargeIPv6("2001:db8:aaaa:a4c:305:ff::")).toBe("2001:0db8:aaaa:0a4c:0305:00ff:0000:0000"); - expect(enlargeIPv6("2001:db8:aaaa:a4c:305::")).toBe("2001:0db8:aaaa:0a4c:0305:0000:0000:0000"); - }); - - it("should properly enlarge ipv6 with starting double colon", () => { - expect(enlargeIPv6("::db8:aaaa:a4c:305:ff:fe00:0")).toBe("0000:0db8:aaaa:0a4c:0305:00ff:fe00:0000"); - expect(enlargeIPv6("::aaaa:a4c:305:ff:fe00:0")).toBe("0000:0000:aaaa:0a4c:0305:00ff:fe00:0000"); - expect(enlargeIPv6("::a4c:305:ff:fe00:0")).toBe("0000:0000:0000:0a4c:0305:00ff:fe00:0000"); - }); - - it("should enlarged RFC 3513 examples", () => { - expect(enlargeIPv6("1080::8:800:200c:417a")).toBe("1080:0000:0000:0000:0008:0800:200c:417a"); - expect(enlargeIPv6("ff01::101")).toBe("ff01:0000:0000:0000:0000:0000:0000:0101"); - expect(enlargeIPv6("::1")).toBe("0000:0000:0000:0000:0000:0000:0000:0001"); - expect(enlargeIPv6("::")).toBe("0000:0000:0000:0000:0000:0000:0000:0000"); - }); - }); + it('should enlarge ipv6', () => { + expect(enlargeIPv6('ffff:ffff:ffff:ffff::')).toBe('ffff:ffff:ffff:ffff:0000:0000:0000:0000') + expect(enlargeIPv6('fe80::72ee:50ff:fe63:d1a0')).toBe('fe80:0000:0000:0000:72ee:50ff:fe63:d1a0') + expect(enlargeIPv6('::1')).toBe('0000:0000:0000:0000:0000:0000:0000:0001') + expect(enlargeIPv6('2001:db8::ff00:42:8329')).toBe('2001:0db8:0000:0000:0000:ff00:0042:8329') + }) + + it('should properly enlarge ipv6 with ending double colon', () => { + expect(enlargeIPv6('2001:db8:aaaa:a4c:305:ff:fe00::')).toBe('2001:0db8:aaaa:0a4c:0305:00ff:fe00:0000') + expect(enlargeIPv6('2001:db8:aaaa:a4c:305:ff::')).toBe('2001:0db8:aaaa:0a4c:0305:00ff:0000:0000') + expect(enlargeIPv6('2001:db8:aaaa:a4c:305::')).toBe('2001:0db8:aaaa:0a4c:0305:0000:0000:0000') + }) + + it('should properly enlarge ipv6 with starting double colon', () => { + expect(enlargeIPv6('::db8:aaaa:a4c:305:ff:fe00:0')).toBe('0000:0db8:aaaa:0a4c:0305:00ff:fe00:0000') + expect(enlargeIPv6('::aaaa:a4c:305:ff:fe00:0')).toBe('0000:0000:aaaa:0a4c:0305:00ff:fe00:0000') + expect(enlargeIPv6('::a4c:305:ff:fe00:0')).toBe('0000:0000:0000:0a4c:0305:00ff:fe00:0000') + }) + + it('should enlarged RFC 3513 examples', () => { + expect(enlargeIPv6('1080::8:800:200c:417a')).toBe('1080:0000:0000:0000:0008:0800:200c:417a') + expect(enlargeIPv6('ff01::101')).toBe('ff01:0000:0000:0000:0000:0000:0000:0101') + expect(enlargeIPv6('::1')).toBe('0000:0000:0000:0000:0000:0000:0000:0001') + expect(enlargeIPv6('::')).toBe('0000:0000:0000:0000:0000:0000:0000:0000') + }) + }) describe(shortenIPv6, () => { - it("should shorten ipv6", () => { - expect(shortenIPv6("ffff:ffff:ffff:ffff:0000:0000:0000:0000")).toBe("ffff:ffff:ffff:ffff::"); - expect(shortenIPv6("fe80:0000:0000:0000:72ee:50ff:fe63:d1a0")).toBe("fe80::72ee:50ff:fe63:d1a0"); - expect(shortenIPv6("0000:0000:0000:0000:0000:0000:0000:0001")).toBe("::1"); - expect(shortenIPv6("2001:0db8:0000:0000:0000:ff00:0042:8329")).toBe("2001:db8::ff00:42:8329"); - expect(shortenIPv6("2001:0db8:0:73c8:0305:00ff:fe00:0aaa")).toBe("2001:db8::73c8:305:ff:fe00:aaa"); - expect(shortenIPv6("2001:0db8:aaaa:0a4c:0305:00ff:fe00:0000")).toBe("2001:db8:aaaa:a4c:305:ff:fe00::"); - }); - - it("should shorten the longest consecutive block of zeros (and only one)", () => { - expect(shortenIPv6("ffff:0000:0000:ffff:0000:0000:0000:ffff")).toBe("ffff:0:0:ffff::ffff"); - expect(shortenIPv6("ffff:0000:0000:0000:ffff:0000:0000:ffff")).toBe("ffff::ffff:0:0:ffff"); - }); - - it("should shorten the first if there are more than one with the same length", () => { - expect(shortenIPv6("ffff:0000:0000:ffff:ffff:0000:0000:ffff")).toBe("ffff::ffff:ffff:0:0:ffff"); - }); - - it("should shorten RFC 3513 examples", () => { - expect(shortenIPv6("1080:0:0:0:8:800:200c:417a")).toBe("1080::8:800:200c:417a"); - expect(shortenIPv6("ff01:0:0:0:0:0:0:101")).toBe("ff01::101"); - expect(shortenIPv6("0:0:0:0:0:0:0:1")).toBe("::1"); - expect(shortenIPv6("0:0:0:0:0:0:0:0")).toBe("::"); - }); - }); + it('should shorten ipv6', () => { + expect(shortenIPv6('ffff:ffff:ffff:ffff:0000:0000:0000:0000')).toBe('ffff:ffff:ffff:ffff::') + expect(shortenIPv6('fe80:0000:0000:0000:72ee:50ff:fe63:d1a0')).toBe('fe80::72ee:50ff:fe63:d1a0') + expect(shortenIPv6('0000:0000:0000:0000:0000:0000:0000:0001')).toBe('::1') + expect(shortenIPv6('2001:0db8:0000:0000:0000:ff00:0042:8329')).toBe('2001:db8::ff00:42:8329') + expect(shortenIPv6('2001:0db8:0:73c8:0305:00ff:fe00:0aaa')).toBe('2001:db8::73c8:305:ff:fe00:aaa') + expect(shortenIPv6('2001:0db8:aaaa:0a4c:0305:00ff:fe00:0000')).toBe('2001:db8:aaaa:a4c:305:ff:fe00::') + }) + + it('should shorten the longest consecutive block of zeros (and only one)', () => { + expect(shortenIPv6('ffff:0000:0000:ffff:0000:0000:0000:ffff')).toBe('ffff:0:0:ffff::ffff') + expect(shortenIPv6('ffff:0000:0000:0000:ffff:0000:0000:ffff')).toBe('ffff::ffff:0:0:ffff') + }) + + it('should shorten the first if there are more than one with the same length', () => { + expect(shortenIPv6('ffff:0000:0000:ffff:ffff:0000:0000:ffff')).toBe('ffff::ffff:ffff:0:0:ffff') + }) + + it('should shorten RFC 3513 examples', () => { + expect(shortenIPv6('1080:0:0:0:8:800:200c:417a')).toBe('1080::8:800:200c:417a') + expect(shortenIPv6('ff01:0:0:0:0:0:0:101')).toBe('ff01::101') + expect(shortenIPv6('0:0:0:0:0:0:0:1')).toBe('::1') + expect(shortenIPv6('0:0:0:0:0:0:0:0')).toBe('::') + }) + }) describe(formatReverseAddressPTRName, () => { - it("should format ipv4", () => { - expect(formatReverseAddressPTRName("192.168.1.23")).toBe("23.1.168.192.in-addr.arpa"); - }); + it('should format ipv4', () => { + expect(formatReverseAddressPTRName('192.168.1.23')).toBe('23.1.168.192.in-addr.arpa') + }) - it("should format ipv6", () => { + it('should format ipv6', () => { // link-local - expect(formatReverseAddressPTRName("fe80::72ee:50ff:fe63:d1a0")).toBe("0.A.1.D.3.6.E.F.F.F.0.5.E.E.2.7.0.0.0.0.0.0.0.0.0.0.0.0.0.8.E.F.ip6.arpa"); + expect(formatReverseAddressPTRName('fe80::72ee:50ff:fe63:d1a0')).toBe('0.A.1.D.3.6.E.F.F.F.0.5.E.E.2.7.0.0.0.0.0.0.0.0.0.0.0.0.0.8.E.F.ip6.arpa') // loopback - expect(formatReverseAddressPTRName("::1")).toBe("1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.ip6.arpa"); + expect(formatReverseAddressPTRName('::1')).toBe('1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.ip6.arpa') // routable - expect(formatReverseAddressPTRName("2001:db8::ff00:42:8329")).toBe("9.2.3.8.2.4.0.0.0.0.F.F.0.0.0.0.0.0.0.0.0.0.0.0.8.B.D.0.1.0.0.2.ip6.arpa"); - }); + expect(formatReverseAddressPTRName('2001:db8::ff00:42:8329')).toBe('9.2.3.8.2.4.0.0.0.0.F.F.0.0.0.0.0.0.0.0.0.0.0.0.8.B.D.0.1.0.0.2.ip6.arpa') + }) - it("should catch illegal ip address format", () => { - expect(() => formatReverseAddressPTRName("192.168.1")).toThrow(Error); - }); - }); + it('should catch illegal ip address format', () => { + expect(() => formatReverseAddressPTRName('192.168.1')).toThrow(Error) + }) + }) describe(ipAddressFromReversAddressName, () => { - it("should parse ipv4 query", () => { - expect(ipAddressFromReversAddressName("23.1.168.192.in-addr.arpa")).toBe("192.168.1.23"); - }); + it('should parse ipv4 query', () => { + expect(ipAddressFromReversAddressName('23.1.168.192.in-addr.arpa')).toBe('192.168.1.23') + }) - it("should parse ipv6 query", () => { - expect(ipAddressFromReversAddressName("0.A.1.D.3.6.E.F.F.F.0.5.E.E.2.7.0.0.0.0.0.0.0.0.0.0.0.0.0.8.E.F.ip6.arpa")).toBe("fe80::72ee:50ff:fe63:d1a0"); - expect(ipAddressFromReversAddressName("1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.ip6.arpa")).toBe("::1"); - expect(ipAddressFromReversAddressName("9.2.3.8.2.4.0.0.0.0.F.F.0.0.0.0.0.0.0.0.0.0.0.0.8.B.D.0.1.0.0.2.ip6.arpa")).toBe("2001:db8::ff00:42:8329"); - }); + it('should parse ipv6 query', () => { + expect(ipAddressFromReversAddressName('0.A.1.D.3.6.E.F.F.F.0.5.E.E.2.7.0.0.0.0.0.0.0.0.0.0.0.0.0.8.E.F.ip6.arpa')).toBe('fe80::72ee:50ff:fe63:d1a0') + expect(ipAddressFromReversAddressName('1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.ip6.arpa')).toBe('::1') + expect(ipAddressFromReversAddressName('9.2.3.8.2.4.0.0.0.0.F.F.0.0.0.0.0.0.0.0.0.0.0.0.8.B.D.0.1.0.0.2.ip6.arpa')).toBe('2001:db8::ff00:42:8329') + }) - it("should catch illegal ip address format", () => { - expect(() => ipAddressFromReversAddressName("192.168.1")).toThrow(Error); - }); - }); + it('should catch illegal ip address format', () => { + expect(() => ipAddressFromReversAddressName('192.168.1')).toThrow(Error) + }) + }) describe(getNetAddress, () => { - it("should calc netAddress for ipv4", () => { - expect(getNetAddress("192.168.1.129", "255.255.255.0")).toBe("192.168.1.0"); - expect(getNetAddress("192.168.1.129", "0.0.0.255")).toBe("0.0.0.129"); - - expect(getNetAddress("192.168.1.141", "255.255.255.0")).toBe("192.168.1.0"); - expect(getNetAddress("192.168.1.189", "255.255.255.0")).toBe("192.168.1.0"); - }); - - it("should calc netAddress for ipv6", () => { - expect(getNetAddress("fe80::803:bfee:be23:93a8", "ffff:ffff:ffff:ffff::")).toBe("fe80::"); - expect(getNetAddress("2001:db8:8725:ee00:1817:778e:aa58:4237", "ffff:ffff:ffff:ffff::")).toBe("2001:db8:8725:ee00::"); - }); - - it("should catch illegal ip address format", () => { - expect(() => getNetAddress("192.168.1", "255.255.0")).toThrow(Error); - }); - }); - -}); + it('should calc netAddress for ipv4', () => { + expect(getNetAddress('192.168.1.129', '255.255.255.0')).toBe('192.168.1.0') + expect(getNetAddress('192.168.1.129', '0.0.0.255')).toBe('0.0.0.129') + + expect(getNetAddress('192.168.1.141', '255.255.255.0')).toBe('192.168.1.0') + expect(getNetAddress('192.168.1.189', '255.255.255.0')).toBe('192.168.1.0') + }) + + it('should calc netAddress for ipv6', () => { + expect(getNetAddress('fe80::803:bfee:be23:93a8', 'ffff:ffff:ffff:ffff::')).toBe('fe80::') + expect(getNetAddress('2001:db8:8725:ee00:1817:778e:aa58:4237', 'ffff:ffff:ffff:ffff::')).toBe('2001:db8:8725:ee00::') + }) + + it('should catch illegal ip address format', () => { + expect(() => getNetAddress('192.168.1', '255.255.0')).toThrow(Error) + }) + }) +}) diff --git a/src/util/domain-formatter.ts b/src/util/domain-formatter.ts index df30151..290e003 100644 --- a/src/util/domain-formatter.ts +++ b/src/util/domain-formatter.ts @@ -1,305 +1,308 @@ -import assert from "assert"; -import net from "net"; -import { ServiceType } from "../CiaoService"; -import { Protocol } from "../index"; -import { getIPFromV4Mapped, isIPv4Mapped } from "./v4mapped"; +import type { ServiceType } from '../CiaoService' + +import assert from 'node:assert' +import net from 'node:net' + +import { Protocol } from '../index.js' +import { getIPFromV4Mapped, isIPv4Mapped } from './v4mapped.js' function isProtocol(part: string): boolean { - return part === "_" + Protocol.TCP || part === "_" + Protocol.UDP; + return part === `_${Protocol.TCP}` || part === `_${Protocol.UDP}` } function isSub(part: string): boolean { - return part === "_sub"; + return part === '_sub' } function removePrefixedUnderscore(part: string): string { - return part.startsWith("_")? part.slice(1): part; + return part.startsWith('_') ? part.slice(1) : part } export interface PTRQueryDomain { // like _http._tcp.local - domain: string; // most of the time it is just local - protocol: Protocol; - type: ServiceType | string; + domain: string // most of the time it is just local + protocol: Protocol + type: ServiceType | string } export interface InstanceNameDomain { // like "My Great Device._hap._tcp.local"; _services._dns-sd._udp.local is a special case of this type - domain: string; // most of the time it is just "local" - protocol: Protocol; - type: ServiceType | string; - name: string; + domain: string // most of the time it is just "local" + protocol: Protocol + type: ServiceType | string + name: string } export interface SubTypedNameDomain { // like _printer._sub._http._tcp.local - domain: string; // most of the time it is just local - protocol: Protocol; - type: ServiceType | string; - subtype: ServiceType | string; + domain: string // most of the time it is just local + protocol: Protocol + type: ServiceType | string + subtype: ServiceType | string } export interface FQDNParts { - name?: string; // exclude if you want to build a PTR domain name - type: ServiceType | string; - protocol?: Protocol; // default tcp - domain?: string; // default local + name?: string // exclude if you want to build a PTR domain name + type: ServiceType | string + protocol?: Protocol // default tcp + domain?: string // default local } export interface SubTypePTRParts { // like '_printer._sub._http._tcp.local' - subtype: ServiceType | string; // !!! ensure this name matches - type: ServiceType | string; // the main type - protocol?: Protocol; // default tcp - domain?: string; // default local + subtype: ServiceType | string // !!! ensure this name matches + type: ServiceType | string // the main type + protocol?: Protocol // default tcp + domain?: string // default local } function isSubTypePTRParts(parts: FQDNParts | SubTypePTRParts): parts is SubTypePTRParts { - return "subtype" in parts; + return 'subtype' in parts } export function parseFQDN(fqdn: string): PTRQueryDomain | InstanceNameDomain | SubTypedNameDomain { - const parts = fqdn.split("."); + const parts = fqdn.split('.') - assert(parts.length >= 3, "Received illegal fqdn: " + fqdn); + assert(parts.length >= 3, `Received illegal fqdn: ${fqdn}`) - let i = parts.length - 1; + let i = parts.length - 1 - let domain = ""; + let domain = '' while (!isProtocol(parts[i])) { - domain = removePrefixedUnderscore(parts[i]) + (domain? "." + domain: ""); - i--; + domain = removePrefixedUnderscore(parts[i]) + (domain ? `.${domain}` : '') + i-- } - assert(i >= 1, "Failed to parse illegal fqdn: " + fqdn); + assert(i >= 1, `Failed to parse illegal fqdn: ${fqdn}`) - const protocol = removePrefixedUnderscore(parts[i--]) as Protocol; - const type = removePrefixedUnderscore(parts[i--]); + const protocol = removePrefixedUnderscore(parts[i--]) as Protocol + const type = removePrefixedUnderscore(parts[i--]) if (i < 0) { return { - domain: domain, - protocol: protocol, - type: type, - }; + domain, + protocol, + type, + } } else if (isSub(parts[i])) { - i--; // skip "_sub"; - assert(i === 0, "Received illegal formatted sub type fqdn: " + fqdn); + i-- // skip "_sub"; + assert(i === 0, `Received illegal formatted sub type fqdn: ${fqdn}`) - const subtype = removePrefixedUnderscore(parts[i]); + const subtype = removePrefixedUnderscore(parts[i]) return { - domain: domain, - protocol: protocol, - type: type, - subtype: subtype, - }; + domain, + protocol, + type, + subtype, + } } else { // the name can contain dots as of RFC 6763 4.1.1. - const name = removePrefixedUnderscore(parts.slice(0, i + 1).join(".")); + const name = removePrefixedUnderscore(parts.slice(0, i + 1).join('.')) return { - domain: domain, - protocol: protocol, - type: type, - name: name, - }; + domain, + protocol, + type, + name, + } } } export function stringify(parts: FQDNParts | SubTypePTRParts): string { - assert(parts.type, "type cannot be undefined"); - assert(parts.type.length <= 15, "type must not be longer than 15 characters"); + assert(parts.type, 'type cannot be undefined') + assert(parts.type.length <= 15, 'type must not be longer than 15 characters') - let prefix; + let prefix if (isSubTypePTRParts(parts)) { - prefix = `_${parts.subtype}._sub.`; + prefix = `_${parts.subtype}._sub.` } else { - prefix = parts.name? `${parts.name}.`: ""; + prefix = parts.name ? `${parts.name}.` : '' } - return `${prefix}_${parts.type}._${parts.protocol || Protocol.TCP}.${parts.domain || "local"}.`; + return `${prefix}_${parts.type}._${parts.protocol || Protocol.TCP}.${parts.domain || 'local'}.` } -export function formatHostname(hostname: string, domain = "local"): string { - assert(!hostname.endsWith("."), "hostname must not end with the root label!"); - const tld = "." + domain; - return (!hostname.endsWith(tld)? hostname + tld: hostname) + "."; +export function formatHostname(hostname: string, domain = 'local'): string { + assert(!hostname.endsWith('.'), 'hostname must not end with the root label!') + const tld = `.${domain}` + return `${!hostname.endsWith(tld) ? hostname + tld : hostname}.` } export function removeTLD(hostname: string): string { - if (hostname.endsWith(".")) { // check for the DNS root label - hostname = hostname.substring(0, hostname.length - 1); + if (hostname.endsWith('.')) { // check for the DNS root label + hostname = hostname.substring(0, hostname.length - 1) } - const lastDot = hostname.lastIndexOf("."); - return hostname.slice(0, lastDot); + const lastDot = hostname.lastIndexOf('.') + return hostname.slice(0, lastDot) } export function formatMappedIPv4Address(address: string): string { if (!isIPv4Mapped(address)) { - assert(net.isIPv4(address), "Illegal argument. Must be an IPv4 address!"); + assert(net.isIPv4(address), 'Illegal argument. Must be an IPv4 address!') } - assert(net.isIPv4(address), "Illegal argument. Must be an IPv4 address!"); + assert(net.isIPv4(address), 'Illegal argument. Must be an IPv4 address!') // Convert IPv4 address to its hexadecimal representation - const hexParts = address.split(".").map(part => parseInt(part).toString(16).padStart(2, "0")); - const ipv6Part = `::ffff:${hexParts.join("")}`; + const hexParts = address.split('.').map(part => Number.parseInt(part).toString(16).padStart(2, '0')) + const ipv6Part = `::ffff:${hexParts.join('')}` // Convert the hexadecimal representation to the standard IPv6 format - return ipv6Part.replace(/(.{4})(.{4})$/, "$1:$2"); + return ipv6Part.replace(/(.{4})(.{4})$/, '$1:$2') } export function enlargeIPv6(address: string): string { - assert(net.isIPv6(address), "Illegal argument. Must be ipv6 address!"); + assert(net.isIPv6(address), 'Illegal argument. Must be ipv6 address!') + + const parts = address.split('::') - const parts = address.split("::"); - // Initialize head and tail arrays - const head = parts[0] ? parts[0].split(":") : []; - const tail = parts[1] ? parts[1].split(":") : []; - + const head = parts[0] ? parts[0].split(':') : [] + const tail = parts[1] ? parts[1].split(':') : [] + // Calculate the number of groups to fill in with "0000" when we expand - const fill = new Array(8 - head.length - tail.length).fill("0000"); - + const fill = Array.from({ length: 8 - head.length - tail.length }).fill('0000') + // Combine it all and normalize each hextet to be 4 characters long - return [...head, ...fill, ...tail].map(hextet => hextet.padStart(4, "0")).join(":"); + // @ts-expect-error - hextet is type unknown + return [...head, ...fill, ...tail].map(hextet => hextet.padStart(4, '0')).join(':') } export function shortenIPv6(address: string | string[]): string { - if (typeof address === "string") { - address = address.split(":"); + if (typeof address === 'string') { + address = address.split(':') } for (let i = 0; i < address.length; i++) { - const part = address[i]; + const part = address[i] - let j = 0; + let j = 0 for (; j < Math.min(3, part.length - 1); j++) { // search for the first index which is non-zero, but leaving at least one zero - if (part.charAt(j) !== "0") { - break; + if (part.charAt(j) !== '0') { + break } } - address[i] = part.substr(j); + address[i] = part.substr(j) } - let longestBlockOfZerosIndex = -1; - let longestBlockOfZerosLength = 0; + let longestBlockOfZerosIndex = -1 + let longestBlockOfZerosLength = 0 for (let i = 0; i < address.length; i++) { // this is not very optimized, but it works - if (address[i] !== "0") { - continue; + if (address[i] !== '0') { + continue } - let zerosCount = 1; - let j = i + 1; + let zerosCount = 1 + let j = i + 1 for (; j < address.length; j++) { - if (address[j] === "0") { - zerosCount++; + if (address[j] === '0') { + zerosCount++ } else { - break; + break } } if (zerosCount > longestBlockOfZerosLength) { - longestBlockOfZerosIndex = i; - longestBlockOfZerosLength = zerosCount; + longestBlockOfZerosIndex = i + longestBlockOfZerosLength = zerosCount } - i = j; // skip all the zeros we already checked + the one after that, we know that's not a zero + i = j // skip all the zeros we already checked + the one after that, we know that's not a zero } if (longestBlockOfZerosIndex !== -1) { - const startOrEnd = longestBlockOfZerosIndex === 0 || (longestBlockOfZerosIndex + longestBlockOfZerosLength === 8); - address[longestBlockOfZerosIndex] = startOrEnd? ":": ""; + const startOrEnd = longestBlockOfZerosIndex === 0 || (longestBlockOfZerosIndex + longestBlockOfZerosLength === 8) + address[longestBlockOfZerosIndex] = startOrEnd ? ':' : '' if (longestBlockOfZerosLength > 1) { - address.splice(longestBlockOfZerosIndex + 1, longestBlockOfZerosLength - 1); + address.splice(longestBlockOfZerosIndex + 1, longestBlockOfZerosLength - 1) } } - const result = address.join(":"); + const result = address.join(':') - if (result === ":") { // special case for the unspecified address - return "::"; + if (result === ':') { // special case for the unspecified address + return '::' } - return result; + return result } export function formatReverseAddressPTRName(address: string): string { if (net.isIPv4(address)) { - const split = address.split(".").reverse(); + const split = address.split('.').reverse() - return split.join(".") + ".in-addr.arpa"; - } + return `${split.join('.')}.in-addr.arpa` + } if (!net.isIPv6(address)) { - throw new Error("Supplied illegal ip address format: " + address); + throw new Error(`Supplied illegal ip address format: ${address}`) } if (isIPv4Mapped(address)) { - return (getIPFromV4Mapped(address) as string).split(".").reverse().join(".") + ".in-addr.arpa"; + return `${(getIPFromV4Mapped(address) as string).split('.').reverse().join('.')}.in-addr.arpa` } - address = enlargeIPv6(address).toUpperCase(); + address = enlargeIPv6(address).toUpperCase() - const nibbleSplit = address.replace(/:/g, "").split("").reverse(); - assert(nibbleSplit.length === 32, "Encountered invalid ipv6 address length! " + nibbleSplit.length); + const nibbleSplit = address.replace(/:/g, '').split('').reverse() + assert(nibbleSplit.length === 32, `Encountered invalid ipv6 address length! ${nibbleSplit.length}`) - return nibbleSplit.join(".") + ".ip6.arpa"; + return `${nibbleSplit.join('.')}.ip6.arpa` } export function ipAddressFromReversAddressName(name: string): string { - name = name.toLowerCase(); + name = name.toLowerCase() - if (name.endsWith(".in-addr.arpa")) { - const split = name.replace(".in-addr.arpa", "").split(".").reverse(); + if (name.endsWith('.in-addr.arpa')) { + const split = name.replace('.in-addr.arpa', '').split('.').reverse() - return split.join("."); - } else if (name.endsWith(".ip6.arpa")) { - const split = name.replace(".ip6.arpa", "").split(".").reverse(); - assert(split.length === 32, "Encountered illegal length for .ip6.arpa split!"); + return split.join('.') + } else if (name.endsWith('.ip6.arpa')) { + const split = name.replace('.ip6.arpa', '').split('.').reverse() + assert(split.length === 32, 'Encountered illegal length for .ip6.arpa split!') - const parts: string[] = []; + const parts: string[] = [] for (let i = 0; i < split.length; i += 4) { - parts.push(split.slice(i, i + 4).join("")); + parts.push(split.slice(i, i + 4).join('')) } - return shortenIPv6(parts.join(":")); + return shortenIPv6(parts.join(':')) } else { - throw new Error("Supplied unknown reverse address name format: " + name); + throw new Error(`Supplied unknown reverse address name format: ${name}`) } } export function getNetAddress(address: string, netmask: string): string { - assert(net.isIP(address) === net.isIP(netmask), "IP address version must match. Netmask cannot have a version different from the address!"); + assert(net.isIP(address) === net.isIP(netmask), 'IP address version must match. Netmask cannot have a version different from the address!') if (net.isIPv4(address)) { - const addressParts = address.split("."); - const netmaskParts = netmask.split("."); - const netAddressParts = new Array(4); + const addressParts = address.split('.') + const netmaskParts = netmask.split('.') + const netAddressParts = Array.from({ length: 4 }) for (let i = 0; i < addressParts.length; i++) { - const addressNum = parseInt(addressParts[i]); - const netmaskNum = parseInt(netmaskParts[i]); + const addressNum = Number.parseInt(addressParts[i]) + const netmaskNum = Number.parseInt(netmaskParts[i]) - netAddressParts[i] = (addressNum & netmaskNum).toString(); + netAddressParts[i] = (addressNum & netmaskNum).toString() } - return netAddressParts.join("."); + return netAddressParts.join('.') } else if (net.isIPv6(address)) { - const addressParts = enlargeIPv6(address).split(":"); - const netmaskParts = enlargeIPv6(netmask).split(":"); + const addressParts = enlargeIPv6(address).split(':') + const netmaskParts = enlargeIPv6(netmask).split(':') - const netAddressParts = new Array(8); + const netAddressParts = Array.from({ length: 8 }) for (let i = 0; i < addressParts.length; i++) { - const addressNum = parseInt(addressParts[i], 16); - const netmaskNum = parseInt(netmaskParts[i], 16); + const addressNum = Number.parseInt(addressParts[i], 16) + const netmaskNum = Number.parseInt(netmaskParts[i], 16) - netAddressParts[i] = (addressNum & netmaskNum).toString(16); + netAddressParts[i] = (addressNum & netmaskNum).toString(16) } - return shortenIPv6(enlargeIPv6(netAddressParts.join(":"))); + return shortenIPv6(enlargeIPv6(netAddressParts.join(':'))) } else { - throw new Error("Illegal argument. Address is not an ip address!"); + throw new Error('Illegal argument. Address is not an ip address!') } } diff --git a/src/util/errors.ts b/src/util/errors.ts index c2c8b26..230d7df 100644 --- a/src/util/errors.ts +++ b/src/util/errors.ts @@ -1,20 +1,16 @@ -export const ERR_INTERFACE_NOT_FOUND = "ERR_INTERFACE_NOT_FOUND"; -export const ERR_SERVER_CLOSED = "ERR_SERVER_CLOSED"; +export const ERR_INTERFACE_NOT_FOUND = 'ERR_INTERFACE_NOT_FOUND' +export const ERR_SERVER_CLOSED = 'ERR_SERVER_CLOSED' export class InterfaceNotFoundError extends Error { - constructor(message: string) { - super(message); - this.name = "ERR_INTERFACE_NOT_FOUND"; + super(message) + this.name = 'ERR_INTERFACE_NOT_FOUND' } - } export class ServerClosedError extends Error { - constructor(message: string) { - super(message); - this.name = ERR_SERVER_CLOSED; + super(message) + this.name = ERR_SERVER_CLOSED } - } diff --git a/src/util/promise-utils.ts b/src/util/promise-utils.ts index 086cfd6..52d4ecc 100644 --- a/src/util/promise-utils.ts +++ b/src/util/promise-utils.ts @@ -1,6 +1,5 @@ - export function PromiseTimeout(timeout: number): Promise { - return new Promise(resolve => { - setTimeout(() => resolve(), timeout); - }); + return new Promise((resolve) => { + setTimeout(() => resolve(), timeout) + }) } diff --git a/src/util/sorted-array.spec.ts b/src/util/sorted-array.spec.ts index 4d4e39a..db779e5 100644 --- a/src/util/sorted-array.spec.ts +++ b/src/util/sorted-array.spec.ts @@ -1,44 +1,46 @@ -import { sortedInsert } from "./sorted-array"; +import { describe, expect, it } from 'vitest' + +import { sortedInsert } from './sorted-array.js' const compareNum = function (a: number, b: number): number { - return a - b; -}; + return a - b +} -describe("sorted-array", () => { - it("should insert correctly", () => { - const array: number[] = [];//[1,5,7,9,15,18,20]; +describe('sorted-array', () => { + it('should insert correctly', () => { + const array: number[] = []// [1,5,7,9,15,18,20]; - sortedInsert(array, 7, compareNum); - expect(array).toEqual([7]); + sortedInsert(array, 7, compareNum) + expect(array).toEqual([7]) - sortedInsert(array, 5, compareNum); - expect(array).toEqual([5, 7]); + sortedInsert(array, 5, compareNum) + expect(array).toEqual([5, 7]) - sortedInsert(array, 1, compareNum); - expect(array).toEqual([1, 5, 7]); + sortedInsert(array, 1, compareNum) + expect(array).toEqual([1, 5, 7]) - sortedInsert(array, 18, compareNum); - expect(array).toEqual([1, 5, 7, 18]); + sortedInsert(array, 18, compareNum) + expect(array).toEqual([1, 5, 7, 18]) - sortedInsert(array, 9, compareNum); - expect(array).toEqual([1, 5, 7, 9, 18]); + sortedInsert(array, 9, compareNum) + expect(array).toEqual([1, 5, 7, 9, 18]) - sortedInsert(array, 15, compareNum); - expect(array).toEqual([1, 5, 7, 9, 15, 18]); + sortedInsert(array, 15, compareNum) + expect(array).toEqual([1, 5, 7, 9, 15, 18]) - sortedInsert(array, 20, compareNum); - expect(array).toEqual([1, 5, 7, 9, 15, 18, 20]); + sortedInsert(array, 20, compareNum) + expect(array).toEqual([1, 5, 7, 9, 15, 18, 20]) - sortedInsert(array, 20, compareNum); - expect(array).toEqual([1, 5, 7, 9, 15, 18, 20, 20]); + sortedInsert(array, 20, compareNum) + expect(array).toEqual([1, 5, 7, 9, 15, 18, 20, 20]) - sortedInsert(array, 1, compareNum); - expect(array).toEqual([1, 1, 5, 7, 9, 15, 18, 20, 20]); + sortedInsert(array, 1, compareNum) + expect(array).toEqual([1, 1, 5, 7, 9, 15, 18, 20, 20]) - sortedInsert(array, 9, compareNum); - expect(array).toEqual([1, 1, 5, 7, 9, 9, 15, 18, 20, 20]); + sortedInsert(array, 9, compareNum) + expect(array).toEqual([1, 1, 5, 7, 9, 9, 15, 18, 20, 20]) - sortedInsert(array, 10, compareNum); - expect(array).toEqual([1, 1, 5, 7, 9, 9, 10, 15, 18, 20, 20]); - }); -}); + sortedInsert(array, 10, compareNum) + expect(array).toEqual([1, 1, 5, 7, 9, 9, 10, 15, 18, 20, 20]) + }) +}) diff --git a/src/util/sorted-array.ts b/src/util/sorted-array.ts index e218cf3..84de05b 100644 --- a/src/util/sorted-array.ts +++ b/src/util/sorted-array.ts @@ -6,37 +6,37 @@ * @param comparator - Comparator to determine the order for the elements. */ export function sortedInsert(array: T[], element: T, comparator: (a: T, b: T) => number): void { - let low = 0; - let high = array.length - 1; + let low = 0 + let high = array.length - 1 - let destination = -1; // if it doesn't change, we insert at position 0 (array is empty) + let destination = -1 // if it doesn't change, we insert at position 0 (array is empty) while (low < high) { - const mid = Math.floor((low + high) / 2); - const comparison = comparator(element, array[mid]); + const mid = Math.floor((low + high) / 2) + const comparison = comparator(element, array[mid]) if (comparison === 0) { - destination = mid + 1; // we currently don't care in which order items are sorted which have the same "order key" - break; + destination = mid + 1 // we currently don't care in which order items are sorted which have the same "order key" + break } if (comparison < 0) { // meaning element < array[mid] - high = mid - 1; + high = mid - 1 } else { // meaning element > array[mid] - low = mid + 1; + low = mid + 1 } } if (array.length === 0) { - destination = 0; + destination = 0 } else if (destination < 0) { if (comparator(element, array[low]) > 0) { - destination = low + 1; + destination = low + 1 } else { - destination = low; + destination = low } } // abuse splice method to insert at destination - array.splice(destination, 0, element); + array.splice(destination, 0, element) } diff --git a/src/util/tiebreaking.spec.ts b/src/util/tiebreaking.spec.ts index 739fd99..9c24eda 100644 --- a/src/util/tiebreaking.spec.ts +++ b/src/util/tiebreaking.spec.ts @@ -1,65 +1,66 @@ -import { AAAARecord } from "../coder/records/AAAARecord"; -import { ARecord } from "../coder/records/ARecord"; -import { TXTRecord } from "../coder/records/TXTRecord"; -import { rrComparator, runTiebreaking, TiebreakingResult } from "./tiebreaking"; +import { Buffer } from 'node:buffer' -const printerHost = new ARecord("MyPrinter.local", "169.254.99.200"); -const printerOpponent = new ARecord("MyPrinter.local", "169.254.200.50"); -const differentClass = new ARecord("MyPrinter.local", "169.254.200.50"); -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-expect-error -// noinspection JSConstantReassignment -differentClass.class = 3; -const differentType = new AAAARecord("MyPrinter.local", "169:254:99:200:72:9015:6be6:f9e2"); -const shortData = new TXTRecord("MyPrinter.local", [Buffer.from("foo=bar")]); // TXT has higher numeric value than A -const longerData = new TXTRecord("MyPrinter.local", [Buffer.from("foo=bar"), Buffer.from("foo=baz")]); +import { describe, expect, it } from 'vitest' -describe("tiebreaking", () => { - describe(rrComparator, () => { +import '../coder/records/index.js' - it("should compare A records correctly", () => { - expect(rrComparator(printerHost, printerOpponent)).toBeLessThanOrEqual(-1); - expect(rrComparator(printerOpponent, printerHost)).toBeGreaterThanOrEqual(1); - }); +// eslint-disable-next-line perfectionist/sort-imports +import { AAAARecord } from '../coder/records/AAAARecord.js' +import { ARecord } from '../coder/records/ARecord.js' +import { TXTRecord } from '../coder/records/TXTRecord.js' +import { rrComparator, runTiebreaking, TiebreakingResult } from './tiebreaking.js' - it("should detect tie correctly", () => { - expect(rrComparator(printerHost, printerHost)).toBe(0); - expect(rrComparator(printerOpponent, printerOpponent)).toBe(0); - }); +const printerHost = new ARecord('MyPrinter.local', '169.254.99.200') +const printerOpponent = new ARecord('MyPrinter.local', '169.254.200.50') +const differentClass = new ARecord('MyPrinter.local', '169.254.200.50') +const differentType = new AAAARecord('MyPrinter.local', '169:254:99:200:72:9015:6be6:f9e2') +const shortData = new TXTRecord('MyPrinter.local', [Buffer.from('foo=bar')]) // TXT has higher numeric value than A +const longerData = new TXTRecord('MyPrinter.local', [Buffer.from('foo=bar'), Buffer.from('foo=baz')]) - it("should correctly decide on class", () => { - expect(rrComparator(printerHost, differentClass)).toBeLessThanOrEqual(-1); - expect(rrComparator(differentClass, printerHost)).toBeGreaterThanOrEqual(1); - }); +differentClass.class = 3 - it("should correctly decide on type", () => { - expect(rrComparator(printerHost, differentType)).toBeLessThanOrEqual(-1); - expect(rrComparator(differentType, printerHost)).toBeGreaterThanOrEqual(1); - }); +describe('tiebreaking', () => { + describe(rrComparator, () => { + it('should compare A records correctly', () => { + expect(rrComparator(printerHost, printerOpponent)).toBeLessThanOrEqual(-1) + expect(rrComparator(printerOpponent, printerHost)).toBeGreaterThanOrEqual(1) + }) - it("should correctly decide on data length", () => { - expect(rrComparator(shortData, longerData)).toBeLessThanOrEqual(-1); - expect(rrComparator(longerData, shortData)).toBeGreaterThanOrEqual(1); - }); + it('should detect tie correctly', () => { + expect(rrComparator(printerHost, printerHost)).toBe(0) + expect(rrComparator(printerOpponent, printerOpponent)).toBe(0) + }) - }); + it('should correctly decide on class', () => { + expect(rrComparator(printerHost, differentClass)).toBeLessThanOrEqual(-1) + expect(rrComparator(differentClass, printerHost)).toBeGreaterThanOrEqual(1) + }) - describe(runTiebreaking, () => { + it('should correctly decide on type', () => { + expect(rrComparator(printerHost, differentType)).toBeLessThanOrEqual(-1) + expect(rrComparator(differentType, printerHost)).toBeGreaterThanOrEqual(1) + }) - it("should decide on winner correctly", () => { - expect(runTiebreaking([printerHost], [printerOpponent])).toBe(TiebreakingResult.OPPONENT); - expect(runTiebreaking([printerOpponent], [printerHost])).toBe(TiebreakingResult.HOST); - }); + it('should correctly decide on data length', () => { + expect(rrComparator(shortData, longerData)).toBeLessThanOrEqual(-1) + expect(rrComparator(longerData, shortData)).toBeGreaterThanOrEqual(1) + }) + }) - it("should result in tie with same data", () => { - const data = [printerHost, printerOpponent, differentType, differentClass]; - expect(runTiebreaking(data, data)).toBe(TiebreakingResult.TIE); - }); + describe(runTiebreaking, () => { + it('should decide on winner correctly', () => { + expect(runTiebreaking([printerHost], [printerOpponent])).toBe(TiebreakingResult.OPPONENT) + expect(runTiebreaking([printerOpponent], [printerHost])).toBe(TiebreakingResult.HOST) + }) - it("should win with the same records but more data", () => { - expect(runTiebreaking([printerHost], [printerHost, differentType])).toBe(TiebreakingResult.OPPONENT); - expect(runTiebreaking([printerHost, differentType], [printerHost])).toBe(TiebreakingResult.HOST); - }); + it('should result in tie with same data', () => { + const data = [printerHost, printerOpponent, differentType, differentClass] + expect(runTiebreaking(data, data)).toBe(TiebreakingResult.TIE) + }) - }); -}); + it('should win with the same records but more data', () => { + expect(runTiebreaking([printerHost], [printerHost, differentType])).toBe(TiebreakingResult.OPPONENT) + expect(runTiebreaking([printerHost, differentType], [printerHost])).toBe(TiebreakingResult.HOST) + }) + }) +}) diff --git a/src/util/tiebreaking.ts b/src/util/tiebreaking.ts index 887607c..055a029 100644 --- a/src/util/tiebreaking.ts +++ b/src/util/tiebreaking.ts @@ -9,41 +9,42 @@ * @param recordB - record b * @returns -1 if record a < record b, 0 if record a == record b, 1 if record a > record b */ -import { ResourceRecord } from "../coder/ResourceRecord"; +import type { ResourceRecord } from '../coder/ResourceRecord' export function rrComparator(recordA: ResourceRecord, recordB: ResourceRecord): number { if (recordA.class !== recordB.class) { - return recordA.class - recordB.class; + return recordA.class - recordB.class } if (recordA.type !== recordB.type) { - return recordA.type - recordB.type; + return recordA.type - recordB.type } // now follows a raw comparison of the binary data - const aData = recordA.getRawData(); - const bData = recordB.getRawData(); - const maxLength = Math.max(aData.length, bData.length); // get the biggest length + const aData = recordA.getRawData() + const bData = recordB.getRawData() + const maxLength = Math.max(aData.length, bData.length) // get the biggest length for (let i = 0; i < maxLength; i++) { if (i >= aData.length && i < bData.length) { // a ran out of data and b still holds data - return -1; + return -1 } else if (i >= bData.length && i < aData.length) { // b ran out of data and a still hold data - return 1; + return 1 } - const aByte = aData.readUInt8(i); - const bByte = bData.readUInt8(i); + const aByte = aData.readUInt8(i) + const bByte = bData.readUInt8(i) if (aByte !== bByte) { - return aByte < bByte? -1: 1; + return aByte < bByte ? -1 : 1 } } // if we reach here we have a tie. both records represent the SAME data. - return 0; + return 0 } +// eslint-disable-next-line no-restricted-syntax export const enum TiebreakingResult { /** * The opponent is considered the winner @@ -68,20 +69,20 @@ export const enum TiebreakingResult { * @returns the result {@see TiebreakingResult} of the tiebreaking algorithm */ export function runTiebreaking(host: ResourceRecord[], opponent: ResourceRecord[]): TiebreakingResult { - const maxLength = Math.max(host.length, opponent.length); + const maxLength = Math.max(host.length, opponent.length) for (let i = 0; i < maxLength; i++) { if (i >= host.length && i < opponent.length) { // host runs out of records and opponent still has some - return TiebreakingResult.OPPONENT; // opponent wins + return TiebreakingResult.OPPONENT // opponent wins } else if (i >= opponent.length && i < host.length) { // opponent runs out of records and host still has some - return TiebreakingResult.HOST; // host wins + return TiebreakingResult.HOST // host wins } - const recordComparison = rrComparator(host[i], opponent[i]); + const recordComparison = rrComparator(host[i], opponent[i]) if (recordComparison !== 0) { - return recordComparison; + return recordComparison } } - return TiebreakingResult.TIE; // they expose the exact same data + return TiebreakingResult.TIE // they expose the exact same data } diff --git a/src/util/v4mapped.spec.ts b/src/util/v4mapped.spec.ts index 0a82111..f65d931 100644 --- a/src/util/v4mapped.spec.ts +++ b/src/util/v4mapped.spec.ts @@ -1,44 +1,45 @@ -import { isIPv4Mapped } from "./v4mapped"; +import { describe, expect, it } from 'vitest' -describe("isIPv4Mapped", () => { - test("should return true for valid IPv4-mapped IPv6 address", () => { - expect(isIPv4Mapped("::ffff:192.168.0.1")).toBe(true); +import { isIPv4Mapped } from './v4mapped.js' - // Test case-insensitivity - expect(isIPv4Mapped("::FFFF:127.0.0.1")).toBe(true); - }); - - test("should return false for non-IPv4-mapped IPv6 address", () => { - expect(isIPv4Mapped("2001:0db8:85a3:0000:0000:8a2e:0370:7334")).toBe(false); - expect(isIPv4Mapped("fe80::1ff:fe23:4567:890a")).toBe(false); - }); - - test("should return false for IPv4 address", () => { - expect(isIPv4Mapped("192.168.0.1")).toBe(false); - expect(isIPv4Mapped("127.0.0.1")).toBe(false); - }); - - test("should return false for invalid IPv4-mapped IPv6 address", () => { - expect(isIPv4Mapped("::ffff:999.999.999.999")).toBe(false); - expect(isIPv4Mapped("::ffff:192.168.0.256")).toBe(false); - expect(isIPv4Mapped("::ffff:192.168.0")).toBe(false); - expect(isIPv4Mapped("::ffff:192.168.0.1.1")).toBe(false); - }); - - test("should return false for malformed addresses", () => { - expect(isIPv4Mapped("::ffff:192.168.0.1g")).toBe(false); - expect(isIPv4Mapped("::ffff:192.168.0.")).toBe(false); - expect(isIPv4Mapped("::ffff:192.168..0.1")).toBe(false); - expect(isIPv4Mapped("::gggg:192.168.0.1")).toBe(false); - }); - - test("should return false for empty string", () => { - expect(isIPv4Mapped("")).toBe(false); - }); - - test("should return false for null or undefined", () => { - expect(isIPv4Mapped(null as unknown as string)).toBe(false); - expect(isIPv4Mapped(undefined as unknown as string)).toBe(false); - }); -}); +describe('isIPv4Mapped', () => { + it('should return true for valid IPv4-mapped IPv6 address', () => { + expect(isIPv4Mapped('::ffff:192.168.0.1')).toBe(true) + // Test case-insensitivity + expect(isIPv4Mapped('::FFFF:127.0.0.1')).toBe(true) + }) + + it('should return false for non-IPv4-mapped IPv6 address', () => { + expect(isIPv4Mapped('2001:0db8:85a3:0000:0000:8a2e:0370:7334')).toBe(false) + expect(isIPv4Mapped('fe80::1ff:fe23:4567:890a')).toBe(false) + }) + + it('should return false for IPv4 address', () => { + expect(isIPv4Mapped('192.168.0.1')).toBe(false) + expect(isIPv4Mapped('127.0.0.1')).toBe(false) + }) + + it('should return false for invalid IPv4-mapped IPv6 address', () => { + expect(isIPv4Mapped('::ffff:999.999.999.999')).toBe(false) + expect(isIPv4Mapped('::ffff:192.168.0.256')).toBe(false) + expect(isIPv4Mapped('::ffff:192.168.0')).toBe(false) + expect(isIPv4Mapped('::ffff:192.168.0.1.1')).toBe(false) + }) + + it('should return false for malformed addresses', () => { + expect(isIPv4Mapped('::ffff:192.168.0.1g')).toBe(false) + expect(isIPv4Mapped('::ffff:192.168.0.')).toBe(false) + expect(isIPv4Mapped('::ffff:192.168..0.1')).toBe(false) + expect(isIPv4Mapped('::gggg:192.168.0.1')).toBe(false) + }) + + it('should return false for empty string', () => { + expect(isIPv4Mapped('')).toBe(false) + }) + + it('should return false for null or undefined', () => { + expect(isIPv4Mapped(null as unknown as string)).toBe(false) + expect(isIPv4Mapped(undefined as unknown as string)).toBe(false) + }) +}) diff --git a/src/util/v4mapped.ts b/src/util/v4mapped.ts index 22d97ab..7a84423 100644 --- a/src/util/v4mapped.ts +++ b/src/util/v4mapped.ts @@ -5,18 +5,16 @@ * @returns true if it is an IPv4-mapped address, false otherwise. */ export function isIPv4Mapped(address: string): boolean { - if(!/^::ffff:(\d{1,3}\.){3}\d{1,3}$/i.test(address)) { - return false; + if (!/^::ffff:(?:\d{1,3}\.){3}\d{1,3}$/i.test(address)) { + return false } // Split the address apart into it's components and test for validity. - const parts = address.split(/::ffff:/i)[1]?.split(".").map(Number); - return parts?.length === 4 && parts.every(part => part >= 0 && part <= 255); + const parts = address.split(/::ffff:/i)[1]?.split('.').map(Number) + return parts?.length === 4 && parts.every(part => part >= 0 && part <= 255) } export function getIPFromV4Mapped(address: string): string | null { - // Split the address apart into it's components and test for validity. - return address.split(/^::ffff:/i)[1] ?? null; + return address.split(/^::ffff:/i)[1] ?? null } -