-Notice how TAB completion shows available options, `show`
-displays current config, and `diff` shows exactly what changed before
-you commit your changes with the `leave` command.
+TAB completes available options and ? shows online help
+for each option and argument. `show` displays the current config, and `diff`
+shows exactly what changed before you commit it with `leave`. See the [CLI
+documentation][3] for more.
+
+## Web interface
-For more information, see [CLI documentation][3].
+If the CLI isn't your style, the same configuration is available through the
+web interface. Log in from a browser, keep an eye on your device from the
+Status dashboard and use the Configure > Interface setup wizard to create more
+advanced setups, or just fold out an interface to add an IP address.
-## Get Started
+
+
+
+
+
-Get [pre-built images][5] for your hardware. Use the CLI, web
-interface, or standard NETCONF/RESTCONF tools, e.g., `curl`. Add
-containers for any custom functionality you need.
+The web interface is built on the same concepts as the CLI, so operational
+status and state are kept separate from configuration and commands.
-### Supported Platforms
+## Try it in 5 minutes
-- **Raspberry Pi 2B/3B/4B/CM4** - Perfect for home labs, learning, and prototyping
-- **Banana Pi-R3** - Your next home router and gateway
-- **NanoPi R2S** - Compact dual-port router in a tiny package
-- **x86_64** - Run in VMs or on mini PCs for development and testing
-- **Marvell CN9130 CRB, EspressoBIN** - High-performance ARM64 platforms
-- **Microchip SparX-5i** - Enterprise switching capabilities
-- **Microchip SAMA7G54-EK** - ARM Cortex-A7
-- **NXP i.MX8MP EVK** - Highly capable ARM64 SoC
-- **StarFive VisionFive2** - RISC-V architecture support
+You don't need hardware to get started:
-*Why start with Raspberry Pi?* It's affordable, widely available, has
-built-in WiFi + Ethernet, and runs the exact same Infix OS you'd deploy
-in production. Perfect for learning, prototyping, or even small-scale
-deployments.
+- **In a virtual lab** — run a full topology in [GNS3][gns3-post] and test
+ networks entirely in software.
+- **From source** — [build it and `make run`][build-post] to boot Infix in
+ QEMU, from `git clone` to pinging the internet.
+- **On real hardware** — grab a [pre-built image][5] for your board, or run
+ the `x86_64` image in any VM.
+Log in with `admin` / `admin` on the virtual and pre-built images. On
+shipped products the factory-reset credentials are customizable — we
+typically provision a unique per-device password stored in EEPROM/VPD.
+
+## Supported hardware
+
+- **Raspberry Pi 2B/3B/4B/CM4** - a good starting point; built-in WiFi and Ethernet
+- **Banana Pi-R64/R3/R3 Mini/R4** - multi-port routers and gateways
+- **NanoPi R2S** - compact dual-port router
+- **x86_64** - VMs and mini PCs, for development or production
+- **Marvell CN9130 CRB, EspressoBIN** - ARM64 development boards
+- **Microchip SparX-5i** - enterprise switching
+- **Microchip SAMA7G54-EK** - ARM Cortex-A7 evaluation kit
+- **NXP i.MX8MP EVK** - ARM64 SoC evaluation kit
+- **StarFive VisionFive2** - RISC-V board
+
+*Why start with Raspberry Pi?* It's cheap, easy to get hold of, has
+built-in WiFi and Ethernet, and runs the same Infix you'd deploy in
+production — so what you learn on it carries straight over.
+
+> [!TIP]
> 📖 **[Complete documentation][4]** • 💬 **[Join our Discord][discord-url]**
-## Technical Details
+## Why Infix
+
+**🔒 Immutable**
+Read-only filesystem with atomic upgrades. An update either applies
+cleanly or rolls back, so a failed upgrade or a power cut midway through
+won't leave you with a half-broken system.
+
+**🤝 Friendly**
+The CLI is generated from the YANG models, so every command carries its
+own help — hit ? or TAB to see what's available.
+The same models are reachable over NETCONF and RESTCONF, with
+[documentation][4] for when you get stuck.
+
+**🛡️ Secure**
+A small attack surface, separation between system and data, and
+container isolation. Since the system partition is read-only, a
+compromised service or container can't rewrite the OS underneath it.
+
+## Use cases
+
+1. **Home labs & hobbyists**:
+ Turn a Raspberry Pi into a router with WiFi
+1. **IoT & edge**:
+ Build gateways you can update in the field
+1. **Small business networks**:
+ Routing, firewalling, and VLANs on affordable hardware
+1. **Developers & makers**:
+ Prototype networking ideas, or build a custom appliance with containers
+1. **Network professionals**:
+ The same tooling from lab to production — spin up a digital twin in raw
+ Qemu or [GNS3](https://gns3.com/infix)
+
+## Under the hood
@@ -135,27 +149,29 @@ deployments.
-Built on proven open-source foundations: [Linux][0], [Buildroot][1], and
-[sysrepo][2] — for reliability you can trust:
+Built on [Linux][0], [Buildroot][1], and [sysrepo][2]:
-- **Immutable OS**: Read-only filesystem, atomic updates, instant rollback
-- **YANG Configuration**: Industry-standard models with auto-generated tooling
-- **Hardware Acceleration**: Linux switchdev support for wire-speed packet processing
-- **Container Integration**: Docker support with flexible network and hardware access
-- **Memory Efficient**: Runs comfortably on devices with as little as 256 MB RAM
-- **Code Signing**: Releases are cryptographically signed for integrity verification
+- **Immutable OS**: read-only filesystem, atomic updates, rollback on failure
+- **YANG configuration**: standard models with an auto-generated CLI and APIs
+- **Hardware acceleration**: switchdev offload for wire-speed forwarding
+- **Container integration**: Docker, with access to host network and hardware
+- **Memory efficient**: runs on devices with as little as 256 MB RAM
+- **Code signing**: releases are cryptographically signed
-Perfect for everything from resource-constrained edge devices to
-high-throughput network appliances.
-
-With the entire system modeled in YANG, scalability is no longer an
-issue, be it in development, testing, or end users deploying and
-monitoring their devices. All knobs and dials are accessible from the
-CLI (console/SSH), or remotely using the native NETCONF or RESTCONF
-APIs.
+Because the whole system is modeled in YANG, every setting is reachable
+the same way: from the CLI over console or SSH, or remotely over the
+native NETCONF and RESTCONF APIs. The same models drive development,
+testing, and day-to-day monitoring.
> Check the *[Latest Build][]* for bleeding-edge features.
+## Contributing
+
+Bug reports, ideas, and pull requests are welcome. Start with
+[CONTRIBUTING][contributing] and the [code of conduct][coc]. Found a
+security issue? Follow the [security policy][security]. Need a hand?
+See [support options][support] or [join us on Discord][discord-url].
+
---
@@ -171,6 +187,13 @@ APIs.
[3]: https://www.kernelkit.org/infix/latest/cli/introduction/
[4]: https://www.kernelkit.org/infix/
[5]: https://github.com/kernelkit/infix/releases/latest
+[inside]: https://www.kernelkit.org/posts/inside-infix/
+[gns3-post]: https://www.kernelkit.org/posts/infix-in-gns3/
+[build-post]: https://www.kernelkit.org/posts/building-infix-from-source/
+[contributing]: .github/CONTRIBUTING.md
+[coc]: .github/CODE_OF_CONDUCT.md
+[security]: .github/SECURITY.md
+[support]: .github/SUPPORT.md
[Latest Build]: https://github.com/kernelkit/infix/releases/tag/latest "Latest build"
[License]: https://en.wikipedia.org/wiki/GPL_license
[License Badge]: https://img.shields.io/badge/License-GPL%20v2-blue.svg
diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md
index b17a6cd45..f9a2ebe41 100644
--- a/doc/ChangeLog.md
+++ b/doc/ChangeLog.md
@@ -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
diff --git a/doc/container.md b/doc/container.md
index eb6de6233..4f3ab2d78 100644
--- a/doc/container.md
+++ b/doc/container.md
@@ -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.
diff --git a/doc/img/webui-dashboard.png b/doc/img/webui-dashboard.png
new file mode 100644
index 000000000..830444004
Binary files /dev/null and b/doc/img/webui-dashboard.png differ
diff --git a/doc/img/webui-login.png b/doc/img/webui-login.png
new file mode 100644
index 000000000..ea7ec54c1
Binary files /dev/null and b/doc/img/webui-login.png differ
diff --git a/doc/img/webui-wizard.png b/doc/img/webui-wizard.png
new file mode 100644
index 000000000..d28770252
Binary files /dev/null and b/doc/img/webui-wizard.png differ
diff --git a/src/bin/show/__init__.py b/src/bin/show/__init__.py
index 14785d9c4..0b3471bd7 100755
--- a/src/bin/show/__init__.py
+++ b/src/bin/show/__init__.py
@@ -544,6 +544,13 @@ def mdns(args: List[str]) -> None:
cli_pretty(data, "show-mdns")
+# Sensor names that represent the SoC/CPU temperature (not per-port PHYs).
+# Matches "cpu"/"soc"/"core", and Marvell CN913x "ap-*" / "cp-*" zones.
+# Note the hyphen after "cp" so mangled PHY names like "cp0busbus…" never
+# match.
+SOC_TEMP_RE = re.compile(r'^(cpu|soc|core|ap-|cp\d+-)')
+
+
def system(args: List[str]) -> None:
# Get system state from sysrepo
data = get_json("/ietf-system:system-state")
@@ -562,6 +569,7 @@ def system(args: List[str]) -> None:
fan_rpm = None
if hardware_data and "ietf-hardware:hardware" in hardware_data:
components = hardware_data.get("ietf-hardware:hardware", {}).get("component", [])
+ soc_temps = []
for component in components:
sensor_data = component.get("sensor-data", {})
if not sensor_data:
@@ -570,16 +578,21 @@ def system(args: List[str]) -> None:
name = component.get("name", "")
value_type = sensor_data.get("value-type")
- # Only capture CPU/SoC temperature (ignore phy, sfp, etc.)
- # Different platforms use different names: cpu, soc, core, etc.
- if value_type == "celsius" and name in ("cpu", "soc", "core") and cpu_temp is None:
- temp_millidegrees = sensor_data.get("value", 0)
- cpu_temp = temp_millidegrees / 1000.0
+ # Capture SoC/CPU temperature, ignoring per-port phy, sfp, etc.
+ # Platforms name the zone differently: a plain "cpu"/"soc"/"core",
+ # or, on Marvell CN913x, an "ap-*" (application processor) or
+ # "cp-*" (communication processor) cluster. Collect them all
+ # and report the hottest as the representative SoC temperature.
+ if value_type == "celsius" and SOC_TEMP_RE.match(name):
+ soc_temps.append(sensor_data.get("value", 0) / 1000.0)
# Capture fan speed if available
elif value_type == "rpm" and fan_rpm is None:
fan_rpm = sensor_data.get("value", 0)
+ if soc_temps:
+ cpu_temp = max(soc_temps)
+
if cpu_temp is not None:
runtime["cpu_temp"] = cpu_temp
if fan_rpm is not None:
diff --git a/src/confd/src/cni.c b/src/confd/src/cni.c
index d70a863af..9f7980aa4 100644
--- a/src/confd/src/cni.c
+++ b/src/confd/src/cni.c
@@ -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)
diff --git a/src/confd/src/if-veth.c b/src/confd/src/if-veth.c
index 306c61f27..ce6bdda3d 100644
--- a/src/confd/src/if-veth.c
+++ b/src/confd/src/if-veth.c
@@ -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.
+ self_cni = lydx_get_child(cif, "container-network") != NULL;
+ peer_cni = lydx_get_child(peer, "container-network") != NULL;
+
+ /* 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 (lydx_get_child(cif, "container-network"))
- return false;
- if (lydx_get_child(peer, "container-network"))
- return true;
+ if (self_cni != peer_cni)
+ return peer_cni;
- return strcmp(lydx_get_cattr(cif, "name"),
- lydx_get_cattr(veth, "peer")) < 0;
+ /* 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)
diff --git a/src/confd/src/interfaces.c b/src/confd/src/interfaces.c
index 132ccca87..db01484c7 100644
--- a/src/confd/src/interfaces.c
+++ b/src/confd/src/interfaces.c
@@ -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;
}
diff --git a/src/confd/yang/confd/infix-if-container.yang b/src/confd/yang/confd/infix-if-container.yang
index 2a2e1bd98..f496aa4ff 100644
--- a/src/confd/yang/confd/infix-if-container.yang
+++ b/src/confd/yang/confd/infix-if-container.yang
@@ -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.";
}
/*
diff --git a/src/confd/yang/confd/infix-if-veth.yang b/src/confd/yang/confd/infix-if-veth.yang
index d997ba360..bd29d4343 100644
--- a/src/confd/yang/confd/infix-if-veth.yang
+++ b/src/confd/yang/confd/infix-if-veth.yang
@@ -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.";
diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py
index a686e8dbc..b4c5b34e4 100755
--- a/src/statd/python/cli_pretty/cli_pretty.py
+++ b/src/statd/python/cli_pretty/cli_pretty.py
@@ -916,7 +916,12 @@ def print(self, indent=0):
# Standalone sensor without description: use name as-is
display_name = self.name
- row = f"{indent_str}{display_name:<{PadSensor.name - len(indent_str)}}"
+ # Truncate over-long names so they never spill into the VALUE column
+ # (e.g. unmapped switch-PHY hwmon names derived from the DT path).
+ field = PadSensor.name - len(indent_str)
+ if len(display_name) >= field:
+ display_name = display_name[:field - 2] + "…"
+ row = f"{indent_str}{display_name:<{field}}"
# For colored value, pad manually to account for ANSI codes
value_str = self.get_formatted_value()
# Count visible characters (strip ANSI codes for length calculation)
@@ -2245,6 +2250,13 @@ def show_services(json):
service_table.print()
+def sensor_sort_key(component):
+ """Natural sort key for sensor names: digit runs compare numerically so
+ e2 sorts before e10, while keeping ap-cpu/cp0-ic/sfp groups together."""
+ name = component.get("name", "")
+ return [int(t) if t.isdigit() else t for t in re.split(r'(\d+)', name)]
+
+
def show_hardware(json):
if not json.get("ietf-hardware:hardware"):
print("Error, top level \"ietf-hardware:component\" missing")
@@ -2418,15 +2430,16 @@ def show_hardware(json):
print(f"\n{module_name}:")
if module_name in children:
- for child in sorted(children[module_name], key=lambda c: c.get("name", "")):
+ for child in sorted(children[module_name], key=sensor_sort_key):
sensor = Sensor(child)
sensor.print(indent=1)
- # Display standalone sensors (no parent)
+ # Display standalone sensors (no parent), naturally sorted so port
+ # temperatures read e1, e2, ... e28 rather than e1, e10, e11, ...
if standalone:
if modules:
print() # Add blank line between modules and standalone
- for component in sorted(standalone, key=lambda c: c.get("name", "")):
+ for component in sorted(standalone, key=sensor_sort_key):
sensor = Sensor(component)
sensor.print()
diff --git a/src/statd/python/yanger/ietf_hardware.py b/src/statd/python/yanger/ietf_hardware.py
index 62e32cd73..934b2f6d7 100644
--- a/src/statd/python/yanger/ietf_hardware.py
+++ b/src/statd/python/yanger/ietf_hardware.py
@@ -149,6 +149,36 @@ def normalize_sensor_name(name):
return name
+def _dt_phandle(path):
+ """Read a device-tree phandle cell as a normalized hex string.
+
+ phandle/phy-handle properties are 4-byte big-endian cells. Read them via
+ od(1) so the binary content survives the text-based HOST transport (works
+ both locally and over the ssh-style remote transport).
+ """
+ out = HOST.run(("od", "-An", "-tx1", path), default="")
+ return "".join(out.split()) if out else None
+
+
+def phy_handle_to_ifname():
+ """Map a PHY's device-tree phandle to the interface it drives.
+
+ DSA user ports carry a "phy-handle" pointing at the PHY that serves them.
+ The reverse map lets us name a switch PHY's hwmon temperature sensor after
+ the front-panel port (e.g. e1) instead of the unreadable name the kernel
+ derives from the full device-tree path (cp0busbusf2000000mdio...).
+ """
+ mapping = {}
+ for ifname in HOST.run(("ls", "/sys/class/net"), default="").split():
+ handle_path = os.path.join("/sys/class/net", ifname, "of_node", "phy-handle")
+ if not HOST.exists(handle_path):
+ continue
+ handle = _dt_phandle(handle_path)
+ if handle:
+ mapping[handle] = ifname
+ return mapping
+
+
def get_wifi_phy_info():
"""
Discover WiFi PHYs using iw list command.
@@ -218,6 +248,7 @@ def hwmon_sensor_components():
"""
components = []
device_sensors = {} # Track {device_base_name: [list of sensor components]}
+ phy_ifname = phy_handle_to_ifname()
def add_sensor(base_name, sensor_component):
"""Helper to track sensors per device"""
@@ -244,6 +275,16 @@ def add_sensor(base_name, sensor_component):
base_name = normalize_sensor_name(device_name)
+ # Switch PHYs get an hwmon name derived from their full
+ # device-tree path (e.g. cp0busbusf2000000mdio12a200switch2mdio01).
+ # If this PHY drives a known port, name the sensor after that
+ # port (e1, e2, ...) instead.
+ phandle_path = os.path.join(hwmon_path, "device", "of_node", "phandle")
+ if HOST.exists(phandle_path):
+ ifname = phy_ifname.get(_dt_phandle(phandle_path))
+ if ifname:
+ base_name = ifname
+
# Helper to create sensor component with human-readable description
def create_sensor(sensor_name, value, value_type, value_scale, label=None):
component = {
diff --git a/test/case/containers/all.yaml b/test/case/containers/all.yaml
index abf9c65de..ea4ab2d8f 100644
--- a/test/case/containers/all.yaml
+++ b/test/case/containers/all.yaml
@@ -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
diff --git a/test/case/containers/internal_link/Readme.adoc b/test/case/containers/internal_link/Readme.adoc
new file mode 120000
index 000000000..ae32c8412
--- /dev/null
+++ b/test/case/containers/internal_link/Readme.adoc
@@ -0,0 +1 @@
+test.adoc
\ No newline at end of file
diff --git a/test/case/containers/internal_link/test.adoc b/test/case/containers/internal_link/test.adoc
new file mode 100644
index 000000000..f52c311a3
--- /dev/null
+++ b/test/case/containers/internal_link/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
+
+
diff --git a/test/case/containers/internal_link/test.py b/test/case/containers/internal_link/test.py
new file mode 100755
index 000000000..26c342ebf
--- /dev/null
+++ b/test/case/containers/internal_link/test.py
@@ -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()
diff --git a/test/case/containers/internal_link/topology.dot b/test/case/containers/internal_link/topology.dot
new file mode 120000
index 000000000..02b788692
--- /dev/null
+++ b/test/case/containers/internal_link/topology.dot
@@ -0,0 +1 @@
+../../../infamy/topologies/1x1.dot
\ No newline at end of file
diff --git a/test/case/containers/internal_link/topology.svg b/test/case/containers/internal_link/topology.svg
new file mode 100644
index 000000000..6fc6f47a8
--- /dev/null
+++ b/test/case/containers/internal_link/topology.svg
@@ -0,0 +1,33 @@
+
+
+
+
+
diff --git a/test/case/firewall/basic/test.py b/test/case/firewall/basic/test.py
index eeb934dc1..d25d0b118 100755
--- a/test/case/firewall/basic/test.py
+++ b/test/case/firewall/basic/test.py
@@ -22,10 +22,12 @@
target = env.attach("target", "mgmt")
_, data_if = env.ltop.xlate("target", "data")
_, mgmt_if = env.ltop.xlate("target", "mgmt")
- _, unused_if = env.ltop.xlate("target", "unused")
_, host_data = env.ltop.xlate("host", "data")
TARGET_IP = "192.168.1.1"
HOST_IP = "192.168.1.42"
+ # A dummy interface stands in for an unused port: it is not placed
+ # in any zone, so it must fall back to the default zone.
+ UNUSED_IF = "dummy0"
with test.step("Configure basic end-device firewall"):
target.put_config_dicts({
@@ -41,6 +43,11 @@
"prefix-length": 24
}]
}
+ },
+ {
+ "name": UNUSED_IF,
+ "type": "infix-if-type:dummy",
+ "enabled": True
}
]
}
@@ -119,16 +126,14 @@
assert "http" in public_zone["service"]
with test.step("Verify unused interface assigned to default zone"):
- data = target.get_data("/infix-firewall:firewall")
- fw = data["firewall"]
-
- assert fw["default"] == "public-untrusted-net", "Default zone should be 'public-untrusted-net'"
-
- zones = {zone["name"]: zone for zone in fw["zone"]}
- public_zone = zones["public-untrusted-net"]
-
- assert unused_if in public_zone["interface"], \
- f"Unused interface {unused_if} should be in default zone 'public-untrusted-net', got interfaces: {public_zone['interface']}"
+ def unused_in_default_zone():
+ data = target.get_data("/infix-firewall:firewall")
+ fw = data["firewall"]
+ assert fw["default"] == "public-untrusted-net", "Default zone should be 'public-untrusted-net'"
+ zones = {zone["name"]: zone for zone in fw["zone"]}
+ return UNUSED_IF in zones["public-untrusted-net"].get("interface", [])
+
+ until(unused_in_default_zone, attempts=10)
with infamy.IsolatedMacVlan(host_data) as ns:
ns.addip(HOST_IP)
diff --git a/test/case/firewall/basic/topology.dot b/test/case/firewall/basic/topology.dot
index 52174355c..84948360a 100644
--- a/test/case/firewall/basic/topology.dot
+++ b/test/case/firewall/basic/topology.dot
@@ -1,4 +1,4 @@
-graph "1x3" {
+graph "1x2" {
layout = "neato";
overlap = false;
esep = "+30";
@@ -8,23 +8,16 @@ graph "1x3" {
host [
label="host | { mgmt | data }",
- pos="10,10.95!",
+ pos="10,10!",
requires="controller"
];
target [
- label="{ mgmt | data | unused } | target",
- pos="30,10!",
- requires="infix",
- ];
-
- dummy [
- label="{ link } | dummy",
- pos="29.8,00!",
+ label="{ mgmt | data } | target",
+ pos="40,10!",
requires="infix",
];
host:mgmt -- target:mgmt [requires="mgmt", color="lightgray"]
- host:data -- target:data [color=black, fontcolor=black, taillabel="192.168.1.42/24"]
- target:unused -- dummy:link [color="gray", style="dashed"]
+ host:data -- target:data [color=black]
}
diff --git a/test/case/firewall/basic/topology.svg b/test/case/firewall/basic/topology.svg
index 984e42c5a..35a4d0caf 100644
--- a/test/case/firewall/basic/topology.svg
+++ b/test/case/firewall/basic/topology.svg
@@ -2,57 +2,41 @@
-
-