From 80e2d7e5fa4abfadf53d09d517483f957e321d45 Mon Sep 17 00:00:00 2001
From: Rene Kievits
Blocking the update servers alone does not do it: the updater asks LG about new firmware around 20 seconds before the Homebrew Channel gets to run its boot hooks, so on a CX the check succeeds every time and the popup is already up. -Nothing running that late can win the race, so this app closes the popup as -well as blocking the servers.
+Nothing running that late can win that race, so this app removes it instead - +it takes the updater off the bus entirely, and the check never happens. -It applies up to five reversible layers and re-applies them on every boot +
It applies up to six reversible layers and re-applies them on every boot through a Homebrew Channel startup hook:
Requires root and the Homebrew Channel: the app asks the Homebrew Channel to -elevate its own service on first launch. "Remove protection" undoes everything.
+elevate its own service on first launch. "Remove protection" undoes everything, +including putting the updater back on the bus. + +While the updater layer is on, expect the Software Update screen in Settings +to show an error - it queries the service that is now gone.
diff --git a/package.json b/package.json index 60771d5..b307045 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "com.rkievits.lgupdateblocker", - "version": "1.1.0", + "version": "1.2.0", "title": "LG Update Blocker", "description": "Blocks the recurring LG webOS firmware update popup on rooted TVs", "private": true, diff --git a/service/service.js b/service/service.js index 04254fe..a85c1a2 100644 --- a/service/service.js +++ b/service/service.js @@ -3,29 +3,44 @@ * * 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. dismiss - close the "software update available" alert at boot - * 3. purge - delete the firmware image the TV already staged - * 4. lock - bind-mount an empty read-only dir over the staging dir - * 5. settings - switch off the update-related com.webos.settingsservice keys + * 1. updater - stop /usr/sbin/update from ever being launched + * 2. hosts - point LG's firmware update servers at 127.0.0.1 + * 3. dismiss - close the "software update available" alert at boot + * 4. purge - delete the firmware image the TV already staged + * 5. lock - bind-mount an empty read-only dir over the staging dir + * 6. settings - switch off the update-related com.webos.settingsservice keys * * / 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). + * every boot, so the layers that live there are re-applied by a boot hook + * script dropped into /var/lib/webosbrew/init.d (run by the Homebrew Channel + * startup script). * - * Why the hosts block alone is not enough: /usr/sbin/update is started by - * systemd (webos-mbd.target) and runs its version check against snu.lge.com - * roughly 20 seconds before the Homebrew Channel gets as far as running the - * hooks in init.d. On a CX that check therefore *succeeds* on every boot and - * the popup is already on screen by the time anything of ours runs - which is - * also why the Homebrew Channel's own "block system updates" toggle does not - * stop it. Nothing persistent gates that check (automaticUpdate, - * support/softwareUpdateEnable, hotelMode/swUpdateEnable and the - * .UpdateIsInprogress flag were all measured to make no difference), and there - * is no writable directory early enough in the boot to win the race: every - * systemd unit path except /run is a read-only overlay. So the popup is closed - * instead of prevented, and the hosts block stops the download and every - * later check. + * Why the hosts block alone is not enough: /usr/sbin/update runs its version + * check against snu.lge.com roughly 20 seconds before the Homebrew Channel + * gets as far as running the hooks in init.d. On a CX that check therefore + * *succeeds* on every boot and the popup is already on screen by the time + * anything of ours runs - which is also why the Homebrew Channel's own "block + * system updates" toggle does not stop it, and why closing the popup after the + * fact leaves it visible for those 20 seconds. + * + * The updater layer removes the race instead of trying to win it. The updater + * is not a normal daemon: it is launched on demand by ls-hubd from a D-Bus + * service file, which ls-hubd finds through a manifest. ls-hubd.conf lists + * ManifestsVolatileDirectories under /var - writable, persistent, and how + * webOS itself ships manifest updates - and a manifest there with the same id + * and a higher version replaces the read-only one. Dropping in a copy whose + * serviceFiles list is empty leaves every role and permission file in place + * but removes the launch entry, so nothing on the bus can start the updater: + * no version check, no alert, nothing to dismiss. Callers get an immediate + * "Service does not exist" instead of hanging, and deleting the one file puts + * it all back. + * + * Things that do *not* work, all measured on a CX before settling on the + * above: automaticUpdate, support/softwareUpdateEnable, hotelMode/swUpdateEnable + * and the .UpdateIsInprogress flag change nothing; no systemd unit directory is + * writable, so update.service cannot be masked; breaking update.service's ping + * via its /var/systemd/system/env override does make that ping fail, but + * something else on the bus activates the updater anyway. * * Written in ES5 without dependencies - it runs on the TV's own node with the * platform-provided webos-service module and nothing else. @@ -84,6 +99,15 @@ var CACHE_GUARD = '/mnt/lg/'; var UPDATE_DAEMON_LOG = '/tmp/var/log/update.log'; var ALERT_ID_PATTERN = 'com\\.webos\\.service\\.update-[0-9]*'; +/* ls-hubd launches the updater on demand; its config says which manifest + * directories are read-only and which are the writable ones we may override + * from. Everything about the updater - its manifest, its D-Bus service file, + * even the path of its binary - is discovered from there rather than assumed, + * so this works the same on a firmware that moves them. */ +var LS_HUBD_CONF = '/etc/luna-service2/ls-hubd.conf'; +var UPDATER_SERVICE = 'com.webos.service.update'; +var UPDATER_BINARY_FALLBACK = '/usr/sbin/update'; + var SETTINGS_CATEGORIES = ['option', 'general', 'network', 'commercial', 'support', 'hotelMode']; var SETTINGS_KEY_PATTERN = /(update|upgrade|firmware)/i; /* "ota" and "nsu" are substrings of perfectly innocent keys - screenRotation, @@ -96,6 +120,7 @@ function isUpdateKey(key) { } var DEFAULT_CONFIG = { + blockUpdater: true, blockHosts: true, dismissPopup: true, purgeCache: true, @@ -202,6 +227,185 @@ function anyEnabled(config) { }); } +/* ---------------------------------------------------------------- updater */ + +/** Semicolon-separated directory list out of ls-hubd.conf. */ +function lsHubdDirs(key) { + var match = readFile(LS_HUBD_CONF, '').match(new RegExp('^[ \\t]*' + key + '[ \\t]*=(.*)$', 'm')); + if (!match) return []; + return match[1].split(';').map(function (dir) { + return dir.trim(); + }).filter(Boolean); +} + +/** The read-only manifest whose serviceFiles make the updater launchable. */ +function findUpdaterManifest() { + var found = null; + lsHubdDirs('ManifestsDirectories').forEach(function (dir) { + if (found || !isDirectory(dir)) return; + var entries; + try { + entries = fs.readdirSync(dir); + } catch (err) { + return; + } + entries.forEach(function (entry) { + if (found || !/\.json$/.test(entry)) return; + var file = path.join(dir, entry); + var manifest = readJson(file, null); + if (!manifest || !manifest.id || !Array.isArray(manifest.serviceFiles)) return; + var launchesUpdater = manifest.serviceFiles.some(function (svc) { + return path.basename(svc) === UPDATER_SERVICE + '.service'; + }); + if (launchesUpdater) found = { file: file, manifest: manifest }; + }); + }); + return found; +} + +/** Where our replacement goes - the first writable manifest dir ls-hubd scans. */ +function overridePath(base) { + var dirs = lsHubdDirs('ManifestsVolatileDirectories'); + var dir = dirs.filter(isDirectory)[0] || dirs[0]; + return dir ? path.join(dir, base.manifest.id + '.json') : null; +} + +/** A version that outranks the stock manifest, so ls-hubd prefers ours. */ +function outrankVersion(version) { + var major = parseInt(String(version || '').split('.')[0], 10); + return Math.max((major >= 0 ? major : 0) + 1, 99) + '.0.0'; +} + +/* Same id, higher version, same roles and permissions - only the serviceFiles + * list is emptied, which is what takes away ls-hubd's ability to launch it. */ +function buildOverride(base) { + var override = { id: base.manifest.id, version: outrankVersion(base.manifest.version) }; + ['roleFiles', 'roleFilesPub', 'roleFilesPrv', 'apiPermissionFiles', 'clientPermissionFiles'] + .forEach(function (key) { + if (base.manifest[key]) override[key] = base.manifest[key]; + }); + override.serviceFiles = []; + return override; +} + +/* An empty serviceFiles list is not something a real manifest ever has, so it + * doubles as the signature that a file at that path was written by us. */ +function isOverrideOurs(target, base) { + var manifest = readJson(target, null); + return !!manifest && manifest.id === base.manifest.id && + Array.isArray(manifest.serviceFiles) && manifest.serviceFiles.length === 0; +} + +/** Exec= out of the updater's D-Bus service file. */ +function updaterBinary(base) { + var files = base.manifest.serviceFiles || []; + for (var i = 0; i < files.length; i += 1) { + var match = readFile(files[i], '').match(/^[ \t]*Exec[ \t]*=[ \t]*(\S+)/m); + if (match) return match[1]; + } + return UPDATER_BINARY_FALLBACK; +} + +function updaterPids(binary) { + var pids = []; + try { + fs.readdirSync('/proc').forEach(function (entry) { + if (!/^[0-9]+$/.test(entry)) return; + /* cmdline is NUL-separated; argv[0] is what ls-hubd launched */ + if (readFile('/proc/' + entry + '/cmdline', '').split('\0')[0] === binary) { + pids.push(parseInt(entry, 10)); + } + }); + } catch (err) { + /* /proc unreadable - nothing we can do */ + } + return pids; +} + +function stopUpdater(binary) { + var stopped = 0; + updaterPids(binary).forEach(function (pid) { + try { + process.kill(pid, 'SIGTERM'); + stopped += 1; + } catch (err) { + /* already gone */ + } + }); + return stopped; +} + +function blockUpdater(log) { + var base = findUpdaterManifest(); + if (!base) { + log.add('! no ' + UPDATER_SERVICE + ' manifest on this TV - leaving the updater alone'); + return false; + } + var target = overridePath(base); + if (!target) { + log.add('! ls-hubd has no writable manifest directory on this TV'); + return false; + } + if (exists(target) && !isOverrideOurs(target, base)) { + log.add('! ' + target + ' exists and is not ours - leaving it alone'); + return false; + } + + try { + mkdirp(path.dirname(target)); + writeJson(target, buildOverride(base)); + } catch (err) { + log.add('! could not write ' + target + ': ' + err.message); + return false; + } + + var res = sh('ls-control scan-services'); + if (!res.ok) log.add('! ls-control scan-services failed: ' + res.output); + log.add('updater disabled - ' + UPDATER_SERVICE + ' can no longer be launched'); + + var binary = updaterBinary(base); + var stopped = stopUpdater(binary); + if (stopped) log.add('stopped ' + stopped + ' running ' + binary + ' process(es)'); + return true; +} + +function unblockUpdater(log) { + var base = findUpdaterManifest(); + var target = base && overridePath(base); + if (!target || !exists(target)) { + log.add('updater was not disabled'); + return; + } + if (!isOverrideOurs(target, base)) { + log.add('! ' + target + ' is not ours - leaving it alone'); + return; + } + try { + fs.unlinkSync(target); + } catch (err) { + log.add('! could not remove ' + target + ': ' + err.message); + return; + } + sh('ls-control scan-services'); + log.add('updater re-enabled - removed ' + target); +} + +function updaterStatus() { + var base = findUpdaterManifest(); + if (!base) return { found: false, blocked: false, running: false }; + var target = overridePath(base); + var binary = updaterBinary(base); + return { + found: true, + service: UPDATER_SERVICE, + binary: binary, + manifest: base.file, + overridePath: target, + blocked: !!(target && exists(target) && isOverrideOurs(target, base)), + running: updaterPids(binary).length > 0 + }; +} + /* ------------------------------------------------------------------ hosts */ function readDomains() { @@ -692,6 +896,37 @@ function restoreSettings(log, callback) { /* -------------------------------------------------------------- boot hook */ +/* The updater override lives on a persistent partition, so unlike the other + * layers it normally survives a reboot on its own. The hook only puts it back + * if something removed it - a firmware update, or an app install that rewrote + * the volatile manifest directory. */ +function updaterHookLines(config) { + var base = config.blockUpdater ? findUpdaterManifest() : null; + var target = base && overridePath(base); + if (!target) return []; + return [ + 'UPDATER_MANIFEST=' + target, + 'UPDATER_BIN=' + updaterBinary(base), + '', + 'if [ ! -f "$UPDATER_MANIFEST" ]; then', + ' mkdir -p "$(dirname "$UPDATER_MANIFEST")"', + ' cat > "$UPDATER_MANIFEST" <<\'LGUB_MANIFEST\'', + JSON.stringify(buildOverride(base), null, 2), + 'LGUB_MANIFEST', + ' ls-control scan-services >/dev/null 2>&1', + ' echo "restored updater override $UPDATER_MANIFEST"', + 'fi', + '# it cannot be launched any more, but kill one that slipped through', + 'for proc in /proc/[0-9]*; do', + ' [ -r "$proc/cmdline" ] || continue', + ' case "$(tr \'\\0\' \' \' < "$proc/cmdline")" in', + ' "$UPDATER_BIN "*) kill "${proc#/proc/}" 2>/dev/null && echo "stopped $UPDATER_BIN" ;;', + ' esac', + 'done', + '' + ]; +} + function bootHookScript(config, domains) { return [ '#!/bin/sh', @@ -712,9 +947,10 @@ function bootHookScript(config, domains) { 'if [ -f "$LOG" ] && [ "$(wc -c < "$LOG")" -gt 65536 ]; then rm -f "$LOG"; fi', 'exec >>"$LOG" 2>&1', 'echo "--- $(date) LG Update Blocker ---"', - '', - '# First, because by now the popup has been on screen for ~20 seconds: the', - '# updater runs its version check from webos-mbd.target, long before the', + '' + ].concat(updaterHookLines(config)).concat([ + '# Fallback for when the updater layer is off: by now the popup has been on', + '# screen for ~20 seconds, because the version check runs long before the', '# Homebrew Channel gets round to running these hooks.', 'if [ "$DISMISS_POPUP" = 1 ]; then', ' id=$(sed -n \'s/.*_gAlertWindowId : \\(' + ALERT_ID_PATTERN + '\\).*/\\1/p\' \\', @@ -787,7 +1023,7 @@ function bootHookScript(config, domains) { '', 'echo "done"', '' - ].join('\n'); + ]).join('\n'); } /* Long-lived companion to the boot hook: subscribes to the notification @@ -888,6 +1124,7 @@ function baseStatus() { uid: typeof process.getuid === 'function' ? process.getuid() : -1, root: isRoot(), config: readConfig(), + updater: updaterStatus(), hosts: { path: HOSTS_PATH, writable: hostsWritable(), @@ -933,6 +1170,13 @@ service.register('apply', function (message) { mkdirp(STATE_DIR); writeJson(CONFIG_PATH, config); + /* first: with the updater gone there is no version check to lose a race to */ + if (config.blockUpdater) { + blockUpdater(log); + } else { + unblockUpdater(log); + } + if (config.blockHosts) { applyHosts(log); } else { @@ -992,6 +1236,7 @@ service.register('revert', function (message) { }); try { + unblockUpdater(log); removeHosts(log); unlockCache(log); if (stopWatcher()) log.add('alert watcher stopped');