From 59ffb7bf065350b9de081de67b24a32717676525 Mon Sep 17 00:00:00 2001 From: mhh Date: Mon, 25 Mar 2024 17:06:02 +0100 Subject: [PATCH] Add DNS package --- package.json | 3 +- packages/dns/__tests__/domain.test.ts | 51 +++++ packages/dns/package.json | 21 +++ packages/dns/src/constants.ts | 5 + packages/dns/src/domain.ts | 256 ++++++++++++++++++++++++++ packages/dns/src/errors.ts | 6 + packages/dns/src/index.ts | 4 + packages/dns/src/types.ts | 5 + 8 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 packages/dns/__tests__/domain.test.ts create mode 100644 packages/dns/package.json create mode 100644 packages/dns/src/constants.ts create mode 100644 packages/dns/src/domain.ts create mode 100644 packages/dns/src/errors.ts create mode 100644 packages/dns/src/index.ts create mode 100644 packages/dns/src/types.ts diff --git a/package.json b/package.json index f52a39a6..12273922 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "packages/substrate", "packages/evm", "packages/superfluid", - "packages/client" + "packages/client", + "packages/dns" ] } diff --git a/packages/dns/__tests__/domain.test.ts b/packages/dns/__tests__/domain.test.ts new file mode 100644 index 00000000..9ccb41c1 --- /dev/null +++ b/packages/dns/__tests__/domain.test.ts @@ -0,0 +1,51 @@ +import { DomainValidator, hostnameFromUrl, TargetType } from '../src/index' + +describe('DomainValidator Tests', () => { + test('hostnameFromUrl', () => { + let hostname = hostnameFromUrl('https://aleph.im') + expect(hostname).toBe('aleph.im') + hostname = hostnameFromUrl('aleph.im') + expect(hostname).toBe('aleph.im') + }) + + test('query A record', async () => { + const alephdns = new DomainValidator() + const hostname = hostnameFromUrl('https://aleph.im') + const query = await alephdns.getIPv4Addresses(hostname) // Adjust method based on actual implementation + expect(query).not.toBeNull() + expect(query.length).toBeGreaterThan(0) + }) + + test('get IPv6 address', async () => { + const alephdns = new DomainValidator() + const url = 'https://aleph.im' + const hostname = hostnameFromUrl(url) + const ipv6Addresses = await alephdns.getIPv6Addresses(hostname) + expect(ipv6Addresses).not.toBeNull() + expect(ipv6Addresses.length).toBeGreaterThan(0) + expect(ipv6Addresses[0]).toContain(':') + }) + + test('DNSLink', async () => { + const alephdns = new DomainValidator() + const url = 'https://aleph.im' + const hostname = hostnameFromUrl(url) + const dnslink = await alephdns.getDnsLink(hostname) + expect(dnslink).not.toBeNull() + }) + + test('configured domain', async () => { + const alephdns = new DomainValidator() + const url = 'https://custom-domain-unit-test.aleph.sh' + const hostname = hostnameFromUrl(url) + const status = await alephdns.checkDomain(hostname, TargetType.IPFS, '0xfakeaddress') + expect(typeof status).toBe('object') + }) + + test('not configured domain', async () => { + const alephdns = new DomainValidator() + const url = 'https://not-configured-domain.aleph.sh' + const hostname = hostnameFromUrl(url) + await expect(alephdns.checkDomain(hostname, TargetType.IPFS, '0xfakeaddress')).rejects.toThrow() + }) +}) diff --git a/packages/dns/package.json b/packages/dns/package.json new file mode 100644 index 00000000..68c214b1 --- /dev/null +++ b/packages/dns/package.json @@ -0,0 +1,21 @@ +{ + "name": "@aleph-sdk/dns", + "version": "1.0.0-rc2", + "description": "", + "main": "dist/cjs/index.min.cjs", + "module": "dist/esm/index.min.mjs", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "rollup": "rollup -c ../../rollup.config.js", + "build": "npm run rollup" + }, + "author": "", + "license": "ISC" +} diff --git a/packages/dns/src/constants.ts b/packages/dns/src/constants.ts new file mode 100644 index 00000000..f8565b09 --- /dev/null +++ b/packages/dns/src/constants.ts @@ -0,0 +1,5 @@ +export const DNS_RESOLVERS = ['9.9.9.9', '1.1.1.1'] +export const DNS_IPFS_DOMAIN = 'ipfs.public.aleph.sh' +export const DNS_PROGRAM_DOMAIN = 'program.public.aleph.sh' +export const DNS_INSTANCE_DOMAIN = 'instance.public.aleph.sh' +export const DNS_STATIC_DOMAIN = 'static.public.aleph.sh' diff --git a/packages/dns/src/domain.ts b/packages/dns/src/domain.ts new file mode 100644 index 00000000..a099461c --- /dev/null +++ b/packages/dns/src/domain.ts @@ -0,0 +1,256 @@ +import { Resolver, resolveNs } from 'dns' +import { URL } from 'url' +import { DomainConfigurationError } from './errors' +import { DNS_RESOLVERS, DNS_STATIC_DOMAIN, DNS_PROGRAM_DOMAIN, DNS_INSTANCE_DOMAIN, DNS_IPFS_DOMAIN } from './constants' +import { TargetType } from './types' + +type Hostname = string + +class DNSRule { + constructor( + public name: string, + public dns: Record, + public info: string, + public on_error: string, + ) {} + + raiseError(status: Record): void { + throw new DomainConfigurationError(`${this.info}, ${this.on_error}, ${JSON.stringify(status)}`) + } +} + +export function hostnameFromUrl(url: string): Hostname { + if (!url.includes('://')) { + url = `https://${url}` + } + const parsed = new URL(url) + if (parsed.hostname) { + return parsed.hostname + } + throw new Error('Invalid URL') +} + +export class DomainValidator { + private resolver: Resolver = new Resolver() + + constructor(dnsServers = DNS_RESOLVERS) { + this.resolver.setServers(dnsServers) + } + + // This function can be problematic due to TypeScript's type system; might need adjustments + async getIPv4Addresses(hostname: Hostname): Promise { + return new Promise((resolve, reject) => { + this.resolver.resolve4(hostname, (err, addresses) => { + if (err) reject(err) + else resolve(addresses) + }) + }) + } + + // Similar to getIPv4Addresses, adjust for IPv6 + async getIPv6Addresses(hostname: Hostname): Promise { + return new Promise((resolve, reject) => { + this.resolver.resolve6(hostname, (err, addresses) => { + if (err) reject(err) + else resolve(addresses) + }) + }) + } + + async getDnsLinks(hostname: Hostname): Promise { + const txtRecords = await new Promise((resolve, reject) => { + this.resolver.resolveTxt(`_dnslink.${hostname}`, (err, records) => { + if (err) reject(err) + else resolve(records) + }) + }) + + return txtRecords.flat().filter((record) => record.startsWith('dnslink=')) + } + + async getDnsLink(hostname: Hostname): Promise { + const dnsLinks = await this.getDnsLinks(hostname) + return dnsLinks.length > 0 ? dnsLinks[0] : null + } + + async getTxtValues(hostname: Hostname, delimiter?: string): Promise { + const txtRecords = await new Promise((resolve, reject) => { + this.resolver.resolveTxt(hostname, (err, records) => { + if (err) reject(err) + else resolve(records) + }) + }) + + let values: string[] = txtRecords.flat() + if (delimiter) { + values = values.flatMap((record) => record.split(delimiter)) + } + + return values.filter((value) => value.startsWith('0x')) // Adjust filter condition as needed + } + + async getNameServers(hostname: Hostname): Promise { + let dnsServers = DNS_RESOLVERS + let fqdn = hostname + + // eslint-disable-next-line no-constant-condition + while (true) { + try { + const entries = await new Promise((resolve, reject) => { + resolveNs(fqdn, (err, addresses) => { + if (err) reject(err) + else resolve(addresses) + }) + }) + let servers: string[] = [] + for (const entry of entries) { + servers = servers.concat(await this.getIPv6Addresses(entry)) + servers = servers.concat(await this.getIPv4Addresses(entry)) + } + + dnsServers = servers + break + } catch (err) { + const subDomains = fqdn.split('.') + if (subDomains.length > 2) { + fqdn = subDomains.slice(1).join('.') + continue + } + + if (subDomains.length === 2) { + break + } + + console.debug(`Unexpected error: ${err}, ${typeof err}`) + break + } + } + + return dnsServers + } + + async getResolverFor(hostname: Hostname): Promise { + const dnsServers = await this.getNameServers(hostname) + const resolver = new Resolver() + resolver.setServers(dnsServers) + return resolver + } + + getRequiredDnsRules(hostname: Hostname, target: TargetType, owner?: string): DNSRule[] { + let cnameValue: string | null = null + if (target === TargetType.IPFS) { + cnameValue = DNS_IPFS_DOMAIN + } else if (target === TargetType.PROGRAM) { + cnameValue = `${hostname}.${DNS_PROGRAM_DOMAIN}` + } else if (target === TargetType.INSTANCE) { + cnameValue = `${hostname}.${DNS_INSTANCE_DOMAIN}` + } + + const dnsRules: DNSRule[] = [] + + if (cnameValue) { + dnsRules.push( + new DNSRule( + 'cname', + { + type: 'cname', + name: hostname, + value: cnameValue, + }, + `Create a CNAME record for ${hostname} with value ${cnameValue}`, + `CNAME record not found: ${hostname}`, + ), + ) + } + + if (target === TargetType.IPFS) { + dnsRules.push( + new DNSRule( + 'delegation', + { + type: 'cname', + name: `_dnslink.${hostname}`, + value: `_dnslink.${hostname}.${DNS_STATIC_DOMAIN}`, + }, + `Create a CNAME record for _dnslink.${hostname} with value _dnslink.${hostname}.${DNS_STATIC_DOMAIN}`, + `CNAME record not found: _dnslink.${hostname}`, + ), + ) + } + + if (owner) { + dnsRules.push( + new DNSRule( + 'owner_proof', + { + type: 'txt', + name: `_control.${hostname}`, + value: owner, + }, + `Create a TXT record for _control.${hostname} with value ${owner}`, + 'Owner address mismatch', + ), + ) + } + + return dnsRules + } + + async checkDomain(hostname: Hostname, target: TargetType, owner?: string): Promise> { + const status: Record = {} + + const dnsRules = this.getRequiredDnsRules(hostname, target, owner) + const resolver = await this.getResolverFor(hostname) // Ensure this method is correctly implemented to get a DNS resolver + for (const dnsRule of dnsRules) { + status[dnsRule.name] = false + + const recordName = dnsRule.dns['name'] + const recordType = dnsRule.dns['type'] + const recordValue = dnsRule.dns['value'] + + try { + let entries: string[] + switch (recordType) { + case 'txt': + entries = await new Promise((resolve, reject) => { + resolver.resolveTxt(recordName, (err, records) => { + if (err) reject(err) + else resolve(records.flat()) + }) + }) + break + case 'cname': + entries = await new Promise((resolve, reject) => { + resolver.resolveCname(recordName, (err, records) => { + if (err) reject(err) + else resolve(records) + }) + }) + break + // Add cases for other record types as needed + default: + entries = [] + break + } + + for (const entry of entries) { + const entryValue = Array.isArray(entry) ? entry.join('') : entry // TXT records are arrays + if (entryValue === recordValue) { + status[dnsRule.name] = true + break + } + } + } catch (error) { + console.error(`Failed to query DNS for ${recordName}: ${error}`) + // Continue checks despite errors + } + } + + const allTrue = Object.values(status).every((value) => value) + if (!allTrue) { + throw new DomainConfigurationError(`Domain configuration error for ${hostname}`) + } + + return status + } +} diff --git a/packages/dns/src/errors.ts b/packages/dns/src/errors.ts new file mode 100644 index 00000000..c638b245 --- /dev/null +++ b/packages/dns/src/errors.ts @@ -0,0 +1,6 @@ +export class DomainConfigurationError extends Error { + constructor(message: string) { + super(message) + this.name = 'DomainConfigurationError' + } +} diff --git a/packages/dns/src/index.ts b/packages/dns/src/index.ts new file mode 100644 index 00000000..cc7585f2 --- /dev/null +++ b/packages/dns/src/index.ts @@ -0,0 +1,4 @@ +export * from './domain' +export * from './constants' +export * from './errors' +export * from './types' diff --git a/packages/dns/src/types.ts b/packages/dns/src/types.ts new file mode 100644 index 00000000..77477664 --- /dev/null +++ b/packages/dns/src/types.ts @@ -0,0 +1,5 @@ +export enum TargetType { + IPFS = 'ipfs', + PROGRAM = 'program', + INSTANCE = 'instance', +} \ No newline at end of file