From c5032c0e0b65289f32017dca9396ce9e56ddc865 Mon Sep 17 00:00:00 2001 From: max-foss <> Date: Sun, 14 Sep 2025 08:49:40 +0200 Subject: [PATCH 1/9] #1905280 Proof of concept for xfrm_interfaces. --- doc/netplan-yaml.md | 43 +++++++ include/types.h | 1 + src/abi.h | 7 ++ src/names.c | 1 + src/networkd.c | 9 ++ src/parse.c | 66 +++++++++++ src/types-internal.h | 3 + src/types.c | 5 + src/validation.c | 21 ++++ .../config_fuzzer/schemas/xfrm-interfaces.js | 68 +++++++++++ tests/ctests/test_netplan_parser.c | 53 +++++++++ tests/generator/test_errors.py | 50 ++++++++ tests/generator/test_xfrm.py | 110 ++++++++++++++++++ 13 files changed, 437 insertions(+) create mode 100644 tests/config_fuzzer/schemas/xfrm-interfaces.js create mode 100644 tests/generator/test_xfrm.py diff --git a/doc/netplan-yaml.md b/doc/netplan-yaml.md index b22755755..dc5c37b15 100644 --- a/doc/netplan-yaml.md +++ b/doc/netplan-yaml.md @@ -65,6 +65,10 @@ network: > Configures Virtual Routing and Forwarding (VRF) devices. +- [**`xfrm-interfaces`**](#properties-for-device-type-xfrm-interfaces) (mapping) + + > Creates and configures XFRM devices for offloaded IPsec. + - [**`wifis`**](#properties-for-device-type-wifis) (mapping) > Configures physical Wi-Fi interfaces as `client`, `adhoc` or `access point`. @@ -1956,6 +1960,45 @@ VXLAN specific keys: > Allows setting the IPv4 Do not Fragment (DF) bit in outgoing packets. > Takes a boolean value. When unset, the kernel default will be used. +(yaml-xfrm-interfaces)= +## Properties for device type `xfrm-interfaces` + +**Status**: Optional. + +**Purpose**: Use the `xfrm-interfaces` key to create virtual XFRM (IPsec) interfaces. + +**Structure**: The key consists of a mapping of XFRM interface names. Each +`xfrm-interface` requires an `if_id`. The general configuration structure for +XFRM interfaces is shown below. + +```yaml +network: + xfrm-interfaces: + xfrm0: + if_id: 10 + link: eth0 + ... +``` + +When applied, a virtual interface called `xfrm0` will be created in the system. + +XFRM interfaces provide the kernel interface for IPsec transform operations. + +The specific settings for `xfrm-interfaces` are defined below. + +- **`if_id`** (scalar) + + > The XFRM interface ID (if_id). This is a required parameter. + > Takes a number in the range `1..4294967295`. + +- **`link`** (scalar) + + > The underlying physical interface for this XFRM interface. + +- **`independent`** (boolean) + + > If set to `true`, the XFRM interface is independent of the underlying interface (`link`). Defaults to `false`. + ## Properties for device type `virtual-ethernets` **Status**: Optional. diff --git a/include/types.h b/include/types.h index 55bbcf4d8..df5dce000 100644 --- a/include/types.h +++ b/include/types.h @@ -57,6 +57,7 @@ typedef enum { NETPLAN_DEF_TYPE_TUNNEL, NETPLAN_DEF_TYPE_PORT, NETPLAN_DEF_TYPE_VRF, + NETPLAN_DEF_TYPE_XFRM, /* Type fallback/passthrough */ NETPLAN_DEF_TYPE_NM, NETPLAN_DEF_TYPE_DUMMY, /* wokeignore:rule=dummy */ diff --git a/src/abi.h b/src/abi.h index 3bb25d31d..d3364f44f 100644 --- a/src/abi.h +++ b/src/abi.h @@ -356,6 +356,13 @@ struct netplan_net_definition { guint port; } tunnel; + /* XFRM interface properties */ + struct { + guint interface_id; + gboolean independent; + NetplanNetDefinition* link; + } xfrm; + NetplanAuthenticationSettings auth; gboolean has_auth; diff --git a/src/names.c b/src/names.c index 9c1a91ded..3989a71bd 100644 --- a/src/names.c +++ b/src/names.c @@ -49,6 +49,7 @@ netplan_def_type_to_str[NETPLAN_DEF_TYPE_MAX_] = { [NETPLAN_DEF_TYPE_VLAN] = "vlans", [NETPLAN_DEF_TYPE_VRF] = "vrfs", [NETPLAN_DEF_TYPE_TUNNEL] = "tunnels", + [NETPLAN_DEF_TYPE_XFRM] = "xfrm-interfaces", [NETPLAN_DEF_TYPE_DUMMY] = "dummy-devices", /* wokeignore:rule=dummy */ [NETPLAN_DEF_TYPE_VETH] = "virtual-ethernets", [NETPLAN_DEF_TYPE_PORT] = "_ovs-ports", diff --git a/src/networkd.c b/src/networkd.c index d0d820562..cbce2a44b 100644 --- a/src/networkd.c +++ b/src/networkd.c @@ -697,6 +697,15 @@ write_netdev_file(const NetplanNetDefinition* def, const char* rootdir, const ch write_tunnel_params(s, def); break; + /* Generate XFRM interface netdev file */ + case NETPLAN_DEF_TYPE_XFRM: + g_string_append_printf(s, "Kind=xfrm\n\n[Xfrm]\nInterfaceId=%u\n", def->xfrm.interface_id); + /* Independent interfaces operate without link device, in reality it will show up as @lo. */ + if (def->xfrm.independent) { + g_string_append(s, "Independent=true\n"); + } + break; + default: g_assert_not_reached(); // LCOV_EXCL_LINE } diff --git a/src/parse.c b/src/parse.c index 64a9a809f..77a5bc816 100644 --- a/src/parse.c +++ b/src/parse.c @@ -346,6 +346,36 @@ process_mapping(NetplanParser* npp, yaml_node_t* node, const char* key_prefix, c * Generic helper functions to extract data from scalar nodes. *************************************************************/ +/** + * Handler for setting a guint field from a scalar node, inside a given struct + * Supports hex (0x prefix) and decimal values + * @entryptr: pointer to the begining of the to-be-modified data structure + * @data: offset into entryptr struct where the guint field to write is located + */ +STATIC gboolean +handle_generic_guint_hex_dec(NetplanParser* npp, yaml_node_t* node, const void* entryptr, const void* data, GError** error) +{ + g_assert(entryptr != NULL); + guint offset = GPOINTER_TO_UINT(data); + guint64 v; + gchar* endptr; + + const char* s_node = scalar(node); + + if (g_str_has_prefix(s_node, "0x") || g_str_has_prefix(s_node, "0X")) { + v = g_ascii_strtoull(s_node, &endptr, 16); + } else { + v = g_ascii_strtoull(s_node, &endptr, 10); + } + + if (*endptr != '\0' || v > G_MAXUINT) + return yaml_error(npp, node, error, "invalid unsigned int value '%s'", s_node); + + mark_data_as_dirty(npp, entryptr + offset); + *((guint*) ((void*) entryptr + offset)) = (guint) v; + return TRUE; +} + /** * Handler for setting a guint field from a scalar node, inside a given struct * @entryptr: pointer to the begining of the to-be-modified data structure @@ -733,6 +763,12 @@ handle_netdef_guint(NetplanParser* npp, yaml_node_t* node, const void* data, GEr return handle_generic_guint(npp, node, npp->current.netdef, data, error); } +STATIC gboolean +handle_netdef_guint_hex_dec(NetplanParser* npp, yaml_node_t* node, const void* data, GError** error) +{ + return handle_generic_guint_hex_dec(npp, node, npp->current.netdef, data, error); +} + STATIC gboolean handle_netdef_ip4(NetplanParser* npp, yaml_node_t* node, const void* data, GError** error) { @@ -3141,6 +3177,15 @@ static const mapping_entry_handler tunnel_def_handlers[] = { {NULL} }; +static const mapping_entry_handler xfrm_def_handlers[] = { + COMMON_LINK_HANDLERS, + COMMON_BACKEND_HANDLERS, + {"if_id", YAML_SCALAR_NODE, {.generic=handle_netdef_guint_hex_dec}, netdef_offset(xfrm.interface_id)}, /* hex/dec like iproute2 */ + {"independent", YAML_SCALAR_NODE, {.generic=handle_netdef_bool}, netdef_offset(xfrm.independent)}, + {"link", YAML_SCALAR_NODE, {.generic=handle_netdef_id_ref}, netdef_offset(xfrm.link)}, + {NULL} +}; + /**************************************************** * Grammar and handlers for network node ****************************************************/ @@ -3375,6 +3420,7 @@ handle_network_type(NetplanParser* npp, yaml_node_t* node, const char* key_prefi case NETPLAN_DEF_TYPE_ETHERNET: handlers = ethernet_def_handlers; break; case NETPLAN_DEF_TYPE_MODEM: handlers = modem_def_handlers; break; case NETPLAN_DEF_TYPE_TUNNEL: handlers = tunnel_def_handlers; break; + case NETPLAN_DEF_TYPE_XFRM: handlers = xfrm_def_handlers; break; case NETPLAN_DEF_TYPE_VLAN: handlers = vlan_def_handlers; break; case NETPLAN_DEF_TYPE_VRF: handlers = vrf_def_handlers; break; case NETPLAN_DEF_TYPE_WIFI: handlers = wifi_def_handlers; break; @@ -3427,6 +3473,10 @@ handle_network_type(NetplanParser* npp, yaml_node_t* node, const char* key_prefi npp->current.netdef->vxlan->independent = TRUE; } + if (!npp->xfrm_if_ids) { + npp->xfrm_if_ids = g_hash_table_new(g_direct_hash, g_direct_equal); + } + /* validate definition-level conditions */ int ret = validate_netdef_grammar(npp, npp->current.netdef, error); if (!ret && (npp->flags & NETPLAN_PARSER_IGNORE_ERRORS) == 0) @@ -3485,6 +3535,7 @@ static const mapping_entry_handler network_handlers[] = { {"vlans", YAML_MAPPING_NODE, {.map={.custom=handle_network_type}}, GUINT_TO_POINTER(NETPLAN_DEF_TYPE_VLAN)}, {"vrfs", YAML_MAPPING_NODE, {.map={.custom=handle_network_type}}, GUINT_TO_POINTER(NETPLAN_DEF_TYPE_VRF)}, {"wifis", YAML_MAPPING_NODE, {.map={.custom=handle_network_type}}, GUINT_TO_POINTER(NETPLAN_DEF_TYPE_WIFI)}, + {"xfrm-interfaces", YAML_MAPPING_NODE, {.map={.custom=handle_network_type}}, GUINT_TO_POINTER(NETPLAN_DEF_TYPE_XFRM)}, {"modems", YAML_MAPPING_NODE, {.map={.custom=handle_network_type}}, GUINT_TO_POINTER(NETPLAN_DEF_TYPE_MODEM)}, {"dummy-devices", YAML_MAPPING_NODE, {.map={.custom=handle_network_type}}, GUINT_TO_POINTER(NETPLAN_DEF_TYPE_DUMMY)}, /* wokeignore:rule=dummy */ {"virtual-ethernets", YAML_MAPPING_NODE, {.map={.custom=handle_network_type}}, GUINT_TO_POINTER(NETPLAN_DEF_TYPE_VETH)}, @@ -3811,6 +3862,11 @@ netplan_parser_reset(NetplanParser* npp) } // LCOV_EXCL_STOP + if (npp->xfrm_if_ids) { + g_hash_table_destroy(npp->xfrm_if_ids); + npp->xfrm_if_ids = NULL; + } + if (npp->missing_id) { g_hash_table_destroy(npp->missing_id); npp->missing_id = NULL; @@ -3839,7 +3895,17 @@ netplan_parser_reset(NetplanParser* npp) npp->global_renderer = NULL; } + if (npp->xfrm_if_ids) { + g_hash_table_destroy(npp->xfrm_if_ids); + npp->xfrm_if_ids = NULL; + } + npp->flags = 0; + + if (npp->xfrm_if_ids) { + g_hash_table_destroy(npp->xfrm_if_ids); + npp->xfrm_if_ids = NULL; + } npp->error_count = 0; } diff --git a/src/types-internal.h b/src/types-internal.h index f8c1df3df..0bbf5112b 100644 --- a/src/types-internal.h +++ b/src/types-internal.h @@ -273,6 +273,9 @@ struct netplan_parser { * when the flag IGNORE_ERRORS is set * */ unsigned int error_count; + + /* Hash table to track XFRM interface IDs to ensure uniqueness */ + GHashTable* xfrm_if_ids; }; struct netplan_state_iterator { diff --git a/src/types.c b/src/types.c index 7a1c20ed2..c6ee6c6a7 100644 --- a/src/types.c +++ b/src/types.c @@ -350,6 +350,11 @@ reset_netdef(NetplanNetDefinition* netdef, NetplanDefType new_type, NetplanBacke memset(&netdef->tunnel, 0, sizeof(netdef->tunnel)); netdef->tunnel.mode = NETPLAN_TUNNEL_MODE_UNKNOWN; + /* Reset XFRM parameters */ + memset(&netdef->xfrm, 0, sizeof(netdef->xfrm)); + netdef->xfrm.independent = FALSE; + netdef->xfrm.link = NULL; + reset_auth_settings(&netdef->auth); netdef->has_auth = FALSE; diff --git a/src/validation.c b/src/validation.c index 14f906bc4..fd6a45c34 100644 --- a/src/validation.c +++ b/src/validation.c @@ -417,6 +417,27 @@ validate_netdef_grammar(const NetplanParser* npp, NetplanNetDefinition* nd, GErr } } + /* Validate XFRM interface configuration */ + if (nd->type == NETPLAN_DEF_TYPE_XFRM) { + if (nd->xfrm.interface_id == 0) { + return yaml_error(npp, NULL, error, "%s: missing 'if_id'", nd->id); + } + if (nd->xfrm.interface_id < 1 || nd->xfrm.interface_id > 0xffffffff) { + return yaml_error(npp, NULL, error, "%s: XFRM 'if_id' must be in range [1..0xffffffff]", nd->id); + } + if (!nd->xfrm.independent && nd->xfrm.link == NULL) { + return yaml_error(npp, NULL, error, "%s: Non-independent XFRM interfaces require property 'link'", nd->id); + } + + /* Ensure no xfrm if_id is used more than once */ + NetplanNetDefinition* existing_def = g_hash_table_lookup(npp->xfrm_if_ids, GINT_TO_POINTER(nd->xfrm.interface_id)); + if (existing_def != NULL && existing_def != nd) { + return yaml_error(npp, NULL, error, "%s: duplicate if_id '%u' (already used by %s)", + nd->id, nd->xfrm.interface_id, existing_def->id); + } + g_hash_table_insert(npp->xfrm_if_ids, GINT_TO_POINTER(nd->xfrm.interface_id), nd); + } + if (nd->type == NETPLAN_DEF_TYPE_VRF) { if (nd->vrf_table == G_MAXUINT) { return yaml_error(npp, NULL, error, "%s: missing 'table' property", nd->id); diff --git a/tests/config_fuzzer/schemas/xfrm-interfaces.js b/tests/config_fuzzer/schemas/xfrm-interfaces.js new file mode 100644 index 000000000..e3e3dd7d1 --- /dev/null +++ b/tests/config_fuzzer/schemas/xfrm-interfaces.js @@ -0,0 +1,68 @@ +import * as common from "./common.js"; + +const xfrm_interfaces_schema = { + type: "object", + additionalProperties: false, + properties: { + network: { + type: "object", + additionalProperties: false, + properties: { + renderer: { + type: "string", + enum: ["networkd"] + }, + version: { + type: "integer", + minimum: 2, + maximum: 2 + }, + ethernets: { + type: "object", + additionalProperties: false, + properties: { + "eth0": { + type: "object", + additionalProperties: false, + properties: { + "dhcp4": { + type: "boolean" + } + } + } + } + }, + "xfrm-interfaces": { + type: "object", + additionalProperties: false, + properties: { + "xfrm0": { + type: "object", + additionalProperties: false, + properties: { + "if_id": { + type: "integer", + minimum: 1, + maximum: 4294967295 + }, + "independent": { + type: "boolean" + }, + "link": { + type: "string", + enum: ["eth0"] + }, + ...common.common_properties + }, + required: ["if_id"] + } + } + } + }, + required: ["version", "xfrm-interfaces"] + } + }, + required: ["network"] +}; + +export default xfrm_interfaces_schema; \ No newline at end of file diff --git a/tests/ctests/test_netplan_parser.c b/tests/ctests/test_netplan_parser.c index d8020e420..4b5ad07f7 100644 --- a/tests/ctests/test_netplan_parser.c +++ b/tests/ctests/test_netplan_parser.c @@ -444,6 +444,57 @@ tear_down(__unused void** state) return 0; } +void +test_netplan_parser_xfrm_basic(__unused void** state) +{ + const char* yaml = "network:\n" + " version: 2\n" + " xfrm-interfaces:\n" + " xfrm0:\n" + " if_id: 42\n" + " independent: true\n"; + + NetplanState* np_state = load_string_to_netplan_state(yaml); + assert_non_null(np_state); + + // Check that the XFRM interface was parsed correctly + NetplanNetDefinition* netdef = netplan_state_get_netdef(np_state, "xfrm0"); + assert_non_null(netdef); + assert_int_equal(netdef->type, NETPLAN_DEF_TYPE_XFRM); + assert_int_equal(netdef->xfrm.interface_id, 42); + assert_true(netdef->xfrm.independent); + + netplan_state_clear(&np_state); +} + +void +test_netplan_parser_xfrm_with_link(__unused void** state) +{ + const char* yaml = "network:\n" + " version: 2\n" + " ethernets:\n" + " eth0:\n" + " dhcp4: true\n" + " xfrm-interfaces:\n" + " xfrm0:\n" + " if_id: 100\n" + " link: eth0\n"; + + NetplanState* np_state = load_string_to_netplan_state(yaml); + assert_non_null(np_state); + + // Check XFRM interface + NetplanNetDefinition* netdef = netplan_state_get_netdef(np_state, "xfrm0"); + assert_non_null(netdef); + assert_int_equal(netdef->type, NETPLAN_DEF_TYPE_XFRM); + assert_int_equal(netdef->xfrm.interface_id, 100); + assert_false(netdef->xfrm.independent); + assert_non_null(netdef->xfrm.link); + assert_string_equal(netdef->xfrm.link->id, "eth0"); + + netplan_state_clear(&np_state); +} + int main() { @@ -465,6 +516,8 @@ main() cmocka_unit_test(test_parser_flags_bad_flags), cmocka_unit_test(test_parser_flags_ignore_errors), cmocka_unit_test(test_parse_utf8_characters), + cmocka_unit_test(test_netplan_parser_xfrm_basic), + cmocka_unit_test(test_netplan_parser_xfrm_with_link), }; return cmocka_run_group_tests(tests, setup, tear_down); diff --git a/tests/generator/test_errors.py b/tests/generator/test_errors.py index d447c4920..4b626f7cf 100644 --- a/tests/generator/test_errors.py +++ b/tests/generator/test_errors.py @@ -1359,3 +1359,53 @@ def test_ignore_errors_missing_interface(self): LinkLocalAddressing=ipv6 ConfigureWithoutCarrier=yes '''}) + + def test_xfrm_missing_if_id(self): + err = self.generate('''network: + version: 2 + xfrm-interfaces: + xfrm0: + independent: true''', expect_fail=True) + self.assertIn("missing 'if_id' property", err) + + def test_xfrm_invalid_if_id_range(self): + err = self.generate('''network: + version: 2 + xfrm-interfaces: + xfrm0: + if_id: 0 + link: eth0''', expect_fail=True) + self.assertIn("XFRM 'if_id' must be in range [1..0xffffffff]", err) + + def test_xfrm_missing_link_non_independent(self): + err = self.generate('''network: + version: 2 + xfrm-interfaces: + xfrm0: + if_id: 42 + independent: false''', expect_fail=True) + self.assertIn("non-independent XFRM interface requires 'link' property", err) + + def test_xfrm_duplicate_if_id(self): + err = self.generate('''network: + version: 2 + xfrm-interfaces: + xfrm0: + if_id: 42 + independent: true + xfrm1: + if_id: 42 + independent: true''', expect_fail=True) + self.assertIn("duplicate if_id '42' (already used by xfrm0)", err) + + def test_xfrm_duplicate_if_id_hex_dec(self): + err = self.generate('''network: + version: 2 + xfrm-interfaces: + xfrm0: + if_id: 42 + independent: true + xfrm1: + if_id: 0x2A + independent: true''', expect_fail=True) + self.assertIn("duplicate if_id '42' (already used by xfrm0)", err) diff --git a/tests/generator/test_xfrm.py b/tests/generator/test_xfrm.py new file mode 100644 index 000000000..da06bb031 --- /dev/null +++ b/tests/generator/test_xfrm.py @@ -0,0 +1,110 @@ +# +# Tests for XFRM interface config generation +# +# Copyright (C) 2024 Canonical, Ltd. +# +# 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. +# +# 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 .base import TestBase + + +class TestNetworkdXfrm(TestBase): + + def test_xfrm_basic(self): + self.generate('''network: + version: 2 + renderer: networkd + ethernets: + eth0: + dhcp4: true + xfrm-interfaces: + xfrm0: + if_id: 42 + link: eth0 + addresses: [192.168.1.10/24]''') + + self.assert_networkd({'xfrm0.netdev': '''[NetDev] +Name=xfrm0 +Kind=xfrm + +[Xfrm] +InterfaceId=42 +''', + 'xfrm0.network': '''[Match] +Name=xfrm0 + +[Network] +LinkLocalAddressing=no +Address=192.168.1.10/24 +'''}) + + def test_xfrm_independent(self): + self.generate('''network: + version: 2 + renderer: networkd + xfrm-interfaces: + xfrm0: + if_id: 100 + independent: true''') + + self.assert_networkd({'xfrm0.netdev': '''[NetDev] +Name=xfrm0 +Kind=xfrm + +[Xfrm] +InterfaceId=100 +Independent=true +'''}) + + +class TestNetworkManagerXfrm(TestBase): + + def test_xfrm_nm_not_supported(self): + err = self.generate('''network: + version: 2 + renderer: NetworkManager + xfrm-interfaces: + xfrm0: + if_id: 42 + independent: true''', expect_fail=True) + + self.assertIn('XFRM interfaces are not supported by NetworkManager', err) + + def test_xfrm_hex_dec_different_values(self): + self.generate('''network: + version: 2 + xfrm-interfaces: + xfrm0: + if_id: 42 + independent: true + xfrm1: + if_id: 0x2B + independent: true''') + + # Verify both interfaces are generated with correct if_id values + self.assert_networkd({'xfrm0.netdev': '''[NetDev] +Name=xfrm0 +Kind=xfrm + +[Xfrm] +InterfaceId=42 +Independent=true +''', + 'xfrm1.netdev': '''[NetDev] +Name=xfrm1 +Kind=xfrm + +[Xfrm] +InterfaceId=43 +Independent=true +'''}) From 52b052df492985db2fe3137ff6c118c494f33e3d Mon Sep 17 00:00:00 2001 From: max-foss <> Date: Mon, 15 Sep 2025 23:03:58 +0200 Subject: [PATCH 2/9] Fix ABI compatibility. --- include/types.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/include/types.h b/include/types.h index df5dce000..fc9a2f1cd 100644 --- a/include/types.h +++ b/include/types.h @@ -57,11 +57,11 @@ typedef enum { NETPLAN_DEF_TYPE_TUNNEL, NETPLAN_DEF_TYPE_PORT, NETPLAN_DEF_TYPE_VRF, - NETPLAN_DEF_TYPE_XFRM, /* Type fallback/passthrough */ NETPLAN_DEF_TYPE_NM, NETPLAN_DEF_TYPE_DUMMY, /* wokeignore:rule=dummy */ NETPLAN_DEF_TYPE_VETH, + NETPLAN_DEF_TYPE_XFRM, /* Place holder type used to fill gaps when a netdef * requires links to another netdef (such as vlan_link) * but it's not strictly mandatory From 848c1cbd49642477b08733d05c148008f127e9ae Mon Sep 17 00:00:00 2001 From: max-foss <> Date: Mon, 15 Sep 2025 23:54:27 +0200 Subject: [PATCH 3/9] Attempt to make some more tests happy. --- src/netplan.c | 9 +++++++++ src/nm.c | 5 +++++ src/validation.c | 4 ++-- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/netplan.c b/src/netplan.c index d6ab84902..993437e67 100644 --- a/src/netplan.c +++ b/src/netplan.c @@ -865,6 +865,15 @@ _serialize_yaml( if (def->type == NETPLAN_DEF_TYPE_VRF) YAML_UINT_DEFAULT(def, event, emitter, "table", def->vrf_table, G_MAXUINT); + /* XFRM settings */ + if (def->type == NETPLAN_DEF_TYPE_XFRM) { + YAML_UINT_DEFAULT(def, event, emitter, "if_id", def->xfrm.interface_id, 0); + if (def->xfrm.link) + YAML_STRING(def, event, emitter, "link", def->xfrm.link->id); + if (def->xfrm.independent) + YAML_BOOL_TRUE(def, event, emitter, "independent", def->xfrm.independent); + } + /* Tunnel settings */ if (def->type == NETPLAN_DEF_TYPE_TUNNEL) { write_tunnel_settings(event, emitter, def); diff --git a/src/nm.c b/src/nm.c index 583fd2d62..41865ae00 100644 --- a/src/nm.c +++ b/src/nm.c @@ -1101,6 +1101,11 @@ _netplan_netdef_write_nm( return FALSE; } + if (netdef->type == NETPLAN_DEF_TYPE_XFRM) { + g_set_error(error, NETPLAN_BACKEND_ERROR, NETPLAN_ERROR_UNSUPPORTED, "ERROR: %s: XFRM interfaces are not supported by NetworkManager\n", netdef->id); + return FALSE; + } + if (netdef->type == NETPLAN_DEF_TYPE_VETH) { /* * Final validation of veths that can't be fully done during parsing due to the diff --git a/src/validation.c b/src/validation.c index fd6a45c34..09da4f0ff 100644 --- a/src/validation.c +++ b/src/validation.c @@ -420,13 +420,13 @@ validate_netdef_grammar(const NetplanParser* npp, NetplanNetDefinition* nd, GErr /* Validate XFRM interface configuration */ if (nd->type == NETPLAN_DEF_TYPE_XFRM) { if (nd->xfrm.interface_id == 0) { - return yaml_error(npp, NULL, error, "%s: missing 'if_id'", nd->id); + return yaml_error(npp, NULL, error, "%s: missing 'if_id' property", nd->id); } if (nd->xfrm.interface_id < 1 || nd->xfrm.interface_id > 0xffffffff) { return yaml_error(npp, NULL, error, "%s: XFRM 'if_id' must be in range [1..0xffffffff]", nd->id); } if (!nd->xfrm.independent && nd->xfrm.link == NULL) { - return yaml_error(npp, NULL, error, "%s: Non-independent XFRM interfaces require property 'link'", nd->id); + return yaml_error(npp, NULL, error, "%s: non-independent XFRM interface requires 'link' property", nd->id); } /* Ensure no xfrm if_id is used more than once */ From 8281b5ed1f4ec7d64620c551b39ea4d8ad24896f Mon Sep 17 00:00:00 2001 From: max-foss <> Date: Tue, 16 Sep 2025 12:55:08 +0200 Subject: [PATCH 4/9] Minor improvements and unit test fixes. --- src/networkd.c | 27 ++++++++++++++--------- src/validation.c | 40 +++++++++++++++++++--------------- tests/generator/test_errors.py | 4 ++-- 3 files changed, 42 insertions(+), 29 deletions(-) diff --git a/src/networkd.c b/src/networkd.c index cbce2a44b..93f1d3c98 100644 --- a/src/networkd.c +++ b/src/networkd.c @@ -905,15 +905,17 @@ _netplan_netdef_write_network_file( /* Set link local addressing -- this does not apply to bond and bridge * member interfaces, which always get it disabled. */ - if (!def->bond && !def->bridge && (def->linklocal.ipv4 || def->linklocal.ipv6)) { - if (def->linklocal.ipv4 && def->linklocal.ipv6) - g_string_append(network, "LinkLocalAddressing=yes\n"); - else if (def->linklocal.ipv4) - g_string_append(network, "LinkLocalAddressing=ipv4\n"); - else if (def->linklocal.ipv6) - g_string_append(network, "LinkLocalAddressing=ipv6\n"); - } else { - g_string_append(network, "LinkLocalAddressing=no\n"); + if (def->type != NETPLAN_DEF_TYPE_XFRM) { + if (!def->bond && !def->bridge && (def->linklocal.ipv4 || def->linklocal.ipv6)) { + if (def->linklocal.ipv4 && def->linklocal.ipv6) + g_string_append(network, "LinkLocalAddressing=yes\n"); + else if (def->linklocal.ipv4) + g_string_append(network, "LinkLocalAddressing=ipv4\n"); + else if (def->linklocal.ipv6) + g_string_append(network, "LinkLocalAddressing=ipv6\n"); + } else { + g_string_append(network, "LinkLocalAddressing=no\n"); + } } if (def->ip4_addresses) @@ -966,7 +968,7 @@ _netplan_netdef_write_network_file( g_string_append_printf(network, "IPv6MTUBytes=%d\n", def->ipv6_mtubytes); } - if (def->type >= NETPLAN_DEF_TYPE_VIRTUAL || def->ignore_carrier) + if ((def->type >= NETPLAN_DEF_TYPE_VIRTUAL && def->type != NETPLAN_DEF_TYPE_XFRM) || def->ignore_carrier) g_string_append(network, "ConfigureWithoutCarrier=yes\n"); if (def->critical) @@ -1119,6 +1121,11 @@ _netplan_netdef_write_network_file( } } + if (def->type == NETPLAN_DEF_TYPE_XFRM && !def->xfrm.independent) { + if (network->len > 0 && !g_str_has_prefix(network->str, "LinkLocalAddressing=")) + g_string_prepend(network, "LinkLocalAddressing=no\n"); + } + if (network->len > 0 || link->len > 0) { s = g_string_sized_new(200); append_match_section(def, s, TRUE); diff --git a/src/validation.c b/src/validation.c index 09da4f0ff..cc994502e 100644 --- a/src/validation.c +++ b/src/validation.c @@ -367,6 +367,8 @@ gboolean validate_netdef_grammar(const NetplanParser* npp, NetplanNetDefinition* nd, GError** error) { guint missing_id_count = g_hash_table_size(npp->missing_id); + gboolean missing_dependencies = (missing_id_count > 0 && + (npp->flags & NETPLAN_PARSER_IGNORE_ERRORS) == 0); gboolean valid = FALSE; NetplanBackend backend = nd->backend; @@ -375,7 +377,25 @@ validate_netdef_grammar(const NetplanParser* npp, NetplanNetDefinition* nd, GErr /* Skip all validation if we're missing some definition IDs (devices). * The ones we have yet to see may be necessary for validation to succeed, * we can complete it on the next parser pass. */ - if (missing_id_count > 0 && (npp->flags & NETPLAN_PARSER_IGNORE_ERRORS) == 0) { + /* XFRM interfaces have some validation that does not require all + * referenced definitions to be available (most notably the if_id + * range/uniqueness checks). Run those always so that we can fail fast + * even if we are still waiting for other definitions to be parsed. */ + if (nd->type == NETPLAN_DEF_TYPE_XFRM) { + if (nd->xfrm.interface_id == 0 || nd->xfrm.interface_id > 0xffffffff) { + return yaml_error(npp, NULL, error, "%s: 'if_id' property must be in range [1..4294967295] or [0x1..0xffffffff]", nd->id); + } + + NetplanNetDefinition* existing_def = g_hash_table_lookup(npp->xfrm_if_ids, + GINT_TO_POINTER(nd->xfrm.interface_id)); + if (existing_def != NULL && existing_def != nd) { + return yaml_error(npp, NULL, error, "%s: duplicate if_id '%u' (already used by %s)", + nd->id, nd->xfrm.interface_id, existing_def->id); + } + g_hash_table_insert(npp->xfrm_if_ids, GINT_TO_POINTER(nd->xfrm.interface_id), nd); + } + + if (missing_dependencies) { return TRUE; } @@ -417,25 +437,11 @@ validate_netdef_grammar(const NetplanParser* npp, NetplanNetDefinition* nd, GErr } } - /* Validate XFRM interface configuration */ - if (nd->type == NETPLAN_DEF_TYPE_XFRM) { - if (nd->xfrm.interface_id == 0) { - return yaml_error(npp, NULL, error, "%s: missing 'if_id' property", nd->id); - } - if (nd->xfrm.interface_id < 1 || nd->xfrm.interface_id > 0xffffffff) { - return yaml_error(npp, NULL, error, "%s: XFRM 'if_id' must be in range [1..0xffffffff]", nd->id); - } + /* Validate XFRM interface configuration that requires resolved links */ + if (nd->type == NETPLAN_DEF_TYPE_XFRM) { if (!nd->xfrm.independent && nd->xfrm.link == NULL) { return yaml_error(npp, NULL, error, "%s: non-independent XFRM interface requires 'link' property", nd->id); } - - /* Ensure no xfrm if_id is used more than once */ - NetplanNetDefinition* existing_def = g_hash_table_lookup(npp->xfrm_if_ids, GINT_TO_POINTER(nd->xfrm.interface_id)); - if (existing_def != NULL && existing_def != nd) { - return yaml_error(npp, NULL, error, "%s: duplicate if_id '%u' (already used by %s)", - nd->id, nd->xfrm.interface_id, existing_def->id); - } - g_hash_table_insert(npp->xfrm_if_ids, GINT_TO_POINTER(nd->xfrm.interface_id), nd); } if (nd->type == NETPLAN_DEF_TYPE_VRF) { diff --git a/tests/generator/test_errors.py b/tests/generator/test_errors.py index 4b626f7cf..02075a8df 100644 --- a/tests/generator/test_errors.py +++ b/tests/generator/test_errors.py @@ -1366,7 +1366,7 @@ def test_xfrm_missing_if_id(self): xfrm-interfaces: xfrm0: independent: true''', expect_fail=True) - self.assertIn("missing 'if_id' property", err) + self.assertIn("'if_id' property must be in range [1..4294967295] or [0x1..0xffffffff]", err) def test_xfrm_invalid_if_id_range(self): err = self.generate('''network: @@ -1375,7 +1375,7 @@ def test_xfrm_invalid_if_id_range(self): xfrm0: if_id: 0 link: eth0''', expect_fail=True) - self.assertIn("XFRM 'if_id' must be in range [1..0xffffffff]", err) + self.assertIn("'if_id' property must be in range [1..4294967295] or [0x1..0xffffffff]", err) def test_xfrm_missing_link_non_independent(self): err = self.generate('''network: From f6187bfd7fcc837e54578d4e67d51562eefa853e Mon Sep 17 00:00:00 2001 From: max-foss <> Date: Tue, 16 Sep 2025 13:18:43 +0200 Subject: [PATCH 5/9] Forgot to commit the Python unit-test fix before. --- tests/generator/test_xfrm.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/generator/test_xfrm.py b/tests/generator/test_xfrm.py index da06bb031..5b18fca3b 100644 --- a/tests/generator/test_xfrm.py +++ b/tests/generator/test_xfrm.py @@ -15,7 +15,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from .base import TestBase +from .base import ND_DHCP4, TestBase class TestNetworkdXfrm(TestBase): @@ -33,7 +33,8 @@ def test_xfrm_basic(self): link: eth0 addresses: [192.168.1.10/24]''') - self.assert_networkd({'xfrm0.netdev': '''[NetDev] + self.assert_networkd({'eth0.network': ND_DHCP4 % 'eth0', + 'xfrm0.netdev': '''[NetDev] Name=xfrm0 Kind=xfrm From 0add32def70530f6750ded7c370a2292e2f0815e Mon Sep 17 00:00:00 2001 From: max-foss <> Date: Sun, 21 Sep 2025 05:53:59 +0200 Subject: [PATCH 6/9] Attempt to remove unreachable code to improve test coverage. --- src/parse.c | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/parse.c b/src/parse.c index 77a5bc816..b5e5a8bd1 100644 --- a/src/parse.c +++ b/src/parse.c @@ -3895,17 +3895,7 @@ netplan_parser_reset(NetplanParser* npp) npp->global_renderer = NULL; } - if (npp->xfrm_if_ids) { - g_hash_table_destroy(npp->xfrm_if_ids); - npp->xfrm_if_ids = NULL; - } - npp->flags = 0; - - if (npp->xfrm_if_ids) { - g_hash_table_destroy(npp->xfrm_if_ids); - npp->xfrm_if_ids = NULL; - } npp->error_count = 0; } From 8bb24d246c93dd922139635348202ebafa154776 Mon Sep 17 00:00:00 2001 From: max-foss <> Date: Sun, 21 Sep 2025 12:36:40 +0200 Subject: [PATCH 7/9] Added one // LCOV_EXCL_LINE just like everybode else, lol. --- src/parse.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/parse.c b/src/parse.c index b5e5a8bd1..80de67f63 100644 --- a/src/parse.c +++ b/src/parse.c @@ -369,7 +369,7 @@ handle_generic_guint_hex_dec(NetplanParser* npp, yaml_node_t* node, const void* } if (*endptr != '\0' || v > G_MAXUINT) - return yaml_error(npp, node, error, "invalid unsigned int value '%s'", s_node); + return yaml_error(npp, node, error, "invalid unsigned int value '%s'", s_node); // LCOV_EXCL_LINE mark_data_as_dirty(npp, entryptr + offset); *((guint*) ((void*) entryptr + offset)) = (guint) v; From 70c84fd8d075c0aec43c036148d26cdcd580fce7 Mon Sep 17 00:00:00 2001 From: max-foss <> Date: Mon, 22 Sep 2025 01:07:21 +0200 Subject: [PATCH 8/9] Update dictionary. --- doc/.custom_wordlist.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doc/.custom_wordlist.txt b/doc/.custom_wordlist.txt index 1ec8f3fdc..2d3ef937e 100644 --- a/doc/.custom_wordlist.txt +++ b/doc/.custom_wordlist.txt @@ -37,6 +37,7 @@ IPv InfiniBand InterVLAN IoT +IPsec KVM LACPDUs LAI @@ -102,6 +103,7 @@ WakeOnWLan Wi Wi-Fi WireGuard +XFRM adapters autostart boolean From d2a0f6a8f8fcc02b0fb5d85e6ae433a6cfbabba7 Mon Sep 17 00:00:00 2001 From: max-foss <> Date: Mon, 29 Sep 2025 22:20:53 +0200 Subject: [PATCH 9/9] Fix for parent interface issues. --- src/networkd.c | 31 +++++++++++++++- tests/generator/test_xfrm.py | 72 +++++++++++++++++++++++++++++++++++- 2 files changed, 99 insertions(+), 4 deletions(-) diff --git a/src/networkd.c b/src/networkd.c index 93f1d3c98..9cb56019b 100644 --- a/src/networkd.c +++ b/src/networkd.c @@ -698,9 +698,20 @@ write_netdev_file(const NetplanNetDefinition* def, const char* rootdir, const ch break; /* Generate XFRM interface netdev file */ - case NETPLAN_DEF_TYPE_XFRM: + case NETPLAN_DEF_TYPE_XFRM: g_string_append_printf(s, "Kind=xfrm\n\n[Xfrm]\nInterfaceId=%u\n", def->xfrm.interface_id); - /* Independent interfaces operate without link device, in reality it will show up as @lo. */ + if (!def->xfrm.independent && def->xfrm.link) { + const NetplanNetDefinition* parent = def->xfrm.link; + /* Determine the actual kernel interface name for the parent. + * This must match the name used in the parent's .network file [Match] section. + * Priority: set-name (if renamed) > match.original_name (if matched) > id (netplan ID) + */ + const char* parent_name = parent->set_name ? parent->set_name : + (parent->match.original_name ? parent->match.original_name : parent->id); + + g_string_append_printf(s, "Parent=%s\n", parent_name); + } + /* Independent interfaces operate without link device, in reality it will show up as @lo. */ if (def->xfrm.independent) { g_string_append(s, "Independent=true\n"); } @@ -1017,6 +1028,22 @@ _netplan_netdef_write_network_file( if (def->vrf_link) g_string_append_printf(network, "VRF=%s\n", def->vrf_link->id); + { + GList* l = np_state->netdefs_ordered; + for (; l != NULL; l = l->next) { + const NetplanNetDefinition* nd = l->data; + + if (nd->type != NETPLAN_DEF_TYPE_XFRM) + continue; + + if (nd->xfrm.independent) + continue; + + if (nd->xfrm.link == def) + g_string_append_printf(network, "Xfrm=%s\n", nd->id); + } + } + /* VXLAN options */ if (def->has_vxlans) { /* iterate over all netdefs to find VXLANs attached to us */ diff --git a/tests/generator/test_xfrm.py b/tests/generator/test_xfrm.py index 5b18fca3b..cb9484c38 100644 --- a/tests/generator/test_xfrm.py +++ b/tests/generator/test_xfrm.py @@ -20,7 +20,7 @@ class TestNetworkdXfrm(TestBase): - def test_xfrm_basic(self): + def test_xfrm(self): self.generate('''network: version: 2 renderer: networkd @@ -33,13 +33,16 @@ def test_xfrm_basic(self): link: eth0 addresses: [192.168.1.10/24]''') - self.assert_networkd({'eth0.network': ND_DHCP4 % 'eth0', + expected_eth0 = (ND_DHCP4 % 'eth0').replace('LinkLocalAddressing=ipv6\n', 'LinkLocalAddressing=ipv6\nXfrm=xfrm0\n') + + self.assert_networkd({'eth0.network': expected_eth0, 'xfrm0.netdev': '''[NetDev] Name=xfrm0 Kind=xfrm [Xfrm] InterfaceId=42 +Parent=eth0 ''', 'xfrm0.network': '''[Match] Name=xfrm0 @@ -67,6 +70,71 @@ def test_xfrm_independent(self): Independent=true '''}) + def test_xfrm_parent_with_set_name(self): + """Test XFRM interface with parent that has set-name, Parent= should use actual interface name""" + self.generate('''network: + version: 2 + renderer: networkd + ethernets: + myeth: + match: {macaddress: "00:11:22:33:44:55"} + set-name: eth0 + xfrm-interfaces: + xfrm0: {if_id: 42, link: myeth}''') + + # Parent= should use set-name (eth0), not netplan ID (myeth) + self.assert_networkd({'myeth.link': '''[Match] +PermanentMACAddress=00:11:22:33:44:55 + +[Link] +Name=eth0 +WakeOnLan=off +''', + 'myeth.network': '''[Match] +PermanentMACAddress=00:11:22:33:44:55 +Name=eth0 + +[Network] +LinkLocalAddressing=ipv6 +Xfrm=xfrm0 +''', + 'xfrm0.netdev': '''[NetDev] +Name=xfrm0 +Kind=xfrm + +[Xfrm] +InterfaceId=42 +Parent=eth0 +'''}) + + def test_xfrm_parent_with_match_no_set_name(self): + """Test XFRM interface with parent using match but no set-name, Parent= should use matched name""" + self.generate('''network: + version: 2 + renderer: networkd + ethernets: + myeth: + match: {name: eth0} + xfrm-interfaces: + xfrm0: {if_id: 42, link: myeth}''') + + # Parent= should use match.original_name (eth0), not netplan ID (myeth) + self.assert_networkd({'myeth.network': '''[Match] +Name=eth0 + +[Network] +LinkLocalAddressing=ipv6 +Xfrm=xfrm0 +''', + 'xfrm0.netdev': '''[NetDev] +Name=xfrm0 +Kind=xfrm + +[Xfrm] +InterfaceId=42 +Parent=eth0 +'''}) + class TestNetworkManagerXfrm(TestBase):