diff --git a/package/gluon-core/check_site.lua b/package/gluon-core/check_site.lua
index 4cb44d5bf8598f43e85b5dbb47458cd2abc792f9..103cb929da8304c8ae4f1bd2afcc10713678b332 100644
--- a/package/gluon-core/check_site.lua
+++ b/package/gluon-core/check_site.lua
@@ -74,6 +74,11 @@ need_string_match(in_domain({'next_node', 'ip4'}), '^%d+.%d+.%d+.%d+$', false)
 
 need_boolean(in_domain({'mesh', 'vxlan'}), false)
 
-need_boolean(in_site({'mesh_on_wan'}), false)
-need_boolean(in_site({'mesh_on_lan'}), false)
-need_boolean(in_site({'single_as_lan'}), false)
+local interfaces_roles = {'client', 'uplink', 'mesh'}
+for _, config in ipairs({'wan', 'lan', 'single'}) do
+	need_array_of(in_site({'interfaces', config, 'default_roles'}), interfaces_roles, false)
+end
+
+obsolete({'mesh_on_wan'}, 'Use interfaces.wan.default_roles.')
+obsolete({'mesh_on_lan'}, 'Use interfaces.lan.default_roles.')
+obsolete({'single_as_lan'}, 'Use interfaces.single.default_roles.')
diff --git a/package/gluon-core/luasrc/lib/gluon/upgrade/021-interface-roles b/package/gluon-core/luasrc/lib/gluon/upgrade/021-interface-roles
new file mode 100755
index 0000000000000000000000000000000000000000..182c8903464c94ba2eb920df2587c43d97ef90ce
--- /dev/null
+++ b/package/gluon-core/luasrc/lib/gluon/upgrade/021-interface-roles
@@ -0,0 +1,66 @@
+#!/usr/bin/lua
+
+local site = require 'gluon.site'
+local sysconfig = require 'gluon.sysconfig'
+local uci = require('simple-uci').cursor()
+local util = require 'gluon.util'
+
+-- Defaults from site.conf
+local roles = {
+	lan = site.interfaces.lan.roles({'client'}),
+	wan = site.interfaces.wan.roles({'uplink'}),
+}
+roles.single = site.interfaces.single.roles(roles.wan)
+
+-- Migration of Mesh-on-WAN/LAN setting from Gluon 2021.1 and older (to be removed in 2024)
+--
+-- Wired meshing is enabled for single interfaces if either of the settings
+-- was previously enabled
+local mesh_lan_disabled = uci:get('network_gluon-old', 'mesh_lan', 'disabled')
+local mesh_wan_disabled = uci:get('network_gluon-old', 'mesh_wan', 'disabled')
+if mesh_wan_disabled == '0' then
+	util.add_to_set(roles.wan, 'mesh')
+	util.add_to_set(roles.single, 'mesh')
+elseif mesh_wan_disabled == '1' then
+	util.remove_from_set(roles.wan, 'mesh')
+	util.remove_from_set(roles.single, 'mesh')
+end
+if mesh_lan_disabled == '0' then
+	util.add_to_set(roles.lan, 'mesh')
+	util.add_to_set(roles.single, 'mesh')
+elseif mesh_lan_disabled == '1' then
+	util.remove_from_set(roles.lan, 'mesh')
+	util.remove_from_set(roles.single, 'mesh')
+end
+
+-- Migration of single to WAN/LAN or vice-versa (an interface was added or removed)
+-- We identify the WAN with the single interface in this case
+--
+-- These settings only take effect when the section that is the target of the
+-- migration does not exist yet.
+if uci:get('gluon', 'iface_wan') then
+	roles.single = uci:get_list('gluon', 'iface_wan', 'role')
+end
+if uci:get('gluon', 'iface_single') then
+	roles.wan = uci:get_list('gluon', 'iface_single', 'role')
+end
+
+-- Non-existing interfaces are nil, so they will not be added to the table
+local interfaces = {
+	lan = sysconfig.lan_ifname,
+	wan = sysconfig.wan_ifname,
+	single = sysconfig.single_ifname,
+}
+
+for iface in pairs(interfaces) do
+	local section_name = 'iface_' .. iface
+	if not uci:get('gluon', section_name) then
+		uci:section('gluon', 'interface', section_name, {
+			-- / prefix refers to sysconfig ifnames
+			name = '/' .. iface,
+			role = roles[iface],
+		})
+	end
+end
+
+uci:save('gluon')