32698b3d028fa887a207c8c373ed8c47da99e5b8
[project/luci.git] /
1 'use strict';
2 'require view';
3 'require form';
4 'require uci';
5 'require rpc';
6 'require ui';
7 'require poll';
8 'require request';
9 'require dom';
10 'require fs';
11
12 const callPackagelist = rpc.declare({
13 object: 'rpc-sys',
14 method: 'packagelist',
15 });
16
17 const callSystemBoard = rpc.declare({
18 object: 'system',
19 method: 'board',
20 });
21
22 const callUpgradeStart = rpc.declare({
23 object: 'rpc-sys',
24 method: 'upgrade_start',
25 params: ['keep'],
26 });
27
28 /**
29 * Returns the branch of a given version. This helps to offer upgrades
30 * for point releases (aka within the branch).
31 *
32 * Logic:
33 * SNAPSHOT -> SNAPSHOT
34 * 21.02-SNAPSHOT -> 21.02
35 * 21.02.0-rc1 -> 21.02
36 * 19.07.8 -> 19.07
37 *
38 * @param {string} version
39 * Input version from which to determine the branch
40 * @returns {string}
41 * The determined branch
42 */
43 function get_branch(version) {
44 return version.replace('-SNAPSHOT', '').split('.').slice(0, 2).join('.');
45 }
46
47 /**
48 * The OpenWrt revision string contains both a hash as well as the number
49 * commits since the OpenWrt/LEDE reboot. It helps to determine if a
50 * snapshot is newer than another.
51 *
52 * @param {string} revision
53 * Revision string of a OpenWrt device
54 * @returns {integer}
55 * The number of commits since OpenWrt/LEDE reboot
56 */
57 function get_revision_count(revision) {
58 return parseInt(revision.substring(1).split('-')[0]);
59 }
60
61 return view.extend({
62 steps: {
63 init: [ 0, _('Received build request')],
64 container_setup: [ 10, _('Setting up ImageBuilder')],
65 validate_revision: [ 20, _('Validating revision')],
66 validate_manifest: [ 30, _('Validating package selection')],
67 calculate_packages_hash: [ 40, _('Calculating package hash')],
68 building_image: [ 50, _('Generating firmware image')],
69 signing_images: [ 95, _('Signing images')],
70 done: [100, _('Completed generating firmware image')],
71 failed: [100, _('Failed to generate firmware image')],
72
73 /* Obsolete status values, retained for backward compatibility. */
74 download_imagebuilder: [ 20, _('Downloading ImageBuilder archive')],
75 unpack_imagebuilder: [ 40, _('Setting Up ImageBuilder')],
76 },
77
78 request_hash: '',
79 sha256_unsigned: '',
80
81 selectImage: function (images, data, firmware) {
82 var filesystemFilter = function(e) {
83 return (e.filesystem == firmware.filesystem);
84 }
85 var typeFilter = function(e) {
86 let efi_targets = ['armsr', 'loongarch', 'x86'];
87 let efi_capable = efi_targets.some((tgt) => firmware.target.startsWith(tgt));
88 if (efi_capable) {
89 if (data.efi) {
90 return (e.type == 'combined-efi');
91 } else {
92 return (e.type == 'combined');
93 }
94 } else {
95 return (e.type == 'sysupgrade' || e.type == 'combined');
96 }
97 }
98 return images.filter(filesystemFilter).filter(typeFilter)[0];
99 },
100
101 handle200: function (response, content, data, firmware) {
102 response = response.json();
103 let image = this.selectImage(response.images, data, firmware);
104
105 if (image.name != undefined) {
106 this.sha256_unsigned = image.sha256_unsigned;
107 let sysupgrade_url = `${data.url}/store/${response.bin_dir}/${image.name}`;
108
109 let keep = E('input', { type: 'checkbox' });
110 keep.checked = true;
111
112 let fields = [
113 _('Version'),
114 `${response.version_number} ${response.version_code}`,
115 _('SHA256'),
116 image.sha256,
117 ];
118
119 if (data.advanced_mode == 1) {
120 fields.push(
121 _('Profile'),
122 response.id,
123 _('Target'),
124 response.target,
125 _('Build Date'),
126 response.build_at,
127 _('Filename'),
128 image.name,
129 _('Filesystem'),
130 image.filesystem
131 );
132 }
133
134 fields.push(
135 '',
136 E('a', { href: sysupgrade_url }, _('Download firmware image'))
137 );
138 if (data.rebuilder) {
139 fields.push(_('Rebuilds'), E('div', { id: 'rebuilder_status' }));
140 }
141
142 let table = E('div', { class: 'table' });
143
144 for (let i = 0; i < fields.length; i += 2) {
145 table.appendChild(
146 E('tr', { class: 'tr' }, [
147 E('td', { class: 'td left', width: '33%' }, [fields[i]]),
148 E('td', { class: 'td left' }, [fields[i + 1]]),
149 ])
150 );
151 }
152
153 let modal_body = [
154 table,
155 E(
156 'p',
157 { class: 'mt-2' },
158 E('label', { class: 'btn' }, [
159 keep,
160 ' ',
161 _('Keep settings and retain the current configuration'),
162 ])
163 ),
164 E('div', { class: 'right' }, [
165 E('div', { class: 'btn', click: ui.hideModal }, _('Cancel')),
166 ' ',
167 E(
168 'button',
169 {
170 class: 'btn cbi-button cbi-button-positive important',
171 click: ui.createHandlerFn(this, function () {
172 this.handleInstall(sysupgrade_url, keep.checked, image.sha256);
173 }),
174 },
175 _('Install firmware image')
176 ),
177 ]),
178 ];
179
180 ui.showModal(_('Successfully created firmware image'), modal_body);
181 if (data.rebuilder) {
182 this.handleRebuilder(content, data, firmware);
183 }
184 }
185 },
186
187 handle202: function (response) {
188 response = response.json();
189 this.request_hash = response.request_hash;
190
191 if ('queue_position' in response) {
192 ui.showModal(_('Queued...'), [
193 E(
194 'p',
195 { class: 'spinning' },
196 _('Request in build queue position %s').format(
197 response.queue_position
198 )
199 ),
200 ]);
201 } else {
202 ui.showModal(_('Building Firmware...'), [
203 E(
204 'p',
205 { class: 'spinning' },
206 _('Progress: %s%% %s').format(
207 this.steps[response.imagebuilder_status][0],
208 this.steps[response.imagebuilder_status][1]
209 )
210 ),
211 ]);
212 }
213 },
214
215 handleError: function (response, data, firmware) {
216 response = response.json();
217 const request_data = {
218 ...data,
219 request_hash: this.request_hash,
220 sha256_unsigned: this.sha256_unsigned,
221 ...firmware
222 };
223 let body = [
224 E('p', {}, _('Server response: %s').format(response.detail)),
225 E(
226 'a',
227 { href: 'https://github.com/openwrt/asu/issues' },
228 _('Please report the error message and request')
229 ),
230 E('p', {}, _('Request Data:')),
231 E('pre', {}, JSON.stringify({ ...request_data }, null, 4)),
232 ];
233
234 if (response.stdout) {
235 body.push(E('b', {}, 'STDOUT:'));
236 body.push(E('pre', {}, response.stdout));
237 }
238
239 if (response.stderr) {
240 body.push(E('b', {}, 'STDERR:'));
241 body.push(E('pre', {}, response.stderr));
242 }
243
244 body = body.concat([
245 E('div', { class: 'right' }, [
246 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
247 ]),
248 ]);
249
250 ui.showModal(_('Error building the firmware image'), body);
251 },
252
253 handleRequest: function (server, main, content, data, firmware) {
254 let request_url = `${server}/api/v1/build`;
255 let method = 'POST';
256 let local_content = content;
257
258 /**
259 * If `request_hash` is available use a GET request instead of
260 * sending the entire object.
261 */
262 if (this.request_hash && main == true) {
263 request_url += `/${this.request_hash}`;
264 local_content = {};
265 method = 'GET';
266 }
267
268 request
269 .request(request_url, { method: method, content: local_content })
270 .then((response) => {
271 switch (response.status) {
272 case 202:
273 if (main) {
274 this.handle202(response);
275 } else {
276 response = response.json();
277
278 let view = document.getElementById(server);
279 view.innerText = `⏳ (${
280 this.steps[response.imagebuilder_status][0]
281 }%) ${server}`;
282 }
283 break;
284 case 200:
285 if (main == true) {
286 poll.remove(this.pollFn);
287 this.handle200(response, content, data, firmware);
288 } else {
289 poll.remove(this.rebuilder_polls[server]);
290 response = response.json();
291 let view = document.getElementById(server);
292 let image = this.selectImage(response.images, data, firmware);
293 if (image.sha256_unsigned == this.sha256_unsigned) {
294 view.innerText = '✅ %s'.format(server);
295 } else {
296 view.innerHTML = `⚠️ ${server} (<a href="${server}/store/${
297 response.bin_dir
298 }/${image.name}">${_('Download')}</a>)`;
299 }
300 }
301 break;
302 case 400: // bad request
303 case 422: // bad package
304 case 500: // build failed
305 if (main == true) {
306 poll.remove(this.pollFn);
307 this.handleError(response, data, firmware);
308 break;
309 } else {
310 poll.remove(this.rebuilder_polls[server]);
311 document.getElementById(server).innerText = '🚫 %s'.format(
312 server
313 );
314 }
315 }
316 });
317 },
318
319 handleRebuilder: function (content, data, firmware) {
320 this.rebuilder_polls = {};
321 for (let rebuilder of data.rebuilder) {
322 this.rebuilder_polls[rebuilder] = L.bind(
323 this.handleRequest,
324 this,
325 rebuilder,
326 false,
327 content,
328 data,
329 firmware
330 );
331 poll.add(this.rebuilder_polls[rebuilder], 5);
332 document.getElementById(
333 'rebuilder_status'
334 ).innerHTML += `<p id="${rebuilder}">⏳ ${rebuilder}</p>`;
335 }
336 poll.start();
337 },
338
339 handleInstall: function (url, keep, sha256) {
340 ui.showModal(_('Downloading...'), [
341 E(
342 'p',
343 { class: 'spinning' },
344 _('Downloading firmware from server to browser')
345 ),
346 ]);
347
348 request
349 .get(url, {
350 headers: {
351 'Content-Type': 'application/x-www-form-urlencoded',
352 },
353 responseType: 'blob',
354 })
355 .then((response) => {
356 let form_data = new FormData();
357 form_data.append('sessionid', rpc.getSessionID());
358 form_data.append('filename', '/tmp/firmware.bin');
359 form_data.append('filemode', 600);
360 form_data.append('filedata', response.blob());
361
362 ui.showModal(_('Uploading...'), [
363 E(
364 'p',
365 { class: 'spinning' },
366 _('Uploading firmware from browser to device')
367 ),
368 ]);
369
370 request
371 .get(`${L.env.cgi_base}/cgi-upload`, {
372 method: 'PUT',
373 content: form_data,
374 })
375 .then((response) => response.json())
376 .then((response) => {
377 if (response.sha256sum != sha256) {
378 ui.showModal(_('Wrong checksum'), [
379 E(
380 'p',
381 _('Error during download of firmware. Please try again')
382 ),
383 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
384 ]);
385 } else {
386 ui.showModal(_('Installing...'), [
387 E(
388 'p',
389 { class: 'spinning' },
390 _('Installing the sysupgrade. Do not unpower device!')
391 ),
392 ]);
393
394 L.resolveDefault(callUpgradeStart(keep), {}).then((response) => {
395 if (keep) {
396 ui.awaitReconnect(window.location.host);
397 } else {
398 ui.awaitReconnect('192.168.1.1', 'openwrt.lan');
399 }
400 });
401 }
402 });
403 });
404 },
405
406 handleCheck: function (data, firmware) {
407 this.request_hash = '';
408 let { url, revision, advanced_mode, branch } = data;
409 let { version, target, profile, packages } = firmware;
410 let candidates = [];
411
412 const endpoint = version.endsWith('SNAPSHOT') ? `revision/${version}/${target}` : 'overview';
413 const request_url = `${url}/api/v1/${endpoint}`;
414
415 ui.showModal(_('Searching...'), [
416 E(
417 'p',
418 { class: 'spinning' },
419 _('Searching for an available sysupgrade of %s - %s').format(
420 version,
421 revision
422 )
423 ),
424 ]);
425
426 L.resolveDefault(request.get(request_url)).then((response) => {
427 if (!response.ok) {
428 ui.showModal(_('Error connecting to upgrade server'), [
429 E(
430 'p',
431 {},
432 _('Could not reach API at "%s". Please try again later.').format(
433 response.url
434 )
435 ),
436 E('pre', {}, response.responseText),
437 E('div', { class: 'right' }, [
438 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
439 ]),
440 ]);
441 return;
442 }
443 if (version.endsWith('SNAPSHOT')) {
444 const remote_revision = response.json().revision;
445 if (
446 get_revision_count(revision) < get_revision_count(remote_revision)
447 ) {
448 candidates.push([version, remote_revision]);
449 }
450 } else {
451 const latest = response.json().latest;
452
453 // ensure order: newest to oldest release
454 latest.sort().reverse();
455
456 for (let remote_version of latest) {
457 let remote_branch = get_branch(remote_version);
458
459 // already latest version installed
460 if (version == remote_version) {
461 break;
462 }
463
464 // skip branch upgrades outside the advanced mode
465 if (branch != remote_branch && advanced_mode == 0) {
466 continue;
467 }
468
469 candidates.unshift([remote_version, null]);
470
471 // don't offer branches older than the current
472 if (branch == remote_branch) {
473 break;
474 }
475 }
476 }
477
478 // allow to re-install running firmware in advanced mode
479 if (advanced_mode == 1) {
480 candidates.unshift([version, revision]);
481 }
482
483 if (candidates.length) {
484 let s, o;
485
486 let mapdata = {
487 request: {
488 profile,
489 version: candidates[0][0],
490 packages: Object.keys(packages).sort(),
491 },
492 };
493
494 let map = new form.JSONMap(mapdata, '');
495
496 s = map.section(
497 form.NamedSection,
498 'request',
499 '',
500 '',
501 'Use defaults for the safest update'
502 );
503 o = s.option(form.ListValue, 'version', 'Select firmware version');
504 for (let candidate of candidates) {
505 if (candidate[0] == version && candidate[1] == revision) {
506 o.value(
507 candidate[0],
508 _('[installed] %s').format(
509 candidate[1]
510 ? `${candidate[0]} - ${candidate[1]}`
511 : candidate[0]
512 )
513 );
514 } else {
515 o.value(
516 candidate[0],
517 candidate[1] ? `${candidate[0]} - ${candidate[1]}` : candidate[0]
518 );
519 }
520 }
521
522 if (advanced_mode == 1) {
523 o = s.option(form.Value, 'profile', _('Board Name / Profile'));
524 o = s.option(form.DynamicList, 'packages', _('Packages'));
525 }
526
527 L.resolveDefault(map.render()).then((form_rendered) => {
528 ui.showModal(_('New firmware upgrade available'), [
529 E(
530 'p',
531 _('Currently running: %s - %s').format(
532 version,
533 revision
534 )
535 ),
536 form_rendered,
537 E('div', { class: 'right' }, [
538 E('div', { class: 'btn', click: ui.hideModal }, _('Cancel')),
539 ' ',
540 E(
541 'button',
542 {
543 class: 'btn cbi-button cbi-button-positive important',
544 click: ui.createHandlerFn(this, function () {
545 map.save().then(() => {
546 const content = {
547 ...firmware,
548 packages: mapdata.request.packages,
549 version: mapdata.request.version,
550 profile: mapdata.request.profile
551 };
552 this.pollFn = L.bind(function () {
553 this.handleRequest(url, true, content, data, firmware);
554 }, this);
555 poll.add(this.pollFn, 5);
556 poll.start();
557 });
558 }),
559 },
560 _('Request firmware image')
561 ),
562 ]),
563 ]);
564 });
565 } else {
566 ui.showModal(_('No upgrade available'), [
567 E(
568 'p',
569 _('The device runs the latest firmware version %s - %s').format(
570 version,
571 revision
572 )
573 ),
574 E('div', { class: 'right' }, [
575 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
576 ]),
577 ]);
578 }
579 });
580 },
581
582 load: async function () {
583 const promises = await Promise.all([
584 L.resolveDefault(callPackagelist(), {}),
585 L.resolveDefault(callSystemBoard(), {}),
586 L.resolveDefault(fs.stat('/sys/firmware/efi'), null),
587 uci.load('attendedsysupgrade'),
588 ]);
589 const data = {
590 url: uci.get_first('attendedsysupgrade', 'server', 'url'),
591 branch: get_branch(promises[1].release.version),
592 revision: promises[1].release.revision,
593 efi: promises[2],
594 advanced_mode: uci.get_first('attendedsysupgrade', 'client', 'advanced_mode') || 0,
595 rebuilder: uci.get_first('attendedsysupgrade', 'server', 'rebuilder')
596 };
597 const firmware = {
598 client: 'luci/' + promises[0].packages['luci-app-attendedsysupgrade'],
599 packages: promises[0].packages,
600 profile: promises[1].board_name,
601 target: promises[1].release.target,
602 version: promises[1].release.version,
603 diff_packages: true,
604 filesystem: promises[1].rootfs_type
605 };
606 return [data, firmware];
607 },
608
609 render: function (response) {
610 const data = response[0];
611 const firmware = response[1];
612
613 return E('p', [
614 E('h2', _('Attended Sysupgrade')),
615 E(
616 'p',
617 _(
618 'The attended sysupgrade service allows to upgrade vanilla and custom firmware images easily.'
619 )
620 ),
621 E(
622 'p',
623 _(
624 'This is done by building a new firmware on demand via an online service.'
625 )
626 ),
627 E(
628 'p',
629 _('Currently running: %s - %s').format(
630 firmware.version,
631 data.revision
632 )
633 ),
634 E(
635 'button',
636 {
637 class: 'btn cbi-button cbi-button-positive important',
638 click: ui.createHandlerFn(this, this.handleCheck, data, firmware),
639 },
640 _('Search for firmware upgrade')
641 ),
642 ]);
643 },
644 handleSaveApply: null,
645 handleSave: null,
646 handleReset: null,
647 });