/** * LG Update Blocker - root service. * * Everything this service does is reversible and is applied in layers: * * 1. hosts - point LG's firmware update servers at 127.0.0.1 * 2. purge - delete the firmware image the TV already staged * 3. lock - bind-mount an empty read-only dir over the staging dir * 4. settings - switch off the update-related com.webos.settingsservice keys * 5. services - stop the update related upstart jobs (advanced, opt-in) * * / is read-only on webOS, and /tmp, /etc and the mount namespace are reset on * every boot, so each layer is re-applied by a boot hook script dropped into * /var/lib/webosbrew/init.d (run by the Homebrew Channel startup script). * * Written in ES5 without dependencies - it runs on the TV's own node with the * platform-provided webos-service module and nothing else. */ 'use strict'; var fs = require('fs'); var path = require('path'); var childProcess = require('child_process'); var Service = require('webos-service'); var pkgInfo = require('./package.json'); var service = new Service(pkgInfo.name); service.activityManager.idleTimeout = 300; var SLUG = 'lgupdateblocker'; var STATE_DIR = '/var/lib/webosbrew/' + SLUG; var CONFIG_PATH = STATE_DIR + '/config.json'; var HOSTS_LIST_PATH = STATE_DIR + '/hosts.txt'; var SETTINGS_BACKUP_PATH = STATE_DIR + '/settings-backup.json'; var EMPTY_DIR = STATE_DIR + '/empty'; var BOOT_LOG_PATH = STATE_DIR + '/boot.log'; var BOOT_HOOK_DIR = '/var/lib/webosbrew/init.d'; var BOOT_HOOK_PATH = BOOT_HOOK_DIR + '/' + SLUG; var HOSTS_PATH = '/etc/hosts'; var HOSTS_TMP = '/tmp/' + SLUG + '-hosts'; var MARKER_BEGIN = '# >>> lg-update-blocker >>>'; var MARKER_END = '# <<< lg-update-blocker <<<'; var BUNDLED_HOSTS = path.join(__dirname, 'data', 'lg-update-hosts.txt'); /* Directories where the TV stages a downloaded firmware image. A staged image * keeps the "update is ready" popup coming back even when the update servers * are unreachable. Only paths below /mnt/lg are ever touched. */ var CACHE_DIRS = [ '/mnt/lg/cmn_data/swupdate', '/mnt/lg/swupdate', '/mnt/lg/cmn_data/var/palm/data/com.webos.service.swupdate' ]; var CACHE_GUARD = '/mnt/lg/'; var UPSTART_DIR = '/etc/init'; var JOB_PATTERN = /(swupdate|softwareupdate|firmware|fota|update|upgrade|nsu)/i; var SETTINGS_CATEGORIES = ['option', 'general', 'network', 'commercial']; var SETTINGS_KEY_PATTERN = /(update|upgrade|firmware|ota)/i; var DEFAULT_CONFIG = { blockHosts: true, purgeCache: true, lockCache: false, disableSettings: true, stopServices: false }; var CONFIG_KEYS = Object.keys(DEFAULT_CONFIG); /* ------------------------------------------------------------------ utils */ function isRoot() { return typeof process.getuid === 'function' && process.getuid() === 0; } function exists(target) { try { fs.statSync(target); return true; } catch (err) { return false; } } function isDirectory(target) { try { return fs.statSync(target).isDirectory(); } catch (err) { return false; } } function mkdirp(dir) { if (isDirectory(dir)) return; mkdirp(path.dirname(dir)); try { fs.mkdirSync(dir); } catch (err) { if (!isDirectory(dir)) throw err; } } function readFile(target, fallback) { try { return fs.readFileSync(target, 'utf8'); } catch (err) { return fallback; } } function readJson(target, fallback) { try { return JSON.parse(fs.readFileSync(target, 'utf8')); } catch (err) { return fallback; } } function writeJson(target, value) { mkdirp(path.dirname(target)); fs.writeFileSync(target, JSON.stringify(value, null, 2) + '\n'); } function sh(command) { try { var out = childProcess.execSync(command + ' 2>&1', { encoding: 'utf8', timeout: 30000 }); return { ok: true, output: String(out || '').trim() }; } catch (err) { var output = (err && (err.stdout || err.message)) || ''; return { ok: false, output: String(output).trim() }; } } function Log() { this.lines = []; } Log.prototype.add = function (line) { console.info(line); this.lines.push(line); return this; }; /* ----------------------------------------------------------------- config */ function readConfig() { var stored = readJson(CONFIG_PATH, {}); var config = {}; CONFIG_KEYS.forEach(function (key) { config[key] = typeof stored[key] === 'boolean' ? stored[key] : DEFAULT_CONFIG[key]; }); return config; } function mergeConfig(config, payload) { var merged = {}; CONFIG_KEYS.forEach(function (key) { merged[key] = typeof payload[key] === 'boolean' ? payload[key] : config[key]; }); return merged; } function anyEnabled(config) { return CONFIG_KEYS.some(function (key) { return config[key]; }); } /* ------------------------------------------------------------------ hosts */ function readDomains() { var raw = readFile(BUNDLED_HOSTS, null); if (raw === null) raw = readFile(HOSTS_LIST_PATH, ''); return raw .split('\n') .map(function (line) { return line.replace(/#.*$/, '').trim(); }) .filter(function (line) { return line.length > 0 && line.indexOf(' ') === -1; }); } function mountTargets() { return readFile('/proc/mounts', '') .split('\n') .map(function (line) { return line.split(' ')[1]; }) .filter(Boolean); } function isMountPoint(target) { return mountTargets().indexOf(target) !== -1; } function hostsWritable() { try { fs.appendFileSync(HOSTS_PATH, ''); return true; } catch (err) { return false; } } /* / is mounted read-only, so /etc/hosts gets replaced by a bind mount of a * writable copy in /tmp - the same trick the Homebrew Channel startup script * uses (and it happily stacks with it). */ function makeHostsWritable(log) { if (hostsWritable()) return true; try { fs.writeFileSync(HOSTS_TMP, readFile(HOSTS_PATH, '')); fs.chmodSync(HOSTS_TMP, parseInt('644', 8)); } catch (err) { log.add('! could not stage a writable hosts file: ' + err.message); return false; } var res = sh('mount --bind ' + HOSTS_TMP + ' ' + HOSTS_PATH); if (!res.ok || !hostsWritable()) { log.add('! bind mount of ' + HOSTS_PATH + ' failed: ' + res.output); return false; } log.add('bind-mounted a writable ' + HOSTS_PATH + ' (from ' + HOSTS_TMP + ')'); return true; } function stripHostsBlock(content) { var out = []; var inside = false; content.split('\n').forEach(function (line) { if (line.indexOf(MARKER_BEGIN) === 0) { inside = true; return; } if (inside) { if (line.indexOf(MARKER_END) === 0) inside = false; return; } out.push(line); }); return out.join('\n').replace(/\n{3,}$/, '\n'); } function buildHostsBlock(domains) { var lines = [MARKER_BEGIN]; domains.forEach(function (domain) { lines.push('127.0.0.1 ' + domain); lines.push('::1 ' + domain); }); lines.push(MARKER_END); return lines.join('\n') + '\n'; } function hostsBlockedDomains() { var content = readFile(HOSTS_PATH, ''); if (content.indexOf(MARKER_BEGIN) === -1) return 0; return readDomains().filter(function (domain) { return content.indexOf(' ' + domain) !== -1; }).length; } function applyHosts(log) { var domains = readDomains(); if (!domains.length) { log.add('! no update domains found to block'); return false; } if (!makeHostsWritable(log)) return false; var content = stripHostsBlock(readFile(HOSTS_PATH, '')); if (content.length && content.charAt(content.length - 1) !== '\n') content += '\n'; fs.writeFileSync(HOSTS_PATH, content + buildHostsBlock(domains)); log.add('blocked ' + domains.length + ' update hostnames in ' + HOSTS_PATH); return true; } function removeHosts(log) { var content = readFile(HOSTS_PATH, ''); if (content.indexOf(MARKER_BEGIN) === -1) { log.add('no hosts entries to remove'); return true; } if (!hostsWritable()) { log.add('! ' + HOSTS_PATH + ' is not writable, entries stay until reboot'); return false; } fs.writeFileSync(HOSTS_PATH, stripHostsBlock(content)); log.add('removed update hostnames from ' + HOSTS_PATH); return true; } /* ------------------------------------------------------- staged firmware */ function rmrf(target) { if (target.indexOf(CACHE_GUARD) !== 0) throw new Error('refusing to delete ' + target); var stat; try { stat = fs.lstatSync(target); } catch (err) { return; } if (stat.isDirectory()) { fs.readdirSync(target).forEach(function (entry) { rmrf(path.join(target, entry)); }); fs.rmdirSync(target); } else { fs.unlinkSync(target); } } function dirBytes(target, depth) { var total = 0; var stat; try { stat = fs.lstatSync(target); } catch (err) { return 0; } if (!stat.isDirectory()) return stat.size; if (depth > 8) return 0; fs.readdirSync(target).forEach(function (entry) { total += dirBytes(path.join(target, entry), depth + 1); }); return total; } function cacheStatus() { return CACHE_DIRS.filter(isDirectory).map(function (dir) { var entries = []; try { entries = fs.readdirSync(dir); } catch (err) { entries = []; } return { path: dir, entries: entries, bytes: dirBytes(dir, 0), locked: isMountPoint(dir) }; }); } function purgeCache(log) { var dirs = CACHE_DIRS.filter(isDirectory); if (!dirs.length) { log.add('no firmware staging directory present on this TV'); return; } dirs.forEach(function (dir) { if (isMountPoint(dir)) { log.add(dir + ' is locked (empty read-only mount), nothing staged'); return; } var entries; try { entries = fs.readdirSync(dir); } catch (err) { log.add('! cannot read ' + dir + ': ' + err.message); return; } if (!entries.length) { log.add(dir + ' is already empty'); return; } var removed = 0; entries.forEach(function (entry) { try { rmrf(path.join(dir, entry)); removed += 1; } catch (err) { log.add('! could not delete ' + entry + ' in ' + dir + ': ' + err.message); } }); log.add('purged ' + removed + '/' + entries.length + ' staged item(s) from ' + dir); }); } function lockCache(log) { var dirs = CACHE_DIRS.filter(isDirectory); if (!dirs.length) { log.add('no firmware staging directory to lock'); return; } mkdirp(EMPTY_DIR); dirs.forEach(function (dir) { if (isMountPoint(dir)) { log.add(dir + ' is already locked'); return; } var res = sh('mount -o bind,ro ' + EMPTY_DIR + ' ' + dir); if (!res.ok) { log.add('! could not lock ' + dir + ': ' + res.output); return; } // Older busybox mount ignores "ro" on the initial bind, so remount. sh('mount -o bind,remount,ro ' + EMPTY_DIR + ' ' + dir); log.add('locked ' + dir + ' (empty, read-only)'); }); } function unlockCache(log) { CACHE_DIRS.forEach(function (dir) { if (!isMountPoint(dir)) return; var res = sh('umount ' + dir); log.add(res.ok ? 'unlocked ' + dir : '! could not unlock ' + dir + ': ' + res.output); }); } /* ----------------------------------------------------------- upstart jobs */ function discoverJobs() { var entries; try { entries = fs.readdirSync(UPSTART_DIR); } catch (err) { return []; } return entries .filter(function (entry) { return /\.conf$/.test(entry) && JOB_PATTERN.test(entry); }) .map(function (entry) { return entry.replace(/\.conf$/, ''); }) .sort(); } function stopJobs(jobs, log) { if (!jobs.length) { log.add('no update-related upstart jobs found'); return; } jobs.forEach(function (job) { var res = sh('initctl stop ' + job); if (!res.ok) res = sh('stop ' + job); log.add(res.ok ? 'stopped upstart job ' + job : '! could not stop ' + job + ': ' + res.output); }); } /* --------------------------------------------------------------- settings */ function callLuna(uri, params, callback) { var done = false; var timer = setTimeout(function () { if (done) return; done = true; callback({ returnValue: false, errorText: 'timed out' }); }, 4000); function finish(payload) { if (done) return; done = true; clearTimeout(timer); callback(payload); } try { service.call(uri, params || {}, function (message) { finish((message && message.payload) || {}); }); } catch (err) { finish({ returnValue: false, errorText: err.message }); } } /** Value that turns a setting off, or null when it is off/not a toggle. */ function disabledValueFor(value) { if (value === true) return false; if (typeof value === 'string') { var normalized = value.toLowerCase(); if (normalized === 'on') return 'off'; if (normalized === 'true') return 'false'; if (normalized === 'yes') return 'no'; if (normalized === 'enable' || normalized === 'enabled') return 'disable'; } return null; } function discoverSettings(callback) { var found = []; var index = 0; function next() { if (index >= SETTINGS_CATEGORIES.length) { callback(found); return; } var category = SETTINGS_CATEGORIES[index]; index += 1; callLuna('luna://com.webos.settingsservice/getSystemSettings', { category: category }, function (payload) { var settings = (payload && payload.settings) || {}; Object.keys(settings).forEach(function (key) { if (!SETTINGS_KEY_PATTERN.test(key)) return; found.push({ category: category, key: key, value: settings[key], canDisable: disabledValueFor(settings[key]) !== null }); }); next(); }); } next(); } function disableSettings(log, callback) { discoverSettings(function (found) { var targets = found.filter(function (entry) { return entry.canDisable; }); if (!found.length) { log.add('no update-related system settings exposed by this firmware'); callback(); return; } if (!targets.length) { log.add('update-related settings are already off: ' + found.map(function (e) { return e.key + '=' + JSON.stringify(e.value); }).join(', ')); callback(); return; } var backup = readJson(SETTINGS_BACKUP_PATH, {}); targets.forEach(function (entry) { var id = entry.category + '.' + entry.key; if (!(id in backup)) backup[id] = entry.value; }); try { writeJson(SETTINGS_BACKUP_PATH, backup); } catch (err) { log.add('! could not save settings backup: ' + err.message); } var index = 0; function next() { if (index >= targets.length) { callback(); return; } var entry = targets[index]; index += 1; var settings = {}; settings[entry.key] = disabledValueFor(entry.value); callLuna( 'luna://com.webos.settingsservice/setSystemSettings', { category: entry.category, settings: settings }, function (payload) { if (payload && payload.returnValue) { log.add('turned off ' + entry.category + '/' + entry.key + ' (' + JSON.stringify(entry.value) + ' -> ' + JSON.stringify(settings[entry.key]) + ')'); } else { log.add('! could not change ' + entry.category + '/' + entry.key + ': ' + ((payload && payload.errorText) || 'unknown error')); } next(); } ); } next(); }); } function restoreSettings(log, callback) { var backup = readJson(SETTINGS_BACKUP_PATH, null); if (!backup || !Object.keys(backup).length) { log.add('no system settings to restore'); callback(); return; } var ids = Object.keys(backup); var index = 0; function next() { if (index >= ids.length) { try { fs.unlinkSync(SETTINGS_BACKUP_PATH); } catch (err) { /* nothing to clean up */ } callback(); return; } var id = ids[index]; index += 1; var split = id.indexOf('.'); var category = id.slice(0, split); var key = id.slice(split + 1); var settings = {}; settings[key] = backup[id]; callLuna( 'luna://com.webos.settingsservice/setSystemSettings', { category: category, settings: settings }, function (payload) { log.add((payload && payload.returnValue ? 'restored ' : '! could not restore ') + category + '/' + key + ' = ' + JSON.stringify(backup[id])); next(); } ); } next(); } /* -------------------------------------------------------------- boot hook */ function bootHookScript(config, domains, jobs) { return [ '#!/bin/sh', '# LG Update Blocker boot hook - generated by ' + pkgInfo.name + ' v' + pkgInfo.version + '.', '# Regenerated every time the settings are applied; delete it to disable.', '', 'STATE=' + STATE_DIR, 'LOG=' + BOOT_LOG_PATH, 'HOSTS_TMP=' + HOSTS_TMP, 'BLOCK_HOSTS=' + (config.blockHosts ? 1 : 0), 'PURGE_CACHE=' + (config.purgeCache ? 1 : 0), 'LOCK_CACHE=' + (config.lockCache ? 1 : 0), 'STOP_SERVICES=' + (config.stopServices ? 1 : 0), 'CACHE_DIRS="' + CACHE_DIRS.join(' ') + '"', 'JOBS="' + jobs.join(' ') + '"', '', 'mkdir -p "$STATE"', 'if [ -f "$LOG" ] && [ "$(wc -c < "$LOG")" -gt 65536 ]; then rm -f "$LOG"; fi', 'exec >>"$LOG" 2>&1', 'echo "--- $(date) LG Update Blocker ---"', '', 'if [ "$BLOCK_HOSTS" = 1 ] && [ -f "$STATE/hosts.txt" ]; then', ' if ! (: >> /etc/hosts) 2>/dev/null; then', ' cp /etc/hosts "$HOSTS_TMP" && chmod 644 "$HOSTS_TMP" \\', ' && mount --bind "$HOSTS_TMP" /etc/hosts \\', ' && echo "bind-mounted writable /etc/hosts"', ' fi', ' if grep -q "lg-update-blocker" /etc/hosts 2>/dev/null; then', ' echo "hosts entries already present"', ' elif (: >> /etc/hosts) 2>/dev/null; then', ' {', ' echo "' + MARKER_BEGIN + '"', ' sed -e "s/#.*//" -e "s/[[:space:]]*$//" -e "/^$/d" "$STATE/hosts.txt" | while read -r domain; do', ' echo "127.0.0.1 $domain"', ' echo "::1 $domain"', ' done', ' echo "' + MARKER_END + '"', ' } >> /etc/hosts && echo "hosts entries added"', ' else', ' echo "could not make /etc/hosts writable"', ' fi', 'fi', '', 'if [ "$PURGE_CACHE" = 1 ]; then', ' for dir in $CACHE_DIRS; do', ' [ -d "$dir" ] || continue', ' case "$dir" in /mnt/lg/*) ;; *) continue ;; esac', ' entries=$(ls -A "$dir" 2>/dev/null)', ' [ -n "$entries" ] || continue', ' rm -rf "$dir"/* "$dir"/.[!.]* 2>/dev/null', ' echo "purged staged item(s) from $dir: $(echo "$entries" | tr \'\\n\' \' \')"', ' done', 'fi', '', 'if [ "$LOCK_CACHE" = 1 ]; then', ' mkdir -p "$STATE/empty"', ' for dir in $CACHE_DIRS; do', ' [ -d "$dir" ] || continue', ' case "$dir" in /mnt/lg/*) ;; *) continue ;; esac', ' if awk \'{print $2}\' /proc/mounts | grep -qx "$dir"; then', ' echo "$dir already locked"', ' continue', ' fi', ' if mount -o bind,ro "$STATE/empty" "$dir"; then', ' mount -o bind,remount,ro "$STATE/empty" "$dir" 2>/dev/null', ' echo "locked $dir"', ' else', ' echo "could not lock $dir"', ' fi', ' done', 'fi', '', 'if [ "$STOP_SERVICES" = 1 ] && [ -n "$JOBS" ]; then', ' for job in $JOBS; do', ' if initctl stop "$job" >/dev/null 2>&1 || stop "$job" >/dev/null 2>&1; then', ' echo "stopped $job"', ' else', ' echo "could not stop $job (not running?)"', ' fi', ' done', 'fi', '', 'echo "done"', '' ].join('\n'); } function installBootHook(config, jobs, log) { var domains = readDomains(); mkdirp(STATE_DIR); mkdirp(BOOT_HOOK_DIR); fs.writeFileSync(HOSTS_LIST_PATH, domains.join('\n') + '\n'); fs.writeFileSync(BOOT_HOOK_PATH, bootHookScript(config, domains, jobs)); fs.chmodSync(BOOT_HOOK_PATH, parseInt('755', 8)); log.add('boot hook installed at ' + BOOT_HOOK_PATH); } function removeBootHook(log) { if (!exists(BOOT_HOOK_PATH)) { log.add('no boot hook installed'); return; } try { fs.unlinkSync(BOOT_HOOK_PATH); log.add('removed boot hook ' + BOOT_HOOK_PATH); } catch (err) { log.add('! could not remove boot hook: ' + err.message); } } /* ---------------------------------------------------------------- methods */ function baseStatus() { return { version: pkgInfo.version, uid: typeof process.getuid === 'function' ? process.getuid() : -1, root: isRoot(), config: readConfig(), hosts: { path: HOSTS_PATH, writable: hostsWritable(), bindMounted: isMountPoint(HOSTS_PATH), blockedDomains: hostsBlockedDomains(), totalDomains: readDomains().length }, cache: cacheStatus(), jobs: discoverJobs(), bootHook: { path: BOOT_HOOK_PATH, installed: exists(BOOT_HOOK_PATH) } }; } function respondError(message, text) { message.respond({ returnValue: false, errorText: text }); } service.register('status', function (message) { var status = baseStatus(); discoverSettings(function (settings) { status.settings = settings; status.returnValue = true; message.respond(status); }); }); service.register('apply', function (message) { if (!isRoot()) { respondError(message, 'Service is not running as root - elevate it through the Homebrew Channel first.'); return; } var log = new Log(); var config = mergeConfig(readConfig(), message.payload || {}); try { mkdirp(STATE_DIR); writeJson(CONFIG_PATH, config); if (config.blockHosts) { applyHosts(log); } else { removeHosts(log); } if (config.purgeCache) purgeCache(log); if (config.lockCache) { lockCache(log); } else { unlockCache(log); } var jobs = discoverJobs(); if (config.stopServices) { stopJobs(jobs, log); } if (anyEnabled(config)) { installBootHook(config, config.stopServices ? jobs : [], log); } else { removeBootHook(log); } } catch (err) { log.add('! ' + err.message); message.respond({ returnValue: false, errorText: err.message, log: log.lines, config: config }); return; } function finish() { log.add('done - reboot the TV to verify the popup is gone'); message.respond({ returnValue: true, log: log.lines, config: config, status: baseStatus() }); } if (config.disableSettings) { disableSettings(log, finish); } else { restoreSettings(log, finish); } }); service.register('revert', function (message) { if (!isRoot()) { respondError(message, 'Service is not running as root - elevate it through the Homebrew Channel first.'); return; } var log = new Log(); var config = {}; CONFIG_KEYS.forEach(function (key) { config[key] = false; }); try { removeHosts(log); unlockCache(log); removeBootHook(log); mkdirp(STATE_DIR); writeJson(CONFIG_PATH, config); } catch (err) { log.add('! ' + err.message); } restoreSettings(log, function () { log.add('everything reverted; stopped services come back after a reboot'); message.respond({ returnValue: true, log: log.lines, config: config, status: baseStatus() }); }); }); service.register('purge', function (message) { if (!isRoot()) { respondError(message, 'Service is not running as root - elevate it through the Homebrew Channel first.'); return; } var log = new Log(); purgeCache(log); message.respond({ returnValue: true, log: log.lines, status: baseStatus() }); }); service.register('diagnostics', function (message) { var status = baseStatus(); discoverSettings(function (settings) { message.respond({ returnValue: true, status: status, settings: settings, osInfo: readJson('/var/run/nyx/os_info.json', null), hostsFile: readFile(HOSTS_PATH, '(unreadable)').split('\n').slice(-40).join('\n'), bootLog: readFile(BOOT_LOG_PATH, '(no boot log yet)').split('\n').slice(-40).join('\n'), mounts: mountTargets().filter(function (target) { return target === HOSTS_PATH || target.indexOf('/mnt/lg') === 0; }) }); }); }); console.info(pkgInfo.name + ' v' + pkgInfo.version + ' started, uid=' + (typeof process.getuid === 'function' ? process.getuid() : 'unknown'));