diff --git a/interface-definitions/include/constraint/interface-name.xml.i b/interface-definitions/include/constraint/interface-name.xml.i
index 3e7c4e667e..be0a4da17e 100644
--- a/interface-definitions/include/constraint/interface-name.xml.i
+++ b/interface-definitions/include/constraint/interface-name.xml.i
@@ -1,4 +1,4 @@
-(bond|br|dum|en|ersp|eth|gnv|ifb|ipoe|lan|l2tp|l2tpeth|macsec|peth|ppp|pppoe|pptp|sstp|sstpc|tun|veth|vti|vtun|vxlan|wg|wlan|wwan)[0-9]+(.\d+)?|lo
+(bond|br|dum|en|ersp|eth|gnv|ifb|ipoe|lan|l2tp|l2tpeth|macsec|peth|ppp|pppoe|pptp|sstp|sstpc|tun|utun|veth|vti|vtun|vxlan|wg|wlan|wwan)[0-9]+(.\d+)?|lo
diff --git a/interface-definitions/interfaces_utunnel.xml.in b/interface-definitions/interfaces_utunnel.xml.in
new file mode 100644
index 0000000000..9c646074d0
--- /dev/null
+++ b/interface-definitions/interfaces_utunnel.xml.in
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+ Tunnel interface
+ 400
+
+ utun[0-9]+
+
+ tunnel interface must be named utunN
+
+ utunN
+ Tunnel interface name
+
+
+
+ #include
+ #include
+ #include
+
+
+
+ Manage type
+
+ external
+
+
+ external
+ Controlled by external program
+
+
+ (external)
+
+
+
+
+
+
+ Tunnel type
+
+
+
+
+ .{0,255}
+
+ Tunnel type too long (limit 255 characters)
+
+
+
+
+
+
+
+
diff --git a/interface-definitions/service_config-sync.xml.in b/interface-definitions/service_config-sync.xml.in
index af4e8ed51c..c618edd3c5 100644
--- a/interface-definitions/service_config-sync.xml.in
+++ b/interface-definitions/service_config-sync.xml.in
@@ -178,6 +178,12 @@
+
+
+ Custom tunnel interface
+
+
+
Virtual Ethernet interface
diff --git a/op-mode-definitions/restart-utunnel.xml.in b/op-mode-definitions/restart-utunnel.xml.in
new file mode 100644
index 0000000000..b5cbfcb4fc
--- /dev/null
+++ b/op-mode-definitions/restart-utunnel.xml.in
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+ Restart a custom tunnel backend service
+
+ interfaces utunnel
+
+
+ sudo ${vyos_op_scripts_dir}/utunnel.py restart --interface="$3"
+
+
+
+
diff --git a/op-mode-definitions/show-interfaces-utunnel.xml.in b/op-mode-definitions/show-interfaces-utunnel.xml.in
new file mode 100644
index 0000000000..9bb990565b
--- /dev/null
+++ b/op-mode-definitions/show-interfaces-utunnel.xml.in
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+ Show specified custom tunnel interface information
+
+ interfaces utunnel
+
+
+ ${vyos_op_scripts_dir}/utunnel.py show_status --interface="$4"
+
+
+
+
+
+
diff --git a/python/vyos/ifconfig/__init__.py b/python/vyos/ifconfig/__init__.py
index 206b2bba19..5cd158c6b7 100644
--- a/python/vyos/ifconfig/__init__.py
+++ b/python/vyos/ifconfig/__init__.py
@@ -39,3 +39,4 @@
from vyos.ifconfig.veth import VethIf
from vyos.ifconfig.wwan import WWANIf
from vyos.ifconfig.sstpc import SSTPCIf
+from vyos.ifconfig.utunnel import CustomTunnelIf
diff --git a/python/vyos/ifconfig/utunnel.py b/python/vyos/ifconfig/utunnel.py
new file mode 100644
index 0000000000..e749462a05
--- /dev/null
+++ b/python/vyos/ifconfig/utunnel.py
@@ -0,0 +1,122 @@
+# Copyright 2019-2021 VyOS maintainers and contributors
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see .
+import glob
+import os
+from pathlib import Path
+import yaml
+
+from vyos.ifconfig import Interface
+from vyos.ifconfig import Operational
+
+utunnel_config_directory = '/config/utunnels/'
+
+
+def get_utunnel_config(tunnel_type):
+ config_file = os.path.join(utunnel_config_directory, '{}.yaml'.format(tunnel_type))
+
+ config = {}
+ if os.path.exists(config_file):
+ config = yaml.safe_load(open(config_file))
+
+ defaults = {
+ 'scripts': {
+ 'start': '',
+ 'stop': '',
+ 'update': '',
+ 'status': '',
+ },
+ }
+ defaults.update(config)
+
+ return defaults
+
+
+def get_custom_tunnel_types() -> list[str]:
+ pattern = os.path.join(utunnel_config_directory, '*.yaml')
+
+ types = []
+ for file_path in glob.glob(pattern):
+ basename = Path(file_path).stem
+ types.append(basename)
+
+ return sorted(types)
+
+
+class CustomTunnelOperational(Operational):
+
+ def get_tunnel_type(self):
+ from vyos.config import Config
+
+ c = Config()
+ return c.return_effective_value(['interfaces', 'utunnel', self.config['ifname'], 'tunnel-type'])
+
+ def start(self):
+ config = get_utunnel_config(self.get_tunnel_type())
+ if config['scripts']['start']:
+ self._cmd(config['scripts']['start'].replace('{device}', self.ifname))
+
+ def stop(self):
+ config = get_utunnel_config(self.get_tunnel_type())
+ if config['scripts']['stop']:
+ self._cmd(config['scripts']['stop'].replace('{device}', self.ifname))
+
+ def restart(self):
+ self.stop()
+ self.start()
+
+ def update(self):
+ config = get_utunnel_config(self.get_tunnel_type())
+ if config['scripts']['update']:
+ self._cmd(config['scripts']['update'].replace('{device}', self.ifname))
+
+ def show_status(self):
+ config = get_utunnel_config(self.get_tunnel_type())
+ if config['scripts']['status']:
+ print(self._cmd(config['scripts']['status'].replace('{device}', self.ifname)))
+
+
+@Interface.register
+class CustomTunnelIf(Interface):
+ """
+ A dummy interface for custom tunnels
+ """
+
+ OperationalClass = CustomTunnelOperational
+
+ iftype = 'utunnel'
+ definition = {
+ **Interface.definition,
+ **{
+ 'section': 'utunnel',
+ 'prefixes': ['utun', ],
+ 'eternal': 'utun[0-9]+$',
+ },
+ }
+
+ def _create(self):
+ # don't create this interface as it is managed outside
+ pass
+
+ def _delete(self):
+ # don't create this interface as it is managed outside
+ pass
+
+ def get_mac(self):
+ """ Get a synthetic MAC address. """
+ return self.get_mac_synthetic()
+
+ def update(self, config):
+ # don't perform any update
+ pass
diff --git a/src/completion/list_utunnel_types.py b/src/completion/list_utunnel_types.py
new file mode 100755
index 0000000000..7f6e329ee7
--- /dev/null
+++ b/src/completion/list_utunnel_types.py
@@ -0,0 +1,41 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2023 VyOS maintainers and contributors
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 or later as
+# published by the Free Software Foundation.
+#
+# 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 .
+
+import argparse
+import glob
+import os
+import sys
+from pathlib import Path
+
+directory = '/config/utunnels/'
+pattern = os.path.join(directory, '*.yaml')
+
+parser = argparse.ArgumentParser(description='list available custom tunnel types')
+
+
+def get_custom_tunnel_types() -> list[str]:
+ types = []
+ for file_path in glob.glob(pattern):
+ basename = Path(file_path).stem
+ types.append(basename)
+
+ return sorted(types)
+
+
+if __name__ == '__main__':
+ args = parser.parse_args()
+ print("\n".join(get_custom_tunnel_types()))
+ sys.exit(0)
diff --git a/src/conf_mode/interfaces_utunnel.py b/src/conf_mode/interfaces_utunnel.py
new file mode 100755
index 0000000000..f7c5e762b8
--- /dev/null
+++ b/src/conf_mode/interfaces_utunnel.py
@@ -0,0 +1,96 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2018-2024 yOS maintainers and contributors
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 or later as
+# published by the Free Software Foundation.
+#
+# 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 .
+
+from sys import exit
+
+from vyos.config import Config
+from vyos.configdict import get_interface_dict
+from vyos.configdict import is_node_changed
+from vyos.configverify import verify_address
+from vyos.ifconfig import Interface
+from vyos.ifconfig import CustomTunnelIf
+from vyos.utils.dict import dict_search
+from vyos.utils.network import get_interface_config
+from vyos.utils.network import interface_exists
+from vyos import ConfigError
+from vyos import airbag
+airbag.enable()
+
+
+def get_config(config=None):
+ """
+ Retrive CLI config as dictionary. Dictionary can never be empty, as at least
+ the interface name will be added or a deleted flag
+ """
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['interfaces', 'utunnel']
+ ifname, utunnel = get_interface_dict(conf, base)
+
+ return utunnel
+
+
+def verify(utunnel):
+ if 'deleted' in utunnel:
+ return None
+
+ verify_address(utunnel)
+
+ # todo: if tunnel_type has no related yaml definitions, throws a warning
+
+ return None
+
+
+def generate(utunnel):
+ return None
+
+
+def apply(utunnel):
+ interface = utunnel['ifname']
+
+ intf = CustomTunnelIf(**utunnel)
+
+ if 'disable' in utunnel or 'deleted' in utunnel:
+ # WireGuard only supports peer removal based on the configured public-key,
+ # by deleting the entire interface this is the shortcut instead of parsing
+ # out all peers and removing them one by one.
+ #
+ # Peer reconfiguration will always come with a short downtime while the
+ # WireGuard interface is recreated (see below)
+
+ # call stop script
+ intf.operational.stop()
+ return None
+
+ # for custom tunnels, if manage-type is external, nothing need to be done.
+ # tun.update(utunnel)
+ # Users should manage external programs by systemd
+ # intf.operational.start()
+
+ return None
+
+
+if __name__ == '__main__':
+ try:
+ c = get_config()
+ generate(c)
+ verify(c)
+ apply(c)
+ except ConfigError as e:
+ print(e)
+ exit(1)
diff --git a/src/op_mode/utunnel.py b/src/op_mode/utunnel.py
new file mode 100755
index 0000000000..ca56e833cc
--- /dev/null
+++ b/src/op_mode/utunnel.py
@@ -0,0 +1,61 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2022-2023 VyOS maintainers and contributors
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 or later as
+# published by the Free Software Foundation.
+#
+# 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 .
+
+import sys
+import typing
+
+import vyos.opmode
+
+from vyos.ifconfig import CustomTunnelIf
+from vyos.configquery import ConfigTreeQuery
+
+
+def _verify(func):
+ """Decorator checks if WireGuard interface config exists"""
+ from functools import wraps
+
+ @wraps(func)
+ def _wrapper(*args, **kwargs):
+ config = ConfigTreeQuery()
+ interface = kwargs.get('interface')
+ if not config.exists(['interfaces', 'utunnel', interface]):
+ unconf_message = f'Custom Tunnel interface {interface} is not configured'
+ raise vyos.opmode.UnconfiguredSubsystem(unconf_message)
+ return func(*args, **kwargs)
+
+ return _wrapper
+
+
+@_verify
+def restart(raw: bool, interface: str):
+ intf = CustomTunnelIf(interface)
+ return intf.operational.restart()
+
+
+@_verify
+def show_status(raw: bool, interface: str):
+ intf = CustomTunnelIf(interface)
+ return intf.operational.show_status()
+
+
+if __name__ == '__main__':
+ try:
+ res = vyos.opmode.run(sys.modules[__name__])
+ if res:
+ print(res)
+ except (ValueError, vyos.opmode.Error) as e:
+ print(e)
+ sys.exit(1)