├── .gitattributes ├── .github └── workflows │ ├── scripts │ └── ci_helpers.sh │ └── formal.yml ├── files ├── owut.defaults ├── pre-install.sh ├── argparse.uc └── owut └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | *.uc linguist-language=Javascript 2 | -------------------------------------------------------------------------------- /.github/workflows/scripts/ci_helpers.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | color_out() { 4 | printf "\e[0;$1m$PKG_NAME: %s\e[0;0m\n" "$2" 5 | } 6 | 7 | success() { 8 | color_out 32 "$1" 9 | } 10 | 11 | info() { 12 | color_out 36 "$1" 13 | } 14 | 15 | err() { 16 | color_out 31 "$1" 17 | } 18 | 19 | warn() { 20 | color_out 33 "$1" 21 | } 22 | 23 | err_die() { 24 | err "$1" 25 | exit 1 26 | } 27 | -------------------------------------------------------------------------------- /files/owut.defaults: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | [ -n "$IPKG_INSTROOT" ] && return 0 4 | 5 | conf_file="/etc/config/attendedsysupgrade" 6 | 7 | if [ ! -e "$conf_file" ]; then 8 | . /etc/uci-defaults/50-attendedsysupgrade 9 | fi 10 | 11 | if [ -z "$(uci get attendedsysupgrade.owut 2> /dev/null)" ]; then 12 | cat <> "$conf_file" 13 | # Example configuration for 'owut'. The option names are the same 14 | # as those used on the command line, with all '-' dashes replaced by 15 | # '_' underscores. Use 'owut --help' to see more. 16 | 17 | config owut 'owut' 18 | # option verbosity 0 19 | # option keep true 20 | # option init_script '/root/data/my-init-script.sh' 21 | # option image '/tmp/my-firmware-img.bin' 22 | # option rootfs_size 256 23 | # option pre_install '/etc/owut.d/pre-install.sh' 24 | # option poll_interval 10000 # In milliseconds 25 | # list ignored_defaults 'kmod-drm-i915' 26 | # list ignored_defaults 'kmod-dwmac-intel' 27 | 28 | CONF 29 | echo "Please see owut section of $conf_file for example options." 30 | fi 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # owut - OpenWrt Upgrade Tool 2 | 3 | `owut` is command line tool that upgrades your router's firmware. It creates custom images of OpenWrt using the [sysupgrade server](https://sysupgrade.openwrt.org) and installs them, retaining all of your currently installed packages and configuration. 4 | 5 | Follow along or participate in the [owut package discussion](https://forum.openwrt.org/t/owut-openwrt-upgrade-tool/200035) on the OpenWrt forum. 6 | 7 | ## Installation 8 | 9 | > [!WARNING] 10 | > `owut` depends on the `ucode-mod-uclient` package, which is only available on versions 24.10 and later, including main snapshots. For OpenWrt 23.05 and earlier, use the [`auc` package](https://openwrt.org/docs/guide-user/installation/attended.sysupgrade#from_the_cli). 11 | 12 | `owut` is a standard OpenWrt package, making installation quite simple. 13 | 14 | ```bash 15 | # If using opkg package manager: 16 | opkg update && opkg install owut 17 | 18 | # If using apk package manager: 19 | apk --update-cache add owut 20 | ``` 21 | 22 | Or, for the hardy or adventurous, install from source: 23 | ```bash 24 | opkg update 25 | opkg install attendedsysupgrade-common rpcd-mod-file ucode ucode-mod-fs \ 26 | ucode-mod-ubus ucode-mod-uci ucode-mod-uclient ucode-mod-uloop 27 | 28 | [ ! -d /usr/share/ucode/utils/ ] && mkdir -p /usr/share/ucode/utils/ 29 | wget -O /usr/share/ucode/utils/argparse.uc https://raw.githubusercontent.com/efahl/owut/main/files/argparse.uc 30 | wget -O /usr/bin/owut https://raw.githubusercontent.com/efahl/owut/main/files/owut 31 | hash="$(wget -q -O - https://api.github.com/repos/efahl/owut/commits/main | jsonfilter -e '$.sha' | cut -c-8)" 32 | sed -i -e "s/%%VERSION%%/source-$hash/" /usr/bin/owut 33 | 34 | chmod +x /usr/bin/owut 35 | ``` 36 | 37 | ## Documentation 38 | 39 | Short documentation is available on your device, use `owut --help` 40 | 41 | Full documentation is available in the OpenWrt wiki at [owut: OpenWrt Upgrade Tool](https://openwrt.org/docs/guide-user/installation/sysupgrade.owut) 42 | 43 | Packaging in https://github.com/openwrt/packages/blob/master/utils/owut/Makefile 44 | 45 | ## License 46 | 47 | SPDX-License-Identifier: GPL-2.0-only 48 | -------------------------------------------------------------------------------- /.github/workflows/formal.yml: -------------------------------------------------------------------------------- 1 | name: Test Formalities 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | build: 8 | name: Test Formalities 9 | runs-on: ubuntu-slim 10 | strategy: 11 | fail-fast: false 12 | 13 | steps: 14 | - uses: actions/checkout@v5 15 | with: 16 | ref: ${{ github.event.pull_request.head.sha }} 17 | fetch-depth: 0 18 | 19 | - name: Determine branch name 20 | run: | 21 | BRANCH="${GITHUB_BASE_REF#refs/heads/}" 22 | echo "Building for $BRANCH" 23 | echo "BRANCH=$BRANCH" >> $GITHUB_ENV 24 | 25 | - name: Test formalities 26 | run: | 27 | source .github/workflows/scripts/ci_helpers.sh 28 | 29 | RET=0 30 | for commit in $(git rev-list HEAD ^origin/$BRANCH); do 31 | info "=== Checking commit '$commit'" 32 | if git show --format='%P' -s $commit | grep -qF ' '; then 33 | err "Pull request should not include merge commits" 34 | RET=1 35 | fi 36 | 37 | author="$(git show -s --format=%aN $commit)" 38 | if echo $author | grep -q '\S\+\s\+\S\+'; then 39 | success "Author name ($author) seems ok" 40 | else 41 | err "Author name ($author) need to be your real name 'firstname lastname'" 42 | RET=1 43 | fi 44 | 45 | subject="$(git show -s --format=%s $commit)" 46 | if echo "$subject" | grep -q -e '^[0-9A-Za-z,+/_\.-]\+: ' -e '^Revert '; then 47 | success "Commit subject line seems ok ($subject)" 48 | else 49 | err "Commit subject line MUST start with ': ' ($subject)" 50 | RET=1 51 | fi 52 | 53 | body="$(git show -s --format=%b $commit)" 54 | sob="$(git show -s --format='Signed-off-by: %aN <%aE>' $commit)" 55 | if echo "$body" | grep -qF "$sob"; then 56 | success "Signed-off-by match author" 57 | else 58 | err "Signed-off-by is missing or doesn't match author (should be '$sob')" 59 | RET=1 60 | fi 61 | 62 | if echo "$body" | grep -v "Signed-off-by:"; then 63 | success "A commit message exists" 64 | else 65 | err "Missing commit message. Please describe your changes" 66 | RET=1 67 | fi 68 | done 69 | 70 | exit $RET 71 | -------------------------------------------------------------------------------- /files/pre-install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Example pre-installation hook 3 | # 4 | # To see the most recent version of this file, go to: 5 | # https://github.com/efahl/owut/blob/main/files/pre-install.sh 6 | # 7 | # Details at 8 | # https://openwrt.org/docs/guide-user/installation/sysupgrade.owut#pre-install_script 9 | # 10 | # Allows the user to inject actions between the download/verify phases of an 11 | # upgrade and the installation step. If the script fails, that is returns an 12 | # exit code != 0, then 'owut' will abort the install process. 13 | # 14 | # You tell 'owut' to use the script by saying 15 | # owut upgrade --pre-install /etc/owut.d/pre-install.sh 16 | # 17 | # To use it by default, without an explicit '--pre-install' on the command 18 | # line, add it to /etc/config/attendedsysupgrade (typically, you only need 19 | # to uncomment the line that's already there): 20 | # 21 | # config owut 'owut' 22 | # option pre_install '/etc/owut.d/pre-install.sh' 23 | # 24 | # Note that you should test any changes to this script in a restricted 25 | # environment using something like: 26 | # env -i PATH=/usr/sbin:/usr/bin:/sbin:/bin /etc/owut.d/pre-install.sh 27 | 28 | 29 | # Example 1 - archive the manifest 30 | # Since /etc/owut.d/ is part of the default backup list, we can just copy the 31 | # manifest produced by the ASU server from /tmp to this directory. We add a 32 | # time stamp to the name for convenience. 33 | if false; then 34 | stamp="$(date +'%FT%H%M')" 35 | 36 | archive="/etc/owut.d/firmware-manifest-${stamp}.json" 37 | cp /tmp/firmware-manifest.json "$archive" || exit 1 38 | gzip "$archive" || exit 1 39 | echo "Archived firmware-manifest.json to $archive.gz" 40 | fi 41 | 42 | 43 | # Example 2 - local auto-backup 44 | # Say you have a USB drive mounted on your router, you can easily do an 45 | # automated backup to that drive just prior to the upgrade. 46 | if false; then 47 | backup_dir="/mnt/sda2/backups" 48 | 49 | stamp="$(date +'%FT%H%M')" 50 | 51 | sysupgrade --create-backup "${backup_dir}/backup-${stamp}.tgz" || exit 1 52 | echo "Created local backup ${backup_dir}/backup-${stamp}.tgz" 53 | fi 54 | 55 | 56 | # Example 3 - remote auto-backup of config and new firmware 57 | # Use a remote machine to which you have ssh access as a backup host. 58 | if false; then 59 | remote="my-nas:/public/backups" 60 | 61 | stamp="$(date +'%FT%H%M')" 62 | router=$(uci get system.@system[0].hostname) 63 | backup="backup-${router}-${stamp}.tgz" 64 | firmware="firmware-${router}-${stamp}.bin" 65 | 66 | sysupgrade --create-backup "/tmp/$backup" || exit 1 67 | scp "/tmp/$backup" "${remote}/${backup}" || exit 1 68 | echo "Created remote backup ${remote}/${backup}" 69 | 70 | scp "/tmp/firmware.bin" "${remote}/${firmware}" || exit 1 71 | echo "Copied image to ${remote}/${firmware}" 72 | fi 73 | 74 | 75 | exit 0 76 | -------------------------------------------------------------------------------- /files/argparse.uc: -------------------------------------------------------------------------------- 1 | //------------------------------------------------------------------------------ 2 | // argparse.uc - Argument Parser for ucode 3 | // Copyright (c) 2024-2025 Eric Fahlgren 4 | // SPDX-License-Identifier: GPL-2.0-only 5 | // vim: set noexpandtab softtabstop=8 shiftwidth=8 syntax=javascript: 6 | //------------------------------------------------------------------------------ 7 | // All uses of 'assert' in the argparse module indicate programming errors, 8 | // and never user input errors. If you define an argument incorrectly, 9 | // an assertion will be raised, whereas if a user enters an incorrect 10 | // command line, then 'usage' or 'usage_short' is called. 11 | // 12 | // To dump the list of actions, try this: 13 | // $ ucode -p 'import { ArgActions } from "utils.argparse"; keys(ArgActions);' 14 | 15 | const VERSION = "%%VERSION%%"; 16 | 17 | import { cursor } from 'uci'; 18 | import { basename, dirname, realpath } from 'fs'; 19 | 20 | const isnan = (x) => x != x; 21 | 22 | export const ArgActions = { 23 | // Static class of standard actions for arguments. 24 | // All functions have the prototype: 25 | // fn(self, params) 26 | // where 27 | // self = an ArgParser object 28 | // params = object with optional parameters, typically, but not restricted 29 | // to: 30 | // name = option name 31 | // value = option value 32 | // msg = any associated output message 33 | 34 | store: function(self, params) { 35 | // We don't flag multiple stores, last one wins. 36 | assert(params.name, "'store' action requires an option name"); 37 | assert(params.value, "'store' action requires a value"); 38 | self.options[params.name] = params.value; 39 | }, 40 | storex: function(self, params) { 41 | // Exclusive version of store, multiple stores generates an error. 42 | if (self.options[params.name]) { 43 | let p = this.long || this.short; 44 | this.usage_short(self, {exit: 1, prefix: `ERROR: '${p}' may only be used once per invocation:\n ${this.help}`}); 45 | } 46 | this.store(self, params); 47 | }, 48 | 49 | store_int: function(self, params) { 50 | assert(params.name, "'store_int' action requires an option name"); 51 | assert(params.value, "'store_int' action requires a value"); 52 | 53 | let value = int(params.value); 54 | let error = null; 55 | if (isnan(value)) 56 | error = `invalid integer '${params.value}'`; 57 | else if ("lower" in this && value < this.lower) 58 | error = `${value} below lower bound ${this.lower}`; 59 | else if ("upper" in this && value > this.upper) 60 | error = `${value} above upper bound ${this.upper}`; 61 | 62 | if (error) this.usage_short(self, {exit: 1, prefix: `ERROR: ${error}`}); 63 | self.options[params.name] = value; 64 | }, 65 | 66 | file: function(self, params) { 67 | assert(params.name, "'file' action requires an option name"); 68 | assert(params.value, "'file' action requires a value"); 69 | if (params.value) { 70 | let dir = realpath(dirname(params.value)); 71 | if (! dir) { 72 | let msg = `'${params.name}' value '${params.value}' must contain a valid directory path.`; 73 | this.usage_short(self, {exit: 1, prefix: `ERROR: ${msg}`}); 74 | } 75 | let file = dir + "/" + basename(params.value); 76 | self.options[params.name] = file; 77 | } 78 | }, 79 | 80 | enum: function(self, params) { 81 | assert(params.name, "'enum' action requires an option name"); 82 | assert(params.value, "'enum' action requires a value"); 83 | assert(this.one_of, "'enum' requires a 'one_of' list of values"); 84 | 85 | if (! (params.value in this.one_of)) { 86 | let msg = `'${params.name}' must be one of ${join(", ", this.one_of)}, not '${params.value}'`; 87 | this.usage_short(self, {exit: 1, prefix: `ERROR: ${msg}`}); 88 | } 89 | self.options[params.name] = params.value; 90 | }, 91 | 92 | set: function(self, params) { 93 | assert(params.name, "'set' action requires an option name"); 94 | assert(type(self.options[params.name]) == "bool", `missing default value for '${params.name}'?`); 95 | let value = exists(params, "value") ? params.value : true; 96 | self.options[params.name] = value in [true, "true", 1, "1", "yes", "on"]; 97 | }, 98 | 99 | inc: function(self, params) { 100 | assert(params.name, "'inc' action requires an option name"); 101 | assert(type(self.options[params.name]) == "int", `missing default value for '${params.name}'?`); 102 | let value = exists(params, "value") ? int(params.value) : (self.options[params.name] + 1); 103 | self.options[params.name] = value; 104 | }, 105 | 106 | dec: function(self, params) { 107 | assert(params.name, "'dec' action requires an option name"); 108 | assert(type(self.options[params.name]) == "int", `missing default value for '${params.name}'?`); 109 | let value = exists(params, "value") ? int(params.value) : (self.options[params.name] - 1); 110 | self.options[params.name] = value; 111 | }, 112 | 113 | version: function(self, params) { 114 | self.show_version(); 115 | }, 116 | 117 | usage_short: function(self, params) { 118 | // Produce a short usage message. 'params' may contain 'prefix' 119 | // or 'suffix'. 120 | 121 | let shorts = []; 122 | for (let arg in self) { 123 | if (! arg.name || ! arg.help) continue; 124 | 125 | if ("position" in arg) { 126 | push(shorts, uc(arg.name)); 127 | } 128 | else { 129 | let name = arg.short || arg.long; 130 | // deal with "nargs" ... 131 | let v = arg.nargs > 0 ? ` ${uc(arg.name)}` : ""; 132 | push(shorts, `[${name}${v}]`); 133 | } 134 | } 135 | 136 | if ("prefix" in params) printf("%s\n", params.prefix); 137 | printf(`Usage: ${self.program_string} %s\n`, join(" ", shorts)); 138 | if ("suffix" in params) printf("%s\n", params.suffix); 139 | if ("exit" in params) exit(params.exit); 140 | }, 141 | 142 | usage: function(self, params) { 143 | self.show_version(); 144 | 145 | // params: optional {msg} 146 | if (params?.msg) printf("\n%s\n", params.msg); 147 | 148 | if (self.prologue) print(self.prologue); 149 | this.usage_short(self, {prefix: ""}); 150 | 151 | for (let arg in self) { 152 | if (! arg.help) continue; // Explicitly ignore items without help. 153 | 154 | if ("position" in arg) { 155 | printf("\n %s - %s, must be one of:\n", uc(arg.name), arg.help); 156 | for (let n,v in arg.one_of) { 157 | if (v.help) { 158 | printf(" %-8s - %s\n", n, v.help); 159 | } 160 | } 161 | printf("\n"); 162 | } 163 | else { 164 | let out = ""; 165 | if (arg.short) out += arg.short; 166 | if (arg.long) { 167 | if (length(out)) out += "/"; 168 | out += arg.long; 169 | } 170 | if (arg.nargs > 0) out += " " + uc(arg.name); 171 | printf(" %-20s - %s\n", out, arg.help); 172 | } 173 | } 174 | 175 | if (self.epilogue) print(self.epilogue); 176 | if ("exit" in params) exit(params.exit); 177 | }, 178 | }; 179 | 180 | export const ArgParser = { 181 | // Singleton class for a program's command line argument parsing. 182 | 183 | program_string: null, // User-defined name of program. 184 | version_string: null, // User-defined version string. 185 | prologue: null, // Text to put before argument list in usage. 186 | epilogue: null, // Text to put after argument list in usage. 187 | options: null, // Collected results from parsing. 188 | 189 | has_required: false, // Did the user specify any required arguments? 190 | 191 | show_version: function() { 192 | printf("%s\n", this.version_string ?? "no version string set"); 193 | }, 194 | 195 | set_prog_info: function(ver, prog) { 196 | // Allow user to set the version string, and optionally the 197 | // program string. 198 | proto(this).version_string = ver; 199 | proto(this).program_string = prog ?? split(sourcepath(1), "/")[-1]; 200 | }, 201 | 202 | set_bookends: function(prologue, epilogue) { 203 | proto(this).prologue = prologue; 204 | proto(this).epilogue = epilogue; 205 | }, 206 | 207 | init: function() { 208 | // Build initial options table. 209 | // Could use a bunch of error checking added to verify 210 | // non-duplicate short and long, etc. 211 | 212 | proto(this).options = {}; // Build the result cache, populate with defaults. 213 | for (let i = 0, arg = this[i]; i < length(this); i++, arg = this[i]) { 214 | assert(arg.short || arg.long || "position" in arg, 215 | `arg definition must contain at least one of 'short', 'long' or 'position'\n ${arg}`); 216 | 217 | arg = this[i] = proto(arg, ArgActions); // Cast each arg to actions type. 218 | 219 | if (arg.name) { 220 | this.options[arg.name] = "default" in arg ? arg.default : null; 221 | } 222 | 223 | if (type(arg.action) == "string") { 224 | // Look up our canned actions. 225 | assert(arg.action in ArgActions, `action '${arg.action}' is not defined`); 226 | arg.action = ArgActions[arg.action]; 227 | } 228 | 229 | if ("position" in arg) { 230 | // Positional arguments are required. 231 | proto(this).has_required = true; 232 | } 233 | } 234 | }, 235 | 236 | get_arg: function(arg, position) { 237 | for (let opt in this) { 238 | if (position == opt.position) { 239 | if (arg in opt.one_of) 240 | return opt; 241 | ArgActions.usage_short(this, {exit: 1, prefix: `ERROR: '${arg}' is not valid here, expected ${uc(opt.name)}`}); 242 | } 243 | if (arg in [opt.short, opt.long]) { 244 | return opt; 245 | } 246 | } 247 | return null; 248 | }, 249 | 250 | get_by_name: function(name) { 251 | for (let opt in this) { 252 | if (opt.name == name) return opt; 253 | } 254 | return null; 255 | }, 256 | 257 | add_config: function(uci_section) { 258 | let uci = cursor(); 259 | for (let file, section in uci_section) { 260 | let cfg = uci.get_all(file, section); 261 | for (let name, value in cfg) { 262 | let opt = this.get_by_name(name); 263 | if (opt) { 264 | opt.action(this, {name: name, value: value}); 265 | } 266 | } 267 | } 268 | }, 269 | 270 | parse: function(argv, uci_section) { 271 | // Parse the argument vector. If no value for 'argv' is 272 | // supplied, then use global ARGV. 273 | // 274 | // uci_section is an object with file and section names to 275 | // be used to override default values. 276 | 277 | argv ??= ARGV; 278 | 279 | this.init(); 280 | 281 | if (uci_section) { 282 | this.add_config(uci_section); 283 | } 284 | 285 | if (this.has_required && length(argv) == 0) { 286 | ArgActions.usage_short(this, {exit: 1, suffix: "Try '-h' for full help."}); 287 | } 288 | 289 | let iarg = 0; 290 | let narg = length(argv); 291 | while (iarg < narg) { 292 | let arg = argv[iarg]; 293 | let opt = this.get_arg(arg, iarg); 294 | iarg++; 295 | 296 | if (! opt) { 297 | ArgActions.usage(this, {exit: 1, msg: `ERROR: '${arg}' is not a valid command or option`}); 298 | } 299 | 300 | let action_args = {}; 301 | if ("name" in opt) { 302 | action_args.name = opt.name; 303 | } 304 | if ("position" in opt) { 305 | action_args.value = arg; 306 | } 307 | if ("nargs" in opt && opt.nargs > 0) { 308 | if (opt.nargs > narg-iarg) { 309 | ArgActions.usage(this, {exit: 1, msg: `ERROR: '${arg}' requires ${opt.nargs} values`}); 310 | } 311 | 312 | if (opt.nargs == 1) { 313 | action_args.value = argv[iarg]; 314 | } 315 | else { 316 | action_args.value = slice(argv, iarg, iarg+opt.nargs); 317 | } 318 | iarg += opt.nargs; 319 | } 320 | 321 | opt.action(this, action_args); 322 | 323 | if ("auto_exit" in opt) { 324 | // The auto_exit parameter value is the exit status code. 325 | exit(opt.auto_exit); 326 | } 327 | } 328 | 329 | return this.options; 330 | }, 331 | }; 332 | 333 | // argparse module provides a few canned option definitions. 334 | export let DEFAULT_HELP = { short: "-h", long: "--help", action: "usage", auto_exit: 1, help: "Show this message and quit." }; 335 | export let DEFAULT_VERSION = { long: "--version", action: "version", auto_exit: 0, help: "Show the program version and terminate." }; 336 | -------------------------------------------------------------------------------- /files/owut: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ucode -S 2 | // owut - OpenWrt Upgrade Tool 3 | // Copyright (c) 2024-2025 Eric Fahlgren 4 | // SPDX-License-Identifier: GPL-2.0-only 5 | // vim: set noexpandtab softtabstop=8 shiftwidth=8 syntax=javascript: 6 | //------------------------------------------------------------------------------ 7 | 8 | let NAME = "owut"; 9 | let VERSION = "%%VERSION%%"; 10 | let PROG = `${NAME}/${VERSION}`; 11 | 12 | import * as uloop from "uloop"; 13 | import * as uclient from "uclient"; 14 | import * as fs from "fs"; 15 | import * as mod_ubus from "ubus"; 16 | import * as ap from "utils.argparse"; 17 | import { cursor } from "uci"; 18 | let uci = cursor(); 19 | 20 | const default_sysupgrade = "https://sysupgrade.openwrt.org"; 21 | const default_upstream = "https://downloads.openwrt.org"; 22 | 23 | const issue_url = "https://github.com/efahl/owut/issues"; 24 | const forum_url = "https://forum.openwrt.org/t/owut-openwrt-upgrade-tool/200035"; 25 | const wiki_url = "https://openwrt.org/docs/guide-user/installation/sysupgrade.owut"; 26 | 27 | //------------------------------------------------------------------------------ 28 | 29 | const EXIT_OK = 0, EXIT_ERR = 1; 30 | 31 | const Logger = { 32 | _levels: [], 33 | _level: 0, 34 | set_level: function(lvl) { this._level = lvl; }, 35 | show: function(lvl) { return this._level >= lvl; }, 36 | push: function(new) { push(this._levels, this._level); this._level = new; }, 37 | pop: function() { this._level = pop(this._levels); }, 38 | 39 | stdout_is_pipe: ! fs.stdout.isatty(), 40 | 41 | GREEN: "0;255;0", 42 | YELLOW: "255;234;0", 43 | RED: "255;0;0", 44 | reset: "reset", 45 | 46 | clreol: function() 47 | { 48 | // Clear to end-of-line when appropriate. 49 | return this.stdout_is_pipe ? "" : "\033[K"; 50 | }, 51 | 52 | color: function(color_name) 53 | { 54 | // Suppress colorization when stdout is a pipe. 55 | if (this.stdout_is_pipe) 56 | return ""; 57 | return color_name == this.reset ? "\033[m" : `\033[38;2;${color_name}m`; 58 | }, 59 | 60 | colorize: function(color_name, text) 61 | { 62 | if (this.stdout_is_pipe) 63 | return text; 64 | return this.color(color_name) + text + this.color(this.reset); 65 | }, 66 | 67 | _n_lines: 0, 68 | mark: function() { this._n_lines = 0; }, 69 | backup: function() { 70 | // Reposition cursor up n lines and reset line counter. 71 | if (! this.stdout_is_pipe && this._n_lines > 0) { 72 | printf("\033[%dA\r", this._n_lines); 73 | fs.stdout.flush(); 74 | } 75 | this.mark(); 76 | }, 77 | 78 | _fmt: function(fmt, ...args) 79 | { 80 | let s = sprintf(fmt, ...args); 81 | this._n_lines += length(match(s, /\n/g)); 82 | return this.clreol() + s; 83 | }, 84 | 85 | _out: function(prefix, clr, fmt, ...args) 86 | { 87 | warn(sprintf("%s: ", this.colorize(clr, prefix)), this._fmt(fmt, ...args)); 88 | }, 89 | 90 | err: function(fmt, ...args) { this._out("ERROR", this.RED, fmt, ...args); }, 91 | wrn: function(fmt, ...args) { this._out("WARNING", this.YELLOW, fmt, ...args); }, 92 | 93 | log: function(level, fmt, ...args) 94 | { 95 | if (this.show(level)) { 96 | print(this._fmt(fmt, ...args)); 97 | fs.stdout.flush(); 98 | } 99 | }, 100 | 101 | die: function(fmt, ...args) { 102 | this.err(fmt, ...args); 103 | exit(EXIT_ERR); 104 | }, 105 | bug: function(fmt, ...args) { 106 | // Variant for errors indicating code issue, not a user error. 107 | this.err(fmt, ...args); 108 | assert(false, 109 | `This is a bug in '${PROG}', please report at\n` 110 | ` ${issue_url}\n`); 111 | }, 112 | }; 113 | 114 | let L = Logger; 115 | 116 | //------------------------------------------------------------------------------ 117 | 118 | let commands = { 119 | check: { help: "Collect all resources and report stats." }, 120 | list: { help: "Show all the packages installed by user." }, 121 | blob: { help: "Display the json blob for the ASU build request." }, 122 | download: { help: "Build, download and verify an image." }, 123 | verify: { help: "Verify the downloaded image." }, 124 | install: { help: "Install the specified local image." }, 125 | upgrade: { help: "Build, download, verify and install an image." }, 126 | 127 | versions: { help: "Show available versions." }, 128 | dump: { help: "Collect all resources and dump internal data structures." }, 129 | }; 130 | 131 | let _fstypes = ["squashfs", "ext4", "ubifs", "jffs2"]; 132 | let _fslo = 1; // See https://sysupgrade.openwrt.org/redoc#operation/api_v1_build_post_api_v1_build_post 133 | let _list_fmts = ["fs-user", "fs-all", "config"]; 134 | 135 | function parse_rev_code(version_code) 136 | { 137 | return match(version_code, /^r(\d+)-([[:xdigit:]]+)$/i); 138 | } 139 | 140 | // Add a custom validator for the '--rev-code' option value. 141 | ap.ArgActions["rc"] = function(self, params) { 142 | let rc = params.value; 143 | if (rc && rc != "none" && ! parse_rev_code(rc)) { 144 | this.usage_short(self, { 145 | exit: 1, 146 | prefix: `ERROR: invalid version code format '${params.value}'` 147 | }); 148 | } 149 | this.store(self, params); 150 | }; 151 | 152 | let arg_defs = proto([ 153 | ap.DEFAULT_HELP, 154 | ap.DEFAULT_VERSION, 155 | { name: "command", position: 0, one_of: commands, action: "store", help: "Sub-command to execute" }, 156 | { name: "version_to", short: "-V", long: "--version-to", action: "store", nargs: 1, default: null, help: "Specify the target version, defaults to installed version." }, 157 | { name: "rev_code", short: "-R", long: "--rev-code", action: "rc", nargs: 1, default: null, help: "Specify a 'version_code', literal 'none' allowed, defaults to latest build." }, 158 | { name: "verbosity", short: "-v", long: "--verbose", action: "inc", default: 0, help: "Print various diagnostics. Repeat for even more output." }, 159 | { name: "verbosity", short: "-q", long: "--quiet", action: "dec", default: 0, help: "Reduce verbosity. Repeat for total silence."}, 160 | { name: "keep", short: "-k", long: "--keep", action: "set", default: false, help: "Save all downloaded working files." }, 161 | { name: "force", long: "--force", action: "set", default: false, help: "Force a build even when there are downgrades or no changes." }, 162 | { name: "add", short: "-a", long: "--add", action: "storex", nargs: 1, help: "New packages to add to build list." }, 163 | { name: "remove", short: "-r", long: "--remove", action: "storex", nargs: 1, help: "Installed packages to remove from build list." }, 164 | { name: "clean_slate", long: "--clean-slate", action: "set", default: false, help: "Remove all but default packages (READ THE DOCS)." }, 165 | { name: "ignored_defaults", long: "--ignored-defaults", action: "storex", nargs: 1, help: "List of explicitly ignored default package names." }, 166 | { name: "ignored_changes", long: "--ignored-changes", action: "storex", nargs: 1, help: "List of explicitly ignored package changes." }, 167 | { name: "init_script", short: "-I", long: "--init-script", action: "store", nargs: 1, default: null, help: "Path to uci-defaults script to run on first boot ('-' use stdin)." }, 168 | { name: "fstype", short: "-F", long: "--fstype", action: "enum", nargs: 1, default: null, one_of: _fstypes, help: `Desired root file system type (${join(", ", _fstypes)}).` }, 169 | { name: "rootfs_size", short: "-S", long: "--rootfs-size", action: "store_int", nargs: 1, default: null, lower: _fslo, help: `DANGER: See wiki before using! Root file system size in MB (${_fslo}-[server-dependent]).` }, 170 | { name: "image", short: "-i", long: "--image", action: "file", nargs: 1, default: "/tmp/firmware.bin", help: "Image name for download, verify, install and upgrade." }, 171 | { name: "format", short: "-f", long: "--format", action: "enum", nargs: 1, default: null, one_of: _list_fmts, help: `Format for 'list' output (${join(", ", _list_fmts)}).` }, 172 | { name: "pre_install", short: "-p", long: "--pre-install", action: "store", nargs: 1, default: null, help: "Script to exec just prior to launching final sysupgrade." }, 173 | { name: "poll_interval", short: "-T", long: "--poll-interval", action: "store_int", nargs: 1, default: 2000, help: "Poll interval for build monitor, in milliseconds." }, 174 | { name: "progress_size", long: "--progress-size", action: "store_int", nargs: 1, default: 0, help: "Response content-length above which we report download progress." }, 175 | 176 | { name: "device", long: "--device", action: "store", nargs: 1, default: null }, // Undocumented: For testing foreign devices. 177 | ], ap.ArgParser); 178 | 179 | arg_defs.set_prog_info(`owut - OpenWrt Upgrade Tool ${VERSION} (${sourcepath()})`); 180 | arg_defs.set_bookends( 181 | "\nowut is an upgrade tool for OpenWrt.\n", 182 | `\nFull documentation\n` 183 | ` ${wiki_url}\n\n` 184 | `Questions and discussion\n` 185 | ` ${forum_url}\n\n` 186 | `Issues and bug reports\n` 187 | ` ${issue_url}\n\n` 188 | ); 189 | 190 | let options = arg_defs.parse(null, {attendedsysupgrade: "owut"}); 191 | 192 | L.set_level(options.verbosity); 193 | 194 | //------------------------------------------------------------------------------ 195 | 196 | let url; // See initialize_urls 197 | let build; // See collect_all for next three 198 | let device; 199 | let release; 200 | let server_limits; 201 | 202 | // Temporary and resource files. 203 | // We save them all with reasonably recognizable names to aid in debugging. 204 | let tmp_root = "/tmp/owut-"; 205 | let img_root = match(options.image, /(.*\/|)[^\.]*/)[0]; 206 | let tmp = { 207 | overview_json: `${tmp_root}overview.json`, 208 | pkg_arch_json: `${tmp_root}packages-arch.json`, 209 | pkg_platform_json: `${tmp_root}packages-plat.json`, 210 | platform_json: `${tmp_root}platform.json`, 211 | 212 | failed_html: `${tmp_root}failures`, 213 | 214 | req_json: `${tmp_root}build-request.json`, // The POST body we send. 215 | build_json: `${tmp_root}build-response.json`, // First response. 216 | build_status_json: `${tmp_root}build-status.json`, // Overwritten subsequent responses. 217 | rsp_header: `${tmp_root}rsp-header.txt`, 218 | 219 | firmware_sums: `${img_root}.sha256sums`, // Expected sha256sums from downloaded firmware. 220 | firmware_man: `${img_root}-manifest.json`, // Manifest of successful build. 221 | }; 222 | 223 | let packageDB = {}; // Dictionary of installed packages and much status. 224 | let pkg_name_len = 0; 225 | 226 | let packages = { // Dictionary of package lists. 227 | // Remove selected packages per 228 | // https://github.com/openwrt/openwrt/commit/451e2ce0 229 | // https://github.com/openwrt/openwrt/commit/999ef827 230 | non_upgradable: [ "kernel", "libgcc", "libc"], 231 | 232 | // Collected from this device. 233 | installed: {}, // All the name:version pairs. 234 | top_level: [], // Simple list of names. 235 | 236 | // Collected from upgrade servers based on to-version. 237 | default: [], // Another simple name list. 238 | available: {}, // The name:version pairs that are available for installation. 239 | changes: {}, // Changes extracted from overview; structure documented in apply_pkg_mods. 240 | 241 | // Logging of modifications 242 | replaced: {}, // Log of replacements and removals due to 'package_changes'. 243 | }; 244 | 245 | //------------------------------------------------------------------------------ 246 | 247 | const ubus = { 248 | init: function() { 249 | this._bus = mod_ubus.connect(); 250 | this.check(this._bus, "mod_ubus.connect"); 251 | }, 252 | term: function() { this._bus.disconnect(); }, 253 | 254 | check: function(result, fmt, ...args) { 255 | if (!result || mod_ubus.error()) 256 | L.bug("ubus: %s\n"+fmt+"\n", mod_ubus.error(), ...args); 257 | }, 258 | 259 | call: function(obj, func, args) { 260 | let result = this._bus.call(obj, func, args); 261 | this.check(result, " from: 'ubus call %s %s %s'", obj, func, args ?? ""); 262 | return result; 263 | }, 264 | 265 | raw_run: function(command, ...params) { 266 | // Dangerous: no error checking. 267 | return this._bus.call("file", "exec", { command, params }); 268 | }, 269 | run: function(command, ...params) { 270 | return this.call("file", "exec", { command, params }); 271 | }, 272 | }; 273 | 274 | //------------------------------------------------------------------------------ 275 | 276 | let sha256 = { 277 | cmd: [ "/bin/busybox", "sha256sum" ], 278 | 279 | save: function(file, sum) { 280 | // Create the checksum file from the image name and expected sum. 281 | let sums = fs.open(tmp.firmware_sums, "w"); 282 | if (sums) { 283 | sums.write(sprintf("%s %s\n", sum, file)); 284 | sums.close(); 285 | } 286 | }, 287 | 288 | saved_sum: function(file) { 289 | // Our saved sums is always just a single line 290 | let sums = fs.open(tmp.firmware_sums, "r"); 291 | if (sums) { 292 | let line = split(trim(sums.read("line")), /\s+/); 293 | sums.close(); 294 | if (line[1] == file) 295 | return line[0]; 296 | L.err("Invalid image: got '%s', but expected '%s' in sum file\n", line[1], file); 297 | } 298 | return null; 299 | }, 300 | 301 | sum: function(file) { 302 | // Return the checksum for the specified file. 303 | let data = ubus.run(...this.cmd, file); 304 | if (data?.code == 0) 305 | return substr(data.stdout, 0, 64); 306 | this._fatal("sum", data); 307 | }, 308 | 309 | verify: function() { 310 | // Run validation against the saved checksums. 311 | let data = ubus.run(...this.cmd, "-c", tmp.firmware_sums); 312 | if (! data) this._fatal("verify", data); 313 | return data; 314 | }, 315 | 316 | _fatal: function(where, data) { 317 | L.bug("Fatal error in sha256.%s:\n%.2J\n", where, data); 318 | }, 319 | }; 320 | 321 | //------------------------------------------------------------------------------ 322 | 323 | function sysupgrade(file, check, options) 324 | { 325 | // When sysupgrade performs the upgrade, it returns with shell status 326 | // of '10', so uc_ubus_call raises an error. '--test' is fine, it 327 | // returns proper status. 328 | 329 | return check 330 | ? ubus.run("sysupgrade", ...options, file) 331 | : ubus.raw_run("sysupgrade", ...(options ?? []), file); 332 | } 333 | 334 | //------------------------------------------------------------------------------ 335 | 336 | let dl_stats = { 337 | count: 0, // Number of files downloaded. 338 | time: 0, // Sum of wall time taken. 339 | bytes: 0, // Only includes content, not http headers. 340 | 341 | Mbits: (bytes) => (bytes * 8.0 / (1024*1024)), 342 | log: function(url, file, bytes, time) { 343 | this.bytes += bytes; 344 | this.time += time; 345 | this.count++; 346 | 347 | let Mbits = this.Mbits(bytes); 348 | L.log(2, "Downloaded %s to %s (%dB at %.3f Mbps)\n", url, file, bytes, Mbits/time); 349 | }, 350 | 351 | report: function() { 352 | if (this.count > 0) { 353 | let Mbps = this.Mbits(this.bytes) / this.time; 354 | L.log(2, "Downloaded %d files, %d bytes at %.3f Mbps\n", this.count, this.bytes, Mbps); 355 | } 356 | }, 357 | }; 358 | 359 | function _get_ca_files() 360 | { 361 | const cert_path = "/etc/ssl/certs"; 362 | try { 363 | return fs.glob(`${cert_path}/*.crt`); 364 | } 365 | catch (error) { 366 | L.wrn("Could not access SSL certificates: %s\n", error); 367 | return []; 368 | } 369 | } 370 | 371 | let _uc_uc = null; 372 | let _uc_cb = null; 373 | 374 | function _get_uclient(url, dst_file) 375 | { 376 | if (! _uc_uc) { 377 | _uc_cb = { 378 | url: "", // For error messages. 379 | dst_file: "", // Where to put result. 380 | output: null, // Handle for dst_file. 381 | rsp_headers: {}, 382 | 383 | _disconnect: (cb) => { 384 | if (cb.output) { 385 | cb.output.close(); 386 | cb.output = null; 387 | } 388 | this.disconnect(); 389 | uloop.end(); 390 | }, 391 | 392 | header_done: (cb) => { 393 | cb.output = fs.open(cb.dst_file, "w"); 394 | if (! cb.output) { 395 | L.err("opening %s: %s\n", cb.dst_file, fs.error()); 396 | cb._disconnect(cb); 397 | } 398 | cb.rsp_headers = this.get_headers(); 399 | cb.rsp_headers.status = this.status().status; 400 | cb.rsp_headers.content_length = int(cb.rsp_headers["content-length"] || 0); 401 | cb.rsp_headers.content_read = 0; 402 | }, 403 | 404 | data_read: (cb) => { 405 | // uc.read returns null on eof. 406 | let n_read = 0; 407 | while ((n_read = cb.output.write(this.read() ?? "")) > 0) { 408 | cb.rsp_headers.content_read += n_read; 409 | if (options.progress_size && cb.rsp_headers.content_length > options.progress_size) { 410 | let pct_read = 100.0 * cb.rsp_headers.content_read / cb.rsp_headers.content_length; 411 | L.log(0, "Download progress: %5.1f%% %8d/%d bytes" + (L.stdout_is_pipe ? "\n" : "\r"), 412 | pct_read, cb.rsp_headers.content_read, cb.rsp_headers.content_length); 413 | } 414 | } 415 | }, 416 | 417 | data_eof: (cb) => { 418 | cb._disconnect(cb); 419 | }, 420 | 421 | error: (cb, code) => { 422 | L.err("uclient error code=%s\n This could be due to the server being down or inaccessible, check\n %s\n", code, cb.url); 423 | cb._disconnect(cb); 424 | }, 425 | }; 426 | 427 | _uc_uc = uclient.new(url, null, _uc_cb); 428 | 429 | if (! _uc_uc.ssl_init({ca_files: _get_ca_files()})) { 430 | L.die("owut uses ucode-mod-uclient, which requires an SSL library to function:\n" + 431 | " Please install one of the libustream-*[ssl|tls] packages as well as\n" + 432 | " one of the ca-bundle or ca-certificates packages.\n"); 433 | } 434 | 435 | } 436 | 437 | _uc_uc.set_url(url); 438 | _uc_cb.url = url; 439 | _uc_cb.dst_file = dst_file; 440 | 441 | return _uc_uc; 442 | } 443 | 444 | function _del_uclient() 445 | { 446 | _uc_uc?.free(); 447 | _uc_uc = null; 448 | _uc_cb = null; 449 | gc(); 450 | } 451 | 452 | function _request(url, dst_file, params) 453 | { 454 | // uclient function for simple http requests 455 | // url = self explanatory 456 | // dst_file = result of request 457 | // params = dictionary of optional parameters: 458 | // 'type' = 'GET' (default), 'HEAD' or 'POST' 459 | // 'json_data' = json data, forces 'POST' implicitly 460 | 461 | let json_data = params?.json_data; 462 | let type = params?.type ?? "GET"; 463 | 464 | if (! (type in ["GET", "HEAD", "POST"])) { 465 | L.bug("Invalid request type '%s'\n", type); 466 | } 467 | 468 | let start = time(); 469 | let uc = _get_uclient(url, dst_file); 470 | 471 | if (! uc.connect()) { 472 | L.err("Failed to connect\n"); 473 | return null; 474 | } 475 | 476 | let headers = { 477 | "User-Agent": PROG, 478 | }; 479 | let args = { 480 | headers: headers, 481 | }; 482 | 483 | if (json_data) { 484 | type = "POST"; 485 | headers["Content-Type"] = "application/json"; 486 | args["post_data"] = json_data; 487 | } 488 | 489 | if (! uc.request(type, args)) { 490 | L.err("Failed to send request\n"); 491 | return null; 492 | } 493 | 494 | uloop.run(); 495 | 496 | if (_uc_cb.rsp_headers) { 497 | let elapsed = (time() - start) || 0.5; 498 | let bytes = fs.stat(dst_file).size; 499 | dl_stats.log(url, dst_file, bytes, elapsed); 500 | 501 | L.log(3, "Response headers = %.2J\n", _uc_cb.rsp_headers); 502 | 503 | if (options.keep) { 504 | let hdrs = fs.open(tmp.rsp_header, "w"); 505 | hdrs.write(sprintf("%.J\n", _uc_cb.rsp_headers)); 506 | hdrs.close(); 507 | } 508 | } 509 | 510 | return { ...uc.status(), type: type, headers: _uc_cb.rsp_headers }; 511 | } 512 | 513 | function read_tmp_file(file) 514 | { 515 | // Reads 'file', then optionally deletes it. 516 | // Return raw text. 517 | 518 | let fd = fs.open(file, "r"); 519 | let tx = fd.read("all"); 520 | fd.close(); 521 | if (! options.keep) { 522 | fs.unlink(file); 523 | } 524 | return tx; 525 | } 526 | 527 | function read_tmp_json(file) 528 | { 529 | // Read 'file' into a json object, then optionally delete the file. 530 | // Primarily used to parse downloads from /tmp. 531 | let txt = trim(read_tmp_file(file)); 532 | try { 533 | return json(txt); 534 | } 535 | catch (error) { 536 | L.err("Expected json, but '%s'\n", error); 537 | return txt; 538 | } 539 | } 540 | 541 | //------------------------------------------------------------------------------ 542 | //-- Source-specific downloaders ----------------------------------------------- 543 | 544 | function dl_json(url, json_file, keep_going) 545 | { 546 | let rsp = _request(url, json_file); 547 | if (rsp?.status == 200) { 548 | return read_tmp_json(json_file); 549 | } 550 | L.err("Response status %s while downloading\n %s\n", rsp?.status, url); 551 | return keep_going ? null : exit(EXIT_ERR); 552 | } 553 | 554 | function dl_platform() 555 | { 556 | // Get the starting point for the target build. 557 | return dl_json(url.platform, tmp.platform_json, true); 558 | } 559 | 560 | function dl_overview() 561 | { 562 | // Overview is the collection of information about the branches and their releases. 563 | // 564 | // Note that auc uses branches.json instead. Its content is all included 565 | // in overview.json at '$.branches', but we like overview as it has a few 566 | // more useful items. It can be found at: 567 | // url.api_root/branches.json 568 | 569 | return dl_json(url.overview, tmp.overview_json); 570 | } 571 | 572 | function dl_failures(feed) 573 | { 574 | // The build failures info is html that resides in odd, one-man-out URL 575 | // locations: 576 | // https://downloads.openwrt.org/snapshots/faillogs/mipsel_24kc// 577 | // https://downloads.openwrt.org/releases/faillogs-23.05/mipsel_24kc// 578 | 579 | let uri = feed ? `${url.failed}${feed}/` : url.failed; 580 | let htm = `${tmp.failed_html}-${feed ? feed : "feeds"}.html`; 581 | 582 | let rsp = _request(uri, htm); 583 | if (rsp?.status == 200) { 584 | return read_tmp_file(htm); 585 | } 586 | return null; // Expected result when no failures found. 587 | } 588 | 589 | function dl_package_versions() 590 | { 591 | // Download and consolidate the two sources of package versions into 592 | // a single object. 593 | 594 | let arch = dl_json(url.pkg_arch, tmp.pkg_arch_json, true); 595 | let plat = dl_json(url.pkg_plat, tmp.pkg_platform_json, true); 596 | return sort({ ...(arch ?? []), ...(plat?.packages ?? []) }); 597 | } 598 | 599 | function check_build_response(response, file) 600 | { 601 | // Check the build responses for errors and substitute appropriate 602 | // messages when the request has failed. 603 | if (! response) { 604 | return { 605 | status: -1, 606 | detail: "Error: unknown server error", 607 | }; 608 | } 609 | if (! (response.status in [200, 202])) { 610 | let txt = trim(read_tmp_file(file)); 611 | try { 612 | response = json(txt); 613 | } 614 | catch { 615 | response.detail = "Error: " + txt; 616 | } 617 | return response; 618 | } 619 | return null; 620 | } 621 | 622 | function dl_build(config) 623 | { 624 | // Start a build by POSTing the json build configuration. 625 | let rsp = _request(url.build, tmp.build_json, {json_data: config}); 626 | return check_build_response(rsp, tmp.build_json) || read_tmp_json(tmp.build_json); 627 | } 628 | 629 | let _use_HEAD = true; 630 | 631 | function dl_build_status() 632 | { 633 | // The response is considered valid even if status != 200 (usually 202), 634 | // as this is the ongoing status query. See switch cases in 'download' 635 | // function. 636 | 637 | let rsp = _use_HEAD && _request(url.build_status, tmp.build_status_json, {type: "HEAD"}); 638 | if (rsp?.status == 405) { 639 | _use_HEAD = false; 640 | } 641 | else if (rsp?.status == 202) { 642 | // Build a minimal response. 643 | let ib_queue = rsp.headers["x-queue-position"]; 644 | let ib_status = rsp.headers["x-imagebuilder-status"]; 645 | return { 646 | status: rsp.status, 647 | detail: ib_status ?? "queued", 648 | queue_position: ib_queue, 649 | imagebuilder_status: ib_status, 650 | }; 651 | } 652 | 653 | // Otherwise do a full GET. 654 | rsp = _request(url.build_status, tmp.build_status_json); 655 | return check_build_response(rsp, tmp.build_status_json) || read_tmp_json(tmp.build_status_json); 656 | } 657 | 658 | //------------------------------------------------------------------------------ 659 | 660 | function to_int(s) 661 | { 662 | // Must be an int or there's a bug in caller. 663 | if (! match(s, /^[0-9]+$/)) L.bug("Invalid int '%s'\n", s); 664 | return int(s); 665 | } 666 | 667 | function ck_int(s) 668 | { 669 | // If empty, NaN or not fully converted, return null. 670 | if (! s) return null; 671 | s = ltrim(s, "0") || "0"; 672 | let n = int(s); 673 | return n == s ? n : null; 674 | } 675 | 676 | const standard = regexp("^([0-9.]+)(~([0-9a-f]+)){0,1}(-r([0-9]+)){0,1}$", "i"); 677 | const pre_bits = regexp("^[-a-z.]+", "i"); 678 | const ver_bits = regexp("^[0-9.]+", "i"); 679 | const ver_splt = regexp("[^a-z0-9]", "i"); 680 | const rev_bits = regexp("-[r]{0,1}([0-9]+)$", "i"); 681 | const apk_bits = regexp("^_[a-z]+([0-9]+)$", "i"); 682 | 683 | function version_parse(version) 684 | { 685 | // Two forms are parsed incorrectly: 686 | // 1) Those with all dashes '-', no hash and no rev. The final number 687 | // is interpreted as the rev number: 688 | // ct-bugcheck: 2016-07-21 689 | // ver=[2016, 7], hsh=null, rev=21 690 | // 2) Those starting with a hash, and the initial digits are decimal: 691 | // open-plc-utils: 1ba7d5a0-6 692 | // ver=[1], hsh="ba7d5a0", rev=6 693 | // 694 | // #1 is only aesthetic and ends up working fine; #2 was fixed in 695 | // the apk version cleanup and only appears in 23.05 and earlier. 696 | 697 | let ver, hsh, rev, extra; 698 | 699 | let parts = match(version, standard); 700 | if (parts) { 701 | // Standard n.n.n~h-rn sequences with optional hash and rev. 702 | ver = map(split(parts[1], ver_splt), to_int); 703 | hsh = parts[3]; 704 | rev = ck_int(parts[5]); 705 | } 706 | else { 707 | // Some odd ones: 708 | // luasoap: 2014-08-21-raf1e100281cee4b972df10121e37e51d53367a98 709 | // luci-app-advanced-reboot: 1.0.1-11-1 710 | 711 | if (parts = match(version, pre_bits)) { 712 | // adb: android.5.0.2_r1-r3 713 | // libinih: r58-r1 714 | // luci-app-dockerman: v0.5.13-20240317-1 715 | // luci-mod-status: git-24.141.29354-5cfe7a7 (23.05) 716 | version = substr(version, length(parts[0])); 717 | } 718 | 719 | if (rev = match(version, rev_bits)) { 720 | // Extract and strip rev number from version, in case 721 | // it's one of those with lots of dashes. 722 | version = substr(version, 0, -length(rev[0])); 723 | rev = to_int(rev[1]); 724 | } 725 | 726 | if (ver = match(version, ver_bits)) { 727 | version = substr(version, length(ver[0])); 728 | ver = map(split(rtrim(ver[0], "."), ver_splt), to_int); 729 | } 730 | 731 | if (length(ver) == 1 && index(version, "-") == 0) { 732 | // pservice: 2017-08-29-r3 733 | for (let ns in split(substr(version, 1), /[-.]/)) { 734 | let n = ck_int(ns); 735 | if (n == null) break; 736 | push(ver, n); 737 | version = substr(version, length(ns)+1); 738 | } 739 | } 740 | 741 | if (extra = match(version, apk_bits)) { 742 | // For apk underscore-named versions: 743 | // openssh-ftp-server 9.9_p1-r3 9.9_p2-r1 744 | push(ver, int(extra[1])); 745 | version = null; 746 | } 747 | 748 | // Anything left over must be the "hash" (if we can call it that). 749 | hsh = version || null; 750 | if (index(hsh, "-") == 0) hsh = substr(hsh, 1); 751 | } 752 | 753 | return { ver, hsh, rev }; 754 | } 755 | 756 | function pkg_ver_cmp(old, new, strict) 757 | { 758 | if (old == null) return -2; 759 | if (new == null) return 2; 760 | let v1 = version_parse(old); 761 | let v2 = version_parse(new); 762 | 763 | // Ensure format changes are respected: jansson 2.14-r3 2.14.1-r1 764 | let len = max(length(v1.ver), length(v2.ver)); 765 | while (length(v1.ver) < len) push(v1.ver, 0); 766 | while (length(v2.ver) < len) push(v2.ver, 0); 767 | 768 | for (let i, n1 in v1.ver) { 769 | let n2 = v2.ver[i]; 770 | if (n1 < n2) return -1; 771 | if (n1 > n2) return 1; 772 | } 773 | if (v1.rev < v2.rev) return -1; 774 | if (v1.rev > v2.rev) return 1; 775 | // Ignore the hashes unless we're being strict 776 | if (strict && v1.hsh != v2.hsh) return 99; // 99 means "I don't know" 777 | return 0; 778 | } 779 | 780 | function _vmangle(v) 781 | { 782 | v = replace(v, "-SNAPSHOT", ".999"); 783 | v = replace(v, "SNAPSHOT", "999"); 784 | v = replace(v, "-rc", "-r"); 785 | if (substr(v, -2) == ".0") v += "-r999"; // After the -rcN 786 | return v; 787 | } 788 | 789 | function version_cmp(v1, v2) 790 | { 791 | // Compare two versions and return cmp value based on their relative 792 | // ordering. We want to make sure RCs are before any release, and 793 | // SNAPSHOTs are after, hence the string mangling. 794 | 795 | return pkg_ver_cmp(_vmangle(v1), _vmangle(v2)); 796 | } 797 | 798 | function version_older(new_version, base_version) 799 | { 800 | // Use version_cmp to see if new_version < base_version. Useful to detect 801 | // if the user is attempting to downgrade their installation. 802 | // 803 | // version_older('23.05.2', 'SNAPSHOT') -> true 804 | // version_older('23.05-SNAPSHOT', 'SNAPSHOT') -> true 805 | // version_older('23.05.0', '23.05.0-rc1') -> false 806 | 807 | return version_cmp(new_version, base_version) < 0; 808 | } 809 | 810 | function branch_of(version) 811 | { 812 | // Extract and return the branch for a given version. 813 | // SNAPSHOT -> SNAPSHOT 814 | // 23.05.0-rc1 -> 23.05 815 | // 23.05.3 -> 23.05 816 | // 23.05-SNAPSHOT -> 23.05 817 | if (version == "SNAPSHOT") return "SNAPSHOT"; 818 | let m = match(version, /\d+\.\d+/); 819 | return m ? m[0] : ""; 820 | } 821 | 822 | //------------------------------------------------------------------------------ 823 | //-- Package management -------------------------------------------------------- 824 | 825 | function is_installed(pkg) 826 | { 827 | return pkg in packageDB; 828 | } 829 | 830 | function is_default(pkg) 831 | { 832 | // Return status if package is in the defaults for this device, i.e., it 833 | // will be present as part of the standard install. 834 | 835 | // Don't attempt to do the following, because we care about things that 836 | // may not be installed, e.g., 'dnsmasq' replaced by 'dnsmasq-full'. 837 | // return pkg in packageDB && packageDB[pkg].default; 838 | 839 | return pkg in packages.default; 840 | } 841 | 842 | function is_top_level(pkg) 843 | { 844 | // We only check in the installed packages. 845 | return is_installed(pkg) && packageDB[pkg].top_level; 846 | } 847 | 848 | function is_available(pkg) 849 | { 850 | // Search for a given package in the combined platform/arch package list. 851 | return pkg in packages.available; 852 | } 853 | 854 | //------------------------------------------------------------------------------ 855 | 856 | const SrcType = { 857 | ALL: 0, 858 | USER_ONLY: 1, 859 | DEFAULT_ONLY: 2, 860 | }; 861 | 862 | function what_provides(pkg) 863 | { 864 | // Attempt to find the package provider, if it exists. This is a 865 | // hacky workaround for https://github.com/efahl/owut/issues/10, see 866 | // that issue for current progress towards a proper solution. 867 | let result; 868 | 869 | result = ubus.raw_run("opkg", "whatprovides", pkg); 870 | if (result) { 871 | let m = match(trim(result.stdout), /What provides.*\n *(.*)/); 872 | if (m) return m[-1]; 873 | } 874 | 875 | result = ubus.raw_run("apk", "list", "--installed", "--providers", "--manifest", pkg); 876 | if (result && result.stdout) { 877 | // dnsmasq-full 2.90-r3 878 | let m = split(result.stdout, /\s+/); 879 | if (m) return m[1]; 880 | } 881 | return null; 882 | } 883 | 884 | function top_level(src_type) 885 | { 886 | // Return only the installed top level packages, i.e., those upon which 887 | // no other package depends. 'src_type' specifies how the top-level list 888 | // is to be filtered. 889 | 890 | let tl; 891 | switch (src_type) { 892 | case SrcType.ALL: 893 | tl = is_top_level; 894 | break; 895 | 896 | case SrcType.DEFAULT_ONLY: 897 | tl = (pkg) => is_top_level(pkg) && is_default(pkg); 898 | break; 899 | 900 | case SrcType.USER_ONLY: 901 | tl = (pkg) => is_top_level(pkg) && ! is_default(pkg); 902 | break; 903 | } 904 | return filter(keys(packageDB), tl); 905 | } 906 | 907 | function with_versions(pkg_list) 908 | { 909 | let versioned = {}; 910 | for (let pkg in pkg_list) { 911 | let ver = packages.available[pkg]; 912 | if (! ver) { 913 | // Hackery because of packages historically missing from the indexes. 914 | if (pkg in ["kernel", "libc"]) continue; 915 | 916 | L.wrn("Package '%s' has no available current version\n", pkg); 917 | } 918 | versioned[pkg] = ver; 919 | } 920 | return versioned; 921 | } 922 | 923 | function removed_defaults() 924 | { 925 | // Return the list of default packages that have been removed. 926 | return filter(packages.default, (p) => ! is_installed(p)); 927 | } 928 | 929 | function add_package(pkg, force) 930 | { 931 | // 'force' is used during package_changes processing to ensure that 932 | // the replacement is added, irrespective of availability (e.g., a 933 | // broken build for the package being added would otherwise fail). 934 | 935 | if (is_installed(pkg)) { 936 | packageDB[pkg].top_level = true; 937 | return true; 938 | } 939 | 940 | if (! force && ! is_available(pkg)) { 941 | L.err("Package '%s' is not available on this platform\n", pkg); 942 | return false; 943 | } 944 | 945 | packageDB[pkg] = { 946 | version: null, 947 | new_version: packages.available[pkg], 948 | top_level: true, 949 | default: is_default(pkg), 950 | }; 951 | return true; 952 | } 953 | 954 | function remove_package(pkg, silent) 955 | { 956 | // pkg - the package to remove, if possible. 957 | 958 | if (! is_installed(pkg)) { 959 | if (! silent) 960 | L.err("Package '%s' is not installed and cannot be removed\n", pkg); 961 | } 962 | else { 963 | if (! is_top_level(pkg)) { 964 | if (! silent) 965 | L.wrn("Package '%s' is a dependency, removal will have no effect on the build\n", pkg); 966 | } 967 | else if (is_default(pkg)) { 968 | L.wrn("Package '%s' is a default package, removal may have unknown side effects\n", pkg); 969 | } 970 | delete packageDB[pkg]; 971 | return true; 972 | } 973 | return false; 974 | } 975 | 976 | function replace_package(old_pkg, new_pkg) 977 | { 978 | // Do optional replacements. If 'old_pkg' is not installed, then we 979 | // have nothing to do but report success. 980 | 981 | if (is_installed(old_pkg)) { 982 | remove_package(old_pkg, true); 983 | return add_package(new_pkg, true); 984 | } 985 | return true; 986 | } 987 | 988 | function clean_slate() 989 | { 990 | for (let pkg in keys(packageDB)) { 991 | if (! is_default(pkg)) { 992 | delete packageDB[pkg]; 993 | } 994 | } 995 | for (let pkg in packages.default) { 996 | if (is_available(pkg)) { 997 | add_package(pkg); 998 | } 999 | } 1000 | return true; 1001 | } 1002 | 1003 | //------------------------------------------------------------------------------ 1004 | 1005 | function collect_defaults(board, device) 1006 | { 1007 | // Use both board json for its defaults, then add the device-specific 1008 | // defaults to make one big defaults list. 1009 | 1010 | let defaults = []; // Must filter out any device-specific removals. 1011 | for (let pkg in board) { 1012 | if (! ("-"+pkg in device)) { 1013 | push(defaults, pkg); 1014 | } 1015 | } 1016 | for (let pkg in device) { 1017 | if (! match(pkg, /^-/)) { 1018 | push(defaults, pkg); 1019 | } 1020 | } 1021 | 1022 | for (let pkg in packages.non_upgradable) { 1023 | if (! (pkg in defaults)) push(defaults, pkg); 1024 | } 1025 | packages.default = sort(defaults); 1026 | } 1027 | 1028 | function parse_package_list(opt) 1029 | { 1030 | // Return array and null as-is, parse strings into array of string. 1031 | // String separators are any of comma, space or newline sequences. 1032 | // Allows use of either "list" or "option" in config file. 1033 | if (type(opt) == "string") { 1034 | opt = split(opt, /[,[:space:]]+/); 1035 | } 1036 | return opt; 1037 | } 1038 | 1039 | function apply_pkg_mods() 1040 | { 1041 | // 1) Handle 'overview.package_changes'. 1042 | // 2) Generate any clean-slate request. 1043 | // 3) Apply user-specified removals. 1044 | // 4) Apply user-specified additions. 1045 | // 1046 | // Package names in changes are already in canonical form. 1047 | // changes = [ 1048 | // { 1049 | // source: "name-from", // Required string - name of package in target version 1050 | // target: "name-to", // Optional string - if present, name of replaced package in installed version 1051 | // revision: 123, // Required int - revision at which package change was introduced 1052 | // mandatory: false, // Optional bool - if true, then force add/remove of target/source, respectively 1053 | // }, 1054 | // ... 1055 | // ]; 1056 | // 1057 | // For a case of simple removal, see 'kmod-nft-nat6', which was merged 1058 | // into 'kmod-nft-nat' in rev 19160. 1059 | 1060 | let errors = 0; 1061 | 1062 | // TODO think about downgrades, i.e., when to.rev_num < from.rev_num... 1063 | 1064 | let ignored = parse_package_list(options.ignored_changes); 1065 | let rev_num = build.to.rev_num(); 1066 | for (let chg in packages.changes) { 1067 | // Upgrades 1068 | if (chg.source in ignored) { 1069 | // Allow user to retain old packages on demand, but inform them when changes are skipped. 1070 | L.log(1, "Ignoring package change %s to %s\n", chg.source, chg.target); 1071 | continue; 1072 | } 1073 | if (chg.revision <= rev_num) { 1074 | if (chg.target) { 1075 | if (is_installed(chg.source)) 1076 | packages.replaced[chg.source] = chg.target; 1077 | if (! replace_package(chg.source, chg.target)) 1078 | errors++; 1079 | } 1080 | else if (is_installed(chg.source)) { 1081 | // No target, only source, so remove it. 1082 | packages.replaced[chg.source] = null; 1083 | if (! remove_package(chg.source)) 1084 | errors++; 1085 | } 1086 | } 1087 | } 1088 | 1089 | if (options.clean_slate) { 1090 | clean_slate(); 1091 | } 1092 | 1093 | // Do removals first, so any conflicts are suppressed. 1094 | for (let pkg in parse_package_list(options.remove)) { 1095 | if (! pkg) continue; 1096 | if (! remove_package(pkg)) { 1097 | errors++; 1098 | } 1099 | } 1100 | for (let pkg in parse_package_list(options.add)) { 1101 | if (! pkg) continue; 1102 | if (! add_package(pkg)) { 1103 | errors++; 1104 | } 1105 | } 1106 | 1107 | for (let pkg in ignored) { 1108 | if (! add_package(pkg)) { 1109 | errors++; 1110 | } 1111 | } 1112 | 1113 | 1114 | return errors == 0; 1115 | } 1116 | 1117 | function collect_packages() 1118 | { 1119 | // Using data from rpc-sys packagelist, build an object containing all 1120 | // installed package data. 1121 | // 1122 | // packageDB = { 1123 | // "pkg1": { 1124 | // version: "version-string", 1125 | // new_version: "version-string", 1126 | // top_level: bool, 1127 | // default: bool, 1128 | // }, 1129 | // "pkg2": { 1130 | // ... 1131 | // }, 1132 | // }; 1133 | 1134 | let installed = ubus.call("rpc-sys", "packagelist", { all: true }); 1135 | let top_level = ubus.call("rpc-sys", "packagelist", { all: false }); 1136 | 1137 | packages.installed = installed.packages; 1138 | packages.top_level = sort(keys(top_level.packages)); 1139 | 1140 | packages.available = dl_package_versions() ?? {}; // Might be null in ancient versions. 1141 | 1142 | for (let pkg, ver in packages.installed) { 1143 | packageDB[pkg] = { 1144 | version: ver, 1145 | new_version: packages.available[pkg], 1146 | top_level: pkg in packages.top_level, 1147 | default: is_default(pkg), 1148 | }; 1149 | } 1150 | 1151 | if (! apply_pkg_mods()) { 1152 | L.die("Errors collecting package data, terminating.\n"); 1153 | } 1154 | } 1155 | 1156 | //------------------------------------------------------------------------------ 1157 | 1158 | function initialize_urls() 1159 | { 1160 | let sysupgrade = rtrim(uci.get_first("attendedsysupgrade", "server", "url"), "/") || default_sysupgrade; 1161 | 1162 | let api = sysupgrade + "/api/v1"; 1163 | let build = api + "/build"; 1164 | let status = build + "/{hash}"; 1165 | let static = sysupgrade + "/json/v1"; 1166 | let store = sysupgrade + "/store"; 1167 | let overview = static + "/overview.json"; 1168 | 1169 | url = { 1170 | sysupgrade_root: sysupgrade, // sysupgrade server base url 1171 | api_root: api, // api for builds and other dynamic requests 1172 | build: build, // build request 1173 | build_status: status, // build status, same as build appended with hash 1174 | static_root: static, // json static api root url 1175 | store_root: store, // api database directory for build results 1176 | platform: null, // release platform json 1177 | overview: overview, // Top-level overview.json, contains branch info 1178 | pkg_arch: null, // Generic arch package list, containing most of the items 1179 | pkg_plat: null, // Platform packages, built specifically for this platform 1180 | 1181 | upstream: null, // upstream build server base url, from overview.json 1182 | download: null, // upstream with directory at which the "to" build can be found 1183 | failed: null, // Failure list html 1184 | }; 1185 | } 1186 | 1187 | function show_versions(check_version_to) 1188 | { 1189 | // Using the ASU overview to get all the available versions, scan that 1190 | // for version-to and report. 1191 | 1192 | L.log(0, "Available 'version-to' values from %s:\n", url.sysupgrade_root); 1193 | 1194 | let branches = release.branches; 1195 | let installed = build.from.version; 1196 | let selected = build.to?.version ?? options.version_to; 1197 | let found_to = false; 1198 | 1199 | for (let branch, details in branches) { 1200 | L.log(0, " %s %s branch\n", branch, branch == "SNAPSHOT" ? "main" : "release"); 1201 | for (let version in details.versions) { 1202 | let suffix = []; 1203 | if (version == details.latest) push(suffix, "latest"); 1204 | if (version == installed ) push(suffix, "installed"); 1205 | if (version == selected ) { 1206 | push(suffix, "requested"); 1207 | found_to = true; 1208 | } 1209 | suffix = length(suffix) == 0 ? "" : " (" + join(",", suffix) + ")"; 1210 | L.log(0, " %s%s\n", version, suffix); 1211 | } 1212 | } 1213 | 1214 | if (! found_to) 1215 | L.log(0, "\nYour specified version-to '%s' is not available. " + 1216 | "Pick one from above.\n", selected); 1217 | else if (check_version_to) 1218 | L.log(0, "\nYour version-to '%s' appears valid, so either:\n" + 1219 | " 1) This build has been removed from the server, or\n" + 1220 | " 2) The ASU server is having issues.\n", selected); 1221 | } 1222 | 1223 | //------------------------------------------------------------------------------ 1224 | 1225 | const BuildInfo = { 1226 | is_snapshot: function() { 1227 | return this.version == "SNAPSHOT"; 1228 | }, 1229 | 1230 | is_rel_snapshot: function() { 1231 | return match(this.version, /.*-SNAPSHOT/) != null; 1232 | }, 1233 | 1234 | is_downgrade_from: function(from) { 1235 | if (version_older(this.version, from.version)) 1236 | return true; 1237 | // Same or newer version, so check revision number. 1238 | return this.rev_num() < from.rev_num(); 1239 | }, 1240 | 1241 | rev_num: function() { 1242 | // Extracts the revision number from the revision code: 1243 | // "r23630-842932a63d" -> 23630 1244 | let m = parse_rev_code(this.rev_code); 1245 | return m ? int(m[1]) : 0; 1246 | }, 1247 | }; 1248 | 1249 | function collect_device_info() 1250 | { 1251 | let sysb = ubus.call("system", "board"); 1252 | 1253 | let efi_check = null; 1254 | 1255 | if (options.device) { 1256 | // Allow test cases (or "cross" downloads?), using '--device' 1257 | // option. tegra is one with 'sdcard' sutype, use as follows: 1258 | // owut ... --device tegra/generic:compulab_trimslice:squashfs 1259 | let bv = split(options.device, ":"); 1260 | sysb.release.target = bv[0]; 1261 | sysb.board_name = bv[1]; 1262 | sysb.rootfs_type = bv[2] || "squashfs"; 1263 | efi_check = bv[3] || "no"; // Default to no, if unspecified. 1264 | } 1265 | 1266 | let target = sysb.release.target; 1267 | let platform = replace(sysb.board_name, /,/, "_"); 1268 | let ver_from = sysb.release.version; 1269 | let sutype; // Sysupgrade type: combined, combined-efi, sdcard or sysupgrade (or trx or lxl or ???) 1270 | 1271 | // Generic efi targets are: 1272 | // armsr - EFI-boot only, see https://github.com/efahl/owut/issues/21 1273 | // loongarch - EFI-boot only 1274 | // x86 - EFI- or BIOS-boot 1275 | let efi_capable = match(target, /^(armsr|loongarch|x86)\//); 1276 | if (efi_check == null) { 1277 | efi_check = efi_capable ? "check" : "no"; // one of: force, no, check 1278 | } 1279 | 1280 | // See also, auc.c:1657 'select_image' for changing installed fstype to requested one. 1281 | // Wait, is there also "factory"??? See asu/build.py abt line 246 1282 | if (efi_capable) { 1283 | // No distinction between "factory" and "sysupgrade" for the generic devices. 1284 | sutype = "combined"; 1285 | } 1286 | else { 1287 | // Could be that sutype = "sdcard" for some devices, but 1288 | // assume it is "sysupgrade", which might be wrong. We'll 1289 | // fix this after we get the profiles for the device, see 1290 | // 'collect_all'. 1291 | sutype = "sysupgrade"; 1292 | } 1293 | 1294 | if (efi_check == "force" || (efi_check == "check" && fs.access("/sys/firmware/efi"))) { 1295 | sutype = `${sutype}-efi`; 1296 | } 1297 | 1298 | device = { 1299 | arch: null, // "x86_64" or "mipsel_24kc" or "aarch64_cortex-a53", contained in platform_json 1300 | target: target, // "x86/64" or "ath79/generic" or "mediatek/mt7622", from system board 1301 | platform: platform, // "generic" (for x86) or "tplink,archer-c7-v4" or "linksys,e8450-ubi" 1302 | fstype: sysb.rootfs_type, // "ext4" or "squashfs", what is actually present now 1303 | sutype: sutype, // Sysupgrade type, combined, combined-efi or sysupgrade or sdcard 1304 | }; 1305 | 1306 | build = { 1307 | from: proto({ 1308 | version: ver_from, // Full version name currently installed: "SNAPSHOT" or "22.03.1" 1309 | rev_code: sysb.release.revision, // Kernel version that is currently running 1310 | kernel: sysb.kernel, // Current build on device 1311 | }, BuildInfo) 1312 | }; 1313 | } 1314 | 1315 | function collect_build_info() 1316 | { 1317 | let ver_to = options.version_to ? uc(options.version_to) : null; 1318 | if (ver_to) { 1319 | if (index(ver_to, "RC") > 0) ver_to = lc(ver_to); 1320 | 1321 | if (ver_to in release.branches) { 1322 | // User specified a branch name, not a specific version, 1323 | // so coerce to latest in that branch. 1324 | ver_to = release.branches[ver_to].latest; 1325 | } 1326 | } 1327 | else if (index(build.from.version, "SNAPSHOT") >= 0) 1328 | // Any snapshot stays on the same branch. 1329 | ver_to = build.from.version; 1330 | else { 1331 | // Any unspecified non-snapshot gets latest from same branch. 1332 | let b = branch_of(build.from.version); 1333 | ver_to = release.branches[b].latest; 1334 | } 1335 | 1336 | let ver_to_branch = branch_of(ver_to); 1337 | if (! (ver_to_branch in release.branches) || 1338 | ! (ver_to in release.branches[ver_to_branch].versions)) { 1339 | show_versions(true); 1340 | exit(EXIT_ERR); 1341 | } 1342 | 1343 | let fstype = options.fstype || device.fstype; 1344 | build.to = proto({ 1345 | version: ver_to, // Full version name of target: "22.03.0-rc4", "23.05.2" or "SNAPSHOT" 1346 | rev_code: null, // Build number from target 1347 | kernel: null, // Kernel version of target build, extracted from BOM 1348 | fstype: fstype, // Requested root FS type 1349 | img_prefix: null, // Prefix of image being built 1350 | img_file: null, // Full image name to download and install 1351 | date: null, // Build date of target 1352 | }, BuildInfo); 1353 | } 1354 | 1355 | function age(from) 1356 | { 1357 | let delta = time() - from; 1358 | let hours = (delta+1800) / 3600; 1359 | if (hours < 48) 1360 | return sprintf("~%d hours ago", hours); 1361 | return sprintf("~%.0f days ago", hours / 24.0); 1362 | } 1363 | 1364 | function complete_build_info(profile, board) 1365 | { 1366 | let dt = gmtime(board.source_date_epoch); 1367 | build.to.date = sprintf("%4d-%02d-%02dT%02d:%02d:%02dZ (%s)", dt.year, dt.mon, dt.mday, dt.hour, dt.min, dt.sec, age(board.source_date_epoch)); 1368 | build.to.rev_code = board.version_code; 1369 | build.to.img_prefix = profile.image_prefix; 1370 | 1371 | // First, only allow legitimate "sysupgrade" types through. 1372 | let images = filter(profile.images, (img) => img.filesystem && img.filesystem != "initramfs" && ! match(img.type, /factory/)); 1373 | 1374 | let valid_fstypes = uniq(sort(map(images, (img) => img.filesystem))); 1375 | if (! (build.to.fstype in valid_fstypes)) { 1376 | L.die("File system type '%s' should be one of %s\n", build.to.fstype, valid_fstypes); 1377 | } 1378 | 1379 | // Next, reduce candidate images to just those with desired fstype. 1380 | images = filter(images, (img) => img.filesystem == build.to.fstype); 1381 | 1382 | // Here is where we fix our guess for 'sutype' made above in 1383 | // 'collect_device_info' (look for "sdcard" there). 1384 | let valid_sutypes = uniq(sort(map(images, (img) => img.type))); 1385 | if (! (device.sutype in valid_sutypes)) { 1386 | if (device.sutype == "sysupgrade" && length(valid_sutypes) == 1) 1387 | device.sutype = valid_sutypes[0]; 1388 | else 1389 | L.bug("%s:%s Sysupgrade type '%s' should be one of %s\n", 1390 | device.target, device.platform, device.sutype, valid_sutypes); 1391 | } 1392 | 1393 | for (let img in images) { 1394 | if (img.filesystem == build.to.fstype && img.type == device.sutype) { 1395 | build.to.img_file = img.name; 1396 | break; 1397 | } 1398 | } 1399 | } 1400 | 1401 | function collect_overview() 1402 | { 1403 | // When building the versions list, refer to 1404 | // https://openwrt.org/docs/guide-developer/security#support_status 1405 | 1406 | collect_device_info(); 1407 | 1408 | let overview = dl_overview(); 1409 | url.upstream = overview.upstream_url ?? default_upstream; 1410 | server_limits = { 1411 | max_rootfs_size: overview.server?.max_custom_rootfs_size_mb || 1024, 1412 | max_defaults_length: overview.server?.max_defaults_length || 20480, 1413 | }; 1414 | if (options.rootfs_size && options.rootfs_size > server_limits.max_rootfs_size) { 1415 | L.die("%d is above this server's maximum rootfs partition size of %d MB\n", 1416 | options.rootfs_size, server_limits.max_rootfs_size); 1417 | } 1418 | 1419 | 1420 | let bad_releases = [ "23.05.1", ]; 1421 | 1422 | release = { 1423 | branch: null, // Release branch name: "SNAPSHOT" or "21.07" or "23.05" 1424 | dir: null, // ASU and DL server release branch directory: "snapshots" or "release/23.05.0" 1425 | branches: {}, 1426 | }; 1427 | 1428 | for (let branch_id, data in overview.branches) { 1429 | release.branches[branch_id] = { 1430 | latest: null, // Determined below. 1431 | versions: filter(data.versions, (v) => !(v in bad_releases)), 1432 | }; 1433 | } 1434 | 1435 | // Put everything in order, select 'latest'... 1436 | release.branches = sort(release.branches, version_cmp); 1437 | for (let data in values(release.branches)) { 1438 | data.versions = sort(data.versions, version_cmp); 1439 | for (let version in data.versions) { 1440 | if (index(version, "-SNAPSHOT") >= 0) continue; 1441 | if (data.latest == null || version_older(data.latest, version)) { 1442 | data.latest = version; 1443 | } 1444 | } 1445 | } 1446 | 1447 | collect_build_info(); 1448 | 1449 | release.branch = branch_of(build.to.version); 1450 | 1451 | let active_branch = overview.branches[release.branch]; 1452 | if (! active_branch) { 1453 | // TODO Should probably check: 1454 | // || ! (build.to.version in release.branches[release.branch].versions) 1455 | // but the versions list we got from the overview is incomplete 1456 | // (i.e., ASU server will build a lot more than is shown) and 1457 | // we catch that when trying to fetch board_json below. 1458 | show_versions(true); 1459 | exit(EXIT_ERR); 1460 | } 1461 | 1462 | release.dir = replace(active_branch.path, "{version}", build.to.version); 1463 | packages.changes = active_branch.package_changes; 1464 | } 1465 | 1466 | function collect_platform() 1467 | { 1468 | // ASU platform.json often updates days after upstream profiles, so the 1469 | // 'version_code' is wrong and build requests fail. As of 2024-06-09, 1470 | // this appears to have been solved, see ASU commit 1e6484d. 1471 | // 1472 | // Previously, to solve this issue we'd look at the download server: 1473 | // url.platform = `${url.upstream}/${release.dir}/targets/${device.target}/profiles.json`; 1474 | // let profile = platform.profiles[device.platform]; 1475 | // 1476 | // Use upstream as much as possible, per @aparcar's comment: 1477 | // https://github.com/efahl/owut/issues/2#issuecomment-2165021615 1478 | 1479 | url.platform = `${url.upstream}/${release.dir}/targets/${device.target}/profiles.json`; 1480 | let platform = dl_platform(); 1481 | if (! platform) { 1482 | L.die("Unsupported target '%s'\n", device.target); 1483 | } 1484 | 1485 | if (! (device.platform in keys(platform.profiles))) { 1486 | // This is a mapped profile, e.g.: raspberrypi,model-b-plus -> rpi 1487 | let found = false; 1488 | for (let real_platform, data in platform.profiles) { 1489 | for (let alias in data.supported_devices) { 1490 | alias = replace(alias, ",", "_"); 1491 | if (device.platform == alias) { 1492 | found = true; 1493 | L.log(2, "Mapping platform %s to %s\n", device.platform, real_platform); 1494 | device.platform = real_platform; 1495 | break; 1496 | } 1497 | } 1498 | if (found) break; 1499 | } 1500 | if (! found) { 1501 | if ("generic" in keys(platform.profiles)) 1502 | device.platform = "generic"; 1503 | else 1504 | L.die("Unsupported profile: %s\n Valid profiles are %s\n", device.platform, keys(platform.profiles)); 1505 | } 1506 | } 1507 | 1508 | let profile = platform.profiles[device.platform]; 1509 | device.arch = platform.arch_packages; 1510 | 1511 | complete_build_info(profile, platform); 1512 | collect_defaults(platform.default_packages, profile.device_packages); 1513 | 1514 | if ("linux_kernel" in platform) { 1515 | build.to.kernel = platform.linux_kernel.version; 1516 | } 1517 | } 1518 | 1519 | function collect_all() 1520 | { 1521 | collect_overview(); 1522 | collect_platform(); 1523 | 1524 | let location = build.to.is_snapshot() 1525 | ? "snapshots/faillogs" 1526 | : `releases/faillogs-${release.branch}`; 1527 | url.failed = `${url.upstream}/${location}/${device.arch}/`; 1528 | 1529 | url.pkg_arch = `${url.static_root}/${release.dir}/packages/${device.arch}-index.json`; 1530 | url.pkg_plat = `${url.static_root}/${release.dir}/targets/${device.target}/index.json`; 1531 | 1532 | let prefix = "openwrt-"; 1533 | let starget = replace(device.target, /\//, "-"); 1534 | if (! build.to.is_snapshot()) prefix = `${prefix}${build.to.version}-`; 1535 | if (build.to.is_rel_snapshot()) prefix = lc(`${prefix}${build.to.rev_code}-`); 1536 | 1537 | url.download = `${url.upstream}/${release.dir}/targets/${device.target}`; 1538 | 1539 | collect_packages(); 1540 | 1541 | if (! build.to.kernel) { 1542 | // Fall back to pre-"linux_kernel" scanning of packages. 1543 | build.to.kernel = "unknown"; 1544 | for (let pkg, data in packageDB) { 1545 | if (data.new_version && index(pkg, "kmod-") == 0 && is_default(pkg)) { 1546 | build.to.kernel = match(data.new_version, /^\d+\.\d+\.\d+/)[0]; 1547 | break; 1548 | } 1549 | } 1550 | } 1551 | 1552 | pkg_name_len = max(...map(keys(packageDB), length), ...map(packages.default, length)); 1553 | } 1554 | 1555 | //------------------------------------------------------------------------------ 1556 | 1557 | function dump() 1558 | { 1559 | // Send forth a json representation of all the stuff we've collected. 1560 | let comma = (l) => L.show(l) ? "," : ""; 1561 | let inst = sort(packages.installed); 1562 | let urls = sort(url); 1563 | let tmps = sort(tmp); 1564 | let pkgs = sort(packageDB); 1565 | L.log(-1, '{\n'); 1566 | L.log(-1, '"version": "%s",\n', PROG); 1567 | L.log(-1, '"options": %.2J,\n', options); 1568 | L.log(-1, '"server_limits": %.2J,\n', server_limits); 1569 | L.log( 1, '"url": %.2J,\n', urls); 1570 | L.log( 1, '"tmp": %.2J,\n', tmps); 1571 | L.log( 1, '"build": %.2J,\n', build); 1572 | L.log(-1, '"device": %.2J,\n', device); 1573 | L.log( 0, '"release": %.2J%s\n', release, comma(2)); 1574 | L.log( 2, '"packageDB": %.2J%s\n', pkgs, comma(3)); 1575 | L.log( 3, '"packages": %.2J\n', packages); 1576 | L.log(-1, '}\n'); 1577 | } 1578 | 1579 | function list() 1580 | { 1581 | // Generate a list formatted for use with firmware-selector or 1582 | // source build. 1583 | let packages; 1584 | switch (options.format) { 1585 | case "config": 1586 | packages = top_level(SrcType.USER_ONLY); 1587 | let ctype = "y"; // "y" to install, "m" just build package. 1588 | for (let pkg in sort(packages)) { 1589 | L.log(0, "CONFIG_PACKAGE_%s=%s\n", pkg, ctype); 1590 | } 1591 | break; 1592 | 1593 | case "fs-all": 1594 | packages = top_level(SrcType.ALL); 1595 | L.log(0, "%s\n", join(" ", packages)); 1596 | break; 1597 | 1598 | case "fs-user": 1599 | default: 1600 | packages = top_level(SrcType.USER_ONLY); 1601 | push(packages, ...map(removed_defaults(), (pkg) => "-"+pkg)); 1602 | L.log(0, "%s\n", join(" ", packages)); 1603 | break; 1604 | } 1605 | } 1606 | 1607 | function show_config() 1608 | { 1609 | // Pretty-print the major configuration values. 1610 | let downgrade = build.to.is_downgrade_from(build.from) ? L.colorize(L.RED, " DOWNGRADE") : ""; 1611 | let hash = split(build.to.rev_code, "-")[1]; 1612 | L.log(0, 1613 | `ASU-Server ${url.sysupgrade_root}\n` 1614 | `Upstream ${url.upstream}\n` 1615 | `Target ${device.target}\n` 1616 | `Profile ${device.platform}\n` 1617 | `Package-arch ${device.arch}\n` 1618 | ); 1619 | L.log(1, 1620 | `Root-FS-type ${device.fstype}\n` 1621 | `Sys-type ${device.sutype}\n` 1622 | ); 1623 | L.log(0, 1624 | `Version-from ${build.from.version} ${build.from.rev_code} (kernel ${build.from.kernel})\n` 1625 | `Version-to ${build.to.version} ${build.to.rev_code} (kernel ${build.to.kernel})${downgrade}\n` 1626 | ); 1627 | if (hash) 1628 | L.log(1, `Build-commit https://git.openwrt.org/?p=openwrt/openwrt.git;a=shortlog;h=${hash}\n`); 1629 | L.log(1, 1630 | `Build-FS-type ${build.to.fstype}\n` 1631 | `Build-at ${build.to.date}\n` 1632 | `Image-prefix ${build.to.img_prefix}\n` 1633 | `Image-URL ${url.download}\n` 1634 | `Image-file ${build.to.img_file}\n` 1635 | ); 1636 | L.log(1, "Installed %3d packages\n", length(packageDB)); 1637 | L.log(1, "Top-level %3d packages\n", length(top_level(SrcType.ALL))); 1638 | L.log(1, "Default %3d packages\n", length(packages.default)); 1639 | L.log(1, "User-installed %3d packages (top-level only)\n", length(top_level(SrcType.USER_ONLY))); 1640 | L.log(1, "\n"); 1641 | } 1642 | 1643 | function check_updates() 1644 | { 1645 | // Scan the old and new package lists, return the number of changed 1646 | // packages and the number of missing packages that would cause a 1647 | // build failure. 1648 | 1649 | let changes = 0; 1650 | let missing = 0; 1651 | let downgrades = 0; 1652 | 1653 | L.log(1, "Package version changes:\n"); 1654 | let w0 = pkg_name_len + 2; 1655 | let w1 = max(...map(values(packageDB), (p) => length(p.version))); 1656 | let f0 = ` %-${w0}s %s%-${w1}s %s%s%s\n`; 1657 | let new = ''; 1658 | for (let pkg, data in sort(packageDB)) { 1659 | if (pkg in packages.non_upgradable) continue; 1660 | 1661 | let old = data.version; 1662 | let new = data.new_version; 1663 | if (old == new) continue; 1664 | let cmp = pkg_ver_cmp(old, new, true); 1665 | if (cmp == 0) continue; 1666 | 1667 | changes++; 1668 | let c1 = ""; 1669 | let c2 = ""; 1670 | switch (cmp) { 1671 | case -2: // Old is null. 1672 | // This happens when you '--add' a new package. 1673 | c1 = L.color(L.YELLOW); 1674 | old = "not-installed"; 1675 | // fallthrough 1676 | case -1: 1677 | c2 = L.color(L.GREEN); 1678 | break; 1679 | 1680 | case 1: 1681 | downgrades++; 1682 | c2 = L.color(L.RED); 1683 | break; 1684 | 1685 | case 2: // New is null. 1686 | if (! is_top_level(pkg) && ! is_default(pkg)) 1687 | c2 = L.color(L.YELLOW); 1688 | else { 1689 | missing++; 1690 | c2 = L.color(L.RED); 1691 | } 1692 | new = "missing to-version"; 1693 | break; 1694 | 1695 | case 99: 1696 | // They are different (hashes usually), but we 1697 | // can't tell if one is older than the other. 1698 | c2 = L.color(L.YELLOW); 1699 | break; 1700 | } 1701 | L.log(1, f0, pkg, c1, old, c2, new, L.color(L.reset)); 1702 | } 1703 | 1704 | if (missing) { 1705 | L.log(0, "%d packages missing in target version, %s\n", missing, L.colorize(L.RED, "cannot upgrade")); 1706 | } 1707 | if (downgrades) { 1708 | L.log(0, "%d packages were downgraded\n", downgrades); 1709 | } 1710 | if (changes) { 1711 | L.log(0, "%d packages are out-of-date\n", changes); 1712 | } 1713 | else { 1714 | L.log(0, "All packages are up-to-date\n"); 1715 | } 1716 | 1717 | if (length(packages.replaced)) { 1718 | let f0 =` %-${w0}s %s\n`; 1719 | L.log(1, "\nAutomatic package replacements/removals:\n"); 1720 | L.log(1, f0, "Package", "Replaced-by"); 1721 | for (let chg, to in sort(packages.replaced)) { 1722 | L.log(1, f0, chg, to ?? L.colorize(L.YELLOW, "removed")); 1723 | } 1724 | L.log(1, "Details at %s\n", url.overview); 1725 | } 1726 | 1727 | L.log(1, "\n"); 1728 | 1729 | return { missing, changes, downgrades }; 1730 | } 1731 | 1732 | function check_missing() 1733 | { 1734 | // Mostly silent check for missing packages. Used when generating a 1735 | // package list for external uses, 'list' or 'blob'. 1736 | L.push(-1); 1737 | let updates = check_updates(); 1738 | L.pop(); 1739 | if (updates.missing) { 1740 | L.err("There are %s missing packages in the build package list, run 'owut check'\n", updates.missing); 1741 | return false; 1742 | } 1743 | return true; 1744 | } 1745 | 1746 | function check_defaults() 1747 | { 1748 | // Scan the package defaults to see if they are 1749 | // 1) missing from the installation or 1750 | // 2) modified/replaced by some other package. 1751 | 1752 | let ignored = parse_package_list(options.ignored_defaults); 1753 | let changes = {}; 1754 | let count = { ignored: 0, retained: 0, replaced: 0, removed: 0 }; 1755 | for (let pkg in packages.default) { 1756 | if (pkg in packages.non_upgradable) continue; 1757 | if (pkg in ignored) { 1758 | count.ignored++; 1759 | changes[pkg] = L.colorize(L.GREEN, "user ignored"); 1760 | } 1761 | else if (is_installed(pkg)) 1762 | count.retained++; 1763 | else { 1764 | changes[pkg] = what_provides(pkg); 1765 | if (changes[pkg]) 1766 | count.replaced++; 1767 | else { 1768 | count.removed++; 1769 | changes[pkg] = L.colorize(L.YELLOW, "not installed"); 1770 | } 1771 | } 1772 | } 1773 | 1774 | L.log(1, "Default package analysis:\n"); 1775 | if (length(changes) == 0) { 1776 | L.log(1, " No missing or modified default packages.\n\n"); 1777 | } 1778 | else { 1779 | let wid = pkg_name_len + 2; 1780 | let fmt = ` %-${wid}s %s\n`; 1781 | L.log(1, fmt, "Default", "Provided-by"); 1782 | for (let p, a in changes) { 1783 | L.log(1, fmt, p, a); 1784 | } 1785 | L.log(1, "\n"); 1786 | } 1787 | 1788 | return count; 1789 | } 1790 | 1791 | function check_pkg_builds() 1792 | { 1793 | // Scraping the failures.html is a total hack. 1794 | // Let me know if you have an API on downloads (or other build site) 1795 | // that can give this info. 1796 | // 1797 | // The lines we're scraping look like: 1798 | // gummiboot/-Tue Apr 23 07:05:36 2024 1799 | 1800 | let info = regexp( 1801 | '([^<]*)/' + 1802 | '[^<]*' + 1803 | '([^<]*)', 1804 | 'g' 1805 | ); 1806 | 1807 | let n_failed = 0; 1808 | let n_broken = 0; 1809 | 1810 | let html_blob = dl_failures(); 1811 | let feeds = map(match(html_blob, info), (f) => f[1]); 1812 | if (! html_blob || ! feeds) { 1813 | L.log(1, "No package build failures found for %s %s.\n\n", build.to.version, device.arch); 1814 | } 1815 | else { 1816 | L.log(1, "There are currently package build failures for %s %s:\n", build.to.version, device.arch); 1817 | 1818 | for (let feed in feeds) { 1819 | L.log(1, " Feed: %s\n", feed); 1820 | html_blob = dl_failures(feed); 1821 | if (html_blob) { 1822 | let fails = match(html_blob, info); 1823 | let fmt = ` %-${pkg_name_len}s %s - %s\n`; 1824 | 1825 | for (let fail in fails) { 1826 | n_broken++; 1827 | let pkg = fail[1]; 1828 | let date = fail[2]; 1829 | let msg; 1830 | if (is_installed(pkg)) { 1831 | n_failed++; 1832 | msg = L.colorize(L.RED, "package installed locally, cannot upgrade"); 1833 | } 1834 | else { 1835 | msg = "not installed"; 1836 | } 1837 | L.log(1, fmt, pkg, date, msg); 1838 | } 1839 | } 1840 | } 1841 | 1842 | let prefix = n_failed ? 1843 | L.colorize(L.RED, `${n_failed} of ${n_broken} package build failures affect this device:`) : 1844 | L.colorize(L.GREEN, `${n_broken} package build failures don't affect this device,`); 1845 | L.log(n_failed ? 0 : 1, "%s details at\n %s\n\n", prefix, url.failed); 1846 | } 1847 | 1848 | return ! n_failed; 1849 | } 1850 | 1851 | function check_init_script() 1852 | { 1853 | // If 1854 | // 1) the user has an immutable image, and 1855 | // 2) that image contains an ASU generated custom defaults file, and 1856 | // 3) the user has not mentioned or overridden it, 1857 | // then warn them and have them read the documentation. 1858 | 1859 | const asu_defaults = "/rom/etc/uci-defaults/99-asu-defaults"; 1860 | if (options.init_script) { 1861 | if (options.init_script != "-" && ! fs.access(options.init_script)) { 1862 | L.die("The specified init-script '%s' does not exist\n", options.init_script); 1863 | } 1864 | } 1865 | else if (fs.access(asu_defaults)) { 1866 | L.wrn(`You have a custom uci-defaults script at\n` 1867 | ` ${asu_defaults}\n` 1868 | `and have not referenced it with the 'init-script' option.\n` 1869 | `Please read:\n` 1870 | ` ${wiki_url}#using_a_uci-defaults_script\n\n` 1871 | ); 1872 | } 1873 | } 1874 | 1875 | function blob(report) 1876 | { 1877 | // Exclude default packages unless explicitly listed in 'packages'. When 1878 | // moving between releases, default packages may be added, deleted or 1879 | // renamed, which can result in bricks if something important is missed. 1880 | 1881 | let contains_defaults = true; 1882 | 1883 | let log_level = report ? 1 : 2; 1884 | 1885 | // We try to use 'packages_versions' in our server request, but can 1886 | // only do so if all packages have valid versions. We fall back to 1887 | // using 'packages' when any version is undefined. 1888 | let package_list = { packages: top_level(SrcType.ALL) }; 1889 | package_list["packages_versions"] = with_versions(package_list.packages); 1890 | package_list["type"] = null in values(package_list.packages_versions) ? "packages" : "packages_versions"; 1891 | 1892 | let init_script; 1893 | if (options.init_script) { 1894 | let inits_file = options.init_script == "-" ? fs.stdin : fs.open(options.init_script); 1895 | if (! inits_file) { 1896 | L.err("Init script file '%s' does not exist\n", options.init_script); 1897 | return null; 1898 | } 1899 | else { 1900 | init_script = inits_file.read("all"); 1901 | inits_file.close(); 1902 | if (length(init_script) > server_limits.max_defaults_length) { 1903 | L.err("'%s' is over the ASU server's %s byte maximum\n", options.init_script, server_limits.max_defaults_length); 1904 | return null; 1905 | } 1906 | } 1907 | } 1908 | 1909 | let rc = (options.rev_code == "none" ? "" : options.rev_code) ?? build.to.rev_code; 1910 | 1911 | L.log(log_level, "Request:\n Version %s %s (kernel %s)\n", build.to.version, rc || "none", build.to.kernel); 1912 | if (build.to.fstype != device.fstype) { 1913 | L.log(log_level, " Change file system type from '%s' to '%s'\n", device.fstype, build.to.fstype); 1914 | } 1915 | 1916 | let blob = { 1917 | client: PROG, 1918 | target: device.target, 1919 | profile: device.platform, // sanitized board name 1920 | 1921 | version: build.to.version, 1922 | version_code: rc, 1923 | filesystem: build.to.fstype, 1924 | 1925 | diff_packages: contains_defaults, 1926 | }; 1927 | blob[package_list.type] = package_list[package_list.type]; 1928 | 1929 | if (options.rootfs_size) { 1930 | if (options.rootfs_size > server_limits.max_rootfs_size) { 1931 | L.die("This server allows a maximum rootfs partition size of %d MB\n", server_limits.max_rootfs_size); 1932 | } 1933 | 1934 | blob.rootfs_size_mb = options.rootfs_size; 1935 | L.log(log_level, " ROOTFS_PARTSIZE set to %d MB\n", options.rootfs_size); 1936 | } 1937 | 1938 | if (init_script) { 1939 | blob.defaults = init_script; 1940 | L.log(log_level, " Included init script '%s' (%d bytes) in build request\n", options.init_script, length(init_script)); 1941 | } 1942 | 1943 | return blob; 1944 | } 1945 | 1946 | function json_blob() 1947 | { 1948 | let b = blob(true); 1949 | return b ? sprintf("%J", b) : null; 1950 | } 1951 | 1952 | function show_blob() 1953 | { 1954 | let b = blob(); 1955 | if (b) { 1956 | L.log(0, "%.2J\n", b); 1957 | } 1958 | } 1959 | 1960 | function select_image(images) 1961 | { 1962 | for (let image in images) { 1963 | if (image.filesystem == build.to.fstype && image.type == device.sutype) { 1964 | return image; 1965 | } 1966 | } 1967 | return null; 1968 | } 1969 | 1970 | function verify_image() 1971 | { 1972 | // Verify the image with both the saved sha256sum and by passing it 1973 | // to 'sysupgrade --test'. 1974 | // 1975 | // Failed images will be delete, unless '--keep' is set. 1976 | 1977 | let image = options.image; 1978 | 1979 | if (! fs.access(image)) { 1980 | L.err("Image file '%s' does not exist\n", image); 1981 | return false; 1982 | } 1983 | 1984 | let info = fs.stat(image); 1985 | L.log(1, "Verifying : %s (%d bytes) against %s\n", image, info.size, tmp.firmware_sums); 1986 | 1987 | let result = sha256.verify(); 1988 | if (result?.code == 0) { 1989 | L.log(1, " Saved sha256 matches\n"); 1990 | } 1991 | else { 1992 | let file_sha = sha256.sum(image); 1993 | let expected = sha256.saved_sum(image); 1994 | L.err(`sha256 doesn't match:\n` 1995 | ` calculated '${file_sha}'\n` 1996 | ` saved '${expected}'\n`); 1997 | if (! options.keep) { 1998 | fs.unlink(image); 1999 | } 2000 | return false; 2001 | } 2002 | 2003 | result = sysupgrade(image, true, ["--test"]); 2004 | if (result?.code == 0) { 2005 | L.log(1, " %s\n", join("\n ", split(trim(result.stderr), "\n"))); 2006 | } 2007 | else { 2008 | L.err("sysupgrade validation failed:\n"); 2009 | if (result.stdout) L.log(0, "stdout =\n%s\n", result.stdout); 2010 | if (result.stderr) L.log(0, "stderr =\n%s\n", result.stderr); 2011 | if (! options.keep) { 2012 | fs.unlink(image); 2013 | } 2014 | return false; 2015 | } 2016 | 2017 | L.log(1, "Checks complete, image is valid.\n"); 2018 | return true; 2019 | } 2020 | 2021 | function download() 2022 | { 2023 | // Use the json_blob to create a build request, run the request and 2024 | // download the result. 2025 | // 2026 | // On success, return true. 2027 | // 2028 | // Certain failures often indicate that the ASU server has fallen 2029 | // behind the build servers. For example, 2030 | // 2031 | // Error: Received incorrect version r26608-blah (requested r26620-blah) 2032 | // 2033 | // When we see one of these cases, we display one or more error 2034 | // messages, increment an error counter and use the counter to fall 2035 | // out of the main build-polling loop, and then generate a final 2036 | // message "wait a bit and retry". 2037 | // 2038 | // Other inexplicable or better explained errors simply show the error 2039 | // message and then immediately return false. 2040 | 2041 | let blob = json_blob(); 2042 | if (! blob) return false; 2043 | 2044 | if (options.keep) { 2045 | L.log(2, "Saving build blob to %s\n", tmp.req_json); 2046 | let save = fs.open(tmp.req_json, "w"); 2047 | if (save) { 2048 | save.write(blob); 2049 | save.close(); 2050 | } 2051 | } 2052 | 2053 | let response = dl_build(blob); 2054 | let t_start = time(); 2055 | let t_build_start = t_start; 2056 | 2057 | let hash = response?.request_hash; 2058 | if (hash) { 2059 | L.log(0, "Request hash:\n %s\n", hash); 2060 | url.build_status = replace(url.build_status, "{hash}", hash); 2061 | } 2062 | 2063 | let errors = 0; 2064 | let prev_status = ""; 2065 | let poll_interval = 500; // Check first response right away. 2066 | while (response && !errors) { 2067 | let status = response.status; 2068 | 2069 | if (response.detail == prev_status) { 2070 | L.backup(); 2071 | } 2072 | else { 2073 | L.log(0, "--\n"); 2074 | L.mark(); 2075 | prev_status = response.detail; 2076 | } 2077 | 2078 | let t_now = time(); 2079 | let highlight = substr(response.detail, 0, 6) == "Error:" ? L.RED : L.GREEN; 2080 | L.log(0, "Status: %s", L.colorize(highlight, response.detail)); 2081 | switch (response.detail) { 2082 | case "queued": 2083 | L.log(0, " - %d ahead of you", response.queue_position); 2084 | t_build_start = t_now; 2085 | break; 2086 | case "started": 2087 | L.log(0, " - %s", response.imagebuilder_status ?? "setup"); 2088 | break; 2089 | default: 2090 | break; 2091 | } 2092 | L.log(0, "\n"); 2093 | 2094 | let t_total = t_now - t_start; 2095 | let t_queue = t_build_start - t_start; 2096 | let t_build = t_now - t_build_start; 2097 | L.log(0, "Progress: %3ds total = %3ds in queue + %3ds in build\n", t_total, t_queue, t_build); 2098 | 2099 | switch (status) { 2100 | case 202: // Build in-progress, update monitor at user-specified interval. 2101 | sleep(poll_interval); 2102 | poll_interval = options.poll_interval; 2103 | response = dl_build_status(); 2104 | break; 2105 | 2106 | case 200: // All done. 2107 | let req_version_code = response.request.version_code; 2108 | L.log(0, "\nBuild succeeded in %3ds total = %3ds in queue + %3ds to build:\n", t_total, t_queue, t_build); 2109 | L.log(2, " build_at = %s\n", response.build_at); 2110 | L.log(1, " version_number = %s\n", response.version_number); 2111 | L.log(1, " version_code = %s (requested %s)\n", response.version_code, req_version_code); 2112 | L.log(1, " kernel_version = %s\n", build.to.kernel); 2113 | L.log(1, " rootfs_size_mb = %s\n", response.request.rootfs_size_mb ?? "default"); 2114 | L.log(1, " init-script = %s\n", response.request.defaults ? options.init_script : "no-init-script"); 2115 | L.log(3, " images = %.2J\n", response.images); 2116 | L.log(4, " build_cmd = %.2J\n", response.build_cmd); 2117 | L.log(4, " manifest = %.2J\n", response.manifest); 2118 | L.log(1, "\n"); 2119 | 2120 | let image = select_image(response.images); 2121 | if (! image) { 2122 | L.err("Could not locate an image for '%s' and '%s'\n", build.to.fstype, device.sutype); 2123 | return false; 2124 | } 2125 | 2126 | let sha = image.sha256; // LuCI-ASU uses sha256_unsigned for something... 2127 | let dir = response.bin_dir; 2128 | let bin = `${url.store_root}/${dir}`; 2129 | let img = `${bin}/${image.name}`; 2130 | let dst = options.image; 2131 | 2132 | L.log(1, "Image source: %s\n", img); 2133 | 2134 | let rsp = _request(img, dst); 2135 | if (rsp?.status != 200) { 2136 | L.err("Couldn't download image %s\n", image.name); 2137 | return false; 2138 | } 2139 | L.log(0, "Image saved : %s\n", dst); 2140 | 2141 | sha256.save(dst, sha); 2142 | 2143 | // Create the manifest, and validate against request. 2144 | let manifest = response.manifest; 2145 | let manifest_file = fs.open(tmp.firmware_man, "w"); 2146 | if (manifest_file) { 2147 | manifest_file.write(sprintf("%.2J\n", response)); 2148 | manifest_file.close(); 2149 | L.log(1, "Manifest : %s\n", tmp.firmware_man); 2150 | } 2151 | 2152 | if (req_version_code && response.version_code != req_version_code) { 2153 | L.err("Firmware revision mismatch: expected %s, but got %s\n" + 2154 | "If you determine this is acceptable, simply 'owut verify' " + 2155 | "and 'owut install'.\n", 2156 | req_version_code, response.version_code); 2157 | errors++; 2158 | } 2159 | 2160 | for (let pkg in top_level(SrcType.ALL)) { 2161 | if (pkg in packages.non_upgradable) continue; 2162 | if (! (pkg in manifest)) { 2163 | L.err("Firmware missing requested package: '%s'\n", pkg); 2164 | errors++; 2165 | continue; 2166 | } 2167 | 2168 | let expected_version = packageDB[pkg].new_version; 2169 | let received_version = manifest[pkg]; 2170 | if (received_version != expected_version) { 2171 | L.err("Firmware package version mismatch: '%s', expected %s, but got %s\n", 2172 | pkg, expected_version, received_version); 2173 | errors++; 2174 | continue; 2175 | } 2176 | } 2177 | 2178 | return errors == 0; 2179 | 2180 | // Everything else is "failure - we're done" cases. 2181 | case 400: // Invalid build request. 2182 | case 422: // Unknown package. 2183 | case 500: // Invalid build request. 2184 | default: // ??? 2185 | L.log(0, "\nBuild failed in %3ds total = %3ds in queue + %3ds to build:\n", t_total, t_queue, t_build); 2186 | if (response.error ) L.log(0, "%s =\n%s\n", L.colorize(L.RED, "ASU server error"), response.error); 2187 | if (response.stdout) L.log(0, "%s =\n%s\n", L.colorize(L.RED, "ASU server stdout"), response.stdout); 2188 | if (response.stderr) L.log(0, "%s =\n%s\n", L.colorize(L.RED, "ASU server stderr"), response.stderr); 2189 | L.err("Build failed with status %s\n", status); 2190 | errors++; 2191 | break; 2192 | } 2193 | } 2194 | 2195 | if (errors) { 2196 | L.log(0, "The above errors are often due to the upgrade server lagging behind the\n" + 2197 | "build server, first suggestion is to wait a while and try again.\n"); 2198 | } 2199 | return false; 2200 | } 2201 | 2202 | function run_pre_install_hook() 2203 | { 2204 | // Execute a user program just prior to doing the final installation. 2205 | 2206 | if (options.pre_install) { 2207 | if (! fs.access(options.pre_install)) 2208 | L.die(`Cannot access pre-install script '${options.pre_install}', aborting upgrade\n`); 2209 | 2210 | L.log(1, "Executing pre-install script '%s'...\n", options.pre_install); 2211 | 2212 | let result = ubus.run(options.pre_install); 2213 | 2214 | if (result?.stdout) L.log(0, result.stdout); 2215 | if (result?.stderr) L.log(0, result.stderr); 2216 | if (result.code != 0) 2217 | L.die(`Pre-install script '${options.pre_install}' failed with status ${result.code}, aborting upgrade\n`); 2218 | L.log(1, "Pre-install script successful, proceeding with upgrade\n"); 2219 | } 2220 | } 2221 | 2222 | function install() 2223 | { 2224 | // Run sysupgrade to install the image. 2225 | // Probably need some more options reflected from sysupgrade itself. 2226 | // '-n' = discard config comes to mind first, maybe '-f' = force, too. 2227 | 2228 | run_pre_install_hook(); // Run user's pre-install hook. 2229 | 2230 | sysupgrade(options.image, false); 2231 | L.log(0, "Installing %s and rebooting...\n", options.image); 2232 | } 2233 | 2234 | //------------------------------------------------------------------------------ 2235 | 2236 | if (options.verbosity > 0 && ! (options.command in ["dump", "blob", "list"])) { 2237 | arg_defs.show_version(); 2238 | } 2239 | 2240 | uloop.init(); 2241 | ubus.init(); 2242 | 2243 | initialize_urls(); 2244 | 2245 | let updates, counts; 2246 | let exit_status = EXIT_ERR; // Assume failure, until we know otherwise. 2247 | 2248 | function terminate() 2249 | { 2250 | // system("grep -E 'VmRSS|VmPeak' /proc/$PPID/status"); 2251 | if (uloop.running()) uloop.end(); 2252 | uloop.done(); 2253 | ubus.term(); 2254 | _del_uclient(); 2255 | exit(exit_status); 2256 | } 2257 | 2258 | signal("SIGINT", terminate); // Ensure ctrl-C works when inside build loop. 2259 | 2260 | switch (options.command) { 2261 | case "versions": 2262 | collect_overview(); 2263 | show_versions(false); 2264 | exit_status = EXIT_OK; 2265 | break; 2266 | 2267 | case "dump": 2268 | L.push(-1); 2269 | collect_all(); 2270 | L.pop(); 2271 | dump(); 2272 | L.push(-1); 2273 | exit_status = EXIT_OK; 2274 | break; 2275 | 2276 | case "list": 2277 | collect_all(); 2278 | list(); 2279 | if (check_missing()) exit_status = EXIT_OK; 2280 | break; 2281 | 2282 | case "check": 2283 | collect_all(); 2284 | show_config(); 2285 | updates = check_updates(); 2286 | counts = check_defaults(); 2287 | if (check_pkg_builds() && ! updates.missing) exit_status = EXIT_OK; 2288 | check_init_script(); 2289 | if (counts.removed) 2290 | L.wrn("There are %d missing default packages, confirm this is expected before proceeding\n", counts.removed); 2291 | if (exit_status != EXIT_OK) 2292 | L.err("Checks reveal errors, do not upgrade\n"); 2293 | else if (updates.downgrades) 2294 | L.wrn("Checks reveal package downgrades, upgrade still possible with '--force'\n"); 2295 | else if (updates.changes == 0) 2296 | L.log(0, "There are no changes, upgrade not necessary\n"); 2297 | else 2298 | L.log(0, "It is safe to proceed with an upgrade\n"); 2299 | break; 2300 | 2301 | case "blob": 2302 | collect_all(); 2303 | check_init_script(); 2304 | show_blob(); 2305 | if (check_missing()) exit_status = EXIT_OK; 2306 | break; 2307 | 2308 | case "download": 2309 | case "upgrade": 2310 | collect_all(); 2311 | show_config(); 2312 | updates = check_updates(); 2313 | if (updates.downgrades && ! options.force) { 2314 | L.err("Update checks reveal package downgrades, re-run with '--force' to proceed\n"); 2315 | break; 2316 | } 2317 | if (updates.missing) { 2318 | L.err("Update checks reveal errors, can't proceed\n"); 2319 | break; 2320 | } 2321 | counts = check_defaults(); 2322 | if (! check_pkg_builds()) { 2323 | L.err("Package build checks reveal errors, can't proceed\n"); 2324 | break; 2325 | } 2326 | if (counts.removed) 2327 | L.wrn("There are %d missing default packages, confirm this is expected before proceeding\n", counts.removed); 2328 | if (updates.changes == 0) { 2329 | if (options.force) 2330 | L.wrn("Forcing build with no changes\n"); 2331 | else { 2332 | L.log(0, "There are no changes to %s (see '--force')\n", options.command); 2333 | exit_status = EXIT_OK; 2334 | break; 2335 | } 2336 | } 2337 | check_init_script(); 2338 | if (! download()) break; 2339 | // fallthrough 2340 | case "verify": 2341 | case "install": 2342 | if (verify_image()) { 2343 | exit_status = EXIT_OK; 2344 | if (options.command in ["upgrade", "install"]) { 2345 | install(); 2346 | } 2347 | } 2348 | break; 2349 | 2350 | default: 2351 | L.err("'%s' not implemented\n", options.command); 2352 | break; 2353 | } 2354 | 2355 | dl_stats.report(); 2356 | 2357 | terminate(); 2358 | --------------------------------------------------------------------------------