diff --git a/.gitignore b/.gitignore index 6d2a015..0542327 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ build/ dist/ *.ipk .DS_Store +tv-diag*.txt diff --git a/README.md b/README.md index fd1dc2d..672402c 100644 --- a/README.md +++ b/README.md @@ -13,32 +13,63 @@ only ever probes for things and acts on what it actually finds on your TV. ![screenshot](docs/screenshot.png) -## Why the popup keeps coming back +## Why blocking the update servers is not enough -The Homebrew Channel's own *Block system updates* toggle only writes four -hostnames into `/etc/hosts` at boot. That is not enough in practice: +Blocking `snu.lge.com` in `/etc/hosts` is the obvious fix, and it is what the +Homebrew Channel's own *Block system updates* toggle does. On a CX it does not +stop the boot popup, and it took a session on the TV to work out why. -* it misses several update hostnames (`nsu`, `*-ssl`, the CDN aliases), -* it does nothing about a firmware image the TV **already downloaded** — a - staged update keeps prompting even when the update servers are unreachable, -* it leaves the auto-update settings and the update services alone. +`/usr/sbin/update` is started by **systemd** (`update.service`, pulled in by +`webos-mbd.target`) and runs its version check against +`https://snu.lge.com/CheckSWAutoUpdate.laf` roughly **20 seconds before** the +Homebrew Channel gets as far as running its `init.d` hooks. So on every boot +the check reaches a real LG server, gets `RESULT_CD 900 / Success` back, and +the popup is already on screen by the time any hosts entry exists: -This app covers all of that, and re-applies everything on each boot (webOS -resets `/`, `/tmp` and the mount namespace on every start). +```text +00:20:52 [tNsuMainTask] pRequestUpdateUrl - https://snu.lge.com/CheckSWAutoUpdate.laf +00:20:52 [updateTimer] NSU_CreateUpdateAlert - "…Version: 04.64.00…" +00:21:11 lg-update-blocker: hosts entries added ← 19 seconds too late +``` + +Losing that race is not fixable from userspace. Every directory systemd reads +units from is a read-only overlay except `/run/systemd/system`, which is tmpfs +and created after the updater has already gone. Nor is there a setting that +gates the check — `automaticUpdate`, `support/softwareUpdateEnable`, +`hotelMode/swUpdateEnable` and the `.UpdateIsInprogress` flag were each tested +by restarting the daemon and counting the requests it made, and none of them +stop it. + +So this app **closes the popup** instead of trying to prevent it, and blocks +the hostnames to stop the download and every later check in the session. Two +things also turned out not to be true, and the app no longer pretends +otherwise: the popup is not caused by a staged firmware image (the staging +directory is empty at boot; the size the daemon reports is in-memory only), and +it is not caused by an update *service* that can be stopped — `/etc/init` is +dead upstart leftovers on a systemd TV, and `initctl stop update` silently +"succeeds" while doing nothing. ## Protection layers | Layer | What it does | Default | | --- | --- | --- | | **Block LG update servers** | Points `snu`/`su`/`nsu.lge.com`, their TLS variants, the `-dev` fallbacks and the CDN aliases at `127.0.0.1` in `/etc/hosts` | on | -| **Delete staged firmware** | Wipes the update image staged in `/mnt/lg/cmn_data/swupdate` | on | +| **Close the update popup** | Closes the alert the updater raised during boot, then stays subscribed to `com.webos.notification` and closes any later one as it opens | on | +| **Delete staged firmware** | Wipes the update image staged in `/mnt/lg/cmn_data/swupdate` if the TV ever does download one | on | | **Lock the staging folder** | Bind-mounts an empty read-only directory over it, so nothing can be staged again | off | -| **Turn off auto-update settings** | Reads every `com.webos.settingsservice` category, switches off each update-related key it finds, and remembers the original values | on | -| **Stop update services at boot** | Stops the update-related upstart jobs discovered in `/etc/init` (advanced) | off | +| **Turn off auto-update settings** | Reads every `com.webos.settingsservice` category, switches off each update-related key it finds, and remembers the original values. On a CX this includes `hotelMode/swUpdateEnable`, which also hides the *Software Update* menu until you revert | on | Every layer is undone by **Remove protection**, which also restores the settings from the backup it made. +Be honest about what this buys you: the popup is closed within a fraction of a +second of the Homebrew Channel starting its hooks, but the updater raised it +15–20 seconds earlier, so on a cold boot **you will see it appear and then +vanish by itself**. It is gone before you can act on it, and you never have to +dismiss it. The hook is installed as `00-lgupdateblocker` so `run-parts` runs +it before the other homebrew hooks, which is as early as anything unprivileged +can go. Measured on a CX: alert raised at `01:26:22`, closed at `01:26:37`. + ## Requirements * A rooted webOS TV with the [Homebrew Channel](https://github.com/webosbrew/webos-homebrew-channel) @@ -67,7 +98,7 @@ to the newest release. Download the `.ipk` from the release and: ```sh -ares-install com.rkievits.lgupdateblocker_1.0.0_all.ipk +ares-install com.rkievits.lgupdateblocker__all.ipk ares-launch com.rkievits.lgupdateblocker ``` @@ -89,8 +120,9 @@ Other buttons: **Purge staged update** (wipe a downloaded image right now), | Path | Purpose | | --- | --- | | `/etc/hosts` | Blocked hostnames, between `# >>> lg-update-blocker >>>` markers. Bind-mounted from `/tmp/lgupdateblocker-hosts` because `/` is read-only | -| `/var/lib/webosbrew/init.d/lgupdateblocker` | Boot hook, run by the Homebrew Channel startup script; re-applies the enabled layers | -| `/var/lib/webosbrew/lgupdateblocker/` | `config.json`, `hosts.txt`, `settings-backup.json`, `boot.log` | +| `/var/lib/webosbrew/init.d/00-lgupdateblocker` | Boot hook, run by the Homebrew Channel startup script; re-applies the enabled layers. The `00-` prefix makes `run-parts` run it first | +| `/var/lib/webosbrew/lgupdateblocker/` | `config.json`, `hosts.txt`, `settings-backup.json`, `boot.log`, `alert-watch.sh` | +| `/tmp/var/log/update.log` | Read only — the updater logs the id of the alert it raised, which is the only way to close a popup that opened before we were subscribed | | `/mnt/lg/cmn_data/swupdate` | Staged firmware, emptied (and optionally locked) | Nothing is written to system partitions, and no LG binaries are patched or @@ -103,7 +135,13 @@ replaced. fix that first (its Settings screen has a button for it). * **Popup still appears after a reboot** — run *Diagnostics* and look at the boot-hook log at the bottom. It records, per boot, whether the hosts entries - were added, what was purged and which jobs were stopped. + were added, what was purged, and the id of the popup it closed. If there is + no `dismissed update popup …` line, check `updateLog` in the same dump for + `_gAlertWindowId` — if that is missing too, your TV raises the alert by some + other route and the diagnostics dump is what to open an issue with. +* **The popup appears for ~15 seconds, then disappears on its own** — expected, see + [Why blocking the update servers is not enough](#why-blocking-the-update-servers-is-not-enough). + Nothing running after the Homebrew Channel can beat the updater to it. * **Belt and braces** — a hosts file only helps if the TV uses DNS. Blocking `snu.lge.com`, `su.lge.com` and `nsu.lge.com` on your router or Pi-hole as well is the one measure that also survives a factory reset. diff --git a/app/index.js b/app/index.js index 6bdde0f..8e0316a 100644 --- a/app/index.js +++ b/app/index.js @@ -18,6 +18,11 @@ title: 'Block LG update servers', desc: 'Points snu/su/nsu.lge.com at 127.0.0.1 in /etc/hosts, re-applied on every boot' }, + { + key: 'dismissPopup', + title: 'Close the update popup', + desc: 'The TV asks LG about updates ~20s before homebrew starts, so the popup is closed instead' + }, { key: 'purgeCache', title: 'Delete staged firmware', @@ -32,11 +37,6 @@ key: 'disableSettings', title: 'Turn off auto-update settings', desc: 'Switches off every update-related key com.webos.settingsservice exposes' - }, - { - key: 'stopServices', - title: 'Stop update services at boot', - desc: 'Advanced: stops the update upstart jobs found on this TV after every boot' } ]; @@ -50,10 +50,10 @@ var config = { blockHosts: true, + dismissPopup: true, purgeCache: true, lockCache: false, - disableSettings: true, - stopServices: false + disableSettings: true }; var focusables = []; @@ -229,7 +229,9 @@ row('Staging folders', stagedDirs ? stagedDirs + ' found' : 'none on this TV'); row('Staged firmware', stagedDirs ? bytes(staged) : '-'); row('Staging locked', stagedDirs ? locked + ' / ' + stagedDirs : '-'); - row('Update jobs found', (status.jobs || []).length ? status.jobs.join(', ') : 'none'); + var popup = status.popup || {}; + row('Update popup this boot', popup.lastAlertId ? 'raised, then closed' : 'none raised'); + row('Popup watcher', popup.watcherPid ? 'running (pid ' + popup.watcherPid + ')' : 'not running'); row('Boot hook', status.bootHook.installed ? 'installed' : 'not installed'); var settings = status.settings || []; diff --git a/docs/description.html b/docs/description.html index 8e89706..45be45b 100644 --- a/docs/description.html +++ b/docs/description.html @@ -1,22 +1,29 @@

