diff --git a/.github/CODE-OF-CONDUCT.md b/.github/CODE_OF_CONDUCT.md similarity index 100% rename from .github/CODE-OF-CONDUCT.md rename to .github/CODE_OF_CONDUCT.md diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 9da11b2b1..c8402ca17 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -272,7 +272,7 @@ other contributions that are not aligned to this Code of Conduct."* [PEP-8]: https://peps.python.org/pep-0008/ [RDD]: https://tom.preston-werner.com/2010/08/23/readme-driven-development [cbeams]: https://cbea.ms/git-commit/#seven-rules -[conduct]: CODE-OF-CONDUCT.md +[conduct]: CODE_OF_CONDUCT.md [DCO]: https://developercertificate.org/ [closing]: https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/using-keywords-in-issues-and-pull-requests [gpg-verify]: https://docs.github.com/en/authentication/managing-commit-signature-verification diff --git a/.github/workflows/build-boot.yml b/.github/workflows/build-boot.yml index 05bcdf991..3678df4b3 100644 --- a/.github/workflows/build-boot.yml +++ b/.github/workflows/build-boot.yml @@ -113,12 +113,6 @@ jobs: pattern: "artifact-*" merge-multiple: true - - name: Create checksums ... - run: | - for file in *.tar.gz; do - sha256sum $file > $file.sha256 - done - - uses: ncipollo/release-action@v1 with: allowUpdates: true @@ -128,7 +122,7 @@ jobs: prerelease: true tag: "latest-boot" token: ${{ secrets.GITHUB_TOKEN }} - artifacts: "*.tar.gz*" + artifacts: "*.tar.gz" - name: Summary run: | diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml index af7b6f675..39a08bdad 100644 --- a/.github/workflows/build-image.yml +++ b/.github/workflows/build-image.yml @@ -223,15 +223,6 @@ jobs: output/images/*-emmc.img* retention-days: 30 - - name: Create checksums - run: | - cd output/images/ - for file in *-sdcard.img *-emmc.img; do - if [ -f "$file" ]; then - sha256sum "$file" > "$file.sha256" - fi - done - - name: Upload to release uses: ncipollo/release-action@v1 with: @@ -242,7 +233,7 @@ jobs: prerelease: true tag: "latest-boot" token: ${{ secrets.GITHUB_TOKEN }} - artifacts: "output/images/*-sdcard.img*,output/images/*-emmc.img*" + artifacts: "output/images/*-sdcard.img,output/images/*-emmc.img" - name: Generate summary run: | diff --git a/.github/workflows/inventory.yml b/.github/workflows/inventory.yml deleted file mode 100644 index 205dba2e5..000000000 --- a/.github/workflows/inventory.yml +++ /dev/null @@ -1,88 +0,0 @@ -name: Manny the Manager - -on: - workflow_dispatch: - inputs: - checkout: - required: true - type: boolean - cleanup: - required: true - type: boolean - peek: - required: true - type: boolean - -jobs: - inventory: - runs-on: ubuntu-latest - steps: - - name: Disk usage ... - run: | - cd - du -hs .[^.]* - - name: Disk inventory (1/2) ... - run: | - echo "df -h =========================================================================" - df -h - echo "mounts ========================================================================" - mount - - name: File inventory (1/2) ... - run: | - echo "Current directory: $(pwd)" - echo "Files in $HOME ================================================================" - ls $HOME - echo "Find $HOME ====================================================================" - find $HOME - - name: Container inventory ... - run: | - echo "Available container images: ===================================================" - docker images - echo "Available containers: =========================================================" - docker ps -a - - checkout: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: kernelkit/actions/cache-restore@v1 - with: - target: x86_64 - dl-prefix: dl-netconf - - name: Disk inventory (2/2) ... - run: | - echo "df -h =========================================================================" - df -h - echo "mounts ========================================================================" - mount - - name: File inventory (2/2) ... - run: | - echo "Current directory: $(pwd)" - echo "Files in $HOME ================================================================" - ls $HOME - echo "Find $HOME ====================================================================" - find $HOME - - peeky: - if: ${{ inputs.peek }} - runs-on: ubuntu-latest - steps: - - name: Peek & Poke ... - run: | - whoami - ls -l /mnt/ - cat /mnt/DATALOSS_WARNING_README.txt - sudo mkdir /mnt/x-aarch64 - sudo chown $(id -un):$(id -gn) /mnt/x-aarch64 - ls -l /mnt/ - - cleanup: - if: ${{ inputs.cleanup }} - needs: [inventory, peeky] - runs-on: ubuntu-latest - steps: - - name: Cleaning up cruft ... - run: | - docker image prune -af - docker volume prune -f - docker container prune -f diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 555c72b9b..c56fafd06 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,12 +16,6 @@ jobs: pattern: "artifact-*" merge-multiple: true - - name: Create checksums ... - run: | - for file in *.tar.gz; do - sha256sum $file > $file.sha256 - done - - uses: ncipollo/release-action@v1 with: allowUpdates: true @@ -32,7 +26,7 @@ jobs: prerelease: true tag: "latest" token: ${{ secrets.GITHUB_TOKEN }} - artifacts: "*.tar.gz*" + artifacts: "*.tar.gz" - name: Summary run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b6b093b0e..7de5995f0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -83,17 +83,6 @@ jobs: pattern: "artifact-*" merge-multiple: true - - name: Create checksums ... - run: | - for file in *.tar.gz; do - sha256sum $file > $file.sha256 - done - if ls *.qcow2 &>/dev/null; then - for file in *.qcow2; do - sha256sum "$file" > "$file.sha256" - done - fi - - name: Extract ChangeLog entry ... run: | cat doc/ChangeLog.md | ./utils/extract-changelog.sh > release.md @@ -106,7 +95,7 @@ jobs: makeLatest: ${{ steps.rel.outputs.latest }} discussionCategory: ${{ steps.rel.outputs.cat }} bodyFile: release.md - artifacts: "*.tar.gz*,*.qcow2*" + artifacts: "*.tar.gz,*.qcow2" - name: Summary run: | diff --git a/.github/workflows/weekly.yml b/.github/workflows/weekly.yml index 0cc259983..158718254 100644 --- a/.github/workflows/weekly.yml +++ b/.github/workflows/weekly.yml @@ -27,17 +27,6 @@ jobs: pattern: "artifact-*" merge-multiple: true - - name: Create checksums - run: | - for file in *.tar.gz; do - sha256sum $file > $file.sha256 - done - if ls *.qcow2 &>/dev/null; then - for file in *.qcow2; do - sha256sum "$file" > "$file.sha256" - done - fi - - uses: ncipollo/release-action@v1 with: tag: latest @@ -54,7 +43,7 @@ jobs: **Commit:** ${{ github.sha }} **Built:** ${{ github.run_id }} - artifacts: "*.tar.gz*,*.qcow2*" + artifacts: "*.tar.gz,*.qcow2" - name: Summary run: | diff --git a/README.md b/README.md index 3e11dec5c..1aa9be1f3 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,21 @@ -[![License Badge][]][License] [![Release Badge][]][Release] [![GitHub Status][]][GitHub] [![Coverity Status][]][Coverity Scan] [![Discord][discord-badge]][discord-url] +[![License Badge][]][License] [![Release Badge][]][Release] [![GitHub Status][]][GitHub] [![Discord][discord-badge]][discord-url] -Infix — Immutable.Friendly.Secure +Infix — Immutable.Friendly.Secure -Turn any ARM or x86 device into a powerful, manageable network appliance -in minutes. From $35 Raspberry Pi boards to enterprise switches — deploy -routers, IoT gateways, edge devices, or custom network solutions that -just work. +Infix turns an ARM or x86 device into a managed network appliance. The +same OS runs on a $35 Raspberry Pi and on enterprise switching hardware, +so you can build a router, an IoT gateway, or an edge device on whatever +you have on hand. -## Our Values +More in-depth material is available in our blog and User Guide: -**🔒 Immutable** -Your system never breaks. Read-only filesystem with atomic upgrades -means no configuration drift, no corrupted updates, and instant rollback -if something goes wrong. Deploy once, trust forever. - -**🤝 Friendly** -Actually easy to use. Auto-generated CLI from standard YANG models comes -with built-in help for every command — just hit ? or -TAB for context-aware assistance. - -Familiar NETCONF & RESTCONF APIs and [comprehensive documentation][4] -mean you're never stuck. Whether you're learning networking or managing -enterprise infrastructure. - -**🛡️ Secure** -Built with security as a foundation, not an afterthought. Minimal -attack surface, separation between system and data, and container -isolation. Sleep better knowing your infrastructure is protected. - -## Why Choose Infix +- +- -**Hardware Flexibility**: Start with a $35 Raspberry Pi, scale to -enterprise switching hardware. Same OS, same tools, same reliability. +## See it in action -**Standards-Based**: Built around YANG models and IETF standards. Learn -once, use everywhere - no vendor lock-in. - -**Container Ready**: Run your applications alongside networking -functions. GPIO access, dedicated Ethernet ports, custom protocols — -your device, your rules. - -## Use Cases - -1. **Home Labs & Hobbyists**: - Transform a Raspberry Pi into a full-featured router with WiFi -1. **IoT & Edge Computing**: - Bridge devices to the cloud with reliable, updatable gateways -1. **Small Business Networks**: - Enterprise-grade features without the complexity or cost -1. **Developers & Makers**: - Test networking concepts, prototype IoT solutions, or build custom - appliances -1. **Network Professionals**: - Consistent tooling from development to production deployment. - How about a digital twin using raw Qemu or [GNS3](https://gns3.com/infix)! - -## Quick Example - -Configure an interface in seconds - the CLI guides you with built-in help: +The CLI is generated from the [YANG models][inside], so it guides you with +built-in help. Here's setting an IP address on an interface:
admin@infix-12-34-56:/> configure
 admin@infix-12-34-56:/config/> edit interface eth0
@@ -94,38 +52,94 @@ eth0            ethernet   UP          52:54:00:12:34:56
 admin@infix-12-34-56:/> copy running startup
 
-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 +

+ Login + Dashboard + Setup wizard +

-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 @@ + + + + + + +1x1 + + + +host + +host + +mgmt + + + +target + +mgmt + +target + + + +host:mgmt--target:mgmt + + + + 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 @@ - - - -1x3 - + + + +1x2 + host - -host - -mgmt - -data + +host + +mgmt + +data target - -mgmt - -data - -unused - -target + +mgmt + +data + +target host:mgmt--target:mgmt - + host:data--target:data - -192.168.1.42/24 - - - -dummy - -link - -dummy - - - -target:unused--dummy:link - +