diff --git a/board/common/image/image-readme/README.md b/board/common/image/image-readme/README.md index 3bca78387..ea55f2029 100644 --- a/board/common/image/image-readme/README.md +++ b/board/common/image/image-readme/README.md @@ -103,11 +103,13 @@ Graphical Network Simulator 3 (GNS3) ------------------------------------ GNS3 is a very powerful front-end to Qemu which takes care of creating -virtual links between network devices running in Qemu. This README is -all you need to get going, alongisde it is the appliance file (.gns3a) -that reference image files in this directory needed to load into GNS3. +virtual links between network devices running in Qemu. The appliance +is available from the GNS3 Marketplace, search for Infix when adding a +new template, and point it to the disk image in this directory. -Necessary Ubuntu packages are available through the offical GNS3 PPA. + - https://gns3.com/marketplace/appliances/infix + +Necessary Ubuntu packages are available through the official GNS3 PPA. If you don't know what a PPA is, read up on that first: - https://launchpad.net/~gns3/+archive/ubuntu/ppa diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index 89de004c4..db6afc3d7 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -21,6 +21,9 @@ All notable changes to the project are documented in this file. per port, and a Health card listing services that are not running, a pending reboot, and sensor readings. Disk Usage no longer lists the read-only root filesystem +- CLI: `check`, `commit`, and `leave` warn about bridge ports with a missing + or mismatched PVID, which drops untagged frames or puts them in the wrong + VLAN, issue #354 ### Fixes @@ -29,6 +32,8 @@ All notable changes to the project are documented in this file. never showed the boot partition - LLDP neighbors were listed without their system name, descriptions, and capabilities, in the CLI, the WebUI, and the operational datastore +- The manufacturer from a VPD was not shown as `mfg-name` of its + `vpd-*` component in the operational datastore [v26.09.0][] - 2026-09-30 ------------------------- diff --git a/doc/branding.md b/doc/branding.md index 01e381bea..2cdce2ca6 100644 --- a/doc/branding.md +++ b/doc/branding.md @@ -24,7 +24,7 @@ Verify the result after a build by inspecting: - `output/images/*`: names, missing prefix, etc. - `output/target/etc/os-release`: this file is sourced by other build - scripts, e.g., `mkgns3a.sh`. For reference, see [os-release(5)][] + scripts, e.g., `post-build.sh`. For reference, see [os-release(5)][] > [!IMPORTANT] > To get a proper GIT revision (hash) from your OS spin, remember to set diff --git a/doc/bridging.md b/doc/bridging.md index 3ba38052a..f9153111b 100644 --- a/doc/bridging.md +++ b/doc/bridging.md @@ -45,8 +45,8 @@ bridge should be used instead. By default bridges in Linux do not filter based on VLAN tags. This can be enabled when creating a bridge by adding a port to a VLAN as a tagged or untagged member. Use the port default VID (PVID) setting to control -VLAN association for traffic ingressing a port untagged (default PVID: -1). +VLAN association for traffic ingressing a port untagged. There is no +default PVID, see [Ingress and Egress Rules](#ingress-and-egress-rules).
admin@example:/config/> edit interface br0
 admin@example:/config/interface/br0/> up
@@ -82,6 +82,42 @@ on this topic.
 > in VLAN 10, IP addresses can be set directly on the bridge without the
 > need for dedicated VLAN interfaces on top of the bridge.
 
