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