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):