+### Ingress and Egress Rules
+
+On a VLAN filtering bridge every frame belongs to a VLAN, decided when
+it enters a port.  Using `eth0` from the example above, with PVID 10
+and untagged member of VLAN 10:
+
+| Frame received on `eth0`  | Result                                         |
+|---------------------------|------------------------------------------------|
+| Untagged                  | Accepted, assigned to VLAN 10                  |
+| Priority tagged (VID 0)   | Accepted, assigned to VLAN 10, PCP is kept     |
+| Tagged with VID 10        | Accepted                                       |
+| Tagged with VID 20        | Dropped, `eth0` is not a member of VLAN 20     |
+
+The rules behind the table:
+
+- A tagged frame is accepted only if the port is a member of its VLAN.
+  Whether the membership is tagged or untagged only matters when frames
+  leave the port
+- Untagged and priority tagged frames are assigned to the port's PVID.
+  A port without a PVID drops them.  The PVID must be one of the port's
+  VLANs, otherwise it is ignored and a warning is logged
+- A frame is only forwarded to ports that are members of its VLAN.  It
+  leaves a tagged member with a VLAN tag, and an untagged member
+  without one, so the PCP of a priority tagged frame is lost on an
+  untagged port
+
+The same rules apply when the bridge is offloaded to a switch chip.
+
+The CLI commands `check`, `commit`, and `leave` warn about ports with a
+missing or mismatched PVID.  The configuration is applied anyway:
+
+
admin@example:/config/> leave
+Warning: eth0 is an untagged member of VLAN 10 on br0, but has no PVID.  Untagged frames received on eth0 are dropped.
+admin@example:/>
+
+ ## Multicast Filtering and Snooping diff --git a/doc/management.md b/doc/management.md index 411f6ce1a..be102122f 100644 --- a/doc/management.md +++ b/doc/management.md @@ -130,7 +130,7 @@ admin@example:/> The system provides a set of Web services: -- a rudimentary Web server, currently limited to an information page +- a Web management interface (WebUI) for configuration and monitoring - a RESTCONF server with equivalent management capabilities as NETCONF - a Web console service, where the shell/CLI can be accessed via HTTPS, similar to connecting via a console port or SSH @@ -139,6 +139,11 @@ There is also a *Netbrowse* Web service presenting information about the unit's neighbors, collected via mDNS (see [Discovery](discovery.md) for more details). +All of them are enabled in the factory configuration. The WebUI, Web +console, and Netbrowse are optional parts of the image. Minimal builds +have only a static information page and RESTCONF. On such an image, +the settings for the missing services are accepted but have no effect. +
admin@example:/> configure
 admin@example:/config/> edit web
 admin@example:/config/web/> help
@@ -160,19 +165,20 @@ admin@example:/config/web/> set enabled
 admin@example:/config/web/>
 
