#!/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(TM) 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") aredn.html = require("aredn.html") require("uci") aredn.info = require("aredn.info") require("ubus") require("luci.sys") local html = aredn.html local cursor = uci.cursor() local conn = ubus.connect() -- handle firmware updates local fw_install = false local patch_install = false local fw_output = {} local fw_images = {} local fw_md5 = {} local fw_version = "" function fwout(msg) fw_output[#fw_output + 1] = msg end function 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 if nixio.fs.stat("/tmp/web/firmware.list") then for line in io.lines("/tmp/web/firmware.list") do local md5, fw, tag = line:match("^(%S+) (%S+) (.*)") if tag and tag ~= "none" and (tag == "all" or fw_version:match(tag)) then fw_images[#fw_images + 1] = fw fw_md5[fw] = md5 end end end 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 reboot() local node = aredn.info.get_nvram("node") if node == "" then node = "Node" end local lanip, _, lanmask = aredn.hardware.get_interface_ip4(aredn.hardware.get_iface_name("lan")) local browser = os.getenv("REMOTE_ADDR") local browser6 = browser:match("::ffff:([%d%.]+)") if browser6 then browser = browser6 end local fromlan = false local subnet_change = false if lanip then fromlan = validate_same_subnet(browser, lanip, lanmask) if fromlan then lanmask = ip_to_decimal(lanmask) local cfgip = cursor:get("network", "lan", "ipaddr") local cfgmask = ip_to_decimal(cursor:get("network", "lan", "netmask")) if lanmask ~= cfgmask or nixio.bit.band(ip_to_decimal(lanip), lanmask) ~= nixio.bit.band(ip_to_decimal(cfgip), cfgmask) then subnet_change = true end end end http_header() if fromlan and subnet_change then html.header(node .. " rebooting", true) html.print("
") html.print("

" .. node .. " is rebooting


") html.print("

The LAN subnet has changed. You will need to acquire a new DHCP lease
") html.print("and reset any name service caches you may be using.


") html.print("

When the node reboots you get your new DHCP lease and reconnect with
") html.print("http://localnode.local.mesh:8080/
or
") html.print("http://" .. node .. ".local.mesh:8080/

") else html.header(node .. " rebooting", false) html.print("") html.print("
") html.print("

" .. node .. " is rebooting


") html.print("

Your browser should return to this node in 60 seconds.

") html.print("If something goes astray you can try to connect with

") html.print("http://localnode.local.mesh:8080/
") if node ~= "Node" then html.print("or
http://" .. node .. ".local.mesh:8080/

