Merge pull request #1544 from kernelkit/misc

This commit is contained in:
Joachim Wiberg
2026-06-22 08:39:14 +02:00
committed by GitHub
31 changed files with 478 additions and 323 deletions
+1 -1
View File
@@ -272,7 +272,7 @@ other contributions that are not aligned to this Code of Conduct."*
[PEP-8]: https://peps.python.org/pep-0008/ [PEP-8]: https://peps.python.org/pep-0008/
[RDD]: https://tom.preston-werner.com/2010/08/23/readme-driven-development [RDD]: https://tom.preston-werner.com/2010/08/23/readme-driven-development
[cbeams]: https://cbea.ms/git-commit/#seven-rules [cbeams]: https://cbea.ms/git-commit/#seven-rules
[conduct]: CODE-OF-CONDUCT.md [conduct]: CODE_OF_CONDUCT.md
[DCO]: https://developercertificate.org/ [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 [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 [gpg-verify]: https://docs.github.com/en/authentication/managing-commit-signature-verification
+1 -7
View File
@@ -113,12 +113,6 @@ jobs:
pattern: "artifact-*" pattern: "artifact-*"
merge-multiple: true merge-multiple: true
- name: Create checksums ...
run: |
for file in *.tar.gz; do
sha256sum $file > $file.sha256
done
- uses: ncipollo/release-action@v1 - uses: ncipollo/release-action@v1
with: with:
allowUpdates: true allowUpdates: true
@@ -128,7 +122,7 @@ jobs:
prerelease: true prerelease: true
tag: "latest-boot" tag: "latest-boot"
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
artifacts: "*.tar.gz*" artifacts: "*.tar.gz"
- name: Summary - name: Summary
run: | run: |
+1 -10
View File
@@ -223,15 +223,6 @@ jobs:
output/images/*-emmc.img* output/images/*-emmc.img*
retention-days: 30 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 - name: Upload to release
uses: ncipollo/release-action@v1 uses: ncipollo/release-action@v1
with: with:
@@ -242,7 +233,7 @@ jobs:
prerelease: true prerelease: true
tag: "latest-boot" tag: "latest-boot"
token: ${{ secrets.GITHUB_TOKEN }} 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 - name: Generate summary
run: | run: |
-88
View File
@@ -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
+1 -7
View File
@@ -16,12 +16,6 @@ jobs:
pattern: "artifact-*" pattern: "artifact-*"
merge-multiple: true merge-multiple: true
- name: Create checksums ...
run: |
for file in *.tar.gz; do
sha256sum $file > $file.sha256
done
- uses: ncipollo/release-action@v1 - uses: ncipollo/release-action@v1
with: with:
allowUpdates: true allowUpdates: true
@@ -32,7 +26,7 @@ jobs:
prerelease: true prerelease: true
tag: "latest" tag: "latest"
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
artifacts: "*.tar.gz*" artifacts: "*.tar.gz"
- name: Summary - name: Summary
run: | run: |
+1 -12
View File
@@ -83,17 +83,6 @@ jobs:
pattern: "artifact-*" pattern: "artifact-*"
merge-multiple: true 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 ... - name: Extract ChangeLog entry ...
run: | run: |
cat doc/ChangeLog.md | ./utils/extract-changelog.sh > release.md cat doc/ChangeLog.md | ./utils/extract-changelog.sh > release.md
@@ -106,7 +95,7 @@ jobs:
makeLatest: ${{ steps.rel.outputs.latest }} makeLatest: ${{ steps.rel.outputs.latest }}
discussionCategory: ${{ steps.rel.outputs.cat }} discussionCategory: ${{ steps.rel.outputs.cat }}
bodyFile: release.md bodyFile: release.md
artifacts: "*.tar.gz*,*.qcow2*" artifacts: "*.tar.gz,*.qcow2"
- name: Summary - name: Summary
run: | run: |
+1 -12
View File
@@ -27,17 +27,6 @@ jobs:
pattern: "artifact-*" pattern: "artifact-*"
merge-multiple: true 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 - uses: ncipollo/release-action@v1
with: with:
tag: latest tag: latest
@@ -54,7 +43,7 @@ jobs:
**Commit:** ${{ github.sha }} **Commit:** ${{ github.sha }}
**Built:** ${{ github.run_id }} **Built:** ${{ github.run_id }}
artifacts: "*.tar.gz*,*.qcow2*" artifacts: "*.tar.gz,*.qcow2"
- name: Summary - name: Summary
run: | run: |
+116 -93
View File
@@ -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]
<img align="right" src="doc/logo.png" alt="Infix — Immutable.Friendly.Secure" width=480 padding=10> <img align="right" src="doc/logo.png" alt="Infix — Immutable.Friendly.Secure" width=380 padding=10>
Turn any ARM or x86 device into a powerful, manageable network appliance Infix turns an ARM or x86 device into a managed network appliance. The
in minutes. From $35 Raspberry Pi boards to enterprise switches — deploy same OS runs on a $35 Raspberry Pi and on enterprise switching hardware,
routers, IoT gateways, edge devices, or custom network solutions that so you can build a router, an IoT gateway, or an edge device on whatever
just work. you have on hand.
## Our Values More in-depth material is available in our blog and User Guide:
**🔒 Immutable** - <https://www.kernelkit.org/>
Your system never breaks. Read-only filesystem with atomic upgrades - <https://www.kernelkit.org/infix/>
means no configuration drift, no corrupted updates, and instant rollback
if something goes wrong. Deploy once, trust forever.
**🤝 Friendly** ## See it in action
Actually easy to use. Auto-generated CLI from standard YANG models comes
with built-in help for every command — just hit <kbd>?</kbd> or
<kbd>TAB</kbd> for context-aware assistance.
Familiar NETCONF & RESTCONF APIs and [comprehensive documentation][4] The CLI is generated from the [YANG models][inside], so it guides you with
mean you're never stuck. Whether you're learning networking or managing built-in help. Here's setting an IP address on an interface:
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.
**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:
<pre><code>admin@infix-12-34-56:/> <b>configure</b> <pre><code>admin@infix-12-34-56:/> <b>configure</b>
admin@infix-12-34-56:/config/> <b>edit interface eth0</b> admin@infix-12-34-56:/config/> <b>edit interface eth0</b>
@@ -94,38 +52,94 @@ eth0 ethernet UP 52:54:00:12:34:56
admin@infix-12-34-56:/> <b>copy running startup</b> admin@infix-12-34-56:/> <b>copy running startup</b>
</code></pre> </code></pre>
Notice how <kbd>TAB</kbd> completion shows available options, `show` <kbd>TAB</kbd> completes available options and <kbd>?</kbd> shows online help
displays current config, and `diff` shows exactly what changed before for each option and argument. `show` displays the current config, and `diff`
you commit your changes with the `leave` command. shows exactly what changed before you commit it with `leave`. See the [CLI
documentation][3] for more.
For more information, see [CLI documentation][3]. ## Web interface
## Get Started 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 [pre-built images][5] for your hardware. Use the CLI, web <p>
interface, or standard NETCONF/RESTCONF tools, e.g., `curl`. Add <a href="doc/img/webui-login.png"><img src="doc/img/webui-login.png" alt="Login" align="top" width=220></a>
containers for any custom functionality you need. <a href="doc/img/webui-dashboard.png"><img src="doc/img/webui-dashboard.png" alt="Dashboard" width=290></a>
<a href="doc/img/webui-wizard.png"><img src="doc/img/webui-wizard.png" alt="Setup wizard" width=260></a>
</p>
### Supported Platforms The web interface is built on the same concepts as the CLI, so operational
status and state are kept separate from configuration and commands.
- **Raspberry Pi 2B/3B/4B/CM4** - Perfect for home labs, learning, and prototyping ## Try it in 5 minutes
- **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
*Why start with Raspberry Pi?* It's affordable, widely available, has You don't need hardware to get started:
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]** > 📖 **[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 <kbd>?</kbd> or <kbd>TAB</kbd> 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
<a href="https://bitsign.se"> <a href="https://bitsign.se">
<picture> <picture>
@@ -135,27 +149,29 @@ deployments.
</picture> </picture>
</a> </a>
Built on proven open-source foundations: [Linux][0], [Buildroot][1], and Built on [Linux][0], [Buildroot][1], and [sysrepo][2]:
[sysrepo][2] — for reliability you can trust:
- **Immutable OS**: Read-only filesystem, atomic updates, instant rollback - **Immutable OS**: read-only filesystem, atomic updates, rollback on failure
- **YANG Configuration**: Industry-standard models with auto-generated tooling - **YANG configuration**: standard models with an auto-generated CLI and APIs
- **Hardware Acceleration**: Linux switchdev support for wire-speed packet processing - **Hardware acceleration**: switchdev offload for wire-speed forwarding
- **Container Integration**: Docker support with flexible network and hardware access - **Container integration**: Docker, with access to host network and hardware
- **Memory Efficient**: Runs comfortably on devices with as little as 256 MB RAM - **Memory efficient**: runs on devices with as little as 256 MB RAM
- **Code Signing**: Releases are cryptographically signed for integrity verification - **Code signing**: releases are cryptographically signed
Perfect for everything from resource-constrained edge devices to Because the whole system is modeled in YANG, every setting is reachable
high-throughput network appliances. the same way: from the CLI over console or SSH, or remotely over the
native NETCONF and RESTCONF APIs. The same models drive development,
With the entire system modeled in YANG, scalability is no longer an testing, and day-to-day monitoring.
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.
> Check the *[Latest Build][]* for bleeding-edge features. > 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].
--- ---
<div align="center"> <div align="center">
@@ -171,6 +187,13 @@ APIs.
[3]: https://www.kernelkit.org/infix/latest/cli/introduction/ [3]: https://www.kernelkit.org/infix/latest/cli/introduction/
[4]: https://www.kernelkit.org/infix/ [4]: https://www.kernelkit.org/infix/
[5]: https://github.com/kernelkit/infix/releases/latest [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" [Latest Build]: https://github.com/kernelkit/infix/releases/tag/latest "Latest build"
[License]: https://en.wikipedia.org/wiki/GPL_license [License]: https://en.wikipedia.org/wiki/GPL_license
[License Badge]: https://img.shields.io/badge/License-GPL%20v2-blue.svg [License Badge]: https://img.shields.io/badge/License-GPL%20v2-blue.svg
+2
View File
@@ -30,6 +30,8 @@ All notable changes to the project are documented in this file.
### Fixes ### 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 - 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 all interfaces. This has been an issue ever since the firewall support was
introduced in v25.10.0 introduced in v25.10.0
+3 -5
View File
@@ -668,11 +668,9 @@ set:
For an example of both, see the next section. For an example of both, see the next section.
> [!IMPORTANT] > [!TIP]
> **VETH Pair Limitation:** When using VETH pairs with containers, at least > Both ends of a VETH pair may be assigned to containers, connecting two
> one side of the pair must remain in the host namespace. It is currently > containers directly without involving the host namespace.
> not possible to create VETH pairs where both ends are assigned to different
> containers. One end must always be accessible from the host.
[^3]: Something which the container bridge network type does behind the [^3]: Something which the container bridge network type does behind the
scenes with one end of an automatically created VETH pair. scenes with one end of an automatically created VETH pair.
Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

+18 -5
View File
@@ -544,6 +544,13 @@ def mdns(args: List[str]) -> None:
cli_pretty(data, "show-mdns") 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<N>-*" zones.
# Note the hyphen after "cp<N>" 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: def system(args: List[str]) -> None:
# Get system state from sysrepo # Get system state from sysrepo
data = get_json("/ietf-system:system-state") data = get_json("/ietf-system:system-state")
@@ -562,6 +569,7 @@ def system(args: List[str]) -> None:
fan_rpm = None fan_rpm = None
if hardware_data and "ietf-hardware:hardware" in hardware_data: if hardware_data and "ietf-hardware:hardware" in hardware_data:
components = hardware_data.get("ietf-hardware:hardware", {}).get("component", []) components = hardware_data.get("ietf-hardware:hardware", {}).get("component", [])
soc_temps = []
for component in components: for component in components:
sensor_data = component.get("sensor-data", {}) sensor_data = component.get("sensor-data", {})
if not sensor_data: if not sensor_data:
@@ -570,16 +578,21 @@ def system(args: List[str]) -> None:
name = component.get("name", "") name = component.get("name", "")
value_type = sensor_data.get("value-type") value_type = sensor_data.get("value-type")
# Only capture CPU/SoC temperature (ignore phy, sfp, etc.) # Capture SoC/CPU temperature, ignoring per-port phy, sfp, etc.
# Different platforms use different names: cpu, soc, core, etc. # Platforms name the zone differently: a plain "cpu"/"soc"/"core",
if value_type == "celsius" and name in ("cpu", "soc", "core") and cpu_temp is None: # or, on Marvell CN913x, an "ap-*" (application processor) or
temp_millidegrees = sensor_data.get("value", 0) # "cp<N>-*" (communication processor) cluster. Collect them all
cpu_temp = temp_millidegrees / 1000.0 # 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 # Capture fan speed if available
elif value_type == "rpm" and fan_rpm is None: elif value_type == "rpm" and fan_rpm is None:
fan_rpm = sensor_data.get("value", 0) fan_rpm = sensor_data.get("value", 0)
if soc_temps:
cpu_temp = max(soc_temps)
if cpu_temp is not None: if cpu_temp is not None:
runtime["cpu_temp"] = cpu_temp runtime["cpu_temp"] = cpu_temp
if fan_rpm is not None: if fan_rpm is not None:
+10
View File
@@ -414,6 +414,16 @@ int cni_netdag_gen_iface(struct dagger *net, const char *ifname,
return -EIO; return -EIO;
fprintf(fp, "container -a -f delete network %s >/dev/null\n", ifname); 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); fclose(fp);
if (cni_type == IFT_BRIDGE) if (cni_type == IFT_BRIDGE)
+15 -10
View File
@@ -21,23 +21,28 @@
bool veth_is_primary(struct lyd_node *cif) bool veth_is_primary(struct lyd_node *cif)
{ {
struct lyd_node *peer, *veth; struct lyd_node *peer, *veth;
bool self_cni, peer_cni;
const char *peername; const char *peername;
veth = lydx_get_child(cif, "veth"); veth = lydx_get_child(cif, "veth");
peername = lydx_get_cattr(veth, "peer"); peername = lydx_get_cattr(veth, "peer");
peer = lydx_find_by_name(lyd_parent(cif), "interface", peername); peer = lydx_find_by_name(lyd_parent(cif), "interface", peername);
/* At the moment, CNI code relies on one side of the pair self_cni = lydx_get_child(cif, "container-network") != NULL;
* remaining in the host namespace, and that that interface peer_cni = lydx_get_child(peer, "container-network") != NULL;
* takes care of creating the pair.
*/
if (lydx_get_child(cif, "container-network"))
return false;
if (lydx_get_child(peer, "container-network"))
return true;
return strcmp(lydx_get_cattr(cif, "name"), /* When exactly one end is handed to a container (CNI host-device),
lydx_get_cattr(veth, "peer")) < 0; * 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) 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)) if (!veth_is_primary(dif))
return 0; 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); 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; 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) static sr_error_t netdag_gen_iface_timeout(struct dagger *net, const char *ifname, const char *iftype)
{ {
if (!strcmp(iftype, "infix-if-type:ethernet")) { 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))) { if ((err = cni_netdag_gen_iface(net, ifname, dif, cif))) {
/* error or managed by CNI/podman */ /* error or managed by CNI/podman */
if (err > 0) if (err > 0) {
err = 0; /* done, nothing more to do here */ 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; goto err;
} }
+1 -5
View File
@@ -59,11 +59,7 @@ submodule infix-if-container {
identity host { identity host {
base container-network; base container-network;
description "Host device, e.g., one end of a VETH pair or other host interface. 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.";
} }
/* /*
+1 -5
View File
@@ -13,11 +13,7 @@ submodule infix-if-veth {
organization "KernelKit"; organization "KernelKit";
contact "kernelkit@googlegroups.com"; contact "kernelkit@googlegroups.com";
description "Linux virtual Ethernet pair extension for ietf-interfaces. 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.";
revision 2023-06-05 { revision 2023-06-05 {
description "Initial revision."; description "Initial revision.";
+17 -4
View File
@@ -916,7 +916,12 @@ class Sensor:
# Standalone sensor without description: use name as-is # Standalone sensor without description: use name as-is
display_name = self.name 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 # For colored value, pad manually to account for ANSI codes
value_str = self.get_formatted_value() value_str = self.get_formatted_value()
# Count visible characters (strip ANSI codes for length calculation) # Count visible characters (strip ANSI codes for length calculation)
@@ -2245,6 +2250,13 @@ def show_services(json):
service_table.print() 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): def show_hardware(json):
if not json.get("ietf-hardware:hardware"): if not json.get("ietf-hardware:hardware"):
print("Error, top level \"ietf-hardware:component\" missing") print("Error, top level \"ietf-hardware:component\" missing")
@@ -2418,15 +2430,16 @@ def show_hardware(json):
print(f"\n{module_name}:") print(f"\n{module_name}:")
if module_name in children: 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 = Sensor(child)
sensor.print(indent=1) 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 standalone:
if modules: if modules:
print() # Add blank line between modules and standalone 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 = Sensor(component)
sensor.print() sensor.print()
+41
View File
@@ -149,6 +149,36 @@ def normalize_sensor_name(name):
return 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(): def get_wifi_phy_info():
""" """
Discover WiFi PHYs using iw list command. Discover WiFi PHYs using iw list command.
@@ -218,6 +248,7 @@ def hwmon_sensor_components():
""" """
components = [] components = []
device_sensors = {} # Track {device_base_name: [list of sensor components]} device_sensors = {} # Track {device_base_name: [list of sensor components]}
phy_ifname = phy_handle_to_ifname()
def add_sensor(base_name, sensor_component): def add_sensor(base_name, sensor_component):
"""Helper to track sensors per device""" """Helper to track sensors per device"""
@@ -244,6 +275,16 @@ def hwmon_sensor_components():
base_name = normalize_sensor_name(device_name) 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 # Helper to create sensor component with human-readable description
def create_sensor(sensor_name, value, value_type, value_scale, label=None): def create_sensor(sensor_name, value, value_type, value_scale, label=None):
component = { component = {
+3
View File
@@ -18,6 +18,9 @@
- name: Container with VETH Pair - name: Container with VETH Pair
case: veth/test.py case: veth/test.py
- name: VETH Pair Between Two Containers
case: internal_link/test.py
- name: Container Volume Persistence - name: Container Volume Persistence
case: volume/test.py 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

+15 -10
View File
@@ -22,10 +22,12 @@ with infamy.Test() as test:
target = env.attach("target", "mgmt") target = env.attach("target", "mgmt")
_, data_if = env.ltop.xlate("target", "data") _, data_if = env.ltop.xlate("target", "data")
_, mgmt_if = env.ltop.xlate("target", "mgmt") _, mgmt_if = env.ltop.xlate("target", "mgmt")
_, unused_if = env.ltop.xlate("target", "unused")
_, host_data = env.ltop.xlate("host", "data") _, host_data = env.ltop.xlate("host", "data")
TARGET_IP = "192.168.1.1" TARGET_IP = "192.168.1.1"
HOST_IP = "192.168.1.42" 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"): with test.step("Configure basic end-device firewall"):
target.put_config_dicts({ target.put_config_dicts({
@@ -41,6 +43,11 @@ with infamy.Test() as test:
"prefix-length": 24 "prefix-length": 24
}] }]
} }
},
{
"name": UNUSED_IF,
"type": "infix-if-type:dummy",
"enabled": True
} }
] ]
} }
@@ -119,16 +126,14 @@ with infamy.Test() as test:
assert "http" in public_zone["service"] assert "http" in public_zone["service"]
with test.step("Verify unused interface assigned to default zone"): with test.step("Verify unused interface assigned to default zone"):
data = target.get_data("/infix-firewall:firewall") def unused_in_default_zone():
fw = data["firewall"] 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", [])
assert fw["default"] == "public-untrusted-net", "Default zone should be 'public-untrusted-net'" until(unused_in_default_zone, attempts=10)
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']}"
with infamy.IsolatedMacVlan(host_data) as ns: with infamy.IsolatedMacVlan(host_data) as ns:
ns.addip(HOST_IP) ns.addip(HOST_IP)
+5 -12
View File
@@ -1,4 +1,4 @@
graph "1x3" { graph "1x2" {
layout = "neato"; layout = "neato";
overlap = false; overlap = false;
esep = "+30"; esep = "+30";
@@ -8,23 +8,16 @@ graph "1x3" {
host [ host [
label="host | { <mgmt> mgmt | <data> data }", label="host | { <mgmt> mgmt | <data> data }",
pos="10,10.95!", pos="10,10!",
requires="controller" requires="controller"
]; ];
target [ target [
label="{ <mgmt> mgmt | <data> data | <unused> unused } | target", label="{ <mgmt> mgmt | <data> data } | target",
pos="30,10!", pos="40,10!",
requires="infix",
];
dummy [
label="{ <link> link } | dummy",
pos="29.8,00!",
requires="infix", requires="infix",
]; ];
host:mgmt -- target:mgmt [requires="mgmt", color="lightgray"] host:mgmt -- target:mgmt [requires="mgmt", color="lightgray"]
host:data -- target:data [color=black, fontcolor=black, taillabel="192.168.1.42/24"] host:data -- target:data [color=black]
target:unused -- dummy:link [color="gray", style="dashed"]
} }
+20 -36
View File
@@ -2,57 +2,41 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Title: 1x3 Pages: 1 --> <!-- Title: 1x2 Pages: 1 -->
<svg width="381pt" height="191pt" <svg width="299pt" height="55pt"
viewBox="0.00 0.00 381.03 190.72" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> viewBox="0.00 0.00 299.02 55.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 186.72)"> <g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 51)">
<title>1x3</title> <title>1x2</title>
<polygon fill="white" stroke="transparent" points="-4,4 -4,-186.72 377.03,-186.72 377.03,4 -4,4"/> <polygon fill="white" stroke="transparent" points="-4,4 -4,-51 295.02,-51 295.02,4 -4,4"/>
<!-- host --> <!-- host -->
<g id="node1" class="node"> <g id="node1" class="node">
<title>host</title> <title>host</title>
<polygon fill="none" stroke="black" points="0,-136.22 0,-182.22 100,-182.22 100,-136.22 0,-136.22"/> <polygon fill="none" stroke="black" points="0,-0.5 0,-46.5 100,-46.5 100,-0.5 0,-0.5"/>
<text text-anchor="middle" x="25" y="-155.52" font-family="DejaVu Sans Mono, Book" font-size="14.00">host</text> <text text-anchor="middle" x="25" y="-19.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">host</text>
<polyline fill="none" stroke="black" points="50,-136.22 50,-182.22 "/> <polyline fill="none" stroke="black" points="50,-0.5 50,-46.5 "/>
<text text-anchor="middle" x="75" y="-167.02" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text> <text text-anchor="middle" x="75" y="-31.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
<polyline fill="none" stroke="black" points="50,-159.22 100,-159.22 "/> <polyline fill="none" stroke="black" points="50,-23.5 100,-23.5 "/>
<text text-anchor="middle" x="75" y="-144.02" font-family="DejaVu Sans Mono, Book" font-size="14.00">data</text> <text text-anchor="middle" x="75" y="-8.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">data</text>
</g> </g>
<!-- target --> <!-- target -->
<g id="node2" class="node"> <g id="node2" class="node">
<title>target</title> <title>target</title>
<polygon fill="none" stroke="black" points="241.03,-112.51 241.03,-181.51 373.03,-181.51 373.03,-112.51 241.03,-112.51"/> <polygon fill="none" stroke="black" points="175.02,-0.5 175.02,-46.5 291.02,-46.5 291.02,-0.5 175.02,-0.5"/>
<text text-anchor="middle" x="274.03" y="-166.31" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text> <text text-anchor="middle" x="200.02" y="-31.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
<polyline fill="none" stroke="black" points="241.03,-158.51 307.03,-158.51 "/> <polyline fill="none" stroke="black" points="175.02,-23.5 225.02,-23.5 "/>
<text text-anchor="middle" x="274.03" y="-143.31" font-family="DejaVu Sans Mono, Book" font-size="14.00">data</text> <text text-anchor="middle" x="200.02" y="-8.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">data</text>
<polyline fill="none" stroke="black" points="241.03,-135.51 307.03,-135.51 "/> <polyline fill="none" stroke="black" points="225.02,-0.5 225.02,-46.5 "/>
<text text-anchor="middle" x="274.03" y="-120.31" font-family="DejaVu Sans Mono, Book" font-size="14.00">unused</text> <text text-anchor="middle" x="258.02" y="-19.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">target</text>
<polyline fill="none" stroke="black" points="307.03,-112.51 307.03,-181.51 "/>
<text text-anchor="middle" x="340.03" y="-143.31" font-family="DejaVu Sans Mono, Book" font-size="14.00">target</text>
</g> </g>
<!-- host&#45;&#45;target --> <!-- host&#45;&#45;target -->
<g id="edge1" class="edge"> <g id="edge1" class="edge">
<title>host:mgmt&#45;&#45;target:mgmt</title> <title>host:mgmt&#45;&#45;target:mgmt</title>
<path fill="none" stroke="lightgray" stroke-width="2" d="M100,-171.22C100,-171.22 241.03,-170.01 241.03,-170.01"/> <path fill="none" stroke="lightgray" stroke-width="2" d="M100,-35.5C100,-35.5 175.02,-35.5 175.02,-35.5"/>
</g> </g>
<!-- host&#45;&#45;target --> <!-- host&#45;&#45;target -->
<g id="edge2" class="edge"> <g id="edge2" class="edge">
<title>host:data&#45;&#45;target:data</title> <title>host:data&#45;&#45;target:data</title>
<path fill="none" stroke="black" stroke-width="2" d="M100,-147.22C100,-147.22 241.03,-147.01 241.03,-147.01"/> <path fill="none" stroke="black" stroke-width="2" d="M100,-11.5C100,-11.5 175.02,-11.5 175.02,-11.5"/>
<text text-anchor="middle" x="159" y="-151.02" font-family="DejaVu Serif, Book" font-size="14.00">192.168.1.42/24</text>
</g>
<!-- dummy -->
<g id="node3" class="node">
<title>dummy</title>
<polygon fill="none" stroke="black" points="250.46,-0.5 250.46,-36.5 358.46,-36.5 358.46,-0.5 250.46,-0.5"/>
<text text-anchor="middle" x="275.46" y="-14.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">link</text>
<polyline fill="none" stroke="black" points="300.46,-0.5 300.46,-36.5 "/>
<text text-anchor="middle" x="329.46" y="-14.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">dummy</text>
</g>
<!-- target&#45;&#45;dummy -->
<g id="edge3" class="edge">
<title>target:unused&#45;&#45;dummy:link</title>
<path fill="none" stroke="gray" stroke-width="2" stroke-dasharray="5,2" d="M274.03,-112.01C274.03,-112.01 275.46,-36.5 275.46,-36.5"/>
</g> </g>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB