├── .npmrc ├── .travis.yml ├── test ├── specs │ ├── hooks.js │ ├── get-host-file-path.tests.js │ ├── cli.tests.js │ └── host-writer.tests.js └── stubs │ ├── os.js │ ├── fs.js │ └── memory-stream.js ├── lib ├── index.js ├── utils │ ├── for-each-key.js │ ├── is-loopback.js │ ├── is-local.js │ ├── map-object.js │ ├── get-host-file-path.js │ ├── host-matcher.js │ ├── mutex.js │ ├── read-lines.js │ └── read-chunks.js ├── entry-types │ ├── blank-line.js │ ├── comment-line.js │ ├── invalid-entry.js │ ├── commented-host-entry.js │ ├── host-entry.js │ ├── host-entry-base.js │ └── parse.js ├── cli.js └── host-writer.js ├── .eslintrc ├── .npmignore ├── .gitignore ├── package.json ├── LICENSE └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | @godaddy:registry=https://registry.npmjs.org 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "v8" 4 | -------------------------------------------------------------------------------- /test/specs/hooks.js: -------------------------------------------------------------------------------- 1 | before(() => { 2 | require('chai').use(require('sinon-chai')); 3 | }); 4 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | const HostWriter = require('./host-writer'); 2 | 3 | module.exports = new HostWriter(); 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": "godaddy", 4 | "rules": { 5 | "no-console": 0, 6 | "no-process-env": [0] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /lib/utils/for-each-key.js: -------------------------------------------------------------------------------- 1 | module.exports = function forEachKey(obj, cb) { 2 | return Object.keys(obj).forEach(key => cb(key, obj[key])); 3 | }; 4 | -------------------------------------------------------------------------------- /test/stubs/os.js: -------------------------------------------------------------------------------- 1 | const { stub } = require('sinon'); 2 | 3 | module.exports = function OsStub() { 4 | return { 5 | type: stub() 6 | }; 7 | }; 8 | -------------------------------------------------------------------------------- /lib/utils/is-loopback.js: -------------------------------------------------------------------------------- 1 | const ip = require('ip'); 2 | 3 | module.exports = function isLoopback(address) { 4 | return ip.isLoopback(address); 5 | }; 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .nyc_output 3 | node_modules 4 | test 5 | .gitignore 6 | .npmignore 7 | .npmrc 8 | .travis.yml 9 | package-lock.json 10 | README.md 11 | -------------------------------------------------------------------------------- /test/stubs/fs.js: -------------------------------------------------------------------------------- 1 | const { stub } = require('sinon'); 2 | 3 | module.exports = function FsStub() { 4 | return { 5 | createReadStream: stub(), 6 | createWriteStream: stub() 7 | }; 8 | }; 9 | -------------------------------------------------------------------------------- /lib/entry-types/blank-line.js: -------------------------------------------------------------------------------- 1 | module.exports = class BlankLine { 2 | constructor(content) { 3 | this.content = content; 4 | } 5 | 6 | format() { 7 | return this.content; 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /lib/entry-types/comment-line.js: -------------------------------------------------------------------------------- 1 | module.exports = class CommentLine { 2 | constructor(content) { 3 | this.content = content; 4 | } 5 | 6 | format() { 7 | return this.content; 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /lib/entry-types/invalid-entry.js: -------------------------------------------------------------------------------- 1 | module.exports = class InvalidEntry { 2 | constructor(content) { 3 | this.content = content; 4 | } 5 | 6 | format() { 7 | return this.content; 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /lib/utils/is-local.js: -------------------------------------------------------------------------------- 1 | const ip = require('ip'); 2 | const isLoopback = require('./is-loopback'); 3 | 4 | module.exports = function isLocal(address) { 5 | return isLoopback(address) || ip.isEqual(address, '::0'); 6 | }; 7 | -------------------------------------------------------------------------------- /lib/utils/map-object.js: -------------------------------------------------------------------------------- 1 | module.exports = function mapObject(obj, valueMapper) { 2 | return Object 3 | .keys(obj) 4 | .reduce((mapping, key) => Object.assign(mapping, { [key]: valueMapper(obj[key]) }), {}); 5 | }; 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | 4 | # Test artifacts 5 | .nyc_output 6 | .eslintcache 7 | 8 | # Development artifacts 9 | npm-debug.log* 10 | .npm 11 | .node_repl_history 12 | 13 | # Editor artifacts 14 | .idea 15 | -------------------------------------------------------------------------------- /test/stubs/memory-stream.js: -------------------------------------------------------------------------------- 1 | const { Readable } = require('stream'); 2 | 3 | module.exports = function MemoryStream(content) { 4 | return new Readable({ 5 | read() { 6 | this.push(content); 7 | this.push(null); 8 | } 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /lib/utils/get-host-file-path.js: -------------------------------------------------------------------------------- 1 | const os = require('os'); 2 | const path = require('path'); 3 | 4 | module.exports = function getHostFilePath() { 5 | return (os.type() === 'Windows_NT' 6 | ? path.join(process.env.SystemRoot, 'System32', 'drivers', 'etc', 'hosts') 7 | : '/etc/hosts'); 8 | }; 9 | -------------------------------------------------------------------------------- /lib/utils/host-matcher.js: -------------------------------------------------------------------------------- 1 | module.exports = function HostMatcher(pattern) { 2 | if (!pattern.includes('*')) { 3 | const lowered = pattern.toLowerCase(); 4 | return { test: value => value.toLowerCase() === lowered }; 5 | } 6 | 7 | pattern = pattern 8 | .replace(/\./g, '\\.') 9 | .replace(/\*/g, '.*'); 10 | return new RegExp(`^${pattern}$`, 'i'); 11 | }; 12 | -------------------------------------------------------------------------------- /lib/entry-types/commented-host-entry.js: -------------------------------------------------------------------------------- 1 | const HostEntryBase = require('./host-entry-base'); 2 | 3 | module.exports = class CommentedHostEntry extends HostEntryBase { 4 | uncomment() { 5 | const HostEntry = require('./host-entry'); 6 | return new HostEntry(this.address, this.hosts, this.comment); 7 | } 8 | 9 | format(addressWidth, hostsWidth) { 10 | return `#${this.address.padEnd(addressWidth)} ${this.hosts.join(' ').padEnd(hostsWidth)} ${this.comment || ''}`.trim(); 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /lib/entry-types/host-entry.js: -------------------------------------------------------------------------------- 1 | const HostEntryBase = require('./host-entry-base'); 2 | 3 | module.exports = class HostEntry extends HostEntryBase { 4 | commentOut() { 5 | const CommentedHostEntry = require('./commented-host-entry'); 6 | return new CommentedHostEntry(this.address, this.hosts, this.comment); 7 | } 8 | 9 | format(addressWidth, hostsWidth) { 10 | return `${this.address.padEnd(addressWidth + 1)} ${this.hosts.join(' ').padEnd(hostsWidth)} ${this.comment || ''}`.trim(); 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /lib/utils/mutex.js: -------------------------------------------------------------------------------- 1 | class Mutex { 2 | constructor() { 3 | this.queue = []; 4 | this.locked = false; 5 | } 6 | 7 | acquire() { 8 | const result = this.locked 9 | ? new Promise(resolve => this.queue.push(resolve)) 10 | : Promise.resolve(); 11 | this.locked = true; 12 | return result; 13 | } 14 | 15 | release() { 16 | const next = this.queue.shift(); 17 | this.locked = !!next; 18 | if (next) { 19 | return void next(); 20 | } 21 | } 22 | } 23 | 24 | module.exports = Mutex; 25 | -------------------------------------------------------------------------------- /lib/entry-types/host-entry-base.js: -------------------------------------------------------------------------------- 1 | const ip = require('ip'); 2 | 3 | module.exports = class HostEntryBase { 4 | constructor(address, hosts, comment) { 5 | this.address = address; 6 | this.hosts = hosts; 7 | this.comment = comment; 8 | } 9 | 10 | referencesHost(host) { 11 | host = host.toLowerCase(); 12 | return this.hosts.some(entryHost => entryHost.toLowerCase() === host); 13 | } 14 | 15 | referencesOnlyHost(host) { 16 | return this.hosts.length === 1 && this.referencesHost(host); 17 | } 18 | 19 | matchesAddress(address) { 20 | return address && ip.isEqual(address, this.address); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /lib/utils/read-lines.js: -------------------------------------------------------------------------------- 1 | const os = require('os'); 2 | const { Observable } = require('rxjs'); 3 | const readChunks = require('./read-chunks'); 4 | 5 | module.exports = function readLines(stream) { 6 | let buffer = ''; 7 | 8 | return readChunks(stream) 9 | .materialize() 10 | .mergeMap(msg => { 11 | if (msg.hasValue) { 12 | buffer += msg.value; 13 | const lines = buffer.split(os.EOL); 14 | const linesToEmit = Observable.of(...lines.slice(0, lines.length - 1)); 15 | buffer = lines[lines.length - 1] || ''; 16 | return linesToEmit; 17 | } else if (msg.kind === 'C') { 18 | return buffer ? Observable.of(buffer) : Observable.empty(); 19 | } 20 | return Observable.throw(msg.error); 21 | 22 | }); 23 | }; 24 | -------------------------------------------------------------------------------- /lib/utils/read-chunks.js: -------------------------------------------------------------------------------- 1 | const { Observable } = require('rxjs'); 2 | 3 | module.exports = function readChunks(stream) { 4 | stream.pause(); 5 | 6 | return new Observable((observer) => { 7 | function dataHandler(data) { 8 | observer.next(data); 9 | } 10 | 11 | function errorHandler(err) { 12 | observer.error(err); 13 | } 14 | 15 | function endHandler() { 16 | observer.complete(); 17 | } 18 | 19 | stream.addListener('data', dataHandler); 20 | stream.addListener('error', errorHandler); 21 | stream.addListener('end', endHandler); 22 | 23 | stream.resume(); 24 | 25 | return () => { 26 | stream.removeListener('data', dataHandler); 27 | stream.removeListener('error', errorHandler); 28 | stream.removeListener('end', endHandler); 29 | }; 30 | }).share(); 31 | }; 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@godaddy/hostfile", 3 | "version": "1.0.7", 4 | "description": "API and CLI for querying and manipulating host files", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+ssh://git@github.com/godaddy/hostwriter" 9 | }, 10 | "author": "jpage@godaddy.com", 11 | "bin": { 12 | "hostfile": "./lib/cli.js" 13 | }, 14 | "main": "./lib", 15 | "publishConfig": { 16 | "registry": "https://registry.npmjs.org" 17 | }, 18 | "dependencies": { 19 | "ip": "^1.1.5", 20 | "rxjs": "^5.5.6" 21 | }, 22 | "devDependencies": { 23 | "chai": "^4.1.2", 24 | "eslint": "^6.8.0", 25 | "eslint-config-godaddy": "^4.0.0", 26 | "eslint-plugin-json": "^1.4.0", 27 | "eslint-plugin-mocha": "^5.3.0", 28 | "mocha": "^7.1.1", 29 | "nyc": "^15.0.0", 30 | "proxyquire": "^1.8.0", 31 | "sinon": "^4.5.0", 32 | "sinon-chai": "^2.14.0" 33 | }, 34 | "scripts": { 35 | "pretest": "eslint ./lib ./test", 36 | "test": "nyc mocha --recursive ./test/specs", 37 | "prepublishOnly": "npm run test" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 GoDaddy Operating Company, LLC. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/specs/get-host-file-path.tests.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const proxyquire = require('proxyquire'); 3 | const { expect } = require('chai'); 4 | const OsStub = require('../stubs/os'); 5 | 6 | describe('get-host-file-path', () => { 7 | let getHostFilePath; 8 | let os; 9 | 10 | beforeEach(() => { 11 | os = new OsStub(); 12 | 13 | getHostFilePath = proxyquire('../../lib/utils/get-host-file-path', { os }); 14 | }); 15 | 16 | it('returns /etc/hosts for Linux', () => { 17 | os.type.returns('Linux'); 18 | 19 | expect(getHostFilePath()).to.equal('/etc/hosts'); 20 | }); 21 | 22 | it('returns /etc/hosts for MacOS', () => { 23 | os.type.returns('Darwin'); 24 | 25 | expect(getHostFilePath()).to.equal('/etc/hosts'); 26 | }); 27 | 28 | it('returns /etc/hosts for MacOS', () => { 29 | os.type.returns('Darwin'); 30 | 31 | expect(getHostFilePath()).to.equal('/etc/hosts'); 32 | }); 33 | 34 | it('returns C:\\Windows\\System32\\drivers\\etc\\hosts for Windows', () => { 35 | os.type.returns('Windows_NT'); 36 | process.env.SystemRoot = 'C:\\Windows'; 37 | 38 | expect(getHostFilePath()).to.equal(path.join('C:\\Windows', 'System32', 'drivers', 'etc', 'hosts')); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /lib/entry-types/parse.js: -------------------------------------------------------------------------------- 1 | const net = require('net'); 2 | const HostEntry = require('./host-entry'); 3 | const CommentedHostEntry = require('./commented-host-entry'); 4 | const CommentLine = require('./comment-line'); 5 | const BlankLine = require('./blank-line'); 6 | const InvalidEntry = require('./invalid-entry'); 7 | 8 | const lineRegex = /^(.*?)(#.*)?$/; 9 | const whitespaceRegex = /^\s*$/; 10 | 11 | module.exports = function parseHostFileLine(text) { 12 | const [, content = '', comment = ''] = lineRegex.exec(text); 13 | 14 | const hasContent = !whitespaceRegex.test(content); 15 | const hasComment = !whitespaceRegex.test(comment); 16 | 17 | if (hasContent) { 18 | const [address, ...hosts] = content.split(/\s+/).filter(Boolean); 19 | if (address && hosts && hosts.length && (net.isIPv4(address) || net.isIPv6(address))) { 20 | return new HostEntry(address, hosts, comment); 21 | } 22 | return new InvalidEntry(text); 23 | 24 | } 25 | if (hasComment) { 26 | const commentedOutContent = comment.replace(/^#+/, ''); 27 | const parsedContent = parseHostFileLine(commentedOutContent); 28 | if (parsedContent instanceof HostEntry) { 29 | return new CommentedHostEntry(parsedContent.address, parsedContent.hosts, parsedContent.comment); 30 | } 31 | return new CommentLine(text); 32 | 33 | } 34 | return new BlankLine(text); 35 | 36 | 37 | }; 38 | -------------------------------------------------------------------------------- /lib/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const HostWriter = require('./host-writer'); 4 | const toHostMatcher = require('./utils/host-matcher'); 5 | 6 | const { argv } = process; 7 | const hostfile = new HostWriter(); 8 | 9 | const usage = `Usage: 10 | hostfile query host # Lists active entries for one or most hosts. Supports * wildcards. 11 | hostfile query address # Lists active entries pointing to one or more address 12 | hostfile point [to]
# Assigns
to one or most hosts 13 | hostfile reset # Removes hostfile assignments for one or more hosts 14 | `; 15 | 16 | const helpHint = 'Run "hostfile help" for usage information.'; 17 | 18 | function showHelp() { 19 | return console.log(usage); 20 | } 21 | 22 | function handleQueryCommand(subject, ...items) { 23 | const entries = hostfile.getHostEntries(); 24 | let matchingEntries; 25 | switch (subject) { 26 | case 'host': { 27 | const hostMatchers = items.map(toHostMatcher); 28 | matchingEntries = entries.filter(e => hostMatchers.some(matcher => e.hosts.some(host => matcher.test(host)))); 29 | break; 30 | } 31 | case 'address': 32 | matchingEntries = entries.filter(e => items.some(address => e.matchesAddress(address))); 33 | break; 34 | default: 35 | return console.error(`Unrecognized query option "${subject}". ${helpHint}`); 36 | } 37 | return hostfile.formatLines(matchingEntries).forEach(line => console.log(line.trim())); 38 | } 39 | 40 | function handlePointCommand(...args) { 41 | const toKeywordPos = args.indexOf('to') || args.length - 1; 42 | const hosts = args.slice(0, toKeywordPos); 43 | const address = args[args.length - 1]; 44 | if (!hosts.length || !address) { 45 | return console.error(`Invalid command. ${helpHint}`); 46 | } 47 | return hostfile.point(hosts.reduce((mapping, host) => Object.assign(mapping, { [host]: address }), {})); 48 | } 49 | 50 | function handleResetCommand(...hosts) { 51 | return hostfile.point(hosts.reduce((mapping, host) => Object.assign(mapping, { [host]: null }), {})); 52 | } 53 | 54 | module.exports = Promise.resolve((function (args) { 55 | const command = args.shift(); 56 | switch (command) { 57 | case 'help': 58 | return showHelp(); 59 | 60 | case 'query': 61 | return handleQueryCommand(...args); 62 | 63 | case 'point': 64 | return handlePointCommand(...args); 65 | 66 | case 'reset': 67 | return handleResetCommand(...args); 68 | 69 | default: 70 | console.error(`Unrecognized command "${command}". Run "hostfile help" for usage information.`); 71 | } 72 | }(argv.slice(2)))).catch(err => { 73 | console.error(err); 74 | }); 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @godaddy/hostfile 2 | 3 | API and CLI for querying and manipulating host files. 4 | 5 | ![Build Status](https://travis-ci.org/godaddy/hostwriter.svg?branch=master) 6 | 7 | > ⚠️ **DEPRECATED**: This package is no longer maintained and should not be used in new projects. Please consider using alternative solutions for host file management. 8 | 9 | ## Installation 10 | 11 | First, an important note. By default, most operating systems protect your host file by restricting write permissions. 12 | You'll need to change the permissions on that file before you can use the API or CLI to modify the file. For example: 13 | 14 | ```shell 15 | sudo chown %USER: /etc/hosts 16 | ``` 17 | 18 | 19 | ## API 20 | 21 | To use the API, install as a dependency: 22 | 23 | ```shell 24 | npm i --save @godaddy/hostfile 25 | ``` 26 | 27 | ...then import: 28 | 29 | ```js 30 | const hostfile = require('@godaddy/hostfile'); 31 | ``` 32 | 33 | The following methods are supported: 34 | 35 | ### `.point(hostEntries: Dictionary): Promise` 36 | 37 | Where `hostEntries` is an object mapping host names to IP addresses, `point` assigns each host to an address. If an 38 | address is set to `null`, any existing host file overrides for the host are commented out instead. 39 | 40 | ### `.pointHost(host: string, address: string): Promise` 41 | 42 | Shortcut method for `.point({ [host]: address })`. 43 | 44 | ### `.pointLocal(host: string): Promise` 45 | 46 | Shortcut method for `.point({ [host]: '127.0.0.1' })`. 47 | 48 | ### `.reset(host: string): Promise` 49 | 50 | Shortcut method for `.point({ [host]: null })`. 51 | 52 | ### `.untilExit(hostEntries: Dictionary): Promise` 53 | 54 | Same as `.point`, except `SIGTERM`, `SIGINT`, and `SIGHUP` handlers are installed for the process, and before exit, 55 | each of the hosts are reset. This is useful if you're developing a local service, and you only want your host file 56 | pointing locally while the service is running. 57 | 58 | ### `.isLocal(host: string): Promise` 59 | 60 | Resolves to `true` if the host is pointing to `0.0.0.0`, `127.0.0.1`, `::1`, or `::0`. 61 | 62 | ### `.watch(): Observable` 63 | 64 | Returns an `Observable` that emits events when the host file changes. This is just a wrapper around 65 | [fs.watch](https://nodejs.org/dist/latest-v8.x/docs/api/fs.html#fs_fs_watch_filename_options_listener); see the node 66 | documentation for more information. 67 | 68 | 69 | ## CLI 70 | 71 | To install the `hostfile` command-line: 72 | 73 | ```shell 74 | npm i -g @godaddy/hostfile 75 | ``` 76 | 77 | The `hostfile` command can be used as follows: 78 | 79 | ```shell 80 | hostfile query host # Lists active entries for one or most hosts. Supports * wildcards. 81 | hostfile query address # Lists active entries pointing to one or more address 82 | hostfile point [to]
# Assigns
to one or most hosts 83 | hostfile reset # Removes hostfile assignments for one or more hosts 84 | ``` 85 | 86 | 87 | 88 | ## Operating System Support 89 | 90 | `hostwriter` only knows how to find the host file on MacOS, Linux, and Windows at this time. If you have a need for 91 | other OS's, let us know. 92 | -------------------------------------------------------------------------------- /test/specs/cli.tests.js: -------------------------------------------------------------------------------- 1 | const proxyquire = require('proxyquire'); 2 | const { match, spy } = require('sinon'); 3 | const { expect } = require('chai'); 4 | const { Writable } = require('stream'); 5 | const FsStub = require('../stubs/fs'); 6 | const MemoryStream = require('../stubs/memory-stream'); 7 | 8 | describe('The command-line interface', function () { 9 | this.timeout(10000); 10 | 11 | let fs, writtenFile; 12 | 13 | beforeEach(() => { 14 | spy(console, 'log'); 15 | spy(console, 'error'); 16 | 17 | fs = new FsStub(); 18 | stubWriteStream(); 19 | }); 20 | 21 | afterEach(() => { 22 | console.log.restore(); 23 | console.error.restore(); 24 | }); 25 | 26 | describe('help command', () => { 27 | it('prints a usage message', async () => { 28 | process.argv = ['/bin/node', 'hostfile', 'help']; 29 | 30 | await invokeCLI(); 31 | 32 | expect(console.log).to.have.been.calledWithMatch(match(str => str.includes('Usage'))); 33 | }); 34 | }); 35 | 36 | describe('query command with an invalid subject', () => { 37 | it('points the user to the help command', async () => { 38 | stubHostFile(''); 39 | process.argv = ['/bin/node', 'hostfile', 'query', 'puppies']; 40 | 41 | await invokeCLI(); 42 | 43 | expect(console.error).to.have.been.calledWithMatch(match(str => str.includes('hostfile help'))); 44 | }); 45 | }); 46 | 47 | describe('query host command', () => { 48 | it('prints matching host entries', async () => { 49 | stubHostFile(` 50 | 127.0.0.1 example.com 51 | 127.0.0.1 other.com 52 | `); 53 | 54 | process.argv = ['/bin/node', 'hostfile', 'query', 'host', 'example.com']; 55 | await invokeCLI(); 56 | 57 | expect(console.log).to.have.been.calledWith('127.0.0.1 example.com'); 58 | }); 59 | 60 | it('supports wildcards', async () => { 61 | stubHostFile(` 62 | 62.6.62.3 example.com 63 | 127.0.0.1 dev.example.com 64 | 127.0.0.1 dev.other.com 65 | `); 66 | 67 | process.argv = ['/bin/node', 'hostfile', 'query', 'host', 'dev.*']; 68 | await invokeCLI(); 69 | 70 | expect(console.log).to.have.been.calledWith('127.0.0.1 dev.example.com'); 71 | expect(console.log).to.have.been.calledWith('127.0.0.1 dev.other.com'); 72 | }); 73 | }); 74 | 75 | describe('query address command', () => { 76 | it('prints matching address entries', async () => { 77 | stubHostFile(` 78 | 127.0.0.1 example.com 79 | 127.0.0.1 other.com 80 | 62.35.73.2 third.com 81 | `); 82 | 83 | process.argv = ['/bin/node', 'hostfile', 'query', 'address', '127.0.0.1']; 84 | await invokeCLI(); 85 | 86 | expect(console.log).to.have.been.calledWith('127.0.0.1 example.com'); 87 | expect(console.log).to.have.been.calledWith('127.0.0.1 other.com'); 88 | }); 89 | }); 90 | 91 | describe('point command', () => { 92 | it('points the user to the help command if the command was invoked improperly', async () => { 93 | stubHostFile(''); 94 | process.argv = ['/bin/node', 'hostfile', 'point', 'puppies']; 95 | 96 | await invokeCLI(); 97 | 98 | expect(console.error).to.have.been.calledWithMatch(match(str => str.includes('hostfile help'))); 99 | }); 100 | 101 | it('adds entries if the host is not in the file', async () => { 102 | stubHostFile(` 103 | 127.0.0.1 example.com 104 | 127.0.0.1 other.com 105 | 62.35.73.2 third.com`); 106 | 107 | process.argv = ['/bin/node', 'hostfile', 'point', 'fourth.com', 'to', '127.0.0.1']; 108 | await invokeCLI(); 109 | 110 | expect(writtenFile).to.equal(` 111 | 127.0.0.1 example.com 112 | 127.0.0.1 other.com 113 | 62.35.73.2 third.com 114 | 127.0.0.1 fourth.com`); 115 | }); 116 | 117 | it('uncomments entries that match', async () => { 118 | stubHostFile(` 119 | #127.0.0.1 example.com 120 | 127.0.0.1 other.com 121 | 62.35.73.2 third.com`); 122 | 123 | process.argv = ['/bin/node', 'hostfile', 'point', 'example.com', 'to', '127.0.0.1']; 124 | await invokeCLI(); 125 | 126 | expect(writtenFile).to.equal(` 127 | 127.0.0.1 example.com 128 | 127.0.0.1 other.com 129 | 62.35.73.2 third.com`); 130 | }); 131 | 132 | it('comments out entries that match and are referencing a different address', async () => { 133 | stubHostFile(` 134 | 127.0.0.1 example.com 135 | 127.0.0.1 other.com 136 | 62.35.73.2 third.com`); 137 | 138 | process.argv = ['/bin/node', 'hostfile', 'point', 'example.com', 'to', '68.23.67.72']; 139 | await invokeCLI(); 140 | 141 | expect(writtenFile).to.equal(` 142 | #127.0.0.1 example.com 143 | 68.23.67.72 example.com 144 | 127.0.0.1 other.com 145 | 62.35.73.2 third.com`); 146 | }); 147 | }); 148 | 149 | describe('reset command', () => { 150 | it('comments out matching hosts', async () => { 151 | stubHostFile(` 152 | 127.0.0.1 example.com 153 | 127.0.0.1 other.com 154 | 62.35.73.2 third.com`); 155 | 156 | process.argv = ['/bin/node', 'hostfile', 'reset', 'other.com']; 157 | await invokeCLI(); 158 | 159 | expect(writtenFile).to.equal(` 160 | 127.0.0.1 example.com 161 | #127.0.0.1 other.com 162 | 62.35.73.2 third.com`); 163 | }); 164 | }); 165 | 166 | function stubHostFile(contents) { 167 | fs.createReadStream.withArgs('/etc/hosts').returns(new MemoryStream(contents)); 168 | } 169 | 170 | function invokeCLI() { 171 | return proxyquire.noPreserveCache().noCallThru()('../../lib/cli', { 172 | './host-writer': proxyquire.noCallThru()('../../lib/host-writer', { fs }) 173 | }); 174 | } 175 | 176 | function stubWriteStream() { 177 | writtenFile = ''; 178 | fs.createWriteStream.withArgs('/etc/hosts').returns(new Writable({ 179 | write(chunk, encoding, cb) { 180 | writtenFile += chunk; 181 | cb(null); 182 | } 183 | })); 184 | } 185 | }); 186 | -------------------------------------------------------------------------------- /lib/host-writer.js: -------------------------------------------------------------------------------- 1 | const { createReadStream, createWriteStream, watch } = require('fs'); 2 | const { Readable } = require('stream'); 3 | const { EOL } = require('os'); 4 | const { Observable } = require('rxjs'); 5 | const mapObject = require('./utils/map-object'); 6 | const forEachKey = require('./utils/for-each-key'); 7 | const getHostFilePath = require('./utils/get-host-file-path'); 8 | const readLines = require('./utils/read-lines'); 9 | const isLocal = require('./utils/is-local'); 10 | const Mutex = require('./utils/mutex'); 11 | const parseHostFileLine = require('./entry-types/parse'); 12 | const HostEntryBase = require('./entry-types/host-entry-base'); 13 | const HostEntry = require('./entry-types/host-entry'); 14 | const CommentedHostEntry = require('./entry-types/commented-host-entry'); 15 | 16 | module.exports = class HostWriter { 17 | constructor({ hostFilePath } = {}) { 18 | this.hostFilePath = hostFilePath || getHostFilePath(); 19 | this.mutex = new Mutex(); 20 | } 21 | 22 | async isLocal(host) { 23 | host = host.toLowerCase(); 24 | 25 | await this.mutex.acquire(); 26 | 27 | try { 28 | return await new Promise((resolve, reject) => { 29 | this 30 | .getHostEntries() 31 | .reduce((foundMatch, entry) => ( 32 | foundMatch || (entry.referencesHost(host) && isLocal(entry.address)) 33 | ), false) 34 | .subscribe(resolve, reject); 35 | }); 36 | } finally { 37 | this.mutex.release(); 38 | } 39 | } 40 | 41 | reset(host) { 42 | return this.pointHost(host, null); 43 | } 44 | 45 | pointLocal(host) { 46 | return this.pointHost(host, '127.0.0.1'); 47 | } 48 | 49 | untilExit(hosts) { 50 | return this.point(hosts).then(() => { 51 | const resetAndExit = () => { 52 | return this.point(mapObject(hosts, () => null)) 53 | .catch(err => { 54 | console.warn( 55 | 'Could not automatically reset host file entry. Make sure the file is writable to enable this feature.', 56 | err.message); 57 | }) 58 | .then(() => { 59 | process.exit(0); // eslint-disable-line no-process-exit 60 | }); 61 | }; 62 | 63 | process 64 | .on('SIGTERM', resetAndExit) 65 | .on('SIGINT', resetAndExit) 66 | .on('SIGHUP', resetAndExit); 67 | }); 68 | } 69 | 70 | pointHost(host, address) { 71 | return this.point({ [host]: address }); 72 | } 73 | 74 | async point(hostAddresses) { 75 | await this.mutex.acquire(); 76 | 77 | hostAddresses = Object 78 | .keys(hostAddresses) 79 | .reduce((mapping, host) => Object.assign(mapping, { [host.toLowerCase()]: hostAddresses[host] }), {}); 80 | 81 | let isDirty = false; 82 | const hostIsDone = mapObject(hostAddresses, address => !address); 83 | const insertionPoints = mapObject(hostAddresses, () => null); 84 | 85 | try { 86 | return await new Promise((resolve, reject) => { 87 | this 88 | .getHostFileContent() 89 | .map((line, i) => { 90 | if (!(line instanceof HostEntryBase)) { 91 | return line; 92 | } 93 | 94 | let newLine = line; 95 | forEachKey(hostAddresses, (host, address) => { 96 | const referencesOnlyHost = line.referencesOnlyHost(host); 97 | const referencesHost = referencesOnlyHost || line.referencesHost(host); 98 | if (!referencesHost) { 99 | return; 100 | } 101 | 102 | insertionPoints[host] = i + 1; 103 | 104 | const matchesAddress = line.matchesAddress(address); 105 | if (line instanceof HostEntry) { 106 | if (matchesAddress) { 107 | // Nothing to do; already pointing where it should be 108 | hostIsDone[host] = true; 109 | } else { 110 | newLine = line.commentOut(); 111 | } 112 | } else if (line instanceof CommentedHostEntry && matchesAddress && referencesOnlyHost) { 113 | hostIsDone[host] = true; 114 | newLine = line.uncomment(); 115 | } 116 | }); 117 | 118 | if (newLine !== line) { 119 | isDirty = true; 120 | } 121 | 122 | return newLine; 123 | }) 124 | .toArray() 125 | .map(content => { 126 | forEachKey(hostAddresses, (host, address) => { 127 | if (hostIsDone[host]) { 128 | return; 129 | } 130 | 131 | isDirty = true; 132 | const newEntry = new HostEntry(address, [host]); 133 | if (insertionPoints[host] === null) { 134 | content.push(newEntry); 135 | } else { 136 | content.splice(insertionPoints[host], 0, newEntry); 137 | } 138 | }); 139 | 140 | if (isDirty) { 141 | return this.writeContent(content); 142 | } 143 | }) 144 | .subscribe(resolve, reject); 145 | }); 146 | } finally { 147 | this.mutex.release(); 148 | } 149 | } 150 | 151 | getHostEntries() { 152 | return this 153 | .getHostFileContent() 154 | .filter(line => line instanceof HostEntry); 155 | } 156 | 157 | getHostFileContent() { 158 | return readLines(createReadStream(this.hostFilePath)) 159 | .map(parseHostFileLine); 160 | } 161 | 162 | formatLines(lines) { 163 | let addressWidth = 0; 164 | let hostWidth = 0; 165 | 166 | lines.filter(line => line instanceof HostEntryBase).forEach(line => { 167 | addressWidth = Math.max(addressWidth, line.address.length); 168 | hostWidth = Math.max(hostWidth, line.hosts.join(' ').length); 169 | }); 170 | 171 | return lines.map((line, i) => line.format(addressWidth, hostWidth) + (i < lines.length - 1 ? EOL : '')); 172 | } 173 | 174 | writeContent(lines) { 175 | const output = this.formatLines(lines); 176 | return new Promise((resolve, reject) => { 177 | const readStream = new Readable({ 178 | read() { 179 | let line; 180 | while (line = output.shift()) { // eslint-disable-line no-cond-assign 181 | if (!this.push(line)) { 182 | return; 183 | } 184 | } 185 | 186 | this.push(null); 187 | } 188 | }); 189 | 190 | readStream 191 | .pipe(createWriteStream(this.hostFilePath)) 192 | .once('error', reject) 193 | .once('finish', resolve); 194 | }); 195 | } 196 | 197 | watch() { 198 | return Observable.create(observer => { 199 | const watcher = watch(this.hostFilePath) 200 | .on('change', e => observer.next(e)) 201 | .on('error', e => observer.error(e)); 202 | 203 | return () => { 204 | watcher.close(); 205 | }; 206 | }); 207 | } 208 | }; 209 | -------------------------------------------------------------------------------- /test/specs/host-writer.tests.js: -------------------------------------------------------------------------------- 1 | const { stub } = require('sinon'); 2 | const proxyquire = require('proxyquire'); 3 | const { expect } = require('chai'); 4 | const { Writable } = require('stream'); 5 | const { EOL } = require('os'); 6 | const MemoryStream = require('../stubs/memory-stream'); 7 | const FsStub = require('../stubs/fs'); 8 | 9 | describe('host-writer', () => { 10 | let HostWriter; 11 | let fs; 12 | let writtenFile; 13 | 14 | beforeEach(() => { 15 | fs = new FsStub(); 16 | stubWriteStream(); 17 | 18 | HostWriter = proxyquire('../../lib/host-writer', { fs }); 19 | }); 20 | 21 | describe('isLocal method', () => { 22 | [ 23 | '127.0.0.1', 24 | '0.0.0.0', 25 | '::1', 26 | '::0' 27 | ].forEach(address => { 28 | it(`resolves to true if a host is pointed to ${address}`, () => { 29 | return expectIsLocalFor(address); 30 | }); 31 | }); 32 | 33 | it('resolves to false if the host is pointing to a non-local address', () => { 34 | return expectIsNotLocalFor('26.73.84.1'); 35 | }); 36 | 37 | it('resolves to false if the host is not pointing to anything', () => { 38 | return expectIsLocalResultForHostFile('', 'example.com', false); 39 | }); 40 | 41 | function expectIsLocalFor(address) { 42 | return expectIsLocalResult(address, true); 43 | } 44 | 45 | function expectIsNotLocalFor(address) { 46 | return expectIsLocalResult(address, false); 47 | } 48 | 49 | function expectIsLocalResult(address, result) { 50 | const host = 'example.com'; 51 | const content = `${address} ${host}`; 52 | return expectIsLocalResultForHostFile(content, host, result); 53 | } 54 | 55 | async function expectIsLocalResultForHostFile(content, host, result) { 56 | stubHostFile(content); 57 | 58 | const isLocal = await new HostWriter({ hostFilePath: '/etc/hosts' }).isLocal(host); 59 | 60 | expect(isLocal).to.equal(result); 61 | } 62 | }); 63 | 64 | describe('pointLocal method', () => { 65 | it('does nothing if the host is already pointing to 127.0.0.1', async () => { 66 | stubHostFile('127.0.0.1 example.com'); 67 | 68 | await new HostWriter().pointLocal('example.com'); 69 | 70 | expect(fs.createWriteStream).to.have.not.been.called; 71 | }); 72 | 73 | it('adds a new line to the file if the host has never been listed in the file before', async () => { 74 | stubHostFile(''); 75 | 76 | await new HostWriter().pointLocal('example.com'); 77 | 78 | expect(writtenFile).to.equal('127.0.0.1 example.com'); 79 | }); 80 | 81 | it('un-comments any previous line matching the request', async () => { 82 | stubHostFile('#127.0.0.1 example.com'); 83 | 84 | await new HostWriter().pointLocal('example.com'); 85 | 86 | expect(writtenFile).to.equal('127.0.0.1 example.com'); 87 | }); 88 | 89 | it('comments out any active host entries matching the request', async () => { 90 | stubHostFile(` 91 | 62.62.87.234 example.com # Production`); 92 | 93 | await new HostWriter().pointLocal('example.com'); 94 | 95 | expect(writtenFile).to.equal(` 96 | #62.62.87.234 example.com # Production 97 | 127.0.0.1 example.com`); 98 | }); 99 | 100 | it('inserts new lines next to adjacent lines pertaining to the host', async () => { 101 | stubHostFile(` 102 | #62.62.87.234 example.com # Production 103 | #62.62.87.234 another.com # Production`); 104 | 105 | await new HostWriter().pointLocal('example.com'); 106 | 107 | expect(writtenFile).to.equal(` 108 | #62.62.87.234 example.com # Production 109 | 127.0.0.1 example.com 110 | #62.62.87.234 another.com # Production`); 111 | }); 112 | }); 113 | 114 | describe('point method', () => { 115 | it('preserves comments and blank lines', async () => { 116 | stubHostFile(` 117 | ## Production systems 118 | #62.62.87.234 example.com 119 | #62.62.87.234 another.com 120 | 121 | ## Development`); 122 | 123 | await new HostWriter().point({ 'dev.example.com': '127.0.0.1' }); 124 | 125 | expect(writtenFile).to.equal(` 126 | ## Production systems 127 | #62.62.87.234 example.com 128 | #62.62.87.234 another.com 129 | 130 | ## Development 131 | 127.0.0.1 dev.example.com`); 132 | }); 133 | 134 | it('preserves invalid lines', async () => { 135 | stubHostFile(` 136 | ## Production systems 137 | #62.62.87.234 example.com 138 | #62.62.87.234 another.com 139 | 62.75.23.62 140 | 141 | ## Development`); 142 | 143 | await new HostWriter().point({ 'dev.example.com': '127.0.0.1' }); 144 | 145 | expect(writtenFile).to.equal(` 146 | ## Production systems 147 | #62.62.87.234 example.com 148 | #62.62.87.234 another.com 149 | 62.75.23.62 150 | 151 | ## Development 152 | 127.0.0.1 dev.example.com`); 153 | }); 154 | }); 155 | 156 | describe('reset method', () => { 157 | it('does nothing if no matching entry is present', async () => { 158 | stubHostFile('#127.0.0.1 example.com'); 159 | 160 | await new HostWriter().reset('example.com'); 161 | 162 | expect(fs.createWriteStream).to.have.not.been.called; 163 | }); 164 | 165 | it('comments out matching entries', async () => { 166 | stubHostFile('127.0.0.1 example.com'); 167 | 168 | await new HostWriter().reset('example.com'); 169 | 170 | expect(writtenFile).to.equal('#127.0.0.1 example.com'); 171 | }); 172 | }); 173 | 174 | describe('untilExit method', () => { 175 | it('assigns the given host addresses, then resets on exit signals', async () => { 176 | try { 177 | stub(process, 'on').returnsThis(); 178 | stub(process, 'exit'); 179 | stubHostFile('#127.0.0.1 example.com'); 180 | 181 | await new HostWriter().untilExit({ 182 | 'example.com': '127.0.0.1', 183 | 'beta.example.com': '127.0.0.2' 184 | }); 185 | 186 | expect(writtenFile).to.equal(['127.0.0.1 example.com', '127.0.0.2 beta.example.com'].join(EOL)); 187 | stubHostFile(writtenFile); 188 | stubWriteStream(); 189 | 190 | const sigtermHandler = process.on.args.find(([handler]) => handler === 'SIGTERM')[1]; 191 | await sigtermHandler(); 192 | 193 | expect(writtenFile).to.equal(['#127.0.0.1 example.com', '#127.0.0.2 beta.example.com'].join(EOL)); 194 | expect(process.exit).to.have.been.calledWith(0); 195 | } finally { 196 | process.on.restore(); 197 | process.exit.restore(); 198 | } 199 | }); 200 | 201 | it('does not block exits if the hostfile cannot be written to', async () => { 202 | try { 203 | stub(process, 'on').returnsThis(); 204 | stub(process, 'exit'); 205 | stubHostFile('#127.0.0.1 example.com'); 206 | 207 | await new HostWriter().untilExit({ 208 | 'example.com': '127.0.0.1', 209 | 'beta.example.com': '127.0.0.2' 210 | }); 211 | 212 | expect(writtenFile).to.equal(['127.0.0.1 example.com', '127.0.0.2 beta.example.com'].join(EOL)); 213 | stubHostFile(writtenFile); 214 | stubFailingWriteStream(); 215 | 216 | const sigtermHandler = process.on.args.find(([handler]) => handler === 'SIGTERM')[1]; 217 | await sigtermHandler(); 218 | expect(process.exit).to.have.been.called; 219 | } finally { 220 | process.on.restore(); 221 | process.exit.restore(); 222 | } 223 | }); 224 | }); 225 | 226 | function stubHostFile(contents) { 227 | fs.createReadStream.withArgs('/etc/hosts').returns(new MemoryStream(contents)); 228 | } 229 | 230 | function stubWriteStream() { 231 | writtenFile = ''; 232 | fs.createWriteStream.withArgs('/etc/hosts').returns(new Writable({ 233 | write(chunk, encoding, cb) { 234 | writtenFile += chunk; 235 | cb(null); 236 | } 237 | })); 238 | } 239 | 240 | function stubFailingWriteStream() { 241 | writtenFile = ''; 242 | fs.createWriteStream.withArgs('/etc/hosts').returns(new Writable({ 243 | write(chunk, encoding, cb) { 244 | cb(new Error('Permission denied')); 245 | } 246 | })); 247 | } 248 | }); 249 | --------------------------------------------------------------------------------