From fd5adb0136aa49a9ec59bb16ff7956b7a9f8a85c Mon Sep 17 00:00:00 2001 From: Christian Poessinger Date: Mon, 18 Jul 2022 23:47:27 +0200 Subject: macsec: T4537: allow 32-byte keys for gcm-aes-256 (cherry picked from commit 393355f7feaa921eba46b83d4f15ad4a5c37adab) --- interface-definitions/interfaces-macsec.xml.in | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/interface-definitions/interfaces-macsec.xml.in b/interface-definitions/interfaces-macsec.xml.in index 96dcf3ca0..fa54aedc1 100644 --- a/interface-definitions/interfaces-macsec.xml.in +++ b/interface-definitions/interfaces-macsec.xml.in @@ -60,11 +60,12 @@ Secure Connectivity Association Key - key - 16-byte (128-bit) hex-string (32 hex-digits) + txt + 16-byte (128-bit) hex-string (32 hex-digits) for gcm-aes-128 or 32-byte (256-bit) hex-string (64 hex-digits) for gcm-aes-256 - ^[A-Fa-f0-9]{32}$ + [A-Fa-f0-9]{32} + [A-Fa-f0-9]{64} @@ -72,7 +73,7 @@ Secure Connectivity Association Key Name - key + txt 32-byte (256-bit) hex-string (64 hex-digits) -- cgit v1.2.3 From 488024e698ac5479b652f072b1680cdc62396f73 Mon Sep 17 00:00:00 2001 From: Christian Poessinger Date: Mon, 18 Jul 2022 23:48:31 +0200 Subject: macsec: T4537: support online ciper and source-interface re-configuration (cherry picked from commit 82d8494d349edd7707c3811a71ca0e9c0648204e) --- src/conf_mode/interfaces-macsec.py | 89 ++++++++++++++++++++------------------ 1 file changed, 46 insertions(+), 43 deletions(-) diff --git a/src/conf_mode/interfaces-macsec.py b/src/conf_mode/interfaces-macsec.py index e3527a366..eac92f149 100755 --- a/src/conf_mode/interfaces-macsec.py +++ b/src/conf_mode/interfaces-macsec.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020 VyOS maintainers and contributors +# Copyright (C) 2020-2022 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 @@ -21,17 +21,19 @@ from sys import exit from vyos.config import Config from vyos.configdict import get_interface_dict -from vyos.ifconfig import MACsecIf -from vyos.ifconfig import Interface -from vyos.template import render -from vyos.util import call -from vyos.util import dict_search +from vyos.configdict import is_node_changed from vyos.configverify import verify_vrf from vyos.configverify import verify_address from vyos.configverify import verify_bridge_delete from vyos.configverify import verify_mtu_ipv6 from vyos.configverify import verify_source_interface from vyos.configverify import verify_bond_bridge_member +from vyos.ifconfig import MACsecIf +from vyos.ifconfig import Interface +from vyos.template import render +from vyos.util import call +from vyos.util import dict_search +from vyos.util import is_systemd_service_running from vyos import ConfigError from vyos import airbag airbag.enable() @@ -56,6 +58,13 @@ def get_config(config=None): source_interface = conf.return_effective_value(['source-interface']) macsec.update({'source_interface': source_interface}) + ifname = macsec['ifname'] + if is_node_changed(conf, base + [ifname, 'security']): + macsec.update({'shutdown_required': {}}) + + if is_node_changed(conf, base + [ifname, 'source_interface']): + macsec.update({'shutdown_required': {}}) + return macsec @@ -70,20 +79,12 @@ def verify(macsec): verify_address(macsec) verify_bond_bridge_member(macsec) - if not (('security' in macsec) and - ('cipher' in macsec['security'])): - raise ConfigError( - 'Cipher suite must be set for MACsec "{ifname}"'.format(**macsec)) - - if (('security' in macsec) and - ('encrypt' in macsec['security'])): - tmp = macsec.get('security') + if dict_search('security.cipher', macsec) == None: + raise ConfigError('Cipher suite must be set for MACsec "{ifname}"'.format(**macsec)) - if not (('mka' in tmp) and - ('cak' in tmp['mka']) and - ('ckn' in tmp['mka'])): - raise ConfigError('Missing mandatory MACsec security ' - 'keys as encryption is enabled!') + if dict_search('security.encrypt', macsec) != None: + if dict_search('security.mka.cak', macsec) == None or dict_search('security.mka.ckn', macsec) == None: + raise ConfigError('Missing mandatory MACsec security keys as encryption is enabled!') cak_len = len(dict_search('security.mka.cak', macsec)) @@ -114,35 +115,37 @@ def generate(macsec): def apply(macsec): - # Remove macsec interface - if 'deleted' in macsec: - call('systemctl stop wpa_supplicant-macsec@{source_interface}' - .format(**macsec)) + systemd_service = 'wpa_supplicant-macsec@{source_interface}'.format(**macsec) + + # Remove macsec interface on deletion or mandatory parameter change + if 'deleted' in macsec or 'shutdown_required' in macsec: + call(f'systemctl stop {systemd_service}') if macsec['ifname'] in interfaces(): tmp = MACsecIf(macsec['ifname']) tmp.remove() - # delete configuration on interface removal - if os.path.isfile(wpa_suppl_conf.format(**macsec)): - os.unlink(wpa_suppl_conf.format(**macsec)) - - else: - # This is a special type of interface which needs additional parameters - # when created using iproute2. Instead of passing a ton of arguments, - # use a dictionary provided by the interface class which holds all the - # options necessary. - conf = MACsecIf.get_config() - conf['source_interface'] = macsec['source_interface'] - conf['security_cipher'] = macsec['security']['cipher'] - - # It is safe to "re-create" the interface always, there is a sanity - # check that the interface will only be create if its non existent - i = MACsecIf(macsec['ifname'], **conf) - i.update(macsec) - - call('systemctl restart wpa_supplicant-macsec@{source_interface}' - .format(**macsec)) + if 'deleted' in macsec: + # delete configuration on interface removal + if os.path.isfile(wpa_suppl_conf.format(**macsec)): + os.unlink(wpa_suppl_conf.format(**macsec)) + return None + + # This is a special type of interface which needs additional parameters + # when created using iproute2. Instead of passing a ton of arguments, + # use a dictionary provided by the interface class which holds all the + # options necessary. + conf = MACsecIf.get_config() + conf['source_interface'] = macsec['source_interface'] + conf['security_cipher'] = macsec['security']['cipher'] + + # It is safe to "re-create" the interface always, there is a sanity + # check that the interface will only be create if its non existent + i = MACsecIf(macsec['ifname'], **conf) + i.update(macsec) + + if not is_systemd_service_running(systemd_service) or 'shutdown_required' in macsec: + call(f'systemctl reload-or-restart {systemd_service}') return None -- cgit v1.2.3 From 66c1fbe7665fab4a51cbf1d626925d27e339f118 Mon Sep 17 00:00:00 2001 From: Christian Poessinger Date: Fri, 22 Jul 2022 23:17:42 +0200 Subject: macsec: T2023: fixup systemd unit description (cherry picked from commit bc70c1f502bc587627b1bd15f6803c6c09d20a66) --- src/systemd/wpa_supplicant-macsec@.service | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/systemd/wpa_supplicant-macsec@.service b/src/systemd/wpa_supplicant-macsec@.service index 7e0bee8e1..93bebd9d9 100644 --- a/src/systemd/wpa_supplicant-macsec@.service +++ b/src/systemd/wpa_supplicant-macsec@.service @@ -1,12 +1,10 @@ [Unit] -Description=WPA supplicant daemon (macsec-specific version) +Description=WPA supplicant daemon (MACsec-specific version) Requires=sys-subsystem-net-devices-%i.device ConditionPathExists=/run/wpa_supplicant/%I.conf After=vyos-router.service RequiresMountsFor=/run -# NetworkManager users will probably want the dbus version instead. - [Service] Type=simple WorkingDirectory=/run/wpa_supplicant -- cgit v1.2.3 From fdc7814f12bc0bd1a6442284a5dbac8adeb57f54 Mon Sep 17 00:00:00 2001 From: Christian Poessinger Date: Mon, 1 Aug 2022 19:46:17 +0200 Subject: macsec: T4537: restart wpa_supplicant on error (cherry picked from commit b2ff1407330e383a9fff688376377efc534bcfbc) --- src/systemd/wpa_supplicant-macsec@.service | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/systemd/wpa_supplicant-macsec@.service b/src/systemd/wpa_supplicant-macsec@.service index 93bebd9d9..39d0bf00b 100644 --- a/src/systemd/wpa_supplicant-macsec@.service +++ b/src/systemd/wpa_supplicant-macsec@.service @@ -9,7 +9,9 @@ RequiresMountsFor=/run Type=simple WorkingDirectory=/run/wpa_supplicant PIDFile=/run/wpa_supplicant/%I.pid -ExecStart=/sbin/wpa_supplicant -c/run/wpa_supplicant/%I.conf -Dmacsec_linux -i%I +ExecStart=/sbin/wpa_supplicant -d -c/run/wpa_supplicant/%I.conf -Dmacsec_linux -i%I +Restart=always +RestartSec=2 [Install] WantedBy=multi-user.target -- cgit v1.2.3 From 52b4b47f9e2484af7d493b72ebf0999c0047bf7b Mon Sep 17 00:00:00 2001 From: Christian Poessinger Date: Mon, 1 Aug 2022 20:53:47 +0200 Subject: macsec: T4537: supply PID path via systemd service file to daemon (cherry picked from commit 5e919d3f91bccaf64878a94756c21766896db132) --- src/systemd/wpa_supplicant-macsec@.service | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/systemd/wpa_supplicant-macsec@.service b/src/systemd/wpa_supplicant-macsec@.service index 39d0bf00b..703009ad5 100644 --- a/src/systemd/wpa_supplicant-macsec@.service +++ b/src/systemd/wpa_supplicant-macsec@.service @@ -9,7 +9,7 @@ RequiresMountsFor=/run Type=simple WorkingDirectory=/run/wpa_supplicant PIDFile=/run/wpa_supplicant/%I.pid -ExecStart=/sbin/wpa_supplicant -d -c/run/wpa_supplicant/%I.conf -Dmacsec_linux -i%I +ExecStart=/sbin/wpa_supplicant -d -c/run/wpa_supplicant/%I.conf -Dmacsec_linux -P/run/wpa_supplicant/%I.pid -i%I Restart=always RestartSec=2 -- cgit v1.2.3 From dc41d55eba5e47a105d295e27fd30a0e6d62c711 Mon Sep 17 00:00:00 2001 From: Christian Poessinger Date: Mon, 1 Aug 2022 21:10:19 +0200 Subject: macsec: T4537: remove debug falg "-d" from systemd service file (cherry picked from commit fa25d349aebc86e43957f37db765787fb7e431db) --- src/systemd/wpa_supplicant-macsec@.service | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/systemd/wpa_supplicant-macsec@.service b/src/systemd/wpa_supplicant-macsec@.service index 703009ad5..d5739583e 100644 --- a/src/systemd/wpa_supplicant-macsec@.service +++ b/src/systemd/wpa_supplicant-macsec@.service @@ -9,7 +9,7 @@ RequiresMountsFor=/run Type=simple WorkingDirectory=/run/wpa_supplicant PIDFile=/run/wpa_supplicant/%I.pid -ExecStart=/sbin/wpa_supplicant -d -c/run/wpa_supplicant/%I.conf -Dmacsec_linux -P/run/wpa_supplicant/%I.pid -i%I +ExecStart=/sbin/wpa_supplicant -c/run/wpa_supplicant/%I.conf -Dmacsec_linux -P/run/wpa_supplicant/%I.pid -i%I Restart=always RestartSec=2 -- cgit v1.2.3 From 99777682f8bc67d8da8eaea00cde7818cf15c9ea Mon Sep 17 00:00:00 2001 From: Christian Poessinger Date: Tue, 2 Aug 2022 11:06:15 +0200 Subject: macsec: T4537: add missing macsec_csindex option to support GCM-AES-256 (cherry picked from commit 258e6873b60531fe70d868d2e53ce2f921fe7f13) --- data/templates/macsec/wpa_supplicant.conf.tmpl | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/data/templates/macsec/wpa_supplicant.conf.tmpl b/data/templates/macsec/wpa_supplicant.conf.tmpl index 5b353def8..04f3700fc 100644 --- a/data/templates/macsec/wpa_supplicant.conf.tmpl +++ b/data/templates/macsec/wpa_supplicant.conf.tmpl @@ -47,6 +47,12 @@ network={ # 1: Integrity only macsec_integ_only={{ '0' if security is defined and security.encrypt is defined else '1' }} + # macsec_csindex: IEEE 802.1X/MACsec cipher suite + # 0 = GCM-AES-128 + # 1 = GCM-AES-256 +{# security.cipher is a mandatory key #} + macsec_csindex={{ '1' if security.cipher is defined and security.cipher == 'gcm-aes-256' else '0' }} + {% if security is defined %} {% if security.encrypt is defined %} # mka_cak, mka_ckn, and mka_priority: IEEE 802.1X/MACsec pre-shared key mode @@ -63,7 +69,13 @@ network={ # mka_priority (Priority of MKA Actor) is in 0..255 range with 255 being # default priority mka_priority={{ security.mka.priority }} -{% endif %} + + # macsec_csindex: IEEE 802.1X/MACsec cipher suite + # 0 = GCM-AES-128 + # 1 = GCM-AES-256 +{# security.cipher is a mandatory key #} + macsec_csindex={{ '1' if security.cipher is vyos_defined('gcm-aes-256') else '0' }} +{% endif %} {% if security.replay_window is defined %} # macsec_replay_protect: IEEE 802.1X/MACsec replay protection @@ -85,5 +97,9 @@ network={ macsec_replay_window={{ security.replay_window }} {% endif %} {% endif %} + + # macsec_port: IEEE 802.1X/MACsec port - Port component of the SCI + # Range: 1-65534 (default: 1) + macsec_port=1 } -- cgit v1.2.3 From ae139a68883caae9591e6ce17674e41d9e65c836 Mon Sep 17 00:00:00 2001 From: Christian Poessinger Date: Thu, 4 Aug 2022 08:28:08 +0200 Subject: smoketest: macsec: T4537: verify macsec_csindex (cherry picked from commit 17e76dc77801ac58b2587f664c884c0d671a55c0) --- smoketest/scripts/cli/test_interfaces_macsec.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/smoketest/scripts/cli/test_interfaces_macsec.py b/smoketest/scripts/cli/test_interfaces_macsec.py index 5b10bfa44..39b07dc06 100755 --- a/smoketest/scripts/cli/test_interfaces_macsec.py +++ b/smoketest/scripts/cli/test_interfaces_macsec.py @@ -104,6 +104,10 @@ class MACsecInterfaceTest(BasicInterfaceTest.TestCase): tmp = get_config_value(src_interface, 'mka_ckn') self.assertTrue(mak_ckn in tmp) + # check that we use the new macsec_csindex option (T4537) + tmp = get_config_value(src_interface, 'macsec_csindex') + self.assertTrue("1" in tmp) + # check that the default priority of 255 is programmed tmp = get_config_value(src_interface, 'mka_priority') self.assertTrue("255" in tmp) -- cgit v1.2.3 From 922871b4dc41f345d7ec1aae518ba91b6dfeb62c Mon Sep 17 00:00:00 2001 From: Christian Poessinger Date: Thu, 4 Aug 2022 08:29:14 +0200 Subject: macsec: T4592: can not create two interfaces using the same source-interface (cherry picked from commit 993961f60ead2a18912eb577b1152463d4eb8b4e) --- src/conf_mode/interfaces-macsec.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/conf_mode/interfaces-macsec.py b/src/conf_mode/interfaces-macsec.py index eac92f149..8076a27b6 100755 --- a/src/conf_mode/interfaces-macsec.py +++ b/src/conf_mode/interfaces-macsec.py @@ -22,6 +22,7 @@ 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.configdict import is_source_interface from vyos.configverify import verify_vrf from vyos.configverify import verify_address from vyos.configverify import verify_bridge_delete @@ -65,6 +66,10 @@ def get_config(config=None): if is_node_changed(conf, base + [ifname, 'source_interface']): macsec.update({'shutdown_required': {}}) + if 'source_interface' in macsec: + tmp = is_source_interface(conf, macsec['source_interface'], 'macsec') + if tmp and tmp != ifname: macsec.update({'is_source_interface' : tmp}) + return macsec @@ -96,6 +101,12 @@ def verify(macsec): # gcm-aes-128 requires a 128bit long key - 64 characters (string) = 32byte = 256bit raise ConfigError('gcm-aes-128 requires a 256bit long key!') + if 'is_source_interface' in macsec: + tmp = macsec['is_source_interface'] + src_ifname = macsec['source_interface'] + raise ConfigError(f'Can not use source-interface "{src_ifname}", it already ' \ + f'belongs to interface "{tmp}"!') + if 'source_interface' in macsec: # MACsec adds a 40 byte overhead (32 byte MACsec + 8 bytes VLAN 802.1ad # and 802.1q) - we need to check the underlaying MTU if our configured -- cgit v1.2.3 From 84f96733bb405453a66557322e43cc566f9ad29b Mon Sep 17 00:00:00 2001 From: Christian Poessinger Date: Thu, 4 Aug 2022 20:26:13 +0200 Subject: smoketest: macsec: T4537: validate macsec_csindex for both AES-GCM-128 and AES-GCM-256 (cherry picked from commit e19889adf8cef101d85a279055271a68b078ec73) --- smoketest/scripts/cli/test_interfaces_macsec.py | 49 ++++++++++++++++++------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/smoketest/scripts/cli/test_interfaces_macsec.py b/smoketest/scripts/cli/test_interfaces_macsec.py index 39b07dc06..64bfa0dc9 100755 --- a/smoketest/scripts/cli/test_interfaces_macsec.py +++ b/smoketest/scripts/cli/test_interfaces_macsec.py @@ -28,6 +28,8 @@ from vyos.util import read_file from vyos.util import get_interface_config from vyos.util import process_named_running +PROCESS_NAME = 'wpa_supplicant' + def get_config_value(interface, key): tmp = read_file(f'/run/wpa_supplicant/{interface}.conf') tmp = re.findall(r'\n?{}=(.*)'.format(key), tmp) @@ -55,6 +57,10 @@ class MACsecInterfaceTest(BasicInterfaceTest.TestCase): # call base-classes classmethod super(cls, cls).setUpClass() + def tearDown(self): + super().tearDown() + self.assertFalse(process_named_running(PROCESS_NAME)) + def test_macsec_encryption(self): # MACsec can be operating in authentication and encryption mode - both # using different mandatory settings, lets test encryption as the basic @@ -96,32 +102,29 @@ class MACsecInterfaceTest(BasicInterfaceTest.TestCase): self.cli_commit() tmp = get_config_value(src_interface, 'macsec_integ_only') - self.assertTrue("0" in tmp) + self.assertIn("0", tmp) tmp = get_config_value(src_interface, 'mka_cak') - self.assertTrue(mak_cak in tmp) + self.assertIn(mak_cak, tmp) tmp = get_config_value(src_interface, 'mka_ckn') - self.assertTrue(mak_ckn in tmp) - - # check that we use the new macsec_csindex option (T4537) - tmp = get_config_value(src_interface, 'macsec_csindex') - self.assertTrue("1" in tmp) + self.assertIn(mak_ckn, tmp) # check that the default priority of 255 is programmed tmp = get_config_value(src_interface, 'mka_priority') - self.assertTrue("255" in tmp) + self.assertIn("255", tmp) tmp = get_config_value(src_interface, 'macsec_replay_window') - self.assertTrue(replay_window in tmp) + self.assertIn(replay_window, tmp) tmp = read_file(f'/sys/class/net/{interface}/mtu') self.assertEqual(tmp, '1460') - # Check for running process - self.assertTrue(process_named_running('wpa_supplicant')) + # Check for running process + self.assertTrue(process_named_running(PROCESS_NAME)) def test_macsec_gcm_aes_128(self): + src_interface = 'eth0' interface = 'macsec1' cipher = 'gcm-aes-128' self.cli_set(self._base_path + [interface]) @@ -129,7 +132,7 @@ class MACsecInterfaceTest(BasicInterfaceTest.TestCase): # check validate() - source interface is mandatory with self.assertRaises(ConfigSessionError): self.cli_commit() - self.cli_set(self._base_path + [interface, 'source-interface', 'eth0']) + self.cli_set(self._base_path + [interface, 'source-interface', src_interface]) # check validate() - cipher is mandatory with self.assertRaises(ConfigSessionError): @@ -142,7 +145,15 @@ class MACsecInterfaceTest(BasicInterfaceTest.TestCase): self.assertIn(interface, interfaces()) self.assertEqual(cipher, get_cipher(interface)) + # check that we use the new macsec_csindex option (T4537) + tmp = get_config_value(src_interface, 'macsec_csindex') + self.assertIn("0", tmp) + + # Check for running process + self.assertTrue(process_named_running(PROCESS_NAME)) + def test_macsec_gcm_aes_256(self): + src_interface = 'eth0' interface = 'macsec4' cipher = 'gcm-aes-256' self.cli_set(self._base_path + [interface]) @@ -150,7 +161,7 @@ class MACsecInterfaceTest(BasicInterfaceTest.TestCase): # check validate() - source interface is mandatory with self.assertRaises(ConfigSessionError): self.cli_commit() - self.cli_set(self._base_path + [interface, 'source-interface', 'eth0']) + self.cli_set(self._base_path + [interface, 'source-interface', src_interface]) # check validate() - cipher is mandatory with self.assertRaises(ConfigSessionError): @@ -162,6 +173,13 @@ class MACsecInterfaceTest(BasicInterfaceTest.TestCase): self.assertIn(interface, interfaces()) self.assertEqual(cipher, get_cipher(interface)) + # check that we use the new macsec_csindex option (T4537) + tmp = get_config_value(src_interface, 'macsec_csindex') + self.assertIn("1", tmp) + + # Check for running process + self.assertTrue(process_named_running(PROCESS_NAME)) + def test_macsec_source_interface(self): # Ensure source-interface can bot be part of any other bond or bridge @@ -190,6 +208,9 @@ class MACsecInterfaceTest(BasicInterfaceTest.TestCase): self.cli_commit() self.assertIn(interface, interfaces()) + # Check for running process + self.assertTrue(process_named_running(PROCESS_NAME)) + if __name__ == '__main__': - unittest.main(verbosity=2) + unittest.main(verbosity=2, failfast=True) -- cgit v1.2.3 From df704a7cb884e879d8c905782aaf869daab31fab Mon Sep 17 00:00:00 2001 From: Christian Poessinger Date: Thu, 4 Aug 2022 20:55:25 +0200 Subject: macsec: T4537: macsec_csindex can be set even without encryption (cherry picked from commit 0943ac00412b0049b7a20a54e27e7b8025726598) --- data/templates/macsec/wpa_supplicant.conf.tmpl | 6 ------ 1 file changed, 6 deletions(-) diff --git a/data/templates/macsec/wpa_supplicant.conf.tmpl b/data/templates/macsec/wpa_supplicant.conf.tmpl index 04f3700fc..65747ea6f 100644 --- a/data/templates/macsec/wpa_supplicant.conf.tmpl +++ b/data/templates/macsec/wpa_supplicant.conf.tmpl @@ -69,12 +69,6 @@ network={ # mka_priority (Priority of MKA Actor) is in 0..255 range with 255 being # default priority mka_priority={{ security.mka.priority }} - - # macsec_csindex: IEEE 802.1X/MACsec cipher suite - # 0 = GCM-AES-128 - # 1 = GCM-AES-256 -{# security.cipher is a mandatory key #} - macsec_csindex={{ '1' if security.cipher is vyos_defined('gcm-aes-256') else '0' }} {% endif %} {% if security.replay_window is defined %} -- cgit v1.2.3