diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index d6d9fa15a8..a3572cce5e 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -43,6 +43,11 @@ All notable changes to the project are documented in this file. editor, show mesh peers on the WiFi and interface status pages, and add an editor section for access point roaming (802.11k/r/v, band steering, OKC). +- Add `higher-layer-if` and `lower-layer-if` to interface operational + status, listing the interfaces stacked directly on top of or beneath + each one, e.g., a VLAN interface and its parent, issue #514 +- Add `last-change` to interface operational status: the time the + interface entered its current operational state, issue #514 ### Fixes diff --git a/src/statd/Makefile.am b/src/statd/Makefile.am index 6b44885224..3c5fddbfb7 100644 --- a/src/statd/Makefile.am +++ b/src/statd/Makefile.am @@ -2,7 +2,7 @@ DISTCLEANFILES = *~ *.d ACLOCAL_AMFLAGS = -I m4 sbin_PROGRAMS = statd -statd_SOURCES = statd.c shared.c shared.h journal.c journal_retention.c journal.h avahi.c avahi.h +statd_SOURCES = statd.c shared.c shared.h journal.c journal_retention.c journal.h avahi.c avahi.h iface.c iface.h statd_CPPFLAGS = -D_DEFAULT_SOURCE -D_GNU_SOURCE statd_CPPFLAGS += -DSTATD_VERSION=\"$(PACKAGE_VERSION)\" statd_CFLAGS = -W -Wall -Wextra diff --git a/src/statd/avahi.c b/src/statd/avahi.c index 1063dcee83..8eb1655061 100644 --- a/src/statd/avahi.c +++ b/src/statd/avahi.c @@ -36,6 +36,7 @@ #include #include "avahi.h" +#include "shared.h" /* Complete the opaque avahi types declared in avahi-common/watch.h */ struct AvahiWatch { @@ -336,15 +337,6 @@ static void free_all(struct mdns_ctx *ctx) #define XPATH_BASE "/infix-services:mdns/neighbors" -static void format_timestamp(char *buf, size_t sz) -{ - struct tm tm; - time_t now = time(NULL); - - gmtime_r(&now, &tm); - strftime(buf, sz, "%Y-%m-%dT%H:%M:%S+00:00", &tm); -} - static int sr_setstr(sr_session_ctx_t *ses, const char *xpath, const char *val) { int err = sr_set_item_str(ses, xpath, val, NULL, 0); @@ -449,7 +441,7 @@ static void ds_push_resolver(struct mdns_ctx *ctx, struct avahi_service *svc, } /* last-seen */ - format_timestamp(ts, sizeof(ts)); + format_timestamp(time(NULL), ts, sizeof(ts)); snprintf(xpath, sizeof(xpath), XPATH_BASE "/neighbor[hostname='%s']/last-seen", svc->hostname); err = err ?: sr_setstr(ctx->sr_ses, xpath, ts); diff --git a/src/statd/iface.c b/src/statd/iface.c new file mode 100644 index 0000000000..8c5e55d455 --- /dev/null +++ b/src/statd/iface.c @@ -0,0 +1,284 @@ +/* SPDX-License-Identifier: BSD-3-Clause */ +/* + * Track interface state changes over rtnetlink to provide the + * ietf-interfaces last-change leaf, see issue #514. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "iface.h" +#include "shared.h" + +static time_t monotonic(void) +{ + struct timespec ts; + + clock_gettime(CLOCK_MONOTONIC, &ts); + return ts.tv_sec; +} + +static struct iface *iface_find(struct iface_ctx *ctx, const char *name) +{ + struct iface *l; + + TAILQ_FOREACH(l, &ctx->ifaces, entries) { + if (!strcmp(l->name, name)) + return l; + } + + return NULL; +} + +static struct iface *iface_find_index(struct iface_ctx *ctx, int ifindex) +{ + struct iface *l; + + TAILQ_FOREACH(l, &ctx->ifaces, entries) { + if (l->ifindex == ifindex) + return l; + } + + return NULL; +} + +static void iface_del(struct iface_ctx *ctx, struct iface *l) +{ + TAILQ_REMOVE(&ctx->ifaces, l, entries); + free(l); +} + +static void iface_update(struct iface_ctx *ctx, int ifindex, const char *name, + uint8_t operstate, int dump) +{ + struct iface *l; + + l = iface_find(ctx, name); + if (!l) { + l = iface_find_index(ctx, ifindex); + if (l) { + DEBUG("Link %s renamed %s", l->name, name); + snprintf(l->name, sizeof(l->name), "%s", name); + } + } + + if (!l) { + l = calloc(1, sizeof(*l)); + if (!l) { + ERRNO("Failed allocating link %s", name); + return; + } + + snprintf(l->name, sizeof(l->name), "%s", name); + l->operstate = operstate; + if (!dump) + l->changed = monotonic(); + TAILQ_INSERT_TAIL(&ctx->ifaces, l, entries); + DEBUG("Link %s added, operstate %u", name, operstate); + } else if (l->operstate != operstate) { + DEBUG("Link %s operstate %u -> %u", name, l->operstate, operstate); + l->operstate = operstate; + l->changed = monotonic(); + } + + l->ifindex = ifindex; +} + +static void iface_parse(struct iface_ctx *ctx, struct nlmsghdr *nlh, int dump) +{ + struct ifinfomsg *ifi = NLMSG_DATA(nlh); + int len = nlh->nlmsg_len - NLMSG_LENGTH(sizeof(*ifi)); + uint8_t operstate = IF_OPER_UNKNOWN; + const char *name = NULL; + struct rtattr *rta; + struct iface *l; + + for (rta = IFLA_RTA(ifi); RTA_OK(rta, len); rta = RTA_NEXT(rta, len)) { + switch (rta->rta_type & NLA_TYPE_MASK) { + case IFLA_IFNAME: + name = RTA_DATA(rta); + break; + case IFLA_OPERSTATE: + operstate = *(uint8_t *)RTA_DATA(rta); + break; + } + } + + if (!name) + return; + + if (nlh->nlmsg_type == RTM_DELLINK) { + l = iface_find(ctx, name); + if (l) { + DEBUG("Link %s removed", name); + iface_del(ctx, l); + } + return; + } + + iface_update(ctx, ifi->ifi_index, name, operstate, dump); +} + +static void iface_purge(struct iface_ctx *ctx) +{ + struct iface *l; + + while ((l = TAILQ_FIRST(&ctx->ifaces))) + iface_del(ctx, l); +} + +/* Request a full link dump, replies are told apart from events by sequence */ +static int iface_dump(struct iface_ctx *ctx) +{ + struct { + struct nlmsghdr nlh; + struct ifinfomsg ifi; + } req = { + .nlh = { + .nlmsg_len = NLMSG_LENGTH(sizeof(struct ifinfomsg)), + .nlmsg_type = RTM_GETLINK, + .nlmsg_flags = NLM_F_REQUEST | NLM_F_DUMP, + }, + .ifi = { + .ifi_family = AF_UNSPEC, + }, + }; + + if (!++ctx->seq) + ctx->seq = 1; + req.nlh.nlmsg_seq = ctx->seq; + + if (send(ctx->sd, &req, req.nlh.nlmsg_len, 0) < 0) { + ERRNO("Failed requesting link dump"); + ctx->seq = 0; + return -1; + } + + return 0; +} + +static void iface_io_cb(struct ev_loop *, ev_io *w, int) +{ + struct iface_ctx *ctx = (struct iface_ctx *)w; + static char buf[32768]; + struct nlmsghdr *nlh; + int len; + + for (;;) { + len = recv(ctx->sd, buf, sizeof(buf), MSG_DONTWAIT); + if (len < 0) { + if (errno == EINTR) + continue; + if (errno == EAGAIN) + break; + if (errno == ENOBUFS) { + WARN("Link event overrun, resyncing"); + iface_purge(ctx); + iface_dump(ctx); + continue; + } + + ERRNO("Failed reading link events"); + break; + } + + for (nlh = (struct nlmsghdr *)buf; NLMSG_OK(nlh, len); nlh = NLMSG_NEXT(nlh, len)) { + int dump = ctx->seq && nlh->nlmsg_seq == ctx->seq; + + switch (nlh->nlmsg_type) { + case RTM_NEWLINK: + case RTM_DELLINK: + iface_parse(ctx, nlh, dump); + break; + case NLMSG_DONE: + if (dump) + ctx->seq = 0; + break; + case NLMSG_ERROR: + if (dump) { + struct nlmsgerr *e = NLMSG_DATA(nlh); + + errno = -e->error; + ERRNO("Link dump failed"); + ctx->seq = 0; + } + break; + default: + break; + } + } + } +} + +int iface_ctx_init(struct iface_ctx *ctx, struct ev_loop *loop) +{ + struct sockaddr_nl sa = { + .nl_family = AF_NETLINK, + .nl_groups = RTMGRP_LINK, + }; + + TAILQ_INIT(&ctx->ifaces); + ctx->loop = loop; + + ctx->sd = socket(AF_NETLINK, SOCK_RAW | SOCK_CLOEXEC | SOCK_NONBLOCK, NETLINK_ROUTE); + if (ctx->sd < 0) { + ERRNO("Failed opening link event socket"); + return -1; + } + + if (bind(ctx->sd, (struct sockaddr *)&sa, sizeof(sa)) < 0) { + ERRNO("Failed subscribing to link events"); + close(ctx->sd); + ctx->sd = -1; + return -1; + } + + ev_io_init(&ctx->io, iface_io_cb, ctx->sd, EV_READ); + ev_io_start(loop, &ctx->io); + + return iface_dump(ctx); +} + +void iface_ctx_exit(struct iface_ctx *ctx) +{ + if (ctx->sd >= 0) { + ev_io_stop(ctx->loop, &ctx->io); + close(ctx->sd); + ctx->sd = -1; + } + + iface_purge(ctx); +} + +/* Add last-change to every interface in tree whose state changed since start */ +void iface_annotate(struct iface_ctx *ctx, struct lyd_node *tree) +{ + time_t now = time(NULL), mono = monotonic(); + struct lyd_node *iface; + + if (!tree) + return; + + LY_LIST_FOR(lyd_child(tree), iface) { + struct iface *l; + char buf[32]; + + l = iface_find(ctx, lydx_get_cattr(iface, "name") ?: ""); + if (!l || !l->changed) + continue; + + format_timestamp(now - (mono - l->changed), buf, sizeof(buf)); + if (lyd_new_term(iface, NULL, "last-change", buf, 0, NULL)) + WARN("Failed adding last-change to interface %s", l->name); + } +} diff --git a/src/statd/iface.h b/src/statd/iface.h new file mode 100644 index 0000000000..a7d456aaf3 --- /dev/null +++ b/src/statd/iface.h @@ -0,0 +1,40 @@ +/* SPDX-License-Identifier: BSD-3-Clause */ + +#ifndef STATD_IFACE_H_ +#define STATD_IFACE_H_ + +#include +#include +#include +#include + +#include +#include + +/* + * In-memory link state, keyed by interface name, tracked over rtnetlink. + * Renames are recognized by ifindex, which the kernel keeps across them. + */ + +struct iface { + char name[IFNAMSIZ]; + int ifindex; + uint8_t operstate; /* IF_OPER_* */ + time_t changed; /* CLOCK_MONOTONIC, 0: state predates statd */ + TAILQ_ENTRY(iface) entries; +}; + +struct iface_ctx { + ev_io io; /* MUST be first (cast from ev_io *) */ + struct ev_loop *loop; + int sd; + uint32_t seq; /* sequence of ongoing dump, 0: none */ + TAILQ_HEAD(, iface) ifaces; +}; + +int iface_ctx_init(struct iface_ctx *ctx, struct ev_loop *loop); +void iface_ctx_exit(struct iface_ctx *ctx); + +void iface_annotate(struct iface_ctx *ctx, struct lyd_node *tree); + +#endif diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index c6b254b950..2ed4a79468 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -1044,6 +1044,9 @@ def __init__(self, data): self.type = data.get('type', '') self.index = data.get('if-index', '') self.oper_status = data.get('oper-status', '') + self.last_change = data.get('last-change', '') + self.higher_layer = data.get('higher-layer-if', []) + self.lower_layer = data.get('lower-layer-if', []) self.autoneg = get_json_data('unknown', self.data, 'ieee802-ethernet-interface:ethernet', 'auto-negotiation', 'enable') self.duplex = get_json_data('', self.data,'ieee802-ethernet-interface:ethernet','duplex') @@ -1666,12 +1669,18 @@ def pr_iface(self): print(f"{'mtu':<{19}}: {self.mtu}") if self.oper(): print(f"{'operational status':<{19}}: {self.oper(detail=True)}") + if self.last_change: + print(f"{'last change':<{19}}: {Date.from_yang(self.last_change).pretty()}") forwarding = "enabled" if self.name in Iface._routing_ifaces else "disabled" print(f"{'ip forwarding':<{19}}: {forwarding}") - if self.lower_if: - print(f"{'lower-layer-if':<{19}}: {self.lower_if}") + # Older operational data only has the VLAN augment's lower-layer-if + lower = self.lower_layer or ([self.lower_if] if self.lower_if else []) + if lower: + self._pr_label_list('lower-layer-if', lower) + if self.higher_layer: + self._pr_label_list('higher-layer-if', self.higher_layer) if label := self._phy_label(): print(f"{'link mode':<{19}}: {label}") diff --git a/src/statd/python/yanger/host.py b/src/statd/python/yanger/host.py index 8fd4f01cba..ccde7c7842 100644 --- a/src/statd/python/yanger/host.py +++ b/src/statd/python/yanger/host.py @@ -59,6 +59,15 @@ def read(self, path): """ pass + @abc.abstractmethod + def listdir(self, path): + """Get the entries of directory path + + Returns an empty list if the directory is not readable. + + """ + pass + def read_multiline(self, path, default=None): """Get lines of content from path @@ -122,6 +131,12 @@ def exists(self, path: str) -> bool: except OSError: return False + def listdir(self, path): + try: + return os.listdir(path) + except OSError: + return [] + class Remotehost(Localhost): def __init__(self, prefix, capdir): super().__init__() @@ -188,6 +203,17 @@ def read(self, path): return out + def listdir(self, path): + entries = self._run(("ls", path), default="", log=False).split() + + if self.capdir: + dirname = os.path.join(self.capdir, "rootfs", path[1:]) + os.makedirs(dirname, exist_ok=True) + for entry in entries: + open(os.path.join(dirname, entry), "a", encoding='utf-8').close() + + return entries + class Replayhost(Host): def SlugOf(cmd): @@ -234,3 +260,10 @@ def read(self, path): except: common.LOG.error(f"No recording found for file \"{path}\"") raise + + def listdir(self, path): + path = os.path.join(self.replaydir, "rootfs", path[1:]) + try: + return os.listdir(path) + except OSError: + return [] diff --git a/src/statd/python/yanger/ietf_interfaces/link.py b/src/statd/python/yanger/ietf_interfaces/link.py index f4ae79a2bb..f5ef6a7cad 100644 --- a/src/statd/python/yanger/ietf_interfaces/link.py +++ b/src/statd/python/yanger/ietf_interfaces/link.py @@ -26,6 +26,31 @@ def statistics(iplink): return statistics +def hidden(iplink): + """Interfaces never reported in operational: internal plumbing and CAN""" + if iplink.get("group") == "internal": + return True + + return iplink.get("link_type") in ("can", "vcan") + + +def layers(ifname): + """Directly adjacent interfaces, from the kernel's sysfs upper_/lower_ links""" + entries = HOST.listdir(f"/sys/class/net/{ifname}") + higher = [e[len("upper_"):] for e in entries if e.startswith("upper_")] + lower = [e[len("lower_"):] for e in entries if e.startswith("lower_")] + if not higher and not lower: + return [], [] + + # Neighbors not shown in operational must not be referenced either + links = common.iplinks() + + def visible(name): + return name in links and not hidden(links[name]) + + return sorted(filter(visible, higher)), sorted(filter(visible, lower)) + + def iplink2yang_type(iplink): ifname=iplink["ifname"] @@ -149,6 +174,12 @@ def interface(iplink, ipaddr, systemjson=None): if ptpcap := ptp_capabilities(iplink["ifname"], systemjson): interface["infix-interfaces:ptp-capabilities"] = ptpcap + higher, lower = layers(iplink["ifname"]) + if higher: + interface["higher-layer-if"] = higher + if lower: + interface["lower-layer-if"] = lower + match interface["type"]: case "infix-if-type:bridge": if br := bridge.bridge(iplink): @@ -203,11 +234,7 @@ def interfaces(ifname=None): interfaces = [] for ifname, iplink in links.items(): - if iplink.get("group") == "internal": - continue - - link_type = iplink.get("link_type") - if link_type in ("can", "vcan"): + if hidden(iplink): continue ipaddr = addrs.get(ifname, {}) diff --git a/src/statd/shared.c b/src/statd/shared.c index 762fbc74fe..50a6b69512 100644 --- a/src/statd/shared.c +++ b/src/statd/shared.c @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -76,3 +77,12 @@ int ip_link_check_group(const char *ifname, const char *group) return 0; } + +/* YANG date-and-time, always in UTC */ +void format_timestamp(time_t when, char *buf, size_t sz) +{ + struct tm tm; + + gmtime_r(&when, &tm); + strftime(buf, sz, "%Y-%m-%dT%H:%M:%S+00:00", &tm); +} diff --git a/src/statd/shared.h b/src/statd/shared.h index 7689ed7354..7225c48090 100644 --- a/src/statd/shared.h +++ b/src/statd/shared.h @@ -3,9 +3,12 @@ #ifndef STATD_SHARED_H_ #define STATD_SHARED_H_ +#include +#include #include json_t *json_get_output(const char *cmd); int ip_link_check_group(const char *ifname, const char *group); +void format_timestamp(time_t when, char *buf, size_t sz); #endif diff --git a/src/statd/statd.c b/src/statd/statd.c index e338725f01..6509f601c6 100644 --- a/src/statd/statd.c +++ b/src/statd/statd.c @@ -31,6 +31,7 @@ #include "shared.h" #include "journal.h" +#include "iface.h" #include "avahi.h" /* New kernel feature, not in sys/mman.h yet */ @@ -72,6 +73,7 @@ struct statd { struct ev_loop *ev_loop; struct journal_ctx journal; /* Periodic operational snapshots */ struct mdns_ctx mdns; /* mDNS neighbor monitor */ + struct iface_ctx iface; /* Interface state change tracking */ }; static int ly_add_yanger_data(const struct ly_ctx *ctx, struct lyd_node **parent, @@ -157,7 +159,7 @@ static char *xpath_extract(const char *xpath, const char *key) static int sr_iface_cb(sr_session_ctx_t *session, uint32_t, const char *model, const char *, const char *xpath, uint32_t, - struct lyd_node **parent, __attribute__((unused)) void *priv) + struct lyd_node **parent, void *priv) { char *yanger_args[5] = { YANGER_BINPATH, @@ -166,6 +168,7 @@ static int sr_iface_cb(sr_session_ctx_t *session, uint32_t, const char *model, NULL, NULL }; + struct statd *statd = priv; char *ifname = NULL; const struct ly_ctx *ctx; sr_conn_ctx_t *con; @@ -193,6 +196,8 @@ static int sr_iface_cb(sr_session_ctx_t *session, uint32_t, const char *model, err = ly_add_yanger_data(ctx, parent, yanger_args); if (err) ERROR("Failed adding yanger data for %s", ifname ?: model); + else + iface_annotate(&statd->iface, *parent); free(ifname); sr_release_context(con); @@ -382,7 +387,7 @@ static int subscribe(struct statd *statd, char *model, char *xpath, memset(sub, 0, sizeof(struct sub)); DEBUG("Subscribe to events for \"%s\"", xpath); - err = sr_oper_get_subscribe(statd->sr_ses, model, xpath, cb, sub, + err = sr_oper_get_subscribe(statd->sr_ses, model, xpath, cb, statd, SR_SUBSCR_DEFAULT | SR_SUBSCR_NO_THREAD | SR_SUBSCR_DONE_ONLY, &sub->sr_sub); if (err) { @@ -596,6 +601,9 @@ int main(int argc, char *argv[]) if (mdns_ctx_init(&statd.mdns, statd.ev_loop, statd.sr_conn)) INFO("mDNS neighbor monitoring not available"); + if (iface_ctx_init(&statd.iface, statd.ev_loop)) + WARN("Interface state change tracking not available"); + /* Signal readiness to Finit */ pidfile(NULL); @@ -605,6 +613,7 @@ int main(int argc, char *argv[]) /* We should never get here during normal operation */ INFO("Status daemon shutting down"); + iface_ctx_exit(&statd.iface); mdns_ctx_exit(&statd.mdns); journal_stop(&statd.journal); diff --git a/src/webui/internal/handlers/interfaces.go b/src/webui/internal/handlers/interfaces.go index fc1a3db494..c1dc9ea4e3 100644 --- a/src/webui/internal/handlers/interfaces.go +++ b/src/webui/internal/handlers/interfaces.go @@ -3,6 +3,7 @@ package handlers import ( + "time" "fmt" "html/template" "log" @@ -32,6 +33,9 @@ type ifaceJSON struct { Type string `json:"type"` Enabled *bool `json:"enabled"` OperStatus string `json:"oper-status"` + LastChange string `json:"last-change"` + HigherLayerIf []string `json:"higher-layer-if"` + LowerLayerIf []string `json:"lower-layer-if"` PhysAddress string `json:"phys-address"` CustomPhysAddress *customPhysAddress `json:"infix-interfaces:custom-phys-address"` IfIndex int `json:"if-index"` @@ -585,6 +589,9 @@ type ifaceDetailData struct { Type string Status string StatusUp bool + LastChange string // local time with age, "" if unknown + HigherLayer []string // interfaces stacked on top of this one + LowerLayer []string // interfaces this one is stacked on PhysAddr string IfIndex int MTU int @@ -696,6 +703,9 @@ func buildDetailData(r *http.Request, iface *ifaceJSON) ifaceDetailData { StatusUp: iface.OperStatus == "up", PhysAddr: iface.PhysAddress, IfIndex: iface.IfIndex, + LastChange: formatLastChange(iface.LastChange), + HigherLayer: iface.HigherLayerIf, + LowerLayer: iface.LowerLayerIf, } if iface.IPv4 != nil { @@ -1003,6 +1013,19 @@ func buildWifiScanEntry(sr wifiScanResultJSON) wifiScanEntry { return e } +// formatLastChange renders a YANG date-and-time as local time with its age. +func formatLastChange(s string) string { + if s == "" { + return "" + } + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return s + } + return fmt.Sprintf("%s (%s ago)", t.Local().Format("2006-01-02 15:04:05"), + formatRelDuration(time.Since(t))) +} + func formatDuration(secs int64) string { if secs < 60 { return fmt.Sprintf("%ds", secs) @@ -1069,6 +1092,9 @@ func (h *InterfacesHandler) fieldDescriptions() map[string]string { ethPath := ifPath + "/ieee802-ethernet-interface:ethernet" return map[string]string{ "mtu": schema.DescriptionOf(mgr, ifPath+"/ietf-ip:ipv4/mtu"), + "last-change": schema.DescriptionOf(mgr, ifPath+"/last-change"), + "higher-layer-if": schema.DescriptionOf(mgr, ifPath+"/higher-layer-if"), + "lower-layer-if": schema.DescriptionOf(mgr, ifPath+"/lower-layer-if"), "speed": schema.DescriptionOf(mgr, ifPath+"/speed"), "duplex": schema.DescriptionOf(mgr, ethPath+"/duplex"), "autoneg": schema.DescriptionOf(mgr, ethPath+"/auto-negotiation/enable"), diff --git a/src/webui/internal/handlers/interfaces_test.go b/src/webui/internal/handlers/interfaces_test.go new file mode 100644 index 0000000000..c06377f655 --- /dev/null +++ b/src/webui/internal/handlers/interfaces_test.go @@ -0,0 +1,22 @@ +package handlers + +import ( + "strings" + "testing" + "time" +) + +func TestFormatLastChange(t *testing.T) { + if got := formatLastChange(""); got != "" { + t.Errorf("empty: got %q", got) + } + if got := formatLastChange("garbage"); got != "garbage" { + t.Errorf("unparsable: got %q", got) + } + + stamp := time.Now().Add(-90 * time.Second).UTC().Format("2006-01-02T15:04:05+00:00") + got := formatLastChange(stamp) + if !strings.HasSuffix(got, "(1m ago)") { + t.Errorf("age: got %q", got) + } +} diff --git a/src/webui/templates/pages/iface-detail.html b/src/webui/templates/pages/iface-detail.html index f9fe523338..fb3a3814b4 100644 --- a/src/webui/templates/pages/iface-detail.html +++ b/src/webui/templates/pages/iface-detail.html @@ -26,6 +26,9 @@

