diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index d079b2352ceb9dc8558e2645be8933e5a2c9abab..a41e1dbcf67bbf9df529e58c2beaa05dbae4fa2e 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -10,7 +10,7 @@ jobs:
     steps:
       - uses: actions/checkout@v1
       - name: Install Dependencies
-        run: sudo apt install lua-check
+        run: sudo apt-get -y update && sudo apt-get -y install lua-check
       - name: Install example site
         run: ln -s ./docs/site-example ./site
       - name: Lint Lua code
@@ -22,7 +22,7 @@ jobs:
     steps:
       - uses: actions/checkout@v1
       - name: Install Dependencies
-        run: sudo apt install shellcheck
+        run: sudo apt-get -y update && sudo apt-get -y install shellcheck
       - name: Install example site
         run: ln -s ./docs/site-example ./site
       - name: Lint shell code
diff --git a/Makefile b/Makefile
index 4ff9f0c6007de2e531133ce1c4aebf733a8f41d2..68179393b4f1c7e741f169387c389c1fd4a1ac8c 100644
--- a/Makefile
+++ b/Makefile
@@ -19,8 +19,9 @@ escape = '$(subst ','\'',$(1))'
 GLUON_SITEDIR ?= site
 $(eval $(call mkabspath,GLUON_SITEDIR))
 
-$(GLUON_SITEDIR)/site.mk:
-	$(error No site configuration was found. Please check out a site configuration to $(GLUON_SITEDIR))
+ifeq ($(realpath $(GLUON_SITEDIR)/site.mk),)
+$(error No site configuration was found. Please check out a site configuration to $(GLUON_SITEDIR))
+endif
 
 include $(GLUON_SITEDIR)/site.mk
 
@@ -176,6 +177,10 @@ config: $(LUA) FORCE
 	$(GLUON_ENV) $(LUA) scripts/target_config_check.lua
 
 
+container: FORCE
+	@scripts/container.sh
+
+
 all: config
 	+@
 	$(GLUON_ENV) $(LUA) scripts/clean_output.lua