LG Update Blocker stops the "a new software version is available" popup that webOS shows on every boot, without touching the firmware itself.

+

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.

+

It applies up to five 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 diff --git a/package-lock.json b/package-lock.json index 28374e0..0624a96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "com.rkievits.lgupdateblocker", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "com.rkievits.lgupdateblocker", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "devDependencies": { "@webos-tools/cli": "^3.2.1" diff --git a/package.json b/package.json index 7e75c7d..60771d5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "com.rkievits.lgupdateblocker", - "version": "1.0.0", + "version": "1.1.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 d3de3d7..04254fe 100644 --- a/service/service.js +++ b/service/service.js @@ -4,15 +4,29 @@ * 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) + * 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 * * / 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). * + * 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. + * * Written in ES5 without dependencies - it runs on the TV's own node with the * platform-provided webos-service module and nothing else. */ @@ -37,7 +51,13 @@ 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; +/* run-parts runs the hooks in lexical order and the popup is already on screen + * by then, so sort ahead of the other homebrew hooks. Older versions installed + * the hook under the bare slug; installBootHook removes that leftover. */ +var BOOT_HOOK_PATH = BOOT_HOOK_DIR + '/00-' + SLUG; +var LEGACY_BOOT_HOOK_PATH = BOOT_HOOK_DIR + '/' + SLUG; +var WATCHER_PATH = STATE_DIR + '/alert-watch.sh'; +var WATCHER_PID_PATH = '/tmp/' + SLUG + '-watch.pid'; var HOSTS_PATH = '/etc/hosts'; var HOSTS_TMP = '/tmp/' + SLUG + '-hosts'; @@ -56,18 +76,31 @@ var CACHE_DIRS = [ ]; var CACHE_GUARD = '/mnt/lg/'; -var UPSTART_DIR = '/etc/init'; -var JOB_PATTERN = /(swupdate|softwareupdate|firmware|fota|update|upgrade|nsu)/i; +/* /usr/sbin/update logs its own alert id here as + * _NSU_CreateAlertCallback - _gAlertWindowId : com.webos.service.update- + * which is the only way to learn the id of a popup that opened before we did: + * com.webos.notification only pushes alerts to clients already subscribed, and + * closeAllAlerts rejects every source id we can pass it. */ +var UPDATE_DAEMON_LOG = '/tmp/var/log/update.log'; +var ALERT_ID_PATTERN = 'com\\.webos\\.service\\.update-[0-9]*'; -var SETTINGS_CATEGORIES = ['option', 'general', 'network', 'commercial']; -var SETTINGS_KEY_PATTERN = /(update|upgrade|firmware|ota)/i; +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, + * consumerMode - and switching one of those off would be a nasty surprise, so + * only match them at a word or camelCase boundary. */ +var SETTINGS_ABBREV_PATTERN = /(^|[^A-Za-z])(ota|nsu)|Ota|OTA|Nsu|NSU/; + +function isUpdateKey(key) { + return SETTINGS_KEY_PATTERN.test(key) || SETTINGS_ABBREV_PATTERN.test(key); +} var DEFAULT_CONFIG = { blockHosts: true, + dismissPopup: true, purgeCache: true, lockCache: false, - disableSettings: true, - stopServices: false + disableSettings: true }; var CONFIG_KEYS = Object.keys(DEFAULT_CONFIG); @@ -411,34 +444,83 @@ function unlockCache(log) { }); } -/* ----------------------------------------------------------- upstart jobs */ +/* ------------------------------------------------------------ update popup */ -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(); +/* Id of the last update alert the daemon raised this boot, recovered from its + * own log. /tmp is wiped at boot, so at boot time this is the popup currently + * on screen; later in a session it may already have been closed. Closing an + * alert that is gone is a no-op, so it is safe either way. */ +function lastAlertId() { + var log = readFile(UPDATE_DAEMON_LOG, ''); + var matches = log.match(/_gAlertWindowId\s*:\s*(com\.webos\.service\.update-[0-9]+)/g); + if (!matches || !matches.length) return null; + return matches[matches.length - 1].replace(/^.*:\s*/, ''); } -function stopJobs(jobs, log) { - if (!jobs.length) { - log.add('no update-related upstart jobs found'); +function closeAlert(alertId, callback) { + callLuna('luna://com.webos.notification/closeAlert', { alertId: alertId }, callback); +} + +function watcherPid() { + var pid = parseInt(readFile(WATCHER_PID_PATH, ''), 10); + if (!pid || !exists('/proc/' + pid)) return 0; + /* the pid file survives a crash; make sure it is still our watcher */ + var cmdline = readFile('/proc/' + pid + '/cmdline', ''); + return cmdline.indexOf('alert-watch') >= 0 ? pid : 0; +} + +function stopWatcher() { + var pid = watcherPid(); + if (!pid) return false; + /* the watcher is a pipeline in its own session (setsid / detached spawn), so + * kill the whole group - killing the script alone orphans its luna-send */ + sh('kill -TERM -' + pid + ' 2>/dev/null || kill ' + pid); + try { + fs.unlinkSync(WATCHER_PID_PATH); + } catch (err) { + /* already gone */ + } + return true; +} + +function startWatcher(log) { + if (watcherPid()) { + log.add('alert watcher already running'); 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); + if (!exists(WATCHER_PATH)) { + log.add('! alert watcher missing at ' + WATCHER_PATH); + return; + } + try { + var child = childProcess.spawn('/bin/sh', [WATCHER_PATH], { + detached: true, + stdio: 'ignore' + }); + child.unref(); + log.add('alert watcher started'); + } catch (err) { + log.add('! could not start alert watcher: ' + err.message); + } +} + +/* Close a popup that is on screen right now, then keep watching for the next + * one. Used by apply(); at boot the hook does the same thing in shell. */ +function dismissPopup(log, done) { + var alertId = lastAlertId(); + if (!alertId) { + log.add('no update popup raised this boot'); + startWatcher(log); + return done(); + } + closeAlert(alertId, function (res) { + log.add( + res && res.returnValue + ? 'closed update popup ' + alertId + : '! could not close ' + alertId + ': ' + ((res && res.errorText) || 'no response') + ); + startWatcher(log); + done(); }); } @@ -495,7 +577,7 @@ function discoverSettings(callback) { 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; + if (!isUpdateKey(key)) return; found.push({ category: category, key: key, @@ -610,7 +692,7 @@ function restoreSettings(log, callback) { /* -------------------------------------------------------------- boot hook */ -function bootHookScript(config, domains, jobs) { +function bootHookScript(config, domains) { return [ '#!/bin/sh', '# LG Update Blocker boot hook - generated by ' + pkgInfo.name + ' v' + pkgInfo.version + '.', @@ -619,18 +701,39 @@ function bootHookScript(config, domains, jobs) { 'STATE=' + STATE_DIR, 'LOG=' + BOOT_LOG_PATH, 'HOSTS_TMP=' + HOSTS_TMP, + 'WATCHER=' + WATCHER_PATH, 'BLOCK_HOSTS=' + (config.blockHosts ? 1 : 0), + 'DISMISS_POPUP=' + (config.dismissPopup ? 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 ---"', '', + '# 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', + '# Homebrew Channel gets round to running these hooks.', + 'if [ "$DISMISS_POPUP" = 1 ]; then', + ' id=$(sed -n \'s/.*_gAlertWindowId : \\(' + ALERT_ID_PATTERN + '\\).*/\\1/p\' \\', + ' ' + UPDATE_DAEMON_LOG + ' 2>/dev/null | tail -1)', + ' if [ -n "$id" ] && luna-send -t 1 -f luna://com.webos.notification/closeAlert \\', + ' "{\\"alertId\\":\\"$id\\"}" >/dev/null 2>&1; then', + ' echo "dismissed update popup $id"', + ' fi', + ' # and stay subscribed for the rest of the session in case it comes back', + ' if [ -x "$WATCHER" ]; then', + ' if command -v setsid >/dev/null 2>&1; then', + ' setsid "$WATCHER" >/dev/null 2>&1 /dev/null 2>&1 > /etc/hosts) 2>/dev/null; then', ' cp /etc/hosts "$HOSTS_TMP" && chmod 644 "$HOSTS_TMP" \\', @@ -682,42 +785,99 @@ function bootHookScript(config, domains, jobs) { ' 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) { +/* Long-lived companion to the boot hook: subscribes to the notification + * manager and closes update alerts as they open. Kept as a separate file so + * the hook itself stays a short, ordinary run-parts script. */ +function watcherScript() { + return [ + '#!/bin/sh', + '# LG Update Blocker alert watcher - generated by ' + pkgInfo.name + ' v' + pkgInfo.version + '.', + '# Closes LG\'s "a new software version is available" alert as it opens.', + '', + 'LOG=' + BOOT_LOG_PATH, + 'PIDFILE=' + WATCHER_PID_PATH, + '', + 'echo $$ > "$PIDFILE"', + '# the INT/TERM handler has to exit explicitly: a trap that just returns', + '# resumes the script, which would then re-subscribe and outlive the kill', + 'trap \'rm -f "$PIDFILE"\' EXIT', + 'trap \'rm -f "$PIDFILE"; exit 0\' INT TERM', + '', + 'note() { echo "$(date) $*" >> "$LOG"; }', + '', + 'close_alert() {', + ' [ -n "$1" ] || return 1', + ' luna-send -t 1 -f luna://com.webos.notification/closeAlert \\', + ' "{\\"alertId\\":\\"$1\\"}" >/dev/null 2>&1', + '}', + '', + '# getAlertNotification only pushes to clients that were already subscribed,', + '# so it never reports the popup that is on screen right now - and', + '# closeAllAlerts rejects every source id it accepts arguments for. The', + '# subscription below therefore only covers alerts raised from now on; the', + '# boot hook handles the one already open, using the updater\'s own log.', + 'while :; do', + ' luna-send -i -f luna://com.webos.notification/getAlertNotification \\', + ' \'{"subscribe":true}\' 2>/dev/null |', + ' while read -r line; do', + ' case "$line" in', + ' *com.webos.service.update-*) ;;', + ' *) continue ;;', + ' esac', + ' id=$(echo "$line" | sed -n \'s/.*\\(' + ALERT_ID_PATTERN + '\\).*/\\1/p\')', + ' close_alert "$id" || continue', + ' # one line per popup, not per notification the bus repeats', + ' [ "$id" = "$last" ] && continue', + ' last=$id', + ' note "dismissed update popup $id"', + ' done', + ' # the bus dropped us; wait before re-subscribing rather than spinning', + ' sleep 30', + 'done', + '' + ].join('\n'); +} + +function installBootHook(config, 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.writeFileSync(WATCHER_PATH, watcherScript()); + fs.chmodSync(WATCHER_PATH, parseInt('755', 8)); + fs.writeFileSync(BOOT_HOOK_PATH, bootHookScript(config, domains)); fs.chmodSync(BOOT_HOOK_PATH, parseInt('755', 8)); log.add('boot hook installed at ' + BOOT_HOOK_PATH); + + /* versions before 1.1 installed the hook under the bare slug, which + * run-parts would then run a second time */ + if (exists(LEGACY_BOOT_HOOK_PATH)) { + try { + fs.unlinkSync(LEGACY_BOOT_HOOK_PATH); + log.add('removed old boot hook ' + LEGACY_BOOT_HOOK_PATH); + } catch (err) { + log.add('! could not remove old boot hook: ' + err.message); + } + } } 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); - } + var removed = [BOOT_HOOK_PATH, LEGACY_BOOT_HOOK_PATH].filter(function (target) { + if (!exists(target)) return false; + try { + fs.unlinkSync(target); + return true; + } catch (err) { + log.add('! could not remove boot hook: ' + err.message); + return false; + } + }); + log.add(removed.length ? 'removed boot hook ' + removed.join(', ') : 'no boot hook installed'); } /* ---------------------------------------------------------------- methods */ @@ -736,7 +896,10 @@ function baseStatus() { totalDomains: readDomains().length }, cache: cacheStatus(), - jobs: discoverJobs(), + popup: { + lastAlertId: lastAlertId(), + watcherPid: watcherPid() + }, bootHook: { path: BOOT_HOOK_PATH, installed: exists(BOOT_HOOK_PATH) @@ -784,13 +947,8 @@ service.register('apply', function (message) { unlockCache(log); } - var jobs = discoverJobs(); - if (config.stopServices) { - stopJobs(jobs, log); - } - if (anyEnabled(config)) { - installBootHook(config, config.stopServices ? jobs : [], log); + installBootHook(config, log); } else { removeBootHook(log); } @@ -805,10 +963,19 @@ service.register('apply', function (message) { message.respond({ returnValue: true, log: log.lines, config: config, status: baseStatus() }); } + function afterSettings() { + if (config.dismissPopup) { + dismissPopup(log, finish); + } else { + if (stopWatcher()) log.add('alert watcher stopped'); + finish(); + } + } + if (config.disableSettings) { - disableSettings(log, finish); + disableSettings(log, afterSettings); } else { - restoreSettings(log, finish); + restoreSettings(log, afterSettings); } }); @@ -827,6 +994,7 @@ service.register('revert', function (message) { try { removeHosts(log); unlockCache(log); + if (stopWatcher()) log.add('alert watcher stopped'); removeBootHook(log); mkdirp(STATE_DIR); writeJson(CONFIG_PATH, config); @@ -835,7 +1003,7 @@ service.register('revert', function (message) { } restoreSettings(log, function () { - log.add('everything reverted; stopped services come back after a reboot'); + log.add('everything reverted - the update popup will be back on the next boot'); message.respond({ returnValue: true, log: log.lines, config: config, status: baseStatus() }); }); }); @@ -860,6 +1028,7 @@ service.register('diagnostics', function (message) { 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'), + updateLog: readFile(UPDATE_DAEMON_LOG, '(no update log)').split('\n').slice(-40).join('\n'), mounts: mountTargets().filter(function (target) { return target === HOSTS_PATH || target.indexOf('/mnt/lg') === 0; }) diff --git a/tools/tv-diag2.sh b/tools/tv-diag2.sh new file mode 100755 index 0000000..34ad2e6 --- /dev/null +++ b/tools/tv-diag2.sh @@ -0,0 +1,98 @@ +#!/bin/sh +# Second probe: find where the pending-update record lives, what the real +# auto-update switch is, and whether /etc/hosts is honoured. +# Read-only: it changes nothing on the TV. +# +# ssh root@ 'sh -s' < tools/tv-diag2.sh | tee tv-diag2.txt + +section() { echo; echo "=================== $* ==================="; } +have() { command -v "$1" >/dev/null 2>&1; } +# luna-send writes progress chatter to stderr; keep only the payload +lsend() { luna-send -n 1 -t 5 -f "luna://$1" "$2" 2>/dev/null; } + +section "is /etc/hosts actually honoured? (getaddrinfo, not DNS)" +for h in su.lge.com snu.lge.com nsu.lge.com su-ssl.lge.com; do + echo "--- $h ---" + ping -c 1 -W 3 "$h" 2>&1 | head -2 +done +echo "--- can it still reach an update server over http? ---" +have wget && wget -q -T 5 -O - http://su.lge.com/ 2>&1 | head -c 200 +echo + +section "com.webos.service.update - what does it expose?" +grep -rl "com.webos.service.update" /usr/share/luna-service2/ 2>/dev/null | head -10 +for f in $(grep -rl "com.webos.service.update" /usr/share/luna-service2/api-permissions.d/ 2>/dev/null | head -4); do + echo "--- $f ---" + head -c 1500 "$f" + echo +done +echo "--- service file ---" +grep -rh "com.webos.service.update" /usr/share/luna-service2/services.d/ 2>/dev/null | head -10 + +section "com.webos.service.update - current state" +for m in getStatus getSettings getUpdateInfo getCurrentSWInformation getUpdateStatus checkUpdate; do + echo "--- /$m ---" + lsend "com.webos.service.update/$m" '{}' | head -c 700 + echo +done + +section "settingsservice: every key mentioning update (via db8)" +lsend com.webos.service.db/find '{"query":{"from":"com.webos.settings.system:1"}}' | + tr ',' '\n' | grep -i -E 'update|upgrade|firmware|ota|nsu|epk' | head -60 +echo "--- same for the description kind (tells us allowed values) ---" +lsend com.webos.service.db/find '{"query":{"from":"com.webos.settings.desc.system:1"}}' | + tr ',' '\n' | grep -i -E 'update|upgrade|firmware|ota|nsu' | head -60 + +section "settingsservice: direct reads" +for k in autoUpdate appUpdateMode swUpdateMode updateMode; do + echo "--- option/$k ---" + lsend com.webos.settingsservice/getSystemSettings "{\"category\":\"option\",\"keys\":[\"$k\"]}" | head -c 300 + echo +done +echo "--- no category, just the key ---" +lsend com.webos.settingsservice/getSystemSettings '{"keys":["autoUpdate","appUpdateMode"]}' | head -c 400 +echo + +section "luna preferences: full update-related keys" +for f in /var/luna/preferences/option /var/luna/preferences/general /var/luna/preferences/network; do + [ -f "$f" ] || continue + echo "--- $f ---" + tr ',' '\n' < "$f" | grep -i -E 'update|upgrade|firmware|ota|nsu|epk' +done +echo "--- any other preference file with an update key ---" +for f in /var/luna/preferences/*; do + [ -f "$f" ] || continue + hits=$(tr ',' '\n' < "$f" 2>/dev/null | grep -i -E '"[a-z]*(update|upgrade|nsu)[a-z]*"' | head -5) + [ -n "$hits" ] && { echo "--- $f ---"; echo "$hits"; } +done + +section "where is the pending update recorded?" +echo "--- files under /mnt/lg mentioning swupdate/nsu/epk ---" +find /mnt/lg -maxdepth 3 -type f -size -2M 2>/dev/null | + while read -r f; do + if grep -l -i -E 'nsuimage|swupdate|epk_|otaVersion' "$f" >/dev/null 2>&1; then + echo "$f ($(wc -c < "$f") bytes, $(date -r "$f" 2>/dev/null))" + fi + done | head -20 +echo "--- update-ish paths anywhere under /mnt/lg and /var ---" +find /mnt/lg /var -maxdepth 4 \( -iname '*swupdate*' -o -iname '*nsu*' -o -iname '*otaid*' \) 2>/dev/null | head -30 +echo "--- tvservice config dirs ---" +ls -la /mnt/lg/cmn_data/ 2>/dev/null | head -60 + +section "the popup at boot: full log window" +ls -la /var/log/ 2>/dev/null | head -20 +echo "--- first 120 update/alert lines in the current log ---" +grep -i -E 'swupdate|softwareupdate|createAlert|notification|popup|otaid' /var/log/messages 2>/dev/null | head -120 + +section "who is running the updater" +ps 2>/dev/null | grep -i -E 'broadcast|update' | grep -v grep +echo "--- upstart job contents ---" +for j in /etc/init/update.conf /etc/init/software-update-complete.conf; do + [ -f "$j" ] || continue + echo "--- $j ---" + cat "$j" +done +echo "--- are they actually stopped? ---" +initctl list 2>/dev/null | grep -i -E 'update|broadcast' + +section "done"