eef35d4fdaf4bbc2d0dade332d89204755f1ebac
[openwrt-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: new Map(),
79         sha256_unsigned: '',
80
81         applyPackageChanges: async function(package_info) {
82                 let { url, target, version, packages } = package_info;
83
84                 const overview_url = `${url}/api/v1/overview`;
85                 const revision_url = `${url}/api/v1/revision/${version}/${target}`;
86
87                 let changes, target_revision;
88
89                 await Promise.all([
90                         request.get(overview_url)
91                                 .then(response => response.json())
92                                 .then(json => json.branches)
93                                 .then(branches => branches[get_branch(version)])
94                                 .then(branch => { changes = branch.package_changes; })
95                                 .catch(error => {
96                                         throw Error(`Get overview failed:<br>${overview_url}<br>${error}`);
97                                 }),
98
99                         request.get(revision_url)
100                                 .then(response => response.json())
101                                 .then(json => json.revision)
102                                 .then(revision => { target_revision = get_revision_count(revision); })
103                                 .catch(error => {
104                                         throw Error(`Get revision failed:<br>${revision_url}<br>${error}`);
105                                 }),
106                 ]);
107
108                 for (const change of changes) {
109                         let idx = packages.indexOf(change.source);
110                         if (idx >= 0 && change.revision <= target_revision) {
111                                 if (change.target)
112                                         packages[idx] = change.target;
113                                 else
114                                         packages.splice(idx, 1);
115                         }
116                 }
117                 return packages;
118         },
119
120         selectImage: function (images, data, firmware) {
121                 var filesystemFilter = function(e) {
122                         return (e.filesystem == firmware.filesystem);
123                 }
124                 var typeFilter = function(e) {
125                         let efi_targets = ['armsr', 'loongarch', 'x86'];
126                         let efi_capable = efi_targets.some((tgt) => firmware.target.startsWith(tgt));
127                         if (efi_capable) {
128                                 if (data.efi) {
129                                         return (e.type == 'combined-efi');
130                                 } else {
131                                         return (e.type == 'combined');
132                                 }
133                         } else {
134                                 return (e.type == 'sysupgrade' || e.type == 'combined');
135                         }
136                 }
137                 return images.filter(filesystemFilter).filter(typeFilter)[0];
138         },
139
140         handle200: function (response, content, data, firmware) {
141                 response = response.json();
142                 let image = this.selectImage(response.images, data, firmware);
143
144                 if (image.name != undefined) {
145                         this.sha256_unsigned = image.sha256_unsigned;
146                         let sysupgrade_url = `${data.url}/store/${response.bin_dir}/${image.name}`;
147
148                         let keep = E('input', { type: 'checkbox' });
149                         keep.checked = true;
150
151                         let fields = [
152                                 _('Version'),
153                                 `${response.version_number} ${response.version_code}`,
154                                 _('SHA256'),
155                                 image.sha256,
156                         ];
157
158                         if (data.advanced_mode == 1) {
159                                 fields.push(
160                                         _('Profile'),
161                                         response.id,
162                                         _('Target'),
163                                         response.target,
164                                         _('Build Date'),
165                                         response.build_at,
166                                         _('Filename'),
167                                         image.name,
168                                         _('Filesystem'),
169                                         image.filesystem
170                                 );
171                         }
172
173                         fields.push(
174                                 '',
175                                 E('a', { href: sysupgrade_url }, _('Download firmware image'))
176                         );
177                         if (data.rebuilder) {
178                                 fields.push(_('Rebuilds'), E('div', { id: 'rebuilder_status' }));
179                         }
180
181                         let table = E('div', { class: 'table' });
182
183                         for (let i = 0; i < fields.length; i += 2) {
184                                 table.appendChild(
185                                         E('tr', { class: 'tr' }, [
186                                                 E('td', { class: 'td left', width: '33%' }, [fields[i]]),
187                                                 E('td', { class: 'td left' }, [fields[i + 1]]),
188                                         ])
189                                 );
190                         }
191
192                         let modal_body = [
193                                 table,
194                                 E(
195                                         'p',
196                                         { class: 'mt-2' },
197                                         E('label', { class: 'btn' }, [
198                                                 keep,
199                                                 ' ',
200                                                 _('Keep settings and retain the current configuration'),
201                                         ])
202                                 ),
203                                 E('div', { class: 'right' }, [
204                                         E('div', { class: 'btn', click: ui.hideModal }, _('Cancel')),
205                                         ' ',
206                                         E(
207                                                 'button',
208                                                 {
209                                                         class: 'btn cbi-button cbi-button-positive important',
210                                                         click: ui.createHandlerFn(this, function () {
211                                                                 this.handleInstall(sysupgrade_url, keep.checked, image.sha256);
212                                                         }),
213                                                 },
214                                                 _('Install firmware image')
215                                         ),
216                                 ]),
217                         ];
218
219                         ui.showModal(_('Successfully created firmware image'), modal_body);
220                         if (data.rebuilder) {
221                                 this.handleRebuilder(content, data, firmware);
222                         }
223                 }
224         },
225
226         handle202: function (response) {
227                 if ('queue_position' in response) {
228                         ui.showModal(_('Queued...'), [
229                                 E(
230                                         'p',
231                                         { class: 'spinning' },
232                                         _('Request in build queue position %s').format(
233                                                 response.queue_position
234                                         )
235                                 ),
236                         ]);
237                 } else {
238                         ui.showModal(_('Building Firmware...'), [
239                                 E(
240                                         'p',
241                                         { class: 'spinning' },
242                                         _('Progress: %s%% %s').format(
243                                                 this.steps[response.imagebuilder_status][0],
244                                                 this.steps[response.imagebuilder_status][1]
245                                         )
246                                 ),
247                         ]);
248                 }
249         },
250
251         handleError: function (response, data, firmware, request_hash) {
252                 response = response.json();
253                 const request_data = {
254                         ...data,
255                         request_hash: request_hash,
256                         sha256_unsigned: this.sha256_unsigned,
257                         ...firmware
258                 };
259                 let body = [
260                         E('p', {}, _('Server response: %s').format(response.detail)),
261                         E(
262                                 'a',
263                                 { href: 'https://forum.openwrt.org/t/luci-attended-sysupgrade-support-thread/230552' },
264                                 _('Please report the error message and request')
265                         ),
266                         E('p', {}, _('Request Data:')),
267                         E('pre', {}, JSON.stringify({ ...request_data }, null, 4)),
268                 ];
269
270                 if (response.stdout) {
271                         body.push(E('b', {}, 'STDOUT:'));
272                         body.push(E('pre', {}, response.stdout));
273                 }
274
275                 if (response.stderr) {
276                         body.push(E('b', {}, 'STDERR:'));
277                         body.push(E('pre', {}, response.stderr));
278                 }
279
280                 body = body.concat([
281                         E('div', { class: 'right' }, [
282                                 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
283                         ]),
284                 ]);
285
286                 ui.showModal(_('Error building the firmware image'), body);
287         },
288
289         handleRequest: function (server, main, content, data, firmware) {
290                 let request_url = `${server}/api/v1/build`;
291                 let method = 'POST';
292                 let local_content = content;
293                 const request_hash = this.request_hash.get(server);
294
295                 /**
296                  * If `request_hash` is available use a GET request instead of
297                  * sending the entire object.
298                  */
299                 if (request_hash) {
300                         request_url += `/${request_hash}`;
301                         local_content = {};
302                         method = 'GET';
303                 }
304
305                 request
306                         .request(request_url, { method: method, content: local_content })
307                         .then((response) => {
308                                 switch (response.status) {
309                                         case 202:
310                                                 response = response.json();
311
312                                                 this.request_hash.set(server, response.request_hash);
313
314                                                 if (main) {
315                                                         this.handle202(response);
316                                                 } else {
317                                                         let view = document.getElementById(server);
318                                                         view.innerText = `⏳   (${
319                                                                 this.steps[response.imagebuilder_status][0]
320                                                         }%) ${server}`;
321                                                 }
322                                                 break;
323                                         case 200:
324                                                 if (main == true) {
325                                                         poll.remove(this.pollFn);
326                                                         this.handle200(response, content, data, firmware);
327                                                 } else {
328                                                         poll.remove(this.rebuilder_polls[server]);
329                                                         response = response.json();
330                                                         let view = document.getElementById(server);
331                                                         let image = this.selectImage(response.images, data, firmware);
332                                                         if (image.sha256_unsigned == this.sha256_unsigned) {
333                                                                 view.innerText = '✅ %s'.format(server);
334                                                         } else {
335                                                                 view.innerHTML = `⚠️ ${server} (<a href="${server}/store/${
336                                                                         response.bin_dir
337                                                                 }/${image.name}">${_('Download')}</a>)`;
338                                                         }
339                                                 }
340                                                 break;
341                                         default:  // any error or unexpected responses
342                                                 if (main == true) {
343                                                         poll.remove(this.pollFn);
344                                                         this.handleError(response, data, firmware, request_hash);
345                                                 } else {
346                                                         poll.remove(this.rebuilder_polls[server]);
347                                                         document.getElementById(server).innerText = '🚫 %s'.format(
348                                                                 server
349                                                         );
350                                                 }
351                                                 break;
352                                 }
353                         });
354         },
355
356         handleRebuilder: function (content, data, firmware) {
357                 this.rebuilder_polls = {};
358                 for (let rebuilder of data.rebuilder) {
359                         this.rebuilder_polls[rebuilder] = L.bind(
360                                 this.handleRequest,
361                                 this,
362                                 rebuilder,
363                                 false,
364                                 content,
365                                 data,
366                                 firmware
367                         );
368                         poll.add(this.rebuilder_polls[rebuilder], 5);
369                         document.getElementById(
370                                 'rebuilder_status'
371                         ).innerHTML += `<p id="${rebuilder}">⏳ ${rebuilder}</p>`;
372                 }
373                 poll.start();
374         },
375
376         handleInstall: function (url, keep, sha256) {
377                 ui.showModal(_('Downloading...'), [
378                         E(
379                                 'p',
380                                 { class: 'spinning' },
381                                 _('Downloading firmware from server to browser')
382                         ),
383                 ]);
384
385                 request
386                         .get(url, {
387                                 headers: {
388                                         'Content-Type': 'application/x-www-form-urlencoded',
389                                 },
390                                 responseType: 'blob',
391                         })
392                         .then((response) => {
393                                 let form_data = new FormData();
394                                 form_data.append('sessionid', rpc.getSessionID());
395                                 form_data.append('filename', '/tmp/firmware.bin');
396                                 form_data.append('filemode', 600);
397                                 form_data.append('filedata', response.blob());
398
399                                 ui.showModal(_('Uploading...'), [
400                                         E(
401                                                 'p',
402                                                 { class: 'spinning' },
403                                                 _('Uploading firmware from browser to device')
404                                         ),
405                                 ]);
406
407                                 request
408                                         .get(`${L.env.cgi_base}/cgi-upload`, {
409                                                 method: 'PUT',
410                                                 content: form_data,
411                                         })
412                                         .then((response) => response.json())
413                                         .then((response) => {
414                                                 if (response.sha256sum != sha256) {
415                                                         ui.showModal(_('Wrong checksum'), [
416                                                                 E(
417                                                                         'p',
418                                                                         _('Error during download of firmware. Please try again')
419                                                                 ),
420                                                                 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
421                                                         ]);
422                                                 } else {
423                                                         ui.showModal(_('Installing...'), [
424                                                                 E(
425                                                                         'p',
426                                                                         { class: 'spinning' },
427                                                                         _('Installing the sysupgrade. Do not unpower device!')
428                                                                 ),
429                                                         ]);
430
431                                                         L.resolveDefault(callUpgradeStart(keep), {}).then((response) => {
432                                                                 if (keep) {
433                                                                         ui.awaitReconnect(window.location.host);
434                                                                 } else {
435                                                                         ui.awaitReconnect('192.168.1.1', 'openwrt.lan');
436                                                                 }
437                                                         });
438                                                 }
439                                         });
440                         });
441         },
442
443         handleCheck: function (data, firmware) {
444                 this.request_hash.clear();
445                 let { url, revision, advanced_mode, branch } = data;
446                 let { version, target, profile, packages } = firmware;
447                 let candidates = [];
448
449                 const endpoint = version.endsWith('SNAPSHOT') ? `revision/${version}/${target}` : 'overview';
450                 const request_url = `${url}/api/v1/${endpoint}`;
451
452                 ui.showModal(_('Searching...'), [
453                         E(
454                                 'p',
455                                 { class: 'spinning' },
456                                 _('Searching for an available sysupgrade of %s - %s').format(
457                                         version,
458                                         revision
459                                 )
460                         ),
461                 ]);
462
463                 L.resolveDefault(request.get(request_url)).then((response) => {
464                         if (!response.ok) {
465                                 ui.showModal(_('Error connecting to upgrade server'), [
466                                         E(
467                                                 'p',
468                                                 {},
469                                                 _('Could not reach API at "%s". Please try again later.').format(
470                                                         response.url
471                                                 )
472                                         ),
473                                         E('pre', {}, response.responseText),
474                                         E('div', { class: 'right' }, [
475                                                 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
476                                         ]),
477                                 ]);
478                                 return;
479                         }
480                         if (version.endsWith('SNAPSHOT')) {
481                                 const remote_revision = response.json().revision;
482                                 if (
483                                         get_revision_count(revision) < get_revision_count(remote_revision)
484                                 ) {
485                                         candidates.push([version, remote_revision]);
486                                 }
487                         } else {
488                                 const latest = response.json().latest;
489
490                                 // ensure order: newest to oldest release
491                                 latest.sort().reverse();
492
493                                 for (let remote_version of latest) {
494                                         let remote_branch = get_branch(remote_version);
495
496                                         // already latest version installed
497                                         if (version == remote_version) {
498                                                 break;
499                                         }
500
501                                         candidates.unshift([remote_version, null]);
502
503                                         // don't offer branches older than the current
504                                         if (branch == remote_branch) {
505                                                 break;
506                                         }
507                                 }
508                         }
509
510                         // allow to re-install running firmware in advanced mode
511                         if (advanced_mode == 1) {
512                                 candidates.unshift([version, revision]);
513                         }
514
515                         if (candidates.length) {
516                                 let s, o;
517
518                                 let mapdata = {
519                                         request: {
520                                                 profile,
521                                                 version: candidates[0][0],
522                                                 packages: Object.keys(packages).sort(),
523                                         },
524                                 };
525
526                                 let map = new form.JSONMap(mapdata, '');
527
528                                 s = map.section(
529                                         form.NamedSection,
530                                         'request',
531                                         '',
532                                         '',
533                                         'Use defaults for the safest update'
534                                 );
535                                 o = s.option(form.ListValue, 'version', 'Select firmware version');
536                                 for (let candidate of candidates) {
537                                         if (candidate[0] == version && candidate[1] == revision) {
538                                                 o.value(
539                                                         candidate[0],
540                                                         _('[installed] %s').format(
541                                                                 candidate[1]
542                                                                         ? `${candidate[0]} - ${candidate[1]}`
543                                                                         : candidate[0]
544                                                         )
545                                                 );
546                                         } else {
547                                                 o.value(
548                                                         candidate[0],
549                                                         candidate[1] ? `${candidate[0]} - ${candidate[1]}` : candidate[0]
550                                                 );
551                                         }
552                                 }
553
554                                 if (advanced_mode == 1) {
555                                         o = s.option(form.Value, 'profile', _('Board Name / Profile'));
556                                         o = s.option(form.DynamicList, 'packages', _('Packages'));
557                                 }
558
559                                 L.resolveDefault(map.render()).then((form_rendered) => {
560                                         ui.showModal(_('New firmware upgrade available'), [
561                                                 E(
562                                                         'p',
563                                                         _('Currently running: %s - %s').format(
564                                                                 version,
565                                                                 revision
566                                                         )
567                                                 ),
568                                                 form_rendered,
569                                                 E('div', { class: 'right' }, [
570                                                         E('div', { class: 'btn', click: ui.hideModal }, _('Cancel')),
571                                                         ' ',
572                                                         E(
573                                                                 'button',
574                                                                 {
575                                                                         class: 'btn cbi-button cbi-button-positive important',
576                                                                         click: ui.createHandlerFn(this, function () {
577                                                                                 map.save().then(() => {
578                                                                                         this.applyPackageChanges({
579                                                                                                 url,
580                                                                                                 target,
581                                                                                                 version:  mapdata.request.version,
582                                                                                                 packages: mapdata.request.packages,
583                                                                                         }).then((packages) => {
584                                                                                                 const content = {
585                                                                                                         ...firmware,
586                                                                                                         packages: packages,
587                                                                                                         version: mapdata.request.version,
588                                                                                                         profile: mapdata.request.profile
589                                                                                                 };
590                                                                                                 this.pollFn = L.bind(function () {
591                                                                                                         this.handleRequest(url, true, content, data, firmware);
592                                                                                                 }, this);
593                                                                                                 poll.add(this.pollFn, 5);
594                                                                                                 poll.start();
595                                                                                         })
596                                                                                         .catch(error => {
597                                                                                             ui.addNotification(null, E('p', error.message));
598                                                                                             ui.hideModal();
599                                                                                         });
600                                                                                 });
601                                                                         }),
602                                                                 },
603                                                                 _('Request firmware image')
604                                                         ),
605                                                 ]),
606                                         ]);
607                                 });
608                         } else {
609                                 ui.showModal(_('No upgrade available'), [
610                                         E(
611                                                 'p',
612                                                 _('The device runs the latest firmware version %s - %s').format(
613                                                         version,
614                                                         revision
615                                                 )
616                                         ),
617                                         E('div', { class: 'right' }, [
618                                                 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
619                                         ]),
620                                 ]);
621                         }
622                 });
623         },
624
625         load: async function () {
626                 const promises = await Promise.all([
627                         L.resolveDefault(callPackagelist(), {}),
628                         L.resolveDefault(callSystemBoard(), {}),
629                         L.resolveDefault(fs.stat('/sys/firmware/efi'), null),
630                         uci.load('attendedsysupgrade'),
631                 ]);
632                 const data = {
633                         url: uci.get_first('attendedsysupgrade', 'server', 'url').replace(/\/+$/, ''),
634                         branch: get_branch(promises[1].release.version),
635                         revision: promises[1].release.revision,
636                         efi: promises[2],
637                         advanced_mode: uci.get_first('attendedsysupgrade', 'client', 'advanced_mode') || 0,
638                         rebuilder: uci.get_first('attendedsysupgrade', 'server', 'rebuilder')
639                 };
640                 const firmware = {
641                         client: 'luci/' + promises[0].packages['luci-app-attendedsysupgrade'],
642                         packages: promises[0].packages,
643                         profile: promises[1].board_name,
644                         target: promises[1].release.target,
645                         version: promises[1].release.version,
646                         diff_packages: true,
647                         filesystem: promises[1].rootfs_type,
648
649                         // If the user has changed the rootfs partition size via owut,
650                         // then make sure to keep new image the same size.  A null value
651                         // is interpreted by the ASU server as "default".
652                         rootfs_size_mb: uci.get('attendedsysupgrade', 'owut', 'rootfs_size'),
653                 };
654                 return [data, firmware];
655         },
656
657         render: function (response) {
658                 const data = response[0];
659                 const firmware = response[1];
660
661                 return E('p', [
662                         E('h2', _('Attended Sysupgrade')),
663                         E(
664                                 'p',
665                                 _(
666                                         'The attended sysupgrade service allows to upgrade vanilla and custom firmware images easily.'
667                                 )
668                         ),
669                         E(
670                                 'p',
671                                 _(
672                                         'This is done by building a new firmware on demand via an online service.'
673                                 )
674                         ),
675                         E(
676                                 'p',
677                                 _('Currently running: %s - %s').format(
678                                         firmware.version,
679                                         data.revision
680                                 )
681                         ),
682                         E(
683                                 'button',
684                                 {
685                                         class: 'btn cbi-button cbi-button-positive important',
686                                         click: ui.createHandlerFn(this, this.handleCheck, data, firmware),
687                                 },
688                                 _('Search for firmware upgrade')
689                         ),
690                 ]);
691         },
692         handleSaveApply: null,
693         handleSave: null,
694         handleReset: null,
695 });
git clone https://git.99rst.org/PROJECT