diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index eead08246..c26df6f66 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -36,6 +36,11 @@ All notable changes to the project are documented in this file. boot and configuration changes, issue #961. Same rationale as Debian's dash-as-/bin/sh. Bash remains available for interactive use and for scripts using `#!/bin/bash` +- 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 6b4488522..3c5fddbfb 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 1063dcee8..8eb165506 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 000000000..8c5e55d45 --- /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 000000000..a7d456aaf --- /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 f098c2734..17f08cc56 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 8fd4f01cb..ccde7c784 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 f4ae79a2b..f5ef6a7ca 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 762fbc74f..50a6b6951 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 7689ed735..7225c4809 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 e338725f0..6509f601c 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 5307a6bb4..8285aca5b 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"` @@ -545,6 +549,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 @@ -646,6 +653,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 { @@ -916,6 +926,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) @@ -982,6 +1005,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 000000000..c06377f65 --- /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 40b7afe87..1207cd05f 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}} @@ -176,3 +179,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 05ed3f838..f0a3039d7 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 def6c8728..239173396 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 e82bbbaf2..716593580 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 46346d980..474904108 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 2c30ec700..fde7c73d6 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 f5be2191d..41758d37b 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 5e9bc6126..924b493e7 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 5d7db92ea..82be3ec46 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 4c3c26e32..2918900cf 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 5b286a667..f26022a1a 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 e476101bb..661b8f2a2 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 cfc2b6ed8..f729a957d 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 000000000..e69de29bb 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 000000000..e69de29bb 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 000000000..e69de29bb 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 000000000..e69de29bb 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 000000000..e69de29bb 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 000000000..e69de29bb 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 000000000..e69de29bb 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 000000000..e69de29bb 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 000000000..e69de29bb 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 000000000..e69de29bb 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 000000000..e69de29bb 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 000000000..e69de29bb 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 000000000..e69de29bb 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 000000000..e69de29bb 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 000000000..e69de29bb 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 000000000..e69de29bb diff --git a/test/infamy/iface.py b/test/infamy/iface.py index 6ee368cd8..b5b58d25b 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"