#!/usr/bin/lua --[[ Part of AREDN® -- Used for creating Amateur Radio Emergency Data Networks Copyright (C) 2021 Tim Wilkinson Original Perl Copyright (C) 2015 Conrad Lara See Contributors file for additional contributors This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation version 3 of the License. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Additional Terms: Additional use restrictions exist on the AREDN® trademark and logo. See AREDNLicense.txt for more info. Attributions to the AREDN® Project must be retained in the source code. If importing this code into a new or existing project attribution to the AREDN® project must be added to the source code. You must not misrepresent the origin of the material contained within. Modified versions must be modified to attribute to the original source and be marked in reasonable ways as differentiate it from the original version --]] require("nixio") require("aredn.hardware") require("aredn.http") require("aredn.utils") require("aredn.html") require("uci") require("aredn.info") require("ubus") require("luci.jsonc") local html = aredn.html local cursor = uci.cursor() local conn = ubus.connect() -- handle firmware updates local fw_install = false local fw_output = {} local fw_versions = {} local fw_names = {} local fw_version = "" function fwout(msg) fw_output[#fw_output + 1] = msg end function get_default_gw() -- a node with a wired default gw will route via this local p = io.popen("ip route list table 254") if p then for line in p:lines() do local gw = line:match("^default%svia%s([%d%.]+)") if gw then p:close() return gw end end p:close() end -- table 31 is populated by OLSR p = io.popen("ip route list table 31") if p then for line in p:lines() do local gw = line:match("^default%svia%s([%d%.]+)") if gw then p:close() return gw end end p:close() end return "none" end function word_wrap(len, lines) local output = "" for _, str in ipairs(lines) do while #str > len do local str1 = str:sub(1, len) local str2 = str:sub(len + 1) local m, x = str1:match("^(.*)%s(%S+)$") if m then output = output .. m .. "\n" str = x .. str2 else output = output .. str1 .. "\n" str = str2 end end output = output .. str .. "\n" end return output:sub(1, #output - 1) end function print_firmware_notice(reboot_when, href_addr, text_addr) html.print([[

Firmware will be written in the background.

If your computer is connected to the LAN of this node you may need to acquire
a new IP address and reset any name service caches you may be using.