-Enabling the Web service implies that a Web server is -enabled. Currently this Web server provides generic Infix information, -as well as a link to a Web console. The Web server uses HTTPS; any -HTTP request is redirected to HTTPS. +Enabling the Web service starts a Web server on port 443 (HTTPS), and +any HTTP request on port 80 is redirected to HTTPS. It serves the +WebUI, where you log in with the same user accounts as for the CLI, +SSH, and NETCONF. The _enabled_ setting for the Web service acts as a global enable/disable setting for the other Web services (Web console, -RESTCONF and Netbrowse). +RESTCONF and Netbrowse). Disabling it stops all of them, regardless +of their own settings. ### Enable/disable Web Console The Web console service provides a terminal service similar to Console -or SSH. The Web console is secured via HTTPS on port 7861. +or SSH. The Web console is secured via HTTPS on port 7681. The Web console has its own enable/disable setting, but will only be activated if the Web service is enabled. The example below shows how @@ -185,12 +191,13 @@ admin@example:/config/web/console/> ### Enable/disable RESTCONF Service -Alternatively, the system can be managed remotely using -RESTCONF. Meaning you can `curl` it instead of using a dedicated -NETCONF client. +Alternatively, the system can be managed remotely using RESTCONF, at +`https://
/restconf`. Meaning you can `curl` it instead of +using a dedicated NETCONF client, see [RESTCONF +Scripting](scripting-restconf.md) for examples. The RESTCONF service has its own enable/disable setting, but will -only be activated if the Web service is enabled. The example below +only be activated if the Web service is enabled. The example below shows how to disable the RESTCONF service.
admin@example:/config/web/> edit restconf
@@ -198,6 +205,10 @@ admin@example:/config/web/restconf/> no enabled
 admin@example:/config/web/restconf/>
 
+> [!NOTE] +> The WebUI uses RESTCONF internally, so disabling RESTCONF only blocks +> requests from other hosts, and the WebUI keeps working. + ### HTTPS Certificate The Web server uses a TLS certificate from the central diff --git a/doc/vpd.md b/doc/vpd.md index 10b58c82b..6adf21be8 100644 --- a/doc/vpd.md +++ b/doc/vpd.md @@ -15,6 +15,93 @@ each board making up a system, figure out if a system belongs to a particular production batch, etc. +## Device Tree Bindings + +The system finds its VPDs through the `vpds` property of the +`/chosen/infix` node in the device tree (DT), a list of phandles to EEPROMs with an ONIE TLV +layout. Each EEPROM is named with an `infix,board` property, and may +be marked `infix,trusted`: + +``` +/ { + chosen { + infix { + vpds = <&vpd_cpu &vpd_product>; + }; + }; +}; + +&i2c0 { + vpd_product: eeprom@50 { + compatible = "atmel,24c02"; + reg = <0x50>; + + infix,board = "product"; + infix,trusted; + + nvmem-layout { + compatible = "onie,tlv-layout"; + + base_mac: mac-address { + #nvmem-cell-cells = <1>; + }; + }; + }; +}; +``` + +Every VPD is listed in the _ietf-hardware_ model as a component named +`vpd-`, e.g., `vpd-product` for the example above. + + +## Product VPD + +The VPD named `product` describes the device as a whole. Its +attributes are used for the following, falling back to other sources +when an attribute is missing: + +| Key | Used for | Fallback | +|-------------------|---------------------------------------------|--------------------------------------| +| `"vendor"` | `mfg-name` of the `mainboard` component | Vendor of DT `compatible` | +| `"product-name"` | `model-name` of the `mainboard` component | DT `model` property | +| `"part-number"` | `hardware-rev` of the `mainboard` component | None | +| `"serial-number"` | `serial-num` of the `mainboard` component | DT `serial-number` property | +| `"mac-address"` | Base (chassis) MAC address, see below | Lowest MAC address of all interfaces | + +The base MAC address is the `phys-address` of the `mainboard` +component. It is also what the `chassis` option of an interface's +`custom-phys-address` refers to, see [Common Interface +Settings](iface.md#chassis-mac), and the source of the `%m` format +specifier in the hostname, which the factory configuration uses to +give each device a unique name, e.g., `example-c0-ff-ee`. + +The kernel sets the MAC address of each Ethernet port from the +`nvmem-cells` property of its device tree node, which can refer to the +`mac-address` cell of the product VPD, plus an offset: + +``` +ð0 { + nvmem-cells = <&base_mac 1>; + nvmem-cell-names = "mac-address"; +}; +``` + +### Factory Password + +The factory default `admin` password hash, the `pwhash` [extension +below](#infix-specific-extensions), is read from the first VPD marked +`infix,trusted` that has one, which need not be the product VPD. It takes precedence +over a `factory-password-hash` property in the `/chosen/infix` node. +Without either, the system reports a critical bootstrap error. + +### Virtual Machines + +When running in QEMU, the product VPD is read from the `opt/vpd` +firmware configuration file, and is always trusted. Without that file +the password defaults to `admin`, and the base MAC address is the lowest +port MAC address minus one. + + ## JSON Encoding To make EEPROM binary generation less cumbersome, Infix defines a JSON diff --git a/src/klish-plugin-infix/src/infix.c b/src/klish-plugin-infix/src/infix.c index 57b8619aa..c301f3678 100644 --- a/src/klish-plugin-infix/src/infix.c +++ b/src/klish-plugin-infix/src/infix.c @@ -837,6 +837,103 @@ int infix_ssh_remove_known_host(kcontext_t *ctx) return run_as_user(cd_home(ctx), argv); } +#define IF_XPATH "/ietf-interfaces:interfaces/interface" + +static int is_member(struct lyd_node *vlan, const char *mode, const char *port) +{ + char path[64]; + + snprintf(path, sizeof(path), "%s[.='%s']", mode, port); + return !lyd_find_path(vlan, path, 0, NULL); +} + +static void pvid_check_port(struct lyd_node *tree, struct lyd_node *iface) +{ + int pvid = 0, uvid = 0, num = 0, member = 0; + char untagged[64] = "", xpath[128]; + struct lyd_node *node, *vlans, *vlan; + const char *port, *br; + + port = lyd_get_value(lyd_child(iface)); + if (!lyd_find_path(iface, "infix-interfaces:bridge", 0, NULL)) + br = port; + else if (!lyd_find_path(iface, "infix-interfaces:bridge-port/bridge", 0, &node)) + br = lyd_get_value(node); + else + return; + + snprintf(xpath, sizeof(xpath), IF_XPATH "[name='%s']/infix-interfaces:bridge/vlans", br); + if (lyd_find_path(tree, xpath, 0, &vlans)) + return; + + if (!lyd_find_path(iface, "infix-interfaces:bridge-port/pvid", 0, &node)) + pvid = atoi(lyd_get_value(node)); + + LY_LIST_FOR(lyd_child(vlans), vlan) { + int vid, untag; + + if (strcmp(vlan->schema->name, "vlan")) + continue; + + untag = is_member(vlan, "untagged", port); + if (!untag && !is_member(vlan, "tagged", port)) + continue; + + vid = atoi(lyd_get_value(lyd_child(vlan))); + if (vid == pvid) + member = 1; + if (untag) { + size_t len = strlen(untagged); + + snprintf(&untagged[len], sizeof(untagged) - len, "%s%d", len ? ", " : "", vid); + uvid = vid; + num++; + } + } + + if (pvid && !member) + printf("Warning: %s has PVID %d, but is not a member of VLAN %d on %s. " + "The PVID is ignored.\n", port, pvid, pvid, br); + else if (!pvid && num) + printf("Warning: %s is an untagged member of VLAN%s %s on %s, but has no PVID. " + "Untagged frames received on %s are dropped.\n", + port, num > 1 ? "s" : "", untagged, br, port); + /* Untagged in several VLANs is an asymmetric VLAN setup, by design */ + else if (pvid && num == 1 && uvid != pvid) + printf("Warning: %s is an untagged member of VLAN %d on %s, but has PVID %d.\n", + port, uvid, br, pvid); +} + +/* + * Warn about bridge port PVIDs that drop untagged traffic or put it in + * another VLAN than the one it leaves untagged in, issue #354. + */ +int infix_pvid_check(kcontext_t *ctx) +{ + const char *xpath = IF_XPATH "/infix-interfaces:bridge/vlans | " + IF_XPATH "/infix-interfaces:bridge-port"; + sr_session_ctx_t *sess = NULL; + sr_conn_ctx_t *conn = NULL; + sr_data_t *data = NULL; + struct lyd_node *iface; + + (void)ctx; + + if (sr_connect(SR_CONN_DEFAULT, &conn)) + return 0; + if (sr_session_start(conn, SR_DS_CANDIDATE, &sess) || + sr_get_data(sess, xpath, 0, 0, 0, &data) || !data) + goto done; + + LY_LIST_FOR(lyd_child(data->tree), iface) + pvid_check_port(data->tree, iface); +done: + sr_release_data(data); + sr_disconnect(conn); + + return 0; +} + int kplugin_infix_fini(kcontext_t *ctx) { (void)ctx; @@ -867,6 +964,7 @@ int kplugin_infix_init(kcontext_t *ctx) kplugin_add_syms(plugin, ksym_new("firewall_addrsets", infix_firewall_addrsets)); kplugin_add_syms(plugin, ksym_new("firewall_addrset_action", infix_firewall_addrset_action)); kplugin_add_syms(plugin, ksym_new("set_boot_order", infix_set_boot_order)); + kplugin_add_syms(plugin, ksym_new("pvid_check", infix_pvid_check)); kplugin_add_syms(plugin, ksym_new("shell", infix_shell)); kplugin_add_syms(plugin, ksym_new("ssh_connect", infix_ssh_connect)); kplugin_add_syms(plugin, ksym_new("ssh_known_hosts", infix_ssh_known_hosts)); diff --git a/src/klish-plugin-infix/xml/infix.xml b/src/klish-plugin-infix/xml/infix.xml index 1f7312a08..105700037 100644 --- a/src/klish-plugin-infix/xml/infix.xml +++ b/src/klish-plugin-infix/xml/infix.xml @@ -1145,6 +1145,7 @@ echo "Public: $pub" + replace main @@ -1192,6 +1193,7 @@ echo "Public: $pub" + @@ -1202,6 +1204,7 @@ echo "Public: $pub" + diff --git a/src/statd/python/yanger/ietf_hardware.py b/src/statd/python/yanger/ietf_hardware.py index b52580924..9c25bc82c 100644 --- a/src/statd/python/yanger/ietf_hardware.py +++ b/src/statd/python/yanger/ietf_hardware.py @@ -31,7 +31,7 @@ def vpd_component(vpd): mfgdate = datetime.datetime.strptime(vpd["data"]["manufacture-date"], "%m/%d/%Y %H:%M:%S") component["mfg-date"] = mfgdate.strftime("%Y-%m-%dT%H:%M:%SZ") - if vpd["data"].get("manufacter"): + if vpd["data"].get("manufacturer"): component["mfg-name"] = vpd["data"]["manufacturer"] if vpd["data"].get("product-name"): component["model-name"] = vpd["data"]["product-name"] diff --git a/test/case/syslog/property_filter/test.py b/test/case/syslog/property_filter/test.py index b2eb5184d..0b6d6c0c6 100755 --- a/test/case/syslog/property_filter/test.py +++ b/test/case/syslog/property_filter/test.py @@ -6,6 +6,8 @@ """ +import uuid + import infamy from infamy.util import parallel, until @@ -19,6 +21,17 @@ ("test", "Warning: lowercase"), ] +# Old messages survive in /var/log on hardware, count only this run's +TOKEN = uuid.uuid4().hex[:8] + + +def count_lines(ssh, logfile, text): + """Count lines in /var/log/logfile from this run containing text""" + rc = ssh.runsh(f"cat /var/log/{logfile} 2>/dev/null") + return sum(1 for line in rc.stdout.splitlines() + if TOKEN in line and text in line) + + with infamy.Test() as test: with test.step("Set up topology and attach to target DUT"): env = infamy.Env() @@ -26,7 +39,9 @@ lambda: env.attach("target", "mgmt", "ssh")) with test.step("Clean up old log files"): - tgtssh.runsh("sudo rm -f /var/log/myapp /var/log/not-error /var/log/case-test /var/log/baseline") + rc = tgtssh.run_retry("sudo rm -f /var/log/myapp /var/log/not-error /var/log/case-test /var/log/baseline") + if rc.returncode: + test.fail("Failed removing old log files") with test.step("Configure syslog with property filters"): target.put_config_dicts({ @@ -94,41 +109,36 @@ with test.step("Send test messages"): for tag, msg in TEST_MESSAGES: - target.log(msg, severity="info", app_name=tag) - until(lambda: "Application startup" in tgtssh.runsh("cat /var/log/baseline 2>/dev/null").stdout, attempts=10) + target.log(f"{msg} {TOKEN}", severity="info", app_name=tag) + until(lambda: count_lines(tgtssh, "baseline", TEST_MESSAGES[-1][1]), attempts=10) with test.step("Verify myapp log contains only myapp messages"): - rc = tgtssh.runsh("grep -c 'myapp' /var/log/myapp 2>/dev/null") - count = int(rc.stdout.strip()) if rc.returncode == 0 else 0 + count = count_lines(tgtssh, "myapp", "myapp") if count != 2: test.fail(f"Expected 2 myapp messages in /var/log/myapp, got {count}") - rc = tgtssh.runsh("grep -c 'otherapp' /var/log/myapp 2>/dev/null") - count = int(rc.stdout.strip()) if rc.returncode == 0 else 0 + count = count_lines(tgtssh, "myapp", "otherapp") if count != 0: test.fail(f"Expected 0 otherapp messages in /var/log/myapp, got {count}") with test.step("Verify not-error log excludes ERROR messages"): - rc = tgtssh.runsh("grep -c 'test' /var/log/not-error 2>/dev/null") - count = int(rc.stdout.strip()) if rc.returncode == 0 else 0 + count = count_lines(tgtssh, "not-error", "test") if count != 3: test.fail(f"Expected 3 non-ERROR messages in /var/log/not-error, got {count}") - rc = tgtssh.runsh("grep -c 'ERROR' /var/log/not-error 2>/dev/null") - count = int(rc.stdout.strip()) if rc.returncode == 0 else 0 + count = count_lines(tgtssh, "not-error", "ERROR") if count != 0: test.fail(f"Expected 0 ERROR messages in /var/log/not-error, got {count}") with test.step("Verify case-test log matches case-insensitive 'warning'"): - rc = tgtssh.runsh("grep -c 'WARNING\\|Warning' /var/log/case-test 2>/dev/null") - count = int(rc.stdout.strip()) if rc.returncode == 0 else 0 + count = count_lines(tgtssh, "case-test", "WARNING") + \ + count_lines(tgtssh, "case-test", "Warning") if count != 2: test.fail(f"Expected 2 warning messages in /var/log/case-test, got {count}") with test.step("Verify baseline log contains all messages"): for tag, msg in TEST_MESSAGES: - rc = tgtssh.runsh(f"grep -q '{msg}' /var/log/baseline 2>/dev/null") - if rc.returncode != 0: + if not count_lines(tgtssh, "baseline", msg): test.fail(f"Expected message '{msg}' not found in /var/log/baseline") test.succeed()