diff --git a/doc/ip.md b/doc/ip.md index 69d105de..a42302ff 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/src/confd/src/interfaces.c b/src/confd/src/interfaces.c
index a50879e6..a255c20e 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 44968f0d..a427872f 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 1c843195..a83c1017 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 a3c64651..6a6505b0 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"
diff --git a/src/confd/yang/confd/infix-ip.yang b/src/confd/yang/confd/infix-ip.yang
index 094a972a..8a21675a 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/common.py b/src/statd/python/yanger/ietf_interfaces/common.py
index a2b0b255..b5ccfc89 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=None, netns=None):
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 f4a1a5fd..924525b8 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 2a3c95ac..e5db2e85 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 00000000..ae32c841
--- /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 00000000..30d07d13
--- /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 00000000..69f413c0
--- /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 00000000..ebb673d5
--- /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 | {