diff --git a/.env.test b/.env.test index cde54b2..3653e92 100644 --- a/.env.test +++ b/.env.test @@ -1,3 +1,4 @@ +GUN_SERVER=http://localhost:8765/gun CONFIG_PATH=/tmp/ulysse/config.json RESOLV_CONF_PATH=/tmp/resolv.conf SOCKET_PATH=/tmp/ulysse.sock diff --git a/__tests__/block.spec.js b/__tests__/block.spec.js index 7bdd8e8..12b9173 100644 --- a/__tests__/block.spec.js +++ b/__tests__/block.spec.js @@ -1,4 +1,6 @@ -import { config, readConfig } from '../src/config'; +import { config, editConfig, readConfig } from '../src/config'; +import { DEFAULT_CONFIG } from '../src/constants'; +import { disableShieldMode } from '../src/shield'; import { getBlockedApps, blockDistraction, @@ -9,10 +11,11 @@ import { getRunningBlockedApps, } from '../src/block'; -beforeEach(() => { - config.blocklist = []; - config.whitelist = []; - config.shield = false; +beforeEach(async () => { + await disableShieldMode('ulysse'); + await editConfig(DEFAULT_CONFIG); + Object.assign(config, DEFAULT_CONFIG); + jest.spyOn(console, 'log').mockImplementation(() => {}); }); test('Should check a distraction', async () => { diff --git a/__tests__/commands.spec.js b/__tests__/commands.spec.js index 44a72d5..6218419 100644 --- a/__tests__/commands.spec.js +++ b/__tests__/commands.spec.js @@ -1,11 +1,13 @@ -import { config } from '../src/config'; +import { config, editConfig } from '../src/config'; +import { DEFAULT_CONFIG } from '../src/constants'; +import { disableShieldMode } from '../src/shield'; import { helpCmd, versionCmd, blockCmd, whitelistCmd, unblockCmd, shieldCmd } from '../src/commands'; -beforeEach(() => { +beforeEach(async () => { process.argv = []; - config.blocklist = []; - config.whitelist = []; - config.shield = false; + await disableShieldMode('ulysse'); + await editConfig(DEFAULT_CONFIG); + Object.assign(config, DEFAULT_CONFIG); jest.spyOn(console, 'log').mockImplementation(() => {}); }); @@ -106,7 +108,9 @@ test('Should whitelist a domain with a wildcard', async () => { }); test('Should enable shield mode', async () => { - shieldCmd(); + process.argv = ['ulysse', '-s', 'on', '-p', 'ulysse']; + + shieldCmd('on'); expect(console.log).toHaveBeenCalledWith('Shield mode enabled.'); }); diff --git a/__tests__/daemon.spec.js b/__tests__/daemon.spec.js index 3360181..b1e9e21 100644 --- a/__tests__/daemon.spec.js +++ b/__tests__/daemon.spec.js @@ -1,7 +1,9 @@ import fs from 'fs'; -import { config, readConfig } from '../src/config'; +import { config, editConfig, readConfig } from '../src/config'; import { getRunningApps } from '../src/utils'; import { blockDistraction } from '../src/block'; +import { DEFAULT_CONFIG } from '../src/constants'; +import { disableShieldMode } from '../src/shield'; import { handleAppBlocking, handleTimeout, updateResolvConf } from '../src/daemon'; jest.mock('../src/utils', () => ({ @@ -14,10 +16,10 @@ jest.mock('child_process', () => ({ exec: jest.fn().mockImplementation(() => false), })); -beforeEach(() => { - config.blocklist = []; - config.whitelist = []; - config.shield = false; +beforeEach(async () => { + await disableShieldMode('ulysse'); + await editConfig(DEFAULT_CONFIG); + Object.assign(config, DEFAULT_CONFIG); jest.spyOn(console, 'log').mockImplementation(() => {}); }); @@ -45,7 +47,7 @@ test('Should edit /etc/resolv.conf', async () => { test('Should remove a distraction from blocklist if timeout is reached', async () => { config.blocklist = [{ name: 'chromium' }, { name: 'example.com', timeout: 1708617136 }]; - handleTimeout(); + await handleTimeout(); expect(readConfig().blocklist).toEqual([{ name: 'chromium' }]); }); diff --git a/__tests__/shield.spec.js b/__tests__/shield.spec.js index 46a565e..7074cb0 100644 --- a/__tests__/shield.spec.js +++ b/__tests__/shield.spec.js @@ -1,9 +1,13 @@ -import { readConfig } from '../src/config'; +import { config, readConfig, editConfig } from '../src/config'; +import { DEFAULT_CONFIG } from '../src/constants'; import { whitelistDistraction } from '../src/whitelist'; import { enableShieldMode, disableShieldMode } from '../src/shield'; import { blockDistraction, unblockDistraction } from '../src/block'; -beforeEach(() => { +beforeEach(async () => { + await disableShieldMode('ulysse'); + await editConfig(DEFAULT_CONFIG); + Object.assign(config, DEFAULT_CONFIG); jest.spyOn(console, 'log').mockImplementation(() => {}); }); diff --git a/__tests__/whitelist.spec.js b/__tests__/whitelist.spec.js index 17cf22b..ebb7f9b 100644 --- a/__tests__/whitelist.spec.js +++ b/__tests__/whitelist.spec.js @@ -1,35 +1,34 @@ -import { config } from '../src/config'; +import { config, readConfig, editConfig } from '../src/config'; +import { DEFAULT_CONFIG } from '../src/constants'; +import { disableShieldMode } from '../src/shield'; 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; +beforeEach(async () => { + await disableShieldMode('ulysse'); + await editConfig(DEFAULT_CONFIG); + Object.assign(config, DEFAULT_CONFIG); + jest.spyOn(console, 'log').mockImplementation(() => {}); }); test('Should whitelist a distraction', async () => { const distraction = { name: 'example.com' }; - whitelistDistraction(distraction); + await whitelistDistraction(distraction); - expect(config.whitelist).toEqual([distraction]); + expect(readConfig().whitelist).toEqual([distraction]); }); test('Should not block a domain if it is in the whitelist', async () => { - blockDistraction({ name: '*.*' }); - whitelistDistraction({ name: 'www.example.com' }); + await blockDistraction({ name: 'www.example.com' }); + await 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' }); + await blockDistraction({ name: '*.*' }); + await whitelistDistraction({ name: '*.example.com' }); expect(isDistractionBlocked('www.example.com')).toBe(false); }); diff --git a/jest.setup.js b/jest.setup.js index 10596d7..357099f 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -1,11 +1,12 @@ require('dotenv/config'); const fs = require('fs'); const { CONFIG_PATH } = require('./src/constants'); +const { socket } = require('./src/socket'); module.exports = () => { if (fs.existsSync(CONFIG_PATH)) { fs.unlinkSync(CONFIG_PATH); } - import('./src/socket'); + socket(); }; diff --git a/package.json b/package.json index 2544c4a..ca79408 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "productivity" ], "dependencies": { - "dns-packet": "^5.6.1" + "dns-packet": "^5.6.1", + "gun": "^0.2020.1240" }, "devDependencies": { "@babel/cli": "^7.23.9", @@ -42,6 +43,7 @@ "@rollup/plugin-babel": "^6.0.4", "@rollup/plugin-commonjs": "^25.0.7", "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-terser": "^0.4.4", "dotenv": "^16.4.5", "eslint": "^8.57.0", diff --git a/rollup.config.js b/rollup.config.js index a2ec515..6655658 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -2,6 +2,7 @@ import json from '@rollup/plugin-json'; import babel from '@rollup/plugin-babel'; import terser from '@rollup/plugin-terser'; import commonjs from '@rollup/plugin-commonjs'; +import { nodeResolve } from '@rollup/plugin-node-resolve'; export default { input: 'src/index.js', @@ -12,6 +13,7 @@ export default { json(), terser(), commonjs(), + nodeResolve(), babel({ babelHelpers: 'bundled' }), ], }; diff --git a/src/commands.js b/src/commands.js index 7afcba6..63d9b85 100644 --- a/src/commands.js +++ b/src/commands.js @@ -6,6 +6,7 @@ import { HELP_MESSAGE } from './constants'; import { whitelistDistraction } from './whitelist'; import { isValidPassword, enableShieldMode, disableShieldMode } from './shield'; import { isValidDistraction, isValidDomain, blockDistraction, unblockDistraction } from './block'; +import { daemon } from './daemon'; export const helpCmd = () => { console.log(HELP_MESSAGE); @@ -16,7 +17,7 @@ export const versionCmd = () => { }; export const daemonCmd = () => { - import('./daemon'); + daemon(); }; export const blockCmd = (name) => { diff --git a/src/config.js b/src/config.js index 8892824..d7a0c4d 100644 --- a/src/config.js +++ b/src/config.js @@ -33,11 +33,7 @@ export const readConfig = () => { }; export const editConfig = async (newConfig) => { - if (isSudo()) { - fs.writeFileSync(CONFIG_PATH, JSON.stringify(newConfig, null, 4), 'utf8'); - } else { - await sendDataToSocket(newConfig); - } + await sendDataToSocket(newConfig); }; export const config = (tryCatch(() => { diff --git a/src/constants.js b/src/constants.js index 1d7eac5..cf80aa9 100644 --- a/src/constants.js +++ b/src/constants.js @@ -6,6 +6,8 @@ export const CONFIG_PATH = process.env.CONFIG_PATH || '/etc/ulysse/config.json'; export const SOCKET_PATH = process.env.SOCKET_PATH || '/var/run/ulysse.sock'; +export const GUN_SERVER = process.env.GUN_SERVER || 'http://localhost:8765/gun'; + export const DNS_SERVER = process.env.DNS_SERVER || '9.9.9.9'; export const DNS_PORT = process.env.DNS_PORT || 53; @@ -13,6 +15,7 @@ export const DNS_PORT = process.env.DNS_PORT || 53; export const DOMAIN_REGEX = /^([\w-]+\.)+[\w-]+$/; export const SYSTEM_WHITELIST = [ + new URL(GUN_SERVER).hostname, '/sbin/init', '/sbin/agetty', '/usr/bin/xinit', diff --git a/src/daemon.js b/src/daemon.js index 2bd4c93..ce3df9e 100644 --- a/src/daemon.js +++ b/src/daemon.js @@ -4,6 +4,9 @@ import { config, editConfig } from './config'; import { getRunningBlockedApps } from './block'; import { isSudo, sendNotification } from './utils'; import { DNS_SERVER, RESOLV_CONF_PATH } from './constants'; +import { synchronize } from './synchronize'; +import { socket } from './socket'; +import { dns } from './dns'; export const updateResolvConf = (dnsServer = DNS_SERVER) => { execSync(`chattr -i ${RESOLV_CONF_PATH}`); @@ -25,11 +28,13 @@ export const handleAppBlocking = () => { } }; -export const handleTimeout = () => { - 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)); +export const handleTimeout = async () => { + const blocklist = config.blocklist.filter(({ timeout }) => !timeout || timeout >= Math.floor(Date.now() / 1000)); + const whitelist = config.whitelist.filter(({ timeout }) => !timeout || timeout >= Math.floor(Date.now() / 1000)); - editConfig(config); + if (blocklist.length !== config.blocklist.length || whitelist.length !== config.whitelist.length) { + await editConfig({ ...config, blocklist, whitelist }); + } }; export const cleanUpAndExit = () => { @@ -37,12 +42,12 @@ export const cleanUpAndExit = () => { process.exit(0); }; -if (!isSudo()) { - console.error('You must run this command with sudo.'); - process.exit(1); -} +export const daemon = () => { + if (!isSudo()) { + console.error('You must run this command with sudo.'); + process.exit(1); + } -if (process.env.NODE_ENV !== 'test') { setInterval(() => { handleAppBlocking(); }, 1000); @@ -53,12 +58,11 @@ if (process.env.NODE_ENV !== 'test') { console.log('Starting daemon...'); updateResolvConf('127.0.0.1'); - handleTimeout(); - handleAppBlocking(); process.on('SIGINT', cleanUpAndExit); process.on('SIGTERM', cleanUpAndExit); - import('./socket'); - import('./dns'); -} + synchronize(); + socket(); + dns(); +}; diff --git a/src/dns.js b/src/dns.js index ed12dc1..8243299 100644 --- a/src/dns.js +++ b/src/dns.js @@ -3,37 +3,39 @@ import packet from 'dns-packet'; import { isDistractionBlocked } from './block'; import { DNS_SERVER, DNS_PORT } from './constants'; -const server = dgram.createSocket('udp4'); +export const dns = () => { + const server = dgram.createSocket('udp4'); -server.on('message', async (msg, rinfo) => { - const proxy = dgram.createSocket('udp4'); + server.on('message', async (msg, rinfo) => { + const proxy = dgram.createSocket('udp4'); - proxy.on('message', (response) => { - const responsePacket = packet.decode(response); - const domain = responsePacket.questions?.[0]?.name; + proxy.on('message', (response) => { + const responsePacket = packet.decode(response); + const domain = responsePacket.questions?.[0]?.name; - if (!isDistractionBlocked(domain) || responsePacket.answers.length === 0) { - server.send(response, rinfo.port, rinfo.address); - proxy.close(); - return; - } - - responsePacket.answers = responsePacket.answers.map((answer) => { - if (answer.type === 'A' || answer.type === 'AAAA') { - return { ...answer, data: answer.type === 'A' ? '127.0.0.1' : '::1' }; + if (!isDistractionBlocked(domain) || responsePacket.answers.length === 0) { + server.send(response, rinfo.port, rinfo.address); + proxy.close(); + return; } - return answer; + responsePacket.answers = responsePacket.answers.map((answer) => { + if (answer.type === 'A' || answer.type === 'AAAA') { + return { ...answer, data: answer.type === 'A' ? '127.0.0.1' : '::1' }; + } + + return answer; + }); + + const newPacket = packet.encode(responsePacket); + server.send(newPacket, rinfo.port, rinfo.address); + proxy.close(); }); - const newPacket = packet.encode(responsePacket); - server.send(newPacket, rinfo.port, rinfo.address); - proxy.close(); + proxy.send(msg, 0, msg.length, 53, DNS_SERVER); }); - proxy.send(msg, 0, msg.length, 53, DNS_SERVER); -}); - -server.bind(DNS_PORT); + server.bind(DNS_PORT); -console.log(`Starting DNS server on port ${DNS_PORT}...`); + console.log(`Starting DNS server on port ${DNS_PORT}...`); +}; diff --git a/src/socket.js b/src/socket.js index 71c1661..fbeeae5 100644 --- a/src/socket.js +++ b/src/socket.js @@ -1,17 +1,16 @@ import 'dotenv/config'; import fs from 'fs'; import net from 'net'; +import Gun from 'gun'; import { config } from './config'; import { removeDuplicates } from './utils'; -import { SOCKET_PATH, CONFIG_PATH } from './constants'; +import { SOCKET_PATH, CONFIG_PATH, GUN_SERVER } from './constants'; import { blockRoot, unblockRoot, isValidPassword } from './shield'; -if (fs.existsSync(SOCKET_PATH)) fs.unlinkSync(SOCKET_PATH); - const editConfig = (newConfig) => { - const { blocklist = [], whitelist = [], shield, password, passwordHash } = newConfig; + const { blocklist = [], whitelist = [], date, shield, password, passwordHash } = newConfig; - config.date = new Date().toISOString(); + config.date = date; config.whitelist = removeDuplicates(config.shield ? config.whitelist : whitelist); config.blocklist = removeDuplicates(config.shield ? [...config.blocklist, ...blocklist] : blocklist); @@ -40,13 +39,24 @@ const server = net.createServer((connection) => { }); connection.on('end', () => { - const newConfig = JSON.parse(buffer); + const data = JSON.parse(buffer); + const newConfig = { ...data, date: new Date().toISOString() }; + editConfig(newConfig); + + if (process.env.NODE_ENV !== 'test' && data.synchronize !== false) { + const gun = Gun({ peers: [GUN_SERVER], axe: false }); + gun.get('db').get('config').put(JSON.stringify(newConfig)); + } }); }); -server.listen(SOCKET_PATH, () => { - const uid = Number(process.env.SUDO_UID || process.getuid()); - const gid = Number(process.env.SUDO_GID || process.getgid()); - fs.chownSync(SOCKET_PATH, uid, gid); -}); +export const socket = () => { + if (fs.existsSync(SOCKET_PATH)) fs.unlinkSync(SOCKET_PATH); + + server.listen(SOCKET_PATH, () => { + const uid = Number(process.env.SUDO_UID || process.getuid()); + const gid = Number(process.env.SUDO_GID || process.getgid()); + fs.chownSync(SOCKET_PATH, uid, gid); + }); +}; diff --git a/src/synchronize.js b/src/synchronize.js new file mode 100644 index 0000000..e0e9a55 --- /dev/null +++ b/src/synchronize.js @@ -0,0 +1,16 @@ +import Gun from 'gun'; +import { GUN_SERVER } from './constants'; +import { editConfig, readConfig } from './config'; + +export const synchronize = () => { + const gun = Gun({ peers: [GUN_SERVER], axe: false }); + + gun.get('db').get('config').on(async (data) => { + const newConfig = typeof data === 'string' ? JSON.parse(data) : data; + + if (new Date(newConfig.date) > new Date(readConfig().date)) { + editConfig({ ...newConfig, synchronize: false }); + console.log('Synchronize...'); + } + }); +};