{{.Name}}

Status {{.Status}} + {{if .LastChange}}Last change{{template "field-info" (index .Desc "last-change")}}{{.LastChange}}{{end}} + {{if .LowerLayer}}Lower layer{{template "field-info" (index .Desc "lower-layer-if")}}{{template "iface-links" .LowerLayer}}{{end}} + {{if .HigherLayer}}Higher layer{{template "field-info" (index .Desc "higher-layer-if")}}{{template "iface-links" .HigherLayer}}{{end}} {{if .PhysAddr}}MAC Address{{.PhysAddr}}{{end}} {{if .MTU}}MTU{{template "field-info" (index .Desc "mtu")}}{{.MTU}}{{end}} {{if .Speed}}Speed{{template "field-info" (index .Desc "speed")}}{{.Speed}}{{end}} @@ -180,3 +183,5 @@

Addresses

{{end}} {{end}} + +{{define "iface-links"}}{{range $i, $name := .}}{{if $i}}, {{end}}{{$name}}{{end}}{{end}} diff --git a/test/case/interfaces/iface_enable_disable/test.adoc b/test/case/interfaces/iface_enable_disable/test.adoc index 05ed3f838b..f0a3039d70 100644 --- a/test/case/interfaces/iface_enable_disable/test.adoc +++ b/test/case/interfaces/iface_enable_disable/test.adoc @@ -7,7 +7,8 @@ ifdef::topdoc[:imagesdir: {topdoc}../../test/case/interfaces/iface_enable_disabl Verify interface status properly propagate changes when an interface is disabled and then re-enabled. -Both admin-status and oper-status are verified. +Both admin-status and oper-status are verified, as well as last-change, +which must advance with each transition of the operational state. ==== Topology @@ -21,6 +22,7 @@ image::topology.svg[Interface Status topology, align=center, scaledwidth=75%] . Verify the interface is disabled . Enable the interface and assign an IP address . Verify the interface is enabled +. Verify last-change reflects the transition to up . Verify it is possible to ping the interface diff --git a/test/case/interfaces/iface_enable_disable/test.py b/test/case/interfaces/iface_enable_disable/test.py index def6c8728a..239173396b 100755 --- a/test/case/interfaces/iface_enable_disable/test.py +++ b/test/case/interfaces/iface_enable_disable/test.py @@ -5,14 +5,23 @@ Verify interface status properly propagate changes when an interface is disabled and then re-enabled. -Both admin-status and oper-status are verified. +Both admin-status and oper-status are verified, as well as last-change, +which must advance with each transition of the operational state. """ +from datetime import datetime + import infamy from infamy.util import parallel, until import infamy.iface as iface +def dut_now(target): + """Return the DUT's current time, to compare against its own timestamps""" + data = target.get_data("/ietf-system:system-state/clock/current-datetime") + return datetime.fromisoformat(data["system-state"]["clock"]["current-datetime"]) + + def print_error_message(iface, param, exp_val, act_val): return f"'{param}' failure for interface '{iface}'. Expected '{exp_val}', Actual: '{act_val}'" @@ -89,14 +98,24 @@ def configure_interface(target, iface_name, iface_type=None, enabled=True, ip_ad with test.step("Verify the interface is disabled"): assert_param(target2, iface_under_test, "admin-status", "down") assert_param(target2, iface_under_test, "oper-status", "down") + disabled_at = iface.get_last_change(target2, iface_under_test) with test.step("Enable the interface and assign an IP address"): configure_interface(target2, iface_under_test, enabled=True, ip_address=target_address) - + with test.step("Verify the interface is enabled"): assert_param(target2, iface_under_test, "admin-status", "up") assert_param(target2, iface_under_test, "oper-status", "up") + with test.step("Verify last-change reflects the transition to up"): + enabled_at = until(lambda: iface.get_last_change(target2, iface_under_test)) + assert enabled_at <= dut_now(target2), \ + f"last-change {enabled_at} is in the future" + # Disabling a link that was already down is no transition, so no stamp + if disabled_at: + assert enabled_at >= disabled_at, \ + f"last-change {enabled_at} predates the disable at {disabled_at}" + with infamy.IsolatedMacVlan(host_send_iface) as send_ns: with test.step("Verify it is possible to ping the interface"): send_ns.addip(host_address) diff --git a/test/case/interfaces/verify_all_interface_types/test.adoc b/test/case/interfaces/verify_all_interface_types/test.adoc index e82bbbaf28..7165935802 100644 --- a/test/case/interfaces/verify_all_interface_types/test.adoc +++ b/test/case/interfaces/verify_all_interface_types/test.adoc @@ -39,5 +39,7 @@ image::topology.svg[Verify that All Interface Types Can Be Created topology, ali . Verify interfaces 'veth0a.20', 'ethQ.10', 'ethX.30', 'ethQ.10' and 'br-Q.40' are of type 'vlan' . Verify GRE interfaces 'gre-v4', 'gre-v6', 'gretap-v4' and 'gretap-v6' . Verify VxLAN interfaces 'vxlan-v4' and 'vxlan-v6' +. Verify higher-layer-if and lower-layer-if of bridge stacks +. Verify standalone interfaces have no higher-layer-if or lower-layer-if diff --git a/test/case/interfaces/verify_all_interface_types/test.py b/test/case/interfaces/verify_all_interface_types/test.py index 46346d9803..474904108b 100755 --- a/test/case/interfaces/verify_all_interface_types/test.py +++ b/test/case/interfaces/verify_all_interface_types/test.py @@ -19,6 +19,36 @@ import infamy import infamy.iface as iface +from infamy.util import until + + +def layers(target): + """Map each interface to its sorted (higher-layer-if, lower-layer-if)""" + data = target.get_data("/ietf-interfaces:interfaces") + return { + entry["name"]: (sorted(entry.get("higher-layer-if", [])), + sorted(entry.get("lower-layer-if", []))) + for entry in data["interfaces"]["interface"] + } + + +def verify_layers(target, expected): + """Verify (higher-layer-if, lower-layer-if) per interface in expected""" + expected = {name: (sorted(higher), sorted(lower)) + for name, (higher, lower) in expected.items()} + + actual = {} + + def matches(): + actual.update(layers(target)) + return all(actual.get(name) == want for name, want in expected.items()) + + try: + until(matches) + except Exception as err: + diff = {name: (actual.get(name), want) + for name, want in expected.items() if actual.get(name) != want} + raise AssertionError(f"layer mismatch, (got, expected): {diff}") from err def verify_interface(target, interface, expected_type): @@ -341,4 +371,30 @@ def verify_interface(target, interface, expected_type): with test.step("Verify VxLAN interfaces 'vxlan-v4' and 'vxlan-v6'"): verify_interface(target, "vxlan-v4", "vxlan") verify_interface(target, "vxlan-v6", "vxlan") + + with test.step("Verify higher-layer-if and lower-layer-if of bridge stacks"): + verify_layers(target, { + eth_X: ([eth_X_30], []), + eth_X_30: ([br_X], [eth_X]), + br_X: ([], [eth_X_30]), + + veth_a: ([veth_a_20], []), + veth_a_20: ([br_D], [veth_a]), + br_D: ([], [veth_a_20]), + + eth_Q: ([br_Q, eth_Q_10], []), + veth_b: ([br_Q], []), + eth_Q_10: ([], [eth_Q]), + br_Q: ([br_Q_40], [eth_Q, veth_b]), + br_Q_40: ([], [br_Q]), + }) + + with test.step("Verify standalone interfaces have no higher-layer-if or lower-layer-if"): + verify_layers(target, { + loopback: ([], []), + br_0: ([], []), + "gre-v4": ([], []), + "vxlan-v4": ([], []), + }) + test.succeed() diff --git a/test/case/statd/interfaces-all/cli/show-interfaces_-n_br-D b/test/case/statd/interfaces-all/cli/show-interfaces_-n_br-D index 2c30ec700a..fde7c73d67 100644 --- a/test/case/statd/interfaces-all/cli/show-interfaces_-n_br-D +++ b/test/case/statd/interfaces-all/cli/show-interfaces_-n_br-D @@ -4,6 +4,7 @@ index : 15 mtu : 1500 operational status : up ip forwarding : disabled +lower-layer-if : veth0a.20 physical address : 00:a0:85:00:03:00 ipv4 addresses : 10.0.0.1/8 (static) 192.168.20.1/24 (static) diff --git a/test/case/statd/interfaces-all/cli/show-interfaces_-n_br-Q b/test/case/statd/interfaces-all/cli/show-interfaces_-n_br-Q index f5be2191d0..41758d37bb 100644 --- a/test/case/statd/interfaces-all/cli/show-interfaces_-n_br-Q +++ b/test/case/statd/interfaces-all/cli/show-interfaces_-n_br-Q @@ -4,6 +4,9 @@ index : 16 mtu : 1500 operational status : up ip forwarding : disabled +lower-layer-if : e3 + veth0b +higher-layer-if : br-Q.40 physical address : 00:a0:85:00:03:00 ipv4 addresses : ipv6 addresses : diff --git a/test/case/statd/interfaces-all/cli/show-interfaces_-n_br-X b/test/case/statd/interfaces-all/cli/show-interfaces_-n_br-X index 5e9bc61267..924b493e7d 100644 --- a/test/case/statd/interfaces-all/cli/show-interfaces_-n_br-X +++ b/test/case/statd/interfaces-all/cli/show-interfaces_-n_br-X @@ -4,6 +4,7 @@ index : 11 mtu : 1500 operational status : up ip forwarding : disabled +lower-layer-if : e2.30 physical address : 00:a0:85:00:03:00 ipv4 addresses : ipv6 addresses : diff --git a/test/case/statd/interfaces-all/cli/show-interfaces_-n_veth0a b/test/case/statd/interfaces-all/cli/show-interfaces_-n_veth0a index 5d7db92eac..82be3ec46c 100644 --- a/test/case/statd/interfaces-all/cli/show-interfaces_-n_veth0a +++ b/test/case/statd/interfaces-all/cli/show-interfaces_-n_veth0a @@ -4,6 +4,7 @@ index : 13 mtu : 1500 operational status : up ip forwarding : disabled +higher-layer-if : veth0a.20 physical address : 6e:d0:98:c4:e7:ef ipv4 addresses : ipv6 addresses : diff --git a/test/case/statd/interfaces-all/cli/show-interfaces_-n_veth0a.20 b/test/case/statd/interfaces-all/cli/show-interfaces_-n_veth0a.20 index 4c3c26e32f..2918900cfd 100644 --- a/test/case/statd/interfaces-all/cli/show-interfaces_-n_veth0a.20 +++ b/test/case/statd/interfaces-all/cli/show-interfaces_-n_veth0a.20 @@ -5,6 +5,7 @@ mtu : 1500 operational status : up ip forwarding : disabled lower-layer-if : veth0a +higher-layer-if : br-D physical address : 6e:d0:98:c4:e7:ef ipv4 addresses : ipv6 addresses : diff --git a/test/case/statd/interfaces-all/cli/show-interfaces_-n_veth0b b/test/case/statd/interfaces-all/cli/show-interfaces_-n_veth0b index 5b286a6676..f26022a1ae 100644 --- a/test/case/statd/interfaces-all/cli/show-interfaces_-n_veth0b +++ b/test/case/statd/interfaces-all/cli/show-interfaces_-n_veth0b @@ -4,6 +4,7 @@ index : 12 mtu : 1500 operational status : up ip forwarding : disabled +higher-layer-if : br-Q physical address : 36:da:80:06:7f:99 ipv4 addresses : ipv6 addresses : diff --git a/test/case/statd/interfaces-all/ietf-interfaces.json b/test/case/statd/interfaces-all/ietf-interfaces.json index e476101bbe..661b8f2a22 100644 --- a/test/case/statd/interfaces-all/ietf-interfaces.json +++ b/test/case/statd/interfaces-all/ietf-interfaces.json @@ -86,6 +86,9 @@ } ] }, + "higher-layer-if": [ + "e2.30" + ], "ieee802-ethernet-interface:ethernet": { "auto-negotiation": { "enable": false @@ -108,6 +111,10 @@ "ietf-ip:ipv6": { "mtu": 1500 }, + "higher-layer-if": [ + "br-Q", + "e3.10" + ], "ieee802-ethernet-interface:ethernet": { "auto-negotiation": { "enable": false @@ -287,6 +294,12 @@ "ietf-ip:ipv6": { "mtu": 1500 }, + "higher-layer-if": [ + "br-X" + ], + "lower-layer-if": [ + "e2" + ], "infix-interfaces:vlan": { "tag-type": "ieee802-dot1q-types:c-vlan", "id": 30, @@ -323,6 +336,9 @@ "ietf-ip:ipv6": { "mtu": 1500 }, + "lower-layer-if": [ + "e2.30" + ], "infix-interfaces:bridge": { "multicast": { "snooping": false, @@ -350,6 +366,9 @@ "ietf-ip:ipv6": { "mtu": 1500 }, + "higher-layer-if": [ + "br-Q" + ], "infix-interfaces:veth": { "peer": "veth0a" }, @@ -388,6 +407,9 @@ "ietf-ip:ipv6": { "mtu": 1500 }, + "higher-layer-if": [ + "veth0a.20" + ], "infix-interfaces:veth": { "peer": "veth0b" } @@ -408,6 +430,12 @@ "ietf-ip:ipv6": { "mtu": 1500 }, + "higher-layer-if": [ + "br-D" + ], + "lower-layer-if": [ + "veth0a" + ], "infix-interfaces:vlan": { "tag-type": "ieee802-dot1q-types:c-vlan", "id": 20, @@ -471,6 +499,9 @@ } ] }, + "lower-layer-if": [ + "veth0a.20" + ], "infix-interfaces:bridge": { "multicast": { "snooping": false, @@ -497,6 +528,13 @@ "ietf-ip:ipv6": { "mtu": 1500 }, + "higher-layer-if": [ + "br-Q.40" + ], + "lower-layer-if": [ + "e3", + "veth0b" + ], "infix-interfaces:bridge": { "vlans": { "proto": "ieee802-dot1q-types:c-vlan", @@ -568,6 +606,9 @@ "ietf-ip:ipv6": { "mtu": 1500 }, + "lower-layer-if": [ + "br-Q" + ], "infix-interfaces:vlan": { "tag-type": "ieee802-dot1q-types:c-vlan", "id": 40, @@ -587,6 +628,9 @@ "ietf-ip:ipv6": { "mtu": 1500 }, + "lower-layer-if": [ + "e3" + ], "infix-interfaces:vlan": { "tag-type": "ieee802-dot1q-types:c-vlan", "id": 10, diff --git a/test/case/statd/interfaces-all/operational.json b/test/case/statd/interfaces-all/operational.json index cfc2b6ed80..f729a957d4 100644 --- a/test/case/statd/interfaces-all/operational.json +++ b/test/case/statd/interfaces-all/operational.json @@ -64,6 +64,9 @@ }, { "admin-status": "up", + "higher-layer-if": [ + "e2.30" + ], "ieee802-ethernet-interface:ethernet": { "auto-negotiation": { "enable": false @@ -94,6 +97,10 @@ }, { "admin-status": "up", + "higher-layer-if": [ + "br-Q", + "e3.10" + ], "ieee802-ethernet-interface:ethernet": { "auto-negotiation": { "enable": false @@ -276,6 +283,9 @@ }, { "admin-status": "up", + "higher-layer-if": [ + "br-X" + ], "ietf-ip:ipv4": { "mtu": 1500 }, @@ -305,6 +315,9 @@ "lower-layer-if": "e2", "tag-type": "ieee802-dot1q-types:c-vlan" }, + "lower-layer-if": [ + "e2" + ], "name": "e2.30", "oper-status": "up", "phys-address": "00:a0:85:00:03:02", @@ -328,6 +341,9 @@ "multicast-filter": [] } }, + "lower-layer-if": [ + "e2.30" + ], "name": "br-X", "oper-status": "up", "phys-address": "00:a0:85:00:03:00", @@ -335,6 +351,9 @@ }, { "admin-status": "up", + "higher-layer-if": [ + "br-Q" + ], "ietf-ip:ipv4": { "mtu": 1500 }, @@ -373,6 +392,9 @@ }, { "admin-status": "up", + "higher-layer-if": [ + "veth0a.20" + ], "ietf-ip:ipv4": { "mtu": 1500 }, @@ -394,6 +416,9 @@ }, { "admin-status": "up", + "higher-layer-if": [ + "br-D" + ], "ietf-ip:ipv4": { "mtu": 1500 }, @@ -423,6 +448,9 @@ "lower-layer-if": "veth0a", "tag-type": "ieee802-dot1q-types:c-vlan" }, + "lower-layer-if": [ + "veth0a" + ], "name": "veth0a.20", "oper-status": "up", "phys-address": "6e:d0:98:c4:e7:ef", @@ -473,6 +501,9 @@ "multicast-filter": [] } }, + "lower-layer-if": [ + "veth0a.20" + ], "name": "br-D", "oper-status": "up", "phys-address": "00:a0:85:00:03:00", @@ -483,6 +514,9 @@ }, { "admin-status": "up", + "higher-layer-if": [ + "br-Q.40" + ], "ietf-ip:ipv4": { "mtu": 1500 }, @@ -547,6 +581,10 @@ ] } }, + "lower-layer-if": [ + "e3", + "veth0b" + ], "name": "br-Q", "oper-status": "up", "phys-address": "00:a0:85:00:03:00", @@ -569,6 +607,9 @@ "lower-layer-if": "br-Q", "tag-type": "ieee802-dot1q-types:c-vlan" }, + "lower-layer-if": [ + "br-Q" + ], "name": "br-Q.40", "oper-status": "up", "phys-address": "00:a0:85:00:03:00", @@ -588,6 +629,9 @@ "lower-layer-if": "e3", "tag-type": "ieee802-dot1q-types:c-vlan" }, + "lower-layer-if": [ + "e3" + ], "name": "e3.10", "oper-status": "up", "phys-address": "00:a0:85:00:03:03", diff --git a/test/case/statd/interfaces-all/system/rootfs/sys/class/net/br-D/lower_veth0a.20 b/test/case/statd/interfaces-all/system/rootfs/sys/class/net/br-D/lower_veth0a.20 new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/case/statd/interfaces-all/system/rootfs/sys/class/net/br-Q.40/lower_br-Q b/test/case/statd/interfaces-all/system/rootfs/sys/class/net/br-Q.40/lower_br-Q new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/case/statd/interfaces-all/system/rootfs/sys/class/net/br-Q/lower_e3 b/test/case/statd/interfaces-all/system/rootfs/sys/class/net/br-Q/lower_e3 new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/case/statd/interfaces-all/system/rootfs/sys/class/net/br-Q/lower_veth0b b/test/case/statd/interfaces-all/system/rootfs/sys/class/net/br-Q/lower_veth0b new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/case/statd/interfaces-all/system/rootfs/sys/class/net/br-Q/upper_br-Q.40 b/test/case/statd/interfaces-all/system/rootfs/sys/class/net/br-Q/upper_br-Q.40 new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/case/statd/interfaces-all/system/rootfs/sys/class/net/br-X/lower_e2.30 b/test/case/statd/interfaces-all/system/rootfs/sys/class/net/br-X/lower_e2.30 new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/case/statd/interfaces-all/system/rootfs/sys/class/net/e2.30/lower_e2 b/test/case/statd/interfaces-all/system/rootfs/sys/class/net/e2.30/lower_e2 new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/case/statd/interfaces-all/system/rootfs/sys/class/net/e2.30/upper_br-X b/test/case/statd/interfaces-all/system/rootfs/sys/class/net/e2.30/upper_br-X new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/case/statd/interfaces-all/system/rootfs/sys/class/net/e2/upper_e2.30 b/test/case/statd/interfaces-all/system/rootfs/sys/class/net/e2/upper_e2.30 new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/case/statd/interfaces-all/system/rootfs/sys/class/net/e3.10/lower_e3 b/test/case/statd/interfaces-all/system/rootfs/sys/class/net/e3.10/lower_e3 new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/case/statd/interfaces-all/system/rootfs/sys/class/net/e3/upper_br-Q b/test/case/statd/interfaces-all/system/rootfs/sys/class/net/e3/upper_br-Q new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/case/statd/interfaces-all/system/rootfs/sys/class/net/e3/upper_e3.10 b/test/case/statd/interfaces-all/system/rootfs/sys/class/net/e3/upper_e3.10 new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/case/statd/interfaces-all/system/rootfs/sys/class/net/veth0a.20/lower_veth0a b/test/case/statd/interfaces-all/system/rootfs/sys/class/net/veth0a.20/lower_veth0a new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/case/statd/interfaces-all/system/rootfs/sys/class/net/veth0a.20/upper_br-D b/test/case/statd/interfaces-all/system/rootfs/sys/class/net/veth0a.20/upper_br-D new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/case/statd/interfaces-all/system/rootfs/sys/class/net/veth0a/upper_veth0a.20 b/test/case/statd/interfaces-all/system/rootfs/sys/class/net/veth0a/upper_veth0a.20 new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/case/statd/interfaces-all/system/rootfs/sys/class/net/veth0b/upper_br-Q b/test/case/statd/interfaces-all/system/rootfs/sys/class/net/veth0b/upper_br-Q new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/infamy/iface.py b/test/infamy/iface.py index 6ee368cd85..b5b58d25b1 100644 --- a/test/infamy/iface.py +++ b/test/infamy/iface.py @@ -1,6 +1,7 @@ """ Fetch interface status from remote device. """ +from datetime import datetime def get_xpath(iface, path=None): @@ -103,6 +104,12 @@ def get_oper_status(target, iface): return get_param(target, iface, "oper-status") +def get_last_change(target, iface): + """Get when the interface entered its operational status, None if unknown""" + stamp = get_param(target, iface, "last-change") + return datetime.fromisoformat(stamp) if stamp else None + + def is_oper_up(target, iface): """Check if interface operational status is 'up'""" return get_oper_status(target, iface) == "up"