confd: allow both ends of a veth pair to be assigned to containers

Previously at least one end of a veth pair had to remain in the host
namespace, because that end created and destroyed the pair.  Assigning
both ends to containers left no one to create it.

Select a deterministic primary end so exactly one side creates the pair.
When the primary is itself a container interface, create the pair in the
host namespace before the container starts; CNI host-device then moves
each end into its container.  Teardown is deferred to the container
removal script so the pair does not linger and block re-creation.

Drop the now-obsolete limitation notes from the documentation and YANG,
and add a regression test connecting two containers over a veth pair.

Fixes: #941

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2026-06-20 19:26:22 +02:00
parent 0229057612
commit 674fd6c396
14 changed files with 241 additions and 28 deletions
+2
View File
@@ -30,6 +30,8 @@ All notable changes to the project are documented in this file.
### Fixes
- Fix #941: a VETH pair can now connect two containers directly, with both
ends assigned to containers.
- Enabling IP masquerading in the firewall no longer enables IP forwarding on
all interfaces. This has been an issue ever since the firewall support was
introduced in v25.10.0
+3 -5
View File
@@ -668,11 +668,9 @@ set:
For an example of both, see the next section.
> [!IMPORTANT]
> **VETH Pair Limitation:** When using VETH pairs with containers, at least
> one side of the pair must remain in the host namespace. It is currently
> not possible to create VETH pairs where both ends are assigned to different
> containers. One end must always be accessible from the host.
> [!TIP]
> Both ends of a VETH pair may be assigned to containers, connecting two
> containers directly without involving the host namespace.
[^3]: Something which the container bridge network type does behind the
scenes with one end of an automatically created VETH pair.
+10
View File
@@ -414,6 +414,16 @@ int cni_netdag_gen_iface(struct dagger *net, const char *ifname,
return -EIO;
fprintf(fp, "container -a -f delete network %s >/dev/null\n", ifname);
/* If this end belongs to a veth pair, the kernel keeps the pair
* alive after CNI host-device returns the interface to the host
* namespace. Remove it here, once the container is gone, so the
* pair does not linger and block a later re-creation. Tolerant:
* the peer's teardown may already have removed it.
*/
if (lydx_get_child(dif, "veth"))
fprintf(fp, "ip link del dev %s 2>/dev/null || true\n", ifname);
fclose(fp);
if (cni_type == IFT_BRIDGE)
+15 -10
View File
@@ -21,23 +21,28 @@
bool veth_is_primary(struct lyd_node *cif)
{
struct lyd_node *peer, *veth;
bool self_cni, peer_cni;
const char *peername;
veth = lydx_get_child(cif, "veth");
peername = lydx_get_cattr(veth, "peer");
peer = lydx_find_by_name(lyd_parent(cif), "interface", peername);
/* At the moment, CNI code relies on one side of the pair
* remaining in the host namespace, and that that interface
* takes care of creating the pair.
*/
if (lydx_get_child(cif, "container-network"))
return false;
if (lydx_get_child(peer, "container-network"))
return true;
self_cni = lydx_get_child(cif, "container-network") != NULL;
peer_cni = lydx_get_child(peer, "container-network") != NULL;
return strcmp(lydx_get_cattr(cif, "name"),
lydx_get_cattr(veth, "peer")) < 0;
/* When exactly one end is handed to a container (CNI host-device),
* the other end stays in the host namespace and creates the pair.
*/
if (self_cni != peer_cni)
return peer_cni;
/* Neither or both ends are container interfaces: pick a stable
* primary by name so exactly one end creates the pair. When both
* ends are containers the pair is still created in the host
* namespace first, then moved into each container by CNI host-device.
*/
return strcmp(lydx_get_cattr(cif, "name"), peername) < 0;
}
int ifchange_cand_infer_veth(sr_session_ctx_t *session, const char *path)
+36 -1
View File
@@ -518,6 +518,14 @@ static int veth_gen_del(struct lyd_node *dif, FILE *sh)
if (!veth_is_primary(dif))
return 0;
/* When the primary end is itself a container interface it currently
* lives in the container's namespace, so a host-namespace delete here
* would fail and abort the teardown. Its removal is handled after the
* container is gone, see cni_netdag_gen_iface().
*/
if (lydx_get_child(dif, "container-network"))
return 0;
return link_gen_del(dif, sh);
}
@@ -571,6 +579,28 @@ static int netdag_gen_iface_del(struct dagger *net, struct lyd_node *dif,
return 0;
}
/*
* Both ends of a veth pair can be handed to containers, leaving no
* host-side interface to create the pair. Have the primary end create it
* in the host namespace early (NETDAG_INIT_PHYS, before the container is
* (re)started); CNI host-device then moves each end into its container.
*/
static int veth_gen_host(struct dagger *net, struct lyd_node *dif, struct lyd_node *cif)
{
const char *ifname = lydx_get_cattr(cif, "name");
FILE *ip;
int err;
ip = dagger_fopen_net_init(net, ifname, NETDAG_INIT_PHYS, "init.ip");
if (!ip)
return -EIO;
err = veth_gen(dif, cif, ip);
fclose(ip);
return err;
}
static sr_error_t netdag_gen_iface_timeout(struct dagger *net, const char *ifname, const char *iftype)
{
if (!strcmp(iftype, "infix-if-type:ethernet")) {
@@ -604,8 +634,13 @@ static sr_error_t netdag_gen_iface(sr_session_ctx_t *session, struct dagger *net
if ((err = cni_netdag_gen_iface(net, ifname, dif, cif))) {
/* error or managed by CNI/podman */
if (err > 0)
if (err > 0) {
err = 0; /* done, nothing more to do here */
if (op == LYDX_OP_CREATE && lydx_get_child(cif, "veth") &&
veth_is_primary(cif))
err = veth_gen_host(net, dif, cif);
}
goto err;
}
+1 -5
View File
@@ -59,11 +59,7 @@ submodule infix-if-container {
identity host {
base container-network;
description "Host device, e.g., one end of a VETH pair or other host interface.
Note: When using VETH pairs, at least one side must remain in the
host namespace. Both ends of a VETH pair cannot be assigned to
different containers.";
description "Host device, e.g., one end of a VETH pair or other host interface.";
}
/*
+1 -5
View File
@@ -13,11 +13,7 @@ submodule infix-if-veth {
organization "KernelKit";
contact "kernelkit@googlegroups.com";
description "Linux virtual Ethernet pair extension for ietf-interfaces.
Note: When using VETH pairs with containers, at least one side
of the pair must remain in the host namespace. Both ends of a
VETH pair cannot be assigned to different containers.";
description "Linux virtual Ethernet pair extension for ietf-interfaces.";
revision 2023-06-05 {
description "Initial revision.";
+3
View File
@@ -18,6 +18,9 @@
- name: Container with VETH Pair
case: veth/test.py
- name: VETH Pair Between Two Containers
case: internal_link/test.py
- name: Container Volume Persistence
case: volume/test.py
+1
View File
@@ -0,0 +1 @@
test.adoc
@@ -0,0 +1,33 @@
=== VETH Pair Between Two Containers
ifdef::topdoc[:imagesdir: {topdoc}../../test/case/containers/internal_link]
==== Description
Verify that a VETH pair can connect two containers directly, with *both*
ends handed to containers and neither remaining in the host namespace.
....
.------------. .------------.
| left | | right |
| veth0a ===|========= veth ===========|=== veth0b |
'------------' 10.0.0.1 10.0.0.2 '------------'
....
The pair is created in the host namespace then each end is moved into
its container when starting up. Connectivity is verified by pinging
across the pair, from inside one container's network namespace to the
other end's address.
==== Topology
image::topology.svg[VETH Pair Between Two Containers topology, align=center, scaledwidth=75%]
==== Sequence
. Set up topology and attach to target DUT
. Create VETH pair with both ends assigned to containers
. Verify both containers have started
. Verify {LEFT} reaches {RIGHT} over the internal VETH pair
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
r"""VETH Pair Between Two Containers
Verify that a VETH pair can connect two containers directly, with *both*
ends handed to containers and neither remaining in the host namespace.
....
.------------. .------------.
| left | | right |
| veth0a ===|========= veth ===========|=== veth0b |
'------------' 10.0.0.1 10.0.0.2 '------------'
....
The pair is created in the host namespace then each end is moved into
its container when starting up. Connectivity is verified by pinging
across the pair, from inside one container's network namespace to the
other end's address.
"""
import infamy
from infamy.util import until
# Regression test for #941: previously, when both ends of a pair were
# assigned to a container, neither side created the pair.
with infamy.Test() as test:
LEFT, IFACE_LEFT, IP_LEFT = "left", "veth0a", "10.0.0.1"
RIGHT, IFACE_RIGHT, IP_RIGHT = "right", "veth0b", "10.0.0.2"
IMAGE = f"oci-archive:{infamy.Container.HTTPD_IMAGE}"
with test.step("Set up topology and attach to target DUT"):
env = infamy.Env()
target = env.attach("target", "mgmt")
tgtssh = env.attach("target", "mgmt", "ssh")
if not target.has_model("infix-containers"):
test.skip()
with test.step("Create VETH pair with both ends assigned to containers"):
target.put_config_dicts({
"ietf-interfaces": {
"interfaces": {
"interface": [
{
"name": IFACE_LEFT,
"type": "infix-if-type:veth",
"enabled": True,
"infix-interfaces:veth": {"peer": IFACE_RIGHT},
"ipv4": {
"address": [{"ip": IP_LEFT, "prefix-length": 24}]
},
"container-network": {}
},
{
"name": IFACE_RIGHT,
"type": "infix-if-type:veth",
"enabled": True,
"infix-interfaces:veth": {"peer": IFACE_LEFT},
"ipv4": {
"address": [{"ip": IP_RIGHT, "prefix-length": 24}]
},
"container-network": {}
},
]
}
},
"infix-containers": {
"containers": {
"container": [
{
"name": LEFT,
"image": IMAGE,
"command": "/usr/sbin/httpd -f -v -p 91",
"network": {"interface": [{"name": IFACE_LEFT}]}
},
{
"name": RIGHT,
"image": IMAGE,
"command": "/usr/sbin/httpd -f -v -p 91",
"network": {"interface": [{"name": IFACE_RIGHT}]}
},
]
}
}
})
c = infamy.Container(target)
with test.step("Verify both containers have started"):
until(lambda: c.running(LEFT), attempts=60)
until(lambda: c.running(RIGHT), attempts=60)
with test.step(f"Verify {LEFT} reaches {RIGHT} over the internal VETH pair"):
pid = tgtssh.runsh(f"sudo podman inspect --format '{{{{.State.Pid}}}}' {LEFT}").stdout.strip()
assert pid.isdigit(), f"failed to get pid for container {LEFT}: {pid!r}"
def reachable():
return tgtssh.runsh(f"sudo nsenter -t {pid} -n ping -c 2 -w 5 {IP_RIGHT}").returncode == 0
until(reachable, attempts=30)
test.succeed()
+1
View File
@@ -0,0 +1 @@
../../../infamy/topologies/1x1.dot
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Title: 1x1 Pages: 1 -->
<svg width="424pt" height="45pt"
viewBox="0.00 0.00 424.03 45.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 41)">
<title>1x1</title>
<polygon fill="white" stroke="transparent" points="-4,4 -4,-41 420.03,-41 420.03,4 -4,4"/>
<!-- host -->
<g id="node1" class="node">
<title>host</title>
<polygon fill="none" stroke="black" points="0,-0.5 0,-36.5 100,-36.5 100,-0.5 0,-0.5"/>
<text text-anchor="middle" x="25" y="-14.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">host</text>
<polyline fill="none" stroke="black" points="50,-0.5 50,-36.5 "/>
<text text-anchor="middle" x="75" y="-14.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
</g>
<!-- target -->
<g id="node2" class="node">
<title>target</title>
<polygon fill="none" stroke="black" points="300.03,-0.5 300.03,-36.5 416.03,-36.5 416.03,-0.5 300.03,-0.5"/>
<text text-anchor="middle" x="325.03" y="-14.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
<polyline fill="none" stroke="black" points="350.03,-0.5 350.03,-36.5 "/>
<text text-anchor="middle" x="383.03" y="-14.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">target</text>
</g>
<!-- host&#45;&#45;target -->
<g id="edge1" class="edge">
<title>host:mgmt&#45;&#45;target:mgmt</title>
<path fill="none" stroke="lightgray" stroke-width="2" d="M100,-18.5C100,-18.5 300.03,-18.5 300.03,-18.5"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+1 -2
View File
@@ -1,8 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 2.43.0 (0)
-->
<!-- Title: 1x2 Pages: 1 -->
<svg width="299pt" height="55pt"
viewBox="0.00 0.00 299.02 55.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB