diff --git a/board/aarch64/bananapi-bpi-r64/README.md b/board/aarch64/bananapi-bpi-r64/README.md
index e4b1ee42d..6c4c3832a 100644
--- a/board/aarch64/bananapi-bpi-r64/README.md
+++ b/board/aarch64/bananapi-bpi-r64/README.md
@@ -14,7 +14,7 @@ The Banana Pi BPI-R64 is a networking board based on the MediaTek MT7622
- 8 GB eMMC storage
- microSD card slot
- MT7531 Gigabit Ethernet switch (4x LAN + 1x WAN)
-- MT7603E built-in 2.4 GHz WiFi
+- MT7622 WMAC built-in 2.4 GHz 802.11ac WiFi
- USB 3.0 port
- 2x Mini PCIe slots
@@ -24,7 +24,7 @@ Infix comes preconfigured with:
- **LAN ports** (lan0-lan3): Bridged for internal networking
- **WAN port**: DHCP client enabled for internet connectivity
-- **WiFi** (wifi0-ap): Bridged to LAN (MT7615 PCIe card if fitted, otherwise MT7603E)
+- **WiFi** (wifi0-ap): Bridged to LAN (MT7622 WMAC, 2.4 GHz; or MT7615 PCIe card if fitted)
## Boot Switch Reference
@@ -113,6 +113,19 @@ mmc partconf 0 1 1 0
## Platform Notes
+### WiFi
+
+The MT7622 SoC includes a built-in WMAC that provides 2.4 GHz 802.11b/g/n/ac
+(2×2) — there is no 5 GHz capability from the onboard radio.
+
+For dual-band operation, a PCIe card can be fitted in one of the two Mini PCIe
+slots. The [Banana Pi BPI-MT7615][bpi-mt7615] is a purpose-built dual-band
+(2.4 + 5 GHz, 802.11ac 4×4) module designed for BPI router boards and is a
+natural fit. With it installed, Infix will use it in preference to the onboard
+WMAC for the `wifi0-ap` interface.
+
+[bpi-mt7615]: https://docs.banana-pi.org/en/BPI-MT7615/BananaPi_MT7615
+
### mmc0 = eMMC, mmc1 = SD
On MT7622, MSDC0 (mmc0) is the 8-bit eMMC controller and MSDC1 (mmc1) is the
diff --git a/buildroot b/buildroot
index e411d462c..168601af4 160000
--- a/buildroot
+++ b/buildroot
@@ -1 +1 @@
-Subproject commit e411d462cc46b4dd7d64eff527c762c9a04a51f6
+Subproject commit 168601af48567b93bfe0d7bdae3cc8eb266718a5
diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md
index 0dda4878a..b4e0c88d4 100644
--- a/doc/ChangeLog.md
+++ b/doc/ChangeLog.md
@@ -3,29 +3,55 @@ Change Log
All notable changes to the project are documented in this file.
-[v26.04.0][UNRELEASED] -
+[v26.04.0][] - 2026-04-30
-------------------------
### Changes
- Upgrade Linux kernel to 6.18.25 (LTS)
- Upgrade Buildroot to 2025.02.13 (LTS)
+- Add support for per-bridge multicast router port in operational, issue #395
+- Add support for static ARP (IPv4) and neighbor cache (IPv6) entries per
+ interface, issue #819. Static entries are installed as permanent kernel
+ neighbor table entries that are never evicted by normal ARP/NDP aging
- Add support for PTP/gPTP (IEEE 1588-2019 / 802.1AS) clock synchronization.
Supported clock types: Ordinary Clock, Boundary Clock, and Transparent Clock.
See the User Guide for configuration details
- Add support for [Banana Pi BPI-R4][BPI-R4], quad-core Cortex-A73 router with
4x 2.5 GbE switching, dual 10 GbE SFP+. Variants BPI-R4-2g5 and BPI-R4P have
one SFP+ replaced by a 2.5 GbE RJ45, with optional PoE on the R4P
+- Update [Marvell ESPRESSObin][ESPRESSObin] board support. Allow booting with
+ stock U-Boot, which only supports ext4 rootfs partitions; to use, apply the
+ `ext4` developer snippet before building (`make apply-ext4 all`)
+- Fix onboard WiFi support on the Banana Pi BPi-R64
### Fixes
+- Fix #520: warn in syslog if multicast flooding is disabled
+- Fix #769: document dummy interfaces in user guide
+- Fix #790: document static multicast filters in user guide
+- Fix #1439: changing hostname does not regenerate DHCP client conf until restart
- Fix #1458: `show ntp tracking` displaying a truncated Reference ID, e.g.,
`92.2` instead of `92.246.137.39`
- Fix #1466: `show container` showing no output for containers whose command
line includes environment variables
-- Fix #1439: changing hostname does not regenerate DHCP client conf until restart
+- Fix issue with IGMP queries sent with all-zeroes source MAC address
+- Fix missing IGMP query startup burst when assuming IGMP querier role, as
+ defined in RFC3376 §8.6/§8.7
+- Fix Raspberry Pi 4 and Pi 400 display instability after soft reboot.
+ Previously the touchscreen/DSI display required a full power cycle to
+ reinitialise correctly; it now works reliably after `reboot`. Please note,
+ you need a fairly up-to-date EEPROM version as well
+- Fix [BPI-R4][] board README showing inverted DIP switch values for eMMC and
+ SPI NAND boot modes, which would prevent the board from booting correctly
+- Fix [SAMA7G54][] U-Boot build system selection that caused build failures
+- Fix [BPI-R3][] PCIe devices failing to initialize on boot due to a missing
+ clock definition in the device tree
+[BPI-R3]: https://wiki.banana-pi.org/Banana_Pi_BPI-R3
[BPI-R4]: https://docs.banana-pi.org/en/BPI-R4/BananaPi_BPI-R4
+[ESPRESSObin]: https://espressobin.net/
+[SAMA7G54]: https://www.microchip.com/en-us/development-tool/ev21h18a
[v26.03.0][] - 2026-03-31
-------------------------
diff --git a/doc/bridging.md b/doc/bridging.md
index c751b90c1..187cb980a 100644
--- a/doc/bridging.md
+++ b/doc/bridging.md
@@ -183,6 +183,48 @@ In this setup we have a lot more going on. Multiple multicast router
ports have been detected, and behind the scenes someone has also added
an IGMP/MLD fast-leave port.
+### Static Multicast Filters
+
+When IGMP/MLD snooping is in use, traffic for an unregistered group is
+flooded to all ports until a receiver joins. For MAC multicast groups,
+or for groups where snooping cannot learn membership automatically, you
+can add static entries to the MDB that immediately restrict forwarding
+to a given set of ports.
+
+> [!NOTE]
+> Snooping must be enabled on the bridge (or per VLAN) before static
+> multicast filters can be configured.
+
+On a plain (non-VLAN) bridge, add a static IPv4 or MAC multicast filter
+like this:
+
+
admin@example:/> configure
+admin@example:/config/> edit interface br0
+admin@example:/config/interface/br0/> set bridge multicast-filters multicast-filter 224.1.1.1 ports e2
+admin@example:/config/interface/br0/> set bridge multicast-filters multicast-filter 224.1.1.1 ports e3
+admin@example:/config/interface/br0/> set bridge multicast-filters multicast-filter 01:00:5e:01:01:01 ports e2
+admin@example:/config/interface/br0/> leave
+admin@example:/> copy running-config startup-config
+
+
+Each `ports` entry for the same group adds one port to the filter.
+Receivers on all other ports will not see traffic for that group.
+
+On a VLAN-filtering bridge the filter is scoped per VLAN:
+
+admin@example:/config/interface/br1/> set bridge vlans vlan 10 multicast-filters multicast-filter 224.2.2.2 ports e5
+admin@example:/config/interface/br1/> set bridge vlans vlan 10 multicast-filters multicast-filter 224.2.2.2 ports e6
+
+
+To verify the MDB — both statically configured and dynamically learned
+entries — use:
+
+admin@example:/> show bridge mdb
+
+br0 224.1.1.1 e2, e3
+br0 01:00:5e:01:01:01 e2
+
+
### Terminology & Abbreviations
- **IGMP**: Internet Group Membership Protocol, multicast subscription
diff --git a/doc/iface.md b/doc/iface.md
index 3ec252850..9cecd8daa 100644
--- a/doc/iface.md
+++ b/doc/iface.md
@@ -138,6 +138,49 @@ admin@example:/config/interface/veth0a/> set custom-phys-address chassis offs
+## Dummy Interface
+
+A dummy interface is a virtual interface that is always administratively
+and operationally UP, regardless of any physical link state. It can
+hold IP addresses just like any other interface.
+
+The two most common uses are:
+
+- **Stable OSPF router-ID**: OSPF picks its router-ID from an interface
+ address. If that interface goes down, adjacencies can flap. Binding
+ the router-ID to a /32 address on a dummy avoids this.
+- **Stable management address**: A /32 on a dummy gives the device a
+ permanent identity on the network, reachable as long as at least one
+ uplink is up and the address is redistributed into the routing domain.
+
+> [!TIP]
+> WiFi interfaces also use dummies as placeholders when the radio
+> hardware is not detected at boot (e.g., a USB dongle that was
+> unplugged). See [WiFi](wifi.md) for details.
+
+### Example: Stable OSPF Router-ID
+
+Create a dummy interface with a /32 address and use it as the OSPF
+router-ID so that the ID never changes when physical ports bounce:
+
+admin@example:/> configure
+admin@example:/config/> edit interface lo0
+admin@example:/config/interface/lo0/> set type dummy
+admin@example:/config/interface/lo0/> set ipv4 address 192.0.2.1 prefix-length 32
+admin@example:/config/interface/lo0/> leave
+admin@example:/config/> edit routing control-plane-protocol ospfv2 name default ospf
+admin@example:/config/routing/…/ospf/> set explicit-router-id 192.0.2.1
+admin@example:/config/routing/…/ospf/> leave
+admin@example:/> copy running-config startup-config
+
+
+To also make the address reachable by other routers, redistribute
+connected routes (or add `lo0` as an OSPF interface):
+
+admin@example:/config/routing/…/ospf/> set redistribute connected
+
+
+
[^1]: A YANG deviation was previously used to make it possible to set
`phys-address`, but this has been replaced with the more flexible
`custom-phys-address`.
diff --git a/doc/ip.md b/doc/ip.md
index 69d105de6..a42302ff4 100644
--- a/doc/ip.md
+++ b/doc/ip.md
@@ -435,6 +435,66 @@ admin@example:/config/interface/eth0/> leave
admin@example:/>
+## ARP and Neighbor Cache
+
+Static ARP entries (IPv4) and neighbor cache entries (IPv6) can be
+configured per interface. The most common reasons to do so are:
+
+- **Security** — prevent ARP/NDP spoofing by locking critical hosts
+ (e.g., a default gateway) to their known MAC addresses
+- **Reliability** — ensure reachability of a host even when ARP/NDP
+ traffic is suppressed or filtered (e.g., across a strict firewall)
+
+Dynamic entries are learned automatically by the kernel using ARP and
+Neighbor Discovery Protocol (NDP), and are visible as read-only
+operational state alongside the static ones.
+
+### Static IPv4 ARP Entry
+
+admin@example:/> configure
+admin@example:/config/> edit interface eth0 ipv4
+admin@example:/config/interface/eth0/ipv4/> set neighbor 192.168.1.100 link-layer-address 00:11:22:33:44:55
+admin@example:/config/interface/eth0/ipv4/> diff
++interfaces {
++ interface eth0 {
++ ipv4 {
++ neighbor 192.168.1.100 {
++ link-layer-address 00:11:22:33:44:55;
++ }
++ }
++ }
++}
+admin@example:/config/interface/eth0/ipv4/> leave
+admin@example:/>
+
+
+### Static IPv6 Neighbor Entry
+
+admin@example:/> configure
+admin@example:/config/> edit interface eth0 ipv6
+admin@example:/config/interface/eth0/ipv6/> set neighbor 2001:db8::100 link-layer-address 00:11:22:33:44:55
+admin@example:/config/interface/eth0/ipv6/> diff
++interfaces {
++ interface eth0 {
++ ipv6 {
++ neighbor 2001:db8::100 {
++ link-layer-address 00:11:22:33:44:55;
++ }
++ }
++ }
++}
+admin@example:/config/interface/eth0/ipv6/> leave
+admin@example:/>
+
+
+The full neighbor table — including dynamically learned entries (origin:
+*dynamic*) — is available as operational state via NETCONF or RESTCONF.
+
+> [!NOTE]
+> Static neighbor entries take effect immediately on `leave` (commit).
+> They are installed as *permanent* entries in the kernel neighbor table,
+> which means they are never evicted by the normal ARP/NDP aging process.
+
[1]: https://www.rfc-editor.org/rfc/rfc3442
[2]: https://www.rfc-editor.org/rfc/rfc8344
[3]: https://www.rfc-editor.org/rfc/rfc8981
diff --git a/package/finit/0001-Remove-redundant-global-path-var-and-fix-memory-corr.patch b/package/finit/0001-Remove-redundant-global-path-var-and-fix-memory-corr.patch
deleted file mode 100644
index e10e66557..000000000
--- a/package/finit/0001-Remove-redundant-global-path-var-and-fix-memory-corr.patch
+++ /dev/null
@@ -1,54 +0,0 @@
-From 184c079c08387d1b0f74de25b63c56304d2156d0 Mon Sep 17 00:00:00 2001
-From: bazub
-Date: Tue, 3 Mar 2026 20:31:33 +0000
-Subject: [PATCH 1/3] Remove redundant global path var and fix memory
- corruption
-Organization: Wires
-
-Signed-off-by: Joachim Wiberg
----
- src/conf.c | 13 ++-----------
- 1 file changed, 2 insertions(+), 11 deletions(-)
-
-diff --git a/src/conf.c b/src/conf.c
-index d0abb61d..6e8ec834 100644
---- a/src/conf.c
-+++ b/src/conf.c
-@@ -121,9 +121,6 @@ static uev_t etcw;
-
- static TAILQ_HEAD(, conf_change) conf_change_list = TAILQ_HEAD_INITIALIZER(conf_change_list);
-
--static char *path;
--static char *shell;
--
- static int parse_conf(char *file, int is_rcsd);
- static void drop_changes(void);
-
-@@ -377,8 +374,6 @@ void conf_parse_cmdline(int argc, char *argv[])
- fstab = strdup(ptr);
- finit_conf = strdup(FINIT_CONF);
- finit_rcsd = strdup(FINIT_RCSD);
-- path = getenv("PATH");
-- shell = getenv("SHELL");
-
- for (int i = 1; i < argc; i++)
- parse_arg(argv[i]);
-@@ -404,13 +399,9 @@ void conf_reset_env(void)
- free(node);
- }
-
-- if (path)
-- setenv("PATH", path, 1);
-- else
-+ if (!getenv("PATH"))
- setenv("PATH", _PATH_STDPATH, 1);
-- if (shell)
-- setenv("SHELL", shell, 1);
-- else
-+ if (!getenv("SHELL"))
- setenv("SHELL", _PATH_BSHELL, 1);
- setenv("LOGNAME", "root", 1);
- setenv("USER", "root", 1);
---
-2.43.0
-
diff --git a/package/finit/0002-Use-explicit-plugin-names-to-prevent-subtle-macro-pr.patch b/package/finit/0002-Use-explicit-plugin-names-to-prevent-subtle-macro-pr.patch
deleted file mode 100644
index 1c9bdb721..000000000
--- a/package/finit/0002-Use-explicit-plugin-names-to-prevent-subtle-macro-pr.patch
+++ /dev/null
@@ -1,318 +0,0 @@
-From 662293e194811213b9b387163dfe8499e3300ccf Mon Sep 17 00:00:00 2001
-From: bazub
-Date: Fri, 6 Mar 2026 20:36:23 +0000
-Subject: [PATCH 2/3] Use explicit plugin names to prevent subtle macro
- processing bugs
-Organization: Wires
-
-Signed-off-by: Joachim Wiberg
----
- plugins/alsa-utils.c | 6 +++---
- plugins/bootmisc.c | 2 +-
- plugins/dbus.c | 4 ++--
- plugins/hook-scripts.c | 2 +-
- plugins/modprobe.c | 2 +-
- plugins/modules-load.c | 2 +-
- plugins/netlink.c | 2 +-
- plugins/pidfile.c | 2 +-
- plugins/procps.c | 4 ++--
- plugins/resolvconf.c | 2 +-
- plugins/rtc.c | 6 +++---
- plugins/sys.c | 2 +-
- plugins/tty.c | 1 +
- plugins/urandom.c | 6 +++---
- plugins/usr.c | 2 +-
- plugins/x11-common.c | 4 ++--
- 16 files changed, 25 insertions(+), 24 deletions(-)
-
-diff --git a/plugins/alsa-utils.c b/plugins/alsa-utils.c
-index 6b2c3603..a8a967b0 100644
---- a/plugins/alsa-utils.c
-+++ b/plugins/alsa-utils.c
-@@ -38,7 +38,7 @@
- static void save(void *arg)
- {
- if (rescue) {
-- dbg("Skipping %s plugin in rescue mode.", __FILE__);
-+ dbg("Skipping %s plugin in rescue mode.", "alsa-utils");
- return;
- }
-
-@@ -51,7 +51,7 @@ static void save(void *arg)
- static void restore(void *arg)
- {
- if (rescue) {
-- dbg("Skipping %s plugin in rescue mode.", __FILE__);
-+ dbg("Skipping %s plugin in rescue mode.", "alsa-utils");
- return;
- }
-
-@@ -62,7 +62,7 @@ static void restore(void *arg)
- }
-
- static plugin_t plugin = {
-- .name = __FILE__,
-+ .name = "alsa-utils",
- .hook[HOOK_BASEFS_UP] = { .cb = restore },
- .hook[HOOK_SHUTDOWN] = { .cb = save }
- };
-diff --git a/plugins/bootmisc.c b/plugins/bootmisc.c
-index 701f73f0..a8ba3808 100644
---- a/plugins/bootmisc.c
-+++ b/plugins/bootmisc.c
-@@ -172,7 +172,7 @@ static void setup(void *arg)
- }
-
- static plugin_t plugin = {
-- .name = __FILE__,
-+ .name = "bootmisc",
- .hook[HOOK_MOUNT_POST] = { .cb = clean },
- .hook[HOOK_BASEFS_UP] = { .cb = setup },
- .depends = { "pidfile" },
-diff --git a/plugins/dbus.c b/plugins/dbus.c
-index bbf55bc2..a8a155a1 100644
---- a/plugins/dbus.c
-+++ b/plugins/dbus.c
-@@ -106,7 +106,7 @@ static void setup(void *arg)
- char *cmd;
-
- if (rescue) {
-- dbg("Skipping %s plugin in rescue mode.", __FILE__);
-+ dbg("Skipping %s plugin in rescue mode.", "dbus");
- return;
- }
-
-@@ -164,7 +164,7 @@ static void setup(void *arg)
- }
-
- static plugin_t plugin = {
-- .name = __FILE__,
-+ .name = "dbus",
- .hook[HOOK_SVC_PLUGIN] = { .cb = setup },
- };
-
-diff --git a/plugins/hook-scripts.c b/plugins/hook-scripts.c
-index 9aa78173..e75808c5 100644
---- a/plugins/hook-scripts.c
-+++ b/plugins/hook-scripts.c
-@@ -79,7 +79,7 @@ static void hscript_shutdown(void *arg)
- }
-
- static plugin_t plugin = {
-- .name = __FILE__,
-+ .name = "hook-scripts",
- .hook[HOOK_BANNER] = { .cb = hscript_banner },
- .hook[HOOK_ROOTFS_UP] = { .cb = hscript_rootfs_up },
- .hook[HOOK_MOUNT_ERROR] = { .cb = hscript_mount_error },
-diff --git a/plugins/modprobe.c b/plugins/modprobe.c
-index b6b7e7bb..e52bf228 100644
---- a/plugins/modprobe.c
-+++ b/plugins/modprobe.c
-@@ -228,7 +228,7 @@ static void coldplug(void *arg)
- }
-
- static plugin_t plugin = {
-- .name = __FILE__,
-+ .name = "modprobe",
- .hook[HOOK_BASEFS_UP] = { .cb = coldplug },
- .depends = { "bootmisc", }
- };
-diff --git a/plugins/modules-load.c b/plugins/modules-load.c
-index 53aef82f..e2e45c72 100644
---- a/plugins/modules-load.c
-+++ b/plugins/modules-load.c
-@@ -213,7 +213,7 @@ static void load(void *arg)
- }
-
- static plugin_t plugin = {
-- .name = __FILE__,
-+ .name = "modules-load",
- .hook[HOOK_SVC_PLUGIN] = { .cb = load },
- };
-
-diff --git a/plugins/netlink.c b/plugins/netlink.c
-index a70b01e8..625cbd1b 100644
---- a/plugins/netlink.c
-+++ b/plugins/netlink.c
-@@ -426,7 +426,7 @@ static void nl_enumerate(void *arg)
- }
-
- static plugin_t plugin = {
-- .name = __FILE__,
-+ .name = "netlink",
- .hook[HOOK_SVC_RECONF] = { .cb = nl_reconf },
- .hook[HOOK_SVC_PLUGIN] = { .cb = nl_enumerate },
- .io = {
-diff --git a/plugins/pidfile.c b/plugins/pidfile.c
-index ae0fdeea..f1232ab6 100644
---- a/plugins/pidfile.c
-+++ b/plugins/pidfile.c
-@@ -335,7 +335,7 @@ static void pidfile_init(void *arg)
- * SIGSTP:ed (in state PAUSED) waiting for .
- */
- static plugin_t plugin = {
-- .name = __FILE__,
-+ .name = "pidfile",
- .hook[HOOK_BASEFS_UP] = { .cb = pidfile_init },
- .hook[HOOK_SVC_RECONF] = { .cb = pidfile_reconf },
- .depends = { "netlink" }, /* bootmisc depends on us */
-diff --git a/plugins/procps.c b/plugins/procps.c
-index 826e38ad..5a178d75 100644
---- a/plugins/procps.c
-+++ b/plugins/procps.c
-@@ -43,7 +43,7 @@ static void setup(void *arg)
- glob_t gl;
-
- if (rescue) {
-- dbg("Skipping %s plugin in rescue mode.", __FILE__);
-+ dbg("Skipping %s plugin in rescue mode.", "procps");
- return;
- }
-
-@@ -69,7 +69,7 @@ static void setup(void *arg)
- }
-
- static plugin_t plugin = {
-- .name = __FILE__,
-+ .name = "procps",
- .hook[HOOK_BASEFS_UP] = {
- .cb = setup
- },
-diff --git a/plugins/resolvconf.c b/plugins/resolvconf.c
-index 1ac29dae..b98e9214 100644
---- a/plugins/resolvconf.c
-+++ b/plugins/resolvconf.c
-@@ -51,7 +51,7 @@ static void setup(void *arg)
- }
-
- static plugin_t plugin = {
-- .name = __FILE__,
-+ .name = "resolvconf",
- .hook[HOOK_BASEFS_UP] = {
- .cb = setup
- },
-diff --git a/plugins/rtc.c b/plugins/rtc.c
-index faf69415..cf734763 100644
---- a/plugins/rtc.c
-+++ b/plugins/rtc.c
-@@ -236,7 +236,7 @@ static void rtc_save(void *arg)
- int fd, rc = 0;
-
- if (rescue) {
-- dbg("Skipping %s plugin in rescue mode.", __FILE__);
-+ dbg("Skipping %s plugin in rescue mode.", "rtc");
- return;
- }
-
-@@ -266,7 +266,7 @@ static void rtc_restore(void *arg)
- int fd, rc = 0;
-
- if (rescue) {
-- dbg("Skipping %s plugin in rescue mode.", __FILE__);
-+ dbg("Skipping %s plugin in rescue mode.", "rtc");
- return;
- }
-
-@@ -321,7 +321,7 @@ static void update(uev_t *w, void *arg, int events)
-
-
- static plugin_t plugin = {
-- .name = __FILE__,
-+ .name = "rtc",
- .hook[HOOK_BASEFS_UP] = {
- .cb = rtc_restore
- },
-diff --git a/plugins/sys.c b/plugins/sys.c
-index 438fdfd5..484adc05 100644
---- a/plugins/sys.c
-+++ b/plugins/sys.c
-@@ -180,7 +180,7 @@ static void sys_init(void *arg)
- }
-
- static plugin_t plugin = {
-- .name = __FILE__,
-+ .name = "sys",
- .hook[HOOK_BASEFS_UP] = { .cb = sys_init },
- .depends = { "bootmisc", },
- };
-diff --git a/plugins/tty.c b/plugins/tty.c
-index b3255f9f..c6a750b0 100644
---- a/plugins/tty.c
-+++ b/plugins/tty.c
-@@ -43,6 +43,7 @@
- static void tty_watcher(void *arg, int fd, int events);
-
- static plugin_t plugin = {
-+ .name = "tty",
- .io = {
- .cb = tty_watcher,
- .flags = PLUGIN_IO_READ,
-diff --git a/plugins/urandom.c b/plugins/urandom.c
-index 410d0660..9bb6252b 100644
---- a/plugins/urandom.c
-+++ b/plugins/urandom.c
-@@ -87,7 +87,7 @@ static void setup(void *arg)
- int fd, err;
-
- if (rescue) {
-- dbg("Skipping %s plugin in rescue mode.", __FILE__);
-+ dbg("Skipping %s plugin in rescue mode.", "urandom");
- return;
- }
-
-@@ -188,7 +188,7 @@ static void save(void *arg)
- mode_t prev;
-
- if (rescue) {
-- dbg("Skipping %s plugin in rescue mode.", __FILE__);
-+ dbg("Skipping %s plugin in rescue mode.", "urandom");
- return;
- }
-
-@@ -202,7 +202,7 @@ static void save(void *arg)
- }
-
- static plugin_t plugin = {
-- .name = __FILE__,
-+ .name = "urandom",
- .hook[HOOK_BASEFS_UP] = { .cb = setup },
- .hook[HOOK_SHUTDOWN] = { .cb = save },
- .depends = { "bootmisc", }
-diff --git a/plugins/usr.c b/plugins/usr.c
-index d30d9440..496facc2 100644
---- a/plugins/usr.c
-+++ b/plugins/usr.c
-@@ -104,7 +104,7 @@ static void usr_init(void *arg)
- }
-
- static plugin_t plugin = {
-- .name = __FILE__,
-+ .name = "usr",
- .hook[HOOK_BASEFS_UP] = { .cb = usr_init },
- .depends = { "bootmisc", },
- };
-diff --git a/plugins/x11-common.c b/plugins/x11-common.c
-index f65331a0..75825a36 100644
---- a/plugins/x11-common.c
-+++ b/plugins/x11-common.c
-@@ -39,7 +39,7 @@
- static void setup(void *arg)
- {
- if (rescue) {
-- dbg("Skipping %s plugin in rescue mode.", __FILE__);
-+ dbg("Skipping %s plugin in rescue mode.", "x11-common");
- return;
- }
-
-@@ -48,7 +48,7 @@ static void setup(void *arg)
- }
-
- static plugin_t plugin = {
-- .name = __FILE__,
-+ .name = "x11-common",
- .hook[HOOK_SVC_PLUGIN] = { .cb = setup },
- };
-
---
-2.43.0
-
diff --git a/package/finit/0003-service-clear-condition-before-stopping-rdeps-on-rel.patch b/package/finit/0003-service-clear-condition-before-stopping-rdeps-on-rel.patch
deleted file mode 100644
index 0925b673c..000000000
--- a/package/finit/0003-service-clear-condition-before-stopping-rdeps-on-rel.patch
+++ /dev/null
@@ -1,66 +0,0 @@
-From c01faef99b7e4ff9c39f29ad5648db61a4742539 Mon Sep 17 00:00:00 2001
-From: Joachim Wiberg
-Date: Thu, 19 Mar 2026 06:37:50 +0100
-Subject: [PATCH 3/3] service: clear condition before stopping rdeps on reload
-MIME-Version: 1.0
-Content-Type: text/plain; charset=UTF-8
-Content-Transfer-Encoding: 8bit
-Organization: Wires
-
-When a service without SIGHUP reload support (noreload) is touched and
-'initctl reload' is called, service_update_rdeps() correctly identifies
-its reverse dependencies but only marks them dirty. It does not clear
-the service's condition, so when service_step_all() runs:
-
- - rdeps supporting SIGHUP hit the sm_in_reload() guard and break early,
- left running while their dependency is being killed.
- - rdeps without SIGHUP support may receive SIGTERM too late, after the
- dependency has already died and broken their connection, causing them
- to exit from RUNNING state and have their restart counter incremented.
-
-Fix by calling cond_clear() on the service's condition immediately in
-service_update_rdeps(), before service_step_all() runs. cond_clear()
-calls cond_update() which calls service_step() inline on all affected
-services, which see COND_OFF and transition to STOPPING_STATE — all
-before SIGTERM is ever sent to the dependency itself.
-
-This mirrors the pattern already used in api.c:do_reload() for direct
-'initctl reload ' calls.
-
-Fixes: avahi/mdns stop causing mdns-alias restart counter increment
-
-Signed-off-by: Joachim Wiberg
----
- src/service.c | 16 +++++++++++++++-
- 1 file changed, 15 insertions(+), 1 deletion(-)
-
-diff --git a/src/service.c b/src/service.c
-index b2c6c15b..016b5d2f 100644
---- a/src/service.c
-+++ b/src/service.c
-@@ -2418,7 +2418,21 @@ void service_update_rdeps(void)
- if (!svc_is_noreload(svc))
- continue; /* Yup, no need to stop start rdeps */
-
-- svc_mark_affected(mkcond(svc, cond, sizeof(cond)));
-+ /*
-+ * Clear the condition immediately, before service_step_all()
-+ * runs. cond_clear() calls cond_update() which calls
-+ * service_step() on all affected services right now. Those
-+ * services see COND_OFF and get service_stop() called,
-+ * transitioning to STOPPING_STATE before we ever send SIGTERM
-+ * to this service. Without this, the condition is only cleared
-+ * after the service dies, by which time reverse-dependencies
-+ * may have already crashed due to the lost connection.
-+ * See also: api.c do_reload() which does the same for direct
-+ * 'initctl reload ' calls.
-+ */
-+ mkcond(svc, cond, sizeof(cond));
-+ cond_clear(cond);
-+ svc_mark_affected(cond);
- }
- }
-
---
-2.43.0
-
diff --git a/package/finit/finit.hash b/package/finit/finit.hash
index fa95a00e6..d761d2d60 100644
--- a/package/finit/finit.hash
+++ b/package/finit/finit.hash
@@ -1,5 +1,5 @@
# From https://github.com/troglobit/finit/releases/
-sha256 f0894cd8b79ce030fdc656600208ddb0b994364f86ebad066de2e005e5e18a35 finit-4.16.tar.gz
+sha256 ca8b489a0ed99c17c0288e9e65e325a8f14af3734dfecf58f516c0a6f884e903 finit-4.17.tar.gz
# Locally calculated
sha256 868cb6c5414933a48db11186042cfe65c87480d326734bc6cf0e4b19b4a2e52a LICENSE
diff --git a/package/finit/finit.mk b/package/finit/finit.mk
index 27326b74d..09bea6a26 100644
--- a/package/finit/finit.mk
+++ b/package/finit/finit.mk
@@ -4,7 +4,7 @@
#
################################################################################
-FINIT_VERSION = 4.16
+FINIT_VERSION = 4.17
FINIT_SITE = https://github.com/troglobit/finit/releases/download/$(FINIT_VERSION)
FINIT_LICENSE = MIT
FINIT_LICENSE_FILES = LICENSE
diff --git a/package/mcd/mcd.hash b/package/mcd/mcd.hash
index d8575d93e..2c5048418 100644
--- a/package/mcd/mcd.hash
+++ b/package/mcd/mcd.hash
@@ -1,2 +1,2 @@
-sha256 dcd639c77689c432e84e63267202c00958fb1032e066ae355e3cbc68d9fcd063 mcd-2.3.tar.gz
+sha256 896c455c1a013a153cbf3186b5f87b7a3344b8939603c0f09e534de714a32e65 mcd-2.4.tar.gz
sha256 99a0480db163445c5a1a5bf7f85bc37cbb5be8c40e9e441291ad78e8d4252d0b LICENSE
diff --git a/package/mcd/mcd.mk b/package/mcd/mcd.mk
index 77fa36931..b8478d0b9 100644
--- a/package/mcd/mcd.mk
+++ b/package/mcd/mcd.mk
@@ -4,7 +4,7 @@
#
################################################################################
-MCD_VERSION = 2.3
+MCD_VERSION = 2.4
MCD_SITE = https://github.com/kernelkit/mcd/releases/download/v$(MCD_VERSION)
MCD_LICENSE = BSD-3-Clause
MCD_LICENSE_FILES = LICENSE
diff --git a/patches/linux/6.18.25/0049-wifi-mt76-mt7615-add-MODULE_DEVICE_TABLE-for-mt7622-.patch b/patches/linux/6.18.25/0049-wifi-mt76-mt7615-add-MODULE_DEVICE_TABLE-for-mt7622-.patch
new file mode 100644
index 000000000..3835043d3
--- /dev/null
+++ b/patches/linux/6.18.25/0049-wifi-mt76-mt7615-add-MODULE_DEVICE_TABLE-for-mt7622-.patch
@@ -0,0 +1,31 @@
+From 925bc823c4f4ece91c090371a358134e238e7cc4 Mon Sep 17 00:00:00 2001
+From: Joachim Wiberg
+Date: Tue, 28 Apr 2026 15:30:01 +0200
+Subject: [PATCH] wifi: mt76: mt7615: add MODULE_DEVICE_TABLE for mt7622 wmac
+Organization: Wires
+
+Without MODULE_DEVICE_TABLE(of, ...) the OF compatible alias is never
+exported to modules.alias, so udev cannot autoload mt7615e when the
+mediatek,mt7622-wmac platform device is probed. Add the export so the
+module loads automatically on BPI-R64 and similar MT7622-based boards.
+
+Signed-off-by: Joachim Wiberg
+---
+ drivers/net/wireless/mediatek/mt76/mt7615/soc.c | 1 +
+ 1 file changed, 1 insertion(+)
+
+diff --git a/drivers/net/wireless/mediatek/mt76/mt7615/soc.c b/drivers/net/wireless/mediatek/mt76/mt7615/soc.c
+index 06a0f2a141e8..538ea1dc333b 100644
+--- a/drivers/net/wireless/mediatek/mt76/mt7615/soc.c
++++ b/drivers/net/wireless/mediatek/mt76/mt7615/soc.c
+@@ -56,6 +56,7 @@ static const struct of_device_id mt7622_wmac_of_match[] = {
+ { .compatible = "mediatek,mt7622-wmac" },
+ {},
+ };
++MODULE_DEVICE_TABLE(of, mt7622_wmac_of_match);
+
+ struct platform_driver mt7622_wmac_driver = {
+ .driver = {
+--
+2.43.0
+
diff --git a/src/confd/src/if-bridge-mcd.c b/src/confd/src/if-bridge-mcd.c
index 7ea3e92a1..bf9a457d4 100644
--- a/src/confd/src/if-bridge-mcd.c
+++ b/src/confd/src/if-bridge-mcd.c
@@ -12,6 +12,56 @@
#include "interfaces.h"
+static void warn_mcast_flood_port(struct lyd_node *port_cif)
+{
+ struct lyd_node *bp, *flood;
+
+ bp = lydx_get_child(port_cif, "bridge-port");
+ if (!bp)
+ return;
+
+ flood = lydx_get_child(bp, "flood");
+ if (!lydx_is_enabled(flood, "multicast"))
+ WARN("%s: multicast flood disabled, topology changes will black-hole multicast until IGMP/MLD rejoin",
+ lydx_get_cattr(port_cif, "name"));
+}
+
+static void warn_mcast_flood_bridge(struct lyd_node *cif, const char *brname)
+{
+ struct ly_set *ports;
+ uint32_t i;
+
+ ports = lydx_find_xpathf(cif, "../interface[bridge-port/bridge='%s']", brname);
+ if (!ports)
+ return;
+
+ for (i = 0; i < ports->count; i++)
+ warn_mcast_flood_port(ports->dnodes[i]);
+
+ ly_set_free(ports, NULL);
+}
+
+static void warn_mcast_flood_vlan(struct lyd_node *cif, struct lyd_node *vlan)
+{
+ static const char *modes[] = { "tagged", "untagged", NULL };
+ struct lyd_node *portentry;
+ struct ly_set *set;
+ const char **mode;
+
+ for (mode = modes; *mode; mode++) {
+ LYX_LIST_FOR_EACH(lyd_child(vlan), portentry, *mode) {
+ set = lydx_find_xpathf(cif, "../interface[name='%s']",
+ lyd_get_value(portentry));
+ if (!set || !set->count) {
+ ly_set_free(set, NULL);
+ continue;
+ }
+ warn_mcast_flood_port(set->dnodes[0]);
+ ly_set_free(set, NULL);
+ }
+ }
+}
+
static int gen_vlan(struct lyd_node *cif, struct lyd_node *vlan, FILE *conf)
{
const char *iface, *querier, *upper;
@@ -26,6 +76,8 @@ static int gen_vlan(struct lyd_node *cif, struct lyd_node *vlan, FILE *conf)
if (!strcmp(querier, "off"))
return 0;
+ warn_mcast_flood_vlan(cif, vlan);
+
interval = atoi(lydx_get_cattr(mcast, "query-interval"));
iface = lydx_get_cattr(cif, "name");
@@ -66,6 +118,8 @@ static int gen_bridge(struct lyd_node *cif, FILE *conf)
interval = atoi(lydx_get_cattr(mcast, "query-interval"));
+ warn_mcast_flood_bridge(cif, iface);
+
fprintf(conf, "iface %s enable %s igmpv3 query-interval %d\n",
iface, !strcmp(querier, "proxy") ? "proxy-queries" : "",
interval);
diff --git a/src/confd/src/interfaces.c b/src/confd/src/interfaces.c
index a50879e68..a255c20ea 100644
--- a/src/confd/src/interfaces.c
+++ b/src/confd/src/interfaces.c
@@ -681,6 +681,8 @@ static sr_error_t netdag_gen_iface(sr_session_ctx_t *session, struct dagger *net
err = err ? : netdag_gen_link_addr(ip, cif, dif);
err = err ? : netdag_gen_ip_addrs(net, ip, "ipv4", cif, dif);
err = err ? : netdag_gen_ip_addrs(net, ip, "ipv6", cif, dif);
+ err = err ? : netdag_gen_ip_neighs(net, ip, "ipv4", cif, dif);
+ err = err ? : netdag_gen_ip_neighs(net, ip, "ipv6", cif, dif);
if (err)
goto err_close_ip;
diff --git a/src/confd/src/interfaces.h b/src/confd/src/interfaces.h
index 44968f0db..a427872f0 100644
--- a/src/confd/src/interfaces.h
+++ b/src/confd/src/interfaces.h
@@ -114,6 +114,8 @@ int netdag_gen_ipv4_autoconf(struct dagger *net, struct lyd_node *cif,
struct lyd_node *dif);
int netdag_gen_ip_addrs(struct dagger *net, FILE *ip, const char *proto,
struct lyd_node *cif, struct lyd_node *dif);
+int netdag_gen_ip_neighs(struct dagger *net, FILE *ip, const char *proto,
+ struct lyd_node *cif, struct lyd_node *dif);
/* if-bridge.c */
int bridge_mstpd_gen(struct lyd_node *cifs);
diff --git a/src/confd/src/ip.c b/src/confd/src/ip.c
index 1c843195e..a83c10170 100644
--- a/src/confd/src/ip.c
+++ b/src/confd/src/ip.c
@@ -242,6 +242,79 @@ static int netdag_set_conf_addrs(FILE *ip, const char *ifname,
return 0;
}
+static int netdag_gen_diff_neigh(FILE *ip, const char *ifname,
+ struct lyd_node *neigh)
+{
+ enum lydx_op op = lydx_get_op(neigh);
+ struct lyd_node *addr, *lladdr;
+ struct lydx_diff addrd, lladrd;
+
+ addr = lydx_get_child(neigh, "ip");
+ if (!addr)
+ return -EINVAL;
+
+ lydx_get_diff(addr, &addrd);
+
+ if (op == LYDX_OP_DELETE) {
+ fprintf(ip, "neigh del %s dev %s\n", addrd.old, ifname);
+ return 0;
+ }
+
+ lladdr = lydx_get_child(neigh, "link-layer-address");
+ if (!lladdr)
+ return -EINVAL;
+
+ lydx_get_diff(lladdr, &lladrd);
+ fprintf(ip, "neigh replace %s lladdr %s dev %s nud permanent\n",
+ addrd.new, lladrd.new, ifname);
+ return 0;
+}
+
+static int netdag_set_conf_neighs(FILE *ip, const char *ifname,
+ struct lyd_node *ipvx)
+{
+ struct lyd_node *neigh;
+
+ LYX_LIST_FOR_EACH(lyd_child(ipvx), neigh, "neighbor") {
+ fprintf(ip, "neigh replace %s lladdr %s dev %s nud permanent\n",
+ lydx_get_cattr(neigh, "ip"),
+ lydx_get_cattr(neigh, "link-layer-address"),
+ ifname);
+ }
+
+ return 0;
+}
+
+int netdag_gen_ip_neighs(struct dagger *net, FILE *ip, const char *proto,
+ struct lyd_node *cif, struct lyd_node *dif)
+{
+ struct lyd_node *ipconf = lydx_get_child(cif, proto);
+ struct lyd_node *ipdiff = lydx_get_child(dif, proto);
+ const char *ifname = lydx_get_cattr(dif, "name");
+ struct lyd_node *neigh;
+ int err = 0;
+
+ if (!ipconf || !lydx_is_enabled(ipconf, "enabled")) {
+ FILE *fp = dagger_fopen_net_exit(net, ifname, NETDAG_EXIT_PRE, "flush-neigh.sh");
+ if (fp) {
+ fprintf(fp, "ip -%c neigh flush dev %s nud permanent\n", proto[3], ifname);
+ fclose(fp);
+ }
+ return 0;
+ }
+
+ if (lydx_get_op(lydx_get_child(ipdiff, "enabled")) == LYDX_OP_REPLACE)
+ return netdag_set_conf_neighs(ip, ifname, ipconf);
+
+ LYX_LIST_FOR_EACH(lyd_child(ipdiff), neigh, "neighbor") {
+ err = netdag_gen_diff_neigh(ip, ifname, neigh);
+ if (err)
+ break;
+ }
+
+ return err;
+}
+
int netdag_gen_ip_addrs(struct dagger *net, FILE *ip, const char *proto,
struct lyd_node *cif, struct lyd_node *dif)
{
diff --git a/src/confd/yang/confd.inc b/src/confd/yang/confd.inc
index 83bea27e4..6a6505b0f 100644
--- a/src/confd/yang/confd.inc
+++ b/src/confd/yang/confd.inc
@@ -29,7 +29,7 @@ MODULES=(
"ietf-hardware@2018-03-13.yang -e hardware-state -e hardware-sensor"
"infix-hardware@2026-02-08.yang"
"ieee802-dot1q-types@2022-10-29.yang"
- "infix-ip@2025-11-02.yang"
+ "infix-ip@2026-04-28.yang"
"infix-if-type@2026-01-07.yang"
"infix-routing@2026-03-11.yang"
"ieee802-dot1ab-lldp@2022-03-15.yang"
@@ -47,7 +47,7 @@ MODULES=(
"ieee802-ethernet-interface@2019-06-21.yang"
"infix-ethernet-interface@2024-02-27.yang"
"infix-factory-default@2023-06-28.yang"
- "infix-interfaces@2026-04-09.yang -e vlan-filtering"
+ "infix-interfaces@2026-04-29.yang -e vlan-filtering"
"ietf-crypto-types -e cleartext-symmetric-keys"
"infix-crypto-types@2026-02-14.yang"
"ietf-keystore -e symmetric-keys"
diff --git a/src/confd/yang/confd/infix-if-bridge.yang b/src/confd/yang/confd/infix-if-bridge.yang
index c3af782c8..3e4d32a2a 100644
--- a/src/confd/yang/confd/infix-if-bridge.yang
+++ b/src/confd/yang/confd/infix-if-bridge.yang
@@ -29,6 +29,11 @@ submodule infix-if-bridge {
contact "kernelkit@googlegroups.com";
description "Linux bridge extension for ietf-interfaces.";
+ revision 2026-04-29 {
+ description "Add operational state for multicast router ports per bridge.";
+ reference "internal";
+ }
+
revision 2025-10-28 {
description "Prevent IP addresses on bridge ports.";
reference "internal";
@@ -389,6 +394,12 @@ submodule infix-if-bridge {
}
default 125;
}
+
+ leaf-list router-ports {
+ config false;
+ description "Ports with detected multicast routers, learned by IGMP/MLD snooping.";
+ type if:interface-ref;
+ }
}
}
diff --git a/src/confd/yang/confd/infix-if-bridge@2025-10-28.yang b/src/confd/yang/confd/infix-if-bridge@2026-04-29.yang
similarity index 100%
rename from src/confd/yang/confd/infix-if-bridge@2025-10-28.yang
rename to src/confd/yang/confd/infix-if-bridge@2026-04-29.yang
diff --git a/src/confd/yang/confd/infix-if-type.yang b/src/confd/yang/confd/infix-if-type.yang
index f64338ef4..3674376a8 100644
--- a/src/confd/yang/confd/infix-if-type.yang
+++ b/src/confd/yang/confd/infix-if-type.yang
@@ -68,7 +68,9 @@ module infix-if-type {
identity dummy {
base infix-interface-type;
base ianaift:other;
- description "Linux dummy interface. Useful mostly for testing.";
+ description "Linux virtual interface that is always UP. Useful for
+ stable loopback-style addresses, e.g., as a permanent
+ management address or OSPF router-ID source.";
}
identity ethernet {
base infix-interface-type;
diff --git a/src/confd/yang/confd/infix-interfaces.yang b/src/confd/yang/confd/infix-interfaces.yang
index 0ace0fe2c..03c9c8a61 100644
--- a/src/confd/yang/confd/infix-interfaces.yang
+++ b/src/confd/yang/confd/infix-interfaces.yang
@@ -41,6 +41,11 @@ module infix-interfaces {
contact "kernelkit@googlegroups.com";
description "Linux bridge and lag extensions for ietf-interfaces.";
+ revision 2026-04-29 {
+ description "Add operational state for multicast router ports per bridge.";
+ reference "internal";
+ }
+
revision 2026-04-09 {
description "Add ptp-capabilities submodule for per-interface PTP timestamping info.";
reference "internal";
diff --git a/src/confd/yang/confd/infix-interfaces@2026-04-09.yang b/src/confd/yang/confd/infix-interfaces@2026-04-29.yang
similarity index 100%
rename from src/confd/yang/confd/infix-interfaces@2026-04-09.yang
rename to src/confd/yang/confd/infix-interfaces@2026-04-29.yang
diff --git a/src/confd/yang/confd/infix-ip.yang b/src/confd/yang/confd/infix-ip.yang
index 094a972ae..8a21675a6 100644
--- a/src/confd/yang/confd/infix-ip.yang
+++ b/src/confd/yang/confd/infix-ip.yang
@@ -18,6 +18,10 @@ module infix-ip {
description "This module augments ietf-ip with Infix extensions and deviations.";
+ revision 2026-04-28 {
+ description "Add support for ARP and neighbor cache (ietf-ip neighbor lists).";
+ reference "Internal, issue #819.";
+ }
revision 2025-11-02 {
description "Change autoconf to presence container, removing enabled leaf.";
reference "Internal, issue #1109.";
@@ -65,15 +69,8 @@ module infix-ip {
deviate not-supported;
}
- deviation "/if:interfaces/if:interface/ip:ipv4/ip:neighbor" {
- deviate not-supported;
- }
-
deviation "/if:interfaces/if:interface/ip:ipv6/ip:address/ip:status" {
deviate not-supported;
}
- deviation "/if:interfaces/if:interface/ip:ipv6/ip:neighbor" {
- deviate not-supported;
- }
}
diff --git a/src/confd/yang/confd/infix-ip@2025-11-02.yang b/src/confd/yang/confd/infix-ip@2026-04-28.yang
similarity index 100%
rename from src/confd/yang/confd/infix-ip@2025-11-02.yang
rename to src/confd/yang/confd/infix-ip@2026-04-28.yang
diff --git a/src/statd/python/yanger/ietf_interfaces/bridge.py b/src/statd/python/yanger/ietf_interfaces/bridge.py
index 3c3bbd8c7..218962ba9 100644
--- a/src/statd/python/yanger/ietf_interfaces/bridge.py
+++ b/src/statd/python/yanger/ietf_interfaces/bridge.py
@@ -218,6 +218,13 @@ def mctl(ifname, vid, mctldata):
return {}
+def mctl_router_ports(brname, mctldata):
+ for entry in mctldata.get("multicast-router-ports", []):
+ if entry.get("bridge") == brname:
+ return entry.get("ports", [])
+ return []
+
+
def multicast_filters(iplink, vid):
filt = ["dev", iplink["ifname"]] + (["vid", str(vid)] if vid else [])
brmdb = HOST.run_json(["bridge", "-j", "mdb", "show"] + filt)[0]["mdb"]
@@ -253,6 +260,9 @@ def multicast(iplink, info, mctldata):
if interval := mctlq.get("interval"):
mcast["query-interval"] = interval
+ if ports := mctl_router_ports(iplink["ifname"], mctldata):
+ mcast["router-ports"] = ports
+
return mcast
diff --git a/src/statd/python/yanger/ietf_interfaces/common.py b/src/statd/python/yanger/ietf_interfaces/common.py
index a2b0b255e..b5ccfc89e 100644
--- a/src/statd/python/yanger/ietf_interfaces/common.py
+++ b/src/statd/python/yanger/ietf_interfaces/common.py
@@ -28,3 +28,17 @@ def _ipaddrs(ifname, netns):
return HOST.run_json(pre + ["ip", "-j", "addr", "show"] + filt)
return { addr["ifname"]: addr for addr in _ipaddrs(ifname, netns) }
+
+
+@cache
+def ipneighs(ifname=None, netns=None):
+ def _ipneighs(ifname, netns):
+ pre = ["ip", "netns", "exec", netns] if netns else []
+ filt = ["dev", ifname] if ifname else []
+ return HOST.run_json(pre + ["ip", "-j", "neigh", "show"] + filt, [])
+
+ result = {}
+ for e in _ipneighs(ifname, netns):
+ if dev := e.get("dev"):
+ result.setdefault(dev, []).append(e)
+ return result
diff --git a/src/statd/python/yanger/ietf_interfaces/ip.py b/src/statd/python/yanger/ietf_interfaces/ip.py
index f4a1a5fde..924525b8f 100644
--- a/src/statd/python/yanger/ietf_interfaces/ip.py
+++ b/src/statd/python/yanger/ietf_interfaces/ip.py
@@ -1,4 +1,55 @@
+import ipaddress
+
from ..host import HOST
+from . import common
+
+
+def neigh_state(states):
+ xlate = {
+ "REACHABLE": "reachable",
+ "STALE": "stale",
+ "DELAY": "delay",
+ "PROBE": "probe",
+ "INCOMPLETE": "incomplete",
+ }
+ return next((xlate[s] for s in states if s in xlate), None)
+
+
+def neighbors(ifname, family):
+ result = []
+ for entry in common.ipneighs().get(ifname, []):
+ dst = entry.get("dst", "")
+ try:
+ version = ipaddress.ip_address(dst).version
+ except ValueError:
+ continue
+
+ if version != (4 if family == "inet" else 6):
+ continue
+
+ lladdr = entry.get("lladdr")
+ states = entry.get("state", [])
+
+ if not lladdr:
+ continue
+
+ origin = "static" if "PERMANENT" in states else "dynamic"
+ neigh = {
+ "ip": dst,
+ "link-layer-address": lladdr,
+ "origin": origin,
+ }
+
+ if family == "inet6":
+ if state := neigh_state(states):
+ neigh["state"] = state
+ if entry.get("router"):
+ neigh["is-router"] = [None]
+
+ result.append(neigh)
+
+ return result
+
def inet2yang_origin(inet):
"""Translate kernel IP address origin to YANG"""
@@ -34,23 +85,31 @@ def addresses(ipaddr, proto):
def ipv4(ipaddr):
ipv4 = {}
+ ifname = ipaddr.get("ifname")
mtu = ipaddr.get("mtu")
- if mtu and ipaddr.get("ifname") != "lo":
+ if mtu and ifname != "lo":
ipv4["mtu"] = mtu
if addrs := addresses(ipaddr, "inet"):
ipv4["address"] = addrs
+ if neighs := neighbors(ifname, "inet"):
+ ipv4["neighbor"] = neighs
+
return ipv4
def ipv6(ipaddr):
ipv6 = {}
+ ifname = ipaddr.get("ifname")
- if mtu := HOST.read(f"/proc/sys/net/ipv6/conf/{ipaddr['ifname']}/mtu"):
+ if mtu := HOST.read(f"/proc/sys/net/ipv6/conf/{ifname}/mtu"):
ipv6["mtu"] = int(mtu.strip())
if addrs := addresses(ipaddr, "inet6"):
ipv6["address"] = addrs
+ if neighs := neighbors(ifname, "inet6"):
+ ipv6["neighbor"] = neighs
+
return ipv6
diff --git a/test/case/interfaces/all.yaml b/test/case/interfaces/all.yaml
index 2a3c95ac7..e5db2e85d 100644
--- a/test/case/interfaces/all.yaml
+++ b/test/case/interfaces/all.yaml
@@ -5,6 +5,9 @@
- name: Interface with IPv4
case: ipv4_address/test.py
+- name: ARP and Neighbor Cache
+ case: neighbor_cache/test.py
+
- name: Interface IPv6 Autoconf for Bridges
case: ipv6_address/test.py
diff --git a/test/case/interfaces/neighbor_cache/Readme.adoc b/test/case/interfaces/neighbor_cache/Readme.adoc
new file mode 120000
index 000000000..ae32c8412
--- /dev/null
+++ b/test/case/interfaces/neighbor_cache/Readme.adoc
@@ -0,0 +1 @@
+test.adoc
\ No newline at end of file
diff --git a/test/case/interfaces/neighbor_cache/test.adoc b/test/case/interfaces/neighbor_cache/test.adoc
new file mode 100644
index 000000000..30d07d133
--- /dev/null
+++ b/test/case/interfaces/neighbor_cache/test.adoc
@@ -0,0 +1,25 @@
+=== ARP and Neighbor Cache
+
+ifdef::topdoc[:imagesdir: {topdoc}../../test/case/interfaces/neighbor_cache]
+
+==== Description
+
+Verify that static ARP entries (IPv4) and neighbor cache entries (IPv6)
+can be configured on an interface and are immediately visible in the
+operational datastore with origin "static". Also verify that removing
+the entries causes them to disappear from the operational datastore.
+
+==== Topology
+
+image::topology.svg[ARP and Neighbor Cache topology, align=center, scaledwidth=75%]
+
+==== Sequence
+
+. Set up topology and attach to target DUT
+. Configure static IPv4 ARP and IPv6 neighbor entries on target:data
+. Verify static IPv4 ARP entry is visible in operational state
+. Verify static IPv6 neighbor entry is visible in operational state
+. Remove static neighbor entries by clearing IPv4 and IPv6 config
+. Verify static neighbor entries are no longer present
+
+
diff --git a/test/case/interfaces/neighbor_cache/test.py b/test/case/interfaces/neighbor_cache/test.py
new file mode 100755
index 000000000..69f413c0e
--- /dev/null
+++ b/test/case/interfaces/neighbor_cache/test.py
@@ -0,0 +1,63 @@
+#!/usr/bin/env python3
+"""
+ARP and Neighbor Cache
+
+Verify that static ARP entries (IPv4) and neighbor cache entries (IPv6)
+can be configured on an interface and are immediately visible in the
+operational datastore with origin "static". Also verify that removing
+the entries causes them to disappear from the operational datastore.
+"""
+import infamy
+import infamy.iface as iface
+
+from infamy.util import until
+
+IPV4_NEIGH = "192.0.2.1"
+IPV4_LLADR = "de:ad:be:ef:ca:fe"
+IPV6_NEIGH = "2001:db8::1"
+IPV6_LLADR = "de:ad:be:ef:ca:ff"
+
+with infamy.Test() as test:
+ with test.step("Set up topology and attach to target DUT"):
+ env = infamy.Env()
+ target = env.attach("target", "mgmt")
+ _, tport = env.ltop.xlate("target", "data")
+
+ with test.step("Configure static IPv4 ARP and IPv6 neighbor entries on target:data"):
+ target.put_config_dict("ietf-interfaces", {
+ "interfaces": {
+ "interface": [{
+ "name": tport,
+ "ipv4": {
+ "neighbor": [{
+ "ip": IPV4_NEIGH,
+ "link-layer-address": IPV4_LLADR,
+ }]
+ },
+ "ipv6": {
+ "neighbor": [{
+ "ip": IPV6_NEIGH,
+ "link-layer-address": IPV6_LLADR,
+ }]
+ }
+ }]
+ }
+ })
+
+ with test.step("Verify static IPv4 ARP entry is visible in operational state"):
+ until(lambda: iface.neighbor_exist(target, tport, IPV4_NEIGH, IPV4_LLADR, "static"))
+
+ with test.step("Verify static IPv6 neighbor entry is visible in operational state"):
+ until(lambda: iface.neighbor_exist(target, tport, IPV6_NEIGH, IPV6_LLADR, "static"))
+
+ with test.step("Remove static neighbor entries by clearing IPv4 and IPv6 config"):
+ target.delete_xpath(
+ f"/ietf-interfaces:interfaces/interface[name='{tport}']/ietf-ip:ipv4")
+ target.delete_xpath(
+ f"/ietf-interfaces:interfaces/interface[name='{tport}']/ietf-ip:ipv6")
+
+ with test.step("Verify static neighbor entries are no longer present"):
+ until(lambda: not iface.neighbor_exist(target, tport, IPV4_NEIGH))
+ until(lambda: not iface.neighbor_exist(target, tport, IPV6_NEIGH))
+
+ test.succeed()
diff --git a/test/case/interfaces/neighbor_cache/topology.dot b/test/case/interfaces/neighbor_cache/topology.dot
new file mode 100644
index 000000000..ebb673d5f
--- /dev/null
+++ b/test/case/interfaces/neighbor_cache/topology.dot
@@ -0,0 +1,24 @@
+graph "1x2" {
+ layout="neato";
+ overlap="false";
+ esep="+80";
+
+ node [shape=record, fontname="DejaVu Sans Mono, Book"];
+ edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"];
+
+ host [
+ label="host | { mgmt | data }",
+ pos="0,12!",
+ requires="controller",
+ ];
+
+ target [
+ label="{ mgmt | data } | target",
+ pos="10,12!",
+
+ requires="infix",
+ ];
+
+ host:mgmt -- target:mgmt [requires="mgmt", color=lightgrey]
+ host:data -- target:data [color=black]
+}
diff --git a/test/case/interfaces/neighbor_cache/topology.svg b/test/case/interfaces/neighbor_cache/topology.svg
new file mode 100644
index 000000000..ff3d246be
--- /dev/null
+++ b/test/case/interfaces/neighbor_cache/topology.svg
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+1x2
+
+
+
+host
+
+host
+
+mgmt
+
+data
+
+
+
+target
+
+mgmt
+
+data
+
+target
+
+
+
+host:mgmt--target:mgmt
+
+
+
+
+host:data--target:data
+
+
+
+
diff --git a/test/infamy/iface.py b/test/infamy/iface.py
index 2831bb680..6ee368cd8 100644
--- a/test/infamy/iface.py
+++ b/test/infamy/iface.py
@@ -108,6 +108,28 @@ def is_oper_up(target, iface):
return get_oper_status(target, iface) == "up"
+def _get_neighbors(target, iface, proto):
+ interface = target.get_iface(iface)
+ if interface is None:
+ return None
+ ip = interface.get(proto) or interface.get(f"ietf-ip:{proto}")
+ return ip.get("neighbor") if ip else None
+
+
+def neighbor_exist(target, iface, address, lladdr=None, origin=None):
+ """Check if neighbor 'address' exists on iface, optionally matching lladdr and origin"""
+ for proto in ("ipv4", "ipv6"):
+ for n in _get_neighbors(target, iface, proto) or []:
+ if n.get("ip") != address:
+ continue
+ if lladdr and n.get("link-layer-address") != lladdr:
+ continue
+ if origin and n.get("origin") != origin:
+ continue
+ return True
+ return False
+
+
def exist_bridge_multicast_filter(target, group, iface, bridge):
"""Check if a bridge has a multicast filter for group with iface"""
# The interface array is different in restconf/netconf, netconf has