diff --git a/board/common/image/image-itb-qcow/generate.sh b/board/common/image/image-itb-qcow/generate.sh
index c85a8f514..9bcbcec49 100755
--- a/board/common/image/image-itb-qcow/generate.sh
+++ b/board/common/image/image-itb-qcow/generate.sh
@@ -41,9 +41,9 @@ dimension()
elif [ $total -ge $((512 << M)) ]; then
bootsize=$(( 8 << M))
auxsize=$(( 8 << M))
- imgsize=$((192 << M))
+ imgsize=$((180 << M))
cfgsize=$(( 16 << M))
- # var is at least ~100M
+ # var is at least ~120M
else
echo "Can't create disk images smaller than 512M"
exit 1
diff --git a/configs/aarch64_defconfig b/configs/aarch64_defconfig
index fa7008cda..a2fcfdd01 100644
--- a/configs/aarch64_defconfig
+++ b/configs/aarch64_defconfig
@@ -163,6 +163,7 @@ BR2_PACKAGE_CURIOS_HTTPD=y
BR2_PACKAGE_CURIOS_NFTABLES=y
BR2_PACKAGE_GENCERT=y
BR2_PACKAGE_STATD=y
+BR2_PACKAGE_SUPPORT_ENCRYPT=y
BR2_PACKAGE_FACTORY=y
BR2_PACKAGE_FINIT_PLUGIN_HOTPLUG=y
BR2_PACKAGE_FINIT_PLUGIN_HOOK_SCRIPTS=y
diff --git a/configs/aarch64_minimal_defconfig b/configs/aarch64_minimal_defconfig
index d077db378..71111df95 100644
--- a/configs/aarch64_minimal_defconfig
+++ b/configs/aarch64_minimal_defconfig
@@ -131,6 +131,7 @@ BR2_PACKAGE_NETD=y
BR2_PACKAGE_CONFD_TEST_MODE=y
BR2_PACKAGE_GENCERT=y
BR2_PACKAGE_STATD=y
+BR2_PACKAGE_SUPPORT=y
BR2_PACKAGE_FACTORY=y
BR2_PACKAGE_FINIT_PLUGIN_HOTPLUG=y
BR2_PACKAGE_FINIT_PLUGIN_HOOK_SCRIPTS=y
diff --git a/configs/arm_defconfig b/configs/arm_defconfig
index c738cedf2..20cc774f5 100644
--- a/configs/arm_defconfig
+++ b/configs/arm_defconfig
@@ -150,6 +150,7 @@ BR2_PACKAGE_NETD=y
BR2_PACKAGE_CONFD_TEST_MODE=y
BR2_PACKAGE_GENCERT=y
BR2_PACKAGE_STATD=y
+BR2_PACKAGE_SUPPORT_ENCRYPT=y
BR2_PACKAGE_FACTORY=y
BR2_PACKAGE_FINIT_PLUGIN_HOTPLUG=y
BR2_PACKAGE_FINIT_PLUGIN_HOOK_SCRIPTS=y
diff --git a/configs/arm_minimal_defconfig b/configs/arm_minimal_defconfig
index 78f203e21..58788bb01 100644
--- a/configs/arm_minimal_defconfig
+++ b/configs/arm_minimal_defconfig
@@ -129,6 +129,7 @@ BR2_PACKAGE_NETD=y
BR2_PACKAGE_CONFD_TEST_MODE=y
BR2_PACKAGE_GENCERT=y
BR2_PACKAGE_STATD=y
+BR2_PACKAGE_SUPPORT=y
BR2_PACKAGE_FACTORY=y
BR2_PACKAGE_FINIT_PLUGIN_HOTPLUG=y
BR2_PACKAGE_FINIT_PLUGIN_HOOK_SCRIPTS=y
diff --git a/configs/riscv64_defconfig b/configs/riscv64_defconfig
index 6164d6646..7288444b3 100644
--- a/configs/riscv64_defconfig
+++ b/configs/riscv64_defconfig
@@ -182,6 +182,7 @@ BR2_PACKAGE_CONFD=y
BR2_PACKAGE_NETD=y
BR2_PACKAGE_GENCERT=y
BR2_PACKAGE_STATD=y
+BR2_PACKAGE_SUPPORT_ENCRYPT=y
BR2_PACKAGE_FACTORY=y
BR2_PACKAGE_FINIT_PLUGIN_HOTPLUG=y
BR2_PACKAGE_FINIT_PLUGIN_HOOK_SCRIPTS=y
diff --git a/configs/x86_64_defconfig b/configs/x86_64_defconfig
index fa9832a32..35cb1808f 100644
--- a/configs/x86_64_defconfig
+++ b/configs/x86_64_defconfig
@@ -157,6 +157,7 @@ BR2_PACKAGE_CURIOS_HTTPD=y
BR2_PACKAGE_CURIOS_NFTABLES=y
BR2_PACKAGE_GENCERT=y
BR2_PACKAGE_STATD=y
+BR2_PACKAGE_SUPPORT_ENCRYPT=y
BR2_PACKAGE_FACTORY=y
BR2_PACKAGE_FINIT_PLUGIN_HOTPLUG=y
BR2_PACKAGE_FINIT_PLUGIN_HOOK_SCRIPTS=y
diff --git a/configs/x86_64_minimal_defconfig b/configs/x86_64_minimal_defconfig
index db9e57b06..1bba3497d 100644
--- a/configs/x86_64_minimal_defconfig
+++ b/configs/x86_64_minimal_defconfig
@@ -128,6 +128,7 @@ BR2_PACKAGE_NETD=y
BR2_PACKAGE_CONFD_TEST_MODE=y
BR2_PACKAGE_GENCERT=y
BR2_PACKAGE_STATD=y
+BR2_PACKAGE_SUPPORT=y
BR2_PACKAGE_FACTORY=y
BR2_PACKAGE_FINIT_PLUGIN_HOTPLUG=y
BR2_PACKAGE_FINIT_PLUGIN_HOOK_SCRIPTS=y
diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md
index d6d9fa15a..eec2b748c 100644
--- a/doc/ChangeLog.md
+++ b/doc/ChangeLog.md
@@ -16,6 +16,12 @@ All notable changes to the project are documented in this file.
- Document the release and maintenance policy: which versions receive
updates, what may go into a patch release, and the levels of long-term
maintenance available, see [Releases & Support][relsup]
+- `support collect` now redacts private keys, password hashes and other
+ secrets from the configuration files in the archive, use `--no-redact`
+ to keep them. The environment dump is no longer collected
+- WebUI: the support bundle is collected with the `infix-system:support-collect`
+ RPC as the logged-in user, so NACM decides who may download it, rather
+ than by running the tool as root
### Added
@@ -28,6 +34,10 @@ All notable changes to the project are documented in this file.
and keys are prompted for, `binary` settings open in the text editor, and
`string` settings are edited on a line prefilled with the current value.
The `text-editor` and `change` commands are removed
+- Add `infix-system:support-collect` RPC, for collecting support data over
+ NETCONF or RESTCONF. The archive is returned base64 encoded, up to 16 MiB,
+ larger ones are left on the device for out-of-band fetching. Access is
+ denied by default, an NACM rule must permit it
- Add CLI `edit` and `clear` verbs to admin-exec: `edit datetime` and
`edit boot-order` prompt with the current value, `clear dhcp-server
statistics` replaces `dhcp-server clear-statistics`. `set datetime` now
diff --git a/doc/support.md b/doc/support.md
index 9dd102b8f..33def831b 100644
--- a/doc/support.md
+++ b/doc/support.md
@@ -6,62 +6,162 @@ This command gathers configuration files, logs, network state, and other
system information into a single compressed archive.
> [!NOTE]
-> The `support collect` command should be run with `sudo` to collect
-> complete system information (kernel logs, hardware details, etc.).
-> Use the `--unprivileged` option to run as a regular user in degraded
-> data collection mode.
+> `support collect` needs root for kernel logs, hardware details and the
+> full configuration, so run it with `sudo`. Without root it refuses;
+> `--unprivileged` lets it run anyway and collect what your user may
+> read, the rest is noted as missing in the archive.
## Collecting Support Data
-To collect support data and save it to a file:
+On the device, collect to a file with `-o`. Progress goes to stderr and
+the path of the archive is the only thing printed on stdout:
```bash
-admin@host:~$ sudo support collect > support-data.tar.gz
+admin@host:~$ sudo support collect -o /var/lib/support
Starting support data collection from host...
Collecting to: /var/lib/support
This may take up to a minute. Please wait...
Tailing /var/log/messages for 30 seconds (please wait)...
Log tail complete.
Collection complete. Creating archive...
-admin@host:~$ ls -l support-data.tar.gz
--rw-rw-r-- 1 admin admin 508362 nov 30 13:05 support-data.tar.gz
+/var/lib/support/support-host-2026-09-11T13:05:42+02:00.tar.gz
```
-The command can also be run remotely via SSH from your workstation:
+Given a directory, the file gets the canonical name shown above. Given a
+file name, that name is used. Either way the file is created with mode
+0600. Secrets are redacted from the configuration, see below, but the
+archive still holds every log on the device. Fetch it with `scp` and
+remove it, or leave that to `support clean`.
+
+Without `-o` the archive goes to stdout, which is what you want when
+running the command from your workstation over SSH:
```bash
$ ssh admin@host 'sudo support collect' > support-data.tar.gz
...
```
-The collection process may take up to a minute depending on system load
-and the amount of logging data. Progress messages are shown during the
-collection process.
+On the device itself, prefer `-o`. A session that drops mid-way then
+leaves the archive behind rather than taking the only copy with it.
+
+The collection may take up to a minute depending on system load and the
+amount of logging data.
+
+Each command is run with a timeout, so a wedged driver or daemon cannot
+stall the collection; the archive then holds a note in place of that
+command's output. The logs are held in the collection and again in the
+archive beside it, so collection measures them first and refuses when
+the partition cannot hold both, rather than fill it. If the collection
+itself fails, the log is kept next to the working directory, for
+instance:
+
+```
+/var/lib/support/support-host-2026-09-11T13:05:42+02:00.log
+```
+
+It shows what was collected and what failed. Use `support clean` to
+remove old collection directories and logs.
+
+## Collecting over NETCONF or RESTCONF
+
+Clients that only speak the management API can call the
+`infix-system:support-collect` RPC, which runs the same collection and
+returns the archive base64 encoded:
+
+```bash
+$ curl -ku admin:admin -X POST \
+ -H "Content-Type: application/yang-data+json" \
+ https://host/restconf/operations/infix-system:support-collect \
+ | jq -r '."infix-system:output".data' | base64 -d > support-data.tar.gz
+```
+
+Add a password to get it encrypted, then decrypt it with the same
+password after passing it on:
+
+```bash
+$ curl -ku admin:admin -X POST \
+ -H "Content-Type: application/yang-data+json" \
+ -d '{"infix-system:input":{"password":"mypassword"}}' \
+ https://host/restconf/operations/infix-system:support-collect \
+ | jq -r '."infix-system:output".data' | base64 -d > support-data.tar.gz.gpg
+```
+
+A few things to know about this path:
+
+- The RPC is denied by default (`nacm:default-deny-all`), so only groups
+ with an explicit NACM permit rule can call it.
+- Collection runs in `/var/lib/support`, like the `support` command,
+ and the archive is removed once it has been returned, so a successful
+ call leaves nothing behind on the device.
+- An archive above 16 MiB is not returned inline. The reply then holds
+ `size` and `filename` instead, and the file stays in `/var/lib/support`
+ for you to fetch.
+- Over NETCONF the archive arrives as one base64 text node, and libxml2
+ rejects text nodes over 10 MB unless the parser is opened with
+ `XML_PARSE_HUGE`. Clients built on it, lxml and ncclient among them,
+ need that option (`huge_tree=True`) to receive an archive above about
+ 7.5 MB. RESTCONF returns JSON and has no such limit.
+- The system log is tailed for 5 seconds, rather than the 30 the command
+ line defaults to, so that the whole collection finishes inside the
+ client's RPC timeout (`CONFD_TIMEOUT` in `/etc/default/confd`, 60
+ seconds by default).
+- Pass `password` to get the archive GPG encrypted, for handing on to
+ someone else afterwards. The management session is already encrypted,
+ so this is not needed to protect the transfer itself. The password
+ must be a single line. It is handed to gpg on a private file
+ descriptor and never appears in the process list. Devices built
+ without the `BR2_PACKAGE_SUPPORT_ENCRYPT` option have no gpg and reject
+ the request.
+- The configuration files in the archive are always redacted, see below.
+- `confd` is busy for the duration of the collection, like it is during a
+ software upgrade, so a configuration change made at the same time has to
+ wait for the collection to finish.
+- The RPC has 60 seconds to finish. If the collection takes longer, the
+ call fails with a timeout and nothing is kept, so there is no file to
+ look for afterwards. On a device where that happens, with many ports
+ or a lot of logging, log in over SSH and collect to a file instead:
+
+ ```bash
+ $ ssh admin@host 'sudo support collect -o /var/lib/support'
+ /var/lib/support/support-host-2026-09-11T13:05:42+02:00.tar.gz
+ $ scp admin@host:/var/lib/support/support-host-2026-09-11T13:05:42+02:00.tar.gz .
+ ```
+- A collection that fails keeps its log in `/var/lib/support`. The error
+ message says why and names the log.
+- Each call removes what earlier calls left behind once it is a week
+ old, the same as `support clean` does.
+
+From a shell on the device, use the `support` command rather than the
+RPC. A base64 blob on your terminal is of no use to anyone.
## Encrypted Collection
For secure transmission of support data, the archive can be encrypted
-with GPG using a password:
+with GPG using a password. This needs gpg on the device, which the
+`BR2_PACKAGE_SUPPORT_ENCRYPT` build option adds.
```bash
-admin@host:~$ sudo support collect -p mypassword > support-data.tar.gz.gpg
+admin@host:~$ sudo support collect -p mypassword -o /var/lib/support
Starting support data collection from host...
Collecting to: /var/lib/support
This may take up to a minute. Please wait...
...
Collection complete. Creating archive...
Encrypting with GPG...
+
+WARNING: Remember to share the encryption password out-of-band!
+ Do not send it in the same email as the encrypted file.
+/var/lib/support/support-host-2026-09-11T13:05:42+02:00.tar.gz.gpg
```
-The `support collect` command even supports omitting `mypassword` and
-will then prompt interactively for the password. This works over SSH too,
-but the local ssh client may then echo the password.
+Given a directory, `-o` appends `.gpg` to the canonical name. The
+password may be left out, the command then prompts for it. That works
+over SSH too, but the local ssh client may echo what you type, so pipe
+it on stdin instead:
-> [!TIP]
-> To hide the encryption password for an SSH session, the script supports
-> reading from stdin:
-> `echo "$MYSECRET" | ssh user@device 'sudo support collect -p' >
-> file.tar.gz.gpg`
+```bash
+$ echo "$MYSECRET" | ssh admin@host 'sudo support collect -p' > support-data.tar.gz.gpg
+```
After transferring the resulting file to your workstation, decrypt it
with the password:
@@ -89,10 +189,25 @@ $ gpg -d support-data.tar.gz.gpg | tar xz
The support archive includes:
- System identification (hostname, uptime, kernel version)
-- Running and operational configuration (sysrepo datastores)
+- Running, operational and startup configuration, with secrets redacted
- System logs (`/var/log` directory and live tail of messages log)
- Network configuration and state (interfaces, routes, neighbors, bridges)
- FRRouting information (OSPF, BFD status)
- Container information (podman containers and their configuration)
- System resource usage (CPU, memory, disk, processes)
- Hardware information (PCI, USB devices, network interfaces)
+
+## Secrets in the Configuration
+
+The configuration holds private keys, password hashes and other secrets
+that help no one troubleshoot, so `support collect` exports it with
+`copy -r`, which drops every node the YANG models tag
+`nacm:default-deny-all`, and the user passwords. The rest is left
+intact.
+
+Pass `--no-redact` to keep them, for instance when the archive is for
+your own use and stays on your workstation. The RPC always redacts.
+
+The archive still contains every log on the device, which may hold
+usernames, addresses and other details of your network. Treat it as
+confidential and encrypt it before it leaves your control.
diff --git a/package/Config.in b/package/Config.in
index 3befdb365..f91fb5580 100644
--- a/package/Config.in
+++ b/package/Config.in
@@ -13,6 +13,7 @@ source "$BR2_EXTERNAL_INFIX_PATH/package/curios-httpd/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/curios-nftables/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/gencert/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/statd/Config.in"
+source "$BR2_EXTERNAL_INFIX_PATH/package/support/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/factory/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/faux/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/finit/Config.in"
diff --git a/package/confd/Config.in b/package/confd/Config.in
index 984d7cdab..38e276929 100644
--- a/package/confd/Config.in
+++ b/package/confd/Config.in
@@ -5,6 +5,7 @@ config BR2_PACKAGE_CONFD
select BR2_PACKAGE_NETOPEER2
select BR2_PACKAGE_SYSREPO
select BR2_PACKAGE_LIBSRX
+ select BR2_PACKAGE_SUPPORT
help
A plugin to sysrepo that provides the core YANG models used to
manage an Infix based system. Configuration can be done using
diff --git a/package/support/Config.in b/package/support/Config.in
new file mode 100644
index 000000000..107e4a199
--- /dev/null
+++ b/package/support/Config.in
@@ -0,0 +1,22 @@
+config BR2_PACKAGE_SUPPORT
+ bool "support"
+ help
+ The support tool collects logs, configuration and system state
+ into an archive for troubleshooting. It is called from the CLI
+ and the infix-system:support-collect RPC, which the WebUI uses.
+
+ https://github.com/kernelkit/infix
+
+if BR2_PACKAGE_SUPPORT
+
+config BR2_PACKAGE_SUPPORT_ENCRYPT
+ bool "Encrypted archives"
+ depends on BR2_PACKAGE_GNUPG2_DEPENDS
+ depends on !BR2_PACKAGE_GNUPG
+ select BR2_PACKAGE_GNUPG2
+ help
+ Allow a support archive to be encrypted with a password, using
+ GnuPG, before it leaves the device. Adds gpg and its libraries
+ to the image.
+
+endif
diff --git a/package/support/support.mk b/package/support/support.mk
new file mode 100644
index 000000000..70853cf7b
--- /dev/null
+++ b/package/support/support.mk
@@ -0,0 +1,18 @@
+################################################################################
+#
+# support
+#
+################################################################################
+
+SUPPORT_VERSION = 1.0
+SUPPORT_SITE_METHOD = local
+SUPPORT_SITE = $(BR2_EXTERNAL_INFIX_PATH)/src/support
+SUPPORT_LICENSE = ISC
+SUPPORT_LICENSE_FILES = LICENSE
+SUPPORT_REDISTRIBUTE = NO
+
+define SUPPORT_INSTALL_TARGET_CMDS
+ $(INSTALL) -D -m 0755 $(@D)/support $(TARGET_DIR)/usr/sbin/support
+endef
+
+$(eval $(generic-package))
diff --git a/package/webui/Config.in b/package/webui/Config.in
index cbf1a3546..c594fb58b 100644
--- a/package/webui/Config.in
+++ b/package/webui/Config.in
@@ -3,6 +3,7 @@ config BR2_PACKAGE_WEBUI
depends on BR2_PACKAGE_HOST_GO_TARGET_ARCH_SUPPORTS
depends on BR2_PACKAGE_ROUSETTE
depends on !BR2_PACKAGE_LANDING
+ select BR2_PACKAGE_SUPPORT
help
Web management interface for Infix, a Go+HTMX application
that provides browser-based configuration and monitoring
diff --git a/src/bin/Makefile.am b/src/bin/Makefile.am
index 8aab109d0..0201be3df 100644
--- a/src/bin/Makefile.am
+++ b/src/bin/Makefile.am
@@ -2,7 +2,6 @@ DISTCLEANFILES = *~ *.d
ACLOCAL_AMFLAGS = -I m4
bin_PROGRAMS = copy erase files
-sbin_SCRIPTS = support
# Bash completion
bashcompdir = $(datadir)/bash-completion/completions
diff --git a/src/bin/copy.bash b/src/bin/copy.bash
index b2c43b8ac..72d9c573f 100644
--- a/src/bin/copy.bash
+++ b/src/bin/copy.bash
@@ -9,7 +9,7 @@ _copy_completion()
prev="${COMP_WORDS[COMP_CWORD-1]}"
# Options for the copy command
- opts="-h -n -q -s -t -u -v"
+ opts="-h -n -q -r -s -t -u -v"
local datastores_dst="running-config startup-config"
local datastores_src="factory-config operational-state running-config"
@@ -37,7 +37,7 @@ _copy_completion()
local i
for ((i=1; i < COMP_CWORD; i++)); do
case "${COMP_WORDS[i]}" in
- -h|-n|-q|-s|-v)
+ -h|-n|-q|-r|-s|-v)
# Flag without argument
;;
-t|-u)
diff --git a/src/bin/copy.c b/src/bin/copy.c
index 2fcc8584a..3465317a7 100644
--- a/src/bin/copy.c
+++ b/src/bin/copy.c
@@ -47,6 +47,7 @@ static int force;
static int timeout;
static int dry_run;
static int sanitize;
+static int redact;
/*
* Current system user, same as sysrepo user. We use getuid() here
@@ -387,6 +388,64 @@ static sr_session_ctx_t *sysrepo_session(const struct infix_ds *ds)
return sess;
}
+/* Models tag their secrets nacm:default-deny-all, the user password in
+ * ietf-system being the one that predates the convention */
+static bool is_secret(const struct lysc_node *snode)
+{
+ LY_ARRAY_COUNT_TYPE u;
+
+ LY_ARRAY_FOR(snode->exts, u) {
+ const struct lysc_ext *def = snode->exts[u].def;
+
+ if (!strcmp(def->name, "default-deny-all") &&
+ !strcmp(def->module->name, "ietf-netconf-acm"))
+ return true;
+ }
+
+ if (!strcmp(snode->name, "password") && snode->parent &&
+ !strcmp(snode->parent->name, "user") &&
+ !strcmp(snode->module->name, "ietf-system"))
+ return true;
+
+ return false;
+}
+
+/* Drops secret nodes, subtree included, like NACM does for a user
+ * without read access. Freeing a first sibling moves *first. */
+static size_t redact_tree(struct lyd_node **first)
+{
+ struct lyd_node *node, *next;
+ size_t num = 0;
+
+ LY_LIST_FOR_SAFE(*first, next, node) {
+ if (!node->schema)
+ continue;
+
+ if (is_secret(node->schema)) {
+ if (debug) {
+ char *path = lyd_path(node, LYD_PATH_STD, NULL, 0);
+
+ dbg("redacting %s", path);
+ free(path);
+ }
+
+ if (node == *first)
+ *first = next;
+ lyd_free_tree(node);
+ num++;
+ continue;
+ }
+
+ if (node->schema->nodetype & (LYS_CONTAINER | LYS_LIST)) {
+ struct lyd_node *child = lyd_child(node);
+
+ num += redact_tree(&child);
+ }
+ }
+
+ return num;
+}
+
static int sysrepo_export(const struct infix_ds *ds, const char *path)
{
sr_session_ctx_t *sess;
@@ -407,6 +466,14 @@ static int sysrepo_export(const struct infix_ds *ds, const char *path)
if (!data)
return 0;
+ if (redact) {
+ size_t num = redact_tree(&data->tree);
+
+ if (num)
+ fprintf(stderr, "redacted %zu secret node%s from %s\n",
+ num, num == 1 ? "" : "s", ds->name);
+ }
+
err = lyd_print_path(path, data->tree, LYD_JSON, LYD_PRINT_SIBLINGS);
sr_release_data(data);
@@ -820,6 +887,8 @@ static int usage(int rc)
" -f Force yes when copying to a file that exists already\n"
" -h This help text\n"
" -n Dry-run, validate configuration without applying\n"
+ " -r Redact secrets when exporting a datastore: drop nodes\n"
+ " tagged nacm:default-deny-all and user passwords\n"
" -s Sanitize paths for CLI use (restrict path traversal)\n"
" -t SEC Timeout for the operation, or default %d sec\n"
" -u USER Username for remote commands, like scp\n"
@@ -957,7 +1026,7 @@ static int copy_main(int argc, char *argv[])
timeout = fgetint("/etc/default/confd", "=", "CONFD_TIMEOUT");
- while ((c = getopt(argc, argv, "dfhnst:u:vx:")) != EOF) {
+ while ((c = getopt(argc, argv, "dfhnrst:u:vx:")) != EOF) {
switch(c) {
case 'd':
debug = 1;
@@ -970,6 +1039,9 @@ static int copy_main(int argc, char *argv[])
case 'n':
dry_run = 1;
break;
+ case 'r':
+ redact = 1;
+ break;
case 's':
sanitize = 1;
break;
diff --git a/src/confd/src/Makefile.am b/src/confd/src/Makefile.am
index 7e9a8b74f..beeae2d61 100644
--- a/src/confd/src/Makefile.am
+++ b/src/confd/src/Makefile.am
@@ -50,6 +50,7 @@ confd_plugin_la_SOURCES = \
if-wireguard.c \
keystore.c \
system.c \
+ support.c \
schedule.c \
ntp.c \
ptp.c \
diff --git a/src/confd/src/core.c b/src/confd/src/core.c
index 99fd04c14..d5ac82284 100644
--- a/src/confd/src/core.c
+++ b/src/confd/src/core.c
@@ -900,6 +900,10 @@ int sr_plugin_init_cb(sr_session_ctx_t *session, void **priv)
if (rc)
goto err;
+ rc = support_rpc_init(&confd);
+ if (rc)
+ goto err;
+
/* Candidate infer configurations */
rc = interfaces_cand_init(&confd);
if (rc)
diff --git a/src/confd/src/core.h b/src/confd/src/core.h
index 38c80873e..6fed4668f 100644
--- a/src/confd/src/core.h
+++ b/src/confd/src/core.h
@@ -263,6 +263,9 @@ int meta_change_cb(sr_session_ctx_t *session, struct lyd_node *config, struct ly
/* system-software.c */
int system_sw_rpc_init(struct confd *confd);
+/* support.c */
+int support_rpc_init(struct confd *confd);
+
/* services.c */
int services_change(sr_session_ctx_t *session, struct lyd_node *config, struct lyd_node *diff, sr_event_t event, struct confd *confd);
diff --git a/src/confd/src/support.c b/src/confd/src/support.c
new file mode 100644
index 000000000..664d98ea9
--- /dev/null
+++ b/src/confd/src/support.c
@@ -0,0 +1,384 @@
+/* SPDX-License-Identifier: BSD-3-Clause */
+#include
Leave blank for a plain .tar.gz, or set
diff --git a/test/case/meta/prune-containers.py b/test/case/meta/prune-containers.py
new file mode 100755
index 000000000..d01425b81
--- /dev/null
+++ b/test/case/meta/prune-containers.py
@@ -0,0 +1,33 @@
+#!/usr/bin/env python3
+"""Prune stray podman containers on all DUTs.
+
+Workaround for the test rig: other tests may leave stray containers
+behind on the DUTs, and Infix cannot prune them itself due to
+limitations in podman. Until that is fixed upstream we simply prune all
+stopped containers before running the test suites. This is mainly a
+problem on the test rig where other tests has left stray containers.
+
+See Infix issue "Stray containers are not pruned":
+https://github.com/kernelkit/infix/issues/1614
+"""
+import infamy
+
+with infamy.Test() as test:
+ with test.step("Discover topology and attach to available DUTs"):
+ env = infamy.Env(False)
+ ctrl = env.ptop.get_ctrl()
+ duts = {}
+ for ix in env.ptop.get_infixen():
+ cport, ixport = env.ptop.get_mgmt_link(ctrl, ix)
+ print(f"Attaching to {ix}:{ixport} via {ctrl}:{cport}")
+ duts[ix] = env.attach(ix, ixport, protocol="ssh", test_reset=False)
+
+ with test.step("Prune stopped containers"):
+ for name, tgt in duts.items():
+ print(f"{name}: pruning containers")
+ rc = tgt.runsh("sudo rm -rf /var/lib/containers")
+ print(rc.stdout)
+ if rc.returncode != 0:
+ test.fail()
+
+ test.succeed()
diff --git a/test/case/misc/support_collect/test.adoc b/test/case/misc/support_collect/test.adoc
index 70dd100f1..14e68ce05 100644
--- a/test/case/misc/support_collect/test.adoc
+++ b/test/case/misc/support_collect/test.adoc
@@ -1,12 +1,14 @@
=== Support Data Collection
-ifdef::topdoc[:imagesdir: {topdoc}../../test/case/misc/support_collect]
+ifdef::topdoc[:imagesdir: {topdoc}../../misc/support_collect]
==== Description
-Verify that the support collect command works and produces a valid tarball
-with expected content. Tests both the --work-dir global option and GPG
-encryption (when available on target).
+Verify that the support-collect RPC returns a valid archive with the
+expected content, that private keys and login hashes are removed from
+the configuration in it, that the archive can be GPG encrypted, and
+that an archive too large to return inline is left on the device and
+its path returned instead.
==== Topology
@@ -15,10 +17,18 @@ image::topology.svg[Support Data Collection topology, align=center, scaledwidth=
==== Sequence
. Set up topology and attach to target DUT
-. Check for GPG availability on target
-. Run support collect with --work-dir and short log tail
-. Verify tarball was created and is valid
-. Run support collect with GPG encryption
-. Verify encrypted file and decrypt it
+. Call the infix-system:support-collect RPC without a password
+. Base64 decode the 'data' reply to a .tar.gz file, verify its length matches the 'size' reply
+. Verify the archive holds collection.log, running-config.json, operational-config.json, system/dmesg.txt, system/meminfo.txt and network/ip/addr.json
+. Verify running-config.json in the archive has the ietf-keystore:keystore container and the admin user
+. Verify the admin user in running-config.json has no password leaf
+. Verify neither running-config.json nor operational-config.json has any password, cleartext-private-key, cleartext-symmetric-key or shared-secret leaf
+. Call the support-collect RPC with password 'test-support-password-123'
+. Base64 decode the reply to a .gpg file, decrypt it with gpg and the same password
+. Verify the decrypted archive holds the same files as the first one
+. Verify the decrypted archive has the same secrets removed
+. Attach to target over ssh and create /var/log/support-test.bin with 17 MB of random data
+. Call the support-collect RPC, verify the reply has 'size' over 16 MiB and 'filename', but no inline 'data'
+. Fetch the archive named in 'filename' from target over ssh, verify its length matches 'size' and it holds the expected files
diff --git a/test/case/misc/support_collect/test.py b/test/case/misc/support_collect/test.py
index abb799314..859fb6109 100755
--- a/test/case/misc/support_collect/test.py
+++ b/test/case/misc/support_collect/test.py
@@ -1,216 +1,273 @@
#!/usr/bin/env python3
"""Support data collection
-Verify that the support collect command works and produces a valid tarball
-with expected content. Tests both the --work-dir global option and GPG
-encryption (when available on target).
+Verify that the support-collect RPC returns a valid archive with the
+expected content, that private keys and login hashes are removed from
+the configuration in it, that the archive can be GPG encrypted, and
+that an archive too large to return inline is left on the device and
+its path returned instead.
"""
+import base64
+import json
import os
+import shutil
import subprocess
import tarfile
import tempfile
import infamy
-from infamy.util import parallel
-import infamy.ssh as ssh
+
+PASSWORD = "test-support-password-123"
+BIG_FILE = "/var/log/support-test.bin"
+BIG_MB = 17
+WORK_DIR = "/var/lib/support"
+
+EXPECTED = [
+ "collection.log",
+ "running-config.json",
+ "operational-config.json",
+ "system/dmesg.txt",
+ "system/meminfo.txt",
+ "network/ip/addr.json",
+]
+SECRETS = ("password", "cleartext-private-key", "cleartext-symmetric-key",
+ "shared-secret")
+
+
+def secrets(node, found=None):
+ """Collect (leaf, value) for every secret leaf in a config tree"""
+ if found is None:
+ found = []
+ if isinstance(node, dict):
+ for key, val in node.items():
+ if key.split(":")[-1] in SECRETS and isinstance(val, str):
+ found.append((key, val))
+ else:
+ secrets(val, found)
+ elif isinstance(node, list):
+ for val in node:
+ secrets(val, found)
+ return found
+
+
+def free_kb(ssh, path):
+ """Free space on the filesystem holding path, in KiB"""
+ result = ssh.runsh(f"df -Pk {path} | awk 'NR == 2 {{ print $4 }}'")
+ return int(result.stdout.strip())
+
+
+def save(local, output):
+ """Decode the archive in an RPC reply to a local file, return its size"""
+ if "data" not in output:
+ raise Exception(f"RPC returned no inline archive: {output}")
+
+ raw = base64.b64decode(output["data"])
+ if len(raw) != int(output["size"]):
+ raise Exception(f"RPC reported {output['size']} bytes,"
+ f" archive is {len(raw)}")
+
+ with open(local, "wb") as f:
+ f.write(raw)
+
+ return len(raw)
+
+
+def verify_contents(local, expected):
+ with tarfile.open(local, "r:gz") as tar:
+ members = tar.getnames()
+ if not members:
+ raise Exception("archive is empty")
+
+ root = members[0].split("/")[0]
+ print(f"Archive {root} contains {len(members)} files/directories")
+
+ missing = [e for e in expected if f"{root}/{e}" not in members]
+ if missing:
+ raise Exception(f"missing from archive: {', '.join(missing)}")
+
+
+def config(local, name):
+ """Load a JSON configuration file from the archive"""
+ with tarfile.open(local, "r:gz") as tar:
+ root = tar.getnames()[0].split("/")[0]
+ with tar.extractfile(f"{root}/{name}") as f:
+ try:
+ return json.load(f)
+ except json.JSONDecodeError as e:
+ raise Exception(f"{name} in archive is not valid JSON,"
+ f" collection of it failed: {e}")
+
+
+def admin_user(running):
+ users = running.get("ietf-system:system", {}) \
+ .get("authentication", {}).get("user", [])
+ admin = [u for u in users if u.get("name") == "admin"]
+ if not admin:
+ raise Exception("running-config.json has no admin user, "
+ f"users: {[u.get('name') for u in users]}")
+ return admin[0]
+
+
+def verify_keystore_and_admin(local):
+ running = config(local, "running-config.json")
+ if "ietf-keystore:keystore" not in running:
+ raise Exception("running-config.json has no keystore, the factory "
+ "configuration has two keys in it")
+ admin_user(running)
+ print("running-config.json: keystore and admin user present")
+
+
+def verify_login_hash_removed(local):
+ admin = admin_user(config(local, "running-config.json"))
+ if "password" in admin:
+ raise Exception("running-config.json leaks the admin login hash: "
+ f"{admin['password']}")
+ print("running-config.json: admin user has no password leaf")
+
+
+def verify_no_secrets(local):
+ for name in ("running-config.json", "operational-config.json"):
+ leaked = [key for key, _ in secrets(config(local, name))]
+ if leaked:
+ raise Exception(f"{name} leaks secrets: {', '.join(leaked)}")
+ print(f"{name}: no secret leaves")
+
with infamy.Test() as test:
with test.step("Set up topology and attach to target DUT"):
env = infamy.Env()
- target, tgtssh = parallel(lambda: env.attach("target", "mgmt"),
- lambda: env.attach("target", "mgmt", "ssh"))
-
- with test.step("Check for GPG availability on target"):
- result = tgtssh.run("command -v gpg >/dev/null 2>&1", check=False)
- has_gpg = (result.returncode == 0)
- if has_gpg:
- print("GPG is available on target - will test encryption")
- else:
- print("GPG not available on target - skipping encryption tests")
-
- with test.step("Run support collect with --work-dir and short log tail"):
- # Create temporary file for output
- with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
- output_file = tmp.name
-
- # Use /tmp as work-dir to test the --work-dir option
- # Run support collect via SSH with short log tail for testing
- # Capture stdout (the tarball) to file
- # Note: timeout is generous to handle systems with many network ports
- # (ethtool collection scales with number of interfaces)
- with open(output_file, 'wb') as f:
- result = tgtssh.run("sudo support --work-dir /tmp collect --log-sec 2",
- stdout=f,
- stderr=subprocess.PIPE,
- timeout=300)
-
- if result.returncode != 0:
- stderr_output = result.stderr.decode('utf-8') if result.stderr else ""
- print(f"support collect failed with return code {result.returncode}")
- print(f"stderr: {stderr_output}")
-
- # Try to retrieve the collection.log for debugging
- print("\n=== Attempting to retrieve collection.log for debugging ===")
- try:
- log_result = tgtssh.run("find /tmp -name 'support-*' -type d -exec cat {}/collection.log \\; 2>/dev/null || echo 'No collection.log found'",
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- timeout=10,
- check=False)
- if log_result.stdout:
- log_output = log_result.stdout.decode('utf-8')
- print(f"collection.log contents:\n{log_output}")
- except Exception as e:
- print(f"Could not retrieve collection.log: {e}")
-
- raise Exception("support collect command failed")
-
- with test.step("Verify tarball was created and is valid"):
- if not os.path.exists(output_file):
- raise Exception(f"Output file {output_file} was not created")
-
- file_size = os.path.getsize(output_file)
- if file_size == 0:
- raise Exception("Output tarball is empty")
-
- print(f"Tarball created: {file_size} bytes")
-
- # Verify it's a valid tar.gz
+ target = env.attach("target", "mgmt")
+
+ local = {}
+ for name in ("archive", "encrypted", "decrypted", "big"):
+ fd, path = tempfile.mkstemp(prefix=f"support-{name}-")
+ os.close(fd)
+ local[name] = path
+
+ def cleanup():
+ for path in local.values():
+ if os.path.exists(path):
+ os.remove(path)
+
+ test.push_test_cleanup(cleanup)
+
+ with test.step("Call the infix-system:support-collect RPC without a password"):
+ output = target.rpc_output("infix-system", "support-collect")
+
+ with test.step("Base64 decode the 'data' reply to a .tar.gz file, verify "
+ "its length matches the 'size' reply"):
+ size = save(local["archive"], output)
+ print(f"RPC returned {size} bytes")
+
+ with test.step("Verify the archive holds collection.log, running-config.json, "
+ "operational-config.json, system/dmesg.txt, system/meminfo.txt "
+ "and network/ip/addr.json"):
+ verify_contents(local["archive"], EXPECTED)
+
+ with test.step("Verify running-config.json in the archive has the "
+ "ietf-keystore:keystore container and the admin user"):
+ verify_keystore_and_admin(local["archive"])
+
+ with test.step("Verify the admin user in running-config.json has no "
+ "password leaf"):
+ verify_login_hash_removed(local["archive"])
+
+ with test.step("Verify neither running-config.json nor operational-config.json "
+ "has any password, cleartext-private-key, "
+ "cleartext-symmetric-key or shared-secret leaf"):
+ verify_no_secrets(local["archive"])
+
+ with test.step("Call the support-collect RPC with password "
+ "'test-support-password-123'"):
try:
- with tarfile.open(output_file, 'r:gz') as tar:
- members = tar.getnames()
- print(f"Tarball contains {len(members)} files/directories")
-
- # Verify some expected files exist
- expected_files = [
- 'collection.log',
- 'operational-config.json',
- 'system/dmesg.txt',
- 'system/meminfo.txt',
- 'network/ip/addr.json'
- ]
-
- root_dir = members[0] if members else None
- for expected in expected_files:
- full_path = f"{root_dir}/{expected}" if root_dir else expected
- if full_path not in members:
- print(f"Warning: Expected file '{expected}' not found in tarball")
- else:
- print(f"Found: {expected}")
-
- except tarfile.TarError as e:
- raise Exception(f"Invalid tarball: {e}")
-
- finally:
- # Clean up
- if os.path.exists(output_file):
- os.remove(output_file)
-
- if has_gpg:
- with test.step("Run support collect with GPG encryption"):
- # Create temporary file for encrypted output
- with tempfile.NamedTemporaryFile(suffix=".tar.gz.gpg", delete=False) as tmp:
- encrypted_file = tmp.name
-
- # Use a test password
- test_password = "test-support-password-123"
-
- # Run support collect with encryption
- with open(encrypted_file, 'wb') as f:
- result = tgtssh.run(f"sudo support --work-dir /tmp collect --log-sec 2 --password {test_password}",
- stdout=f,
- stderr=subprocess.PIPE,
- timeout=300)
+ output = target.rpc_output("infix-system", "support-collect",
+ {"password": PASSWORD})
+ except Exception as e:
+ if "gpg is not available" not in str(e):
+ raise
+ print("GPG not available on target - skipping encryption test")
+ output = None
+
+ with test.step("Base64 decode the reply to a .gpg file, decrypt it with "
+ "gpg and the same password"):
+ if output is None:
+ print("Skipped, target has no gpg")
+ elif not shutil.which("gpg"):
+ raise Exception("gpg is required on the test host")
+ else:
+ save(local["encrypted"], output)
+
+ with open(local["encrypted"], "rb") as ef, \
+ open(local["decrypted"], "wb") as df:
+ result = subprocess.run(
+ ["gpg", "--batch", "--yes", "--passphrase", PASSWORD,
+ "--pinentry-mode", "loopback", "-d"],
+ stdin=ef, stdout=df, stderr=subprocess.PIPE, timeout=60)
if result.returncode != 0:
- stderr_output = result.stderr.decode('utf-8') if result.stderr else ""
- print(f"support collect with encryption failed: {stderr_output}")
-
- # Try to retrieve the collection.log for debugging
- print("\n=== Attempting to retrieve collection.log for debugging ===")
- try:
- log_result = tgtssh.run("find /tmp -name 'support-*' -type d -exec cat {}/collection.log \\; 2>/dev/null || echo 'No collection.log found'",
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- timeout=10,
- check=False)
- if log_result.stdout:
- log_output = log_result.stdout.decode('utf-8')
- print(f"collection.log contents:\n{log_output}")
- except Exception as e:
- print(f"Could not retrieve collection.log: {e}")
-
- raise Exception("support collect with --password failed")
-
- with test.step("Verify encrypted file and decrypt it"):
- if not os.path.exists(encrypted_file):
- raise Exception(f"Encrypted output file {encrypted_file} was not created")
-
- file_size = os.path.getsize(encrypted_file)
- if file_size == 0:
- raise Exception("Encrypted output file is empty")
-
- print(f"Encrypted file created: {file_size} bytes")
-
- # Create temporary file for decrypted output
- with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
- decrypted_file = tmp.name
+ raise Exception("failed to decrypt support data:"
+ f" {result.stderr.decode(errors='replace')}")
- try:
- # Decrypt the file using gpg
- with open(encrypted_file, 'rb') as ef:
- with open(decrypted_file, 'wb') as df:
- decrypt_result = subprocess.run(
- ["gpg", "--batch", "--yes", "--passphrase", test_password,
- "--pinentry-mode", "loopback", "-d"],
- stdin=ef,
- stdout=df,
- stderr=subprocess.PIPE,
- timeout=30
- )
-
- if decrypt_result.returncode != 0:
- stderr_output = decrypt_result.stderr.decode('utf-8') if decrypt_result.stderr else ""
- print(f"GPG decryption failed: {stderr_output}")
- raise Exception("Failed to decrypt GPG-encrypted support data")
-
- print("Successfully decrypted GPG file")
-
- # Verify the decrypted file is a valid tarball
- with tarfile.open(decrypted_file, 'r:gz') as tar:
- members = tar.getnames()
- print(f"Decrypted tarball contains {len(members)} files/directories")
-
- # Verify some expected files exist
- expected_files = [
- 'collection.log',
- 'operational-config.json',
- 'system/dmesg.txt'
- ]
-
- root_dir = members[0] if members else None
- for expected in expected_files:
- full_path = f"{root_dir}/{expected}" if root_dir else expected
- if full_path not in members:
- print(f"Warning: Expected file '{expected}' not found in decrypted tarball")
- else:
- print(f"Found in decrypted tarball: {expected}")
-
- except tarfile.TarError as e:
- raise Exception(f"Decrypted file is not a valid tarball: {e}")
-
- except subprocess.TimeoutExpired:
- raise Exception("GPG decryption timed out")
-
- except FileNotFoundError:
- print("Warning: gpg not available on host system - skipping decryption verification")
-
- finally:
- # Clean up
- if os.path.exists(encrypted_file):
- os.remove(encrypted_file)
- if os.path.exists(decrypted_file):
- os.remove(decrypted_file)
+ with test.step("Verify the decrypted archive holds the same files as the "
+ "first one"):
+ if output is None:
+ print("Skipped, target has no gpg")
+ else:
+ verify_contents(local["decrypted"], EXPECTED)
+
+ with test.step("Verify the decrypted archive has the same secrets removed"):
+ if output is None:
+ print("Skipped, target has no gpg")
+ else:
+ verify_login_hash_removed(local["decrypted"])
+ verify_no_secrets(local["decrypted"])
+
+ with test.step("Attach to target over ssh and create /var/log/support-test.bin "
+ "with 17 MB of random data"):
+ tgtssh = env.attach("target", "mgmt", "ssh", test_reset=False)
+
+ free = free_kb(tgtssh, WORK_DIR)
+ need = 2 * (size // 1024 + BIG_MB * 1024) + 2048
+ room = free >= need
+ print(f"{WORK_DIR}: {free // 1024} MB free, collecting {BIG_MB} MB of extra"
+ f" logs needs about {need // 1024} MB")
+
+ if not room:
+ print("Skipped, no room on the device")
+ else:
+ test.push_test_cleanup(
+ lambda: tgtssh.run(f"sudo rm -f {BIG_FILE}", check=False))
+ tgtssh.run(f"sudo dd if=/dev/urandom of={BIG_FILE} bs=1M count={BIG_MB}",
+ check=True, capture_output=True)
+
+ with test.step("Call the support-collect RPC, verify the reply has 'size' over "
+ "16 MiB and 'filename', but no inline 'data'"):
+ if not room:
+ print("Skipped, no room on the device")
+ else:
+ output = target.rpc_output("infix-system", "support-collect")
+ if "data" in output or "filename" not in output:
+ raise Exception("expected the archive left on the device,"
+ f" got {list(output)}")
+ if int(output["size"]) <= 16 * 1024 * 1024:
+ raise Exception(f"archive is {output['size']} bytes, not over 16 MiB")
+
+ remote = output["filename"]
+ test.push_test_cleanup(
+ lambda: tgtssh.run(f"sudo rm -f {remote}", check=False))
+ print(f"Archive of {output['size']} bytes left at {remote}")
+
+ with test.step("Fetch the archive named in 'filename' from target over ssh, "
+ "verify its length matches 'size' and it holds the expected files"):
+ if not room:
+ print("Skipped, no room on the device")
+ else:
+ with open(local["big"], "wb") as f:
+ tgtssh.run(f"sudo cat {remote}", check=True, stdout=f)
+ if os.path.getsize(local["big"]) != int(output["size"]):
+ raise Exception(f"RPC reported {output['size']} bytes,"
+ f" fetched {os.path.getsize(local['big'])}")
+ verify_contents(local["big"], EXPECTED)
test.succeed()
diff --git a/test/case/sanity.yaml b/test/case/sanity.yaml
index ccc4eb991..59e4b3d3f 100644
--- a/test/case/sanity.yaml
+++ b/test/case/sanity.yaml
@@ -13,6 +13,10 @@
name: "Verify Software Version"
infamy:
specification: False
+- case: meta/prune-containers.py
+ name: "Removing old containers"
+ infamy:
+ specification: False
# This typically reveals problems triggered or caused by previous test runs.
- case: misc/operational_all/test.py
diff --git a/test/infamy/netconf.py b/test/infamy/netconf.py
index fe2708df1..23242aebe 100644
--- a/test/infamy/netconf.py
+++ b/test/infamy/netconf.py
@@ -13,13 +13,27 @@
import libyang
import lxml
+import types
import netconf_client.connect
import netconf_client.ncclient
+import netconf_client.session
from infamy.transport import Transport,infer_put_dict
from netconf_client.error import RpcError
from . import env, netutil, coverage
+def fromstring(text):
+ """Parse XML, accepting text nodes over libxml2's 10 MB limit"""
+ return lxml.etree.fromstring(text, lxml.etree.XMLParser(huge_tree=True))
+
+
+# The receive thread in netconf_client parses every reply with the
+# default lxml parser and dies silently when that fails, leaving
+# every pending RPC to time out. A binary leaf is easily over the
+# limit, e.g. the support-collect archive.
+netconf_client.session.etree = types.SimpleNamespace(fromstring=fromstring)
+
+
def netconf_syn(addr):
if netutil.tcp_port_is_open(addr, 830):
return True
@@ -79,7 +93,7 @@ def __init__(self, raw, ele):
class NccGetSchemaReply:
def __init__(self, raw):
- self.ele = lxml.etree.fromstring(raw.xml.decode())
+ self.ele = fromstring(raw.xml.decode())
self.ele = self.ele.find("{urn:ietf:params:xml:ns:yang:ietf-netconf-monitoring}data")
self.schema = self.ele.text
@@ -372,7 +386,13 @@ def patch_config(self, modname, edit, retries=3):
def call(self, call):
"""Call RPC, XML version"""
- return self.ncc.dispatch(call)
+ try:
+ return self.ncc.dispatch(call)
+ except TimeoutError:
+ if self.ncc.session.thread.is_alive():
+ raise
+ raise Exception("NETCONF receive thread has died, "
+ "the reply could not be parsed") from None
def call_dict(self, modname, call):
"""Call RPC, Python dictionary version"""
@@ -386,6 +406,22 @@ def call_dict(self, modname, call):
lyd = mod.parse_data_dict(call, rpc=True)
return self.call(lyd.print_mem("xml", with_siblings=True, pretty=False))
+ def rpc_output(self, module, rpc, input_data=None):
+ """Call RPC, returning output leaves as a dict of strings"""
+ reply = self.call_dict(module, {rpc: input_data or {}})
+ xml = reply.xml
+ if isinstance(xml, str):
+ xml = xml.encode()
+
+ output = {}
+ for node in fromstring(xml).iter():
+ if len(node) or not node.text:
+ continue
+ leaf = lxml.etree.QName(node).localname
+ output[leaf] = node.text.strip()
+
+ return output
+
def call_action(self, xpath, input_data=None):
"""Call NETCONF action (contextualized RPC), XML version.
diff --git a/test/infamy/restconf.py b/test/infamy/restconf.py
index ad004a8dc..ba086e0f1 100644
--- a/test/infamy/restconf.py
+++ b/test/infamy/restconf.py
@@ -426,6 +426,32 @@ def call_rpc(self, rpc):
)
response.raise_for_status() # Raise an exception for HTTP errors
+ def rpc_output(self, module, rpc, input_data=None):
+ """Call RPC, returning output leaves as a dict of strings"""
+ coverage.track_dict(module, {rpc: input_data or {}})
+ url = f"{self.rpc_url}/{module}:{rpc}"
+ body = {f"{module}:input": input_data} if input_data else None
+ try:
+ response = requests_workaround_post(
+ url,
+ json=body,
+ headers=self.headers,
+ auth=self.auth,
+ verify=False
+ )
+ except requests.exceptions.HTTPError as e:
+ # requests only reports the status line, the reason the
+ # server gives is in the body
+ raise Exception(f"{e}: {e.response.text}") from None
+
+ if not response.content:
+ return {}
+
+ data = response.json()
+ output = data.get(f"{module}:output", data)
+
+ return {k: str(v) for k, v in output.items()}
+
def get_dict(self, xpath=None, parse=True):
"""NETCONF compat function, just wraps get_data"""
return self.get_data(xpath, parse)
diff --git a/test/infamy/ssh.py b/test/infamy/ssh.py
index 4714ac934..0055e1261 100644
--- a/test/infamy/ssh.py
+++ b/test/infamy/ssh.py
@@ -4,6 +4,11 @@
from . import env, netutil, util
+# ssh(1) itself failed, the remote command never ran or its exit
+# status could not be collected
+TRANSPORT_ERROR = 255
+
+
@dataclass
class Location:
host: str
@@ -81,6 +86,8 @@ def __str__(self):
return nm + " [SSH]"
def _mangle_subprocess_args(self, args, kwargs):
+ loglevel = kwargs.pop("loglevel", "ERROR")
+
if not args:
return None
@@ -95,7 +102,7 @@ def _mangle_subprocess_args(self, args, kwargs):
args[0] = ["ssh",
"-oStrictHostKeyChecking no",
"-oUserKnownHostsFile /dev/null",
- "-oLogLevel QUIET",
+ f"-oLogLevel {loglevel}",
f"-l{self.location.username}",
self.location.host] + args[0]
@@ -108,7 +115,32 @@ def run(self, *args, **kwargs):
args, kwargs = self._mangle_subprocess_args(args, kwargs)
return subprocess.run(*args, **kwargs)
+ def run_retry(self, *args, tries=3, **kwargs):
+ """Like run(), but retry transport failures (ssh exit code 255)
+
+ Waits for the SSH port between attempts. Only for idempotent
+ commands, and stdout must not be a file object, it is not
+ rewound between attempts.
+ """
+ for attempt in range(1, tries + 1):
+ result = self.run(*args, **kwargs)
+ if result.returncode != TRANSPORT_ERROR:
+ return result
+
+ print(f"{self}: ssh transport failure, attempt {attempt}/{tries}")
+ if attempt < tries:
+ util.until(lambda: ssh_syn(self.location.host,
+ self.location.port), attempts=30)
+
+ return result
+
def runsh(self, script, *args, **kwargs):
+ """Run a script, with stderr merged into the captured stdout
+
+ Callers parse that stdout, so ssh(1) stays quiet here, use
+ run() to see transport errors.
+ """
+ kwargs.setdefault("loglevel", "QUIET")
return self.run("/bin/sh", text=True, input=script,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, *args, **kwargs)
diff --git a/test/infamy/transport.py b/test/infamy/transport.py
index fec58fd5c..823a5a861 100644
--- a/test/infamy/transport.py
+++ b/test/infamy/transport.py
@@ -55,6 +55,16 @@ def reboot(self):
def call_dict(self, module, call):
pass
+ @abstractmethod
+ def rpc_output(self, module, rpc, input_data=None):
+ """Call RPC `module:rpc`, returning output leaves as a dict.
+
+ `input_data`, if supplied, is a dict of input leaves. Values
+ are strings on both transports, an RPC without output returns
+ an empty dict.
+ """
+ pass
+
@abstractmethod
def call_action(self, xpath, input_data=None):
"""Invoke a YANG action at `xpath`.