") end end html.print("
") http_footer() luci.sys.reboot() os.exit() 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 -- read_postdata local parms = {} local firmfile = "" if os.getenv("REQUEST_METHOD") == "POST" then require('luci.http') local request = luci.http.Request(luci.sys.getenv(), function() local v = io.read(1024) if not v then io.close() end return v end ) -- only allow file uploading without active tunnels if not active_tunnel then 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$") then -- Uploading a system upgrade - clear out memory early os.execute("/usr/local/bin/upgrade_prepare.sh > /dev/null 2>&1") end end 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 ) end parms = request:formvalue() end if parms.button_reboot then 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 wget command options local wget = "wget -U 'node: " .. node .. "' " local serverpaths = {} local uciserverpath = cursor:get("aredn", "@downloads[0]", "firmwarepath") if not uciserverpath then uciserverpath = "" end serverpaths[#serverpaths + 1] = uciserverpath local hardwaretype = aredn.hardware.get_type() local targettype = conn:call("system", "board", {}).release.target -- 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 local hardwaretypev if hardwaretype == "nanostation-m" then hardwaretypev = "nano-m" -- Nano XM elseif hardwaretype == "nanostation-m-xw" then hardwaretypev = "nano-m-xw" -- Nano XW elseif hardwaretype == "rb-952ui-5ac2nd" then hardwaretypev = "rb-nor-flash-16M-ac" -- hAP AC Lite elseif hardwaretype:match("rb-911g-[25]hpnd") or hardwaretype:match("rb-912uag-[25]hpnd") then hardwaretypev = "nand-large" -- Basebox 2/5 and QRT 2/5 elseif hardwaretype:match("rb-l[dfhg]+-[25]nd") or hardwaretype:match("rb-lhg-[25]hpnd") then hardwaretypev = "rb-nor-flash-16M" -- LHGs & LDFs elseif mfgprefix == "cpe" then local hwmodel = aredn.hardware.get_board_id() if hwmodel:match("CPE210 v1%.[01]") then hardwaretypev = "210-220-v1" -- v1.0/v1.1 elseif hwmodel:match("CPE210 v2%.0") then hardwaretypev = "210-v2" -- v2.0 elseif hwmodel:match("CPE210 v3%.0") then hardwaretypev = "210-v3" -- v3.0 elseif hwmodel:match("CPE220 v2%.0") then hardwaretypev = "220-v2" -- v3.0 elseif hwmodel:match("CPE220 v3%.0") then hardwaretypev = "220-v3" -- v3.0 elseif hwmodel:match("CPE510 v2%.0") then hardwaretypev = "510-v2" -- v2.0 elseif hwmodel:match("CPE510 v3%.0") then hardwaretypev = "510-v3" -- v3.0 elseif hwmodel:match("CPE510") then hardwaretypev = "510-520-v1" -- CPE510 V1.0/v1.1 elseif hwmodel:match("CPE610 v2%.0") then hardwaretypev = "610-v2" -- CPE610 V2.0 elseif hwmodel:match("CPE610") then hardwaretypev = "610-v1" -- CPE610 V1.0 elseif hwmodel:match("WBS510 v2%.0") then mfgprefix="wbs" hardwaretypev = "510-v2" -- WBS510 v2.0 elseif hwmodel:match("WBS210 v1%.[012]") then mfgprefix="wbs" hardwaretypev = "210-v1" -- WBS210 v1.0/v1.1 end else hardwaretypev = hardwaretype end -- refresh fw if parms.button_refresh_fw then nixio.fs.remove("/tmp/web/firmware.list") if get_default_gw() ~= "none" or uciserverpath:match("%.local%.mesh") then fwout("Downloading firmware list from " .. uciserverpath .. "...") local ok = false for _, serverpath in ipairs(serverpaths) do if os.execute(wget .. "-O /tmp/web/firmware.list " .. serverpath .. "/firmware." .. hardwaretype .. ".list >/dev/null 2>>" .. tmpdir .. "/wget.err") == 0 then ok = true break end end if ok then fwout("Done") else fwout(read_all(tmpdir .. "/wget.err")) end nixio.fs.remove(tmpdir .. "/wget.err") else fwout("Error: no route to Host") end end -- generate data structures -- and set fw_version firmware_list_gen() -- sideload fw if parms.button_apply_fw and nixio.fs.stat("/tmp/web/local_firmware.bin") then nixio.fs.mkdir("/tmp/web/upload") os.execute("mv -f /tmp/web/local_firmware.bin /tmp/web/upload/file") firmfile = "arednmesh-sideload-sysupgrade.bin" parms.button_ul_fw = "Upload" os.execute("/usr/local/bin/upgrade_prepare.sh > /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 firmfile:match("sysupgrade%.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 if os.execute("/usr/local/bin/firmwarecheck.sh " .. tmpdir .. "/firmware") ~= 0 then fwout("Firmware CANNOT be updated") fwout("firmware file is not valid") 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 firmfile:match("^patch%S+%.tgz$") then -- firmware patch patch_install = false else fwout("Firmware CANNOT be updated") fwout("the uploaded file is not recognized") 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 end -- download fw if parms.button_dl_fw and parms.dl_fw ~= "default" then if get_default_gw() ~= "none" or uciserverpath:match("%.local%.mesh") then nixio.fs.remove(tmpdir .. "/firmware") os.execute("/usr/local/bin/uploadctlservices update > /dev/null 2>&1") if parms.dl_fw:match("sysupgrade%.bin$") then -- Downloading a system upgrade - clear out memory early os.execute("/usr/local/bin/upgrade_prepare.sh > /dev/null 2>&1") end local ok = false for _, serverpath in ipairs(serverpaths) do if os.execute(wget .. "-O " .. tmpdir .. "/firmware " .. serverpath .. "/" .. parms.dl_fw .. " >/dev/null 2>>" .. tmpdir .. "/wget.err") == 0 then ok = true break end end if parms.dl_fw:match("sysupgrade%.bin$") then -- full firmware fw_install = true if not ok then fwout("Downloading firmware image...") fwout(read_all(tmpdir .. "/wget.err")) end nixio.fs.remove(tmpdir .. "/wget.err") -- check md5sum local fw = parms.dl_fw if os.execute("echo '" .. (fw_md5[fw] or "error") .. " " .. tmpdir .. "/firmware' | md5sum -cs") ~= 0 then fwout("Firmware CANNOT be updated") fwout("firmware file is not valid") 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.") end fwout("Please reboot this node.") end elseif parms.dl_fw:match("^patch%S+%.tgz$") then -- firmware patch patch_install = true if not ok then fwout("Downloading patch file...") fwout(read_all(tmpdir .. "/wget.err")) end nixio.fs.remove(tmpdir .. "/wget.err") -- check md5sum local fw = parms.dl_fw if os.execute("echo '" .. (fw_md5[fw] or "error") .. " firmware' | md5sum -cs") ~= 0 then fwout("Firmware CANNOT be updated") fwout("patch file is not valid") patch_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 else fwout("Firmware CANNOT be updated") fwout("the downloaded file is not recognized") 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 else fwout("Error: no route to Host") nixio.fs.remove(tmpdir .. "/wget.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 html.print("") html.print("") 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") luci.sys.reboot() else 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 twice while the configuration is applied
When the node has finished booting you should ensure your computer has
received a new IP address and reconnect with
http://]] .. node .. [[.local.mesh:8080/
(This page will automatically reload in 3 minutes)

]]) http_footer() nixio.fs.remove("/tmp/sysupgradefilelist") upgradecmd = "/sbin/sysupgrade -f /tmp/arednsysupgradebackup.tgz -q " .. tmpdir .. "/firmware 2>&1 &" end else fin:close() 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() luci.sys.reboot() end else 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 after the firmware has been written to flash memory
When the node has finished booting you should ensure your computer has
received a new IP address and reconnect with
http://192.168.1.1:8080/
and continue setup of the node in firstboot state.
(This page will automatically reload in 3 minutes)

]]) http_footer() upgradecmd = "/sbin/sysupgrade -q -n " .. tmpdir .. "/firmware 2>&1 &" end if upgradecmd then os.execute(upgradecmd) end os.exit() end -- install patch if patch_install and nixio.fs.stat(tmpdir .. "/firmware") then fwout("Error: Installing firmware patches no longer supported") 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 -- 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:") 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) 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:") 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) 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() end 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() html.header(node .. " administration", false) html.print([[
]]) html.alert_banner() html.print("
") html.print("") html.print("") html.print("
") -- nav html.print("
") html.print("") html.print("") html.print("") html.print("") html.print("") html.print("") html.print("") html.print("
Node StatusBasic SetupPort Forwarding,
DHCP, and Services
Tunnel
Server
Tunnel
Client
Advanced
Configuration

") 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("") 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 .. ") " .. mfgprefix .. " (" .. hardwaretype .. ")
Keep Existing Configuration Settings
Upload Firmware
Download Firmware") html.print("") html.print("
Load Local Firmware") html.print("") html.print(" /tmp/web/local_firmware.bin
") html.print("") html.print("") 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
")
    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
Download Support Data
") html.print("
") html.print("
") html.print("
") html.footer() html.print("") html.print("") http_footer()