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 | { 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 00000000..ff3d246b --- /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 2831bb68..6ee368cd 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