The node will reboot ]] .. reboot_when .. [[.
When the node has finished booting you should ensure your computer has
received a new IP address and reconnect with
http://]] .. text_addr .. [[:8080/
This page will automatically reload once the upgrade has completed


Time Remaining:

]]) end -- read_postdata local parms = {} local firmfile = "" if os.getenv("REQUEST_METHOD") == "POST" then require('luci.http') local request = luci.http.Request(nixio.getenv(), function() local v = io.read(1024) if not v then io.close() end return v end ) -- only allow file uploading without active tunnels local fp request:setfilehandler( function(meta, chunk, eof) if not fp then if meta and meta.file then firmfile = meta.file if firmfile:match("sysupgrade%.bin$") or firmfile:match("sysupgrade%-v7%.bin$") or firmfile:match("ext4%-combined%.img%.gz$") then -- Uploading a system upgrade - clear out memory early os.execute("/usr/local/bin/upgrade_prepare.sh stop > /dev/null 2>&1") end end nixio.fs.mkdir("/tmp/web") nixio.fs.mkdir("/tmp/web/upload") fp = io.open("/tmp/web/upload/file", "w") end if chunk then fp:write(chunk) end if eof then fp:close() end end ) parms = request:formvalue() end if parms.button_reboot then html.reboot() end local node = aredn.info.get_nvram("node") local tmpdir = "/tmp/web/admin" nixio.fs.mkdir("/tmp/web") nixio.fs.mkdir("/tmp/web/admin") -- set the curl command options local curl = "curl -A 'node: " .. node .. "' " local serverpaths = {} local aredn_firmware = cursor:get("aredn", "@downloads[0]", "firmware_aredn") if aredn_firmware then serverpaths[#serverpaths + 1] = aredn_firmware else local uciserverpath = cursor:get("aredn", "@downloads[0]", "firmwarepath") if not uciserverpath then uciserverpath = "" end serverpaths[#serverpaths + 1] = uciserverpath:match("^(.*)/firmware") or uciserverpath end local hardwaretype = aredn.hardware.get_type() local targettype = conn:call("system", "board", {}).release.target local local_firmware = "local_firmware.bin" local firmware_type = "bin" local firmware_subtype = "" if targettype:match("x86") then local_firmware = "local_firmware.img.gz" firmware_type = "gz" end -- handle TPLink and Mikrotik exception conditions local mfg = aredn.hardware.get_manufacturer() local mfgprefix = "" if mfg:match("[Uu]biquiti") then mfgprefix = "ubnt" elseif mfg:match("[Mm]ikro[Tt]ik") then mfgprefix = "mikrotik" elseif mfg:match("[Tt][Pp]-[Ll]ink") then mfgprefix = "cpe" end -- Handle Mikrotik v7 bootloader if mfgprefix == "mikrotik" then local f = io.open("/sys/firmware/mikrotik/soft_config/bios_version") if f then local booter_version = f:read("*a") f:close(); if booter_version:match("^7%.") then firmware_subtype = "v7" end end end -- refresh fw if parms.button_refresh_fw then nixio.fs.remove("/tmp/web/firmware.list") if get_default_gw() ~= "none" or serverpaths[1]:match("%.local%.mesh") then fwout("Downloading firmware list from " .. serverpaths[1] .. "...") local config_versions local config_serverpath for _, serverpath in ipairs(serverpaths) do config_serverpath = serverpath .. "/afs/www/" for line in io.popen(curl .. " -o - " .. config_serverpath .. "config.js 2> /dev/null"):lines() do local v = line:match("versions: {(.+)}") if v then config_versions = v break end end if config_versions then break end end if not config_versions then fwout("Failed to load firmware versions") else local firmware_versions = {} for k, v in config_versions:gmatch("'([^']+)': '([^']+)'") do firmware_versions[k] = v end local board_type = aredn.hardware.get_board_type():gsub(",", "_") local firmware_list = {} for attempt = 1, 3 do for ver, data in pairs(firmware_versions) do local raw = io.popen(curl .. " -o - " .. config_serverpath .. data .. "/overview.json 2> /dev/null") local info = luci.jsonc.parse(raw:read("*a") or "") raw:close() if info then firmware_versions[ver] = nil for _, profile in ipairs(info.profiles) do if profile.id == board_type or ((board_type == "qemu" or board_type == "vmware") and profile.id == "generic" and profile.target == "x86/64") then firmware_list[ver] = { overview = config_serverpath .. data .. "/" .. profile.target .. "/" .. profile.id .. ".json", target = info.image_url:gsub("{target}", profile.target) } break end end end end end local f = io.open("/tmp/web/firmware.list", "w") if f then f:write(luci.jsonc.stringify(firmware_list, true)) f:close() end local err = false for _, _ in pairs(firmware_versions) do err = true end if err then fwout("Failed to load all firmware versions. List is incomplete") else fwout("Done") end end else fwout("Error: no route to Host") end end -- generate data structures -- and set fw_version --firmware_list_gen() if nixio.fs.stat("/etc/mesh-release") then for line in io.lines("/etc/mesh-release") do fw_version = line:chomp() break end end local f = io.open("/tmp/web/firmware.list") if f then fw_versions = luci.jsonc.parse(f:read("*a") or "") f:close() if fw_versions then for v, d in pairs(fw_versions) do fw_names[#fw_names + 1] = v end -- Sort in version number order (newest at the top) but with nightlies at the bottom table.sort(fw_names, function (a, b) if a:match("%-") then return false elseif b:match("%-") then return true end local ai = a:gmatch("(%d+)") local bi = b:gmatch("(%d+)") while true do local va = tonumber(ai() or nil) local vb = tonumber(bi() or nil) if not va then return false elseif not vb then return true elseif va < vb then return false elseif va > vb then return true end end return false end) end end -- sideload fw if parms.button_apply_fw and nixio.fs.stat("/tmp/web/" .. local_firmware) then nixio.fs.mkdir("/tmp/web/upload") os.execute("mv -f /tmp/web/" .. local_firmware .. " /tmp/web/upload/file") if firmware_type == "gz" then firmfile = "arednmesh-sideload-ext4-combined.img.gz" else firmfile = "arednmesh-sideload-sysupgrade.bin" end parms.button_ul_fw = "Upload" os.execute("/usr/local/bin/upgrade_prepare.sh stop > /dev/null 2>&1") end -- upload fw if parms.button_ul_fw and nixio.fs.stat("/tmp/web/upload/file") then os.execute("mv -f /tmp/web/upload/file " .. tmpdir .. "/firmware") if firmware_type == "bin" and (firmfile:match("sysupgrade%.bin$") or firmfile:match("sysupgrade%-v7%.bin$")) then -- full firmware fw_install = true -- drop the page cache to take pressure off tmps when checking the firmware write_all("/proc/sys/vm/drop_caches", "3") -- check firmware header local fcheck = capture("/usr/local/bin/firmwarecheck.sh " .. tmpdir .. "/firmware") if fcheck ~= "" then fwout("Firmware CANNOT be updated") fwout("firmware file is not valid: " .. fcheck) fw_install = false nixio.fs.remove(tmpdir .. "/firmware") if os.execute("/usr/local/bin/uploadctlservices restore > /dev/null 2>&1") ~= 0 then fwout("Failed to restart all services, please reboot this node.") end end elseif firmware_type == "gz" and firmfile:match("ext4%-combined%.img%.gz$") then -- full x86 firmware fw_install = true else fwout("Firmware CANNOT be updated") fwout("the uploaded file is not recognized") nixio.fs.remove(tmpdir .. "/firmware") os.execute("/usr/local/bin/upgrade_prepare.sh restore > /dev/null 2>&1") if os.execute("/usr/local/bin/uploadctlservices restore > /dev/null 2>&1") ~= 0 then fwout("Failed to restart all services, please reboot this node.") end end end -- download fw if parms.button_dl_fw and parms.dl_fw ~= "default" then if get_default_gw() ~= "none" or serverpaths[1]:match("%.local%.mesh") then nixio.fs.remove(tmpdir .. "/firmware") os.execute("/usr/local/bin/uploadctlservices update > /dev/null 2>&1") fw_install = false local err local f = io.popen(curl .. " -o - " .. fw_versions[parms.dl_fw].overview .. " 2> /dev/null") local fwinfo = luci.jsonc.parse(f:read("*a") or "") f:close() if fwinfo then local fwimage for _, image in ipairs(fwinfo.images) do if (firmware_type == "bin" and firmware_subtype == "" and image.type == "sysupgrade") or (firmware_type == "bin" and firmware_subtype == "" and image.type == "nand-sysupgrade") or (firmware_type == "bin" and firmware_subtype == "v7" and image.type == "sysupgrade-v7") or (firmware_type == "gz" and image.type == "combined") then fwimage = { url = fw_versions[parms.dl_fw].target .. "/" .. image.name, sha = image.sha256 } break end end if fwimage then -- We shutdown dnsmasq to save memory, so we resolve the URL's IP address before doing that if we can local url = fwimage.url local host = url:match("^https?://([^/:]+)") local headers = "" if host then local ip = iplookup(host) if ip then url = url:gsub(host:gsub("%-","%%-"):gsub("%.","%%."), ip) headers = "-H 'Host: " .. host .. "' " os.execute("/usr/local/bin/upgrade_prepare.sh stop > /dev/null 2>&1") end end if os.execute(curl .. "-o " .. tmpdir .. "/firmware " .. headers .. url .. " > /dev/null 2>>" .. tmpdir .. "/curl.err") ~= 0 then err = "Download failed!\n" .. read_all(tmpdir .. "/curl.err") else local sha = capture("sha256sum " .. tmpdir .. "/firmware"):match("^(%S+)") if sha ~= fwimage.sha then err = "firmware file checksum failed" else fw_install = true end end else err = "upgrade is not available" end else err = "the downloaded file cannot be found" end nixio.fs.remove(tmpdir .. "/curl.err") if err then fwout("Firmware CANNOT be updated") fwout(err) os.execute("/usr/local/bin/upgrade_prepare.sh restore > /dev/null 2>&1") if os.execute("/usr/local/bin/uploadctlservices restore > /dev/null 2>&1") ~= 0 then fwout("Failed to restart all services, please reboot this node.") end nixio.fs.remove(tmpdir .. "/firmware") end else fwout("Error: no route to Host") nixio.fs.remove(tmpdir .. "/curl.err") end end -- install fw if fw_install and nixio.fs.stat(tmpdir .. "/firmware") then http_header() html.print("") html.print("") html.print("") html.print("FIRMWARE UPDATE IN PROGRESS") html.print("") html.print("") html.print("") html.print("") if not nixio.fs.readlink("/tmp/web/style.css") then if not nixio.fs.stat("/tmp/web") then nixio.fs.mkdir("/tmp/web") end nixio.fs.symlink("/www/aredn.css", "/tmp/web/style.css") end -- Show a different reload address if we're running on the ramdisk local displayaddress = "192.168.1.1" local waitaddress = displayaddress if parms.checkbox_keep_settings then for line in io.lines("/proc/mounts") do if line:match("overlay") or line:match("ext4") then displayaddress = node .. ".local.mesh" waitaddress = nil break end end end html.print("") html.wait_for_reboot(120, 300, waitaddress) html.print("") html.print("
") html.print("

The firmware is being updated.

") html.print("

DO NOT REMOVE POWER UNTIL UPDATE IS FINISHED

") html.print("

") local upgradecmd = nil if parms.checkbox_keep_settings then local fin = io.open("/etc/arednsysupgrade.conf", "r") if fin then local fout = io.open("/tmp/sysupgradefilelist", "w") if fout then for line in fin:lines() do if not line:match("^#") and nixio.fs.stat(line) then fout:write(line .. "\n") end end fout:close() fin:close() aredn.info.set_nvram("nodeupgraded", "1") if os.execute("tar -czf /tmp/arednsysupgradebackup.tgz -T /tmp/sysupgradefilelist > /dev/null 2>&1") ~= 0 then html.print([[

ERROR: Could not backup filesystem.

An error occured trying to backup the file system. Node will now reboot.

]]) html.footer() html.print("") http_footer() aredn.info.set_nvram("nodeupgraded", "0") os.execute("reboot >/dev/null 2>&1") else print_firmware_notice("twice while the configuration is applied", displayaddress, displayaddress) http_footer() nixio.fs.remove("/tmp/sysupgradefilelist") upgradecmd = "/usr/local/bin/aredn_sysupgrade -f /tmp/arednsysupgradebackup.tgz -q " .. tmpdir .. "/firmware 2>&1 &" end else fin:close() html.footer() html.print("") http_footer() end else html.print([[

ERROR: Failed to create backup.

An error occured trying to backup the file system. Node will now reboot.

]]) html.footer() html.print("") http_footer() os.execute("reboot >/dev/null 2>&1") end else print_firmware_notice("after the firmware has been written to flash memory", "localnode.local.mesh", "192.168.1.1") http_footer() upgradecmd = "/usr/local/bin/aredn_sysupgrade -q -n " .. tmpdir .. "/firmware 2>&1 &" end if upgradecmd then os.execute(upgradecmd) end os.exit() end -- handle package actions local pkg_output = {} function pkgout(msg) pkg_output[#pkg_output + 1] = msg end local permpkg = {} if nixio.fs.stat("/etc/permpkg") then for line in io.lines("/etc/permpkg") do if not line:match("^#") then permpkg[line] = true end end end local meshpkgs = capture("grep -q \".local.mesh\" /etc/opkg/distfeeds.conf"):chomp() function update_advertised_services() local script = "/etc/cron.hourly/check-services" local stat = nixio.fs.stat(script) if stat.type == "reg" and nixio.bit.band(tonumber(stat.modedec, 8), tonumber(111, 8)) ~= 0 then os.execute("(cd /tmp;" .. script .. " 2>&1 | logger -p daemon.debug -t " .. script .. ")&") end end local package_store = "/etc/package_store" local package_catalog = package_store .. "/catalog.json" function record_package(op, ipkg, file) nixio.fs.mkdir(package_store) local catalog = luci.jsonc.parse(read_all(package_catalog) or '{"installed":{}}') if op == "upload" then local package = ipkg:match("^([^_]+)") if package then os.execute("/bin/cp -f " .. file .. " " .. package_store .. "/" .. package .. ".ipk") catalog.installed[package] = "local" end elseif op == "download" then local result = capture("opkg status " .. ipkg .. " 2>&1") local package = result:match("Package: (%S+)") if package then catalog.installed[package] = "global" end elseif op == "remove" then nixio.fs.remove(package_store .. "/" .. ipkg .. ".ipk") catalog.installed[ipkg] = nil end os.remove(package_catalog) for _,_ in pairs(catalog.installed) do write_all(package_catalog, luci.jsonc.stringify(catalog)) break end end -- upload package if parms.button_ul_pkg and nixio.fs.stat("/tmp/web/upload/file") then os.execute("mv -f /tmp/web/upload/file /tmp/web/upload/newpkg.ipk") os.execute("/usr/local/bin/uploadctlservices opkginstall > /dev/null 2>&1") local result = capture("opkg -force-overwrite install /tmp/web/upload/newpkg.ipk 2>&1") if result:match("satisfy_dependencies_for:") or result:match("cannot find dependency") then -- dependency failure - silently update dependencies and try again os.execute("opkg update > /dev/null 2>&1") os.execute("opkg list | grep -v '^ ' | cut -f1,3 -d' ' | gzip -c > /etc/opkg.list.gz") result = capture("opkg -force-overwrite install /tmp/web/upload/newpkg.ipk 2>&1") end pkgout(result) record_package("upload", firmfile, "/tmp/web/upload/newpkg.ipk") os.execute("rm -rf /tmp/opkg-*") nixio.fs.remove("/tmp/web/upload/newpkg.ipk") if os.execute("/usr/local/bin/uploadctlservices restore > /dev/null 2>&1") ~= 0 then pkgout("Failed to restart all services, please reboot this node.") else update_advertised_services() end end -- download package if parms.button_dl_pkg and parms.dl_pkg ~= "default" then if get_default_gw() ~= "none" or meshpkgs ~= "" then os.execute("/usr/local/bin/uploadctlservices opkginstall > /dev/null 2>&1") local result = capture("opkg -force-overwrite install " .. parms.dl_pkg .. " 2>&1") if result:match("satisfy_dependencies_for:") or result:match("cannot find dependency") then -- dependency failure - silently update dependencies and try again os.execute("opkg update > /dev/null 2>&1") os.execute("opkg list | grep -v '^ ' | cut -f1,3 -d' ' | gzip -c > /etc/opkg.list.gz") result = capture("opkg -force-overwrite install " .. parms.dl_pkg .. " 2>&1") end pkgout(result) record_package("download", parms.dl_pkg) if os.execute("/usr/local/bin/uploadctlservices restore > /dev/null 2>&1") ~= 0 then pkgout("Failed to restart all services, please reboot this node.") else update_advertised_services() end else pkgout("Error: no route to Host") end end -- refresh package list if parms.button_refresh_pkg then if get_default_gw() ~= "none" or meshpkgs ~= "" then pkgout(capture("opkg update 2>&1")) os.execute("opkg list | grep -v '^ ' | cut -f1,3 -d' ' | gzip -c > /etc/opkg.list.gz") else pkgout("Error: no route to Host") end end -- remove package if parms.button_rm_pkg and parms.rm_pkg ~= "default" and not permpkg[parms.rm_pkg] then local output = capture("opkg remove " .. parms.rm_pkg .. " 2>&1") pkgout(output) if not output:match("No packages removed") then pkgout("Package removed successfully") update_advertised_services() record_package("remove", parms.rm_pkg) end end -- generate support data file if parms.button_support_data then os.execute("/www/cgi-bin/supporttool") end -- generate data structures local pkgs = {} local pkgver = {} local f = io.popen("opkg list_installed | cut -f1,3 -d' '") if f then for line in f:lines() do local pkg, ver = line:match("(.+)%s(.+)") if ver then pkgs[#pkgs + 1] = pkg pkgver[pkg] = ver end end f:close() end local dl_pkgs = {} local dlpkgver = {} if nixio.fs.stat("/etc/opkg.list.gz") then local f = io.popen("zcat /etc/opkg.list.gz") if f then for line in f:lines() do local pkg, ver = line:match("(.+)%s(.+)") if ver and not (pkgver[pkg] and pkgver[pkg] == ver) then dl_pkgs[#dl_pkgs + 1] = pkg dlpkgver[pkg] = ver end end f:close() end end -- handle ssh key actions local key_output = {} function keyout(msg) key_output[#key_output + 1] = msg end local keyfile = "/etc/dropbear/authorized_keys" -- upload key if parms.button_ul_key and nixio.fs.stat("/tmp/web/upload/file") then local count = 0 if nixio.fs.stat(keyfile) then for _ in io.lines(keyfile) do count = count + 1 end end os.execute("grep ^ssh- /tmp/web/upload/file >> " .. keyfile) local count = 0 for _ in io.lines(keyfile) do count = count - 1 end if count == 0 then keyout("Error: file does not appear to be an ssh key file") keyout("Authorized keys not changed.") else keyout("Key installed.") end nixio.fs.remove("/tmp/web/upload/file") if os.execute("/usr/local/bin/uploadctlservices restore > /dev/null 2>&1") ~= 0 then keyout("Failed to restart all services, please reboot this node.") end end -- remove key if parms.button_rm_key and parms.rm_key ~= "default" and nixio.fs.stat(keyfile) then local count = 0 for _ in io.lines(keyfile) do count = count + 1 end os.execute("grep -v '" .. parms.rm_key .. "' " .. keyfile .. " > " .. tmpdir .. "/keys") os.execute("mv -f " .. tmpdir .. "/keys " .. keyfile) for _ in io.lines(keyfile) do count = count - 1 end if count == 0 then keyout("Error: authorized keys were not changed.") else keyout("Key " .. parms.rm_key .. " removed.") end end -- generate data structures local keys = {} local f = io.open(tmpdir .. "/newkeys", "w") if f then if nixio.fs.stat(keyfile) then for line in io.lines(keyfile) do local type, key, who, extra = line:match("(%S+)%s+(%S+)%s+(%S+)(.*)") if extra == "" and who:match(".@.") and type:match("^ssh-") then keys[#keys + 1] = who f:write(type .. " " .. key .. " " .. who .. "\n") end end end f:close() end -- sanitize the key file if nixio.fs.stat(keyfile) and os.execute("diff " .. keyfile .. " " .. tmpdir .. "/newkeys >/dev/null 2>&1") ~= 0 then os.execute("mv -f " .. tmpdir .. "/newkeys " .. keyfile) keyout("Info: key file sanitized.") end remove_all("/tmp/web/upload") remove_all(tmpdir) -- generate the page http_header(true) html.header(node .. " administration", true) html.print("
") html.alert_banner() html.print("
") -- nav html.navbar_admin("admin") html.print("") html.print("") html.print("") html.print("
Help  ") html.print("") html.print("
") html.print("") -- firmware html.print("") html.print("") -- packages html.print("") html.print("") -- ssh keys html.print("") html.print("") html.print("") html.print("") html.print("") html.print("
") html.print("") html.print("") if #fw_output > 0 then html.print("") end html.print("") html.print("") if nixio.fs.stat("/tmp/force-upgrade-this-is-dangerous") then html.print("") end html.print("") html.print("") html.print("") html.print("") html.print("") html.print("") html.print("") html.print("") html.print("") html.print("") html.print("") html.print("") html.print("") html.print("
Firmware Update
") html.print("
" .. word_wrap(80, fw_output) .. "
") html.print("
Current Version: " .. fw_version .. "      Hardware Type: (" .. targettype .. (firmware_subtype ~= "" and "-" .. firmware_subtype or "") .. ") " .. mfgprefix .. " (" .. hardwaretype .. ")
Keep Existing Configuration Settings
  ALL FIRMWARE UPGRADE COMPATIBILITY CHECKS HAVE BEEN DISABLED  
Upload Firmware
Download Firmware") html.print("") html.print("
Load Local Firmware") html.print("") html.print(" /tmp/web/" .. local_firmware .. "
") html.print("") html.print("") -- low memory warning if isLowMemNode() then html.print("") end if #pkg_output > 0 then -- opkg can produce duplicate first lines, remove them here while pkg_output[2] and pkg_output[1] == pkg_output[2] do pkg_output:remove(1) end html.print("") end html.print("") html.print("") html.print("") html.print("") html.print("") html.print("") html.print("") html.print("") html.print("") html.print("") html.print("") html.print("") html.print("") html.print("") html.print("
Package Management
  Recommend not to install extra packages due to low memory on this node  
")
    html.print(word_wrap(80, pkg_output))
    html.print("
Upload Package ") html.print("
Download Package") html.print("") html.print("
Remove Package
") html.print("") html.print("") if #key_output > 0 then html.print("") end html.print("") html.print("") html.print("") html.print("") html.print("") html.print("") html.print("") html.print("") html.print("") html.print("
Authorized SSH Keys
")
    html.print(word_wrap(80, key_output))
    html.print("
Upload Key
Remove Key") html.print("
Support Data
") html.print("
") html.print("
") html.print("
") html.footer() html.print("") html.print("") http_footer()