diff --git a/contrib/Dockerfile b/contrib/docker/Dockerfile
similarity index 100%
rename from contrib/Dockerfile
rename to contrib/docker/Dockerfile
diff --git a/docs/user/getting_started.rst b/docs/user/getting_started.rst
index 4d2067323f5fd06641fdafda10ac0d588a4e2794..7fb7b37efe038c49fd7b923d72e71b743289e036 100644
--- a/docs/user/getting_started.rst
+++ b/docs/user/getting_started.rst
@@ -40,6 +40,12 @@ freshly installed Debian Stretch system the following packages are required:
 * `time` (built-in `time` doesn't work)
 
 
+We also provide a container environment that already tracks all these dependencies. It quickly gets you up and running, if you already have either Docker or Podman installed locally.
+
+::
+
+   ./scripts/container.sh
+
 Building the images
 -------------------
 
diff --git a/docs/user/site.rst b/docs/user/site.rst
index 408cf4553c5190bd6ffe21c09dec8ddf60977391..31fb2653597a7cb7f9696c569737f9925138469a 100644
--- a/docs/user/site.rst
+++ b/docs/user/site.rst
@@ -478,7 +478,7 @@ config_mode \: optional
 
     *openlayers_url* allows to override the base URL of the
     *build/ol.js* and *css/ol.css* files (the default is
-    ``https://cdn.rawgit.com/openlayers/openlayers.github.io/master/en/v5.2.0``).
+    ``https://cdn.jsdelivr.net/gh/openlayers/openlayers.github.io@35ffe7626ce16c372143f3c903950750075e7068/en/v5.3.0``).
     It is also possible to replace the default tile layer (which is OpenStreetMap)
     with a custom one using the *tile_layer* section. Only XYZ layers are supported
     at this point.
diff --git a/modules b/modules
index 21929b6e61b8ba616dc1422b59b93778b895f1bd..c94649ce84e207da77d4c164d4faf82d06b2ce54 100644
--- a/modules
+++ b/modules
@@ -2,7 +2,7 @@ GLUON_FEEDS='packages routing gluon'
 
 OPENWRT_REPO=https://github.com/openwrt/openwrt.git
 OPENWRT_BRANCH=openwrt-19.07
-OPENWRT_COMMIT=ffd4452f8b241d1d5b5ea8a56206f51702bbd6c5
+OPENWRT_COMMIT=81d0b4a9f431b2b2ca71edca91febedde98994a3
 
 PACKAGES_PACKAGES_REPO=https://github.com/openwrt/packages.git
 PACKAGES_PACKAGES_BRANCH=openwrt-19.07
@@ -13,4 +13,5 @@ PACKAGES_ROUTING_BRANCH=openwrt-19.07
 PACKAGES_ROUTING_COMMIT=101632e153b41238bc19dfd96ba2d23339dbcb76
 
 PACKAGES_GLUON_REPO=https://github.com/freifunk-gluon/packages.git
-PACKAGES_GLUON_COMMIT=825aa0c093d6c0b4f81a95cd2320331a5b5adae6
+PACKAGES_GLUON_BRANCH=v2021.1.x
+PACKAGES_GLUON_COMMIT=015408e702a5843310e40c2ca664e1903b601204
diff --git a/package/gluon-config-mode-core/luasrc/lib/gluon/config-mode/model/gluon-config-mode/wizard.lua b/package/gluon-config-mode-core/luasrc/lib/gluon/config-mode/model/gluon-config-mode/wizard.lua
index dfc4ab4c046826a480ff60b502d289ad50113fca..edfe0bc3a82278695bc5fb44bb401cdd72c3f7fc 100644
--- a/package/gluon-config-mode-core/luasrc/lib/gluon/config-mode/model/gluon-config-mode/wizard.lua
+++ b/package/gluon-config-mode-core/luasrc/lib/gluon/config-mode/model/gluon-config-mode/wizard.lua
@@ -22,7 +22,7 @@ function f:write()
 	uci:set("gluon-setup-mode", uci:get_first("gluon-setup-mode", "setup_mode"), "configured", true)
 	uci:save("gluon-setup-mode")
 
-	os.execute('gluon-reconfigure')
+	os.execute('exec gluon-reconfigure >/dev/null')
 
 	f.template = "wizard/reboot"
 	f.package = "gluon-config-mode-core"
diff --git a/package/gluon-neighbour-info/src/gluon-neighbour-info.c b/package/gluon-neighbour-info/src/gluon-neighbour-info.c
index 6470508ccaaddc4eec283d59a782cc01ecd64d16..119aaddc2a6040a1a8136fd465219b1a31f5e7ea 100644
--- a/package/gluon-neighbour-info/src/gluon-neighbour-info.c
+++ b/package/gluon-neighbour-info/src/gluon-neighbour-info.c
@@ -69,8 +69,23 @@ void tv_subtract (struct timeval *r, const struct timeval *a, const struct timev
 	}
 }
 
-ssize_t recvtimeout(int socket, void *buffer, size_t length, int flags, const struct timeval *timeout) {
+void resize_recvbuffer(char **recvbuffer, size_t *recvbuffer_len, size_t recvlen)
+{
+	free(*recvbuffer);
+	*recvbuffer = malloc(recvlen);
+
+	if (!(*recvbuffer)) {
+		perror("Could not resize recvbuffer");
+		exit(EXIT_FAILURE);
+	}
+
+	*recvbuffer_len = recvlen;
+}
+
+ssize_t recvtimeout(int socket, char **recvbuffer, size_t *recvbuffer_len,
+		    const struct timeval *timeout) {
 	struct timeval now, timeout_left;
+	ssize_t recvlen;
 
 	getclock(&now);
 	tv_subtract(&timeout_left, timeout, &now);
@@ -79,18 +94,28 @@ ssize_t recvtimeout(int socket, void *buffer, size_t length, int flags, const st
 		return -1;
 
 	setsockopt(socket, SOL_SOCKET, SO_RCVTIMEO, &timeout_left, sizeof(timeout_left));
-	return recv(socket, buffer, length, flags);
+
+	recvlen = recv(socket, NULL, 0, MSG_PEEK | MSG_TRUNC);
+	if (recvlen < 0)
+		return recvlen;
+
+	if (recvlen > *recvbuffer_len)
+		resize_recvbuffer(recvbuffer, recvbuffer_len, recvlen);
+
+	return recv(socket, *recvbuffer, *recvbuffer_len, 0);
 }
 
-int request(const int sock, const struct sockaddr_in6 *client_addr, const char *request, const char *sse, double timeout, unsigned int max_count) {
+int request(const int sock, char **recvbuffer, size_t *recvbuffer_len,
+	    const struct sockaddr_in6 *client_addr, const char *request,
+	    const char *sse, double timeout, unsigned int max_count) {
 	ssize_t ret;
-	char buffer[8192];
 	unsigned int count = 0;
 
 	ret = sendto(sock, request, strlen(request), 0, (struct sockaddr *)client_addr, sizeof(struct sockaddr_in6));
 
 	if (ret < 0) {
 		perror("Error in sendto()");
+		free(*recvbuffer);
 		exit(EXIT_FAILURE);
 	}
 
@@ -105,7 +130,7 @@ int request(const int sock, const struct sockaddr_in6 *client_addr, const char *
 	}
 
 	do {
-		ret = recvtimeout(sock, buffer, sizeof(buffer), 0, &tv_timeout);
+		ret = recvtimeout(sock, recvbuffer, recvbuffer_len, &tv_timeout);
 
 		if (ret < 0)
 			break;
@@ -116,7 +141,7 @@ int request(const int sock, const struct sockaddr_in6 *client_addr, const char *
 			fputs("data: ", stdout);
 		}
 
-		fwrite(buffer, sizeof(char), ret, stdout);
+		fwrite(*recvbuffer, sizeof(char), ret, stdout);
 
 		if (sse)
 			fputs("\n\n", stdout);
@@ -137,6 +162,8 @@ int main(int argc, char **argv) {
 	int sock;
 	struct sockaddr_in6 client_addr = {};
 	char *request_string = NULL;
+	char *recvbuffer = NULL;
+	size_t recvbuffer_len = 0;
 
 	sock = socket(PF_INET6, SOCK_DGRAM, 0);
 
@@ -243,11 +270,13 @@ int main(int argc, char **argv) {
 	}
 
 	do {
-		ret = request(sock, &client_addr, request_string, sse, timeout, max_count);
+		ret = request(sock, &recvbuffer, &recvbuffer_len, &client_addr,
+			      request_string, sse, timeout, max_count);
 	} while(loop);
 
 	if (sse)
 		fputs("event: eot\ndata: null\n\n", stdout);
 
+	free(recvbuffer);
 	return ret;
 }
diff --git a/package/gluon-status-page/files/lib/gluon/status-page/view/status-page.html b/package/gluon-status-page/files/lib/gluon/status-page/view/status-page.html
index 861c13a43f9fa09545bb4fa5a8a74172c587cbf5..6957599f37803637d3a14c89e4630c1442874fa3 100644
--- a/package/gluon-status-page/files/lib/gluon/status-page/view/status-page.html
+++ b/package/gluon-status-page/files/lib/gluon/status-page/view/status-page.html
@@ -1,7 +1,11 @@
 <%-
+	local iwinfo = require 'iwinfo'
 	local ubus = require 'ubus'
 	local unistd = require 'posix.unistd'
 	local util = require 'gluon.util'
+	local wireless = require 'gluon.wireless'
+
+	local uci = require('simple-uci').cursor()
 
 	local translations = {}
 	local site_i18n = i18n 'gluon-site'
@@ -29,17 +33,31 @@
 
 	local mesh = get_mesh()
 
-	local function get_interfaces()
-		local uconn = ubus.connect()
-		if not uconn then
-			error('failed to connect to ubus')
-		end
+	local function get_interfaces(uconn)
 		local interfaces = util.get_mesh_devices(uconn)
-		ubus.close(uconn)
 		table.sort(interfaces)
 		return interfaces
 	end
 
+	local function get_radios()
+		local ret = {}
+
+		wireless.foreach_radio(uci, function(radio)
+			local channel = iwinfo.nl80211.channel(wireless.find_phy(radio))
+			if channel then
+				table.insert(ret, {
+					name = radio['.name'],
+					channel = channel,
+				})
+			end
+		end)
+		table.sort(ret, function(a, b)
+			return a.name < b.name
+		end)
+
+		return ret
+	end
+
 	local function is_wireless(iface)
 		while true do
 			local pattern = '/sys/class/net/' .. iface .. '/lower_*'
@@ -52,7 +70,16 @@
 		return unistd.access('/sys/class/net/' .. iface .. '/wireless') ~= nil
 	end
 
-	local interfaces = get_interfaces()
+	local uconn = ubus.connect()
+	if not uconn then
+		error('failed to connect to ubus')
+	end
+
+	local interfaces = get_interfaces(uconn)
+
+	ubus.close(uconn)
+
+	local radios = get_radios()
 
 	local function sorted(t)
 		t = {unpack(t)}
@@ -66,12 +93,17 @@
 
 	local function formatBits(bits)
 		local units = {[0]='', 'k', 'M', 'G'}
+		local unit = 0
 
-		local pow = math.floor(math.log(math.max(math.abs(bits), 1)) / math.log(1000))
-		local known_pow = math.min(pow, #units)
+		for i = 1, #units do
+			if math.abs(bits) < 1000 then
+				break
+			end
+			unit = i
+			bits = bits / 1000
+		end
 
-		local significand = bits/(1000^known_pow)
-		return string.format('%g %sbit', significand, units[known_pow])
+		return string.format('%g %sbit', bits, units[unit])
 	end
 
 	local function statistics(key, format)
@@ -135,11 +167,11 @@
 						<% if nodeinfo.network.mesh_vpn.bandwidth_limit.enabled then -%>
 						<dt><%:Bandwidth limit%></dt>
 						<dd>
-							<% if nodeinfo.network.mesh_vpn.bandwidth_limit.egress then -%>
-								▲ <%| formatBits(nodeinfo.network.mesh_vpn.bandwidth_limit.egress*1000) %>/s <%:upstream%><br />
-							<%- end %>
 							<% if nodeinfo.network.mesh_vpn.bandwidth_limit.ingress then -%>
-								▼ <%| formatBits(nodeinfo.network.mesh_vpn.bandwidth_limit.ingress*1000) %>/s <%:downstream%>
+								▼ <%| formatBits(nodeinfo.network.mesh_vpn.bandwidth_limit.ingress*1000) %>/s <%:downstream%><br />
+							<%- end %>
+							<% if nodeinfo.network.mesh_vpn.bandwidth_limit.egress then -%>
+								▲ <%| formatBits(nodeinfo.network.mesh_vpn.bandwidth_limit.egress*1000) %>/s <%:upstream%>
 							<%- end %>
 						</dd>
 						<%- end %>
@@ -190,11 +222,17 @@
 					<tr><th><%:Wireless 2.4 GHz%></th><td><%= statistics('clients/wifi24') %></td></tr>
 					<tr><th><%:Wireless 5 GHz%></th><td><%= statistics('clients/wifi5') %></td></tr>
 				</table>
-					<div id="radios" style="display: none">
+				<% if radios[1] then -%>
 					<h3><%:Radios%></h3>
-					<table id="radio-devices">
+					<table>
+						<% for _, radio in ipairs(radios) do -%>
+							<tr>
+								<th><%| radio.name %></th>
+								<td><%| translatef('Channel %u', radio.channel) %></td>
+							</tr>
+						<%- end %>
 					</table>
-				</div>
+				<%- end %>
 
 				<h3><%:Traffic%></h3>
 				<table>
diff --git a/package/gluon-status-page/files/lib/gluon/status-page/www/static/status-page.js b/package/gluon-status-page/files/lib/gluon/status-page/www/static/status-page.js
index 3fe35c37a96d42a199f6dcf7c382ef83c33765f5..21c3c692925fa2d789916114aab4abfa980cfc7e 100644
--- a/package/gluon-status-page/files/lib/gluon/status-page/www/static/status-page.js
+++ b/package/gluon-status-page/files/lib/gluon/status-page/www/static/status-page.js
@@ -1 +1 @@
-"use strict";!function(){var r=JSON.parse(document.body.getAttribute("data-translations"));function i(t,e){return t.toFixed(e).replace(/\./,r["."])}function o(t,e){e--;for(var n=t;10<=n&&0<e;n/=10)e--;return i(t,e)}function a(t){return function(t,e,n){var i=0;if(void 0===n)return"- ";for(;e<n&&i<t.length-1;)n/=e,i++;return(n=o(n,3))+" "+t[i]}(["","K","M","G","T"],1024,t)}String.prototype.sprintf=function(){var t=0,e=arguments;return this.replace(/%s/g,function(){return e[t++]})};var s={id:function(t){return t},decimal:function(t){return i(t,2)},percent:function(t){return r["%s used"].sprintf(o(100*t,3)+"%")},memory:function(t){t=1-t.available/t.total;return s.percent(t)},time:function(t){var e=Math.round(t/60),n=Math.floor(e/1440),i=Math.floor(e%1440/60),e=Math.floor(e%60),t="";return 1===n?t+=r["1 day"]+", ":1<n&&(t+=r["%s days"].sprintf(n)+", "),t+=i+":",e<10&&(t+="0"),t+=e},packetsDiff:function(t,e,n){if(0<n)return n=(t-e)/n,r["%s packets/s"].sprintf(i(n,0))},bytesDiff:function(t,e,n){if(0<n)return a(8*((t-e)/n))+"bps"},bytes:function(t){return a(t)+"B"},neighbour:function(t){if(!t)return"";for(var e in c){var n=c[e].lookup_neigh(t);if(n)return"via "+n.get_hostname()+" ("+e+")"}return"via "+t+" (unknown iface)"}};function l(e,t){return t.split("/").forEach(function(t){e=e&&e[t]}),e}function d(t,e){var n=new EventSource(t),i={};n.onmessage=function(t){t=JSON.parse(t.data);e(t,i),i=t},n.onerror=function(){n.close(),window.setTimeout(function(){d(t,e)},3e3)}}var x,k=document.body.getAttribute("data-node-address");try{x=JSON.parse(document.body.getAttribute("data-node-location"))}catch(t){}function t(t){var e=document.getElementById("mesh-vpn");if(t){e.style.display="";for(var i=document.getElementById("mesh-vpn-peers");i.lastChild;)i.removeChild(i.lastChild);t=function e(n,i){return Object.keys(i.peers||{}).forEach(function(t){n.push([t,i.peers[t]])}),Object.keys(i.groups||{}).forEach(function(t){e(n,i.groups[t])}),n}([],t);t.sort(),t.forEach(function(t){var e=document.createElement("tr"),n=document.createElement("th");n.textContent=t[0],e.appendChild(n);n=document.createElement("td");t[1]?n.textContent=r.connected+" ("+s.time(t[1].established)+")":n.textContent=r["not connected"],e.appendChild(n),i.appendChild(e)})}else e.style.display="none"}function e(t){var e=document.getElementById("radios");if(t){e.style.display="";for(var i=document.getElementById("radio-devices");i.lastChild;)i.removeChild(i.lastChild);t.sort(function(t,e){return t.phy-e.phy}),t.forEach(function(t){var e=document.createElement("tr"),n=document.createElement("th");n.textContent="phy"+t.phy,e.appendChild(n);n=document.createElement("td");n.innerHTML=t.frequency+" MHz<br />Channel "+(2484===(t=t.frequency)?14:2412<=t&&t<=2472?(t-2407)/5:5160<=t&&t<=5885?(t-5e3)/5:"unknown"),e.appendChild(n),i.appendChild(e)})}else e.style.display="none"}var n=document.querySelectorAll("[data-statistics]");d("/cgi-bin/dyn/statistics",function(o,a){var c=o.uptime-a.uptime;n.forEach(function(t){var e=t.getAttribute("data-statistics"),n=t.getAttribute("data-format"),i=l(a,e),e=l(o,e);try{var r=s[n](e,i,c);void 0!==r&&(t.textContent=r)}catch(t){console.error(t)}});try{t(o.mesh_vpn)}catch(t){console.error(t)}try{e(o.wireless)}catch(t){console.error(t)}});var c={};function A(n){var i=document.createElement("canvas"),r=i.getContext("2d"),o=null;return{canvas:i,highlight:!1,resize:function(t,e){try{r.getImageData(0,0,t,e)}catch(t){}i.width=t,i.height=e},draw:function(t,e){e=e(o);r.clearRect(t,0,5,i.height),e&&(t=t,e=e,r.beginPath(),r.fillStyle=n,r.arc(t,e,1.2,0,2*Math.PI,!1),r.closePath(),r.fill())},set:function(t){o=t}}}function h(){var c=-100,s=0,n=0,i=[],l=document.createElement("canvas");l.className="signalgraph",l.height=200;var u=l.getContext("2d");function t(){l.width=l.clientWidth,i.forEach(function(t){t.resize(l.width,l.height)})}function r(){var e;0!==l.clientWidth&&(l.width!==l.clientWidth&&t(),u.clearRect(0,0,l.width,l.height),e=!1,i.forEach(function(t){t.highlight&&(e=!0)}),u.save(),i.forEach(function(t){e&&(u.globalAlpha=.2),t.highlight&&(u.globalAlpha=1),t.draw(n,function(t){return e=t,n=c,i=s,t=l.height,(1-(e-n)/(i-n))*t;var e,n,i}),u.drawImage(t.canvas,0,0)}),u.restore(),u.save(),u.beginPath(),u.strokeStyle="rgba(255, 180, 0, 0.15)",u.lineWidth=5,u.moveTo(n+2.5,0),u.lineTo(n+2.5,l.height),u.stroke(),function(){var t=Math.floor(l.height/40);u.save(),u.lineWidth=.5,u.strokeStyle="rgba(0, 0, 0, 0.25)",u.fillStyle="rgba(0, 0, 0, 0.5)",u.textAlign="end",u.textBaseline="bottom",u.beginPath();for(var e,n,i,r=0;r<t;r++){var o=l.height-40*r;u.moveTo(0,o-.5),u.lineTo(l.width,o-.5);var a=Math.round((e=o,n=c,i=s,a=l.height,(n*e+i*(a-e))/a))+" dBm";u.save(),u.strokeStyle="rgba(255, 255, 255, 0.9)",u.lineWidth=4,u.miterLimit=2,u.strokeText(a,l.width-5,o-2.5),u.fillText(a,l.width-5,o-2.5),u.restore()}u.stroke(),u.strokeStyle="rgba(0, 0, 0, 0.83)",u.lineWidth=1.5,u.strokeRect(.5,.5,l.width-1,l.height-1),u.restore()}())}t(),window.addEventListener("resize",r);var o=0;return window.requestAnimationFrame(function t(e){40<e-o&&(r(),n=(n+1)%l.width,o=e),window.requestAnimationFrame(t)}),{el:l,addSignal:function(t){i.push(t),t.resize(l.width,l.height)},removeSignal:function(t){i.splice(i.indexOf(t),1)}}}function f(t,e,n,i){var r,o=t.table.firstElementChild,a=t.table.insertRow(),c=a.insertCell();c.setAttribute("data-label",o.children[0].textContent),t.wireless&&((r=document.createElement("span")).textContent="⬤ ",r.style.color=n,c.appendChild(r));var s=document.createElement("span");s.textContent=e,c.appendChild(s);var l={};for(var u,d,h,f,g,v,m,p,b,y=0;y<o.children.length;y++)u=o.children[y],f=h=d=void 0,(f=u.getAttribute("data-key"))&&(d=u.getAttribute("data-suffix")||"",(h=a.insertCell()).textContent="-",h.setAttribute("data-label",u.textContent),l[f]={td:h,suffix:d});function C(){b&&window.clearTimeout(b),b=window.setTimeout(function(){p&&t.signalgraph.removeSignal(p),a.parentNode.removeChild(a),i()},6e4)}function E(t){t=function(t){"::"==(t="::"==t.slice(0,2)?"0"+t:t).slice(-2)&&(t+="0");var n=(t=t.split(":")).length,i=[];return t.forEach(function(t,e){if(""===t)for(;n++<=8;)i.push(0);else/^[a-f0-9]{1,4}$/i.test(t)&&i.push(parseInt(t,16))}),i}(t);if(t){var e="";return t.forEach(function(t){e+=("0000000000000000"+t.toString(2)).slice(-16)}),e}}function w(t){var i=E(k);if(t&&t[0]){(t=t.map(function(t){var e=E(t);if(!e)return[-1];var n=0;return[n=i?function(t,e){for(var n=0;n<t.length&&n<e.length&&t[n]===e[n];n++);return n}(i,e):n,e,t]})).sort(function(t,e){return t[0]<e[0]?1:t[0]>e[0]||t[1]<e[1]?-1:t[1]>e[1]?1:0});t=t[0][2];return t&&!/^fe80:/i.test(t)?t:void 0}}return t.wireless&&((g=a.insertCell()).textContent="-",g.setAttribute("data-label",o.children[Object.keys(l).length+1].textContent),(v=a.insertCell()).textContent="-",v.setAttribute("data-label",o.children[Object.keys(l).length+2].textContent),(m=a.insertCell()).textContent="-",m.setAttribute("data-label",o.children[Object.keys(l).length+3].textContent),p=A(n),t.signalgraph.addSignal(p)),a.onmouseenter=function(){a.classList.add("highlight"),p&&(p.highlight=!0)},a.onmouseleave=function(){a.classList.remove("highlight"),p&&(p.highlight=!1)},C(),{get_hostname:function(){return s.textContent},update_nodeinfo:function(t){var e,n,i,r,o=w(t.network.addresses);o&&("span"===s.nodeName.toLowerCase()&&(r=s,s=document.createElement("a"),r.parentNode.replaceChild(s,r)),s.href="http://["+o+"]/"),s.textContent=t.hostname,x&&t.location&&(e=x.latitude,n=x.longitude,i=t.location.latitude,r=t.location.longitude,o=Math.PI/180,t=(i*=o)-(e*=o),n=(r*=o)-(n*=o),i=Math.sin(t/2)*Math.sin(t/2)+Math.sin(n/2)*Math.sin(n/2)*Math.cos(e)*Math.cos(i),i=6372.8*(2*Math.asin(Math.sqrt(i))),v.textContent=Math.round(1e3*i)+" m"),C()},update_mesh:function(n){Object.keys(l).forEach(function(t){var e=l[t];e.td.textContent=n[t]+e.suffix}),C()},update_wifi:function(t){g.textContent=t.signal,m.textContent=Math.round(t.inactive/1e3)+" s",a.classList.toggle("inactive",200<t.inactive),p.set(200<t.inactive?null:t.signal),C()}}}function u(t,e,n){var i,o={};n&&(i=h(),t.appendChild(i.el));var r={table:t.firstElementChild,signalgraph:i,ifname:e,wireless:n},a=!1,c={},s=[];function l(){var t;a||(a=!0,(t=new EventSource("/cgi-bin/dyn/neighbours-nodeinfo?"+encodeURIComponent(e))).addEventListener("neighbour",function(t){try{var n=JSON.parse(t.data);i=[],r=n.network.mesh,Object.keys(r).forEach(function(t){var e=r[t].interfaces;Object.keys(e).forEach(function(t){e[t].forEach(function(t){i.push(t)})})}),i.forEach(function(t){var e=o[t];if(e){delete c[t];try{e.update_nodeinfo(n)}catch(t){console.error(t)}}})}catch(t){console.error(t)}var i,r},!1),t.onerror=function(){t.close(),a=!1,Object.keys(c).forEach(function(t){0<c[t]&&(c[t]--,l())})})}function u(t){var e=o[t];return e||(c[t]=3,e=o[t]=f(r,t,(s=s[0]?s:["#396AB1","#DA7C30","#3E9651","#CC2529","#535154","#6B4C9A","#922428","#948B3D"]).shift(),function(){delete c[t],delete o[t]}),l()),e}return n&&d("/cgi-bin/dyn/stations?"+encodeURIComponent(e),function(n){Object.keys(n).forEach(function(t){var e=n[t];u(t).update_wifi(e)})}),{get_neigh:u,lookup_neigh:function(t){return o[t]}}}document.querySelectorAll("[data-interface]").forEach(function(t){var e=t.getAttribute("data-interface"),n=(t.getAttribute("data-interface-address"),!!t.getAttribute("data-interface-wireless"));c[e]=u(t,e,n)});var g=document.body.getAttribute("data-mesh-provider");g&&d(g,function(i){Object.keys(i).forEach(function(t){var e=i[t],n=c[e.ifname];n&&n.get_neigh(t).update_mesh(e)})})}();
\ No newline at end of file
+"use strict";!function(){var i=JSON.parse(document.body.getAttribute("data-translations"));function r(t,e){return t.toFixed(e).replace(/\./,i["."])}function a(t,e){e--;for(var n=t;10<=n&&0<e;n/=10)e--;return r(t,e)}function o(t){return function(t,e,n){var r=0;if(void 0===n)return"- ";for(;e<n&&r<t.length-1;)n/=e,r++;return(n=a(n,3))+" "+t[r]}(["","K","M","G","T"],1024,t)}String.prototype.sprintf=function(){var t=0,e=arguments;return this.replace(/%s/g,function(){return e[t++]})};var u={id:function(t){return t},decimal:function(t){return r(t,2)},percent:function(t){return i["%s used"].sprintf(a(100*t,3)+"%")},memory:function(t){t=1-t.available/t.total;return u.percent(t)},time:function(t){var e=Math.round(t/60),n=Math.floor(e/1440),r=Math.floor(e%1440/60),e=Math.floor(e%60),t="";return 1===n?t+=i["1 day"]+", ":1<n&&(t+=i["%s days"].sprintf(n)+", "),t+=r+":",e<10&&(t+="0"),t+=e},packetsDiff:function(t,e,n){if(0<n)return n=(t-e)/n,i["%s packets/s"].sprintf(r(n,0))},bytesDiff:function(t,e,n){if(0<n)return o(8*((t-e)/n))+"bps"},bytes:function(t){return o(t)+"B"},neighbour:function(t){if(!t)return"";for(var e in c){var n=c[e].lookup_neigh(t);if(n){var r=document.createElement("span");r.appendChild(document.createTextNode("via "));var i=document.createElement("a");return i.href="http://["+n.get_addr()+"]/",i.textContent=n.get_hostname(),r.appendChild(i),r.appendChild(document.createTextNode(" ("+e+")")),r}}return"via "+t+" (unknown iface)"}};function s(e,t){return t.split("/").forEach(function(t){e=e&&e[t]}),e}function d(t,e){var n=new EventSource(t),r={};n.onmessage=function(t){t=JSON.parse(t.data);e(t,r),r=t},n.onerror=function(){n.close(),window.setTimeout(function(){d(t,e)},3e3)}}var x,k=document.body.getAttribute("data-node-address");try{x=JSON.parse(document.body.getAttribute("data-node-location"))}catch(t){}function t(t){var e=document.getElementById("mesh-vpn");if(t){e.style.display="";for(var r=document.getElementById("mesh-vpn-peers");r.lastChild;)r.removeChild(r.lastChild);t=function e(n,r){return Object.keys(r.peers||{}).forEach(function(t){n.push([t,r.peers[t]])}),Object.keys(r.groups||{}).forEach(function(t){e(n,r.groups[t])}),n}([],t);t.sort(),t.forEach(function(t){var e=document.createElement("tr"),n=document.createElement("th");n.textContent=t[0],e.appendChild(n);n=document.createElement("td");t[1]?n.textContent=i.connected+" ("+u.time(t[1].established)+")":n.textContent=i["not connected"],e.appendChild(n),r.appendChild(e)})}else e.style.display="none"}var e=document.querySelectorAll("[data-statistics]");d("/cgi-bin/dyn/statistics",function(a,o){var c=a.uptime-o.uptime;e.forEach(function(t){var e=t.getAttribute("data-statistics"),n=t.getAttribute("data-format"),r=s(o,e),e=s(a,e);try{var i=u[n](e,r,c);"object"==typeof i?(t.lastChild&&t.removeChild(t.lastChild),t.appendChild(i)):t.textContent=i}catch(t){console.error(t)}});try{t(a.mesh_vpn)}catch(t){console.error(t)}});var c={};function A(n){var r=document.createElement("canvas"),i=r.getContext("2d"),a=null;return{canvas:r,highlight:!1,resize:function(t,e){var n;try{n=i.getImageData(0,0,t,e)}catch(t){}r.width=t,r.height=e,n&&i.putImageData(n,0,0)},draw:function(t,e){e=e(a);i.clearRect(t,0,5,r.height),e&&(t=t,e=e,i.beginPath(),i.fillStyle=n,i.arc(t,e,1.2,0,2*Math.PI,!1),i.closePath(),i.fill())},set:function(t){a=t}}}function h(){var i=-100,a=0,n=0,r=[],o=document.createElement("canvas");o.className="signalgraph",o.height=200;var c=o.getContext("2d");function t(){o.width=o.clientWidth,r.forEach(function(t){t.resize(o.width,o.height)})}function u(){var e;0!==o.clientWidth&&(o.width!==o.clientWidth&&t(),c.clearRect(0,0,o.width,o.height),e=!1,r.forEach(function(t){t.highlight&&(e=!0)}),c.save(),r.forEach(function(t){e&&(c.globalAlpha=.2),t.highlight&&(c.globalAlpha=1),t.draw(n,function(t){return e=o.height,(1-(t-i)/(a-i))*e;var e}),c.drawImage(t.canvas,0,0)}),c.restore(),c.save(),c.beginPath(),c.strokeStyle="rgba(255, 180, 0, 0.15)",c.lineWidth=5,c.moveTo(n+2.5,0),c.lineTo(n+2.5,o.height),c.stroke(),function(){var t=Math.floor(o.height/40);c.save(),c.lineWidth=.5,c.strokeStyle="rgba(0, 0, 0, 0.25)",c.fillStyle="rgba(0, 0, 0, 0.5)",c.textAlign="end",c.textBaseline="bottom",c.beginPath();for(var e=0;e<t;e++){var n=o.height-40*e;c.moveTo(0,n-.5),c.lineTo(o.width,n-.5);var r=Math.round((r=o.height,(i*n+a*(r-n))/r))+" dBm";c.save(),c.strokeStyle="rgba(255, 255, 255, 0.9)",c.lineWidth=4,c.miterLimit=2,c.strokeText(r,o.width-5,n-2.5),c.fillText(r,o.width-5,n-2.5),c.restore()}c.stroke(),c.strokeStyle="rgba(0, 0, 0, 0.83)",c.lineWidth=1.5,c.strokeRect(.5,.5,o.width-1,o.height-1),c.restore()}())}t(),window.addEventListener("resize",u);var s=0;return window.requestAnimationFrame(function t(e){40<e-s&&(u(),n=(n+1)%o.width,s=e),window.requestAnimationFrame(t)}),{el:o,addSignal:function(t){r.push(t),t.resize(o.width,o.height)},removeSignal:function(t){r.splice(r.indexOf(t),1)}}}function f(t,o,e,n){var r,i=t.table.firstElementChild,a=t.table.insertRow(),c=a.insertCell();c.setAttribute("data-label",i.children[0].textContent),t.wireless&&((r=document.createElement("span")).textContent="⬤ ",r.style.color=e,c.appendChild(r));var u=document.createElement("span");u.textContent=o,c.appendChild(u);var s={};for(var l,d,h,f,g,v,p,m,b,C=0;C<i.children.length;C++)l=i.children[C],f=h=d=void 0,(f=l.getAttribute("data-key"))&&(d=l.getAttribute("data-suffix")||"",(h=a.insertCell()).textContent="-",h.setAttribute("data-label",l.textContent),s[f]={td:h,suffix:d});function y(){b&&window.clearTimeout(b),b=window.setTimeout(function(){m&&t.signalgraph.removeSignal(m),a.parentNode.removeChild(a),n()},6e4)}function w(t){t=function(t){"::"==(t="::"==t.slice(0,2)?"0"+t:t).slice(-2)&&(t+="0");for(var e=t.split(":"),n=e.length,r=[],i=0;i<e.length;i++){var a=e[i];if(""===a)for(;n++<=8;)r.push(0);else{if(!/^[a-f0-9]{1,4}$/i.test(a))return;r.push(parseInt(a,16))}}return r}(t);if(t){var e="";return t.forEach(function(t){e+=("0000000000000000"+t.toString(2)).slice(-16)}),e}}function E(t){var r=w(k);if(t&&t[0]){(t=t.map(function(t){var e=w(t);if(!e)return[-1];var n=0;return[n=r?function(t,e){for(var n=0;n<t.length&&n<e.length&&t[n]===e[n];n++);return n}(r,e):n,e,t]})).sort(function(t,e){return t[0]<e[0]?1:t[0]>e[0]||t[1]<e[1]?-1:t[1]>e[1]?1:0});t=t[0][2];return t&&!/^fe80:/i.test(t)?t:void 0}}return t.wireless&&((g=a.insertCell()).textContent="-",g.setAttribute("data-label",i.children[Object.keys(s).length+1].textContent),(v=a.insertCell()).textContent="-",v.setAttribute("data-label",i.children[Object.keys(s).length+2].textContent),(p=a.insertCell()).textContent="-",p.setAttribute("data-label",i.children[Object.keys(s).length+3].textContent),m=A(e),t.signalgraph.addSignal(m)),a.onmouseenter=function(){a.classList.add("highlight"),m&&(m.highlight=!0)},a.onmouseleave=function(){a.classList.remove("highlight"),m&&(m.highlight=!1)},y(),{get_hostname:function(){return u.textContent},get_addr:function(){return o},update_nodeinfo:function(t){var e,n,r,i,a;(o=E(t.network.addresses))&&("span"===u.nodeName.toLowerCase()&&(a=u,u=document.createElement("a"),a.parentNode.replaceChild(u,a)),u.href="http://["+o+"]/"),u.textContent=t.hostname,x&&t.location&&(e=x.latitude,n=x.longitude,r=t.location.latitude,i=t.location.longitude,a=Math.PI/180,t=(r*=a)-(e*=a),n=(i*=a)-(n*=a),r=Math.sin(t/2)*Math.sin(t/2)+Math.sin(n/2)*Math.sin(n/2)*Math.cos(e)*Math.cos(r),r=6372.8*(2*Math.asin(Math.sqrt(r))),v.textContent=Math.round(1e3*r)+" m"),y()},update_mesh:function(n){Object.keys(s).forEach(function(t){var e=s[t];e.td.textContent=n[t]+e.suffix}),y()},update_wifi:function(t){g.textContent=t.signal,p.textContent=Math.round(t.inactive/1e3)+" s",a.classList.toggle("inactive",200<t.inactive),m.set(200<t.inactive?null:t.signal),y()}}}function l(t,e,n){var r,a={};n&&(r=h(),t.appendChild(r.el));var i={table:t.firstElementChild,signalgraph:r,ifname:e,wireless:n},o=!1,c={},u=[];function s(){var t;o||(o=!0,(t=new EventSource("/cgi-bin/dyn/neighbours-nodeinfo?"+encodeURIComponent(e))).addEventListener("neighbour",function(t){try{var n=JSON.parse(t.data);r=[],i=n.network.mesh,Object.keys(i).forEach(function(t){var e=i[t].interfaces;Object.keys(e).forEach(function(t){e[t].forEach(function(t){r.push(t)})})}),r.forEach(function(t){var e=a[t];if(e){delete c[t];try{e.update_nodeinfo(n)}catch(t){console.error(t)}}})}catch(t){console.error(t)}var r,i},!1),t.onerror=function(){t.close(),o=!1,Object.keys(c).forEach(function(t){0<c[t]&&(c[t]--,s())})})}function l(t){var e=a[t];return e||(c[t]=3,e=a[t]=f(i,t,(u=u[0]?u:["#396AB1","#DA7C30","#3E9651","#CC2529","#535154","#6B4C9A","#922428","#948B3D"]).shift(),function(){delete c[t],delete a[t]}),s()),e}return n&&d("/cgi-bin/dyn/stations?"+encodeURIComponent(e),function(n){Object.keys(n).forEach(function(t){var e=n[t];l(t).update_wifi(e)})}),{get_neigh:l,lookup_neigh:function(t){return a[t]}}}document.querySelectorAll("[data-interface]").forEach(function(t){var e=t.getAttribute("data-interface"),n=(t.getAttribute("data-interface-address"),!!t.getAttribute("data-interface-wireless"));c[e]=l(t,e,n)});var n=document.body.getAttribute("data-mesh-provider");n&&d(n,function(r){Object.keys(r).forEach(function(t){var e=r[t],n=c[e.ifname];n&&n.get_neigh(t).update_mesh(e)})})}();
\ No newline at end of file
diff --git a/package/gluon-status-page/i18n/de.po b/package/gluon-status-page/i18n/de.po
index e86b174bee7cb616c8be99ba0b063217f0e4a739..27801e197c475f6f3a152a80c3c9a12069ab4e36 100644
--- a/package/gluon-status-page/i18n/de.po
+++ b/package/gluon-status-page/i18n/de.po
@@ -31,8 +31,8 @@ msgstr "Automatische Updates"
 msgid "Bandwidth limit"
 msgstr "Bandbreitenlimit"
 
-msgid "Channel"
-msgstr "Kanal"
+msgid "Channel %u"
+msgstr "Kanal %u"
 
 msgid "Clients"
 msgstr "Clients"
diff --git a/package/gluon-status-page/i18n/gluon-status-page.pot b/package/gluon-status-page/i18n/gluon-status-page.pot
index 5a8008fdfa7d59965f085ad1a678b4538b5bb75a..50d7d6bee78208c0b16f4de623cce9564ee11d02 100644
--- a/package/gluon-status-page/i18n/gluon-status-page.pot
+++ b/package/gluon-status-page/i18n/gluon-status-page.pot
@@ -22,7 +22,7 @@ msgstr ""
 msgid "Bandwidth limit"
 msgstr ""
 
-msgid "Channel"
+msgid "Channel %u"
 msgstr ""
 
 msgid "Clients"
diff --git a/package/gluon-status-page/javascript/status-page.js b/package/gluon-status-page/javascript/status-page.js
index 4c44e7ed53493ef6cad3b24c781667b4007d4572..7581dc7fb9a2ca88e180ebb29cff19278ec83d2d 100644
--- a/package/gluon-status-page/javascript/status-page.js
+++ b/package/gluon-status-page/javascript/status-page.js
@@ -121,7 +121,15 @@
 				var neigh = iface.lookup_neigh(addr);
 				if (!neigh)
 					continue;
-				return 'via ' + neigh.get_hostname() + ' (' + i + ')';
+
+				var span = document.createElement('span');
+				span.appendChild(document.createTextNode('via '));
+				var a = document.createElement('a');
+				a.href = 'http://[' + neigh.get_addr() + ']/';
+				a.textContent = neigh.get_hostname();
+				span.appendChild(a);
+				span.appendChild(document.createTextNode(' (' + i + ')'));
+				return span;
 			}
 
 			return 'via ' + addr + ' (unknown iface)';
@@ -208,50 +216,6 @@
 		});
 	}
 
-	function update_radios(wireless) {
-		function channel(frequency) {
-			if (frequency===2484)
-				return 14
-
-			if (2412<=frequency && frequency<=2472)
-				return (frequency-2407)/5
-
-			if (5160<=frequency && frequency<=5885)
-				return (frequency-5000)/5
-
-			return 'unknown'
-		}
-
-		var div = document.getElementById('radios');
-		if (!wireless) {
-			div.style.display = 'none';
-			return;
-		}
-		div.style.display = '';
-
-		var table = document.getElementById('radio-devices');
-		while (table.lastChild)
-			table.removeChild(table.lastChild);
-
-		wireless.sort(function (a, b) {
-			return a.phy - b.phy;
-		});
-
-		wireless.forEach(function (radio) {
-			var tr = document.createElement('tr');
-
-			var th = document.createElement('th');
-			th.textContent = "phy" + radio.phy;
-			tr.appendChild(th);
-
-			var td = document.createElement('td');
-			td.innerHTML = radio.frequency + " MHz<br />Channel " + channel(radio.frequency);
-			tr.appendChild(td);
-
-			table.appendChild(tr);
-		});
-	}
-
 	var statisticsElems = document.querySelectorAll('[data-statistics]');
 
 	add_event_source('/cgi-bin/dyn/statistics', function(data, dataPrev) {
@@ -264,9 +228,16 @@
 			var valuePrev = resolve_key(dataPrev, stat);
 			var value = resolve_key(data, stat);
 			try {
-				var text = formats[format](value, valuePrev, diff);
-				if (text !== undefined)
-					elem.textContent = text;
+				var format_result = formats[format](value, valuePrev, diff);
+				switch (typeof format_result) {
+					case "object":
+						if (elem.lastChild)
+							elem.removeChild(elem.lastChild);
+						elem.appendChild(format_result);
+						break;
+					default:
+						elem.textContent = format_result;
+				}
 			} catch (e) {
 				console.error(e);
 			}
@@ -277,11 +248,6 @@
 		} catch (e) {
 			console.error(e);
 		}
-		try {
-			update_radios(data.wireless);
-		} catch (e) {
-			console.error(e);
-		}
 	})
 
 	function haversine(lat1, lon1, lat2, lon2) {
@@ -319,7 +285,7 @@
 			'resize': function(w, h) {
 				var lastImage;
 				try {
-					ctx.getImageData(0, 0, w, h);
+					lastImage = ctx.getImageData(0, 0, w, h);
 				} catch (e) {}
 				canvas.width = w;
 				canvas.height = h;
@@ -492,6 +458,7 @@
 		}
 
 		var hostname = document.createElement("span");
+		var addr;
 		hostname.textContent = addr;
 		tdHostname.appendChild(hostname);
 
@@ -552,13 +519,13 @@
 			el.classList.add("highlight");
 			if (signal)
 				signal.highlight = true;
-		}
+		};
 
 		el.onmouseleave = function () {
-			el.classList.remove("highlight")
+			el.classList.remove("highlight");
 			if (signal)
 				signal.highlight = false;
-		}
+		};
 
 		var timeout;
 
