From 14525be679695accfb7cf292bcada3511b808502 Mon Sep 17 00:00:00 2001 From: johackim Date: Sat, 20 Apr 2024 01:43:53 +0200 Subject: [PATCH] refactor: separate utility functions and business logic --- __tests__/block.spec.js | 134 ++++++++++++++++ __tests__/commands.spec.js | 113 +++++++------- __tests__/daemon.spec.js | 59 +++---- __tests__/shield.spec.js | 57 +++++++ __tests__/utils.spec.js | 286 +--------------------------------- __tests__/whitelist.spec.js | 35 +++++ package.json | 1 + src/block.js | 78 ++++++++++ src/commands.js | 17 +- src/config.js | 36 +++++ src/daemon.js | 98 +++++++----- src/dns.js | 2 +- src/index.js | 2 +- src/shield.js | 48 ++++++ src/socket.io.js | 6 +- src/socket.js | 9 +- src/utils.js | 299 ++++++------------------------------ src/whitelist.js | 27 ++++ 18 files changed, 636 insertions(+), 671 deletions(-) create mode 100644 __tests__/block.spec.js create mode 100644 __tests__/shield.spec.js create mode 100644 __tests__/whitelist.spec.js create mode 100644 src/block.js create mode 100644 src/config.js create mode 100644 src/shield.js create mode 100644 src/whitelist.js diff --git a/__tests__/block.spec.js b/__tests__/block.spec.js new file mode 100644 index 0000000..a6c1ff5 --- /dev/null +++ b/__tests__/block.spec.js @@ -0,0 +1,134 @@ +import { config } from '../src/config'; +import { + blockDistraction, + isWithinTimeRange, + unblockDistraction, + isValidDistraction, + isDistractionBlocked, +} from '../src/block'; + +import('../src/socket'); + +jest.mock('child_process', () => ({ + execSync: jest.fn().mockImplementation(() => false), +})); + +beforeEach(() => { + config.blocklist = []; + config.whitelist = []; + config.shield = false; +}); + +test('Should check a distraction', async () => { + expect(isValidDistraction({ name: '' })).toBe(false); + expect(isValidDistraction({ name: '*' })).toBe(false); + expect(isValidDistraction({ name: '*.*' })).toBe(true); + expect(isValidDistraction({ name: '*.example.com' })).toBe(true); + expect(isValidDistraction({ name: 'example.com' })).toBe(true); + expect(isValidDistraction({ name: 'chromium' })).toBe(true); + expect(isValidDistraction({ name: 'chromium', time: 'badtime' })).toBe(false); + expect(isValidDistraction({ name: 'chromium', time: '1m' })).toBe(true); + expect(isValidDistraction({ name: 'inexistent' })).toBe(false); +}); + +test('Should check if a time is within an interval', async () => { + const currentDate = new Date('2021-01-01T12:00:00Z'); + jest.spyOn(global, 'Date').mockImplementation(() => currentDate); + + expect(isWithinTimeRange('0h-23h')).toBe(true); + expect(isWithinTimeRange('0h-19h')).toBe(true); + expect(isWithinTimeRange('20h-23h')).toBe(false); +}); + +test('Should block a distraction', async () => { + blockDistraction({ name: 'example.com' }); + + expect(isDistractionBlocked('example.com')).toEqual(true); +}); + +test('Should block a distraction with a duration', async () => { + blockDistraction({ name: 'twitter.com', time: '2m' }); + + expect(isDistractionBlocked('twitter.com')).toBe(true); + expect(config.blocklist).toEqual([{ name: 'twitter.com', time: '2m', timeout: expect.any(Number) }]); +}); + +test('Should block a distraction with a time-based interval', async () => { + const currentDate = new Date('2021-01-01T12:00:00Z'); + jest.spyOn(global, 'Date').mockImplementation(() => currentDate); + + blockDistraction({ name: 'example.com', time: '0h-23h' }); + + expect(isDistractionBlocked('example.com')).toBe(true); +}); + +test('Should block a specific subdomain', async () => { + blockDistraction({ name: 'www.example.com' }); + + expect(isDistractionBlocked('www.example.com')).toBe(true); + expect(isDistractionBlocked('example.com')).toBe(false); +}); + +test('Should block all subdomains of a domain with a wildcard', async () => { + blockDistraction({ name: '*.example.com' }); + + expect(isDistractionBlocked('www.example.com')).toBe(true); +}); + +test('Should block all subdomains of a domain with a wildcard & a time-based interval', async () => { + const currentDate = new Date('2021-01-01T12:00:00Z'); + jest.spyOn(global, 'Date').mockImplementation(() => currentDate); + + blockDistraction({ name: '*.example.com', time: '0h-19h' }); + + expect(isDistractionBlocked('www.example.com')).toBe(true); +}); + +test('Should block all domains with *.*', async () => { + blockDistraction({ name: '*.*' }); + + expect(isDistractionBlocked('example.com')).toBe(true); +}); + +test('Should not block an app with a time-based interval', async () => { + const currentDate = new Date('2021-01-01T22:00:00Z'); + jest.spyOn(global, 'Date').mockImplementation(() => currentDate); + + blockDistraction({ name: 'chromium', time: '0h-20h' }); + + expect(isDistractionBlocked('chromium')).toBe(false); +}); + +test('Should not block a subdomain of a domain with a wildcard & a time-based interval', async () => { + const currentDate = new Date('2021-01-01T20:00:00Z'); + jest.spyOn(global, 'Date').mockImplementation(() => currentDate); + + blockDistraction({ name: '*.example.com', time: '0h-19h' }); + + expect(isDistractionBlocked('www.example.com')).toBe(false); +}); + +test('Should not block apps if *.* is in the blocklist', async () => { + blockDistraction({ name: '*.*' }); + + expect(isDistractionBlocked('chromium')).toBe(false); +}); + +test('Should unblock a distraction', async () => { + blockDistraction({ name: 'example.com' }); + + unblockDistraction({ name: 'example.com' }); + + expect(isDistractionBlocked('example.com')).toBe(false); +}); + +test('Should run isDistractionBlocked in less than 150ms with a large blocklist', async () => { + config.blocklist = Array.from({ length: 500000 }, (_, i) => ({ name: `${i + 1}.com` })); + + isDistractionBlocked('example.com'); + const start = process.hrtime(); + isDistractionBlocked('example.com'); + const end = process.hrtime(start); + + expect(end[1] / 1000000).toBeLessThan(150); +}); diff --git a/__tests__/commands.spec.js b/__tests__/commands.spec.js index 5ca182d..b31a274 100644 --- a/__tests__/commands.spec.js +++ b/__tests__/commands.spec.js @@ -1,4 +1,4 @@ -import { config, editConfig } from '../src/utils'; +import { config } from '../src/config'; import { helpCmd, versionCmd, blockCmd, whitelistCmd, unblockCmd, shieldCmd } from '../src/commands'; jest.mock('net', () => ({ @@ -19,113 +19,114 @@ beforeEach(() => { jest.spyOn(console, 'log').mockImplementation(() => {}); }); -test('As a user, I can display the help', async () => { +test('Should display the help', async () => { helpCmd(); expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Usage: ulysse [OPTIONS]')); }); -test('As a user, I can block a domain', async () => { +test('Should display the version', async () => { + versionCmd(); + + expect(console.log).toHaveBeenCalledWith(expect.stringMatching(/\d+\.\d+\.\d+/)); +}); + +test('Should block a domain', async () => { blockCmd('example.com'); expect(console.log).toHaveBeenCalledWith('Blocking example.com'); }); -test('As a user, I can block an app', async () => { +test('Should block an app', async () => { blockCmd('chromium'); expect(console.log).toHaveBeenCalledWith('Blocking chromium'); }); -test('As a user, I cannot block an invalid distraction', async () => { - blockCmd('inexistent'); - - expect(console.log).toHaveBeenCalledWith('You must provide a valid distraction.'); -}); - -test('As a user, I can whitelist a domain', async () => { - whitelistCmd('youtube.com'); - - expect(console.log).toHaveBeenCalledWith('Whitelisting youtube.com'); -}); - -test('As a user, I can whitelist a domain with a wildcard', async () => { - whitelistCmd('*.youtube.com'); - - expect(console.log).toHaveBeenCalledWith('Whitelisting *.youtube.com'); -}); - -test('As a user, I can unblock a domain', async () => { +test('Should unblock a domain', async () => { unblockCmd('example.com'); expect(console.log).toHaveBeenCalledWith('Unblocking example.com'); }); -test('As a user, I can unblock an app', async () => { +test('Should unblock an app', async () => { unblockCmd('chromium'); expect(console.log).toHaveBeenCalledWith('Unblocking chromium'); }); -test('As a user, I can enable shield mode', async () => { - shieldCmd(); +test('Should not block an invalid distraction', async () => { + blockCmd('inexistent'); - expect(console.log).toHaveBeenCalledWith('Shield mode enabled.'); + expect(console.log).toHaveBeenCalledWith('You must provide a valid distraction.'); }); -test('As a user, I cannot enable shield mode if it is already enabled', async () => { - const passwordHash = 'd97e609b03de7506d4be3bee29f2431b40e375b33925c2f7de5466ce1928da1b'; - editConfig({ shield: true, passwordHash }); +test('Should not unblock a distraction if shield mode is enabled', async () => { + config.shield = true; + config.passwordHash = 'd97e609b03de7506d4be3bee29f2431b40e375b33925c2f7de5466ce1928da1b'; - shieldCmd(); + unblockCmd('youtube.com'); - expect(console.log).toHaveBeenCalledWith('Shield mode already enabled.'); + expect(console.log).toHaveBeenCalledWith('You must disable the shield mode first.'); }); -test('As a user, I can disable shield mode', async () => { - process.argv = ['ulysse', '-s', 'off', '-p', 'ulysse']; - const passwordHash = 'd97e609b03de7506d4be3bee29f2431b40e375b33925c2f7de5466ce1928da1b'; - editConfig({ shield: true, passwordHash }); +test('Should not whitelist a distraction if shield mode is enabled', async () => { + config.shield = true; + config.passwordHash = 'd97e609b03de7506d4be3bee29f2431b40e375b33925c2f7de5466ce1928da1b'; - shieldCmd('off'); + whitelistCmd('youtube.com'); - expect(console.log).toHaveBeenCalledWith('Shield mode disabled.'); + expect(console.log).toHaveBeenCalledWith('You must disable the shield mode first.'); +}); + +test('Should not whitelist an app with a relative path', async () => { + whitelistCmd('signal-desktop'); + + expect(console.log).toHaveBeenCalledWith('You must provide a valid distraction.'); }); -test('As a user, I cannot disable shield mode if it is already disabled', async () => { - editConfig({ shield: false, password: 'ulysse' }); +test('Should not disable shield mode if it is already disabled', async () => { + config.shield = false; + config.password = 'ulysse'; shieldCmd('off'); expect(console.log).toHaveBeenCalledWith('Shield mode already disabled.'); }); -test('As a user, I cannot unblock a distraction if shield mode is enabled', async () => { - const passwordHash = 'd97e609b03de7506d4be3bee29f2431b40e375b33925c2f7de5466ce1928da1b'; - editConfig({ shield: true, passwordHash }); +test('Should not enable shield mode if it is already enabled', async () => { + config.shield = true; + config.passwordHash = 'd97e609b03de7506d4be3bee29f2431b40e375b33925c2f7de5466ce1928da1b'; - unblockCmd('youtube.com'); + shieldCmd(); - expect(console.log).toHaveBeenCalledWith('You must disable the shield mode first.'); + expect(console.log).toHaveBeenCalledWith('Shield mode already enabled.'); }); -test('As a user, I cannot whitelist a distraction if shield mode is enabled', async () => { - const passwordHash = 'd97e609b03de7506d4be3bee29f2431b40e375b33925c2f7de5466ce1928da1b'; - editConfig({ shield: true, passwordHash }); - +test('Should whitelist a domain', async () => { whitelistCmd('youtube.com'); - expect(console.log).toHaveBeenCalledWith('You must disable the shield mode first.'); + expect(console.log).toHaveBeenCalledWith('Whitelisting youtube.com'); }); -test('As a user, I cannot whitelist an app with a relative path', async () => { - whitelistCmd('signal-desktop'); +test('Should whitelist a domain with a wildcard', async () => { + whitelistCmd('*.youtube.com'); - expect(console.log).toHaveBeenCalledWith('You must provide a valid distraction.'); + expect(console.log).toHaveBeenCalledWith('Whitelisting *.youtube.com'); }); -test('As a user, I can display the version of Ulysse', async () => { - versionCmd(); +test('Should enable shield mode', async () => { + shieldCmd(); - expect(console.log).toHaveBeenCalledWith(expect.stringMatching(/\d+\.\d+\.\d+/)); + expect(console.log).toHaveBeenCalledWith('Shield mode enabled.'); +}); + +test('Should disable shield mode', async () => { + process.argv = ['ulysse', '-s', 'off', '-p', 'ulysse']; + config.shield = true; + config.passwordHash = 'd97e609b03de7506d4be3bee29f2431b40e375b33925c2f7de5466ce1928da1b'; + + shieldCmd('off'); + + expect(console.log).toHaveBeenCalledWith('Shield mode disabled.'); }); diff --git a/__tests__/daemon.spec.js b/__tests__/daemon.spec.js index bf69344..a0faed4 100644 --- a/__tests__/daemon.spec.js +++ b/__tests__/daemon.spec.js @@ -1,48 +1,51 @@ import fs from 'fs'; -import * as Utils from '../src/utils'; - -jest.mock('../src/utils'); - -jest.mock('dgram', () => ({ - createSocket: jest.fn().mockReturnThis(), - bind: jest.fn().mockReturnThis(), - on: jest.fn(), -})); - -jest.mock('net', () => ({ - createServer: jest.fn().mockReturnThis(), - listen: jest.fn().mockReturnThis(), +import { config } from '../src/config'; +import { getRunningApps } from '../src/utils'; +import { blockDistraction } from '../src/block'; +import { handleAppBlocking, handleTimeout, updateResolvConf } from '../src/daemon'; + +jest.mock('../src/utils', () => ({ + ...jest.requireActual('../src/utils'), + isSudo: jest.fn().mockImplementation(() => true), })); jest.mock('child_process', () => ({ execSync: jest.fn().mockImplementation(() => false), -})); - -jest.mock('socket.io-client', () => ({ - io: jest.fn(() => ({ - emit: jest.fn(), - on: jest.fn(), - })), + exec: jest.fn().mockImplementation(() => false), })); beforeEach(() => { + config.blocklist = []; + config.whitelist = []; + config.shield = false; jest.spyOn(console, 'log').mockImplementation(() => {}); - jest.spyOn(Utils, 'isSudo').mockReturnValue(true); - jest.spyOn(Utils, 'getRunningBlockedApps').mockReturnValue([{ name: 'chromium', pid: 123 }]); - jest.spyOn(Utils, 'updateResolvConf').mockImplementation(() => { - fs.writeFileSync(process.env.RESOLV_CONF_PATH, 'nameserver 127.0.0.1', 'utf8'); - }); }); test('Should block a running app', async () => { - await import('../src/daemon'); + blockDistraction({ name: 'node' }); + + handleAppBlocking(); - expect(console.log).toHaveBeenCalledWith('Blocking chromium'); + expect(console.log).toHaveBeenCalledWith('Blocking node'); +}); + +test('Should get all running apps', async () => { + const apps = getRunningApps(); + + expect(JSON.stringify(apps)).toContain('node'); }); test('Should edit /etc/resolv.conf', async () => { - await import('../src/daemon'); + updateResolvConf('127.0.0.1'); expect(fs.existsSync(process.env.RESOLV_CONF_PATH)).toBe(true); expect(fs.readFileSync(process.env.RESOLV_CONF_PATH, 'utf8')).toBe('nameserver 127.0.0.1'); }); + +test('Should remove a distraction from blocklist if timeout is reached', async () => { + config.blocklist = [{ name: 'chromium' }, { name: 'example.com', timeout: 1708617136 }]; + + handleTimeout(); + + expect(config.blocklist).toEqual([{ name: 'chromium' }]); +}); diff --git a/__tests__/shield.spec.js b/__tests__/shield.spec.js new file mode 100644 index 0000000..6f50f75 --- /dev/null +++ b/__tests__/shield.spec.js @@ -0,0 +1,57 @@ +import { config } from '../src/config'; +import { enableShieldMode, disableShieldMode } from '../src/shield'; +import { whitelistDistraction } from '../src/whitelist'; +import { blockDistraction, unblockDistraction } from '../src/block'; + +jest.mock('child_process', () => ({ + execSync: jest.fn().mockImplementation(() => false), +})); + +beforeEach(() => { + config.blocklist = []; + config.whitelist = []; + config.shield = false; + config.password = 'ulysse'; + jest.spyOn(console, 'log').mockImplementation(() => {}); +}); + +test('Should enable shield mode', async () => { + enableShieldMode('ulysse'); + + const passwordHash = 'd97e609b03de7506d4be3bee29f2431b40e375b33925c2f7de5466ce1928da1b'; + expect(config.passwordHash).toBe(passwordHash); + expect(config.shield).toBe(true); +}); + +test('Should disable shield mode', async () => { + enableShieldMode('ulysse'); + + disableShieldMode('ulysse'); + + expect(config.shield).toBe(false); +}); + +test('Should not disable shield mode if bad password', async () => { + enableShieldMode('ulysse'); + + disableShieldMode('badpassword'); + + expect(config.shield).toBe(true); +}); + +test('Should not unblock a distraction if shield mode is enabled', async () => { + blockDistraction({ name: 'example.com' }); + enableShieldMode('ulysse'); + + unblockDistraction({ name: 'example.com' }); + + expect(config.blocklist).toEqual([{ name: 'example.com' }]); +}); + +test('Should not whitelist a distraction if shield mode is enabled', async () => { + enableShieldMode('ulysse'); + + whitelistDistraction({ name: 'example.com' }); + + expect(config.whitelist).toEqual([]); +}); diff --git a/__tests__/utils.spec.js b/__tests__/utils.spec.js index 918eb25..1e3c698 100644 --- a/__tests__/utils.spec.js +++ b/__tests__/utils.spec.js @@ -1,106 +1,4 @@ -import { - editConfig, - getTimeType, - createTimeout, - getRunningApps, - isWithinTimeRange, - isValidDistraction, - isDistractionBlocked, - getRunningBlockedApps, -} from '../src/utils'; - -jest.mock('child_process', () => ({ - execSync: jest.fn().mockImplementation(() => false), -})); - -beforeEach(() => { - editConfig({ shield: false, password: 'ulysse', blocklist: [], whitelist: [] }); -}); - -test('Should add a distraction to blocklist', async () => { - const distraction = { name: 'example.com' }; - - const config = editConfig({ blocklist: [distraction] }); - - expect(config.blocklist).toEqual(expect.arrayContaining([distraction])); -}); - -test('Should remove a distraction from blocklist', async () => { - editConfig({ blocklist: [{ name: 'example.com' }] }); - - const { blocklist } = editConfig({ blocklist: [] }); - - expect(blocklist).toEqual([]); -}); - -test('Should not remove a distraction from blocklist if shield mode is enabled', async () => { - const distraction = { name: 'example.com' }; - const passwordHash = 'd97e609b03de7506d4be3bee29f2431b40e375b33925c2f7de5466ce1928da1b'; - editConfig({ shield: true, passwordHash, blocklist: [distraction] }); - - const config = editConfig({ blocklist: [] }); - - expect(config.blocklist).toEqual(expect.arrayContaining([distraction])); -}); - -test('Should whitelist a distraction', async () => { - const distraction = { name: 'example.com' }; - - const config = editConfig({ whitelist: [distraction] }); - - expect(config.whitelist).toEqual(expect.arrayContaining([distraction])); -}); - -test('Should not whitelist a distraction if shield mode is enabled', async () => { - const distraction = { name: 'example.com' }; - const passwordHash = 'd97e609b03de7506d4be3bee29f2431b40e375b33925c2f7de5466ce1928da1b'; - editConfig({ shield: true, passwordHash, blocklist: [], whitelist: [] }); - - const config = editConfig({ whitelist: [distraction] }); - - expect(config.whitelist).toEqual([]); -}); - -test('Should enable shield mode', async () => { - const passwordHash = 'd97e609b03de7506d4be3bee29f2431b40e375b33925c2f7de5466ce1928da1b'; - - const config = editConfig({ shield: true, passwordHash }); - - expect(config.passwordHash).toBe(passwordHash); -}); - -test('Should disable shield mode', async () => { - const passwordHash = 'd97e609b03de7506d4be3bee29f2431b40e375b33925c2f7de5466ce1928da1b'; - editConfig({ passwordHash, shield: true }); - - const config = editConfig({ shield: false, password: 'ulysse' }); - - expect(config.shield).toBe(false); -}); - -test('Should not disable shield mode if password is wrong', async () => { - const passwordHash = 'd97e609b03de7506d4be3bee29f2431b40e375b33925c2f7de5466ce1928da1b'; - editConfig({ passwordHash, shield: true }); - - const config = editConfig({ shield: false, password: 'badpassword' }); - - expect(config.shield).toBe(true); -}); - -test('Should get all running apps', async () => { - const apps = getRunningApps(); - - expect(JSON.stringify(apps)).toContain('node'); -}); - -test('Should create a timeout incremented by a duration', async () => { - const timestamp = 1704063600; - expect(createTimeout('30m', timestamp)).toBe(1704065400); - expect(createTimeout('2h', timestamp)).toBe(1704070800); - expect(createTimeout('1h59m', timestamp)).toBe(1704070740); - expect(createTimeout('1d', timestamp)).toBe(1704150000); - expect(createTimeout('1m', timestamp)).toBe(1704063660); -}); +import { getTimeType, createTimeout } from '../src/utils'; test('Should get duration time type', () => { expect(getTimeType('1d')).toBe('duration'); @@ -109,179 +7,11 @@ test('Should get duration time type', () => { expect(getTimeType('10h-18h')).toBe('interval'); }); -test('Should block an app', async () => { - editConfig({ blocklist: [{ name: 'chromium' }] }); - - const isBlocked = isDistractionBlocked('chromium'); - - expect(isBlocked).toBe(true); -}); - -test('Should block a distraction with a duration', async () => { - const distraction = { name: 'twitter.com', time: '2m' }; - editConfig({ blocklist: [distraction] }); - - const isBlocked = isDistractionBlocked('twitter.com'); - - expect(isBlocked).toBe(true); -}); - -test('Should block an app with a time-based interval', async () => { - const currentDate = new Date('2021-01-01T12:00:00Z'); - editConfig({ blocklist: [{ name: 'chromium', time: '0h-23h' }] }); - jest.spyOn(global, 'Date').mockImplementation(() => currentDate); - - const isBlocked = isDistractionBlocked('chromium'); - - expect(isBlocked).toBe(true); -}); - -test('Should not block an app with a time-based interval', async () => { - const currentDate = new Date('2021-01-01T22:00:00Z'); - jest.spyOn(global, 'Date').mockImplementation(() => currentDate); - editConfig({ blocklist: [{ name: 'chromium', time: '0h-20h' }] }); - - const isBlocked = isDistractionBlocked('chromium'); - - expect(isBlocked).toBe(false); -}); - -test('Should block a specific subdomain', async () => { - editConfig({ blocklist: [{ name: 'www.example.com' }] }); - - expect(isDistractionBlocked('www.example.com')).toBe(true); - expect(isDistractionBlocked('example.com')).toBe(false); -}); - -test('Should block a distraction with a time-based interval', async () => { - const currentDate = new Date('2021-01-01T12:00:00Z'); - editConfig({ blocklist: [{ name: 'example.com', time: '0h-23h' }] }); - jest.spyOn(global, 'Date').mockImplementation(() => currentDate); - - const isBlocked = isDistractionBlocked('example.com'); - - expect(isBlocked).toBe(true); -}); - -test('Should block all subdomains of a domain with a wildcard', async () => { - editConfig({ blocklist: [{ name: '*.example.com' }] }); - - const isBlocked = isDistractionBlocked('www.example.com'); - - expect(isBlocked).toBe(true); -}); - -test('Should block all subdomains of a domain with a wildcard & a subdomain', async () => { - editConfig({ blocklist: [{ name: '*.www.example.com' }] }); - - const isBlocked = isDistractionBlocked('www.example.com'); - - expect(isBlocked).toBe(true); -}); - -test('Should block all subdomains of a domain with a wildcard & a time-based interval', async () => { - const currentDate = new Date('2021-01-01T12:00:00Z'); - editConfig({ blocklist: [{ name: '*.example.com', time: '0h-19h' }] }); - jest.spyOn(global, 'Date').mockImplementation(() => currentDate); - - const isBlocked = isDistractionBlocked('www.example.com'); - - expect(isBlocked).toBe(true); -}); - -test('Should not block a subdomain of a domain with a wildcard & a time-based interval', async () => { - const currentDate = new Date('2021-01-01T20:00:00Z'); - editConfig({ blocklist: [{ name: '*.example.com', time: '0h-19h' }] }); - jest.spyOn(global, 'Date').mockImplementation(() => currentDate); - - const isBlocked = isDistractionBlocked('www.example.com'); - - expect(isBlocked).toBe(false); -}); - -test('Should block all domains with *.*', async () => { - editConfig({ blocklist: [{ name: '*.*' }] }); - - const isBlocked = isDistractionBlocked('example.com'); - - expect(isBlocked).toBe(true); -}); - -test('Should block all domains with *.* except for the whitelist', async () => { - editConfig({ blocklist: [{ name: '*.*' }], whitelist: [{ name: 'www.example.com' }] }); - - const isBlocked = isDistractionBlocked('www.example.com'); - - expect(isBlocked).toBe(false); -}); - -test('Should not block apps if *.* is in the blocklist', async () => { - editConfig({ blocklist: [{ name: '*.*' }] }); - - const isBlocked = isDistractionBlocked('chromium'); - - expect(isBlocked).toBe(false); -}); - -test('Should not block a domain if it is in the whitelist with a wildcard', async () => { - editConfig({ blocklist: [{ name: '*.*' }], whitelist: [{ name: '*.example.com' }] }); - - const isBlocked = isDistractionBlocked('www.example.com'); - - expect(isBlocked).toBe(false); -}); - -test('Should remove a distraction from blocklist if timeout is reached and shield mode is enabled', async () => { - editConfig({ - shield: true, - blocklist: [{ name: 'chromium' }, { name: '*.*', timeout: 1708617136 }], - passwordHash: 'd97e609b03de7506d4be3bee29f2431b40e375b33925c2f7de5466ce1928da1b', - }); - - const { blocklist } = editConfig({ blocklist: [{ name: 'chromium' }] }); - - expect(blocklist).toEqual([{ name: 'chromium' }]); -}); - -test('Should check if a time is within an interval', async () => { - const currentDate = new Date('2021-01-01T12:00:00Z'); - jest.spyOn(global, 'Date').mockImplementation(() => currentDate); - - expect(isWithinTimeRange('0h-23h')).toBe(true); - expect(isWithinTimeRange('0h-19h')).toBe(true); - expect(isWithinTimeRange('20h-23h')).toBe(false); -}); - -test('Should check distraction value', async () => { - expect(isValidDistraction({ name: '' })).toBe(false); - expect(isValidDistraction({ name: '*' })).toBe(false); - expect(isValidDistraction({ name: '*.*' })).toBe(true); - expect(isValidDistraction({ name: '*.example.com' })).toBe(true); - expect(isValidDistraction({ name: 'example.com' })).toBe(true); - expect(isValidDistraction({ name: 'chromium' })).toBe(true); - expect(isValidDistraction({ name: 'chromium', time: 'badtime' })).toBe(false); - expect(isValidDistraction({ name: 'chromium', time: '1m' })).toBe(true); - expect(isValidDistraction({ name: 'inexistent' })).toBe(false); -}); - -test('Should run isDistractionBlocked in less than 150ms with a large blocklist', async () => { - editConfig({ blocklist: Array.from({ length: 500000 }, (_, i) => ({ name: `${i + 1}.com` })) }); - - isDistractionBlocked('example.com'); - - const start = process.hrtime(); - isDistractionBlocked('example.com'); - const end = process.hrtime(start); - - expect(end[1] / 1000000).toBeLessThan(150); -}); - -test('Should run getRunningBlockedApps in less than 150ms with a large blocklist', async () => { - editConfig({ blocklist: Array.from({ length: 500000 }, (_, i) => ({ name: `${i + 1}.com` })) }); - - const start = process.hrtime(); - getRunningBlockedApps(); - const end = process.hrtime(start); - - expect(end[1] / 1000000).toBeLessThan(150); +test('Should create a timeout incremented by a duration', async () => { + const timestamp = 1704063600; + expect(createTimeout('1m', timestamp)).toBe(1704063660); + expect(createTimeout('1d', timestamp)).toBe(1704150000); + expect(createTimeout('2h', timestamp)).toBe(1704070800); + expect(createTimeout('30m', timestamp)).toBe(1704065400); + expect(createTimeout('1h59m', timestamp)).toBe(1704070740); }); diff --git a/__tests__/whitelist.spec.js b/__tests__/whitelist.spec.js new file mode 100644 index 0000000..17cf22b --- /dev/null +++ b/__tests__/whitelist.spec.js @@ -0,0 +1,35 @@ +import { config } from '../src/config'; +import { blockDistraction, isDistractionBlocked } from '../src/block'; +import { whitelistDistraction } from '../src/whitelist'; + +jest.mock('child_process', () => ({ + execSync: jest.fn().mockImplementation(() => false), +})); + +beforeEach(() => { + config.blocklist = []; + config.whitelist = []; + config.shield = false; +}); + +test('Should whitelist a distraction', async () => { + const distraction = { name: 'example.com' }; + + whitelistDistraction(distraction); + + expect(config.whitelist).toEqual([distraction]); +}); + +test('Should not block a domain if it is in the whitelist', async () => { + blockDistraction({ name: '*.*' }); + whitelistDistraction({ name: 'www.example.com' }); + + expect(isDistractionBlocked('www.example.com')).toBe(false); +}); + +test('Should not block a domain if it is in the whitelist with a wildcard', async () => { + blockDistraction({ name: '*.*' }); + whitelistDistraction({ name: '*.example.com' }); + + expect(isDistractionBlocked('www.example.com')).toBe(false); +}); diff --git a/package.json b/package.json index 70b1f5e..c7386c6 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "rollup": "^4.12.0" }, "scripts": { + "lint": "eslint src", "prepublishOnly": "npm run build", "prebuild": "rm -rf dist", "build": "rollup --bundleConfigAsCjs -c", diff --git a/src/block.js b/src/block.js new file mode 100644 index 0000000..11e4e91 --- /dev/null +++ b/src/block.js @@ -0,0 +1,78 @@ +import fs from 'fs'; +import { config, editConfig } from './config'; +import { isDistractionWhitelisted } from './whitelist'; +import { DOMAIN_REGEX } from './constants'; +import { removeDuplicates, getRootDomain, getTimeType, createTimeout } from './utils'; + +export const blockDistraction = (distraction) => { + config.blocklist = removeDuplicates([...config.blocklist, distraction]); + config.blocklist = config.blocklist.map((d) => { + if (getTimeType(d.time) === 'duration') { + return { ...d, timeout: createTimeout(d.time) }; + } + + return d; + }); + + editConfig(config); +}; + +export const unblockDistraction = (distraction) => { + if (config.shield) return; + + config.blocklist = config.blocklist.filter(({ name, time }) => JSON.stringify({ name, time }) !== JSON.stringify(distraction)); + + editConfig(config); +}; + +export const isValidDomain = (domain) => DOMAIN_REGEX.test(domain); + +export const isDomainBlocked = (domain, rule, rootDomain) => { + if (!isValidDomain(domain)) return false; + return rule === '*.*' || rule === domain || rule === `*.${rootDomain}` || rule === `*.${domain}`; +}; + +export const isWithinTimeRange = (time) => { + if (!time || getTimeType(time) !== 'interval') return true; + + const [start, end] = time.split('-').map((t) => parseInt(t, 10)); + const hour = new Date().getHours(); + + return hour >= start && hour < end; +}; + +export const isDistractionBlocked = (distraction) => { + if (isDistractionWhitelisted(distraction)) return false; + + const rootDomain = getRootDomain(distraction); + const { blocklist } = config; + + return blocklist.some(({ name, time }) => (name === distraction || isDomainBlocked(distraction, name, rootDomain)) && isWithinTimeRange(time)); +}; + +export const isValidApp = (app) => { + const paths = process.env.PATH.split(':'); + + if (!app) return false; + + if (fs.existsSync(app)) return true; + + return paths.some((path) => fs.existsSync(`${path}/${app}`)); +}; + +const isValidTime = (time) => getTimeType(time) !== 'unknown'; + +export const isValidDistraction = (distraction) => { + const { name = '', time = '' } = distraction; + + if (time && !isValidTime(time)) return false; + + if (name === '*.*') return true; + + if (name.includes('*.')) { + const [, domain] = name.split('*.'); + return isValidDomain(domain); + } + + return isValidDomain(name) || isValidApp(name); +}; diff --git a/src/commands.js b/src/commands.js index caf7070..13776e6 100644 --- a/src/commands.js +++ b/src/commands.js @@ -1,18 +1,11 @@ import path from 'path'; +import { config } from './config'; +import { getParam } from './utils'; import { version } from '../package.json'; import { HELP_MESSAGE } from './constants'; -import { - config, - getParam, - isValidDomain, - enableShieldMode, - disableShieldMode, - isValidPassword, - blockDistraction, - isValidDistraction, - unblockDistraction, - whitelistDistraction, -} from './utils'; +import { whitelistDistraction } from './whitelist'; +import { isValidPassword, enableShieldMode, disableShieldMode } from './shield'; +import { isValidDistraction, isValidDomain, blockDistraction, unblockDistraction } from './block'; export const helpCmd = () => { console.log(HELP_MESSAGE); diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..b1f0b21 --- /dev/null +++ b/src/config.js @@ -0,0 +1,36 @@ +import fs from 'fs'; +import net from 'net'; +import { dirname } from 'path'; +import { isSudo, tryCatch } from './utils'; +import { CONFIG_PATH, DEFAULT_CONFIG, SOCKET_PATH } from './constants'; + +export const sendDataToSocket = (data) => { + const client = net.createConnection(SOCKET_PATH); + + if (typeof data === 'object') { + client.write(JSON.stringify(data)); + } else { + client.write(data); + } + + client.end(); +}; + +export const config = (tryCatch(() => { + if (!fs.existsSync(CONFIG_PATH)) { + fs.mkdirSync(dirname(CONFIG_PATH), { recursive: true }); + fs.writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 4), 'utf8'); + } + + return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); +}, DEFAULT_CONFIG))(); + +export const editConfig = (newConfig) => { + if (isSudo()) { + fs.writeFileSync(CONFIG_PATH, JSON.stringify(newConfig, null, 4), 'utf8'); + } else { + sendDataToSocket(newConfig); + } + + return newConfig; +}; diff --git a/src/daemon.js b/src/daemon.js index 3947967..1bdacad 100644 --- a/src/daemon.js +++ b/src/daemon.js @@ -1,62 +1,86 @@ +import fs from 'fs'; import { execSync } from 'child_process'; -import { - config, - isSudo, - editConfig, - updateResolvConf, - sendNotification, - getRunningBlockedApps, -} from './utils'; +import { config } from './config'; +import { isDistractionWhitelisted } from './whitelist'; +import { isWithinTimeRange, isValidDomain } from './block'; +import { DNS_SERVER, RESOLV_CONF_PATH } from './constants'; +import { isSudo, sendNotification, getRunningApps } from './utils'; -if (!isSudo()) { - console.error('You must run this command with sudo.'); - process.exit(1); -} +export const getRunningBlockedApps = () => { + const blockedApps = config.blocklist + .filter(({ name }) => !isValidDomain(name) && !name.includes('*')) + .filter(({ name }) => !isDistractionWhitelisted(name)) + .filter(({ time }) => isWithinTimeRange(time)) + .map(({ name }) => name); + + const runningBlockedApps = getRunningApps() + .filter((a) => blockedApps?.includes(a.cmd) || blockedApps?.includes(a.bin) || blockedApps?.includes(a.name)) + .map((p) => ({ ...p, name: blockedApps.find((b) => b === p.cmd || b === p.name) })); -const handleAppBlocking = () => { + return runningBlockedApps || []; +}; + +export const updateResolvConf = (dnsServer = DNS_SERVER) => { + execSync(`chattr -i ${RESOLV_CONF_PATH}`); + fs.writeFileSync(RESOLV_CONF_PATH, `nameserver ${dnsServer}`, 'utf8'); + execSync(`chattr +i ${RESOLV_CONF_PATH}`); +}; + +export const handleAppBlocking = () => { const blockedApps = getRunningBlockedApps(); for (const app of blockedApps) { - execSync(`kill -9 ${app.pid}`); - console.log(`Blocking ${app.name}`); - sendNotification('Ulysse', `Blocking ${app.name}`); + try { + execSync(`kill -9 ${app.pid} > /dev/null 2>&1`); + console.log(`Blocking ${app.name}`); + sendNotification('Ulysse', `Blocking ${app.name}`); + } catch (e) { + console.error(e); + } } }; -const handleTimeout = () => { - const list = [...config.blocklist, ...config.whitelist]; +export const handleTimeout = () => { + const blocklist = [...config.blocklist, ...config.whitelist]; - const listWithoutTimeout = list.filter(({ timeout }) => { + const newBlockList = blocklist.filter(({ timeout }) => { if (!timeout) return true; return timeout >= Math.floor(Date.now() / 1000); }); - if (listWithoutTimeout.length === list.length) return; + if (newBlockList.length === blocklist.length) return; - editConfig(config); + config.blocklist = newBlockList; }; -const cleanUpAndExit = () => { +export const cleanUpAndExit = () => { updateResolvConf(); process.exit(0); }; -setInterval(() => { - handleAppBlocking(); -}, 1000); +if (!isSudo()) { + console.error('You must run this command with sudo.'); + process.exit(1); +} -setInterval(() => { - handleTimeout(); -}, 60000); +if (process.env.NODE_ENV !== 'test') { + setInterval(() => { + handleAppBlocking(); + }, 1000); -console.log('Starting daemon...'); -updateResolvConf('127.0.0.1'); -handleTimeout(); -handleAppBlocking(); + setInterval(() => { + handleTimeout(); + }, 60000); -process.on('SIGINT', cleanUpAndExit); -process.on('SIGTERM', cleanUpAndExit); + console.log('Starting daemon...'); + updateResolvConf('127.0.0.1'); + handleTimeout(); + handleAppBlocking(); -import('./socket.io'); -import('./socket'); -import('./dns'); + process.on('SIGINT', cleanUpAndExit); + process.on('SIGTERM', cleanUpAndExit); + + import('./socket.io'); + import('./socket'); + import('./dns'); +} diff --git a/src/dns.js b/src/dns.js index e01308f..ed12dc1 100644 --- a/src/dns.js +++ b/src/dns.js @@ -1,6 +1,6 @@ import dgram from 'dgram'; import packet from 'dns-packet'; -import { isDistractionBlocked } from './utils'; +import { isDistractionBlocked } from './block'; import { DNS_SERVER, DNS_PORT } from './constants'; const server = dgram.createSocket('udp4'); diff --git a/src/index.js b/src/index.js index 8d969ce..9da24d8 100755 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,7 @@ #!/usr/bin/env node -import { getParam, getAlias, isDaemonRunning } from './utils'; import * as cmd from './commands'; +import { getParam, getAlias, isDaemonRunning } from './utils'; const commands = { '--help': cmd.helpCmd, diff --git a/src/shield.js b/src/shield.js new file mode 100644 index 0000000..a9e0f34 --- /dev/null +++ b/src/shield.js @@ -0,0 +1,48 @@ +import fs from 'fs'; +import { execSync } from 'child_process'; +import { config } from './config'; +import { isValidApp } from './block'; +import { generatePassword, sha256 } from './utils'; + +export const isValidPassword = (password) => { + if (!password) return false; + const sha256sum = sha256(String(password)); + return sha256sum === config.passwordHash; +}; + +export const enableShieldMode = (password = generatePassword()) => { + const passwordHash = sha256(password); + console.log(`Your password is: ${password}`); + + config.password = password; + config.passwordHash = passwordHash; + config.shield = true; +}; + +export const disableShieldMode = (password) => { + if (isValidPassword(password)) { + config.shield = false; + delete config.passwordHash; + } +}; + +export const blockRoot = () => { + if (process.env.NODE_ENV === 'test') return; + execSync('usermod -s /usr/sbin/nologin root'); + fs.writeFileSync('/etc/sudoers.d/ulysse', `${process.env.SUDO_USER} ALL=(ALL) !ALL`, 'utf8'); + + for (const w of config.whitelist) { + if (isValidApp(w.name)) { + fs.appendFileSync('/etc/sudoers.d/ulysse', `\n${process.env.SUDO_USER} ALL=(ALL) ${w.name}`, 'utf8'); + } + } + + fs.chmodSync('/etc/sudoers.d/ulysse', '0440'); +}; + +export const unblockRoot = () => { + execSync('usermod -s /bin/bash root'); + if (fs.existsSync('/etc/sudoers.d/ulysse')) { + fs.unlinkSync('/etc/sudoers.d/ulysse'); + } +}; diff --git a/src/socket.io.js b/src/socket.io.js index 6d5a886..bc46538 100644 --- a/src/socket.io.js +++ b/src/socket.io.js @@ -1,5 +1,5 @@ import { io } from 'socket.io-client'; -import { config, editConfig } from './utils'; +import { config } from './config'; import { SERVER_HOST } from './constants'; const socket = io(SERVER_HOST); @@ -10,7 +10,9 @@ socket.on('connect', () => { socket.on('synchronize', (newConfig) => { if (new Date(newConfig.date) > new Date(config.date)) { - editConfig({ ...newConfig, date: newConfig.date }); + config.date = newConfig.date; + config.blocklist = newConfig.blocklist; + config.whitelist = newConfig.whitelist; console.log('Synchronize...'); } }); diff --git a/src/socket.js b/src/socket.js index 6138758..26900d1 100644 --- a/src/socket.js +++ b/src/socket.js @@ -1,8 +1,8 @@ import fs from 'fs'; import net from 'net'; -import { editConfig } from './utils'; -import { SOCKET_PATH } from './constants'; import socket from './socket.io'; +import { config, editConfig } from './config'; +import { SOCKET_PATH } from './constants'; if (fs.existsSync(SOCKET_PATH)) fs.unlinkSync(SOCKET_PATH); @@ -14,10 +14,11 @@ const server = net.createServer((connection) => { }); connection.on('end', () => { - const config = JSON.parse(buffer); + const newConfig = JSON.parse(buffer); socket.emit('synchronize', { - ...editConfig({ ...config, date: new Date().toISOString() }), + ...editConfig(newConfig), password: config?.password, + date: new Date().toISOString(), }); }); }); diff --git a/src/utils.js b/src/utils.js index 32d2197..2997c42 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,17 +1,7 @@ import fs from 'fs'; -import net from 'net'; import crypto from 'crypto'; import readline from 'readline'; -import { dirname } from 'path'; -import { exec, execSync } from 'child_process'; -import { - DNS_SERVER, - SOCKET_PATH, - CONFIG_PATH, - DOMAIN_REGEX, - DEFAULT_CONFIG, - RESOLV_CONF_PATH, -} from './constants'; +import { exec } from 'child_process'; export const tryCatch = (fn, fallback = false, retry = 0) => (...args) => { try { @@ -25,216 +15,13 @@ export const tryCatch = (fn, fallback = false, retry = 0) => (...args) => { } }; -export const config = (tryCatch(() => { - if (!fs.existsSync(CONFIG_PATH)) { - fs.mkdirSync(dirname(CONFIG_PATH), { recursive: true }); - fs.writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 4), 'utf8'); - } - - return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); -}, DEFAULT_CONFIG))(); - export const sha256 = (str) => crypto.createHash('sha256').update(str).digest('hex'); -export const isValidPassword = (password) => { - if (!password) return false; - const sha256sum = sha256(String(password)); - return sha256sum === config.passwordHash; -}; - -export const sendDataToSocket = (data) => { - const client = net.createConnection(SOCKET_PATH); - - if (typeof data === 'object') { - client.write(JSON.stringify(data)); - } else { - client.write(data); - } - - client.end(); -}; - -export const isValidApp = (app) => { - const paths = process.env.PATH.split(':'); - - if (!app) return false; - - if (fs.existsSync(app)) return true; - - return paths.some((path) => fs.existsSync(`${path}/${app}`)); -}; - -export const blockRoot = () => { - if (process.env.NODE_ENV === 'test') return; - execSync('usermod -s /usr/sbin/nologin root'); - fs.writeFileSync('/etc/sudoers.d/ulysse', `${process.env.SUDO_USER} ALL=(ALL) !ALL`, 'utf8'); - - for (const w of config.whitelist) { - if (isValidApp(w.name)) { - fs.appendFileSync('/etc/sudoers.d/ulysse', `\n${process.env.SUDO_USER} ALL=(ALL) ${w.name}`, 'utf8'); - } - } - - fs.chmodSync('/etc/sudoers.d/ulysse', '0440'); -}; - -export const unblockRoot = () => { - execSync('usermod -s /bin/bash root'); - if (fs.existsSync('/etc/sudoers.d/ulysse')) { - fs.unlinkSync('/etc/sudoers.d/ulysse'); - } -}; - export const removeDuplicates = (arr) => { const set = new Set(arr.map((e) => JSON.stringify(e))); return Array.from(set).map((e) => JSON.parse(e)); }; -/* eslint-disable-next-line complexity */ -export const editConfig = ({ blocklist = [], whitelist = [], shield, password, passwordHash, date }) => { - config.date = date || new Date().toISOString(); - - config.whitelist = removeDuplicates(config.shield ? config.whitelist : whitelist); - config.blocklist = removeDuplicates(config.shield ? [...config.blocklist, ...blocklist] : blocklist); - config.blocklist = config.blocklist.filter(({ timeout }) => !timeout || timeout >= Math.floor(Date.now() / 1000)); - config.whitelist = config.whitelist.filter(({ timeout }) => !timeout || timeout >= Math.floor(Date.now() / 1000)); - - if (isValidPassword(password)) { - unblockRoot(); - config.shield = false; - delete config.passwordHash; - } - - if (shield && passwordHash) { - blockRoot(); - config.shield = true; - config.passwordHash = passwordHash; - } - - fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 4), 'utf8'); - - return config; -}; - -export const getRunningApps = tryCatch(() => { - const folders = fs.readdirSync('/proc').filter((f) => !Number.isNaN(Number(f))); - - return folders.map((folder) => { - if (!fs.existsSync(`/proc/${folder}/status`) || !fs.existsSync(`/proc/${folder}/cmdline`)) return false; - - const status = fs.readFileSync(`/proc/${folder}/status`, 'utf8'); - const name = status.split('\n')[0].split(':').pop().trim(); - const cmd = fs.readFileSync(`/proc/${folder}/cmdline`, 'utf8').split('\u0000').join(' ').trim(); - const bin = cmd.split(' ').shift(); - - return { pid: folder, cmd, name, bin }; - }).filter((p) => p.name); -}, []); - -export const isValidDomain = (domain) => DOMAIN_REGEX.test(domain); - -export const getTimeType = (time) => { - const durationPattern = /^(\d+d)?(\d+h)?(\d+m)?$/; - const intervalPattern = /^\d+h-\d+h$/; - - if (durationPattern.test(time)) return 'duration'; - if (intervalPattern.test(time)) return 'interval'; - - return 'unknown'; -}; - -const isValidTime = (time) => getTimeType(time) !== 'unknown'; - -export const isValidDistraction = (distraction) => { - const { name = '', time = '' } = distraction; - - if (time && !isValidTime(time)) return false; - - if (name === '*.*') return true; - - if (name.includes('*.')) { - const [, domain] = name.split('*.'); - return isValidDomain(domain); - } - - return isValidDomain(name) || isValidApp(name); -}; - -export const createTimeout = (duration, timestamp = Math.floor(Date.now() / 1000)) => { - const units = { m: 60, h: 3600, d: 86400 }; - const match = duration.match(/(\d+)([mhd])/g); - - return match.reduce((acc, part) => { - const value = parseInt(part, 10); - const unit = part.match(/[mhd]/)[0]; - return acc + value * units[unit]; - }, timestamp); -}; - -export const blockDistraction = (distraction) => { - config.blocklist.push(distraction); - config.blocklist = removeDuplicates(config.blocklist); - config.blocklist = config.blocklist.map((d) => { - if (getTimeType(d.time) === 'duration') { - return { ...d, timeout: createTimeout(d.time) }; - } - - return d; - }); - - sendDataToSocket(config); -}; - -export const unblockDistraction = (distraction) => { - config.blocklist = config.blocklist.filter(({ name, time }) => JSON.stringify({ name, time }) !== JSON.stringify(distraction)); - sendDataToSocket(config); -}; - -export const whitelistDistraction = (distraction) => { - config.whitelist.push(distraction); - config.whitelist = config.whitelist.map((d) => { - if (getTimeType(d.time) === 'duration') { - return { ...d, timeout: createTimeout(d.time) }; - } - - return d; - }); - sendDataToSocket(config); -}; - -export const getRootDomain = (domain) => domain.split('.').slice(-2).join('.'); - -export const isDistractionWhitelisted = (distraction) => { - if (config.whitelist.some((d) => d.name === distraction)) return true; - if (config.whitelist.some((d) => d.name === '*')) return true; - if (config.whitelist.some((d) => d.name === `*.${getRootDomain(distraction)}`)) return true; - - return false; -}; - -export const isWithinTimeRange = (time) => { - if (!time || getTimeType(time) !== 'interval') return true; - - const [start, end] = time.split('-').map((t) => parseInt(t, 10)); - const hour = new Date().getHours(); - - return hour >= start && hour < end; -}; - -export const isDomainBlocked = (domain, rule, rootDomain) => { - if (!isValidDomain(domain)) return false; - return rule === '*.*' || rule === domain || rule === `*.${rootDomain}` || rule === `*.${domain}`; -}; - -export const isDistractionBlocked = (distraction) => { - if (isDistractionWhitelisted(distraction)) return false; - - const rootDomain = getRootDomain(distraction); - const { blocklist } = config; - - return blocklist.some(({ name, time }) => (name === distraction || isDomainBlocked(distraction, name, rootDomain)) && isWithinTimeRange(time)); -}; - export const isSudo = () => !!process.env.SUDO_USER; export const sendNotification = (title, message) => { @@ -247,34 +34,6 @@ export const sendNotification = (title, message) => { exec(`sudo -u $SUDO_USER ${envs} notify-send "${title}" "${message}"`); }; -export const getRunningBlockedApps = () => { - const blockedApps = config.blocklist - .filter(({ name }) => !isValidDomain(name) && !name.includes('*')) - .filter(({ name }) => !isDistractionWhitelisted(name)) - .filter(({ time }) => isWithinTimeRange(time)) - .map(({ name }) => name); - - const runningBlockedApps = getRunningApps() - .filter((a) => blockedApps?.includes(a.cmd) || blockedApps?.includes(a.bin) || blockedApps?.includes(a.name)) - .map((p) => ({ ...p, name: blockedApps.find((b) => b === p.cmd || b === p.name) })); - - return runningBlockedApps || []; -}; - -export const isDaemonRunning = () => { - const apps = getRunningApps(); - - const cmds = ['ulysse -d', 'ulysse --daemon']; - - return cmds.some((cmd) => apps.some((app) => app.cmd.includes(cmd))); -}; - -export const updateResolvConf = (dnsServer = DNS_SERVER) => { - execSync(`chattr -i ${RESOLV_CONF_PATH}`); - fs.writeFileSync(RESOLV_CONF_PATH, `nameserver ${dnsServer}`, 'utf8'); - execSync(`chattr +i ${RESOLV_CONF_PATH}`); -}; - export const generatePassword = (length = 20) => { let password; @@ -288,16 +47,6 @@ export const generatePassword = (length = 20) => { return password; }; -export const enableShieldMode = (password = generatePassword()) => { - const passwordHash = sha256(password); - console.log(`Your password is: ${password}`); - sendDataToSocket({ ...config, passwordHash, password, shield: true }); -}; - -export const disableShieldMode = (password) => { - sendDataToSocket({ ...config, password, shield: false }); -}; - export const displayPrompt = async (message) => { if (process.env.NODE_ENV === 'test') return true; @@ -319,4 +68,50 @@ export const getParam = (key) => { return index !== -1 ? process.argv[index + 1] : undefined; }; +export const createTimeout = (duration, timestamp = Math.floor(Date.now() / 1000)) => { + const units = { m: 60, h: 3600, d: 86400 }; + const match = duration.match(/(\d+)([mhd])/g); + + return match.reduce((acc, part) => { + const value = parseInt(part, 10); + const unit = part.match(/[mhd]/)[0]; + return acc + value * units[unit]; + }, timestamp); +}; + +export const getTimeType = (time) => { + const durationPattern = /^(\d+d)?(\d+h)?(\d+m)?$/; + const intervalPattern = /^\d+h-\d+h$/; + + if (durationPattern.test(time)) return 'duration'; + if (intervalPattern.test(time)) return 'interval'; + + return 'unknown'; +}; + +export const getRunningApps = tryCatch(() => { + const folders = fs.readdirSync('/proc').filter((f) => !Number.isNaN(Number(f))); + + return folders.map((folder) => { + if (!fs.existsSync(`/proc/${folder}/status`) || !fs.existsSync(`/proc/${folder}/cmdline`)) return false; + + const status = fs.readFileSync(`/proc/${folder}/status`, 'utf8'); + const name = status.split('\n')[0].split(':').pop().trim(); + const cmd = fs.readFileSync(`/proc/${folder}/cmdline`, 'utf8').split('\u0000').join(' ').trim(); + const bin = cmd.split(' ').shift(); + + return { pid: folder, cmd, name, bin }; + }).filter((p) => p.name); +}, []); + +export const isDaemonRunning = () => { + const apps = getRunningApps(); + + const cmds = ['ulysse -d', 'ulysse --daemon']; + + return cmds.some((cmd) => apps.some((app) => app.cmd.includes(cmd))); +}; + export const getAlias = (key) => key?.replace('--', '-').slice(0, 2); + +export const getRootDomain = (domain) => domain.split('.').slice(-2).join('.'); diff --git a/src/whitelist.js b/src/whitelist.js new file mode 100644 index 0000000..e1b558e --- /dev/null +++ b/src/whitelist.js @@ -0,0 +1,27 @@ +import fs from 'fs'; +import { config } from './config'; +import { CONFIG_PATH } from './constants'; +import { getRootDomain, removeDuplicates, getTimeType, createTimeout } from './utils'; + +export const whitelistDistraction = (distraction) => { + if (config.shield) return; + + config.whitelist = removeDuplicates([...config.whitelist, distraction]); + config.whitelist = config.whitelist.map((d) => { + if (getTimeType(d.time) === 'duration') { + return { ...d, timeout: createTimeout(d.time) }; + } + + return d; + }); + + fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 4), 'utf8'); +}; + +export const isDistractionWhitelisted = (distraction) => { + if (config.whitelist.some((d) => d.name === distraction)) return true; + if (config.whitelist.some((d) => d.name === '*')) return true; + if (config.whitelist.some((d) => d.name === `*.${getRootDomain(distraction)}`)) return true; + + return false; +};