luci-app-attendedsysupgrade: introduce rebuilders

This adds automatic verification builds to shift trust on multiple
server and multiple entities.

Signed-off-by: Paul Spooren <mail@aparcar.org>
This commit is contained in:
Paul Spooren 2022-03-31 16:01:31 +01:00
parent a97ff279ce
commit fa9fb2f955
2 changed files with 425 additions and 210 deletions

View file

@ -4,27 +4,52 @@
return view.extend({ return view.extend({
render: function () { render: function () {
var m, s, o; let m, s, o;
m = new form.Map('attendedsysupgrade', _('Attended Sysupgrade'), m = new form.Map(
_('Attendedsysupgrade Configuration.')); 'attendedsysupgrade',
_('Attended Sysupgrade'),
_('Attendedsysupgrade Configuration.')
);
s = m.section(form.TypedSection, 'server', _('Server')); s = m.section(form.TypedSection, 'server', _('Server'));
s.anonymous = true; s.anonymous = true;
s.option(form.Value, 'url', _('Address'), s.option(
_('Address of the sysupgrade server')); 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 = m.section(form.TypedSection, 'client', _('Client'));
s.anonymous = true; s.anonymous = true;
o = s.option(form.Flag, 'auto_search', _('Search on opening'), o = s.option(
_('Search for new sysupgrades on opening the tab')); form.Flag,
'auto_search',
_('Search on opening'),
_('Search for new sysupgrades on opening the tab')
);
o.default = '1'; o.default = '1';
o.rmempty = false; o.rmempty = false;
o = s.option(form.Flag, 'advanced_mode', _('Advanced Mode'), o = s.option(
_('Show advanced options like package list modification')); form.Flag,
'advanced_mode',
_('Advanced Mode'),
_('Show advanced options like package list modification')
);
o.default = '0'; o.default = '0';
o.rmempty = false; o.rmempty = false;

View file

@ -9,17 +9,17 @@
'require dom'; 'require dom';
'require fs'; 'require fs';
var callPackagelist = rpc.declare({ let callPackagelist = rpc.declare({
object: 'rpc-sys', object: 'rpc-sys',
method: 'packagelist', method: 'packagelist',
}); });
var callSystemBoard = rpc.declare({ let callSystemBoard = rpc.declare({
object: 'system', object: 'system',
method: 'board', method: 'board',
}); });
var callUpgradeStart = rpc.declare({ let callUpgradeStart = rpc.declare({
object: 'rpc-sys', object: 'rpc-sys',
method: 'upgrade_start', method: 'upgrade_start',
params: ['keep'], params: ['keep'],
@ -60,17 +60,19 @@ function get_revision_count(revision) {
return view.extend({ return view.extend({
steps: { steps: {
init: _('10% Received build request'), init: [10, _('Received build request')],
download_imagebuilder: _('20% Downloading ImageBuilder archive'), download_imagebuilder: [20, _('Downloading ImageBuilder archive')],
unpack_imagebuilder: _('40% Setup ImageBuilder'), unpack_imagebuilder: [40, _('Setup ImageBuilder')],
calculate_packages_hash: _('60% Validate package selection'), calculate_packages_hash: [60, _('Validate package selection')],
building_image: _('80% Generating firmware image') building_image: [80, _('Generating firmware image')],
}, },
data: { data: {
url: '', url: '',
revision: '', revision: '',
advanced_mode: 0, advanced_mode: 0,
rebuilder: [],
sha256_unsigned: '',
}, },
firmware: { firmware: {
@ -82,74 +84,107 @@ return view.extend({
filesystem: '', filesystem: '',
}, },
handle200: function (response) { selectImage: function (images) {
response = response.json(); let image;
var image; for (image of images) {
for (image of response.images) {
if (this.firmware.filesystem == image.filesystem) { if (this.firmware.filesystem == image.filesystem) {
if (this.data.efi) { if (this.data.efi) {
if (image.type == 'combined-efi') { if (image.type == 'combined-efi') {
break; return image;
} }
} else { } else {
if (image.type == 'sysupgrade' || image.type == 'combined') { 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) { 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; keep.checked = true;
var fields = [ let fields = [
_('Version'), `${response.version_number} ${response.version_code}`, _('Version'),
_('SHA256'), image.sha256, `${response.version_number} ${response.version_code}`,
_('SHA256'),
image.sha256,
]; ];
if (this.data.advanced_mode == 1) { if (this.data.advanced_mode == 1) {
fields.push( fields.push(
_('Profile'), response.id, _('Profile'),
_('Target'), response.target, response.id,
_('Build Date'), response.build_at, _('Target'),
_('Filename'), image.name, response.target,
_('Filesystem'), image.filesystem, _('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) { for (let i = 0; i < fields.length; i += 2) {
table.appendChild(E('tr', { class: 'tr' }, [ table.appendChild(
E('tr', { class: 'tr' }, [
E('td', { class: 'td left', width: '33%' }, [fields[i]]), E('td', { class: 'td left', width: '33%' }, [fields[i]]),
E('td', { class: 'td left' }, [fields[i + 1]]), E('td', { class: 'td left' }, [fields[i + 1]]),
])); ])
);
} }
var modal_body = [ let modal_body = [
table, table,
E('p', { class: 'mt-2' }, E(
'p',
{ class: 'mt-2' },
E('label', { class: 'btn' }, [ E('label', { class: 'btn' }, [
keep, ' ', keep,
_('Keep settings and retain the current configuration') ' ',
])), _('Keep settings and retain the current configuration'),
])
),
E('div', { class: 'right' }, [ E('div', { class: 'right' }, [
E('div', { class: 'btn', click: ui.hideModal }, _('Cancel')), ' ', E('div', { class: 'btn', click: ui.hideModal }, _('Cancel')),
E('button', { ' ',
'class': 'btn cbi-button cbi-button-positive important', E(
'click': ui.createHandlerFn(this, function () { 'button',
this.handleInstall(sysupgrade_url, keep.checked, image.sha256) {
}) class: 'btn cbi-button cbi-button-positive important',
}, _('Install firmware image')), click: ui.createHandlerFn(this, function () {
this.handleInstall(sysupgrade_url, keep.checked, image.sha256);
}),
},
_('Install firmware image')
),
]), ]),
]; ];
ui.showModal(_('Successfully created firmware image'), modal_body); 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) { if ('queue_position' in response) {
ui.showModal(_('Queued...'), [ 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 { } else {
ui.showModal(_('Building Firmware...'), [ 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) { handleError: function (response) {
response = response.json(); response = response.json();
var body = [ let body = [
E('p', {}, _('Server response: %s').format(response.detail)), 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('p', {}, _('Request Data:')),
E('pre', {}, JSON.stringify({ ...this.data, ...this.firmware }, null, 4)), 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); ui.showModal(_('Error building the firmware image'), body);
}, },
handleRequest: function () { handleRequest: function (server, main) {
var request_url = `${this.data.url}/api/v1/build`; let request_url = `${server}/api/v1/build`;
var method = "POST" let method = 'POST';
var content = this.firmware; 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. * sending the entire object.
*/ */
if (this.data.request_hash) { if (this.data.request_hash && main == true) {
request_url += `/${this.data.request_hash}`; request_url += `/${this.data.request_hash}`;
content = {}; content = {};
method = "GET" method = 'GET';
} }
request.request(request_url, { method: method, content: content }) request
.request(request_url, { method: method, content: content })
.then((response) => { .then((response) => {
switch (response.status) { switch (response.status) {
case 202: case 202:
if (main) {
this.handle202(response); this.handle202(response);
} else {
response = response.json();
let view = document.getElementById(server);
view.innerText = `⏳ (${
this.steps[response.imagebuilder_status][0]
}%) ${server}`;
}
break; break;
case 200: case 200:
poll.stop(); if (main == true) {
poll.remove(this.pollFn);
this.handle200(response); 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} (<a href="${server}/store/${
response.bin_dir
}/${image.name}">${_('Download')}</a>)`;
}
}
break; break;
case 400: // bad request case 400: // bad request
case 422: // bad package case 422: // bad package
case 500: // build failed case 500: // build failed
poll.stop(); if (main == true) {
poll.remove(this.pollFn);
this.handleError(response); this.handleError(response);
break; 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 += `<p id="${rebuilder}">⏳ ${rebuilder}</p>`;
}
poll.start();
},
handleInstall: function (url, keep, sha256) { handleInstall: function (url, keep, sha256) {
ui.showModal(_('Downloading...'), [ 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, { request
.get(url, {
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
}, },
responseType: 'blob', responseType: 'blob',
}) })
.then((response) => { .then((response) => {
var form_data = new FormData(); let form_data = new FormData();
form_data.append('sessionid', rpc.getSessionID()); form_data.append('sessionid', rpc.getSessionID());
form_data.append('filename', '/tmp/firmware.bin'); form_data.append('filename', '/tmp/firmware.bin');
form_data.append('filemode', 600); form_data.append('filemode', 600);
form_data.append('filedata', response.blob()); form_data.append('filedata', response.blob());
ui.showModal(_('Uploading...'), [ ui.showModal(_('Uploading...'), [
E('p', { 'class': 'spinning' }, _('Uploading firmware from browser to device')) E(
'p',
{ class: 'spinning' },
_('Uploading firmware from browser to device')
),
]); ]);
request request
@ -261,18 +370,23 @@ return view.extend({
.then((response) => response.json()) .then((response) => response.json())
.then((response) => { .then((response) => {
if (response.sha256sum != sha256) { if (response.sha256sum != sha256) {
ui.showModal(_('Wrong checksum'), [ ui.showModal(_('Wrong checksum'), [
E('p', _('Error during download of firmware. Please try again')), E(
E('div', { class: 'btn', click: ui.hideModal }, _('Close')) 'p',
_('Error during download of firmware. Please try again')
),
E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
]); ]);
} else { } else {
ui.showModal(_('Installing...'), [ 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), {}) L.resolveDefault(callUpgradeStart(keep), {}).then((response) => {
.then((response) => {
if (keep) { if (keep) {
ui.awaitReconnect(window.location.host); ui.awaitReconnect(window.location.host);
} else { } else {
@ -285,42 +399,54 @@ return view.extend({
}, },
handleCheck: function () { handleCheck: function () {
var { url, revision } = this.data let { url, revision } = this.data;
var { version, target } = this.firmware let { version, target } = this.firmware;
var candidates = []; let candidates = [];
var response; let request_url = `${url}/api/overview`;
var request_url = `${url}/api/overview`;
if (version.endsWith('SNAPSHOT')) { if (version.endsWith('SNAPSHOT')) {
request_url = `${url}/api/v1/revision/${version}/${target}`; request_url = `${url}/api/v1/revision/${version}/${target}`;
} }
ui.showModal(_('Searching...'), [ ui.showModal(_('Searching...'), [
E('p', { 'class': 'spinning' }, E(
_('Searching for an available sysupgrade of %s - %s').format(version, revision)) 'p',
{ class: 'spinning' },
_('Searching for an available sysupgrade of %s - %s').format(
version,
revision
)
),
]); ]);
L.resolveDefault(request.get(request_url)) L.resolveDefault(request.get(request_url)).then((response) => {
.then(response => {
if (!response.ok) { if (!response.ok) {
ui.showModal(_('Error connecting to upgrade server'), [ ui.showModal(_('Error connecting to upgrade server'), [
E('p', {}, _('Could not reach API at "%s". Please try again later.').format(response.url)), E(
'p',
{},
_('Could not reach API at "%s". Please try again later.').format(
response.url
)
),
E('pre', {}, response.responseText), E('pre', {}, response.responseText),
E('div', { class: 'right' }, [ E('div', { class: 'right' }, [
E('div', { class: 'btn', click: ui.hideModal }, _('Close')) E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
]), ]),
]); ]);
return; return;
} }
if (version.endsWith('SNAPSHOT')) { if (version.endsWith('SNAPSHOT')) {
const remote_revision = response.json().revision; const remote_revision = response.json().revision;
if (get_revision_count(revision) < get_revision_count(remote_revision)) { if (
get_revision_count(revision) < get_revision_count(remote_revision)
) {
candidates.push([version, remote_revision]); candidates.push([version, remote_revision]);
} }
} else { } else {
const latest = response.json().latest; const latest = response.json().latest;
for (let remote_version of latest) { for (let remote_version of latest) {
var remote_branch = get_branch(remote_version); let remote_branch = get_branch(remote_version);
// already latest version installed // already latest version installed
if (version == remote_version) { if (version == remote_version) {
@ -328,7 +454,10 @@ return view.extend({
} }
// skip branch upgrades outside the advanced mode // skip branch upgrades outside the advanced mode
if (this.data.branch != remote_branch && this.data.advanced_mode == 0) { if (
this.data.branch != remote_branch &&
this.data.advanced_mode == 0
) {
continue; continue;
} }
@ -343,13 +472,13 @@ return view.extend({
// allow to re-install running firmware in advanced mode // allow to re-install running firmware in advanced mode
if (this.data.advanced_mode == 1) { if (this.data.advanced_mode == 1) {
candidates.unshift([version, revision]) candidates.unshift([version, revision]);
} }
if (candidates.length) { if (candidates.length) {
var m, s, o; let s, o;
var mapdata = { let mapdata = {
request: { request: {
profile: this.firmware.profile, profile: this.firmware.profile,
version: candidates[0][0], version: candidates[0][0],
@ -357,16 +486,31 @@ return view.extend({
}, },
}; };
var map = new form.JSONMap(mapdata, ''); let map = new form.JSONMap(mapdata, '');
s = map.section(form.NamedSection, 'request', '', '', 'Use defaults for the safest update'); s = map.section(
form.NamedSection,
'request',
'',
'',
'Use defaults for the safest update'
);
o = s.option(form.ListValue, 'version', 'Select firmware version'); o = s.option(form.ListValue, 'version', 'Select firmware version');
for (let candidate of candidates) { for (let candidate of candidates) {
if (candidate[0] == version && candidate[1] == revision) { if (candidate[0] == version && candidate[1] == revision) {
o.value(candidate[0], _('[installed] %s') o.value(
.format(candidate[1] ? `${candidate[0]} - ${candidate[1]}` : candidate[0])); candidate[0],
_('[installed] %s').format(
candidate[1]
? `${candidate[0]} - ${candidate[1]}`
: candidate[0]
)
);
} else { } else {
o.value(candidate[0], candidate[1] ? `${candidate[0]} - ${candidate[1]}` : candidate[0]); o.value(
candidate[0],
candidate[1] ? `${candidate[0]} - ${candidate[1]}` : candidate[0]
);
} }
} }
@ -375,36 +519,55 @@ return view.extend({
o = s.option(form.DynamicList, 'packages', _('Packages')); o = s.option(form.DynamicList, 'packages', _('Packages'));
} }
L.resolveDefault(map.render()). L.resolveDefault(map.render()).then((form_rendered) => {
then(form_rendered => {
ui.showModal(_('New firmware upgrade available'), [ ui.showModal(_('New firmware upgrade available'), [
E('p', _('Currently running: %s - %s').format(this.firmware.version, this.data.revision)), E(
'p',
_('Currently running: %s - %s').format(
this.firmware.version,
this.data.revision
)
),
form_rendered, form_rendered,
E('div', { class: 'right' }, [ E('div', { class: 'right' }, [
E('div', { class: 'btn', click: ui.hideModal }, _('Cancel')), ' ', E('div', { class: 'btn', click: ui.hideModal }, _('Cancel')),
E('button', { ' ',
'class': 'btn cbi-button cbi-button-positive important', E(
'click': ui.createHandlerFn(this, function () { 'button',
{
class: 'btn cbi-button cbi-button-positive important',
click: ui.createHandlerFn(this, function () {
map.save().then(() => { map.save().then(() => {
this.firmware.packages = mapdata.request.packages; this.firmware.packages = mapdata.request.packages;
this.firmware.version = mapdata.request.version; this.firmware.version = mapdata.request.version;
this.firmware.profile = mapdata.request.profile; this.firmware.profile = mapdata.request.profile;
poll.add(L.bind(this.handleRequest, this), 5); this.pollFn = L.bind(function () {
this.handleRequest(this.data.url, true);
}, this);
poll.add(this.pollFn, 5);
poll.start();
}); });
}) }),
}, _('Request firmware image')), },
_('Request firmware image')
),
]), ]),
]); ]);
}); });
} else { } else {
ui.showModal(_('No upgrade available'), [ ui.showModal(_('No upgrade available'), [
E('p', _('The device runs the latest firmware version %s - %s').format(version, revision)), E(
'p',
_('The device runs the latest firmware version %s - %s').format(
version,
revision
)
),
E('div', { class: 'right' }, [ E('div', { class: 'right' }, [
E('div', { class: 'btn', click: ui.hideModal }, _('Close')), E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
]), ]),
]); ]);
} }
}); });
}, },
@ -412,13 +575,14 @@ return view.extend({
return Promise.all([ return Promise.all([
L.resolveDefault(callPackagelist(), {}), L.resolveDefault(callPackagelist(), {}),
L.resolveDefault(callSystemBoard(), {}), L.resolveDefault(callSystemBoard(), {}),
L.resolveDefault(fs.stat("/sys/firmware/efi"), null), L.resolveDefault(fs.stat('/sys/firmware/efi'), null),
uci.load('attendedsysupgrade'), uci.load('attendedsysupgrade'),
]); ]);
}, },
render: function (response) { 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.packages = response[0].packages;
this.firmware.profile = response[1].board_name; this.firmware.profile = response[1].board_name;
@ -431,20 +595,46 @@ return view.extend({
this.data.efi = response[2]; this.data.efi = response[2];
this.data.url = uci.get_first('attendedsysupgrade', 'server', 'url'); 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', [ return E('p', [
E('h2', _('Attended Sysupgrade')), E('h2', _('Attended Sysupgrade')),
E('p', _('The attended sysupgrade service allows to easily upgrade vanilla and custom firmware images.')), E(
E('p', _('This is done by building a new firmware on demand via an online service.')), 'p',
E('p', _('Currently running: %s - %s').format(this.firmware.version, this.data.revision)), _(
E('button', { 'The attended sysupgrade service allows to easily upgrade vanilla and custom firmware images.'
'class': 'btn cbi-button cbi-button-positive important', )
'click': ui.createHandlerFn(this, this.handleCheck) ),
}, _('Search for firmware upgrade')) 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, handleSaveApply: null,
handleSave: null, handleSave: null,
handleReset: null handleReset: null,
}); });