├── .github └── workflows │ └── test.yml ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── bin ├── cli.cjs ├── dnslink └── dnslink-js ├── index.mjs ├── package.json ├── test ├── integration-doh.cjs ├── integration-udp.cjs └── unit.mjs └── types ├── index.d.ts ├── test.ts └── tsconfig.json /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: push 4 | 5 | jobs: 6 | test: 7 | strategy: 8 | matrix: 9 | include: 10 | - id: windows:12 11 | os: windows-latest 12 | node: 12 13 | - id: macos:14 14 | os: macos-latest 15 | node: 14 16 | - id: ubuntu:16 17 | os: ubuntu-latest 18 | node: 16 19 | runs-on: ${{ matrix.os }} 20 | needs: [strict, browser] 21 | steps: 22 | - uses: actions/checkout@v3 23 | - uses: actions/setup-node@v3 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | - run: npm i 27 | - run: npm run test 28 | 29 | browser: 30 | runs-on: ubuntu-latest 31 | needs: strict 32 | steps: 33 | - uses: actions/checkout@v3 34 | - uses: actions/setup-node@v3 35 | with: 36 | node-version: 18 37 | - run: sudo apt-get install xvfb 38 | - run: npm i 39 | - run: xvfb-run --auto-servernum npm run browser 40 | 41 | strict: 42 | runs-on: ubuntu-latest 43 | steps: 44 | - uses: actions/checkout@v3 45 | - uses: actions/setup-node@v3 46 | with: 47 | node-version: lts/* 48 | - run: npm i 49 | - run: npm run lint 50 | - run: npm test 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | *.js 3 | *.tgz 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .github 2 | types/test.test 3 | types/tsconfig.json 4 | test/integration* 5 | *.tgz 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT OR Apache-2.0 2 | 3 | --- 4 | 5 | MIT License 6 | 7 | Copyright (c) 2021 Martin Heidegger 8 | 9 | Permission is hereby granted, free of charge, to any person obtaining a copy 10 | of this software and associated documentation files (the "Software"), to deal 11 | in the Software without restriction, including without limitation the rights 12 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | copies of the Software, and to permit persons to whom the Software is 14 | furnished to do so, subject to the following conditions: 15 | 16 | The above copyright notice and this permission notice shall be included in all 17 | copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | SOFTWARE. 26 | 27 | --- 28 | 29 | Apache License 30 | Version 2.0, January 2004 31 | http://www.apache.org/licenses/ 32 | 33 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 34 | 35 | 1. Definitions. 36 | 37 | "License" shall mean the terms and conditions for use, reproduction, 38 | and distribution as defined by Sections 1 through 9 of this document. 39 | 40 | "Licensor" shall mean the copyright owner or entity authorized by 41 | the copyright owner that is granting the License. 42 | 43 | "Legal Entity" shall mean the union of the acting entity and all 44 | other entities that control, are controlled by, or are under common 45 | control with that entity. For the purposes of this definition, 46 | "control" means (i) the power, direct or indirect, to cause the 47 | direction or management of such entity, whether by contract or 48 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 49 | outstanding shares, or (iii) beneficial ownership of such entity. 50 | 51 | "You" (or "Your") shall mean an individual or Legal Entity 52 | exercising permissions granted by this License. 53 | 54 | "Source" form shall mean the preferred form for making modifications, 55 | including but not limited to software source code, documentation 56 | source, and configuration files. 57 | 58 | "Object" form shall mean any form resulting from mechanical 59 | transformation or translation of a Source form, including but 60 | not limited to compiled object code, generated documentation, 61 | and conversions to other media types. 62 | 63 | "Work" shall mean the work of authorship, whether in Source or 64 | Object form, made available under the License, as indicated by a 65 | copyright notice that is included in or attached to the work 66 | (an example is provided in the Appendix below). 67 | 68 | "Derivative Works" shall mean any work, whether in Source or Object 69 | form, that is based on (or derived from) the Work and for which the 70 | editorial revisions, annotations, elaborations, or other modifications 71 | represent, as a whole, an original work of authorship. For the purposes 72 | of this License, Derivative Works shall not include works that remain 73 | separable from, or merely link (or bind by name) to the interfaces of, 74 | the Work and Derivative Works thereof. 75 | 76 | "Contribution" shall mean any work of authorship, including 77 | the original version of the Work and any modifications or additions 78 | to that Work or Derivative Works thereof, that is intentionally 79 | submitted to Licensor for inclusion in the Work by the copyright owner 80 | or by an individual or Legal Entity authorized to submit on behalf of 81 | the copyright owner. For the purposes of this definition, "submitted" 82 | means any form of electronic, verbal, or written communication sent 83 | to the Licensor or its representatives, including but not limited to 84 | communication on electronic mailing lists, source code control systems, 85 | and issue tracking systems that are managed by, or on behalf of, the 86 | Licensor for the purpose of discussing and improving the Work, but 87 | excluding communication that is conspicuously marked or otherwise 88 | designated in writing by the copyright owner as "Not a Contribution." 89 | 90 | "Contributor" shall mean Licensor and any individual or Legal Entity 91 | on behalf of whom a Contribution has been received by Licensor and 92 | subsequently incorporated within the Work. 93 | 94 | 2. Grant of Copyright License. Subject to the terms and conditions of 95 | this License, each Contributor hereby grants to You a perpetual, 96 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 97 | copyright license to reproduce, prepare Derivative Works of, 98 | publicly display, publicly perform, sublicense, and distribute the 99 | Work and such Derivative Works in Source or Object form. 100 | 101 | 3. Grant of Patent License. Subject to the terms and conditions of 102 | this License, each Contributor hereby grants to You a perpetual, 103 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 104 | (except as stated in this section) patent license to make, have made, 105 | use, offer to sell, sell, import, and otherwise transfer the Work, 106 | where such license applies only to those patent claims licensable 107 | by such Contributor that are necessarily infringed by their 108 | Contribution(s) alone or by combination of their Contribution(s) 109 | with the Work to which such Contribution(s) was submitted. If You 110 | institute patent litigation against any entity (including a 111 | cross-claim or counterclaim in a lawsuit) alleging that the Work 112 | or a Contribution incorporated within the Work constitutes direct 113 | or contributory patent infringement, then any patent licenses 114 | granted to You under this License for that Work shall terminate 115 | as of the date such litigation is filed. 116 | 117 | 4. Redistribution. You may reproduce and distribute copies of the 118 | Work or Derivative Works thereof in any medium, with or without 119 | modifications, and in Source or Object form, provided that You 120 | meet the following conditions: 121 | 122 | (a) You must give any other recipients of the Work or 123 | Derivative Works a copy of this License; and 124 | 125 | (b) You must cause any modified files to carry prominent notices 126 | stating that You changed the files; and 127 | 128 | (c) You must retain, in the Source form of any Derivative Works 129 | that You distribute, all copyright, patent, trademark, and 130 | attribution notices from the Source form of the Work, 131 | excluding those notices that do not pertain to any part of 132 | the Derivative Works; and 133 | 134 | (d) If the Work includes a "NOTICE" text file as part of its 135 | distribution, then any Derivative Works that You distribute must 136 | include a readable copy of the attribution notices contained 137 | within such NOTICE file, excluding those notices that do not 138 | pertain to any part of the Derivative Works, in at least one 139 | of the following places: within a NOTICE text file distributed 140 | as part of the Derivative Works; within the Source form or 141 | documentation, if provided along with the Derivative Works; or, 142 | within a display generated by the Derivative Works, if and 143 | wherever such third-party notices normally appear. The contents 144 | of the NOTICE file are for informational purposes only and 145 | do not modify the License. You may add Your own attribution 146 | notices within Derivative Works that You distribute, alongside 147 | or as an addendum to the NOTICE text from the Work, provided 148 | that such additional attribution notices cannot be construed 149 | as modifying the License. 150 | 151 | You may add Your own copyright statement to Your modifications and 152 | may provide additional or different license terms and conditions 153 | for use, reproduction, or distribution of Your modifications, or 154 | for any such Derivative Works as a whole, provided Your use, 155 | reproduction, and distribution of the Work otherwise complies with 156 | the conditions stated in this License. 157 | 158 | 5. Submission of Contributions. Unless You explicitly state otherwise, 159 | any Contribution intentionally submitted for inclusion in the Work 160 | by You to the Licensor shall be under the terms and conditions of 161 | this License, without any additional terms or conditions. 162 | Notwithstanding the above, nothing herein shall supersede or modify 163 | the terms of any separate license agreement you may have executed 164 | with Licensor regarding such Contributions. 165 | 166 | 6. Trademarks. This License does not grant permission to use the trade 167 | names, trademarks, service marks, or product names of the Licensor, 168 | except as required for reasonable and customary use in describing the 169 | origin of the Work and reproducing the content of the NOTICE file. 170 | 171 | 7. Disclaimer of Warranty. Unless required by applicable law or 172 | agreed to in writing, Licensor provides the Work (and each 173 | Contributor provides its Contributions) on an "AS IS" BASIS, 174 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 175 | implied, including, without limitation, any warranties or conditions 176 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 177 | PARTICULAR PURPOSE. You are solely responsible for determining the 178 | appropriateness of using or redistributing the Work and assume any 179 | risks associated with Your exercise of permissions under this License. 180 | 181 | 8. Limitation of Liability. In no event and under no legal theory, 182 | whether in tort (including negligence), contract, or otherwise, 183 | unless required by applicable law (such as deliberate and grossly 184 | negligent acts) or agreed to in writing, shall any Contributor be 185 | liable to You for damages, including any direct, indirect, special, 186 | incidental, or consequential damages of any character arising as a 187 | result of this License or out of the use or inability to use the 188 | Work (including but not limited to damages for loss of goodwill, 189 | work stoppage, computer failure or malfunction, or any and all 190 | other commercial damages or losses), even if such Contributor 191 | has been advised of the possibility of such damages. 192 | 193 | 9. Accepting Warranty or Additional Liability. While redistributing 194 | the Work or Derivative Works thereof, You may choose to offer, 195 | and charge a fee for, acceptance of support, warranty, indemnity, 196 | or other liability obligations and/or rights consistent with this 197 | License. However, in accepting such obligations, You may act only 198 | on Your own behalf and on Your sole responsibility, not on behalf 199 | of any other Contributor, and only if You agree to indemnify, 200 | defend, and hold each Contributor harmless for any liability 201 | incurred by, or claims asserted against, such Contributor by reason 202 | of your accepting any such warranty or additional liability. 203 | 204 | END OF TERMS AND CONDITIONS 205 | 206 | APPENDIX: How to apply the Apache License to your work. 207 | 208 | To apply the Apache License to your work, attach the following 209 | boilerplate notice, with the fields enclosed by brackets "[]" 210 | replaced with your own identifying information. (Don't include 211 | the brackets!) The text should be enclosed in the appropriate 212 | comment syntax for the file format. We also recommend that a 213 | file or class name and description of purpose be included on the 214 | same "printed page" as the copyright notice for easier 215 | identification within third-party archives. 216 | 217 | Copyright 2021 Martin Heidegger 218 | 219 | Licensed under the Apache License, Version 2.0 (the "License"); 220 | you may not use this file except in compliance with the License. 221 | You may obtain a copy of the License at 222 | 223 | http://www.apache.org/licenses/LICENSE-2.0 224 | 225 | Unless required by applicable law or agreed to in writing, software 226 | distributed under the License is distributed on an "AS IS" BASIS, 227 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 228 | See the License for the specific language governing permissions and 229 | limitations under the License. 230 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @dnslink/js 2 | 3 | The reference implementation for DNSLink resolver in JavaScript. Tested in Node.js and in the Browser. 4 | 5 | ## Usage 6 | 7 | You can use `dnslink` both as a [CLI tool](#command-line) or a [library](#javascript-api). 8 | 9 | ## JavaScript API 10 | 11 | Getting started with DNSLink resolution in a jiffy: 12 | 13 | ```javascript 14 | import { resolve, DNSRcodeError } from '@dnslink/js' 15 | 16 | // assumes top-level await 17 | let result 18 | try { 19 | result = await resolve('dnslink.dev/abcd?foo=bar', { 20 | endpoints: ['dns.google'], // required! see more below. 21 | /* (optional) */ 22 | signal, // AbortSignal that you can use to abort the request 23 | timeout: 1000, // timeout for the operation 24 | retries: 3 // retries in case of transport error 25 | }) 26 | } catch (err) { 27 | // Errors provided by DNS server 28 | if (err instanceof DNSRcodeError) { 29 | err.rcode // Error code number following - https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dns-parameters-6 30 | err.error // Error code name following (same list) 31 | err.code // `RCODE_${err.code} 32 | err.domain // Domain lookup that resulted in the error 33 | if (err.rcode === 3) { 34 | // NXDomain = Domain not found; most relevant error 35 | } 36 | } else { 37 | // A variety other errors may be thrown as well. Possible causes include, but are not limited to: 38 | // - Invalid input 39 | // - Timeouts / aborts 40 | // - Networking errors 41 | // - Incompatible dns packets provided by server 42 | } 43 | } 44 | const { links, log, txtEntries } = result 45 | 46 | // `links` is an object containing given links for the different namespaces 47 | // Each names contains an identifier and a ttl. 48 | links.ipfs === [{ identifier: 'QmTg....yomU', ttl: 60 }] 49 | 50 | // The `log` is always an Array and contains a list of log entries 51 | // that were should help to trace back how the linked data was resolved. 52 | Array.isArray(log) 53 | 54 | // The `txtEntries` are a reduced form of the links that contains the namespace 55 | // as part of the value 56 | txtEntries === [{ value: '/ipfs/QmTg....yomU', ttl: 60 }] 57 | ``` 58 | 59 | ### Endpoints 60 | 61 | You **need** to specify endpoints to be used with the API. You can specify them the same way 62 | as you would in [`dns-query`](https://github.com/martinheidegger/dns-query#endpoints). 63 | 64 | ## Possible log statements 65 | 66 | The log statements follow the [DNSLink specification][log-codes]. 67 | 68 | [log-codes]: https://github.com/dnslink-std/test/blob/main/LOG_CODES.md 69 | 70 | ## Command Line 71 | 72 | To use `dnslink` in the command line you will need Node.js installed. 73 | 74 | Install it permanently using `npm i -g @dnslink/js` or run in on-the-fly 75 | using `npx @dnslink/js`. 76 | 77 | You can get detailed help for the app by passing a `--help` option at the end: 78 | 79 | ``` 80 | $ npx @dnslink/js --help 81 | ``` 82 | 83 | ## License 84 | 85 | Published under dual-license: [MIT OR Apache-2.0](./LICENSE) 86 | -------------------------------------------------------------------------------- /bin/cli.cjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { AbortController } = require('abort-controller') 3 | const { resolve } = require('../index.js') 4 | const { version } = require('../package.json') 5 | const dns = require('dns') 6 | 7 | const json = input => JSON.stringify(input) 8 | 9 | const outputs = { 10 | json: class JSON { 11 | constructor (options) { 12 | this.options = options 13 | this.firstOut = true 14 | this.firstErr = true 15 | const { debug, out, err, domains } = options 16 | if (domains.length > 1) { 17 | out.write('[\n') 18 | } 19 | if (debug) { 20 | err.write('[\n') 21 | } 22 | } 23 | 24 | write (lookup, result) { 25 | const { debug, out, err, domains } = this.options 26 | if (this.firstOut) { 27 | this.firstOut = false 28 | } else { 29 | out.write('\n,') 30 | } 31 | if (!this.options.ttl) { 32 | result.txtEntries = result.txtEntries.map(link => link.value) 33 | for (const ns in result.links) { 34 | result.links[ns] = result.links[ns].map(link => link.identifier) 35 | } 36 | } 37 | const outLine = domains.length > 1 38 | ? Object.assign({ lookup }, result) 39 | : Object.assign({}, result) 40 | delete outLine.log 41 | out.write(json(outLine)) 42 | if (debug) { 43 | for (const statement of result.log) { 44 | let prefix = '' 45 | if (this.firstErr) { 46 | this.firstErr = false 47 | } else { 48 | prefix = '\n,' 49 | } 50 | const errLine = domains.length > 1 51 | ? Object.assign({ lookup }, statement) 52 | : statement 53 | err.write(prefix + json(errLine)) 54 | } 55 | } 56 | } 57 | 58 | end () { 59 | const { debug, out, err, domains } = this.options 60 | if (domains.length > 1) { 61 | out.write('\n]') 62 | } 63 | if (debug) { 64 | err.write('\n]') 65 | } 66 | } 67 | }, 68 | text: class Text { 69 | constructor (options) { 70 | this.options = options 71 | } 72 | 73 | write (domain, { links, log }) { 74 | const { debug, out, err, ns: searchNS, domains, first: firstNS } = this.options 75 | const prefix = domains.length > 1 ? `${domain}: ` : '' 76 | for (const ns in links) { 77 | for (let { identifier: id, ttl } of links[ns]) { 78 | if (!searchNS) { 79 | id = `/${ns}/${id}` 80 | } else if (ns !== searchNS) { 81 | continue 82 | } 83 | if (this.options.ttl) { 84 | id += `\t[ttl=${ttl}]` 85 | } 86 | out.write(`${prefix}${id}\n`) 87 | if (firstNS) { 88 | break 89 | } 90 | } 91 | } 92 | if (debug) { 93 | for (const logEntry of log) { 94 | err.write(`[${logEntry.code}] ${logEntry.entry ? ` entry=${logEntry.entry}` : ''}${logEntry.reason ? ` (${logEntry.reason})` : ''}\n`) 95 | } 96 | } 97 | } 98 | 99 | end () {} 100 | }, 101 | csv: class CSV { 102 | constructor (options) { 103 | this.options = options 104 | this.firstOut = true 105 | this.firstErr = true 106 | } 107 | 108 | write (lookup, { links, log }) { 109 | const { debug, out, err, ns: searchNS, first: firstNS } = this.options 110 | if (this.firstOut) { 111 | this.firstOut = false 112 | out.write(`lookup,namespace,identifier${this.options.ttl ? ',ttl' : ''}\n`) 113 | } 114 | 115 | for (const ns in links) { 116 | if (searchNS && ns !== searchNS) { 117 | continue 118 | } 119 | for (const { identifier: id, ttl } of links[ns]) { 120 | out.write(`${csv(lookup)},${csv(ns)},${csv(id)}${this.options.ttl ? `,${csv(ttl)}` : ''}\n`) 121 | } 122 | if (firstNS) { 123 | break 124 | } 125 | } 126 | if (debug) { 127 | for (const logEntry of log) { 128 | if (this.firstErr) { 129 | this.firstErr = false 130 | err.write('domain,code,entry,reason\n') 131 | } 132 | err.write(`${csv(logEntry.domain)},${csv(logEntry.code)},${csv(logEntry.entry)},${csv(logEntry.reason)}\n`) 133 | } 134 | } 135 | } 136 | 137 | end () {} 138 | } 139 | } 140 | 141 | function safeStream (stream, controller) { 142 | stream.on('error', () => controller.abort()) 143 | return { 144 | write (data) { 145 | if (stream.closed || stream.destroyed || stream.errored) return 146 | stream.write(data) 147 | } 148 | } 149 | } 150 | 151 | module.exports = (command) => { 152 | ;(async function main () { 153 | const controller = new AbortController() 154 | const { signal } = controller 155 | process.on('SIGINT', onSigint) 156 | try { 157 | const { options, rest: domains } = getOptions(process.argv.slice(2)) 158 | if (options.help || options.h) { 159 | showHelp(command) 160 | return 0 161 | } 162 | if (options.v || options.version) { 163 | showVersion() 164 | return 0 165 | } 166 | if (domains.length === 0) { 167 | showHelp(command) 168 | return 1 169 | } 170 | const format = firstEntry(options.format) || firstEntry(options.f) || 'text' 171 | const OutputClass = outputs[format] 172 | if (!OutputClass) { 173 | throw new Error(`Unexpected format ${format}`) 174 | } 175 | const first = firstEntry(options.first) 176 | const ns = first || firstEntry(options.ns) || firstEntry(options.n) 177 | const out = safeStream(process.stdout, controller) 178 | const err = safeStream(process.stderr, controller) 179 | const output = new OutputClass({ 180 | first, 181 | ns, 182 | ttl: !!(options.ttl), 183 | debug: !!(options.debug || options.d), 184 | domains, 185 | out, 186 | err 187 | }) 188 | let endpoints = (options.endpoint || []).concat(options.e || []).filter(endpoint => endpoint !== true) 189 | if (endpoints.length === 0) { 190 | endpoints = dns.getServers().map(ip => `udp://${ip}`) 191 | } 192 | await Promise.all(domains.map(async (domain) => { 193 | output.write(domain, await resolve(domain, { 194 | endpoints, 195 | signal 196 | })) 197 | })) 198 | output.end() 199 | } finally { 200 | process.off('SIGINT', onSigint) 201 | } 202 | 203 | function onSigint () { 204 | controller.abort() 205 | } 206 | })() 207 | .then( 208 | code => process.exit(code), 209 | err => { 210 | console.error((err && (err.stack || err.message)) || err) 211 | process.exit(1) 212 | } 213 | ) 214 | } 215 | 216 | function showHelp (command) { 217 | console.log(`${command} - resolve dns links in TXT records 218 | 219 | USAGE 220 | ${command} [--help] [--format=json|text|csv] [--debug] \\ 221 | [--ns=] [--first=] [--endpoint[=]] \\ 222 | [...] 223 | 224 | EXAMPLE 225 | # Receive the dnslink entries for the dnslink.io domain. 226 | > ${command} dnslink.dev 227 | /ipfs/QmXNosdfz3WQUHncsYBTw7diwYzCibVhrJmEhNNaMPQBQF 228 | 229 | # Receive only namespace "ipfs" entries as text for dnslink.io. 230 | > ${command} --ns=ipfs dnslink.dev 231 | QmXNosdfz3WQUHncsYBTw7diwYzCibVhrJmEhNNaMPQBQF 232 | 233 | # Receive only the first ipfs entry for the "ipfs" namespace. 234 | > ${command} --first=ipfs dnslink.dev 235 | QmXNosdfz3WQUHncsYBTw7diwYzCibVhrJmEhNNaMPQBQF 236 | 237 | # Getting information about the --ttl as received from the server. 238 | > ${command} --ttl dnslink.dev 239 | /ipfs/QmXNosdfz3WQUHncsYBTw7diwYzCibVhrJmEhNNaMPQBQF [ttl=53] 240 | 241 | 242 | # Receive all dnslink entries for multiple domains as csv. 243 | > ${command} --format=csv dnslink.dev ipfs.io 244 | lookup,namespace,identifier 245 | "ipfs.io","ipns","website.ipfs.io" 246 | "dnslink.dev","ipfs","QmXNosdfz3WQUHncsYBTw7diwYzCibVhrJmEhNNaMPQBQF" 247 | 248 | # Receive ipfs entries for multiple domains as json. 249 | > ${command} --format=json dnslink.dev ipfs.io 250 | [ 251 | {"lookup":"ipfs.io","txtEntries":["/ipns/website.ipfs.io"],"links":{"ipns":["website.ipfs.io"]}} 252 | ,{"lookup":"dnslink.dev","txtEntries":["/ipfs/QmXNosdfz3WQUHncsYBTw7diwYzCibVhrJmEhNNaMPQBQF"],"links":{"ipfs":["QmXNosdfz3WQUHncsYBTw7diwYzCibVhrJmEhNNaMPQBQF"]}} 253 | ] 254 | 255 | # Receive both the result and log as csv and redirect each to files. 256 | > ${command} --format=csv --debug dnslink.io \\ 257 | >dnslink-io.csv \\ 258 | 2>dnslink-io.log.csv 259 | 260 | OPTIONS 261 | --help, -h Show this help. 262 | --version, -v Show the version of this command. 263 | --format, -f Output format json, text or csv (default=text) 264 | --ttl Include ttl in output (any format) 265 | --endpoint=, Specify a dns or doh server to use. If more than 266 | -e= one endpoint is specified it will use one of the 267 | specified at random. More about specifying 268 | servers in the dns-query docs: [1] 269 | --debug, -d Render log output to stderr in the specified format. 270 | --ns, -n Only render one particular DNSLink namespace. 271 | --first Only render the first of the defined DNSLink namespace. 272 | 273 | [1]: https://github.com/martinheidegger/dns-query#string-endpoints 274 | 275 | NOTE 276 | If you don't specify any endpoints, te systems DNS server will be used. 277 | 278 | Read more about DNSLink at https://dnslink.dev. 279 | 280 | dnslink-js@${version}`) 281 | } 282 | 283 | function firstEntry (maybeArray) { 284 | if (maybeArray) { 285 | return maybeArray[0] 286 | } 287 | } 288 | 289 | function getOptions (args) { 290 | const options = {} 291 | const addOption = (name, value) => { 292 | if (options[name] === undefined) { 293 | options[name] = [value] 294 | } else { 295 | options[name].push(value) 296 | } 297 | } 298 | const rest = [] 299 | for (const arg of args) { 300 | const parts = /^--?([^=]+)(=(.*))?/.exec(arg) 301 | if (parts) { 302 | const key = parts[1] 303 | const value = parts[3] 304 | addOption(key, value || true) 305 | continue 306 | } 307 | rest.push(arg) 308 | } 309 | return { options, rest } 310 | } 311 | 312 | function csv (entry) { 313 | if (entry === null || entry === undefined || entry === '') { 314 | return '' 315 | } 316 | if (Array.isArray(entry)) { 317 | entry = entry.join(' - ') 318 | } 319 | if (entry === true || entry === false || typeof entry === 'number') { 320 | return entry 321 | } 322 | return `"${entry.toString().replace(/"/g, '""')}"` 323 | } 324 | 325 | function showVersion () { 326 | console.log(version) 327 | } 328 | -------------------------------------------------------------------------------- /bin/dnslink: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('./cli.cjs')('dnslink') 3 | -------------------------------------------------------------------------------- /bin/dnslink-js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // DNSlink-js exists to make sure that other implementations (e.g. go) may be used the command 3 | // line alias "dnslink" in a command line, while dnslink-js remains available. 4 | require('./cli.cjs')('dnslink-js') 5 | -------------------------------------------------------------------------------- /index.mjs: -------------------------------------------------------------------------------- 1 | import { lookupTxt, AbortError } from 'dns-query' 2 | 3 | export { DNSRcodeError, AbortError } from 'dns-query' 4 | 5 | export const DNS_PREFIX = '_dnslink.' 6 | export const TXT_PREFIX = 'dnslink=' 7 | export const LogCode = Object.freeze({ 8 | fallback: 'FALLBACK', 9 | invalidEntry: 'INVALID_ENTRY' 10 | }) 11 | export const EntryReason = Object.freeze({ 12 | wrongStart: 'WRONG_START', 13 | namespaceMissing: 'NAMESPACE_MISSING', 14 | noIdentifier: 'NO_IDENTIFIER', 15 | invalidCharacter: 'INVALID_CHARACTER' 16 | }) 17 | export const FQDNReason = Object.freeze({ 18 | emptyPart: 'EMPTY_PART', 19 | tooLong: 'TOO_LONG' 20 | }) 21 | export const CODE_MEANING = Object.freeze({ 22 | [LogCode.fallback]: 'Falling back to domain without _dnslink prefix.', 23 | [LogCode.invalidEntry]: 'Entry misformatted, cant be used.', 24 | [EntryReason.wrongStart]: 'A DNSLink entry needs to start with a /.', 25 | [EntryReason.namespaceMissing]: 'A DNSLink entry needs to have a namespace, like: dnslink=/namespace/...', 26 | [EntryReason.noIdentifier]: 'A DNSLink entry needs to have an identifier, like: dnslink=/namespace/identifier', 27 | [EntryReason.invalidCharacter]: 'A DNSLink entry may only contain ascii characters.', 28 | [FQDNReason.emptyPart]: 'A FQDN may not contain empty parts.', 29 | [FQDNReason.tooLong]: 'A FQDN may be max 253 characters which each subdomain not exceeding 63 characters.' 30 | }) 31 | 32 | export function resolve (domain, options = {}) { 33 | return _resolve(domain, options) 34 | } 35 | 36 | function bubbleAbort (signal) { 37 | if (signal !== undefined && signal !== null && signal.aborted) { 38 | throw new AbortError() 39 | } 40 | } 41 | 42 | async function _resolve (domain, options) { 43 | domain = validateDomain(domain) 44 | let fallbackResult = null 45 | let useFallback = false 46 | const defaultResolve = lookupTxt(`${DNS_PREFIX}${domain}`, options) 47 | const fallbackResolve = lookupTxt(domain, options).then( 48 | result => { fallbackResult = { result } }, 49 | error => { fallbackResult = { error } } 50 | ) 51 | let data 52 | try { 53 | data = await defaultResolve 54 | } catch (err) { 55 | if (err.rcode !== 3) { 56 | throw err 57 | } 58 | } 59 | if (data === undefined) { // Could be undefined if an error occured 60 | bubbleAbort(options.signal) 61 | await fallbackResolve 62 | if (fallbackResult.error) { 63 | throw fallbackResult.error 64 | } 65 | useFallback = true 66 | data = fallbackResult.result 67 | } 68 | const result = processEntries(data.entries) 69 | if (useFallback) { 70 | result.log.unshift({ code: LogCode.fallback }) 71 | } 72 | return result 73 | } 74 | 75 | function validateDomain (domain) { 76 | if (domain.endsWith('.')) { 77 | domain = domain.substr(0, domain.length - 1) 78 | } 79 | if (domain.startsWith(DNS_PREFIX)) { 80 | domain = domain.substr(DNS_PREFIX.length) 81 | } 82 | const domainError = testFqdn(domain) 83 | if (domainError !== undefined) { 84 | throw Object.assign(new Error(`Invalid input domain: ${domain}`), { code: 'INVALID_DOMAIN', reason: domainError, domain }) 85 | } 86 | return domain 87 | } 88 | 89 | function testFqdn (domain) { 90 | // https://en.wikipedia.org/wiki/Domain_name#Domain_name_syntax 91 | if (domain.length > 253 - 9 /* '_dnslink.'.length */) { 92 | // > The full domain name may not exceed a total length of 253 ASCII characters in its textual representation. 93 | return FQDNReason.tooLong 94 | } 95 | 96 | for (const label of domain.split('.')) { 97 | if (label.length === 0) { 98 | return FQDNReason.emptyPart 99 | } 100 | if (label.length > 63) { 101 | return FQDNReason.tooLong 102 | } 103 | } 104 | } 105 | 106 | function processEntries (input) { 107 | const links = {} 108 | const log = [] 109 | for (const entry of input.filter(entry => entry.data.startsWith(TXT_PREFIX))) { 110 | const { error, parsed } = validateDNSLinkEntry(entry.data) 111 | if (error !== undefined) { 112 | log.push({ code: LogCode.invalidEntry, entry: entry.data, reason: error }) 113 | continue 114 | } 115 | const { namespace, identifier } = parsed 116 | const linksByNS = links[namespace] 117 | const link = { identifier, ttl: entry.ttl } 118 | if (linksByNS === undefined) { 119 | links[namespace] = [link] 120 | } else { 121 | linksByNS.push(link) 122 | } 123 | } 124 | const txtEntries = [] 125 | for (const ns of Object.keys(links).sort()) { 126 | const linksByNS = links[ns].sort(sortByID) 127 | for (const { identifier, ttl } of linksByNS) { 128 | txtEntries.push({ value: `/${ns}/${identifier}`, ttl }) 129 | } 130 | links[ns] = linksByNS 131 | } 132 | return { txtEntries, links, log } 133 | } 134 | 135 | function sortByID (a, b) { 136 | if (a.identifier < b.identifier) return -1 137 | if (a.identifier > b.identifier) return 1 138 | return 0 139 | } 140 | 141 | function validateDNSLinkEntry (entry) { 142 | entry = entry.substr(TXT_PREFIX.length) 143 | if (!entry.startsWith('/')) { 144 | return { error: EntryReason.wrongStart } 145 | } 146 | // https://datatracker.ietf.org/doc/html/rfc4343#section-2.1 147 | if (!/^[\u0020-\u007e]*$/.test(entry)) { 148 | return { error: EntryReason.invalidCharacter } 149 | } 150 | const parts = entry.split('/') 151 | parts.shift() 152 | let namespace 153 | if (parts.length !== 0) { 154 | namespace = parts.shift() 155 | } 156 | if (!namespace) { 157 | return { error: EntryReason.namespaceMissing } 158 | } 159 | let identifier 160 | if (parts.length !== 0) { 161 | identifier = parts.join('/') 162 | } 163 | if (!identifier) { 164 | return { error: EntryReason.noIdentifier } 165 | } 166 | return { parsed: { namespace, identifier } } 167 | } 168 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dnslink/js", 3 | "version": "0.13.0", 4 | "description": "The reference implementation for DNSLink in JavaScript. Tested in Node.js and in the Browser.", 5 | "main": "index.js", 6 | "module": "index.mjs", 7 | "types": "./types/index.d.ts", 8 | "exports": { 9 | "require": "./index.js", 10 | "import": "./index.mjs", 11 | "types": "./types/index.d.ts" 12 | }, 13 | "scripts": { 14 | "prepare": "esm2umd dnslink", 15 | "lint": "standard *.mjs test/*.cjs bin/* && dtslint --localTs node_modules/typescript/lib types", 16 | "test": "npm run unit && npm run test-doh && npm run test-udp", 17 | "test-doh": "dnslink-test -e log -- node test/integration-doh.cjs", 18 | "test-udp": "dnslink-test -e log -- node test/integration-udp.cjs", 19 | "browser": "browserify --debug test/unit.js | browser-run", 20 | "unit": "fresh-tape test/unit.mjs" 21 | }, 22 | "standard": { 23 | "ignore": "*.ts", 24 | "include": "bin/dnslink" 25 | }, 26 | "bin": { 27 | "dnslink": "bin/dnslink", 28 | "dnslink-js": "bin/dnslink-js" 29 | }, 30 | "repository": { 31 | "type": "git", 32 | "url": "git+https://github.com/dnslink-std/js.git" 33 | }, 34 | "keywords": [ 35 | "dnslink", 36 | "decentralized", 37 | "dns", 38 | "ipfs", 39 | "dweb" 40 | ], 41 | "author": "Martin Heidegger ", 42 | "license": "MIT or Apache", 43 | "bugs": { 44 | "url": "https://github.com/dnslink-std/js/issues" 45 | }, 46 | "homepage": "https://github.com/dnslink-std/js#readme", 47 | "dependencies": { 48 | "@leichtgewicht/esm2umd": "^0.4.0", 49 | "abort-controller": "^3.0.0", 50 | "dns-query": "^0.11.1" 51 | }, 52 | "devDependencies": { 53 | "@definitelytyped/dtslint": "^0.0.112", 54 | "@dnslink/test": "^0.11.1", 55 | "browser-run": "^11.0.0", 56 | "browserify": "^17.0.0", 57 | "fresh-tape": "^5.5.3", 58 | "standard": "^17.0.0", 59 | "typescript": "^4.7.2" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /test/integration-doh.cjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const { resolve } = require('../index.js') 4 | const { doh: port } = JSON.parse(process.argv[3]) 5 | 6 | resolve(process.argv[2], { 7 | endpoints: [`http://0.0.0.0:${port}/dns-query`] 8 | }).then( 9 | result => console.log(JSON.stringify(result)), 10 | error => console.log(JSON.stringify({ 11 | error: { 12 | message: error.message, 13 | code: error.code, 14 | reason: error.reason 15 | } 16 | })) 17 | ) 18 | -------------------------------------------------------------------------------- /test/integration-udp.cjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const { resolve } = require('../index.js') 4 | const { udp: port } = JSON.parse(process.argv[3]) 5 | 6 | resolve(process.argv[2], { 7 | endpoints: [`udp://127.0.0.1:${port}`] 8 | }).then( 9 | result => console.log(JSON.stringify(result)), 10 | error => console.log(JSON.stringify({ 11 | error: { 12 | message: error.message, 13 | code: error.code, 14 | reason: error.reason 15 | } 16 | })) 17 | ) 18 | -------------------------------------------------------------------------------- /test/unit.mjs: -------------------------------------------------------------------------------- 1 | import { resolve } from '../index.mjs' 2 | import tape from 'fresh-tape' 3 | 4 | // Note: this is currently a simple test to make sure that the code compiles and 5 | // runs in te browser, not a thorough API test. 6 | tape('basic API test', function (t) { 7 | return resolve('t02.dnslink.dev', { 8 | endpoints: ['https://1.1.1.1'] // Using a pretty stable doh endpoint to work with. 9 | }).then(data => { 10 | let ttl = data && data.txtEntries && data.txtEntries[0] && data.txtEntries[0].ttl 11 | t.deepEquals(data, { 12 | txtEntries: [ 13 | { value: '/testkey/ABCD', ttl } 14 | ], 15 | links: { 16 | testkey: [ 17 | { identifier: 'ABCD', ttl } 18 | ] 19 | }, 20 | log: [ 21 | { code: 'FALLBACK' } 22 | ] 23 | }) 24 | }) 25 | }) 26 | 27 | tape('done.', { skip: typeof window === 'undefined' }, function () { 28 | window.close() 29 | }) 30 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | import { 2 | QueryOpts, 3 | TxtEntry 4 | } from 'dns-query'; 5 | 6 | export { 7 | QueryOpts, 8 | TxtEntry, 9 | TxtResult, 10 | EndpointsInput, 11 | } from 'dns-query'; 12 | 13 | export enum LogCode { 14 | fallback = 'FALLBACK', 15 | invalidEntry = 'INVALID_ENTRY', 16 | } 17 | export enum EntryReason { 18 | wrongStart = 'WRONG_START', 19 | namepaceMissing = 'NAMESPACE_MISSING', 20 | noIdentifier = 'NO_IDENTIFIER', 21 | invalidCharacter = 'INVALID_CHARACTER', 22 | invalidEncoding = 'INVALID_ENCODING', 23 | } 24 | export enum FQDNReason { 25 | emptyPart = 'EMPTY_PART', 26 | tooLong = 'TOO_LONG', 27 | } 28 | export const CODE_MEANING: { 29 | [code in LogCode | EntryReason | FQDNReason]: string; 30 | }; 31 | export interface InvalidEntry { 32 | code: LogCode.invalidEntry; 33 | entry: string; 34 | reason: EntryReason; 35 | } 36 | export interface FallbackEntry { 37 | code: LogCode.fallback; 38 | } 39 | export type LogEntry = FallbackEntry | InvalidEntry; 40 | export interface Links { 41 | [namespace: string]: Array<{ identifier: string, ttl: number }>; 42 | } 43 | export interface Result { 44 | txtEntries: TxtEntry[]; 45 | links: Links; 46 | log: LogEntry[]; 47 | } 48 | export function resolve(domain: string, options?: QueryOpts): Promise; 49 | 50 | export {}; 51 | -------------------------------------------------------------------------------- /types/test.ts: -------------------------------------------------------------------------------- 1 | import { resolve, LogCode, EntryReason, Result, CODE_MEANING, FQDNReason, QueryOpts } from '@dnslink/js'; 2 | 3 | const c = new AbortController(); 4 | 5 | const anyCode: LogCode | EntryReason | FQDNReason = LogCode.fallback; 6 | const meaning: string = CODE_MEANING[anyCode]; 7 | 8 | resolve('domain.com').then(({ links, log }: Result) => { 9 | const { ipfs, other }: { ipfs?: string, other?: string } = links; 10 | for (const logEntry of log) { 11 | const code: LogCode = logEntry.code; 12 | /* tslint:disable:prefer-switch */ 13 | if ( 14 | logEntry.code === LogCode.invalidEntry 15 | ) { 16 | const entry: string = logEntry.entry; 17 | } 18 | } 19 | }); 20 | 21 | let o: QueryOpts = {}; 22 | o = { signal: c.signal }; 23 | o = { timeout: 1000, }; 24 | o = { endpoints: ['dns.google'] }; 25 | -------------------------------------------------------------------------------- /types/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strictNullChecks": true, 4 | "noImplicitAny": true, 5 | "noImplicitThis": true, 6 | "strictFunctionTypes": true, 7 | "alwaysStrict": true, 8 | "resolveJsonModule": true, 9 | "lib": ["es6", "dom"], 10 | "baseUrl": ".", 11 | "paths": { "@dnslink/js": [".."] } 12 | } 13 | } --------------------------------------------------------------------------------