4a724c6af6a85cccfe85bc13775eedaf31a47cf3
[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: '',
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                 response = response.json();
228                 this.request_hash = response.request_hash;
229
230                 if ('queue_position' in response) {
231                         ui.showModal(_('Queued...'), [
232                                 E(
233                                         'p',
234                                         { class: 'spinning' },
235                                         _('Request in build queue position %s').format(
236                                                 response.queue_position
237                                         )
238                                 ),
239                         ]);
240                 } else {
241                         ui.showModal(_('Building Firmware...'), [
242                                 E(
243                                         'p',
244                                         { class: 'spinning' },
245                                         _('Progress: %s%% %s').format(
246                                                 this.steps[response.imagebuilder_status][0],
247                                                 this.steps[response.imagebuilder_status][1]
248                                         )
249                                 ),
250                         ]);
251                 }
252         },
253
254         handleError: function (response, data, firmware) {
255                 response = response.json();
256                 const request_data = {
257                         ...data,
258                         request_hash: this.request_hash,
259                         sha256_unsigned: this.sha256_unsigned,
260                         ...firmware
261                 };
262                 let body = [
263                         E('p', {}, _('Server response: %s').format(response.detail)),
264                         E(
265                                 'a',
266                                 { href: 'https://forum.openwrt.org/t/luci-attended-sysupgrade-support-thread/230552' },
267                                 _('Please report the error message and request')
268                         ),
269                         E('p', {}, _('Request Data:')),
270                         E('pre', {}, JSON.stringify({ ...request_data }, null, 4)),
271                 ];
272
273                 if (response.stdout) {
274                         body.push(E('b', {}, 'STDOUT:'));
275                         body.push(E('pre', {}, response.stdout));
276                 }
277
278                 if (response.stderr) {
279                         body.push(E('b', {}, 'STDERR:'));
280                         body.push(E('pre', {}, response.stderr));
281                 }
282
283                 body = body.concat([
284                         E('div', { class: 'right' }, [
285                                 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
286                         ]),
287                 ]);
288
289                 ui.showModal(_('Error building the firmware image'), body);
290         },
291
292         handleRequest: function (server, main, content, data, firmware) {
293                 let request_url = `${server}/api/v1/build`;
294                 let method = 'POST';
295                 let local_content = content;
296
297                 /**
298                  * If `request_hash` is available use a GET request instead of
299                  * sending the entire object.
300                  */
301                 if (this.request_hash && main == true) {
302                         request_url += `/${this.request_hash}`;
303                         local_content = {};
304                         method = 'GET';
305                 }
306
307                 request
308                         .request(request_url, { method: method, content: local_content })
309                         .then((response) => {
310                                 switch (response.status) {
311                                         case 202:
312                                                 if (main) {
313                                                         this.handle202(response);
314                                                 } else {
315                                                         response = response.json();
316
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                                         case 400: // bad request
342                                         case 422: // bad package
343                                         case 500: // build failed
344                                                 if (main == true) {
345                                                         poll.remove(this.pollFn);
346                                                         this.handleError(response, data, firmware);
347                                                         break;
348                                                 } else {
349                                                         poll.remove(this.rebuilder_polls[server]);
350                                                         document.getElementById(server).innerText = '🚫 %s'.format(
351                                                                 server
352                                                         );
353                                                 }
354                                 }
355                         });
356         },
357
358         handleRebuilder: function (content, data, firmware) {
359                 this.rebuilder_polls = {};
360                 for (let rebuilder of data.rebuilder) {
361                         this.rebuilder_polls[rebuilder] = L.bind(
362                                 this.handleRequest,
363                                 this,
364                                 rebuilder,
365                                 false,
366                                 content,
367                                 data,
368                                 firmware
369                         );
370                         poll.add(this.rebuilder_polls[rebuilder], 5);
371                         document.getElementById(
372                                 'rebuilder_status'
373                         ).innerHTML += `<p id="${rebuilder}">⏳ ${rebuilder}</p>`;
374                 }
375                 poll.start();
376         },
377
378         handleInstall: function (url, keep, sha256) {
379                 ui.showModal(_('Downloading...'), [
380                         E(
381                                 'p',
382                                 { class: 'spinning' },
383                                 _('Downloading firmware from server to browser')
384                         ),
385                 ]);
386
387                 request
388                         .get(url, {
389                                 headers: {
390                                         'Content-Type': 'application/x-www-form-urlencoded',
391                                 },
392                                 responseType: 'blob',
393                         })
394                         .then((response) => {
395                                 let form_data = new FormData();
396                                 form_data.append('sessionid', rpc.getSessionID());
397                                 form_data.append('filename', '/tmp/firmware.bin');
398                                 form_data.append('filemode', 600);
399                                 form_data.append('filedata', response.blob());
400
401                                 ui.showModal(_('Uploading...'), [
402                                         E(
403                                                 'p',
404                                                 { class: 'spinning' },
405                                                 _('Uploading firmware from browser to device')
406                                         ),
407                                 ]);
408
409                                 request
410                                         .get(`${L.env.cgi_base}/cgi-upload`, {
411                                                 method: 'PUT',
412                                                 content: form_data,
413                                         })
414                                         .then((response) => response.json())
415                                         .then((response) => {
416                                                 if (response.sha256sum != sha256) {
417                                                         ui.showModal(_('Wrong checksum'), [
418                                                                 E(
419                                                                         'p',
420                                                                         _('Error during download of firmware. Please try again')
421                                                                 ),
422                                                                 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
423                                                         ]);
424                                                 } else {
425                                                         ui.showModal(_('Installing...'), [
426                                                                 E(
427                                                                         'p',
428                                                                         { class: 'spinning' },
429                                                                         _('Installing the sysupgrade. Do not unpower device!')
430                                                                 ),
431                                                         ]);
432
433                                                         L.resolveDefault(callUpgradeStart(keep), {}).then((response) => {
434                                                                 if (keep) {
435                                                                         ui.awaitReconnect(window.location.host);
436                                                                 } else {
437                                                                         ui.awaitReconnect('192.168.1.1', 'openwrt.lan');
438                                                                 }
439                                                         });
440                                                 }
441                                         });
442                         });
443         },
444
445         handleCheck: function (data, firmware) {
446                 this.request_hash = '';
447                 let { url, revision, advanced_mode, branch } = data;
448                 let { version, target, profile, packages } = firmware;
449                 let candidates = [];
450
451                 const endpoint = version.endsWith('SNAPSHOT') ? `revision/${version}/${target}` : 'overview';
452                 const request_url = `${url}/api/v1/${endpoint}`;
453
454                 ui.showModal(_('Searching...'), [
455                         E(
456                                 'p',
457                                 { class: 'spinning' },
458                                 _('Searching for an available sysupgrade of %s - %s').format(
459                                         version,
460                                         revision
461                                 )
462                         ),
463                 ]);
464
465                 L.resolveDefault(request.get(request_url)).then((response) => {
466                         if (!response.ok) {
467                                 ui.showModal(_('Error connecting to upgrade server'), [
468                                         E(
469                                                 'p',
470                                                 {},
471                                                 _('Could not reach API at "%s". Please try again later.').format(
472                                                         response.url
473                                                 )
474                                         ),
475                                         E('pre', {}, response.responseText),
476                                         E('div', { class: 'right' }, [
477                                                 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
478                                         ]),
479                                 ]);
480                                 return;
481                         }
482                         if (version.endsWith('SNAPSHOT')) {
483                                 const remote_revision = response.json().revision;
484                                 if (
485                                         get_revision_count(revision) < get_revision_count(remote_revision)
486                                 ) {
487                                         candidates.push([version, remote_revision]);
488                                 }
489                         } else {
490                                 const latest = response.json().latest;
491
492                                 // ensure order: newest to oldest release
493                                 latest.sort().reverse();
494
495                                 for (let remote_version of latest) {
496                                         let remote_branch = get_branch(remote_version);
497
498                                         // already latest version installed
499                                         if (version == remote_version) {
500                                                 break;
501                                         }
502
503                                         candidates.unshift([remote_version, null]);
504
505                                         // don't offer branches older than the current
506                                         if (branch == remote_branch) {
507                                                 break;
508                                         }
509                                 }
510                         }
511
512                         // allow to re-install running firmware in advanced mode
513                         if (advanced_mode == 1) {
514                                 candidates.unshift([version, revision]);
515                         }
516
517                         if (candidates.length) {
518                                 let s, o;
519
520                                 let mapdata = {
521                                         request: {
522                                                 profile,
523                                                 version: candidates[0][0],
524                                                 packages: Object.keys(packages).sort(),
525                                         },
526                                 };
527
528                                 let map = new form.JSONMap(mapdata, '');
529
530                                 s = map.section(
531                                         form.NamedSection,
532                                         'request',
533                                         '',
534                                         '',
535                                         'Use defaults for the safest update'
536                                 );
537                                 o = s.option(form.ListValue, 'version', 'Select firmware version');
538                                 for (let candidate of candidates) {
539                                         if (candidate[0] == version && candidate[1] == revision) {
540                                                 o.value(
541                                                         candidate[0],
542                                                         _('[installed] %s').format(
543                                                                 candidate[1]
544                                                                         ? `${candidate[0]} - ${candidate[1]}`
545                                                                         : candidate[0]
546                                                         )
547                                                 );
548                                         } else {
549                                                 o.value(
550                                                         candidate[0],
551                                                         candidate[1] ? `${candidate[0]} - ${candidate[1]}` : candidate[0]
552                                                 );
553                                         }
554                                 }
555
556                                 if (advanced_mode == 1) {
557                                         o = s.option(form.Value, 'profile', _('Board Name / Profile'));
558                                         o = s.option(form.DynamicList, 'packages', _('Packages'));
559                                 }
560
561                                 L.resolveDefault(map.render()).then((form_rendered) => {
562                                         ui.showModal(_('New firmware upgrade available'), [
563                                                 E(
564                                                         'p',
565                                                         _('Currently running: %s - %s').format(
566                                                                 version,
567                                                                 revision
568                                                         )
569                                                 ),
570                                                 form_rendered,
571                                                 E('div', { class: 'right' }, [
572                                                         E('div', { class: 'btn', click: ui.hideModal }, _('Cancel')),
573                                                         ' ',
574                                                         E(
575                                                                 'button',
576                                                                 {
577                                                                         class: 'btn cbi-button cbi-button-positive important',
578                                                                         click: ui.createHandlerFn(this, function () {
579                                                                                 map.save().then(() => {
580                                                                                         this.applyPackageChanges({
581                                                                                                 url,
582                                                                                                 target,
583                                                                                                 version:  mapdata.request.version,
584                                                                                                 packages: mapdata.request.packages,
585                                                                                         }).then((packages) => {
586                                                                                                 const content = {
587                                                                                                         ...firmware,
588                                                                                                         packages: packages,
589                                                                                                         version: mapdata.request.version,
590                                                                                                         profile: mapdata.request.profile
591                                                                                                 };
592                                                                                                 this.pollFn = L.bind(function () {
593                                                                                                         this.handleRequest(url, true, content, data, firmware);
594                                                                                                 }, this);
595                                                                                                 poll.add(this.pollFn, 5);
596                                                                                                 poll.start();
597                                                                                         })
598                                                                                         .catch(error => {
599                                                                                             ui.addNotification(null, E('p', error.message));
600                                                                                             ui.hideModal();
601                                                                                         });
602                                                                                 });
603                                                                         }),
604                                                                 },
605                                                                 _('Request firmware image')
606                                                         ),
607                                                 ]),
608                                         ]);
609                                 });
610                         } else {
611                                 ui.showModal(_('No upgrade available'), [
612                                         E(
613                                                 'p',
614                                                 _('The device runs the latest firmware version %s - %s').format(
615                                                         version,
616                                                         revision
617                                                 )
618                                         ),
619                                         E('div', { class: 'right' }, [
620                                                 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
621                                         ]),
622                                 ]);
623                         }
624                 });
625         },
626
627         load: async function () {
628                 const promises = await Promise.all([
629                         L.resolveDefault(callPackagelist(), {}),
630                         L.resolveDefault(callSystemBoard(), {}),
631                         L.resolveDefault(fs.stat('/sys/firmware/efi'), null),
632                         uci.load('attendedsysupgrade'),
633                 ]);
634                 const data = {
635                         url: uci.get_first('attendedsysupgrade', 'server', 'url'),
636                         branch: get_branch(promises[1].release.version),
637                         revision: promises[1].release.revision,
638                         efi: promises[2],
639                         advanced_mode: uci.get_first('attendedsysupgrade', 'client', 'advanced_mode') || 0,
640                         rebuilder: uci.get_first('attendedsysupgrade', 'server', 'rebuilder')
641                 };
642                 const firmware = {
643                         client: 'luci/' + promises[0].packages['luci-app-attendedsysupgrade'],
644                         packages: promises[0].packages,
645                         profile: promises[1].board_name,
646                         target: promises[1].release.target,
647                         version: promises[1].release.version,
648                         diff_packages: true,
649                         filesystem: promises[1].rootfs_type
650                 };
651                 return [data, firmware];
652         },
653
654         render: function (response) {
655                 const data = response[0];
656                 const firmware = response[1];
657
658                 return E('p', [
659                         E('h2', _('Attended Sysupgrade')),
660                         E(
661                                 'p',
662                                 _(
663                                         'The attended sysupgrade service allows to upgrade vanilla and custom firmware images easily.'
664                                 )
665                         ),
666                         E(
667                                 'p',
668                                 _(
669                                         'This is done by building a new firmware on demand via an online service.'
670                                 )
671                         ),
672                         E(
673                                 'p',
674                                 _('Currently running: %s - %s').format(
675                                         firmware.version,
676                                         data.revision
677                                 )
678                         ),
679                         E(
680                                 'button',
681                                 {
682                                         class: 'btn cbi-button cbi-button-positive important',
683                                         click: ui.createHandlerFn(this, this.handleCheck, data, firmware),
684                                 },
685                                 _('Search for firmware upgrade')
686                         ),
687                 ]);
688         },
689         handleSaveApply: null,
690         handleSave: null,
691         handleReset: null,
692 });
git clone https://git.99rst.org/PROJECT