Closing the popup after the fact left it on screen for the 15-20 seconds between the updater's version check and the Homebrew Channel running its init.d hooks. That race cannot be won, so remove it. /usr/sbin/update is launched on demand by ls-hubd via a manifest, and ls-hubd.conf lists ManifestsVolatileDirectories under /var - writable, persistent, and how webOS itself ships manifest updates. A manifest there with the same id and a higher version replaces the read-only one, so drop in a copy that keeps every role and permission file and only empties serviceFiles. Nothing on the bus can start the updater after that: no version check, no alert, nothing to dismiss. Callers get an immediate "Service does not exist" rather than a hang, and deleting the file undoes it. Verified on a CX (webOS 5, 04.60.65) across reboots: no update process, /tmp/var/log/update.log never created, no alert. Previously that log was 55 kB with two server checks and an _gAlertWindowId per boot. Revert puts the updater back on the bus. The manifest, its D-Bus service file and the updater binary are all discovered from ls-hubd.conf rather than hardcoded. The popup-closing layer stays as a fallback for when this one is off. Also tried and rejected, now documented in the README: update-related settings, masking update.service (no writable unit directory), and breaking its ping through /var/systemd/system/env/update.env - the ping does fail, but something else on the bus activates the updater anyway. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ef8b6b96c4
commit
80e2d7e5fa
+269
-24
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user