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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "base64.h" +#include "core.h" + +#define SUPPORT_TOOL "/usr/sbin/support" +#define SUPPORT_GPG "/usr/bin/gpg" +#define SUPPORT_LOG_SEC 5 + +/* Where the support command collects too, so 'support clean' covers + * what is left behind. RAM when the persistent storage is unusable. */ +#define SUPPORT_DIR "/var/lib/support" +#define SUPPORT_TMP "/tmp" + +/* Held in RAM raw, base64 encoded, in sysrepo, and in netopeer2 or + * rousette. Bigger archives are left for out-of-band fetching. */ +#define SUPPORT_LIMIT (16 * 1024 * 1024) + +static unsigned char *slurp(const char *fn, size_t len) +{ + unsigned char *buf; + FILE *fp; + + buf = malloc(len); + if (!buf) + return NULL; + + fp = fopen(fn, "r"); + if (!fp) { + free(buf); + return NULL; + } + + if (fread(buf, 1, len, fp) != len) { + fclose(fp); + free(buf); + return NULL; + } + + fclose(fp); + return buf; +} + +/* libyang rejects the line feeds base64_encode() wraps with */ +static void strip_lf(unsigned char *str) +{ + unsigned char *src = str, *dst = str; + + while (*src) { + if (*src != '\n') + *dst++ = *src; + src++; + } + + *dst = 0; +} + +/* Same mode as the tool gives it, our umask is stricter */ +static const char *workdir(void) +{ + if (!mkdir(SUPPORT_DIR, 0755)) + chmod(SUPPORT_DIR, 0755); + else if (errno != EEXIST) + return SUPPORT_TMP; + + if (access(SUPPORT_DIR, W_OK)) + return SUPPORT_TMP; + + return SUPPORT_DIR; +} + +/* The tool names both the archive and the log of a failed run, the + * extension is what tells them apart */ +static int find_file(const char *dir, int log, char *path, size_t len) +{ + struct dirent *d; + int found = -1; + DIR *dp; + + dp = opendir(dir); + if (!dp) + return -1; + + while ((d = readdir(dp))) { + char *ext = strrchr(d->d_name, '.'); + int is_log = ext && !strcmp(ext, ".log"); + + if (d->d_name[0] == '.' || is_log != log) + continue; + + snprintf(path, len, "%s/%s", dir, d->d_name); + found = 0; + break; + } + + closedir(dp); + return found; +} + +/* Move out of the private directory, under the canonical name the + * tool gave it, before the directory is removed. rename() replaces a + * symlink squatting the name rather than following it. */ +static int keep(const char *dir, char *path, size_t len) +{ + const char *name = strrchr(path, '/'); + const char *slash = strrchr(dir, '/'); + char dst[PATH_MAX]; + + if (!name || !slash) + return -1; + + snprintf(dst, sizeof(dst), "%.*s%s", (int)(slash - dir), dir, name); + if (rename(path, dst)) + return -1; + + snprintf(path, len, "%s", dst); + return 0; +} + +/* The tool says on stderr why it gave up, on an "Error:" line. The + * rest is noise, and a full filesystem truncates it. */ +static void reason(const char *dir, char *buf, size_t len) +{ + char line[256], path[PATH_MAX]; + FILE *fp; + + buf[0] = 0; + snprintf(path, sizeof(path), "%s/.stderr", dir); + fp = fopen(path, "r"); + if (!fp) + return; + + while (fgets(line, sizeof(line), fp)) { + line[strcspn(line, "\n")] = 0; + if (!strncmp(line, "Error: ", 7)) + snprintf(buf, len, "%s", line + 7); + } + + fclose(fp); +} + +static int rm_cb(const char *path, const struct stat *st, int flag, struct FTW *ftw) +{ + (void)st; + (void)ftw; + + if (flag == FTW_DP || flag == FTW_D) + return rmdir(path); + + return unlink(path); +} + +/* A tool killed mid-run leaves its collection directory behind */ +static void cleanup(const char *dir) +{ + if (nftw(dir, rm_cb, 16, FTW_DEPTH | FTW_PHYS)) + WARN("Cannot remove %s: %s", dir, strerror(errno)); +} + +/* The event session runs as confd itself, the caller is only known + * from the originator data: netopeer2 pushes the NETCONF session id + * and then the username, rousette pushes nothing */ +static const char *rpc_user(sr_session_ctx_t *session, const char **via) +{ + const char *orig = sr_session_get_orig_name(session); + const void *data; + uint32_t size; + + *via = orig && orig[0] ? orig : "local session"; + + if (orig && !strcmp(orig, "netopeer2") && + !sr_session_get_orig_data(session, 1, &size, &data) && size) + return data; + + return NULL; +} + +static int add_str(sr_val_t **output, size_t *cnt, const char *path, + const char *leaf, sr_val_type_t type, const char *val) +{ + if (sr_realloc_values(*cnt, *cnt + 1, output)) + return -1; + + /* Count it now, the caller frees *cnt values on failure */ + (*cnt)++; + + if (sr_val_build_xpath(&(*output)[*cnt - 1], "%s/%s", path, leaf)) + return -1; + + return sr_val_set_str_data(&(*output)[*cnt - 1], type, val) ? -1 : 0; +} + +static int add_uint32(sr_val_t **output, size_t *cnt, const char *path, + const char *leaf, uint32_t val) +{ + if (sr_realloc_values(*cnt, *cnt + 1, output)) + return -1; + + (*cnt)++; + + if (sr_val_build_xpath(&(*output)[*cnt - 1], "%s/%s", path, leaf)) + return -1; + + (*output)[*cnt - 1].type = SR_UINT32_T; + (*output)[*cnt - 1].data.uint32_val = val; + + return 0; +} + +/* Drop the archive, the caller gets no filename and cannot clean up */ +static int fail(sr_session_ctx_t *session, sr_val_t **output, size_t cnt, + const char *msg, const char *dir) +{ + sr_free_values(*output, cnt); + *output = NULL; + + if (dir) + cleanup(dir); + + sr_session_set_netconf_error(session, "application", "operation-failed", + NULL, NULL, msg, 0); + return SR_ERR_OPERATION_FAILED; +} + +static int rpc_collect(sr_session_ctx_t *session, uint32_t sub_id, const char *path, + const sr_val_t *input, const size_t input_cnt, sr_event_t event, + unsigned request_id, sr_val_t **output, size_t *output_cnt, + void *priv) +{ + char dir[PATH_MAX], file[PATH_MAX], msg[PATH_MAX + 256], why[200]; + const char *password = NULL; + const char *user, *via, *work; + unsigned char *raw, *b64; + struct stat st; + size_t cnt = 0; + FILE *pp; + int rc; + + /* Abort follows a successful callback the originator stopped + * waiting for, nothing to undo but the archive is gone */ + if (event != SR_EV_RPC) { + NOTE("Support data collection outlived the RPC timeout, archive discarded."); + return SR_ERR_OK; + } + + for (size_t i = 0; i < input_cnt; i++) { + char *leaf = strrchr(input[i].xpath, '/'); + + if (leaf && !strcmp(leaf, "/password") && + input[i].data.string_val[0]) + password = input[i].data.string_val; + } + + user = rpc_user(session, &via); + AUDIT("Support data collection requested by user \"%s\" over %s.", + user ?: "unknown", via); + + /* The tool reads it as one line */ + if (password && strpbrk(password, "\r\n")) { + sr_session_set_netconf_error(session, "application", "invalid-value", + NULL, NULL, "password must be a single " + "line", 0); + return SR_ERR_INVAL_ARG; + } + + if (password && access(SUPPORT_GPG, X_OK)) { + sr_session_set_netconf_error(session, "application", "operation-failed", + NULL, NULL, "gpg is not available on " + "this device", 0); + return SR_ERR_OPERATION_FAILED; + } + + work = workdir(); + + /* What earlier calls left behind, the caller cannot clean up */ + systemf(SUPPORT_TOOL " --work-dir %s clean >/dev/null 2>&1", work); + + snprintf(dir, sizeof(dir), "%s/support-rpc-XXXXXX", work); + if (!mkdtemp(dir)) { + ERROR("Cannot create %s: %s", dir, strerror(errno)); + return SR_ERR_INTERNAL; + } + + /* The password goes on stdin, never in the process list, which is + * also why the tool gets a directory of its own instead of us + * reading the archive name off its stdout */ + pp = popenf("w", SUPPORT_TOOL " --work-dir %s collect --log-sec %u -o %s%s 2>%s/.stderr", + dir, SUPPORT_LOG_SEC, dir, password ? " -p" : "", dir); + if (!pp) { + ERROR("Failed running %s: %s", SUPPORT_TOOL, strerror(errno)); + cleanup(dir); + return SR_ERR_INTERNAL; + } + + if (password) + fprintf(pp, "%s\n", password); + + rc = pclose(pp); + if (rc == -1 || !WIFEXITED(rc) || WEXITSTATUS(rc)) { + if (rc != -1 && WIFEXITED(rc)) + ERROR("Support data collection failed, exit code %d", WEXITSTATUS(rc)); + else + ERROR("Support data collection failed: %s", rc == -1 ? strerror(errno) : "killed"); + + reason(dir, why, sizeof(why)); + if (!find_file(dir, 1, file, sizeof(file)) && !keep(dir, file, sizeof(file))) + snprintf(msg, sizeof(msg), "Support data collection failed%s%s, see %s", + why[0] ? ": " : "", why, file); + else + snprintf(msg, sizeof(msg), "Support data collection failed%s%s", + why[0] ? ": " : "", why); + + return fail(session, output, cnt, msg, dir); + } + + if (find_file(dir, 0, file, sizeof(file)) || stat(file, &st)) { + ERROR("No support archive in %s: %s", dir, strerror(errno)); + return fail(session, output, cnt, "Support archive is missing", dir); + } + + if (add_uint32(output, &cnt, path, "size", st.st_size)) + return fail(session, output, cnt, "Out of memory", dir); + + if (st.st_size > SUPPORT_LIMIT) { + NOTE("Support archive %s is %jd bytes, too large to return inline.", + file, (intmax_t)st.st_size); + + if (keep(dir, file, sizeof(file))) + return fail(session, output, cnt, "Cannot keep support archive", dir); + cleanup(dir); + + if (add_str(output, &cnt, path, "filename", SR_STRING_T, file)) + return fail(session, output, cnt, "Out of memory", NULL); + + *output_cnt = cnt; + return SR_ERR_OK; + } + + raw = slurp(file, st.st_size); + if (!raw) { + ERROR("Cannot read support archive %s: %s", file, strerror(errno)); + return fail(session, output, cnt, "Cannot read support archive", dir); + } + + b64 = base64_encode(raw, st.st_size, NULL); + free(raw); + if (!b64) + return fail(session, output, cnt, "Cannot encode support archive", dir); + + strip_lf(b64); + rc = add_str(output, &cnt, path, "data", SR_BINARY_T, (char *)b64); + free(b64); + if (rc) + return fail(session, output, cnt, "Out of memory", dir); + + /* The caller has it now */ + cleanup(dir); + + *output_cnt = cnt; + return SR_ERR_OK; +} + +int support_rpc_init(struct confd *confd) +{ + int rc = 0; + + REGISTER_RPC(confd->session, "/infix-system:support-collect", + rpc_collect, NULL, &confd->sub); +fail: + return rc; +} diff --git a/src/confd/yang/confd.inc b/src/confd/yang/confd.inc index 62ba37b9d..008965499 100644 --- a/src/confd/yang/confd.inc +++ b/src/confd/yang/confd.inc @@ -43,7 +43,7 @@ MODULES=( "infix-firewall-icmp-types@2025-04-26.yang" "infix-meta@2025-12-10.yang" "infix-services@2026-06-17.yang" - "infix-system@2026-09-08.yang" + "infix-system@2026-09-11.yang" "ieee802-ethernet-interface@2025-09-10.yang" "ieee802-ethernet-phy-type@2025-09-10.yang" "infix-ethernet-interface@2026-05-21.yang" diff --git a/src/confd/yang/confd/infix-system.yang b/src/confd/yang/confd/infix-system.yang index c077b0fae..c670fe27c 100644 --- a/src/confd/yang/confd/infix-system.yang +++ b/src/confd/yang/confd/infix-system.yang @@ -26,12 +26,22 @@ module infix-system { prefix infix-schedule; } + import ietf-netconf-acm { + prefix nacm; + reference + "RFC 8341: Network Configuration Access Control Model"; + } + include infix-system-software; organization "KernelKit"; contact "kernelkit@googlegroups.com"; description "Infix augments and deviations to ietf-system."; + revision 2026-09-11 { + description "Add support-collect RPC, returns a support archive inline."; + reference "internal"; + } revision 2026-09-08 { description "Add /system/advanced, for low-level system customization: - rc.ds: user scripts run once at boot, extracted from the @@ -926,4 +936,61 @@ module infix-system { type infix-sys:crypt-hash; } } + + rpc support-collect { + nacm:default-deny-all; + description + "Collect support data and return the resulting archive. + + The archive holds system and kernel logs, running and + operational configuration, and network and hardware state. + Private keys, password hashes and other secrets are removed from + the configuration first. + + The archive comes back in 'data', or is left on the device with + its path in 'filename' when it is too large to return inline. + Nothing else is kept on the device after a successful call. If + collection fails, the error says why and names a log with the + details."; + input { + leaf password { + type string { + length "1..255"; + } + description + "Encrypt the archive with GPG, using this password, a single + line. The management session is already encrypted, this is + for passing the archive on to someone else afterwards. Fails + when the device has no gpg."; + } + } + output { + leaf size { + type uint32; + units bytes; + description "Size of the archive, before base64 encoding."; + } + leaf data { + type binary; + description + "The archive itself. Omitted when it is larger than the + inline limit of 16 MiB, see 'filename'. + + Over NETCONF this is a single 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) to receive an archive + above about 7.5 MB. RESTCONF returns JSON and has no such + limit."; + } + leaf filename { + type string; + description + "Path to the archive on the device, in /var/lib/support. + Present only when the archive was too large to return + inline; the caller then fetches it."; + } + } + } + } diff --git a/src/confd/yang/confd/infix-system@2026-09-08.yang b/src/confd/yang/confd/infix-system@2026-09-11.yang similarity index 100% rename from src/confd/yang/confd/infix-system@2026-09-08.yang rename to src/confd/yang/confd/infix-system@2026-09-11.yang diff --git a/src/support/LICENSE b/src/support/LICENSE new file mode 100644 index 000000000..f9b6d6c12 --- /dev/null +++ b/src/support/LICENSE @@ -0,0 +1,13 @@ +Copyright (c) 2024 The KernelKit Authors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/src/bin/support b/src/support/support similarity index 71% rename from src/bin/support rename to src/support/support index 805eca8e1..1d788dfff 100755 --- a/src/bin/support +++ b/src/support/support @@ -17,6 +17,10 @@ cmd_collect() # Default values LOG_TAIL_SEC=30 PASSWORD="" + OUTPUT="" + REDACT=1 + CMD_TIMEOUT=30 + HOOK_TIMEOUT=120 # Parse options while [ $# -gt 0 ]; do @@ -40,7 +44,7 @@ cmd_collect() old_stty=$(stty -g 2>/dev/null) stty -echo 2>/dev/null || true printf "Enter encryption password: " >&2 - read -r PASSWORD + IFS= read -r PASSWORD echo "" >&2 # Restore terminal settings if [ -n "$old_stty" ]; then @@ -55,14 +59,32 @@ cmd_collect() shift fi ;; + --output|-o) + if [ -z "$2" ]; then + echo "Error: --output requires a file or directory" >&2 + exit 1 + fi + OUTPUT="$2" + shift 2 + ;; + --no-redact|-R) + REDACT=0 + shift + ;; *) echo "Error: Unknown option '$1'" >&2 - echo "Usage: $prognm collect [-s N] [-p PASSWORD]" >&2 + echo "Usage: $prognm collect [-s N] [-p PASSWORD] [-o FILE] [-R]" >&2 exit 1 ;; esac done + # Collection cd's to WORK_DIR later, resolve relative paths now + case "$OUTPUT" in + ""|/*) ;; + *) OUTPUT="$PWD/$OUTPUT" ;; + esac + # If WORK_DIR not set globally, try /var/lib/support first (more space, # persistent across user sessions). Fall back to $HOME if we can't create/write there if [ -z "$WORK_DIR" ]; then @@ -98,25 +120,64 @@ cmd_collect() COLLECT_DIR="${WORK_DIR}/support-$(hostname -s)-$(date -Iseconds)" EXEC_LOG="${COLLECT_DIR}/collection.log" - # Cleanup on exit + # Cleanup on exit, the log is kept if the run failed cleanup() { - echo "[$(date -Iseconds)] Cleanup called (signal: ${1:-EXIT})" >> "${EXEC_LOG}" 2>&1 || echo "[$(date -Iseconds)] Cleanup called (signal: ${1:-EXIT})" >&2 + rc=$? if [ -d "${COLLECT_DIR}" ]; then - echo "[$(date -Iseconds)] Removing collection directory: ${COLLECT_DIR}" >> "${EXEC_LOG}" 2>&1 || echo "[$(date -Iseconds)] Removing: ${COLLECT_DIR}" >&2 + if [ "$rc" -ne 0 ] && [ -f "${EXEC_LOG}" ]; then + # mv, a full filesystem is a likely reason we are here + if mv "${EXEC_LOG}" "${COLLECT_DIR}.log" 2>/dev/null; then + chmod 600 "${COLLECT_DIR}.log" + echo "Collection failed (exit ${rc}), log saved to ${COLLECT_DIR}.log" >&2 + fi + fi rm -rf "${COLLECT_DIR}" - else - echo "[$(date -Iseconds)] Collection directory already gone: ${COLLECT_DIR}" >> "${EXEC_LOG}" 2>&1 || echo "[$(date -Iseconds)] Already gone: ${COLLECT_DIR}" >&2 fi + [ -n "${GNUPG_TMP}" ] && rm -rf "${GNUPG_TMP}" } - trap cleanup EXIT INT TERM + trap cleanup EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + + # Plain mkdir below, two collections in the same second must not + # share a directory, the first to finish removes it + if ! mkdir -p "${WORK_DIR}" 2>/dev/null; then + echo "Error: Cannot create work directory: ${WORK_DIR}" >&2 + exit 1 + fi - # Create collection directory - if ! mkdir -p "${COLLECT_DIR}"; then - echo "Error: Cannot create collection directory: ${COLLECT_DIR}" >&2 - echo " Check permissions for ${WORK_DIR}" >&2 + # du says nothing about how well the logs compress, and they are + # what a collection is made of, so measure them. They are stored + # once in the collection and again in the archive beside it, on many + # devices in the same partition as /cfg. + logs=$( { tar czf - -C / var/log var/crash 2>/dev/null || true; } | wc -c ) + need=$((logs / 512 + 2048)) + set -- $(df -Pk "${WORK_DIR}" 2>/dev/null | awk 'NR == 2 { print $4, $6 }') + if [ -n "$1" ] && [ "$1" -lt "$need" ]; then + echo "Error: $2 has $(($1 / 1024)) MB free, collection needs about $(((need + 1023) / 1024)) MB" >&2 exit 1 fi + n=0 + while ! mkdir "${COLLECT_DIR}" 2>/dev/null; do + n=$((n + 1)) + if [ "$n" -gt 9 ]; then + echo "Error: Cannot create collection directory: ${COLLECT_DIR}" >&2 + echo " Check permissions for ${WORK_DIR}" >&2 + exit 1 + fi + COLLECT_DIR="${WORK_DIR}/support-$(hostname -s)-$(date -Iseconds)-${n}" + EXEC_LOG="${COLLECT_DIR}/collection.log" + done + + # Bound every command, a wedged driver must not stall collection + if command -v timeout >/dev/null 2>&1; then + TMO="timeout -k 5 ${CMD_TIMEOUT}" + HOOK_TMO="timeout -k 5 ${HOOK_TIMEOUT}" + else + TMO="" + HOOK_TMO="" + fi # Helper function to run commands with output to specific file collect() @@ -127,13 +188,19 @@ cmd_collect() mkdir -p "${COLLECT_DIR}/$(dirname "$output_file")" echo "[$(date -Iseconds)] Collecting: $cmd_desc -> ${output_file}" >> "${EXEC_LOG}" 2>&1 - if "$@" > "${COLLECT_DIR}/${output_file}" 2>> "${EXEC_LOG}"; then + if $TMO "$@" > "${COLLECT_DIR}/${output_file}" 2>> "${EXEC_LOG}"; then echo "[$(date -Iseconds)] Success: ${output_file}" >> "${EXEC_LOG}" 2>&1 else exit_code=$? echo "[$(date -Iseconds)] Failed (exit ${exit_code}): ${output_file}" >> "${EXEC_LOG}" 2>&1 - # Create placeholder file indicating failure - echo "Command failed with exit code ${exit_code}: $cmd_desc" > "${COLLECT_DIR}/${output_file}" + # Create placeholder file indicating failure. busybox + # timeout signals the program, giving 128+SIG, not 124. + if [ "${exit_code}" -eq 143 ] || [ "${exit_code}" -eq 137 ] || \ + [ "${exit_code}" -eq 124 ]; then + echo "Command timed out after ${CMD_TIMEOUT}s: $cmd_desc" > "${COLLECT_DIR}/${output_file}" + else + echo "Command failed with exit code ${exit_code}: $cmd_desc" > "${COLLECT_DIR}/${output_file}" + fi fi } @@ -145,6 +212,13 @@ cmd_collect() echo "Collection directory: ${COLLECT_DIR}" >> "${EXEC_LOG}" echo "" >> "${EXEC_LOG}" + # /var/log alone can be tens of megabytes + avail=$(df -k "${WORK_DIR}" 2>/dev/null | awk 'NR==2 {print $4}') + if [ -n "$avail" ] && [ "$avail" -lt 20480 ] 2>/dev/null; then + echo "Warning: only ${avail} KiB available in ${WORK_DIR}, collection may be incomplete" >&2 + echo "Available space in ${WORK_DIR}: ${avail} KiB" >> "${EXEC_LOG}" + fi + # Inform user that collection is starting (to stderr for SSH visibility) echo "Starting support data collection from $(hostname)..." >&2 echo "Collecting to: ${WORK_DIR}" >&2 @@ -155,21 +229,18 @@ cmd_collect() collect hostname.txt hostname collect uptime.txt uptime - # Configuration files - collect running-config.json copy running - collect operational-config.json copy operational + # Configuration, copy -r drops the secrets, see 'copy -h' + CP="" + [ "$REDACT" -eq 1 ] && CP="-r" + collect running-config.json copy $CP running + collect operational-config.json copy $CP operational + collect startup-config.cfg copy $CP startup # Sysrepo YANG modules if command -v sysrepoctl >/dev/null 2>&1; then collect sysrepo-modules.txt sysrepoctl -l fi - # Startup config (may not exist on first boot) - if [ -f /cfg/startup-config.cfg ]; then - cp /cfg/startup-config.cfg "${COLLECT_DIR}/startup-config.cfg" 2>> "${EXEC_LOG}" - else - echo "No startup-config.cfg found" > "${COLLECT_DIR}/startup-config.cfg" - fi # System logs and runtime data if [ -d /var/log ]; then @@ -344,9 +415,6 @@ cmd_collect() collect system/pstree.txt ps fax fi - # Environment and versions - collect system/env.txt env - # Network sockets if command -v netstat >/dev/null 2>&1; then collect system/netstat.txt netstat -tunlp @@ -357,7 +425,7 @@ cmd_collect() for script in $(find "/etc/support.d" -type f -executable 2>/dev/null | sort); do echo "[$(date -Iseconds)] Running ${script}..." >> "${EXEC_LOG}" 2>&1 - if "${script}" "${COLLECT_DIR}" >> "${EXEC_LOG}" 2>&1; then + if $HOOK_TMO "${script}" "${COLLECT_DIR}" >> "${EXEC_LOG}" 2>&1; then echo "[$(date -Iseconds)] Success: ${script}" >> "${EXEC_LOG}" 2>&1 else exit_code=$? @@ -384,35 +452,90 @@ cmd_collect() echo "[$(date -Iseconds)] Creating archive from: $(basename "${COLLECT_DIR}")" >> "${EXEC_LOG}" 2>&1 # Check if password encryption is requested - if [ -n "$PASSWORD" ]; then - if ! command -v gpg >/dev/null 2>&1; then - echo "Error: --password specified but gpg is not available" >&2 + if [ -n "$PASSWORD" ] && ! command -v gpg >/dev/null 2>&1; then + echo "Error: --password specified but gpg is not available" >&2 + exit 1 + fi + + # The passphrase goes to gpg on a private descriptor, never on its + # command line where any local process could read it. gpg gets a + # throwaway home so its agent and keyrings stay out of /root. + GNUPG_TMP="" + archive() + { + if [ -n "$PASSWORD" ]; then + echo "Encrypting with GPG..." >&2 + echo "[$(date -Iseconds)] Starting tar with GPG encryption" >> "${EXEC_LOG}" 2>&1 + GNUPG_TMP=$(mktemp -d "${WORK_DIR}/.gnupg-XXXXXX") || return 1 + tar czf - "$(basename "${COLLECT_DIR}")" 2>> "${EXEC_LOG}" | \ + GNUPGHOME="$GNUPG_TMP" gpg --batch --yes --pinentry-mode loopback \ + --passphrase-fd 3 -c 2>> "${EXEC_LOG}" 3</dev/null || true + return $rc + else + echo "[$(date -Iseconds)] Starting tar (no encryption)" >> "${EXEC_LOG}" 2>&1 + tar czf - "$(basename "${COLLECT_DIR}")" 2>> "${EXEC_LOG}" + fi + } + + # A directory gets the canonical name, same for every front end + if [ -d "$OUTPUT" ]; then + OUTPUT="${OUTPUT%/}/$(basename "${COLLECT_DIR}").tar.gz" + if [ -n "$PASSWORD" ]; then + OUTPUT="${OUTPUT}.gpg" + fi + fi + + # The collection stays on disk while the archive is written beside + # it, so the partition needs room for both. On many devices + # /var/lib shares its partition with /cfg. + if [ -n "$OUTPUT" ]; then + need=$(du -sk "${COLLECT_DIR}" | awk '{ print $1 }') + set -- $(df -Pk "$(dirname "$OUTPUT")" 2>/dev/null | awk 'NR == 2 { print $4, $6 }') + if [ -n "$1" ] && [ "$1" -lt "$((need + 512))" ]; then + echo "Error: $2 has $(($1 / 1024)) MB free, the archive needs about $(((need + 1023) / 1024)) MB" >&2 exit 1 fi - echo "Encrypting with GPG..." >&2 - echo "[$(date -Iseconds)] Starting tar with GPG encryption" >> "${EXEC_LOG}" 2>&1 - tar czf - "$(basename "${COLLECT_DIR}")" 2>> "${EXEC_LOG}" | \ - gpg --batch --yes --passphrase "$PASSWORD" --pinentry-mode loopback -c 2>> "${EXEC_LOG}" + fi + + if [ -n "$OUTPUT" ]; then + # Archives hold password hashes and keys, keep them private. + # umask only covers creation, so drop any existing file first. + rm -f "$OUTPUT" + if ! (umask 077; : > "$OUTPUT"); then + echo "[$(date -Iseconds)] ERROR: Cannot create ${OUTPUT}" >> "${EXEC_LOG}" 2>&1 + echo "Error: Cannot create output file ${OUTPUT}" >&2 + exit 1 + fi + archive > "$OUTPUT" tar_exit=$? - echo "[$(date -Iseconds)] tar+gpg pipeline exit code: $tar_exit" >> "${EXEC_LOG}" 2>&1 + else + archive + tar_exit=$? + fi + echo "[$(date -Iseconds)] tar exit code: $tar_exit" >> "${EXEC_LOG}" 2>&1 + + if [ -n "$PASSWORD" ]; then echo "" >&2 echo "WARNING: Remember to share the encryption password out-of-band!" >&2 echo " Do not send it in the same email as the encrypted file." >&2 - if [ $tar_exit -ne 0 ]; then - echo "[$(date -Iseconds)] ERROR: tar+gpg failed with exit code $tar_exit" >> "${EXEC_LOG}" 2>&1 - exit $tar_exit - fi - else - echo "[$(date -Iseconds)] Starting tar (no encryption)" >> "${EXEC_LOG}" 2>&1 - tar czf - "$(basename "${COLLECT_DIR}")" 2>> "${EXEC_LOG}" - tar_exit=$? - echo "[$(date -Iseconds)] tar exit code: $tar_exit" >> "${EXEC_LOG}" 2>&1 - if [ $tar_exit -ne 0 ]; then - echo "[$(date -Iseconds)] ERROR: tar failed with exit code $tar_exit" >> "${EXEC_LOG}" 2>&1 - exit $tar_exit - fi fi + + if [ $tar_exit -ne 0 ]; then + echo "[$(date -Iseconds)] ERROR: archive failed with exit code $tar_exit" >> "${EXEC_LOG}" 2>&1 + [ -n "$OUTPUT" ] && rm -f "$OUTPUT" + exit $tar_exit + fi + echo "[$(date -Iseconds)] Archive creation completed successfully" >> "${EXEC_LOG}" 2>&1 + + # With -o the path is the only thing on stdout + if [ -n "$OUTPUT" ]; then + echo "$OUTPUT" + fi } cmd_clean() @@ -479,6 +602,18 @@ cmd_clean() fi done + # Archives and logs left behind by 'collect --output' + find "$search_dir" -maxdepth 1 -type f \ + \( -name "support-*-20*.log" -o -name "support-*-20*.tar.gz*" \) \ + -mtime "+${days}" 2>/dev/null | while IFS= read -r file; do + if [ "$dry_run" -eq 1 ]; then + echo "Would remove: $file" + else + echo "Removing: $file" + rm -f "$file" + fi + done + # Count directories found in this location count=$(find "$search_dir" -maxdepth 1 -type d -name "support-*-20*" -mtime "+${days}" 2>/dev/null | wc -l) total_count=$((total_count + count)) @@ -512,19 +647,26 @@ usage() echo "" echo "Options for collect:" echo " -s, --log-sec SEC Tail /var/log/messages for SEC seconds (default: 30)" + echo " -o, --output FILE Write archive to FILE instead of stdout, printing" + echo " its path. A directory gets the canonical name" + echo " support-HOST-DATE.tar.gz[.gpg]" echo " -p, --password [PASS] Encrypt output with GPG. If PASS is omitted, prompts" echo " interactively or reads from stdin, so possible to do" echo " echo "\$MYSECRET" | ... (recommended for security)" + echo " -R, --no-redact Keep private keys, password hashes and other secrets" + echo " in the collected configuration, see 'copy -h'" echo "" echo "Options for clean:" echo " -n, --dry-run Show what would be deleted without deleting" echo " -d, --days N Remove directories older than N days (default: 7)" echo "" echo "Examples:" + echo " sudo $prognm collect -o /var/lib/support" echo " sudo $prognm collect > support-data.tar.gz" echo " sudo $prognm collect -p > support-data.tar.gz.gpg" echo " sudo $prognm collect --password mypass > support-data.tar.gz.gpg" echo " sudo $prognm --work-dir /tmp/ram collect > support-data.tar.gz" + echo " sudo $prognm collect -o /var/lib/support" echo " ssh admin@device 'sudo $prognm collect' > support-data.tar.gz" echo " $prognm -u collect > support-data.tar.gz (degraded)" echo " sudo $prognm clean --dry-run" diff --git a/src/webui/internal/handlers/support_bundle_test.go b/src/webui/internal/handlers/support_bundle_test.go new file mode 100644 index 000000000..25c267d5d --- /dev/null +++ b/src/webui/internal/handlers/support_bundle_test.go @@ -0,0 +1,146 @@ +package handlers + +import ( + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "infix/webui/internal/restconf" +) + +// fakeSupportRPC serves infix-system:support-collect, recording the input +// it received and answering with reply. +func fakeSupportRPC(t *testing.T, reply string, status int) (*httptest.Server, *map[string]any) { + t.Helper() + var got map[string]any + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/operations/infix-system:support-collect" || r.Method != http.MethodPost { + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + http.Error(w, "unexpected", http.StatusNotFound) + return + } + if user, pass, ok := r.BasicAuth(); !ok || user != "admin" || pass != "secret" { + t.Errorf("credentials not forwarded: %q %q %v", user, pass, ok) + } + body, _ := io.ReadAll(r.Body) + got = nil + if len(body) > 0 { + if err := json.Unmarshal(body, &got); err != nil { + t.Errorf("input is not JSON: %v", err) + } + } + w.Header().Set("Content-Type", "application/yang-data+json") + w.WriteHeader(status) + io.WriteString(w, reply) //nolint:errcheck + })) + t.Cleanup(srv.Close) + return srv, &got +} + +func supportRequest(srv *httptest.Server, form string) *httptest.ResponseRecorder { + h := &SystemHandler{RC: restconf.NewClient(srv.URL, true)} + req := httptest.NewRequest(http.MethodPost, "/maintenance/support-bundle", strings.NewReader(form)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req = req.WithContext(restconf.ContextWithCredentials(req.Context(), + restconf.Credentials{Username: "admin", Password: "secret"})) + rec := httptest.NewRecorder() + h.SupportBundle(rec, req) + return rec +} + +func TestSupportBundleDownloadsArchive(t *testing.T) { + archive := []byte("\x1f\x8b\x08not really gzip") + reply := `{"infix-system:output":{"size":` + strconv.Itoa(len(archive)) + `,"data":"` + + base64.StdEncoding.EncodeToString(archive) + `"}}` + srv, got := fakeSupportRPC(t, reply, http.StatusOK) + + rec := supportRequest(srv, "") + + if rec.Code != http.StatusOK { + t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) + } + if *got != nil { + t.Errorf("no password given, but input sent: %v", *got) + } + if rec.Body.String() != string(archive) { + t.Errorf("body is not the decoded archive: %q", rec.Body.String()) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/gzip" { + t.Errorf("Content-Type %q", ct) + } + cd := rec.Header().Get("Content-Disposition") + if !strings.HasPrefix(cd, `attachment; filename="support-`) || !strings.HasSuffix(cd, `.tar.gz"`) { + t.Errorf("Content-Disposition %q", cd) + } +} + +func TestSupportBundlePasswordEncrypts(t *testing.T) { + reply := `{"infix-system:output":{"size":3,"data":"` + base64.StdEncoding.EncodeToString([]byte("gpg")) + `"}}` + srv, got := fakeSupportRPC(t, reply, http.StatusOK) + + rec := supportRequest(srv, "password=hunter2") + + if rec.Code != http.StatusOK { + t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) + } + input, _ := (*got)["infix-system:input"].(map[string]any) + if input["password"] != "hunter2" { + t.Errorf("password not passed to the RPC: %v", *got) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/pgp-encrypted" { + t.Errorf("Content-Type %q", ct) + } + if !strings.HasSuffix(rec.Header().Get("Content-Disposition"), `.tar.gz.gpg"`) { + t.Errorf("Content-Disposition %q", rec.Header().Get("Content-Disposition")) + } +} + +func TestSupportBundleTooLarge(t *testing.T) { + reply := `{"infix-system:output":{"size":20000000,"filename":"/var/lib/support/support-host-x.tar.gz"}}` + srv, _ := fakeSupportRPC(t, reply, http.StatusOK) + + rec := supportRequest(srv, "") + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "19 MB") || + !strings.Contains(rec.Body.String(), "/var/lib/support/support-host-x.tar.gz") { + t.Errorf("message does not say where the archive is: %q", rec.Body.String()) + } +} + +func TestSupportBundleReportsRPCError(t *testing.T) { + reply := `{"ietf-restconf:errors":{"error":[{"error-type":"application","error-tag":"operation-failed",` + + `"error-message":"gpg is not available on this device"}]}}` + srv, _ := fakeSupportRPC(t, reply, http.StatusInternalServerError) + + rec := supportRequest(srv, "password=x") + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "gpg is not available on this device") { + t.Errorf("server's reason not surfaced: %q", rec.Body.String()) + } +} + +func TestSupportBundleDeniedIsForbidden(t *testing.T) { + reply := `{"ietf-restconf:errors":{"error":[{"error-type":"application","error-tag":"access-denied",` + + `"error-message":"Access denied."}]}}` + srv, _ := fakeSupportRPC(t, reply, http.StatusForbidden) + + rec := supportRequest(srv, "") + + if rec.Code != http.StatusForbidden { + t.Fatalf("status %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "Access denied") { + t.Errorf("reason not surfaced: %q", rec.Body.String()) + } +} diff --git a/src/webui/internal/handlers/system.go b/src/webui/internal/handlers/system.go index 38da7e78a..fa07ddd1a 100644 --- a/src/webui/internal/handlers/system.go +++ b/src/webui/internal/handlers/system.go @@ -6,6 +6,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "html/template" "io" @@ -208,52 +209,65 @@ func (h *SystemHandler) Backup(w http.ResponseWriter, r *http.Request) { } } -// SupportBundle runs the on-device `support collect` tool and streams the -// resulting archive back as a download. The WebUI runs as root, so the -// collection is complete (dmesg, ethtool, etc.). An optional password -// encrypts the archive via the tool's GPG support and is fed on stdin so -// it never lands in the process list. +// SupportBundle collects a support archive with the support-collect RPC +// and streams it back as a download. The RPC runs with the logged-in +// user's credentials, so NACM decides who gets it. An optional password +// has the archive GPG encrypted on the device. // -// Collection emits nothing on stdout until it finishes (~50 s), then the -// whole archive at once. We buffer it and only commit response headers -// once the tool exits successfully, so a mid-collection failure becomes a -// clean 500 rather than a truncated download. --work-dir /tmp keeps the -// transient files in tmpfs; the tool cleans up after itself. +// The RPC returns nothing until the collection is done, up to a minute, +// so the response headers are committed only once the archive is in +// hand and a failure becomes a clean error rather than a truncated +// download. // POST /maintenance/support-bundle func (h *SystemHandler) SupportBundle(w http.ResponseWriter, r *http.Request) { - // Collection blocks ~50 s with no output, but the server's 15 s - // WriteTimeout would close the connection long before then (nginx - // then logs a 502 "upstream prematurely closed connection"). Push - // the write deadline out for this long-running download. + // The server's 15 s WriteTimeout would close the connection long + // before the RPC returns (nginx then logs a 502 "upstream prematurely + // closed connection"). Push the write deadline out. if err := http.NewResponseController(w).SetWriteDeadline(time.Now().Add(4 * time.Minute)); err != nil { log.Printf("support bundle: extend write deadline: %v", err) } password := r.FormValue("password") - encrypt := password != "" - - args := []string{"--work-dir", "/tmp", "collect"} ext, ctype := "tar.gz", "application/gzip" - if encrypt { - args = append(args, "-p") + var input any + if password != "" { + input = map[string]any{"infix-system:input": map[string]string{"password": password}} ext, ctype = "tar.gz.gpg", "application/pgp-encrypted" } + var reply struct { + Output struct { + Size uint32 `json:"size"` + Data []byte `json:"data"` + Filename string `json:"filename"` + } `json:"infix-system:output"` + } + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute) defer cancel() - cmd := exec.CommandContext(ctx, "/usr/sbin/support", args...) - if encrypt { - cmd.Stdin = strings.NewReader(password + "\n") - } - out, err := cmd.Output() - if err != nil { - stderr := "" - if ee, ok := err.(*exec.ExitError); ok { - stderr = strings.TrimSpace(string(ee.Stderr)) + if err := h.RC.CallRPC(ctx, "/operations/infix-system:support-collect", input, &reply); err != nil { + log.Printf("support bundle: %v", err) + msg, status := "Failed to collect support bundle", http.StatusInternalServerError + var re *restconf.Error + if errors.As(err, &re) { + if re.Message != "" { + msg += ": " + re.Message + } + if re.StatusCode == http.StatusForbidden { + status = re.StatusCode + } } - log.Printf("support bundle: %v: %s", err, stderr) - http.Error(w, "Failed to collect support bundle", http.StatusInternalServerError) + http.Error(w, msg, status) + return + } + + out := reply.Output + if len(out.Data) == 0 { + log.Printf("support bundle: %d bytes, too large to return inline, left in %s", out.Size, out.Filename) + http.Error(w, fmt.Sprintf("Support bundle is %d MB, too large to download here. "+ + "It is on the device as %s, fetch it with scp.", out.Size>>20, out.Filename), + http.StatusInternalServerError) return } @@ -265,8 +279,8 @@ func (h *SystemHandler) SupportBundle(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", ctype) w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", fname)) - w.Header().Set("Content-Length", fmt.Sprint(len(out))) - w.Write(out) //nolint:errcheck + w.Header().Set("Content-Length", fmt.Sprint(len(out.Data))) + w.Write(out.Data) //nolint:errcheck } // RestoreConfig accepts a multipart-uploaded JSON config file and applies it. diff --git a/src/webui/internal/restconf/client.go b/src/webui/internal/restconf/client.go index 6846baae5..23a7b1ef1 100644 --- a/src/webui/internal/restconf/client.go +++ b/src/webui/internal/restconf/client.go @@ -156,6 +156,51 @@ func (c *Client) PostJSON(ctx context.Context, path string, body any) error { return c.writeJSON(ctx, http.MethodPost, path, body) } +// CallRPC invokes a RESTCONF operation and decodes its output into output, +// nil for an operation without one. input, when not nil, is sent as the +// operation's input container. Only the context's deadline bounds the +// call, not the client's timeout for plain requests; support-collect runs +// for up to a minute. +func (c *Client) CallRPC(ctx context.Context, path string, input, output any) error { + if err := checkPath(path); err != nil { + return err + } + var body io.Reader + if input != nil { + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(input); err != nil { + return fmt.Errorf("encoding request body: %w", err) + } + body = &buf + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, body) + if err != nil { + return err + } + // Required by rousette on every POST, body or not + req.Header.Set("Content-Type", "application/yang-data+json") + req.Header.Set("Accept", "application/yang-data+json") + creds := CredentialsFromContext(ctx) + req.SetBasicAuth(creds.Username, creds.Password) + + hc := *c.httpClient + hc.Timeout = 0 + resp, err := hc.Do(req) + if err != nil { + return fmt.Errorf("restconf request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + return parseError(resp) + } + if output == nil || resp.StatusCode == http.StatusNoContent { + return nil + } + return json.NewDecoder(resp.Body).Decode(output) +} + // Put replaces a RESTCONF config resource with the given value. func (c *Client) Put(ctx context.Context, path string, body any) error { return c.writeJSON(ctx, http.MethodPut, path, body) diff --git a/src/webui/internal/restconf/errors.go b/src/webui/internal/restconf/errors.go index 129774ab9..a32813d59 100644 --- a/src/webui/internal/restconf/errors.go +++ b/src/webui/internal/restconf/errors.go @@ -101,7 +101,7 @@ func parseError(resp *http.Response) error { var parts []string for _, e := range errs { - msg := e.Message + msg := unwrap(e.Message) if msg == "" { msg = e.Tag } @@ -115,3 +115,23 @@ func parseError(resp *http.Response) error { re.Message = strings.Join(parts, "; ") return re } + +// unwrap strips rousette's framing of a sysrepo error, which quotes the +// same message twice: +// +// Internal server error due to sysrepo exception: Couldn't send RPC: +// SR_ERR_OPERATION_FAILED (SR_ERR_OPERATION_FAILED) NETCONF: +// application: operation-failed: +// +// Only the message after the NETCONF type and tag is of any use to a user. +func unwrap(msg string) string { + i := strings.LastIndex(msg, "NETCONF: ") + if i < 0 { + return msg + } + parts := strings.SplitN(msg[i+len("NETCONF: "):], ": ", 3) + if len(parts) < 3 { + return msg + } + return parts[2] +} diff --git a/src/webui/internal/restconf/errors_test.go b/src/webui/internal/restconf/errors_test.go new file mode 100644 index 000000000..c37503687 --- /dev/null +++ b/src/webui/internal/restconf/errors_test.go @@ -0,0 +1,39 @@ +package restconf + +import ( + "io" + "net/http" + "strings" + "testing" +) + +func TestParseErrorUnwrapsSysrepoFraming(t *testing.T) { + body := `{"ietf-restconf:errors":{"error":[{"error-type":"application","error-tag":"operation-failed",` + + `"error-message":"Internal server error due to sysrepo exception: Couldn't send RPC: ` + + `SR_ERR_OPERATION_FAILED Support data collection failed: /mnt/cfg has 9 MB free, collection needs about 37 MB ` + + `(SR_ERR_OPERATION_FAILED) NETCONF: application: operation-failed: ` + + `Support data collection failed: /mnt/cfg has 9 MB free, collection needs about 37 MB"}]}}` + resp := &http.Response{StatusCode: http.StatusInternalServerError, Body: io.NopCloser(strings.NewReader(body))} + + err := parseError(resp) + + re, ok := err.(*Error) + if !ok { + t.Fatalf("not a *Error: %T", err) + } + want := "Support data collection failed: /mnt/cfg has 9 MB free, collection needs about 37 MB" + if re.Message != want { + t.Errorf("message %q, want %q", re.Message, want) + } +} + +func TestParseErrorKeepsPlainMessage(t *testing.T) { + body := `{"ietf-restconf:errors":{"error":[{"error-type":"application","error-tag":"access-denied",` + + `"error-message":"Access denied."}]}}` + resp := &http.Response{StatusCode: http.StatusForbidden, Body: io.NopCloser(strings.NewReader(body))} + + re := parseError(resp).(*Error) + if re.Message != "Access denied." { + t.Errorf("message %q", re.Message) + } +} diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js index 025efd620..c1230b72c 100644 --- a/src/webui/static/js/app.js +++ b/src/webui/static/js/app.js @@ -2864,7 +2864,11 @@ function renderCfgLog() { headers: { 'X-CSRF-Token': btn.getAttribute('data-csrf') || '' }, body: body }).then(function (r) { - if (!r.ok) throw new Error('HTTP ' + r.status); + if (!r.ok) { + return r.text().then(function (t) { + throw new Error(t.trim() || ('HTTP ' + r.status)); + }); + } var name = filenameFromDisposition(r.headers.get('Content-Disposition'), 'support-bundle.tar.gz'); return r.blob().then(function (blob) { return { blob: blob, name: name }; }); }).then(function (res) { @@ -2879,7 +2883,7 @@ function renderCfgLog() { setStatus('Downloaded ' + res.name, ''); if (pass) pass.value = ''; }).catch(function (e) { - setStatus('Failed to generate bundle', 'err'); + setStatus(e.message || 'Failed to generate bundle', 'err'); if (window.console) console.warn('[support]', e); }).finally(function () { btn.disabled = false; diff --git a/src/webui/templates/pages/backup.html b/src/webui/templates/pages/backup.html index 710835596..7217b950c 100644 --- a/src/webui/templates/pages/backup.html +++ b/src/webui/templates/pages/backup.html @@ -54,7 +54,7 @@ single archive to attach to a support ticket. Collection can take up to a minute.

-

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`.