@@ -586,7 +553,8 @@
 			var n = parts.length;
 			var groups = [];
 
-			parts.forEach(function(part, i) {
+			for (var i = 0; i < parts.length; i++) {
+				var part = parts[i];
 				if (part === '') {
 					while (n++ <= 8)
 						groups.push(0);
@@ -596,7 +564,7 @@
 
 					groups.push(parseInt(part, 16));
 				}
-			});
+			};
 
 			return groups;
 		}
@@ -664,8 +632,11 @@
 			'get_hostname': function() {
 				return hostname.textContent;
 			},
+			'get_addr': function() {
+				return addr;
+			},
 			'update_nodeinfo': function(nodeinfo) {
-				var addr = choose_address(nodeinfo.network.addresses);
+				addr = choose_address(nodeinfo.network.addresses);
 				if (addr) {
 					if (hostname.nodeName.toLowerCase() === 'span') {
 						var oldHostname = hostname;
diff --git a/package/gluon-status-page/luasrc/lib/gluon/status-page/controller/status-page.lua b/package/gluon-status-page/luasrc/lib/gluon/status-page/controller/status-page.lua
index ceb5d855273acba80d8e11468ee5421db7f99e26..80c5e50a4e03f882828dfdada1edefda6d0029d1 100644
--- a/package/gluon-status-page/luasrc/lib/gluon/status-page/controller/status-page.lua
+++ b/package/gluon-status-page/luasrc/lib/gluon/status-page/controller/status-page.lua
@@ -61,7 +61,7 @@ local function match(a, b, n)
 end
 
 entry({}, call(function(http, renderer)
-	local nodeinfo = json.parse(util.exec('exec gluon-neighbour-info -d ::1 -p 1001 -t 1 -c 1 -r nodeinfo'))
+	local nodeinfo = json.parse(util.exec('exec gluon-neighbour-info -d ::1 -p 1001 -t 3 -c 1 -r nodeinfo'))
 
 	local node_ip = parse_ip(http:getenv('SERVER_ADDR'))
 	if node_ip and (
diff --git a/package/gluon-web-admin/files/lib/gluon/config-mode/view/admin/upgrade.html b/package/gluon-web-admin/files/lib/gluon/config-mode/view/admin/upgrade.html
index 31555f16530b6a12c9a3131474e98850d40c88f5..7778be4a6c9eb8a01b8232557293c58a4beb8135 100644
--- a/package/gluon-web-admin/files/lib/gluon/config-mode/view/admin/upgrade.html
+++ b/package/gluon-web-admin/files/lib/gluon/config-mode/view/admin/upgrade.html
@@ -44,7 +44,6 @@ $Id$
 
 	<div class="gluon-page-actions">
 		<input type="hidden" name="step" value="2" />
-		<input type="hidden" name="token" value="<%=token%>" />
 		<input class="gluon-button gluon-button-submit" type="submit" value="<%:Upload image%>" />
 	</div>
 </form>
diff --git a/package/gluon-web-admin/files/lib/gluon/config-mode/view/admin/upgrade_confirm.html b/package/gluon-web-admin/files/lib/gluon/config-mode/view/admin/upgrade_confirm.html
index 92c25a632910f5ad953672013476c2efdff284cd..9733132fa9ec7ca77d5d64ae4d198bc25e3f4a9c 100644
--- a/package/gluon-web-admin/files/lib/gluon/config-mode/view/admin/upgrade_confirm.html
+++ b/package/gluon-web-admin/files/lib/gluon/config-mode/view/admin/upgrade_confirm.html
@@ -49,13 +49,11 @@ You may obtain a copy of the License at
   <form method="post" enctype="multipart/form-data" action="<%|url(request)%>" style="display:inline">
     <input type="hidden" name="step" value="3" />
     <input type="hidden" name="keepcfg" value="<%=keepconfig and "1" or "0"%>" />
-    <input type="hidden" name="token" value="<%=token%>" />
     <input class="gluon-button gluon-button-submit" type="submit" value="<%:Continue%>" />
   </form>
   <form method="post" enctype="multipart/form-data" action="<%|url(request)%>" style="display:inline">
     <input type="hidden" name="step" value="1" />
     <input type="hidden" name="keepcfg" value="<%=keepconfig and "1" or "0"%>" />
-    <input type="hidden" name="token" value="<%=token%>" />
     <input class="gluon-button gluon-button-reset" type="submit" value="<%:Cancel%>" />
   </form>
 </div>
diff --git a/package/gluon-web-model/files/lib/gluon/web/view/model/form.html b/package/gluon-web-model/files/lib/gluon/web/view/model/form.html
index 06270be3a576eb1a4aac23a77da9f1f401c20bd2..d222dde2c9c07bd96d5dbdac50ac83a2862fe9a5 100644
--- a/package/gluon-web-model/files/lib/gluon/web/view/model/form.html
+++ b/package/gluon-web-model/files/lib/gluon/web/view/model/form.html
@@ -1,5 +1,4 @@
 <form method="post" enctype="multipart/form-data" action="<%|url(request)%>" data-update="reset">
-	<input type="hidden" name="token" value="<%=token%>" />
 	<input type="hidden" name="<%=id%>" value="1" />
 
 	<div class="gluon-form" id="form-<%=id%>">
diff --git a/package/gluon-web-osm/files/lib/gluon/web/view/model/osm/map.html b/package/gluon-web-osm/files/lib/gluon/web/view/model/osm/map.html
index 2caa3f379161d18a03c16cdf48341b57a9cd4581..a2822cd4420bc691c43fe9851152ef23edbdee7f 100644
--- a/package/gluon-web-osm/files/lib/gluon/web/view/model/osm/map.html
+++ b/package/gluon-web-osm/files/lib/gluon/web/view/model/osm/map.html
@@ -1,6 +1,7 @@
 <div id="<%=id%>" class="gluon-osm-map" style="display: none"></div>
 <script type="text/javascript" src="/static/gluon-web-osm.js"></script>
 <script type="text/javascript">
+	//<![CDATA[
 	(function() {
 		var elMap = document.getElementById(<%=json(id)%>);
 		var wrapper = elMap.parentNode;
@@ -41,4 +42,5 @@
 			});
 		});
 	})();
+	//]]>
 </script>
diff --git a/package/gluon-web-osm/luasrc/usr/lib/lua/gluon/web/model/osm.lua b/package/gluon-web-osm/luasrc/usr/lib/lua/gluon/web/model/osm.lua
index ec18a003d6196e9a9410fccdb92f1c2787725794..294caddff77b4427b0d9b5912ae4ce5a57777115 100644
--- a/package/gluon-web-osm/luasrc/usr/lib/lua/gluon/web/model/osm.lua
+++ b/package/gluon-web-osm/luasrc/usr/lib/lua/gluon/web/model/osm.lua
@@ -4,7 +4,8 @@ local util = require "gluon.web.util"
 local class = util.class
 
 
-local DEFAULT_URL = 'https://cdn.rawgit.com/openlayers/openlayers.github.io/master/en/v5.2.0'
+local DEFAULT_URL =
+	'https://cdn.jsdelivr.net/gh/openlayers/openlayers.github.io@35ffe7626ce16c372143f3c903950750075e7068/en/v5.3.0'
 
 
 local M = {}
diff --git a/package/gluon-web/luasrc/usr/lib/lua/gluon/web/dispatcher.lua b/package/gluon-web/luasrc/usr/lib/lua/gluon/web/dispatcher.lua
index 2d72777661b2d9a6f565e4836129c7bf3d23ad37..3aca99dcb96428afd26ed73ec73dc214240ce58c 100644
--- a/package/gluon-web/luasrc/usr/lib/lua/gluon/web/dispatcher.lua
+++ b/package/gluon-web/luasrc/usr/lib/lua/gluon/web/dispatcher.lua
@@ -184,9 +184,15 @@ local function dispatch(config, http, request)
 		return
 	end
 
-	http:parse_input(node.filehandler)
+	local ok, err = pcall(http.parse_input, http, node.filehandler)
+	if not ok then
+		http:status(400, "Bad request")
+		http:prepare_content("text/plain")
+		http:write(err .. "\r\n")
+		return
+	end
 
-	local ok, err = pcall(node.target)
+	ok, err = pcall(node.target)
 	if not ok then
 		http:status(500, "Internal Server Error")
 		renderer.render_layout("error/500", {
@@ -208,6 +214,6 @@ return function(config, http)
 	if not ok then
 		http:status(500, "Internal Server Error")
 		http:prepare_content("text/plain")
-		http:write(err)
+		http:write(err .. "\r\n")
 	end
 end
diff --git a/package/gluon-web/luasrc/usr/lib/lua/gluon/web/http/protocol.lua b/package/gluon-web/luasrc/usr/lib/lua/gluon/web/http/protocol.lua
index 8d070d951d3cd3d48c4a5c38fe1c05a5027227a8..5f56fa1b64b4fcb2fe4e8738cd7e7dc7ec171a87 100644
--- a/package/gluon-web/luasrc/usr/lib/lua/gluon/web/http/protocol.lua
+++ b/package/gluon-web/luasrc/usr/lib/lua/gluon/web/http/protocol.lua
@@ -108,16 +108,11 @@ end
 --  o String value containing a chunk of the file data
 --  o Boolean which indicates whether the current chunk is the last one (eof)
 local function mimedecode_message_body(src, msg, filecb)
-
-	if msg and msg.env.CONTENT_TYPE then
-		msg.mime_boundary = msg.env.CONTENT_TYPE:match("^multipart/form%-data; boundary=(.+)$")
-	end
-
-	if not msg.mime_boundary then
-		return nil, "Invalid Content-Type found"
+	local mime_boundary = (msg.env.CONTENT_TYPE or ''):match("^multipart/form%-data; boundary=(.+)$")
+	if not mime_boundary then
+		error("Invalid Content-Type found")
 	end
 
-
 	local tlen   = 0
 	local inhdr  = false
 	local field  = nil
@@ -188,10 +183,10 @@ local function mimedecode_message_body(src, msg, filecb)
 			local spos, epos, found
 
 			repeat
-				spos, epos = data:find("\r\n--" .. msg.mime_boundary .. "\r\n", 1, true)
+				spos, epos = data:find("\r\n--" .. mime_boundary .. "\r\n", 1, true)
 
 				if not spos then
-					spos, epos = data:find("\r\n--" .. msg.mime_boundary .. "--\r\n", 1, true)
+					spos, epos = data:find("\r\n--" .. mime_boundary .. "--\r\n", 1, true)
 				end
 
 
@@ -250,20 +245,61 @@ local function mimedecode_message_body(src, msg, filecb)
 		return true
 	end
 
-	return pump(src, snk)
+	assert(pump(src, snk))
+end
+
+local function check_post_origin(msg)
+	local default_port = '80'
+	local request_scheme = 'http'
+	if msg.env.HTTPS then
+		default_port = '443'
+		request_scheme = 'https'
+	end
+
+	local request_host = msg.env.HTTP_HOST
+	if not request_host then
+		error('POST request without Host header')
+	end
+	if not request_host:match(':[0-9]+$') then
+		request_host = request_host .. ':' .. default_port
+	end
+
+	local origin = msg.env.HTTP_ORIGIN
+	if not origin then
+		error('POST request without Origin header')
+	end
+	local origin_scheme, origin_host = origin:match('^([^:]*)://(.*)$')
+	if not origin_host then
+		error('POST request with invalid Origin header')
+	end
+	if not origin_host:match(':[0-9]+$') then
+		local origin_port
+		if origin_scheme == 'http' then
+			origin_port = '80'
+		elseif origin_scheme == 'https' then
+			origin_port = '443'
+		else
+			error('POST request with invalid Origin header')
+		end
+		origin_host = origin_host .. ':' .. origin_port
+	end
+
+	if request_scheme ~= origin_scheme or request_host ~= origin_host then
+		error('Invalid cross-origin POST')
+	end
 end
 
 -- This function will examine the Content-Type within the given message object
 -- to select the appropriate content decoder.
 -- Currently only the multipart/form-data mime type is supported.
 function M.parse_message_body(src, msg, filecb)
-	if not (msg.env.REQUEST_METHOD == "POST" and msg.env.CONTENT_TYPE) then
+	if msg.env.REQUEST_METHOD ~= "POST" then
 		return
 	end
 
-	if msg.env.CONTENT_TYPE:match("^multipart/form%-data") then
-		return mimedecode_message_body(src, msg, filecb)
-	end
+	check_post_origin(msg)
+
+	mimedecode_message_body(src, msg, filecb)
 end
 
 return M
diff --git a/patches/openwrt/0013-mac80211-create-channel-list-for-fixed-channel-operation.patch b/patches/openwrt/0013-mac80211-create-channel-list-for-fixed-channel-operation.patch
index c2178b1eb9e1602600c33b1adc4e45d838854bf8..729a3e573fe761a725f8d80708f05ef15091e329 100644
--- a/patches/openwrt/0013-mac80211-create-channel-list-for-fixed-channel-operation.patch
+++ b/patches/openwrt/0013-mac80211-create-channel-list-for-fixed-channel-operation.patch
@@ -13,10 +13,10 @@ circumvent this issue.
 Signed-off-by: David Bauer <mail@david-bauer.net>
 
 diff --git a/package/kernel/mac80211/files/lib/netifd/wireless/mac80211.sh b/package/kernel/mac80211/files/lib/netifd/wireless/mac80211.sh
-index 36aebbb2ccfec2137d5d260fe2111d77f531ddec..367a3e8e37a8e8435c35ca2912ef0855efbdfc78 100644
+index bb48ab9a15e470b6807693e08fdc84fb3c94aeed..272fb2a726bb34fa3ab74dfe48150197dbf918ca 100644
 --- a/package/kernel/mac80211/files/lib/netifd/wireless/mac80211.sh
 +++ b/package/kernel/mac80211/files/lib/netifd/wireless/mac80211.sh
-@@ -100,6 +100,9 @@ mac80211_hostapd_setup_base() {
+@@ -101,6 +101,9 @@ mac80211_hostapd_setup_base() {
  	json_get_vars noscan ht_coex
  	json_get_values ht_capab_list ht_capab tx_burst
  
diff --git a/patches/openwrt/0015-kernel-bridge-Implement-MLD-Querier-wake-up-calls-Android-bug-workaround.patch b/patches/openwrt/0015-kernel-bridge-Implement-MLD-Querier-wake-up-calls-Android-bug-workaround.patch
index ad9cc4f1546dfef308aec986c7fe0c506e57708e..c08c3d2dfebcea281912c61b65fb1f31b7bfa2e4 100644
--- a/patches/openwrt/0015-kernel-bridge-Implement-MLD-Querier-wake-up-calls-Android-bug-workaround.patch
+++ b/patches/openwrt/0015-kernel-bridge-Implement-MLD-Querier-wake-up-calls-Android-bug-workaround.patch
@@ -1954,10 +1954,10 @@ index 0000000000000000000000000000000000000000..92bb9275df9d54778ce8f00b1cb6e999
 +2.27.0
 +
 diff --git a/target/linux/generic/config-4.14 b/target/linux/generic/config-4.14
-index d54ede9efda0a3ffd84e9a0c49dc410a01737d82..15b50523bf55d9a77fc1655ec6ba6ffde6d93a3e 100644
+index cbe2c09af91dcbb036bb71d42b6b1075d7f31012..e7faafd719656769fe2e43ff9145abc28b806827 100644
 --- a/target/linux/generic/config-4.14
 +++ b/target/linux/generic/config-4.14
-@@ -628,6 +628,7 @@ CONFIG_BRIDGE=y
+@@ -629,6 +629,7 @@ CONFIG_BRIDGE=y
  # CONFIG_BRIDGE_EBT_T_NAT is not set
  # CONFIG_BRIDGE_EBT_VLAN is not set
  CONFIG_BRIDGE_IGMP_SNOOPING=y
diff --git a/patches/openwrt/0020-ipq40xx-add-support-for-Plasma-Cloud-PA1200.patch b/patches/openwrt/0020-ipq40xx-add-support-for-Plasma-Cloud-PA1200.patch
index aec18d5122ba65e671f6f7cd1505447831715a4e..0372f409a2e71859641afe8abfdb436eb28870bd 100644
--- a/patches/openwrt/0020-ipq40xx-add-support-for-Plasma-Cloud-PA1200.patch
+++ b/patches/openwrt/0020-ipq40xx-add-support-for-Plasma-Cloud-PA1200.patch
@@ -429,7 +429,7 @@ index 0000000000000000000000000000000000000000..bcb9552ce777d1d522c7642649e22ec2
 +	qcom,ath10k-calibration-variant = "PlasmaCloud-PA1200";
 +};
 diff --git a/target/linux/ipq40xx/image/Makefile b/target/linux/ipq40xx/image/Makefile
-index 68dcbc59a42f6d8360b87c7b4e74cd34f697b465..e14d00ad08b8caf2dae935d573f0ba7bb0433c23 100644
+index 68dcbc59a42f6d8360b87c7b4e74cd34f697b465..3a2e7a4410afcba1a1369cac328e237fc350668b 100644
 --- a/target/linux/ipq40xx/image/Makefile
 +++ b/target/linux/ipq40xx/image/Makefile
 @@ -345,6 +345,21 @@ endef
@@ -447,7 +447,7 @@ index 68dcbc59a42f6d8360b87c7b4e74cd34f697b465..e14d00ad08b8caf2dae935d573f0ba7b
 +	IMAGES = factory.bin sysupgrade.bin
 +	IMAGE/factory.bin := append-rootfs | pad-rootfs | openmesh-image ce_type=PA1200
 +	IMAGE/sysupgrade.bin/squashfs := append-rootfs | pad-rootfs | sysupgrade-tar rootfs=$$$$@ | append-metadata
-+	DEVICE_PACKAGES := uboot-envtools ipq-wifi-plasmacloud-pa1200
++	DEVICE_PACKAGES := uboot-envtools ipq-wifi-plasmacloud_pa1200
 +endef
 +TARGET_DEVICES += plasmacloud_pa1200
 +
diff --git a/patches/openwrt/0021-ipq40xx-add-support-for-Plasma-Cloud-PA2200.patch b/patches/openwrt/0021-ipq40xx-add-support-for-Plasma-Cloud-PA2200.patch
index b588f083ab8f97c9d9e0e44dfb49fb136cd0aad6..d4f580129411f23f8fe1a4bef624d0e0b586e04d 100644
--- a/patches/openwrt/0021-ipq40xx-add-support-for-Plasma-Cloud-PA2200.patch
+++ b/patches/openwrt/0021-ipq40xx-add-support-for-Plasma-Cloud-PA2200.patch
@@ -501,7 +501,7 @@ index 0000000000000000000000000000000000000000..2d0655114b4e0749e0c878a3d16ece2a
 +	ieee80211-freq-limit = <5470000 5875000>;
 +};
 diff --git a/target/linux/ipq40xx/image/Makefile b/target/linux/ipq40xx/image/Makefile
-index e14d00ad08b8caf2dae935d573f0ba7bb0433c23..9872d0c4abcbb9d607bb15c47f0f820e7cdea077 100644
+index 3a2e7a4410afcba1a1369cac328e237fc350668b..b6241d622574657b5261a45507ba5959d39eaa67 100644
 --- a/target/linux/ipq40xx/image/Makefile
 +++ b/target/linux/ipq40xx/image/Makefile
 @@ -360,6 +360,21 @@ define Device/plasmacloud_pa1200
@@ -519,7 +519,7 @@ index e14d00ad08b8caf2dae935d573f0ba7bb0433c23..9872d0c4abcbb9d607bb15c47f0f820e
 +	IMAGES = factory.bin sysupgrade.bin
 +	IMAGE/factory.bin := append-rootfs | pad-rootfs | openmesh-image ce_type=PA2200
 +	IMAGE/sysupgrade.bin/squashfs := append-rootfs | pad-rootfs | sysupgrade-tar rootfs=$$$$@ | append-metadata
-+	DEVICE_PACKAGES := ath10k-firmware-qca9888-ct ipq-wifi-plasmacloud-pa2200 uboot-envtools
++	DEVICE_PACKAGES := ath10k-firmware-qca9888-ct ipq-wifi-plasmacloud_pa2200 uboot-envtools
 +endef
 +TARGET_DEVICES += plasmacloud_pa2200
 +
diff --git a/patches/packages/packages/0003-perl-don-t-build-in-parallel-and-bump-release.patch b/patches/packages/packages/0003-perl-don-t-build-in-parallel-and-bump-release.patch
new file mode 100644
index 0000000000000000000000000000000000000000..b8fe11d23c4afac4bb7d85ed816a7ab43649d476
--- /dev/null
+++ b/patches/packages/packages/0003-perl-don-t-build-in-parallel-and-bump-release.patch
@@ -0,0 +1,24 @@
+From: Martin Weinelt <martin@darmstadt.freifunk.net>
+Date: Tue, 8 Feb 2022 21:09:20 +0100
+Subject: perl: don't build in parallel and bump release
+
+Parallel builds cause spurious build failures with high core counts.
+
+https://github.com/openwrt/packages/issues/8238
+https://github.com/openwrt/packages/pull/17274
+
+diff --git a/lang/perl/Makefile b/lang/perl/Makefile
+index 84d256d2d8c682f18670a4cbae0a48e3333fb222..c2e5cf8e703af675dd296704597934aa9b5f7446 100644
+--- a/lang/perl/Makefile
++++ b/lang/perl/Makefile
+@@ -34,8 +34,8 @@ PKG_BUILD_DIR:=$(BUILD_DIR)/perl/$(PKG_NAME)-$(PKG_VERSION)
+ HOST_BUILD_DIR:=$(BUILD_DIR_HOST)/perl/$(PKG_NAME)-$(PKG_VERSION)
+ PKG_INSTALL:=1
+ PKG_BUILD_DEPENDS:=perl/host
+-PKG_BUILD_PARALLEL:=1
+-HOST_BUILD_PARALLEL:=1
++PKG_BUILD_PARALLEL:=0
++HOST_BUILD_PARALLEL:=0
+ 
+ # Variables used during configuration/build
+ HOST_PERL_PREFIX:=$(STAGING_DIR_HOSTPKG)/usr
diff --git a/scripts/container.sh b/scripts/container.sh
new file mode 100755
index 0000000000000000000000000000000000000000..072d2ec13ba06804faab16c5f4a5ac2b1a4d196d
--- /dev/null
+++ b/scripts/container.sh
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+# move into base directory, in case this script is not executed via `make container`
+cd "$(dirname "$0")/.."
+
+# normalize branch name to reflect a valid image name
+BRANCH=$(git branch --show-current 2>/dev/null | sed 's/[^a-z0-9-]/_/ig')
+TAG="gluon:${BRANCH:-latest}"
+
+if [ "$(command -v podman)" ]
+then
+	podman build -t "${TAG}" contrib/docker
+	podman run -it --rm --userns=keep-id --volume="$(pwd):/gluon" "${TAG}"
+elif [ "$(command -v docker)" ]
+then
+	docker build -t "${TAG}" contrib/docker
+	docker run -it --rm --volume="$(pwd):/gluon" "${TAG}"
+else
+	1>&2 echo "Please install either podman or docker. Exiting" >/dev/null
+	exit 1
+fi
+