From fa9fb2f9552034f7e4229bbb35eb3524e5557185 Mon Sep 17 00:00:00 2001 From: Paul Spooren Date: Thu, 31 Mar 2022 16:01:31 +0100 Subject: [PATCH] luci-app-attendedsysupgrade: introduce rebuilders This adds automatic verification builds to shift trust on multiple server and multiple entities. Signed-off-by: Paul Spooren --- .../view/attendedsysupgrade/configuration.js | 45 +- .../view/attendedsysupgrade/overview.js | 586 ++++++++++++------ 2 files changed, 423 insertions(+), 208 deletions(-) diff --git a/applications/luci-app-attendedsysupgrade/htdocs/luci-static/resources/view/attendedsysupgrade/configuration.js b/applications/luci-app-attendedsysupgrade/htdocs/luci-static/resources/view/attendedsysupgrade/configuration.js index 2d539c5e68..d5d1ddfbf4 100644 --- a/applications/luci-app-attendedsysupgrade/htdocs/luci-static/resources/view/attendedsysupgrade/configuration.js +++ b/applications/luci-app-attendedsysupgrade/htdocs/luci-static/resources/view/attendedsysupgrade/configuration.js @@ -3,28 +3,53 @@ 'require form'; return view.extend({ - render: function() { - var m, s, o; + render: function () { + let m, s, o; - m = new form.Map('attendedsysupgrade', _('Attended Sysupgrade'), - _('Attendedsysupgrade Configuration.')); + m = new form.Map( + 'attendedsysupgrade', + _('Attended Sysupgrade'), + _('Attendedsysupgrade Configuration.') + ); s = m.section(form.TypedSection, 'server', _('Server')); s.anonymous = true; - s.option(form.Value, 'url', _('Address'), - _('Address of the sysupgrade server')); + s.option( + form.Value, + 'url', + _('Address'), + _('Address of the sysupgrade server') + ); + + s.option( + form.DynamicList, + 'rebuilder', + _('Rebuilders'), + _( + 'Other ASU server instances that rebuild a requested image. ' + + 'Allows to compare checksums and verify that the results are the same.' + ) + ); s = m.section(form.TypedSection, 'client', _('Client')); s.anonymous = true; - o = s.option(form.Flag, 'auto_search', _('Search on opening'), - _('Search for new sysupgrades on opening the tab')); + o = s.option( + form.Flag, + 'auto_search', + _('Search on opening'), + _('Search for new sysupgrades on opening the tab') + ); o.default = '1'; o.rmempty = false; - o = s.option(form.Flag, 'advanced_mode', _('Advanced Mode'), - _('Show advanced options like package list modification')); + o = s.option( + form.Flag, + 'advanced_mode', + _('Advanced Mode'), + _('Show advanced options like package list modification') + ); o.default = '0'; o.rmempty = false; diff --git a/applications/luci-app-attendedsysupgrade/htdocs/luci-static/resources/view/attendedsysupgrade/overview.js b/applications/luci-app-attendedsysupgrade/htdocs/luci-static/resources/view/attendedsysupgrade/overview.js index 55ed0509e1..76e504086e 100644 --- a/applications/luci-app-attendedsysupgrade/htdocs/luci-static/resources/view/attendedsysupgrade/overview.js +++ b/applications/luci-app-attendedsysupgrade/htdocs/luci-static/resources/view/attendedsysupgrade/overview.js @@ -9,17 +9,17 @@ 'require dom'; 'require fs'; -var callPackagelist = rpc.declare({ +let callPackagelist = rpc.declare({ object: 'rpc-sys', method: 'packagelist', }); -var callSystemBoard = rpc.declare({ +let callSystemBoard = rpc.declare({ object: 'system', method: 'board', }); -var callUpgradeStart = rpc.declare({ +let callUpgradeStart = rpc.declare({ object: 'rpc-sys', method: 'upgrade_start', params: ['keep'], @@ -28,14 +28,14 @@ var callUpgradeStart = rpc.declare({ /** * Returns the branch of a given version. This helps to offer upgrades * for point releases (aka within the branch). - * + * * Logic: * SNAPSHOT -> SNAPSHOT * 21.02-SNAPSHOT -> 21.02 * 21.02.0-rc1 -> 21.02 * 19.07.8 -> 19.07 - * - * @param {string} version + * + * @param {string} version * Input version from which to determine the branch * @returns {string} * The determined branch @@ -45,10 +45,10 @@ function get_branch(version) { } /** - * The OpenWrt revision string contains both a hash as well as the number - * commits since the OpenWrt/LEDE reboot. It helps to determine if a + * The OpenWrt revision string contains both a hash as well as the number + * commits since the OpenWrt/LEDE reboot. It helps to determine if a * snapshot is newer than another. - * + * * @param {string} revision * Revision string of a OpenWrt device * @returns {integer} @@ -60,17 +60,19 @@ function get_revision_count(revision) { return view.extend({ steps: { - init: _('10% Received build request'), - download_imagebuilder: _('20% Downloading ImageBuilder archive'), - unpack_imagebuilder: _('40% Setup ImageBuilder'), - calculate_packages_hash: _('60% Validate package selection'), - building_image: _('80% Generating firmware image') + init: [10, _('Received build request')], + download_imagebuilder: [20, _('Downloading ImageBuilder archive')], + unpack_imagebuilder: [40, _('Setup ImageBuilder')], + calculate_packages_hash: [60, _('Validate package selection')], + building_image: [80, _('Generating firmware image')], }, data: { url: '', revision: '', advanced_mode: 0, + rebuilder: [], + sha256_unsigned: '', }, firmware: { @@ -82,74 +84,107 @@ return view.extend({ filesystem: '', }, - handle200: function (response) { - response = response.json(); - var image; - for (image of response.images) { + selectImage: function (images) { + let image; + for (image of images) { if (this.firmware.filesystem == image.filesystem) { if (this.data.efi) { if (image.type == 'combined-efi') { - break; + return image; } } else { if (image.type == 'sysupgrade' || image.type == 'combined') { - break; + return image; } } } } + return null; + }, + + handle200: function (response) { + response = response.json(); + let image = this.selectImage(response.images); if (image.name != undefined) { - var sysupgrade_url = `${this.data.url}/store/${response.bin_dir}/${image.name}`; + this.data.sha256_unsigned = image.sha256_unsigned; + let sysupgrade_url = `${this.data.url}/store/${response.bin_dir}/${image.name}`; - var keep = E('input', { type: 'checkbox' }); + let keep = E('input', { type: 'checkbox' }); keep.checked = true; - var fields = [ - _('Version'), `${response.version_number} ${response.version_code}`, - _('SHA256'), image.sha256, + let fields = [ + _('Version'), + `${response.version_number} ${response.version_code}`, + _('SHA256'), + image.sha256, ]; if (this.data.advanced_mode == 1) { fields.push( - _('Profile'), response.id, - _('Target'), response.target, - _('Build Date'), response.build_at, - _('Filename'), image.name, - _('Filesystem'), image.filesystem, - ) + _('Profile'), + response.id, + _('Target'), + response.target, + _('Build Date'), + response.build_at, + _('Filename'), + image.name, + _('Filesystem'), + image.filesystem + ); } - fields.push('', E('a', { href: sysupgrade_url }, _('Download firmware image'))) + fields.push( + '', + E('a', { href: sysupgrade_url }, _('Download firmware image')) + ); + if (this.data.rebuilder) { + fields.push(_('Rebuilds'), E('div', { id: 'rebuilder_status' })); + } - var table = E('div', { class: 'table' }); + let table = E('div', { class: 'table' }); - for (var i = 0; i < fields.length; i += 2) { - table.appendChild(E('tr', { class: 'tr' }, [ - E('td', { class: 'td left', width: '33%' }, [fields[i]]), - E('td', { class: 'td left' }, [fields[i + 1]]), - ])); + for (let i = 0; i < fields.length; i += 2) { + table.appendChild( + E('tr', { class: 'tr' }, [ + E('td', { class: 'td left', width: '33%' }, [fields[i]]), + E('td', { class: 'td left' }, [fields[i + 1]]), + ]) + ); } - var modal_body = [ + let modal_body = [ table, - E('p', { class: 'mt-2' }, + E( + 'p', + { class: 'mt-2' }, E('label', { class: 'btn' }, [ - keep, ' ', - _('Keep settings and retain the current configuration') - ])), + keep, + ' ', + _('Keep settings and retain the current configuration'), + ]) + ), E('div', { class: 'right' }, [ - E('div', { class: 'btn', click: ui.hideModal }, _('Cancel')), ' ', - E('button', { - 'class': 'btn cbi-button cbi-button-positive important', - 'click': ui.createHandlerFn(this, function () { - this.handleInstall(sysupgrade_url, keep.checked, image.sha256) - }) - }, _('Install firmware image')), + E('div', { class: 'btn', click: ui.hideModal }, _('Cancel')), + ' ', + E( + 'button', + { + class: 'btn cbi-button cbi-button-positive important', + click: ui.createHandlerFn(this, function () { + this.handleInstall(sysupgrade_url, keep.checked, image.sha256); + }), + }, + _('Install firmware image') + ), ]), ]; ui.showModal(_('Successfully created firmware image'), modal_body); + if (this.data.rebuilder) { + this.handleRebuilder(); + } } }, @@ -159,20 +194,37 @@ return view.extend({ if ('queue_position' in response) { ui.showModal(_('Queued...'), [ - E('p', { 'class': 'spinning' }, _('Request in build queue position %s').format(response.queue_position)) + E( + 'p', + { class: 'spinning' }, + _('Request in build queue position %s').format( + response.queue_position + ) + ), ]); } else { ui.showModal(_('Building Firmware...'), [ - E('p', { 'class': 'spinning' }, _('Progress: %s').format(this.steps[response.imagebuilder_status])) + E( + 'p', + { class: 'spinning' }, + _('Progress: %s%% %s').format( + this.steps[response.imagebuilder_status][0], + this.steps[response.imagebuilder_status][1] + ) + ), ]); } }, handleError: function (response) { response = response.json(); - var body = [ + let body = [ E('p', {}, _('Server response: %s').format(response.detail)), - E('a', { href: 'https://github.com/openwrt/asu/issues' }, _('Please report the error message and request')), + E( + 'a', + { href: 'https://github.com/openwrt/asu/issues' }, + _('Please report the error message and request') + ), E('p', {}, _('Request Data:')), E('pre', {}, JSON.stringify({ ...this.data, ...this.firmware }, null, 4)), ]; @@ -196,61 +248,118 @@ return view.extend({ ui.showModal(_('Error building the firmware image'), body); }, - handleRequest: function () { - var request_url = `${this.data.url}/api/v1/build`; - var method = "POST" - var content = this.firmware; + handleRequest: function (server, main) { + let request_url = `${server}/api/v1/build`; + let method = 'POST'; + let content = this.firmware; /** - * If `request_hash` is available use a GET request instead of + * If `request_hash` is available use a GET request instead of * sending the entire object. */ - if (this.data.request_hash) { + if (this.data.request_hash && main == true) { request_url += `/${this.data.request_hash}`; content = {}; - method = "GET" + method = 'GET'; } - request.request(request_url, { method: method, content: content }) + request + .request(request_url, { method: method, content: content }) .then((response) => { switch (response.status) { case 202: - this.handle202(response); + if (main) { + this.handle202(response); + } else { + response = response.json(); + + let view = document.getElementById(server); + view.innerText = `⏳ (${ + this.steps[response.imagebuilder_status][0] + }%) ${server}`; + } break; case 200: - poll.stop(); - this.handle200(response); + if (main == true) { + poll.remove(this.pollFn); + this.handle200(response); + } else { + poll.remove(this.rebuilder_polls[server]); + response = response.json(); + let view = document.getElementById(server); + let image = this.selectImage(response.images); + if (image.sha256_unsigned == this.data.sha256_unsigned) { + view.innerText = '✅ %s'.format(server); + } else { + view.innerHTML = `⚠️ ${server} (${_('Download')})`; + } + } break; case 400: // bad request case 422: // bad package case 500: // build failed - poll.stop(); - this.handleError(response); - break; + if (main == true) { + poll.remove(this.pollFn); + this.handleError(response); + break; + } else { + poll.remove(this.rebuilder_polls[server]); + document.getElementById(server).innerText = '🚫 %s'.format( + server + ); + } } }); }, + handleRebuilder: function () { + this.rebuilder_polls = {}; + for (let rebuilder of this.data.rebuilder) { + this.rebuilder_polls[rebuilder] = L.bind( + this.handleRequest, + this, + rebuilder, + false + ); + poll.add(this.rebuilder_polls[rebuilder], 5); + document.getElementById( + 'rebuilder_status' + ).innerHTML += `

⏳ ${rebuilder}

`; + } + poll.start(); + }, + handleInstall: function (url, keep, sha256) { ui.showModal(_('Downloading...'), [ - E('p', { 'class': 'spinning' }, _('Downloading firmware from server to browser')) + E( + 'p', + { class: 'spinning' }, + _('Downloading firmware from server to browser') + ), ]); - request.get(url, { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - responseType: 'blob', - }) + request + .get(url, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + responseType: 'blob', + }) .then((response) => { - var form_data = new FormData(); + let form_data = new FormData(); form_data.append('sessionid', rpc.getSessionID()); form_data.append('filename', '/tmp/firmware.bin'); form_data.append('filemode', 600); form_data.append('filedata', response.blob()); ui.showModal(_('Uploading...'), [ - E('p', { 'class': 'spinning' }, _('Uploading firmware from browser to device')) + E( + 'p', + { class: 'spinning' }, + _('Uploading firmware from browser to device') + ), ]); request @@ -261,164 +370,219 @@ return view.extend({ .then((response) => response.json()) .then((response) => { if (response.sha256sum != sha256) { - ui.showModal(_('Wrong checksum'), [ - E('p', _('Error during download of firmware. Please try again')), - E('div', { class: 'btn', click: ui.hideModal }, _('Close')) + E( + 'p', + _('Error during download of firmware. Please try again') + ), + E('div', { class: 'btn', click: ui.hideModal }, _('Close')), ]); } else { ui.showModal(_('Installing...'), [ - E('p', { class: 'spinning' }, _('Installing the sysupgrade. Do not unpower device!')) + E( + 'p', + { class: 'spinning' }, + _('Installing the sysupgrade. Do not unpower device!') + ), ]); - L.resolveDefault(callUpgradeStart(keep), {}) - .then((response) => { - if (keep) { - ui.awaitReconnect(window.location.host); - } else { - ui.awaitReconnect('192.168.1.1', 'openwrt.lan'); - } - }); + L.resolveDefault(callUpgradeStart(keep), {}).then((response) => { + if (keep) { + ui.awaitReconnect(window.location.host); + } else { + ui.awaitReconnect('192.168.1.1', 'openwrt.lan'); + } + }); } }); }); }, handleCheck: function () { - var { url, revision } = this.data - var { version, target } = this.firmware - var candidates = []; - var response; - var request_url = `${url}/api/overview`; + let { url, revision } = this.data; + let { version, target } = this.firmware; + let candidates = []; + let request_url = `${url}/api/overview`; if (version.endsWith('SNAPSHOT')) { request_url = `${url}/api/v1/revision/${version}/${target}`; } ui.showModal(_('Searching...'), [ - E('p', { 'class': 'spinning' }, - _('Searching for an available sysupgrade of %s - %s').format(version, revision)) + E( + 'p', + { class: 'spinning' }, + _('Searching for an available sysupgrade of %s - %s').format( + version, + revision + ) + ), ]); - L.resolveDefault(request.get(request_url)) - .then(response => { - if (!response.ok) { - ui.showModal(_('Error connecting to upgrade server'), [ - E('p', {}, _('Could not reach API at "%s". Please try again later.').format(response.url)), - E('pre', {}, response.responseText), - E('div', { class: 'right' }, [ - E('div', { class: 'btn', click: ui.hideModal }, _('Close')) - ]), - ]); - return; + L.resolveDefault(request.get(request_url)).then((response) => { + if (!response.ok) { + ui.showModal(_('Error connecting to upgrade server'), [ + E( + 'p', + {}, + _('Could not reach API at "%s". Please try again later.').format( + response.url + ) + ), + E('pre', {}, response.responseText), + E('div', { class: 'right' }, [ + E('div', { class: 'btn', click: ui.hideModal }, _('Close')), + ]), + ]); + return; + } + if (version.endsWith('SNAPSHOT')) { + const remote_revision = response.json().revision; + if ( + get_revision_count(revision) < get_revision_count(remote_revision) + ) { + candidates.push([version, remote_revision]); } - if (version.endsWith('SNAPSHOT')) { - const remote_revision = response.json().revision; - if (get_revision_count(revision) < get_revision_count(remote_revision)) { - candidates.push([version, remote_revision]); - } - } else { - const latest = response.json().latest; + } else { + const latest = response.json().latest; - for (let remote_version of latest) { - var remote_branch = get_branch(remote_version); + for (let remote_version of latest) { + let remote_branch = get_branch(remote_version); - // already latest version installed - if (version == remote_version) { - break; - } + // already latest version installed + if (version == remote_version) { + break; + } - // skip branch upgrades outside the advanced mode - if (this.data.branch != remote_branch && this.data.advanced_mode == 0) { - continue; - } + // skip branch upgrades outside the advanced mode + if ( + this.data.branch != remote_branch && + this.data.advanced_mode == 0 + ) { + continue; + } - candidates.unshift([remote_version, null]); + candidates.unshift([remote_version, null]); - // don't offer branches older than the current - if (this.data.branch == remote_branch) { - break; - } + // don't offer branches older than the current + if (this.data.branch == remote_branch) { + break; } } + } - // allow to re-install running firmware in advanced mode - if (this.data.advanced_mode == 1) { - candidates.unshift([version, revision]) - } - - if (candidates.length) { - var m, s, o; - - var mapdata = { - request: { - profile: this.firmware.profile, - version: candidates[0][0], - packages: Object.keys(this.firmware.packages).sort(), - }, - }; - - var map = new form.JSONMap(mapdata, ''); + // allow to re-install running firmware in advanced mode + if (this.data.advanced_mode == 1) { + candidates.unshift([version, revision]); + } - s = map.section(form.NamedSection, 'request', '', '', 'Use defaults for the safest update'); - o = s.option(form.ListValue, 'version', 'Select firmware version'); - for (let candidate of candidates) { - if (candidate[0] == version && candidate[1] == revision) { - o.value(candidate[0], _('[installed] %s') - .format(candidate[1] ? `${candidate[0]} - ${candidate[1]}` : candidate[0])); - } else { - o.value(candidate[0], candidate[1] ? `${candidate[0]} - ${candidate[1]}` : candidate[0]); - } + if (candidates.length) { + let s, o; + + let mapdata = { + request: { + profile: this.firmware.profile, + version: candidates[0][0], + packages: Object.keys(this.firmware.packages).sort(), + }, + }; + + let map = new form.JSONMap(mapdata, ''); + + s = map.section( + form.NamedSection, + 'request', + '', + '', + 'Use defaults for the safest update' + ); + o = s.option(form.ListValue, 'version', 'Select firmware version'); + for (let candidate of candidates) { + if (candidate[0] == version && candidate[1] == revision) { + o.value( + candidate[0], + _('[installed] %s').format( + candidate[1] + ? `${candidate[0]} - ${candidate[1]}` + : candidate[0] + ) + ); + } else { + o.value( + candidate[0], + candidate[1] ? `${candidate[0]} - ${candidate[1]}` : candidate[0] + ); } + } - if (this.data.advanced_mode == 1) { - o = s.option(form.Value, 'profile', _('Board Name / Profile')); - o = s.option(form.DynamicList, 'packages', _('Packages')); - } + if (this.data.advanced_mode == 1) { + o = s.option(form.Value, 'profile', _('Board Name / Profile')); + o = s.option(form.DynamicList, 'packages', _('Packages')); + } - L.resolveDefault(map.render()). - then(form_rendered => { - ui.showModal(_('New firmware upgrade available'), [ - E('p', _('Currently running: %s - %s').format(this.firmware.version, this.data.revision)), - form_rendered, - E('div', { class: 'right' }, [ - E('div', { class: 'btn', click: ui.hideModal }, _('Cancel')), ' ', - E('button', { - 'class': 'btn cbi-button cbi-button-positive important', - 'click': ui.createHandlerFn(this, function () { - map.save().then(() => { - this.firmware.packages = mapdata.request.packages; - this.firmware.version = mapdata.request.version; - this.firmware.profile = mapdata.request.profile; - poll.add(L.bind(this.handleRequest, this), 5); - }); - }) - }, _('Request firmware image')), - ]), - ]); - }); - } else { - ui.showModal(_('No upgrade available'), [ - E('p', _('The device runs the latest firmware version %s - %s').format(version, revision)), + L.resolveDefault(map.render()).then((form_rendered) => { + ui.showModal(_('New firmware upgrade available'), [ + E( + 'p', + _('Currently running: %s - %s').format( + this.firmware.version, + this.data.revision + ) + ), + form_rendered, E('div', { class: 'right' }, [ - E('div', { class: 'btn', click: ui.hideModal }, _('Close')), + E('div', { class: 'btn', click: ui.hideModal }, _('Cancel')), + ' ', + E( + 'button', + { + class: 'btn cbi-button cbi-button-positive important', + click: ui.createHandlerFn(this, function () { + map.save().then(() => { + this.firmware.packages = mapdata.request.packages; + this.firmware.version = mapdata.request.version; + this.firmware.profile = mapdata.request.profile; + this.pollFn = L.bind(function () { + this.handleRequest(this.data.url, true); + }, this); + poll.add(this.pollFn, 5); + poll.start(); + }); + }), + }, + _('Request firmware image') + ), ]), ]); - } - - }); + }); + } else { + ui.showModal(_('No upgrade available'), [ + E( + 'p', + _('The device runs the latest firmware version %s - %s').format( + version, + revision + ) + ), + E('div', { class: 'right' }, [ + E('div', { class: 'btn', click: ui.hideModal }, _('Close')), + ]), + ]); + } + }); }, load: function () { return Promise.all([ L.resolveDefault(callPackagelist(), {}), L.resolveDefault(callSystemBoard(), {}), - L.resolveDefault(fs.stat("/sys/firmware/efi"), null), + L.resolveDefault(fs.stat('/sys/firmware/efi'), null), uci.load('attendedsysupgrade'), ]); }, render: function (response) { - this.firmware.client = 'luci/' + response[0].packages['luci-app-attendedsysupgrade']; + this.firmware.client = + 'luci/' + response[0].packages['luci-app-attendedsysupgrade']; this.firmware.packages = response[0].packages; this.firmware.profile = response[1].board_name; @@ -431,20 +595,46 @@ return view.extend({ this.data.efi = response[2]; this.data.url = uci.get_first('attendedsysupgrade', 'server', 'url'); - this.data.advanced_mode = uci.get_first('attendedsysupgrade', 'client', 'advanced_mode') || 0 + this.data.advanced_mode = + uci.get_first('attendedsysupgrade', 'client', 'advanced_mode') || 0; + this.data.rebuilder = uci.get_first( + 'attendedsysupgrade', + 'server', + 'rebuilder' + ); return E('p', [ E('h2', _('Attended Sysupgrade')), - E('p', _('The attended sysupgrade service allows to easily upgrade vanilla and custom firmware images.')), - E('p', _('This is done by building a new firmware on demand via an online service.')), - E('p', _('Currently running: %s - %s').format(this.firmware.version, this.data.revision)), - E('button', { - 'class': 'btn cbi-button cbi-button-positive important', - 'click': ui.createHandlerFn(this, this.handleCheck) - }, _('Search for firmware upgrade')) + E( + 'p', + _( + 'The attended sysupgrade service allows to easily upgrade vanilla and custom firmware images.' + ) + ), + E( + 'p', + _( + 'This is done by building a new firmware on demand via an online service.' + ) + ), + E( + 'p', + _('Currently running: %s - %s').format( + this.firmware.version, + this.data.revision + ) + ), + E( + 'button', + { + class: 'btn cbi-button cbi-button-positive important', + click: ui.createHandlerFn(this, this.handleCheck), + }, + _('Search for firmware upgrade') + ), ]); }, handleSaveApply: null, handleSave: null, - handleReset: null + handleReset: null, }); -- 2.30.2