From 648296a5a25f9e4223c8e712983d1a25989fd81d Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 12 Jan 2026 05:34:15 +0100 Subject: [PATCH 01/31] board/common: cleanup old aliases and 'cfg' tool The sysrepocfg tool does not set up nacm based on $USER, and we now have the shell 'copy' tool which does. So drop 'cfg' and while we're at it we also drop the 'edit' alias. Signed-off-by: Joachim Wiberg --- .../rootfs/etc/profile.d/convenience.sh | 6 +- board/common/rootfs/usr/bin/cfg | 58 ------------------- board/common/rootfs/usr/bin/edit | 1 - 3 files changed, 3 insertions(+), 62 deletions(-) delete mode 100755 board/common/rootfs/usr/bin/cfg delete mode 120000 board/common/rootfs/usr/bin/edit diff --git a/board/common/rootfs/etc/profile.d/convenience.sh b/board/common/rootfs/etc/profile.d/convenience.sh index 05235ff3e..08eb105fb 100644 --- a/board/common/rootfs/etc/profile.d/convenience.sh +++ b/board/common/rootfs/etc/profile.d/convenience.sh @@ -4,14 +4,14 @@ alias ll='ls -alF' alias ls='ls --color=auto' export LANG=C.UTF-8 -export EDITOR=/usr/bin/edit -export VISUAL=/usr/bin/edit +export EDITOR=/usr/bin/editor +export VISUAL=/usr/bin/editor export LESS="-P %f (press h for help or q to quit)" export LESSOPEN="|/usr/bin/lesspipe.sh %s" alias vim='vi' alias view='vi -R' alias emacs='mg' -alias sensible-editor=edit +alias sensible-editor=editor alias sensible-pager=pager alias hd="hexdump -C" diff --git a/board/common/rootfs/usr/bin/cfg b/board/common/rootfs/usr/bin/cfg deleted file mode 100755 index 7e539b27b..000000000 --- a/board/common/rootfs/usr/bin/cfg +++ /dev/null @@ -1,58 +0,0 @@ -#!/bin/sh -# User-friendly wrapper for sysrepocfg -# TODO: add import/export, copy, ... - -# Edit YANG binary types using sysrepo, base64, and duct tape. -edit() -{ - xpath=$1 - if [ -z "$xpath" ]; then - echo "Usage: cfg edit \"/full/xpath/to/binary/leaf\"" - exit 1 - fi - - if tmp=$(sysrepocfg -G "$xpath"); then - file=$(mktemp) - - echo "$tmp" | base64 -d > "$file" - if /usr/bin/editor "$file"; then - tmp=$(base64 -w0 < "$file") - sysrepocfg -S "$xpath" -u "$tmp" - fi - - rm -f "$file" - else - echo "Failed to retrieve value for $xpath" - exit 1 - fi -} - -usage() -{ - echo "Usage:" - echo " cfg CMD [ARG]" - echo - echo "Command:" - echo " edit XPATH Edit YANG binary type" - echo " help This help text" - echo - echo "As a backwards compatible fallback, this script forwards" - echo "all other commands as options to sysrepocfg." - echo - - exit 0 -} - -cmd=$1; shift -case $cmd in - edit) - edit "$1" - ;; - help) - usage - ;; - *) - set -- "$cmd" "$@" - exec sysrepocfg -f json "$@" - ;; -esac diff --git a/board/common/rootfs/usr/bin/edit b/board/common/rootfs/usr/bin/edit deleted file mode 120000 index ddd7f8594..000000000 --- a/board/common/rootfs/usr/bin/edit +++ /dev/null @@ -1 +0,0 @@ -/etc/alternatives/editor \ No newline at end of file From 8a624c7c4a0e3def12b01c2fe9163ce79235e208 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 9 Jan 2026 15:26:06 +0100 Subject: [PATCH 02/31] package/klish: bump for new SocketGroup setting and bugfix - The new SocketGroup setting allows for configurable /run/klishd.sock - Also a bug fix for async commands, needed to fix regressions in the klish-plugin-sysrepo commands 'change passwordk' and 'text-editor' after the latest upgrade to sysrepo + libyang v4 Signed-off-by: Joachim Wiberg --- package/klish/klish.hash | 2 +- package/klish/klish.mk | 2 +- package/klish/klishd.conf | 8 ++++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/package/klish/klish.hash b/package/klish/klish.hash index dbb7092f5..5d0796cc9 100644 --- a/package/klish/klish.hash +++ b/package/klish/klish.hash @@ -1,3 +1,3 @@ # Locally calculated sha256 9d9d33b873917ca5d0bdcc47a36d2fd385971ab0c045d1472fcadf95ee5bcf5b LICENCE -sha256 d3148fd6d3fc106384c88efe5a4ce3c7b1d512bd919b864d84c13b7b9ad523f0 klish-4fd82287cfe74c6bbafea8d6c0ba8d8b4c65cdd0-git4.tar.gz +sha256 4c1b6b461a805f0cf80f797f85415e0663ee371df6495641bb494bfb18829fe8 klish-7c5f1c4045d9a868dea547bd7ef0143bff5a84d7-git4.tar.gz diff --git a/package/klish/klish.mk b/package/klish/klish.mk index 0cf577ea5..71ff7968c 100644 --- a/package/klish/klish.mk +++ b/package/klish/klish.mk @@ -4,7 +4,7 @@ # ################################################################################ -KLISH_VERSION = 4fd82287cfe74c6bbafea8d6c0ba8d8b4c65cdd0 +KLISH_VERSION = 7c5f1c4045d9a868dea547bd7ef0143bff5a84d7 KLISH_SITE = https://github.com/kernelkit/klish.git #KLISH_VERSION = tags/3.0.0 #KLISH_SITE = https://src.libcode.org/pkun/klish.git diff --git a/package/klish/klishd.conf b/package/klish/klishd.conf index d28de10d6..ccc5d0d9f 100644 --- a/package/klish/klishd.conf +++ b/package/klish/klishd.conf @@ -1,5 +1,13 @@ # Overrides for config file /etc/klish/klishd.conf +# The klishd uses UNIX domain socket to receive connections. It will create an +# filesystem entry to allow clients to find connection point. By default klishd +# uses /tmp/klish-unix-socket path. UnixSocketPath=/run/klishd.sock DBs=libxml2 + +# The group to set on the UNIX socket. By default, the socket retains the group +# of the user starting the daemon. The upstream sysrepo project recommends using +# the 'sysrepo' group to allow access to CLI tools. +SocketGroup=sysrepo From c0844b97d740f1a7ac403aea76ff0d49186359a9 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 13 Jan 2026 05:48:17 +0100 Subject: [PATCH 03/31] package/klish-plugin-sysrepo: sysrepo session and mode bits fixes Signed-off-by: Joachim Wiberg --- package/klish-plugin-sysrepo/klish-plugin-sysrepo.hash | 2 +- package/klish-plugin-sysrepo/klish-plugin-sysrepo.mk | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package/klish-plugin-sysrepo/klish-plugin-sysrepo.hash b/package/klish-plugin-sysrepo/klish-plugin-sysrepo.hash index 3a4a66ba7..527cc0926 100644 --- a/package/klish-plugin-sysrepo/klish-plugin-sysrepo.hash +++ b/package/klish-plugin-sysrepo/klish-plugin-sysrepo.hash @@ -1,3 +1,3 @@ # Locally calculated sha256 9d9d33b873917ca5d0bdcc47a36d2fd385971ab0c045d1472fcadf95ee5bcf5b LICENCE -sha256 a852daabf76a7bdd22637af23718a8470b89bc3a0e1c2d0ea10d4b6ba3fc4280 klish-plugin-sysrepo-001a2e49e37ae969fb8f2f9cf63155ae4e172946-git4.tar.gz +sha256 92d5154d1c9024b961a4874435723b0c74fa2586d9d2de43e55334f0ad9dedbb klish-plugin-sysrepo-e561ec062e6e6646d6cdc63e0f9f07e6ef2e75b9-git4.tar.gz diff --git a/package/klish-plugin-sysrepo/klish-plugin-sysrepo.mk b/package/klish-plugin-sysrepo/klish-plugin-sysrepo.mk index cd8f33a27..34d7d05f1 100644 --- a/package/klish-plugin-sysrepo/klish-plugin-sysrepo.mk +++ b/package/klish-plugin-sysrepo/klish-plugin-sysrepo.mk @@ -4,7 +4,7 @@ # ################################################################################ -KLISH_PLUGIN_SYSREPO_VERSION = 001a2e49e37ae969fb8f2f9cf63155ae4e172946 +KLISH_PLUGIN_SYSREPO_VERSION = e561ec062e6e6646d6cdc63e0f9f07e6ef2e75b9 KLISH_PLUGIN_SYSREPO_SITE = https://github.com/kernelkit/klish-plugin-sysrepo.git #KLISH_PLUGIN_SYSREPO_VERSION = cdd3eb51a7f7ee0ed5bd925fa636061d3b1b85fb #KLISH_PLUGIN_SYSREPO_SITE = https://src.libcode.org/pkun/klish-plugin-sysrepo.git From 9e47c593150e5f9930bfe919cc36409f5929fdc9 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 12 Jan 2026 19:37:27 +0100 Subject: [PATCH 04/31] package/finit: backport fixes to critical issues in v4.15 Signed-off-by: Joachim Wiberg --- ...NAME-environment-variables-when-drop.patch | 48 +++++++++++ ...ices-stuck-in-restart-state-after-no.patch | 86 +++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 package/finit/0001-Set-USER-and-LOGNAME-environment-variables-when-drop.patch create mode 100644 package/finit/0002-Fix-467-TTY-services-stuck-in-restart-state-after-no.patch diff --git a/package/finit/0001-Set-USER-and-LOGNAME-environment-variables-when-drop.patch b/package/finit/0001-Set-USER-and-LOGNAME-environment-variables-when-drop.patch new file mode 100644 index 000000000..8cc5b67a5 --- /dev/null +++ b/package/finit/0001-Set-USER-and-LOGNAME-environment-variables-when-drop.patch @@ -0,0 +1,48 @@ +From abaad560f0bf1f7de832a55222e20e2a9a61986f Mon Sep 17 00:00:00 2001 +From: Aaron Andersen +Date: Sat, 10 Jan 2026 18:57:52 -0500 +Subject: [PATCH 1/2] Set USER and LOGNAME environment variables when dropping + privileges +Organization: Wires + +When a service is configured to run as a non-root user (@user), finit +correctly drops privileges via setuid() and sets HOME and PATH, but +does not set the USER and LOGNAME environment variables. They remain +set to "root" from boot time. + +This causes problems for software that determines its identity from +the environment rather than getuid(). For example, rootless Podman +checks os.Getenv("USER") first when looking up subordinate UID/GID +ranges in /etc/subuid and /etc/subgid. + +With USER=root but UID=1000, Podman looks up root's subuid entry +instead of the actual user's, causing applications like newuidmap +to fail. Setting USER and LOGNAME to match the actual user identity +follows POSIX conventions and matches the behavior of su, sudo, and +login. + +Signed-off-by: Joachim Wiberg +--- + src/service.c | 5 ++++- + 1 file changed, 4 insertions(+), 1 deletion(-) + +diff --git a/src/service.c b/src/service.c +index 2c8fb6e..d45db40 100644 +--- a/src/service.c ++++ b/src/service.c +@@ -627,8 +627,11 @@ static pid_t service_fork(svc_t *svc) + set_uid(uid, svc); + + /* Set default path for regular users */ +- if (uid > 0) ++ if (uid > 0) { + setenv("PATH", _PATH_DEFPATH, 1); ++ setenv("USER", svc->username, 1); ++ setenv("LOGNAME", svc->username, 1); ++ } + if (home) { + setenv("HOME", home, 1); + if (chdir(home)) { +-- +2.43.0 + diff --git a/package/finit/0002-Fix-467-TTY-services-stuck-in-restart-state-after-no.patch b/package/finit/0002-Fix-467-TTY-services-stuck-in-restart-state-after-no.patch new file mode 100644 index 000000000..88c8e936f --- /dev/null +++ b/package/finit/0002-Fix-467-TTY-services-stuck-in-restart-state-after-no.patch @@ -0,0 +1,86 @@ +From 34bf9a77765db4d963fc66dde29b415ecc8ab611 Mon Sep 17 00:00:00 2001 +From: Joachim Wiberg +Date: Mon, 12 Jan 2026 19:31:39 +0100 +Subject: [PATCH 2/2] Fix #467: TTY services stuck in restart state after + non-zero exit +Organization: Wires + +When a TTY exited with non-zero code (e.g., user with shell=/sbin/false), +it would enter restart state but never recover, requiring manual restart. + +The throttling logic from commit f0032ab had two issues: + + 1. Duplicate exit code check in service_retry() created infinite timer loop + 2. TTYs lacked default restart_tmo, causing timer to never start + +Fix by removing duplicate check and ensuring TTYs get a 2-second default +restart_tmo for proper throttling. + +Signed-off-by: Joachim Wiberg +--- + src/service.c | 31 ++++++++++++++----------------- + 1 file changed, 14 insertions(+), 17 deletions(-) + +diff --git a/src/service.c b/src/service.c +index d45db40..ff8e180 100644 +--- a/src/service.c ++++ b/src/service.c +@@ -2194,15 +2194,20 @@ int service_register(int type, char *cfg, struct rlimit rlimit[], char *file) + /* only set forking based on pidfile if user supplied pid: option */ + if (pid && svc->pidfile[0] == '!') + svc->forking = 1; ++ } + +- if (svc->restart_tmo == 0) { +- if (svc_is_forking(svc)) +- svc->restart_tmo = 2000; +- else +- svc->restart_tmo = 1; +- } ++ /* Set default restart_tmo for services and TTYs that can restart */ ++ if (svc_is_daemon(svc) && svc->restart_tmo == 0) { ++ if (svc_is_forking(svc)) ++ svc->restart_tmo = 2000; ++ else ++ svc->restart_tmo = 1; + } + ++ /* TTYs need a longer default to throttle errors (e.g., missing device) */ ++ if (svc_is_tty(svc) && svc->restart_tmo == 0) ++ svc->restart_tmo = 2000; ++ + /* Set configured limits */ + memcpy(svc->rlimit, rlimit, sizeof(svc->rlimit)); + +@@ -2631,7 +2636,6 @@ static void service_cleanup_script(svc_t *svc) + static void service_retry(svc_t *svc) + { + char *restart_cnt = (char *)&svc->restart_cnt; +- int rc = WEXITSTATUS(svc->status); + int timeout; + + service_timeout_cancel(svc); +@@ -2641,17 +2645,10 @@ static void service_retry(svc_t *svc) + + if (svc->respawn) { + /* +- * Non-zero exit indicates an error that may not be resolved +- * by immediate retry. Add delay to prevent busy-loop and to +- * rate-limit retries, e.g. when TTY device doesn't exist. ++ * Respawn services (TTYs) that exited with non-zero status ++ * have already been delayed in the SVC_RUNNING_STATE handler. ++ * Just restart now. + */ +- if (WIFEXITED(svc->status) && rc != 0) { +- dbg("%s exited with error %d, delaying respawn ...", +- svc_ident(svc, NULL, 0), rc); +- service_timeout_after(svc, svc->restart_tmo, service_retry); +- return; +- } +- + dbg("%s crashed/exited, respawning ...", svc_ident(svc, NULL, 0)); + svc_unblock(svc); + service_step(svc); +-- +2.43.0 + From 3056ce4ab496dd1cee15f530c9ab0732a7790abb Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 12 Jan 2026 05:36:07 +0100 Subject: [PATCH 05/31] cli: fix default hash in 'do password encrypt' We could go with the old default sha512crypt, but since the default has changed to yescrypt, as used by the 'change password' command. We use that for consistency. Signed-off-by: Joachim Wiberg --- src/klish-plugin-infix/xml/infix.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/klish-plugin-infix/xml/infix.xml b/src/klish-plugin-infix/xml/infix.xml index 4a4317c1b..38676fbc6 100644 --- a/src/klish-plugin-infix/xml/infix.xml +++ b/src/klish-plugin-infix/xml/infix.xml @@ -238,7 +238,7 @@ - type=${KLISH_PARAM_pwhash:-sha512} + type=${KLISH_PARAM_pwhash:-yescrypt} salt=${KLISH_PARAM_pwsalt:+-S $KLISH_PARAM_pwsalt} mkpasswd -m $type $salt $KLISH_PARAM_pwpass From b106bac1204ab40ff851bfc8768a27792c4fde83 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Wed, 7 Jan 2026 18:13:58 +0100 Subject: [PATCH 06/31] buildoot: enforce sysrepo group, umask, and permissions This patch enables umask and nacm defalts in the Buildoot sysrepo package and a patch to sysrepo to set the sysrepo group also on the event pipes in /etc/sysrepo/, without which it would be hard to inject any type of new event as non-root user now since the umask now prevent all 'other' users from interacting with sysrepo. Signed-off-by: Joachim Wiberg --- buildroot | 2 +- ...add-support-for-running-in-foregroun.patch | 6 +-- ...NE-to-return-any-error-to-sysrepocfg.patch | 6 +-- ...3-Allow-to-copy-from-factory-default.patch | 7 +-- ...sysrepoctl-to-install-factory-config.patch | 7 +-- ...e-new-log-level-SEC-for-audit-trails.patch | 6 +-- ...ail-for-high-priority-system-changes.patch | 6 +-- ...sr_shmsub_listen_thread-exit-process.patch | 6 +-- .../4.2.10/0008-Cross-compile-fixes.patch | 7 +-- ...kfifo-set-sysrepo-group-if-available.patch | 47 +++++++++++++++++++ 10 files changed, 59 insertions(+), 41 deletions(-) create mode 100644 patches/sysrepo/4.2.10/0009-sr_mkfifo-set-sysrepo-group-if-available.patch diff --git a/buildroot b/buildroot index 4166c59af..4a1f18fb9 160000 --- a/buildroot +++ b/buildroot @@ -1 +1 @@ -Subproject commit 4166c59afd84cdd17311b8705f5c4d94e8af32a8 +Subproject commit 4a1f18fb9ab56adaa98422b65377eacf503b7bcf diff --git a/patches/sysrepo/4.2.10/0001-sysrepo-plugind-add-support-for-running-in-foregroun.patch b/patches/sysrepo/4.2.10/0001-sysrepo-plugind-add-support-for-running-in-foregroun.patch index 2622693eb..251697fb2 100644 --- a/patches/sysrepo/4.2.10/0001-sysrepo-plugind-add-support-for-running-in-foregroun.patch +++ b/patches/sysrepo/4.2.10/0001-sysrepo-plugind-add-support-for-running-in-foregroun.patch @@ -1,15 +1,11 @@ From efe7706fd7397c2feb384afea00ee97e74287df0 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 28 Mar 2023 10:37:53 +0200 -Subject: [PATCH 1/8] sysrepo-plugind: add support for running in foreground +Subject: [PATCH 1/9] sysrepo-plugind: add support for running in foreground with syslog -MIME-Version: 1.0 -Content-Type: text/plain; charset=UTF-8 -Content-Transfer-Encoding: 8bit Organization: Wires Signed-off-by: Joachim Wiberg -Signed-off-by: Mattias Walström --- src/executables/sysrepo-plugind.c | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/patches/sysrepo/4.2.10/0002-Allow-SR_EV_DONE-to-return-any-error-to-sysrepocfg.patch b/patches/sysrepo/4.2.10/0002-Allow-SR_EV_DONE-to-return-any-error-to-sysrepocfg.patch index b1f40820a..f09b75612 100644 --- a/patches/sysrepo/4.2.10/0002-Allow-SR_EV_DONE-to-return-any-error-to-sysrepocfg.patch +++ b/patches/sysrepo/4.2.10/0002-Allow-SR_EV_DONE-to-return-any-error-to-sysrepocfg.patch @@ -1,10 +1,7 @@ From 11b9938206cf2bafc456bb22e14c7f85a604760c Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 7 May 2024 15:41:53 +0200 -Subject: [PATCH 2/8] Allow SR_EV_DONE to return any error to sysrepocfg -MIME-Version: 1.0 -Content-Type: text/plain; charset=UTF-8 -Content-Transfer-Encoding: 8bit +Subject: [PATCH 2/9] Allow SR_EV_DONE to return any error to sysrepocfg Organization: Wires Importing a system configuration with sysrepocfg the model callbacks do @@ -20,7 +17,6 @@ This patch is a clumsy way of forcing the (first) error to bubble up to the surface and cause a non-zero exit code from sysrepocfg. Signed-off-by: Joachim Wiberg -Signed-off-by: Mattias Walström --- src/shm_sub.c | 40 +++++++++++++++++++++++++++++++--------- src/shm_sub.h | 2 +- diff --git a/patches/sysrepo/4.2.10/0003-Allow-to-copy-from-factory-default.patch b/patches/sysrepo/4.2.10/0003-Allow-to-copy-from-factory-default.patch index ac1749d63..ccf526eb3 100644 --- a/patches/sysrepo/4.2.10/0003-Allow-to-copy-from-factory-default.patch +++ b/patches/sysrepo/4.2.10/0003-Allow-to-copy-from-factory-default.patch @@ -1,13 +1,10 @@ From 9e0267d4f20733b2a26df6d0ee0bc4019db8b13f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Wed, 8 May 2024 17:00:50 +0200 -Subject: [PATCH 3/8] Allow to copy from factory default -MIME-Version: 1.0 -Content-Type: text/plain; charset=UTF-8 -Content-Transfer-Encoding: 8bit +Subject: [PATCH 3/9] Allow to copy from factory default Organization: Wires -Signed-off-by: Mattias Walström +Signed-off-by: Joachim Wiberg --- src/sysrepo.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/patches/sysrepo/4.2.10/0004-Add-z-switch-to-sysrepoctl-to-install-factory-config.patch b/patches/sysrepo/4.2.10/0004-Add-z-switch-to-sysrepoctl-to-install-factory-config.patch index f5aa25f84..a2d0d547c 100644 --- a/patches/sysrepo/4.2.10/0004-Add-z-switch-to-sysrepoctl-to-install-factory-config.patch +++ b/patches/sysrepo/4.2.10/0004-Add-z-switch-to-sysrepoctl-to-install-factory-config.patch @@ -1,16 +1,13 @@ From e0c899ba266b959544d7cc08c917cebba7ac91c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Mon, 6 May 2024 14:49:32 +0200 -Subject: [PATCH 4/8] Add -z switch to sysrepoctl to install factory config +Subject: [PATCH 4/9] Add -z switch to sysrepoctl to install factory config from a json file -MIME-Version: 1.0 -Content-Type: text/plain; charset=UTF-8 -Content-Transfer-Encoding: 8bit Organization: Wires This to be able to load the yang modules during build time instead on boot. -Signed-off-by: Mattias Walström +Signed-off-by: Joachim Wiberg --- src/executables/sysrepoctl.c | 20 +++++++++++++-- src/lyd_mods.h | 7 ++++++ diff --git a/patches/sysrepo/4.2.10/0005-Introduce-new-log-level-SEC-for-audit-trails.patch b/patches/sysrepo/4.2.10/0005-Introduce-new-log-level-SEC-for-audit-trails.patch index 9ff33c230..1884baf9b 100644 --- a/patches/sysrepo/4.2.10/0005-Introduce-new-log-level-SEC-for-audit-trails.patch +++ b/patches/sysrepo/4.2.10/0005-Introduce-new-log-level-SEC-for-audit-trails.patch @@ -1,10 +1,7 @@ From c7602dc8eabb941e0a163208aaf4de92dd5ef526 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Wed, 21 Aug 2024 16:00:35 +0200 -Subject: [PATCH 5/8] Introduce new log level [SEC] for audit trails -MIME-Version: 1.0 -Content-Type: text/plain; charset=UTF-8 -Content-Transfer-Encoding: 8bit +Subject: [PATCH 5/9] Introduce new log level [SEC] for audit trails Organization: Wires This adds a new log level for security and audit trail related log @@ -22,7 +19,6 @@ system log daemon, dropping any [SEVERITY] prefix. Also, \n is most often dropped by log daemons. Signed-off-by: Joachim Wiberg -Signed-off-by: Mattias Walström --- src/log.c | 18 +++++++++++++++++- src/log.h | 1 + diff --git a/patches/sysrepo/4.2.10/0006-Add-audit-trail-for-high-priority-system-changes.patch b/patches/sysrepo/4.2.10/0006-Add-audit-trail-for-high-priority-system-changes.patch index a7c377af9..68ce67cb9 100644 --- a/patches/sysrepo/4.2.10/0006-Add-audit-trail-for-high-priority-system-changes.patch +++ b/patches/sysrepo/4.2.10/0006-Add-audit-trail-for-high-priority-system-changes.patch @@ -1,10 +1,7 @@ From a86dfdd4a5cb74c1f8c90c8d5aea6f5505c1b88c Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Wed, 21 Aug 2024 16:04:43 +0200 -Subject: [PATCH 6/8] Add audit trail for high priority system changes -MIME-Version: 1.0 -Content-Type: text/plain; charset=UTF-8 -Content-Transfer-Encoding: 8bit +Subject: [PATCH 6/9] Add audit trail for high priority system changes Organization: Wires Committing a change to running, copying to a datastore, or calling an @@ -16,7 +13,6 @@ is when the system actually activates the changes. Copying to startup or other datastores is handled separately. Signed-off-by: Joachim Wiberg -Signed-off-by: Mattias Walström --- src/sysrepo.c | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/patches/sysrepo/4.2.10/0007-On-error-in-sr_shmsub_listen_thread-exit-process.patch b/patches/sysrepo/4.2.10/0007-On-error-in-sr_shmsub_listen_thread-exit-process.patch index ac6067ca8..c02449e69 100644 --- a/patches/sysrepo/4.2.10/0007-On-error-in-sr_shmsub_listen_thread-exit-process.patch +++ b/patches/sysrepo/4.2.10/0007-On-error-in-sr_shmsub_listen_thread-exit-process.patch @@ -1,10 +1,7 @@ From dbf08c67d8f17bdf98466b18fd72a230269e5d46 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 23 Aug 2024 12:22:06 +0200 -Subject: [PATCH 7/8] On error in sr_shmsub_listen_thread(), exit process -MIME-Version: 1.0 -Content-Type: text/plain; charset=UTF-8 -Content-Transfer-Encoding: 8bit +Subject: [PATCH 7/9] On error in sr_shmsub_listen_thread(), exit process Organization: Wires If processing callback events in, e.g., sysrepo-plugind, make sure to @@ -12,7 +9,6 @@ log the error and exit(1) the entire process so the system can decide to handle the problem. For example, restart all dependent services. Signed-off-by: Joachim Wiberg -Signed-off-by: Mattias Walström --- src/shm_sub.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/patches/sysrepo/4.2.10/0008-Cross-compile-fixes.patch b/patches/sysrepo/4.2.10/0008-Cross-compile-fixes.patch index 7b94019fa..221b13f9b 100644 --- a/patches/sysrepo/4.2.10/0008-Cross-compile-fixes.patch +++ b/patches/sysrepo/4.2.10/0008-Cross-compile-fixes.patch @@ -1,13 +1,10 @@ From 2549c966c090dd38a7a09907d27d13107d15aedd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Tue, 16 Dec 2025 08:18:32 +0100 -Subject: [PATCH 8/8] Cross compile fixes -MIME-Version: 1.0 -Content-Type: text/plain; charset=UTF-8 -Content-Transfer-Encoding: 8bit +Subject: [PATCH 8/9] Cross compile fixes Organization: Wires -Signed-off-by: Mattias Walström +Signed-off-by: Joachim Wiberg --- CMakeModules/SetupPrintedContext.cmake | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/patches/sysrepo/4.2.10/0009-sr_mkfifo-set-sysrepo-group-if-available.patch b/patches/sysrepo/4.2.10/0009-sr_mkfifo-set-sysrepo-group-if-available.patch new file mode 100644 index 000000000..d75f9d57d --- /dev/null +++ b/patches/sysrepo/4.2.10/0009-sr_mkfifo-set-sysrepo-group-if-available.patch @@ -0,0 +1,47 @@ +From 78d62382bf9d665764844a0f686b27e42d73bea9 Mon Sep 17 00:00:00 2001 +From: Joachim Wiberg +Date: Wed, 7 Jan 2026 18:09:32 +0100 +Subject: [PATCH 9/9] sr_mkfifo(): set sysrepo group if available +Organization: Wires + +We already set the umask, set the group to allow users of the sysrepo +group to initiate events. + +Signed-off-by: Joachim Wiberg +--- + src/common.c | 12 ++++++++++++ + 1 file changed, 12 insertions(+) + +diff --git a/src/common.c b/src/common.c +index 447fbd28..ad39f0ef 100644 +--- a/src/common.c ++++ b/src/common.c +@@ -3883,6 +3883,7 @@ sr_error_info_t * + sr_mkfifo(const char *path, mode_t mode) + { + sr_error_info_t *err_info = NULL; ++ gid_t gid; + + /* apply umask on mode */ + mode &= ~SR_UMASK; +@@ -3900,6 +3901,17 @@ sr_mkfifo(const char *path, mode_t mode) + return err_info; + } + ++ /* and group, if any */ ++ if (sr_is_prod_env() && strlen(SR_GROUP)) { ++ if ((err_info = sr_get_gid(SR_GROUP, &gid))) ++ return err_info; ++ ++ if (chown(path, -1, gid) == -1) { ++ SR_ERRINFO_SYSERRNO(&err_info, "chown"); ++ return err_info; ++ } ++ } ++ + return NULL; + } + +-- +2.43.0 + From 32d585d9585b5fc5edc4d905f56d27a0099ebc96 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Wed, 7 Jan 2026 18:25:15 +0100 Subject: [PATCH 07/31] sys: rename sys-cli -> sysrepo + klish Previously the sys-cli group was for interactive shell access, but with ever changing requirements this split has become necessary. This commit introduces the 'sysrepo' group for low-level access to all sysrepo commands, i.e., bootstrap only. For user-level shell access a 'klish' group is added which allows users to connect to the CLI. This is now the only group users, including the default 'admin', are members of, effectively making the new 'copy' tool the norm. Signed-off-by: Joachim Wiberg --- configs/aarch32_defconfig | 2 +- configs/aarch32_minimal_defconfig | 2 +- configs/aarch64_defconfig | 2 +- configs/aarch64_minimal_defconfig | 2 +- configs/riscv64_defconfig | 2 +- configs/x86_64_defconfig | 2 +- configs/x86_64_minimal_defconfig | 2 +- package/confd-test-mode/confd-test-mode.mk | 4 ++-- package/klish/klishd.conf | 5 ++--- package/skeleton-init-finit/skeleton/etc/group | 3 ++- package/skeleton-init-finit/skeleton/etc/passwd | 3 ++- package/skeleton-init-finit/skeleton/etc/shadow | 2 +- src/confd/src/system.c | 6 +++--- test/case/misc/start_from_startup/Readme.adoc | 2 +- test/case/misc/start_from_startup/test.py | 4 ++-- test/case/statd/system/system/run/getent_shadow | 2 +- 16 files changed, 23 insertions(+), 22 deletions(-) diff --git a/configs/aarch32_defconfig b/configs/aarch32_defconfig index 2dad00f40..0edd0526d 100644 --- a/configs/aarch32_defconfig +++ b/configs/aarch32_defconfig @@ -60,7 +60,7 @@ BR2_PACKAGE_LIBINPUT=y BR2_PACKAGE_LIBCURL_CURL=y BR2_PACKAGE_NETOPEER2_CLI=y BR2_PACKAGE_NSS_MDNS=y -BR2_PACKAGE_SYSREPO_GROUP="sys-cli" +BR2_PACKAGE_SYSREPO_GROUP="sysrepo" BR2_PACKAGE_LINUX_PAM=y BR2_PACKAGE_ONIGURUMA=y BR2_PACKAGE_AVAHI_DAEMON=y diff --git a/configs/aarch32_minimal_defconfig b/configs/aarch32_minimal_defconfig index ce580c695..639f37300 100644 --- a/configs/aarch32_minimal_defconfig +++ b/configs/aarch32_minimal_defconfig @@ -60,7 +60,7 @@ BR2_PACKAGE_LIBINPUT=y BR2_PACKAGE_LIBCURL_CURL=y BR2_PACKAGE_NETOPEER2_CLI=y BR2_PACKAGE_NSS_MDNS=y -BR2_PACKAGE_SYSREPO_GROUP="sys-cli" +BR2_PACKAGE_SYSREPO_GROUP="sysrepo" BR2_PACKAGE_LINUX_PAM=y BR2_PACKAGE_ONIGURUMA=y BR2_PACKAGE_AVAHI_DAEMON=y diff --git a/configs/aarch64_defconfig b/configs/aarch64_defconfig index 4db4a74f9..e4fcc51d3 100644 --- a/configs/aarch64_defconfig +++ b/configs/aarch64_defconfig @@ -64,7 +64,7 @@ BR2_PACKAGE_LIBINPUT=y BR2_PACKAGE_LIBCURL_CURL=y BR2_PACKAGE_NETOPEER2_CLI=y BR2_PACKAGE_NSS_MDNS=y -BR2_PACKAGE_SYSREPO_GROUP="sys-cli" +BR2_PACKAGE_SYSREPO_GROUP="sysrepo" BR2_PACKAGE_LINUX_PAM=y BR2_PACKAGE_LIBPAM_RADIUS_AUTH=y BR2_PACKAGE_ONIGURUMA=y diff --git a/configs/aarch64_minimal_defconfig b/configs/aarch64_minimal_defconfig index 1d621fab6..d4cb99c67 100644 --- a/configs/aarch64_minimal_defconfig +++ b/configs/aarch64_minimal_defconfig @@ -34,7 +34,7 @@ BR2_PACKAGE_BUSYBOX_CONFIG="${BR2_EXTERNAL_INFIX_PATH}/board/common/busybox_defc BR2_PACKAGE_ODHCP6C=y BR2_PACKAGE_STRACE=y BR2_PACKAGE_STRESS_NG=y -BR2_PACKAGE_SYSREPO_GROUP="sys-cli" +BR2_PACKAGE_SYSREPO_GROUP="sysrepo" BR2_PACKAGE_JQ=y BR2_PACKAGE_E2FSPROGS=y BR2_PACKAGE_E2FSPROGS_RESIZE2FS=y diff --git a/configs/riscv64_defconfig b/configs/riscv64_defconfig index 660c0f425..30ddff4b1 100644 --- a/configs/riscv64_defconfig +++ b/configs/riscv64_defconfig @@ -76,7 +76,7 @@ BR2_PACKAGE_LIBINPUT=y BR2_PACKAGE_LIBCURL_CURL=y BR2_PACKAGE_NETOPEER2_CLI=y BR2_PACKAGE_NSS_MDNS=y -BR2_PACKAGE_SYSREPO_GROUP="sys-cli" +BR2_PACKAGE_SYSREPO_GROUP="sysrepo" BR2_PACKAGE_LINUX_PAM=y BR2_PACKAGE_LIBPAM_RADIUS_AUTH=y BR2_PACKAGE_ONIGURUMA=y diff --git a/configs/x86_64_defconfig b/configs/x86_64_defconfig index c8c61399a..9a2e604c4 100644 --- a/configs/x86_64_defconfig +++ b/configs/x86_64_defconfig @@ -60,7 +60,7 @@ BR2_PACKAGE_LIBOPENSSL_BIN=y BR2_PACKAGE_LIBCURL_CURL=y BR2_PACKAGE_NETOPEER2_CLI=y BR2_PACKAGE_NSS_MDNS=y -BR2_PACKAGE_SYSREPO_GROUP="sys-cli" +BR2_PACKAGE_SYSREPO_GROUP="sysrepo" BR2_PACKAGE_LINUX_PAM=y BR2_PACKAGE_LIBPAM_RADIUS_AUTH=y BR2_PACKAGE_ONIGURUMA=y diff --git a/configs/x86_64_minimal_defconfig b/configs/x86_64_minimal_defconfig index c821286e4..24ed73f2d 100644 --- a/configs/x86_64_minimal_defconfig +++ b/configs/x86_64_minimal_defconfig @@ -35,7 +35,7 @@ BR2_PACKAGE_BUSYBOX_CONFIG="${BR2_EXTERNAL_INFIX_PATH}/board/common/busybox_defc BR2_PACKAGE_ODHCP6C=y BR2_PACKAGE_STRACE=y BR2_PACKAGE_STRESS_NG=y -BR2_PACKAGE_SYSREPO_GROUP="sys-cli" +BR2_PACKAGE_SYSREPO_GROUP="sysrepo" BR2_PACKAGE_JQ=y BR2_PACKAGE_E2FSPROGS=y BR2_PACKAGE_DBUS_CXX=y diff --git a/package/confd-test-mode/confd-test-mode.mk b/package/confd-test-mode/confd-test-mode.mk index aa144ed8d..1a9f040c9 100644 --- a/package/confd-test-mode/confd-test-mode.mk +++ b/package/confd-test-mode/confd-test-mode.mk @@ -26,8 +26,8 @@ define CONFD_TEST_MODE_INSTALL_YANG_MODULES $(BR2_EXTERNAL_INFIX_PATH)/utils/srload $(@D)/yang/test-mode.inc endef define CONFD_TEST_MODE_PERMISSIONS - /etc/sysrepo/data/ r 660 root wheel - - - - - - /etc/sysrepo/data d 770 root wheel - - - - - + /etc/sysrepo/data/ r 660 root sysrepo - - - - - + /etc/sysrepo/data d 770 root sysrepo - - - - - endef define CONFD_TEST_MODE_CLEANUP rm -f /dev/shm/$(CONFD_TEST_MODE_SYSREPO_SHM_PREFIX)* diff --git a/package/klish/klishd.conf b/package/klish/klishd.conf index ccc5d0d9f..b6e538fc6 100644 --- a/package/klish/klishd.conf +++ b/package/klish/klishd.conf @@ -8,6 +8,5 @@ UnixSocketPath=/run/klishd.sock DBs=libxml2 # The group to set on the UNIX socket. By default, the socket retains the group -# of the user starting the daemon. The upstream sysrepo project recommends using -# the 'sysrepo' group to allow access to CLI tools. -SocketGroup=sysrepo +# of the user starting the daemon. +SocketGroup=klish diff --git a/package/skeleton-init-finit/skeleton/etc/group b/package/skeleton-init-finit/skeleton/etc/group index 2614592d1..fccad8eca 100644 --- a/package/skeleton-init-finit/skeleton/etc/group +++ b/package/skeleton-init-finit/skeleton/etc/group @@ -20,7 +20,8 @@ backup:x:34: utmp:x:43: plugdev:x:46: lock:x:54: -sys-cli:x:60: +sysrepo:x:60: +klish:x:70: netdev:x:82: users:x:100: nobody:x:65534: diff --git a/package/skeleton-init-finit/skeleton/etc/passwd b/package/skeleton-init-finit/skeleton/etc/passwd index a2c662ba1..38a20ce2b 100644 --- a/package/skeleton-init-finit/skeleton/etc/passwd +++ b/package/skeleton-init-finit/skeleton/etc/passwd @@ -6,5 +6,6 @@ sync:x:4:100:sync:/bin:/bin/sync mail:x:8:8:mail:/var/spool/mail:/bin/false www-data:x:33:33:www-data:/var/www:/bin/false backup:x:34:34:backup:/var/backups:/bin/false -sys-cli:x:60:60:CLI capability:/var:/bin/false +sysrepo:x:60:60:sysrepo:/var:/bin/false +klish:x:70:70:CLI capability:/var:/bin/false nobody:x:65534:65534:nobody:/home:/bin/false diff --git a/package/skeleton-init-finit/skeleton/etc/shadow b/package/skeleton-init-finit/skeleton/etc/shadow index 820b8fee3..d8384929d 100644 --- a/package/skeleton-init-finit/skeleton/etc/shadow +++ b/package/skeleton-init-finit/skeleton/etc/shadow @@ -5,6 +5,6 @@ sys:*::::::: sync:*::::::: mail:*::::::: www-data:*::::::: -sys-cli:*::::::: +sysrepo:*::::::: backup:*::::::: nobody:*::::::: diff --git a/src/confd/src/system.c b/src/confd/src/system.c index 33a371bed..26941ce6c 100644 --- a/src/confd/src/system.c +++ b/src/confd/src/system.c @@ -558,9 +558,9 @@ static void del_groups(const char *user, const char **groups) static void adjust_access(const char *user, const char *shell) { if (strcmp(shell, "/bin/false")) - add_group(user, "sys-cli"); + add_group(user, "klish"); else - del_group(user, "sys-cli"); + del_group(user, "klish"); } /* XXX: Currently Infix only has admin and non-admins as a group */ @@ -615,7 +615,7 @@ static int is_valid_username(const char *user) return 1; } -static char *sys_find_usable_shell(sr_session_ctx_t *sess, char *name) +static char *sys_find_usable_shell(sr_session_ctx_t *sess, const char *name) { const char *conf = NULL; char *shell = NULL; diff --git a/test/case/misc/start_from_startup/Readme.adoc b/test/case/misc/start_from_startup/Readme.adoc index 5669c6812..e5b93969c 100644 --- a/test/case/misc/start_from_startup/Readme.adoc +++ b/test/case/misc/start_from_startup/Readme.adoc @@ -19,7 +19,7 @@ endif::topdoc[] . Configure . Reboot and wait for the unit to come back . Verify user admin is now in wheel group -. Verify user admin is now in sys-cli group +. Verify user admin is now in sysrepo group <<< diff --git a/test/case/misc/start_from_startup/test.py b/test/case/misc/start_from_startup/test.py index 92a0c9673..386886789 100755 --- a/test/case/misc/start_from_startup/test.py +++ b/test/case/misc/start_from_startup/test.py @@ -24,8 +24,8 @@ if not tgtssh.runsh("grep wheel /etc/group | grep 'admin'"): test.fail() - with test.step("Verify user admin is now in sys-cli group"): - if not tgtssh.runsh("grep sys-cli /etc/group | grep 'admin'"): + with test.step("Verify user admin is now in sysrepo group"): + if not tgtssh.runsh("grep sysrepo /etc/group | grep 'admin'"): test.fail() test.succeed() diff --git a/test/case/statd/system/system/run/getent_shadow b/test/case/statd/system/system/run/getent_shadow index a286e12d8..8f3f8e3bd 100644 --- a/test/case/statd/system/system/run/getent_shadow +++ b/test/case/statd/system/system/run/getent_shadow @@ -5,7 +5,7 @@ sys:*::::::: sync:*::::::: mail:*::::::: www-data:*::::::: -sys-cli:*::::::: +sysrepo:*::::::: backup:*::::::: nobody:*::::::: yangnobody:*::::::: From 58d1d3fd7f74b5c451012ac5e314200603a84930 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 8 Jan 2026 17:14:35 +0100 Subject: [PATCH 08/31] confd: add operator and guest groups to factory-config Signed-off-by: Joachim Wiberg --- src/confd/share/factory.d/10-nacm.json | 45 -------- src/confd/share/factory.d/10-nacm.json.in | 129 ++++++++++++++++++++++ src/confd/share/factory.d/Makefile.am | 15 ++- 3 files changed, 142 insertions(+), 47 deletions(-) delete mode 100644 src/confd/share/factory.d/10-nacm.json create mode 100644 src/confd/share/factory.d/10-nacm.json.in diff --git a/src/confd/share/factory.d/10-nacm.json b/src/confd/share/factory.d/10-nacm.json deleted file mode 100644 index 49583a20a..000000000 --- a/src/confd/share/factory.d/10-nacm.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "ietf-netconf-acm:nacm": { - "enable-nacm": true, - "groups": { - "group": [ - { - "name": "admin", - "user-name": [ - "admin" - ] - } - ] - }, - "rule-list": [ - { - "name": "admin-acl", - "group": [ - "admin" - ], - "rule": [ - { - "name": "permit-all", - "module-name": "*", - "access-operations": "*", - "action": "permit", - "comment": "Allow 'admin' group complete access to all operations and data." - } - ] - }, - { - "name": "default-deny-all", - "group": ["*"], - "rule": [ - { - "name": "deny-password-read", - "module-name": "ietf-system", - "path": "/ietf-system:system/authentication/user/password", - "access-operations": "*", - "action": "deny" - } - ] - } - ] - } -} diff --git a/src/confd/share/factory.d/10-nacm.json.in b/src/confd/share/factory.d/10-nacm.json.in new file mode 100644 index 000000000..3b530fb9c --- /dev/null +++ b/src/confd/share/factory.d/10-nacm.json.in @@ -0,0 +1,129 @@ +{ + "ietf-netconf-acm:nacm": { + "enable-nacm": true, + "read-default": "permit", + "write-default": "deny", + "exec-default": "permit", + "groups": { + "group": [ + { + "name": "admin", + "user-name": [ + "admin" + ] + }, + { + "name": "operator", + "user-name": [] + }, + { + "name": "guest", + "user-name": [] + } + ] + }, + "rule-list": [ + { + "name": "admin-acl", + "group": [ + "admin" + ], + "rule": [ + { + "name": "permit-all", + "module-name": "*", + "access-operations": "*", + "action": "permit", + "comment": "Allow 'admin' group complete access to all operations and data." + } + ] + }, + { + "name": "operator-acl", + "group": [ + "operator" + ], + "rule": [ + { + "name": "permit-interfaces", + "path": "/ietf-interfaces:interfaces/interface", + "access-operations": "*", + "action": "permit", + "comment": "Operators can manage network interfaces." + }, + { + "name": "permit-routing", + "path": "/ietf-routing:routing", + "access-operations": "*", + "action": "permit", + "comment": "Operators can manage routing protocols." + }, + { + "name": "permit-firewall", + "path": "/infix-firewall:firewall", + "access-operations": "*", + "action": "permit", + "comment": "Operators can manage firewall rules." + }, +#CONTAINERS# { +#CONTAINERS# "name": "permit-containers", +#CONTAINERS# "path": "/infix-containers:containers", +#CONTAINERS# "access-operations": "*", +#CONTAINERS# "action": "permit", +#CONTAINERS# "comment": "Operators can manage containers." +#CONTAINERS# }, + { + "name": "permit-system-restart", + "module-name": "ietf-system", + "rpc-name": "system-restart", + "access-operations": "exec", + "action": "permit", + "comment": "Operators can restart the system." + } + ] + }, + { + "name": "guest-acl", + "group": [ + "guest" + ], + "rule": [ + { + "name": "deny-all-exec", + "module-name": "*", + "access-operations": "exec", + "action": "deny", + "comment": "Guests cannot execute any operations." + } + ] + }, + { + "name": "default-deny-all", + "group": ["*"], + "rule": [ + { + "name": "deny-password-access", + "path": "/ietf-system:system/authentication/user/password", + "access-operations": "*", + "action": "deny", + "comment": "No user can access password hashes." + }, + { + "name": "deny-keystore-access", + "module-name": "ietf-keystore", + "access-operations": "*", + "action": "deny", + "comment": "No user can access cryptographic keys." + }, + { + "name": "deny-truststore-access", + "module-name": "ietf-truststore", + "access-operations": "*", + "action": "deny", + "comment": "No user can access trust store." + } + ] + } + ] + } +} diff --git a/src/confd/share/factory.d/Makefile.am b/src/confd/share/factory.d/Makefile.am index bc9a9b8fe..e1ba3f4f9 100644 --- a/src/confd/share/factory.d/Makefile.am +++ b/src/confd/share/factory.d/Makefile.am @@ -1,3 +1,14 @@ factorydir = $(pkgdatadir)/factory.d -dist_factory_DATA = 10-nacm.json 10-netconf-server.json \ - 10-infix-services.json 10-system.json +dist_factory_DATA = 10-netconf-server.json 10-infix-services.json 10-system.json +nodist_factory_DATA = 10-nacm.json + +EXTRA_DIST = 10-nacm.json.in +CLEANFILES = 10-nacm.json + +# Generic rule to process .json.in templates with conditional markers +%.json: %.json.in Makefile +if CONTAINERS + $(AM_V_GEN)sed -e 's/#CONTAINERS#//g' $< > $@ +else + $(AM_V_GEN)sed -e '/#CONTAINERS#/d' $< > $@ +endif From 0aed63ce2e5c6efa36c29063c9bfb240c00208d7 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 12 Jan 2026 19:14:41 +0100 Subject: [PATCH 09/31] confd: fix "is admin" check Prevent non-admin level users from getting UNIX wheel group assignment. Signed-off-by: Joachim Wiberg --- src/confd/src/system.c | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/confd/src/system.c b/src/confd/src/system.c index 26941ce6c..3750c3717 100644 --- a/src/confd/src/system.c +++ b/src/confd/src/system.c @@ -579,17 +579,25 @@ static bool is_admin_user(sr_session_ctx_t *session, const char *user) return false; /* safe default */ for (size_t j = 0; j < group_count; j++) { - /* Fetch and check rules for each group */ + const char *group = groups[j].data.string_val; + + /* + * Note: module-name has default="*", so we must explicitly exclude + * rules with 'path' and 'rpc-name' to only match pure module-level + * wildcard admin rules. + */ snprintf(xpath, sizeof(xpath), NACM_BASE_"/rule-list[group='%s']/rule" - "[module-name='*'][access-operations='*'][action='permit']", - groups[j].data.string_val); + "[module-name='*'][access-operations='*'][action='permit']" + "[not(path)][not(rpc-name)]", group); rc = sr_get_items(session, xpath, 0, 0, &rules, &rule_count); if (rc) continue; /* not found, this is OK */ /* At least one group grants full administrator permissions */ - if (rule_count > 0) + if (rule_count > 0) { + DEBUG("User '%s' granted admin via group '%s'", user, group); is_admin = true; + } sr_free_values(rules, rule_count); } From afbcd7e31d1108cdfa76a897a8f8e96420ffb88f Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 12 Jan 2026 19:15:28 +0100 Subject: [PATCH 10/31] confd: prevent motd from showing on non-shell user login attempts Signed-off-by: Joachim Wiberg --- src/confd/src/system.c | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/confd/src/system.c b/src/confd/src/system.c index 3750c3717..16ca790f2 100644 --- a/src/confd/src/system.c +++ b/src/confd/src/system.c @@ -557,10 +557,14 @@ static void del_groups(const char *user, const char **groups) /* Users with a valid shell are also allowed CLI access */ static void adjust_access(const char *user, const char *shell) { - if (strcmp(shell, "/bin/false")) + if (strcmp(shell, "/bin/false")) { add_group(user, "klish"); - else + erasef("/home/%s/.hushlogin", user); + } else { del_group(user, "klish"); + /* prevent even motd from showing */ + touchf("/home/%s/.hushlogin", user); + } } /* XXX: Currently Infix only has admin and non-admins as a group */ From 4a07c139e6fb10990de1ee6d7e6a7b6f6063d827 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 8 Jan 2026 12:38:57 +0100 Subject: [PATCH 11/31] bin: leverage the simplicity of err.h in copy tool Like err.h but without the leading "argv[0]: " prefix. Signed-off-by: Joachim Wiberg --- src/bin/copy.c | 60 +++++++++++++++++++++++++++----------------------- src/bin/util.h | 1 + 2 files changed, 33 insertions(+), 28 deletions(-) diff --git a/src/bin/copy.c b/src/bin/copy.c index 338b80798..f84b6b200 100644 --- a/src/bin/copy.c +++ b/src/bin/copy.c @@ -17,6 +17,12 @@ #include "util.h" +#define err(rc, fmt, args...) { fprintf(stderr, ERRMSG fmt ":%s\n", ##args, strerror(errno)); exit(rc); } +#define errx(rc, fmt, args...) { fprintf(stderr, ERRMSG fmt "\n", ##args); exit(rc); } +#define warnx(fmt, args...) fprintf(stderr, ERRMSG fmt "\n", ##args) +#define warn(fmt, args...) fprintf(stderr, ERRMSG fmt ":%s\n", ##args, strerror(errno)) +#define dbg(fmt, args...) if (debug) fprintf(stderr, DBGMSG fmt "\n", ##args) + struct infix_ds { char *name; /* startup-config, etc. */ int datastore; /* sr_datastore_t and -1 */ @@ -48,10 +54,8 @@ static const char *getuser(void) uid = getuid(); pw = getpwuid(uid); - if (!pw) { - perror("getpwuid"); - exit(1); - } + if (!pw) + err(1, "failed querying user info for uid %d", uid); return pw->pw_name; } @@ -86,7 +90,7 @@ static int in_group(const char *user, const char *fn, gid_t *gid) num = NGROUPS_MAX; groups = malloc(num * sizeof(gid_t)); if (!groups) { - perror("in_group() malloc"); + warn("failed in_group()"); return 0; } @@ -127,11 +131,10 @@ static void set_owner(const char *fn, const char *user) return; /* user not in parent directory's group */ if (chown(fn, -1, gid) && errno != EPERM) { - int _errno = errno; const struct group *gr = getgrgid(gid); - fprintf(stderr, ERRMSG "setting group owner %s (%d) on %s: %s\n", - gr ? gr->gr_name : "", gid, fn, strerror(_errno)); + warn("setting group owner %s (%d) on %s", + gr ? gr->gr_name : "", gid, fn); } } @@ -185,7 +188,7 @@ static void rmtmp(const char *path) if (errno == ENOENT) return; - fprintf(stderr, ERRMSG "removal of temporary file %s failed\n", path); + warn("failed removing temporary file %s", path); } } @@ -199,7 +202,7 @@ static void sysrepo_print_error(sr_session_ctx_t *sess) if (err || !erri || !erri->err_count) return; - fprintf(stderr, ERRMSG "%s (%d)\n", erri->err->message, erri->err->err_code); + warnx("%s (%d)", erri->err->message, erri->err->err_code); } static sr_session_ctx_t *sysrepo_session(const struct infix_ds *ds) @@ -225,7 +228,7 @@ static sr_session_ctx_t *sysrepo_session(const struct infix_ds *ds) err = sr_connect(0, &conn); if (err != SR_ERR_OK) { sysrepo_print_error(sess); - fprintf(stderr, ERRMSG "could not connect to %s\n", ds->name); + warnx(ERRMSG "failed connecting to %s", ds->name); goto err; } @@ -235,21 +238,21 @@ static sr_session_ctx_t *sysrepo_session(const struct infix_ds *ds) err = sr_session_start(conn, SR_DS_RUNNING, &sess); if (err != SR_ERR_OK) { sysrepo_print_error(sess); - fprintf(stderr, ERRMSG "%s session setup failed\n", ds->name); + warnx(ERRMSG "%s session setup failed", ds->name); goto err_disconnect; } err = sr_nacm_init(sess, 0, &sub); if (err != SR_ERR_OK) { sysrepo_print_error(sess); - fprintf(stderr, ERRMSG "%s NACM setup failed\n", ds->name); + warnx(ERRMSG "%s NACM setup failed", ds->name); goto err_stop; } err = sr_nacm_set_user(sess, user); if (err != SR_ERR_OK) { sysrepo_print_error(sess); - fprintf(stderr, ERRMSG "%s NACM setup for %s failed\n", ds->name, user); + warnx(ERRMSG "%s NACM setup failed for user %s", ds->name, user); goto err_nacm_destroy; } } @@ -257,7 +260,7 @@ static sr_session_ctx_t *sysrepo_session(const struct infix_ds *ds) err = sr_session_switch_ds(sess, ds->datastore); if (err) { sysrepo_print_error(sess); - fprintf(stderr, ERRMSG "%s activation failed\n", ds->name); + warnx("%s activation failed", ds->name); return NULL; } @@ -287,7 +290,7 @@ static int sysrepo_export(const struct infix_ds *ds, const char *path) err = sr_get_data(sess, "/*", 0, timeout * 1000, SR_OPER_DEFAULT, &data); if (err) { sysrepo_print_error(sess); - fprintf(stderr, ERRMSG "retrieval of %s data failed\n", ds->name); + warnx("failed retrieving %s data", ds->name); return err; } @@ -295,7 +298,7 @@ static int sysrepo_export(const struct infix_ds *ds, const char *path) sr_release_data(data); if (err) { sysrepo_print_error(sess); - fprintf(stderr, ERRMSG "failed to store %s data\n", ds->name); + warnx("failed storing %s data", ds->name); return err; } @@ -319,14 +322,14 @@ static int sysrepo_import(const struct infix_ds *ds, const char *path) LYD_PARSE_NO_STATE | LYD_PARSE_ONLY | LYD_PARSE_STORE_ONLY | LYD_PARSE_STRICT, 0, &data); if (err) { - fprintf(stderr, ERRMSG "failed to parse %s data\n", ds->name); + warnx("failed parsing %s data", ds->name); goto out; } err = dry_run ? 0 : sr_replace_config(sess, NULL, data, timeout * 1000); if (err) { sysrepo_print_error(sess); - fprintf(stderr, ERRMSG "failed import %s data\n", ds->name); + warnx("failed importing %s data, error %d", ds->name, err); } out: @@ -390,7 +393,7 @@ static int curl_upload(const char *srcpath, const char *uri) char upload[] = "-T"; if (curl(upload, srcpath, uri)) { - fprintf(stderr, ERRMSG "upload to %s failed\n", uri); + warnx("upload to %s failed", uri); return 1; } @@ -400,9 +403,10 @@ static int curl_upload(const char *srcpath, const char *uri) static int curl_download(const char *uri, const char *dstpath) { char download[] = "-o"; + int err; - if (curl(download, dstpath, uri)) { - fprintf(stderr, ERRMSG "download of %s failed\n", uri); + if ((err = curl(download, dstpath, uri))) { + warnx("download of %s failed, exit code %d", uri, err); return 1; } @@ -423,7 +427,7 @@ static int cp(const char *srcpath, const char *dstpath) err = subprocess(argv); if (err) - fprintf(stderr, ERRMSG "failed to save %s\n", dstpath); + warnx("failed to save %s, exit code %d", dstpath, err); out: free(argv[2]); free(argv[1]); @@ -480,7 +484,7 @@ static int resolve_src(const char **src, const struct infix_ds **ds, char **path } if (!*path) { - fprintf(stderr, ERRMSG "no such file %s.", *src); + warn("no such file %s", *src); return 1; } @@ -494,7 +498,7 @@ static int resolve_dst(const char **dst, const struct infix_ds **ds, char **path if (*ds) { if (!(*ds)->rw) { - fprintf(stderr, ERRMSG "%s is not writable", (*ds)->name); + warn("%s is not writable", (*ds)->name); return 1; } @@ -509,12 +513,12 @@ static int resolve_dst(const char **dst, const struct infix_ds **ds, char **path } if (!*path) { - fprintf(stderr, ERRMSG "no such file: %s", *dst); + warn("no such file: %s", *dst); return 1; } if (!*ds && !access(*path, F_OK) && !yorn("Overwrite existing file %s", *path)) { - fprintf(stderr, "OK, aborting.\n"); + warn("OK, aborting."); return 1; } @@ -533,7 +537,7 @@ static int copy(const char *src, const char *dst) oldmask = umask(0006); if (!strcmp(src, dst)) { - fprintf(stderr, ERRMSG "source and destination are the same, aborting.\n"); + warn("source and destination are the same, aborting."); goto err; } diff --git a/src/bin/util.h b/src/bin/util.h index e9a5b6f8a..516a5d5e3 100644 --- a/src/bin/util.h +++ b/src/bin/util.h @@ -6,6 +6,7 @@ #include #define ERRMSG "Error: " +#define DBGMSG "Debug: " #define INFMSG "Note: " int yorn (const char *fmt, ...); From 94ceb3efceb07ca16fd5a51562f8ec2d738c03ef Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 8 Jan 2026 14:36:55 +0100 Subject: [PATCH 12/31] bin: make DST in copy command optional and use in CLI Since sysrepocfg does not do any NACM based on the UNIX user, we expand the scope of the copy tool slightly to allow outputting JSON to stdout. This allows us to replace all sysrepocfg commands in the CLI used to show runnining/startup/factory. Signed-off-by: Joachim Wiberg --- src/bin/copy.c | 24 +++++++++++++++++------- src/klish-plugin-infix/xml/infix.xml | 6 +++--- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/bin/copy.c b/src/bin/copy.c index f84b6b200..43516fc83 100644 --- a/src/bin/copy.c +++ b/src/bin/copy.c @@ -140,15 +140,19 @@ static void set_owner(const char *fn, const char *user) static const char *infix_ds(const char *text, const struct infix_ds **ds) { - size_t i, len = strlen(text); + size_t i, len; + if (!text) + goto out; + + len = strlen(text); for (i = 0; i < NELEMS(infix_config); i++) { if (!strncmp(infix_config[i].name, text, len)) { *ds = &infix_config[i]; return infix_config[i].name; } } - +out: *ds = NULL; return text; } @@ -441,6 +445,8 @@ static int put(const char *srcpath, const char *dst, if (ds) err = sysrepo_import(ds, srcpath); + else if (!dst) + err = systemf("cat %s", srcpath); else if (is_uri(dst)) err = curl_upload(srcpath, dst); @@ -506,6 +512,8 @@ static int resolve_dst(const char **dst, const struct infix_ds **ds, char **path return 0; *path = strdup((*ds)->path); + } else if (!*dst) { + return 0; } else if (is_uri(*dst)) { return 0; } else { @@ -536,7 +544,7 @@ static int copy(const char *src, const char *dst) /* rw for user and group only */ oldmask = umask(0006); - if (!strcmp(src, dst)) { + if (dst && !strcmp(src, dst)) { warn("source and destination are the same, aborting."); goto err; } @@ -562,7 +570,8 @@ static int copy(const char *src, const char *dst) if (rmsrc) rmtmp(srcpath); - free(dstpath); + if (dstpath) + free(dstpath); free(srcpath); sync(); @@ -572,7 +581,7 @@ static int copy(const char *src, const char *dst) static int usage(int rc) { - printf("Usage: %s [OPTIONS] SRC DST\n" + printf("Usage: %s [OPTIONS] SRC [DST]\n" "\n" "Options:\n" " -h This help text\n" @@ -584,7 +593,8 @@ static int usage(int rc) "\n" "Files:\n" " SRC JSON configuration file, or a datastore\n" - " DST A file or datastore, except factory-config\n" + " DST Optiional file or datastore, except factory-config,\n" + " when omitted output goes to stdout\n" "\n" "Datastores:\n" " running-config The running datastore, current active config\n" @@ -627,7 +637,7 @@ int main(int argc, char *argv[]) if (timeout < 0) timeout = 120; - if (argc - optind != 2) + if (argc - optind < 1) return usage(1); src = argv[optind++]; diff --git a/src/klish-plugin-infix/xml/infix.xml b/src/klish-plugin-infix/xml/infix.xml index 38676fbc6..75f1cc685 100644 --- a/src/klish-plugin-infix/xml/infix.xml +++ b/src/klish-plugin-infix/xml/infix.xml @@ -628,15 +628,15 @@ - jq -C . /etc/factory-config.cfg |pager + copy factory | jq -C . |pager - sysrepocfg -X -f json | jq -C . |pager + copy running | jq -C . |pager - jq -C . /cfg/startup-config.cfg |pager + copy startup | jq -C . |pager From b9f745c27490a44fdae50e0456781a65b20a3525 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 8 Jan 2026 14:44:23 +0100 Subject: [PATCH 13/31] bin: add -d debug mode to copy command Signed-off-by: Joachim Wiberg --- src/bin/copy.c | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/bin/copy.c b/src/bin/copy.c index 43516fc83..e7d779568 100644 --- a/src/bin/copy.c +++ b/src/bin/copy.c @@ -40,6 +40,7 @@ const struct infix_ds infix_config[] = { static const char *prognm = "copy"; static const char *remote_user; +static int debug; static int timeout; static int dry_run; static int sanitize; @@ -253,6 +254,7 @@ static sr_session_ctx_t *sysrepo_session(const struct infix_ds *ds) goto err_stop; } + dbg("Setting NACM user %s for session", user); err = sr_nacm_set_user(sess, user); if (err != SR_ERR_OK) { sysrepo_print_error(sess); @@ -584,6 +586,7 @@ static int usage(int rc) printf("Usage: %s [OPTIONS] SRC [DST]\n" "\n" "Options:\n" + " -d Enable debug mode, verbose output on stderr\n" " -h This help text\n" " -n Dry-run, validate configuration without applying\n" " -s Sanitize paths for CLI use (restrict path traversal)\n" @@ -612,8 +615,11 @@ int main(int argc, char *argv[]) timeout = fgetint("/etc/default/confd", "=", "CONFD_TIMEOUT"); - while ((c = getopt(argc, argv, "hnst:u:v")) != EOF) { + while ((c = getopt(argc, argv, "dhnst:u:v")) != EOF) { switch(c) { + case 'd': + debug = 1; + break; case 'h': return usage(0); case 'n': From 613ef481bdd3747773fa1352eb9ff543a6be4c2f Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 8 Jan 2026 14:47:08 +0100 Subject: [PATCH 14/31] bin: retain sysrepo subscription in import *and* export Signed-off-by: Joachim Wiberg --- package/bin/bin.mk | 4 ++++ src/bin/copy.c | 14 +++++++++----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/package/bin/bin.mk b/package/bin/bin.mk index 15d276a94..49856cfce 100644 --- a/package/bin/bin.mk +++ b/package/bin/bin.mk @@ -18,4 +18,8 @@ define BIN_CONF_ENV CFLAGS="$(INFIX_CFLAGS)" endef +define BIN_PERMISSIONS + /usr/bin/copy d 04750 root sysrepo - - - - - +endef + $(eval $(autotools-package)) diff --git a/src/bin/copy.c b/src/bin/copy.c index e7d779568..0687836ee 100644 --- a/src/bin/copy.c +++ b/src/bin/copy.c @@ -46,14 +46,15 @@ static int dry_run; static int sanitize; /* - * Current system user, same as sysrepo user + * Current system user, same as sysrepo user. We use getuid() here + * because `copy` is SUID root to work around sysrepo issues with a + * /dev/shm that's moounted 01777. */ static const char *getuser(void) { const struct passwd *pw; - uid_t uid; + uid_t uid = getuid(); - uid = getuid(); pw = getpwuid(uid); if (!pw) err(1, "failed querying user info for uid %d", uid); @@ -176,6 +177,7 @@ static char *mktmp(void) oldmask = umask(0077); fd = mkstemp(path); umask(oldmask); + chown(path, getuid(), -1); if (fd < 0) goto err; @@ -212,9 +214,8 @@ static void sysrepo_print_error(sr_session_ctx_t *sess) static sr_session_ctx_t *sysrepo_session(const struct infix_ds *ds) { + static sr_subscription_ctx_t *sub = NULL; static sr_session_ctx_t *sess; - - sr_subscription_ctx_t *sub = NULL; const char *user = getuser(); sr_conn_ctx_t *conn = NULL; int err; @@ -226,6 +227,8 @@ static sr_session_ctx_t *sysrepo_session(const struct infix_ds *ds) conn = sr_session_get_connection(sess); sr_session_stop(sess); sr_disconnect(conn); + sess = NULL; + sub = NULL; return NULL; } @@ -274,6 +277,7 @@ static sr_session_ctx_t *sysrepo_session(const struct infix_ds *ds) err_nacm_destroy: sr_nacm_destroy(); + sub = NULL; err_stop: sr_session_stop(sess); err_disconnect: From 87e1e4a339d8f4c3e3e52062e45d3553f727fe0d Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Thu, 8 Jan 2026 15:42:01 +0100 Subject: [PATCH 15/31] bin: add optional xpath support to copy tool Signed-off-by: Joachim Wiberg --- src/bin/copy.c | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/bin/copy.c b/src/bin/copy.c index 0687836ee..1b7310dd9 100644 --- a/src/bin/copy.c +++ b/src/bin/copy.c @@ -40,6 +40,7 @@ const struct infix_ds infix_config[] = { static const char *prognm = "copy"; static const char *remote_user; +static char *xpath = "/*"; static int debug; static int timeout; static int dry_run; @@ -297,7 +298,7 @@ static int sysrepo_export(const struct infix_ds *ds, const char *path) if (!sess) return 1; - err = sr_get_data(sess, "/*", 0, timeout * 1000, SR_OPER_DEFAULT, &data); + err = sr_get_data(sess, xpath, 0, timeout * 1000, SR_OPER_DEFAULT, &data); if (err) { sysrepo_print_error(sess); warnx("failed retrieving %s data", ds->name); @@ -597,6 +598,7 @@ static int usage(int rc) " -t SEC Timeout for the operation, or default %d sec\n" " -u USER Username for remote commands, like scp\n" " -v Show version\n" + " -x PATH XPath to copy, default: all\n" "\n" "Files:\n" " SRC JSON configuration file, or a datastore\n" @@ -619,7 +621,7 @@ int main(int argc, char *argv[]) timeout = fgetint("/etc/default/confd", "=", "CONFD_TIMEOUT"); - while ((c = getopt(argc, argv, "dhnst:u:v")) != EOF) { + while ((c = getopt(argc, argv, "dhnst:u:vx:")) != EOF) { switch(c) { case 'd': debug = 1; @@ -641,6 +643,9 @@ int main(int argc, char *argv[]) case 'v': puts(PACKAGE_VERSION); return 0; + case 'x': + xpath = optarg; + break; } } From 46835bec78631c54fd486ec4aaa17a47f149c323 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 12 Jan 2026 13:25:50 +0100 Subject: [PATCH 16/31] bin: make copy a multicall binary with new rpc interface Signed-off-by: Joachim Wiberg --- package/bin/Config.in | 4 + src/bin/Makefile.am | 4 + src/bin/configure.ac | 1 + src/bin/copy.c | 311 +++++++++++++++++++++++++++++++++--------- 4 files changed, 254 insertions(+), 66 deletions(-) diff --git a/package/bin/Config.in b/package/bin/Config.in index ee14b8e95..a0e8a587d 100644 --- a/package/bin/Config.in +++ b/package/bin/Config.in @@ -5,4 +5,8 @@ config BR2_PACKAGE_BIN help Misc. tools for CLI and shell users. + Includes show (show.py) copy (NACM-aware datastore operations), + rpc (YANG RPC execution with NACM enforcement), erase, and files + commands. + https://github.com/kernelkit/infix diff --git a/src/bin/Makefile.am b/src/bin/Makefile.am index e2c4ebf83..8aab109d0 100644 --- a/src/bin/Makefile.am +++ b/src/bin/Makefile.am @@ -25,3 +25,7 @@ files_CPPFLAGS = -D_DEFAULT_SOURCE -D_GNU_SOURCE files_CFLAGS = -W -Wall -Wextra files_CFLAGS += $(libite_CFLAGS) $(sysrepo_CFLAGS) files_LDADD = $(libite_LIBS) $(sysrepo_LIBS) + +# Create rpc symlink for multicall binary +install-exec-hook: + cd $(DESTDIR)$(bindir) && $(LN_S) -f copy$(EXEEXT) rpc$(EXEEXT) diff --git a/src/bin/configure.ac b/src/bin/configure.ac index 8b5c12c81..bfed012b6 100644 --- a/src/bin/configure.ac +++ b/src/bin/configure.ac @@ -10,6 +10,7 @@ AC_CONFIG_FILES([ AC_PROG_CC AC_PROG_INSTALL +AC_PROG_LN_S # Check for pkg-config first, warn if it's not installed PKG_PROG_PKG_CONFIG diff --git a/src/bin/copy.c b/src/bin/copy.c index 1b7310dd9..48ccf7433 100644 --- a/src/bin/copy.c +++ b/src/bin/copy.c @@ -14,14 +14,15 @@ #include #include +#include #include "util.h" -#define err(rc, fmt, args...) { fprintf(stderr, ERRMSG fmt ":%s\n", ##args, strerror(errno)); exit(rc); } -#define errx(rc, fmt, args...) { fprintf(stderr, ERRMSG fmt "\n", ##args); exit(rc); } -#define warnx(fmt, args...) fprintf(stderr, ERRMSG fmt "\n", ##args) -#define warn(fmt, args...) fprintf(stderr, ERRMSG fmt ":%s\n", ##args, strerror(errno)) -#define dbg(fmt, args...) if (debug) fprintf(stderr, DBGMSG fmt "\n", ##args) +#define err(rc, fmt, args...) { fprintf(stderr, ERRMSG fmt ":%s\n", ##args, strerror(errno)); exit(rc); } +#define errx(rc, fmt, args...) { fprintf(stderr, ERRMSG fmt "\n", ##args); exit(rc); } +#define warnx(fmt, args...) fprintf(stderr, ERRMSG fmt "\n", ##args) +#define warn(fmt, args...) fprintf(stderr, ERRMSG fmt ":%s\n", ##args, strerror(errno)) +#define dbg(fmt, args...) if (debug) fprintf(stderr, DBGMSG fmt "\n", ##args) struct infix_ds { char *name; /* startup-config, etc. */ @@ -38,7 +39,7 @@ const struct infix_ds infix_config[] = { { "factory-config", SR_DS_FACTORY_DEFAULT, false, NULL } }; -static const char *prognm = "copy"; +static const char *prognm; static const char *remote_user; static char *xpath = "/*"; static int debug; @@ -178,11 +179,13 @@ static char *mktmp(void) oldmask = umask(0077); fd = mkstemp(path); umask(oldmask); - chown(path, getuid(), -1); if (fd < 0) goto err; + if (chown(path, getuid(), -1)) + dbg("Failed to chown %s: %s", path, strerror(errno)); + close(fd); return path; err: @@ -200,12 +203,14 @@ static void rmtmp(const char *path) } } - static void sysrepo_print_error(sr_session_ctx_t *sess) { const sr_error_info_t *erri = NULL; int err; + if (!sess) + return; + err = sr_session_get_error(sess, &erri); if (err || !erri || !erri->err_count) return; @@ -213,11 +218,54 @@ static void sysrepo_print_error(sr_session_ctx_t *sess) warnx("%s (%d)", erri->err->message, erri->err->err_code); } +/* Connect to sysrepo and create NACM-aware session on running datastore */ +static int sysrepo_init(sr_conn_ctx_t **conn, sr_session_ctx_t **sess, + sr_subscription_ctx_t **sub) +{ + const char *user = getuser(); + int err; + + err = sr_connect(SR_CONN_DEFAULT, conn); + if (err != SR_ERR_OK) { + warnx("failed connecting to sysrepo: %s", sr_strerror(err)); + return err; + } + + /* Always open running, because sr_nacm_init() does not work + * against the factory DS. + */ + err = sr_session_start(*conn, SR_DS_RUNNING, sess); + if (err != SR_ERR_OK) { + warnx("failed starting session: %s", sr_strerror(err)); + goto fail; + } + + err = sr_nacm_init(*sess, 0, sub); + if (err != SR_ERR_OK) { + warnx("NACM init failed: %s", sr_strerror(err)); + goto fail; + } + + dbg("Setting NACM user %s for session", user); + err = sr_nacm_set_user(*sess, user); + if (err != SR_ERR_OK) { + warnx("NACM setup failed for user %s: %s", user, sr_strerror(err)); + goto fail; + } + + return SR_ERR_OK; +fail: + sysrepo_print_error(*sess); + sr_session_stop(*sess); + sr_disconnect(*conn); + + return err; +} + static sr_session_ctx_t *sysrepo_session(const struct infix_ds *ds) { static sr_subscription_ctx_t *sub = NULL; static sr_session_ctx_t *sess; - const char *user = getuser(); sr_conn_ctx_t *conn = NULL; int err; @@ -234,36 +282,10 @@ static sr_session_ctx_t *sysrepo_session(const struct infix_ds *ds) } if (!sess) { - err = sr_connect(0, &conn); - if (err != SR_ERR_OK) { - sysrepo_print_error(sess); - warnx(ERRMSG "failed connecting to %s", ds->name); - goto err; - } - - /* Always open running, because sr_nacm_init() does not work - * against the factory DS. - */ - err = sr_session_start(conn, SR_DS_RUNNING, &sess); + err = sysrepo_init(&conn, &sess, &sub); if (err != SR_ERR_OK) { - sysrepo_print_error(sess); - warnx(ERRMSG "%s session setup failed", ds->name); - goto err_disconnect; - } - - err = sr_nacm_init(sess, 0, &sub); - if (err != SR_ERR_OK) { - sysrepo_print_error(sess); - warnx(ERRMSG "%s NACM setup failed", ds->name); - goto err_stop; - } - - dbg("Setting NACM user %s for session", user); - err = sr_nacm_set_user(sess, user); - if (err != SR_ERR_OK) { - sysrepo_print_error(sess); - warnx(ERRMSG "%s NACM setup failed for user %s", ds->name, user); - goto err_nacm_destroy; + warnx("Failed to initialize session for %s", ds->name); + return NULL; } } @@ -275,17 +297,6 @@ static sr_session_ctx_t *sysrepo_session(const struct infix_ds *ds) } return sess; - -err_nacm_destroy: - sr_nacm_destroy(); - sub = NULL; -err_stop: - sr_session_stop(sess); -err_disconnect: - sr_disconnect(conn); -err: - sess = NULL; - return NULL; } static int sysrepo_export(const struct infix_ds *ds, const char *path) @@ -591,30 +602,139 @@ static int usage(int rc) printf("Usage: %s [OPTIONS] SRC [DST]\n" "\n" "Options:\n" - " -d Enable debug mode, verbose output on stderr\n" - " -h This help text\n" - " -n Dry-run, validate configuration without applying\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" - " -v Show version\n" - " -x PATH XPath to copy, default: all\n" + " -d Enable debug mode, verbose output on stderr\n" + " -h This help text\n" + " -n Dry-run, validate configuration without applying\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" + " -v Show version\n" + " -x PATH XPath to copy, default: all\n" "\n" "Files:\n" - " SRC JSON configuration file, or a datastore\n" - " DST Optiional file or datastore, except factory-config,\n" - " when omitted output goes to stdout\n" + " SRC JSON configuration file, or a datastore\n" + " DST Optiional file or datastore, except factory-config,\n" + " when omitted output goes to stdout\n" + "\n" + "Datastores (short forms possible):\n" + " running-config The running datastore, current active config\n" + " startup-config The non-volatile config used at startup\n" + " factory-config The device's factory default configuration\n" + " operational-state Operational status and state data" "\n" - "Datastores:\n" - " running-config The running datastore, current active config\n" - " startup-config The non-volatile config used at startup\n" - " factory-config The device's factory default configuration\n" - "\n", prognm, timeout); + "Examples:\n" + " %s operational -x /system-state/software/boot-order\n" + "\n", prognm, timeout, prognm); return rc; } -int main(int argc, char *argv[]) +static int usage_rpc(int rc) +{ + printf("Usage: %s [OPTIONS] [key value ...]\n" + "\n" + "Execute a YANG RPC/action with NACM enforcement.\n" + "\n" + "Options:\n" + " -d Enable debug mode, verbose output on stderr\n" + " -h This help text\n" + " -t SEC Timeout for the operation, or default %d sec\n" + " -v Show version\n" + "\n" + "Arguments:\n" + " rpc-xpath RPC XPath (e.g., /ietf-system:set-current-datetime)\n" + " key value Pairs of RPC argument names and values\n" + " Values can be comma-separated for lists/leaf-lists\n" + "\n" + "Examples:\n" + " %s /ietf-system:set-current-datetime current-datetime \"2025-01-01T00:00:00Z\"\n" + " %s /infix-system:set-boot-order boot-order primary boot-order secondary\n" + " %s /infix-system:set-boot-order boot-order primary,secondary,net\n" + "\n", prognm, timeout, prognm, prognm, prognm); + + return rc; +} + +/* Execute RPC from CLI arguments: xpath and key-value pairs */ +static int rpc_exec(const char *rpc_xpath, int argc, char *argv[]) +{ + sr_subscription_ctx_t *sub = NULL; + sr_conn_ctx_t *conn = NULL; + sr_session_ctx_t *sess = NULL; + sr_val_t *input = NULL; + sr_val_t *output = NULL; + size_t icnt = 0, ocnt = 0; + int rc = 1, err, i; + + dbg("Executing RPC %s with %d arguments", rpc_xpath, argc / 2); + + err = sysrepo_init(&conn, &sess, &sub); + if (err != SR_ERR_OK) + return 1; + + for (i = 0; i < argc - 1; i += 2) { + const char *key = argv[i]; + const char *val = argv[i + 1]; + char *val_copy, *token, *saveptr; + + /* Check if value contains commas - split into multiple values */ + if (strchr(val, ',')) { + val_copy = strdup(val); + if (!val_copy) { + warnx("Memory allocation failed"); + goto cleanup; + } + + token = strtok_r(val_copy, ",", &saveptr); + while (token) { + sr_realloc_values(icnt, icnt + 1, &input); + sr_val_build_xpath(&input[icnt], "%s/%s", rpc_xpath, key); + sr_val_set_str_data(&input[icnt], SR_STRING_T, token); + dbg("Adding RPC argument %zu: %s = %s", icnt, input[icnt].xpath, token); + icnt++; + token = strtok_r(NULL, ",", &saveptr); + } + free(val_copy); + } else { + /* Single value */ + sr_realloc_values(icnt, icnt + 1, &input); + sr_val_build_xpath(&input[icnt], "%s/%s", rpc_xpath, key); + sr_val_set_str_data(&input[icnt], SR_STRING_T, val); + dbg("Adding RPC argument %zu: %s = %s", icnt, input[icnt].xpath, val); + icnt++; + } + } + + dbg("Sending RPC %s (timeout: %d ms)", rpc_xpath, timeout * 1000); + err = sr_rpc_send(sess, rpc_xpath, input, icnt, timeout * 1000, &output, &ocnt); + if (err != SR_ERR_OK) { + sysrepo_print_error(sess); + warnx("RPC execution failed: %s", sr_strerror(err)); + goto cleanup; + } + + /* Print output if any */ + for (i = 0; i < (int)ocnt; i++) { + sr_print_val(&output[i]); + puts(""); + } + + rc = 0; + +cleanup: + sr_free_values(input, icnt); + sr_free_values(output, ocnt); + if (sub) + sr_nacm_destroy(); + if (sess) + sr_session_stop(sess); + if (conn) + sr_disconnect(conn); + + return rc; +} + +static int copy_main(int argc, char *argv[]) { const char *src = NULL, *dst = NULL; int c; @@ -660,3 +780,62 @@ int main(int argc, char *argv[]) return copy(src, dst); } + +static int rpc_main(int argc, char *argv[]) +{ + int c, remaining_args; + + timeout = fgetint("/etc/default/confd", "=", "CONFD_TIMEOUT"); + + while ((c = getopt(argc, argv, "dht:v")) != EOF) { + switch(c) { + case 'd': + debug = 1; + break; + case 'h': + return usage_rpc(0); + case 't': + timeout = atoi(optarg); + break; + case 'v': + puts(PACKAGE_VERSION); + return 0; + } + } + + if (timeout < 0) + timeout = 120; + + /* Require at least RPC xpath */ + if (optind >= argc) { + warnx("Missing RPC xpath"); + return usage_rpc(1); + } + + /* Validate RPC xpath starts with '/' */ + if (argv[optind][0] != '/') { + warnx("RPC xpath must start with '/'"); + return usage_rpc(1); + } + + remaining_args = argc - optind - 1; + + /* Validate argument count (must be key-value pairs) */ + if (remaining_args % 2 != 0) { + warnx("Arguments must be key-value pairs after RPC xpath"); + return usage_rpc(1); + } + + /* Execute RPC with xpath and key-value pairs */ + return rpc_exec(argv[optind], remaining_args, &argv[optind + 1]); +} + +int main(int argc, char *argv[]) +{ + prognm = basename(argv[0]); + + if (!strcmp(prognm, "rpc")) + return rpc_main(argc, argv); + + return copy_main(argc, argv); +} From 331bf58d8903afb2b9c0df7c7d616768feb65b59 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 18 Jan 2026 16:00:44 +0100 Subject: [PATCH 17/31] statd: add optional min_width to columns in SimpleTable class Signed-off-by: Joachim Wiberg --- src/statd/python/cli_pretty/cli_pretty.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index b1e25cd6e..79bfb1e9b 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -229,11 +229,12 @@ def table_width(cls): class Column: """Column definition for SimpleTable""" - def __init__(self, name, align='left', formatter=None, flexible=False): + def __init__(self, name, align='left', formatter=None, flexible=False, min_width=None): self.name = name self.align = align self.formatter = formatter self.flexible = flexible + self.min_width = min_width class SimpleTable: """Simple table formatter that handles ANSI colors correctly and calculates dynamic column widths""" @@ -320,6 +321,11 @@ def _calculate_column_widths(self): value_width = self.visible_width(str(formatted_value)) widths[i] = max(widths[i], value_width) + # Apply column minimum widths + for i, column in enumerate(self.columns): + if column.min_width: + widths[i] = max(widths[i], column.min_width) + return widths def _format_header(self, column_widths, styled=True): From 708ff90c3cdea01b3ba8a4e2d577e2639f9934f2 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 9 Jan 2026 15:27:18 +0100 Subject: [PATCH 18/31] cli: new admin-exec level command 'show nacm' Show operational details about nacm and user//group mappings. Signed-off-by: Joachim Wiberg --- src/klish-plugin-infix/xml/infix.xml | 4 + src/show/show.py | 23 +++++- src/statd/python/cli_pretty/cli_pretty.py | 71 ++++++++++++++++ src/statd/python/yanger/ietf_system.py | 81 +++++++++++++++---- test/case/statd/system/ietf-system.json | 3 +- test/case/statd/system/operational.json | 3 +- .../statd/system/system/run/getent_passwd | 17 ++++ 7 files changed, 181 insertions(+), 21 deletions(-) create mode 100644 test/case/statd/system/system/run/getent_passwd diff --git a/src/klish-plugin-infix/xml/infix.xml b/src/klish-plugin-infix/xml/infix.xml index 75f1cc685..3daf60ed7 100644 --- a/src/klish-plugin-infix/xml/infix.xml +++ b/src/klish-plugin-infix/xml/infix.xml @@ -324,6 +324,10 @@ + + show nacm + + diff --git a/src/show/show.py b/src/show/show.py index e0fd57a96..fccafd707 100755 --- a/src/show/show.py +++ b/src/show/show.py @@ -10,7 +10,7 @@ RAW_OUTPUT = False -def run_sysrepocfg(xpath: str) -> dict: +def run_sysrepocfg(xpath: str, datastore: str = "operational") -> dict: if not isinstance(xpath, str) or not xpath.startswith("/"): print("Invalid XPATH. It must be a valid string starting with '/'.") return {} @@ -19,7 +19,7 @@ def run_sysrepocfg(xpath: str) -> dict: try: result = subprocess.run([ - "sysrepocfg", "-f", "json", "-X", "-d", "operational", "-x", safe_xpath + "sysrepocfg", "-f", "json", "-X", "-d", datastore, "-x", safe_xpath ], capture_output=True, text=True, check=True) json_data = json.loads(result.stdout) return json_data @@ -596,6 +596,24 @@ def human_readable(kib_val): cli_pretty(data, "show-system") +def nacm(args: List[str]) -> None: + data = run_sysrepocfg("/ietf-netconf-acm:nacm", "running") + if not data: + print("No NACM data retrieved.") + return + + # Fetch user data from operational (includes shell and authorized-key from yanger) + user_oper = get_json("/ietf-system:system/authentication") + + if user_oper: + oper_users = user_oper.get("ietf-system:system", {}).get("authentication", {}).get("user", []) + data["ietf-system:system"] = {"authentication": {"user": oper_users}} + + if RAW_OUTPUT: + print(json.dumps(data, indent=2)) + return + cli_pretty(data, "show-nacm") + def execute_command(command: str, args: List[str]): command_mapping = { 'bfd': bfd, @@ -605,6 +623,7 @@ def execute_command(command: str, args: List[str]): 'hardware': hardware, 'interface': interface, 'lldp': lldp, + 'nacm': nacm, 'ntp': ntp, 'ospf': ospf, 'rip': rip, diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index 79bfb1e9b..278498108 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -2919,6 +2919,73 @@ def show_ntp_source(json, address=None): print(row) +def show_nacm(json): + """Display users and NACM (Network Configuration Access Control) groups""" + min_width = 62 + + nacm = json.get("ietf-netconf-acm:nacm", {}) + if not nacm: + print("NACM not configured.") + print() + + if nacm: + enabled = "yes" if nacm.get("enable-nacm", True) else "no" + print(f"{'enabled':<25}: {enabled}") + print(f"{'default read access':<25}: {nacm.get('read-default', 'permit')}") + print(f"{'default write access':<25}: {nacm.get('write-default', 'deny')}") + print(f"{'default exec access':<25}: {nacm.get('exec-default', 'permit')}") + print(f"{'denied operations':<25}: {nacm.get('denied-operations', 0)}") + print(f"{'denied data writes':<25}: {nacm.get('denied-data-writes', 0)}") + print(f"{'denied notifications':<25}: {nacm.get('denied-notifications', 0)}") + print() + + # Users table + system = json.get("ietf-system:system", {}) + users = system.get("authentication", {}).get("user", []) + if users: + user_table = SimpleTable([ + Column('USER', min_width=12), + Column('SHELL'), + Column('LOGIN', flexible=True) + ], min_width=min_width) + + for user in users: + name = user.get("name", "") + shell_data = user.get("infix-system:shell", "false") + shell = shell_data.split(":")[-1] if ":" in shell_data else shell_data + + has_password = bool(user.get("password")) + has_keys = bool(user.get("authorized-key")) + if has_password and has_keys: + login = "password+key" + elif has_password: + login = "password" + elif has_keys: + login = "key" + else: + login = "-" + + user_table.row(name, shell, login) + + user_table.print() + print() + + # Groups table + groups = nacm.get("groups", {}).get("group", []) if nacm else [] + if groups: + group_table = SimpleTable([ + Column('GROUP', min_width=12), + Column('USERS', flexible=True) + ], min_width=min_width) + + for group in groups: + name = group.get("name", "") + members = " ".join(group.get("user-name", [])) + group_table.row(name, members) + + group_table.print() + + def show_system(json): """System information overivew""" if not json.get("ietf-system:system-state"): @@ -4903,6 +4970,8 @@ def main(): subparsers.add_parser('show-firewall-log', help='Show firewall log') \ .add_argument('limit', nargs='?', help='Last N lines, default: all') + subparsers.add_parser('show-nacm', help='Show NACM status and groups') + subparsers.add_parser('show-ntp', help='Show NTP status') \ .add_argument('-a', '--address', help='Show details for specific address') subparsers.add_parser('show-ntp-tracking', help='Show NTP tracking status') @@ -4966,6 +5035,8 @@ def main(): show_firewall_service(json_data, args.name) elif args.command == "show-firewall-log": show_firewall_logs(args.limit) + elif args.command == "show-nacm": + show_nacm(json_data) elif args.command == "show-ntp": show_ntp(json_data, args.address) elif args.command == "show-ntp-tracking": diff --git a/src/statd/python/yanger/ietf_system.py b/src/statd/python/yanger/ietf_system.py index 5cff7ed72..36a9613fb 100644 --- a/src/statd/python/yanger/ietf_system.py +++ b/src/statd/python/yanger/ietf_system.py @@ -263,27 +263,74 @@ def add_timezone(out): def add_users(out): - shadow_output = HOST.run_multiline(["getent", "shadow"], []) - users = [] + # Map shell paths to YANG identity names + shell_map = { + "/bin/bash": "infix-system:bash", + "/bin/sh": "infix-system:sh", + "/usr/bin/clish": "infix-system:clish", + "/bin/false": "infix-system:false", + "/sbin/nologin": "infix-system:false", + "/usr/sbin/nologin": "infix-system:false", + } + # Get users from /etc/passwd - include users with 1000 <= uid < 10000 (added by confd) + passwd_output = HOST.run_multiline(["getent", "passwd"], []) + passwd_users = {} + for line in passwd_output: + parts = line.split(':') + if len(parts) >= 7: + username = parts[0] + uid = int(parts[2]) if parts[2].isdigit() else 0 + shell = parts[6].strip() + if 1000 <= uid < 10000: + passwd_users[username] = shell_map.get(shell, "infix-system:false") + + # Get password hashes from shadow + shadow_output = HOST.run_multiline(["getent", "shadow"], []) + shadow_hashes = {} for line in shadow_output: parts = line.split(':') - if len(parts) < 2: - continue - username = parts[0] - password_hash = parts[1] - - # Skip any records that do not pass YANG validation - if (not password_hash or - password_hash.startswith('0') or - password_hash.startswith('*') or - password_hash.startswith('!')): - continue - user = {} - user["name"] = username - user["password"] = password_hash - users.append(user) + if len(parts) >= 2: + username = parts[0] + password_hash = parts[1] + # Only include valid password hashes (not locked/disabled) + if (password_hash and + not password_hash.startswith('*') and + not password_hash.startswith('!')): + shadow_hashes[username] = password_hash + + # Build user list from passwd users (1000 <= uid < 10000) + users = [] + for username, shell in passwd_users.items(): + user = {"name": username} + if username in shadow_hashes: + user["password"] = shadow_hashes[username] + user["infix-system:shell"] = shell + + # Read SSH authorized keys from /var/run/sshd/${user}.keys + keys_file = f"/var/run/sshd/{username}.keys" + keys_content = HOST.read(keys_file) + if keys_content: + authorized_keys = [] + for line in keys_content.splitlines(): + line = line.strip() + if not line or line.startswith('#'): + continue + parts = line.split(None, 2) + if len(parts) >= 2: + algorithm = parts[0] + key_data = parts[1] + # Use comment as key name, or generate one + key_name = parts[2] if len(parts) > 2 else f"{username}-key-{len(authorized_keys)}" + authorized_keys.append({ + "name": key_name, + "algorithm": algorithm, + "key-data": key_data + }) + if authorized_keys: + user["authorized-key"] = authorized_keys + users.append(user) insert(out, "authentication", "user", users) diff --git a/test/case/statd/system/ietf-system.json b/test/case/statd/system/ietf-system.json index beddd9296..a8dde70bf 100644 --- a/test/case/statd/system/ietf-system.json +++ b/test/case/statd/system/ietf-system.json @@ -5,7 +5,8 @@ "user": [ { "name": "admin", - "password": "$5$mI/zpOAqZYKLC2WU$i7iPzZiIjOjrBF3NyftS9CCq8dfYwHwrmUK097Jca9A" + "password": "$5$mI/zpOAqZYKLC2WU$i7iPzZiIjOjrBF3NyftS9CCq8dfYwHwrmUK097Jca9A", + "infix-system:shell": "infix-system:bash" } ] }, diff --git a/test/case/statd/system/operational.json b/test/case/statd/system/operational.json index 491289366..42832152d 100644 --- a/test/case/statd/system/operational.json +++ b/test/case/statd/system/operational.json @@ -39,7 +39,8 @@ "user": [ { "name": "admin", - "password": "$5$mI/zpOAqZYKLC2WU$i7iPzZiIjOjrBF3NyftS9CCq8dfYwHwrmUK097Jca9A" + "password": "$5$mI/zpOAqZYKLC2WU$i7iPzZiIjOjrBF3NyftS9CCq8dfYwHwrmUK097Jca9A", + "infix-system:shell": "infix-system:bash" } ] }, diff --git a/test/case/statd/system/system/run/getent_passwd b/test/case/statd/system/system/run/getent_passwd new file mode 100644 index 000000000..46fbec072 --- /dev/null +++ b/test/case/statd/system/system/run/getent_passwd @@ -0,0 +1,17 @@ +root:x:0:0:root:/root:/bin/bash +daemon:x:1:1:daemon:/usr/sbin:/bin/false +bin:x:2:2:bin:/bin:/bin/false +sys:x:3:3:sys:/dev:/bin/false +sync:x:4:100:sync:/bin:/bin/sync +mail:x:8:8:mail:/var/spool/mail:/bin/false +www-data:x:33:33:www-data:/var/www:/bin/false +sysrepo:x:60:60:sysrepo:/var:/bin/false +backup:x:34:34:backup:/var/backups:/bin/false +nobody:x:65534:65534:nobody:/home:/bin/false +yangnobody:x:333666:333666:Unauthenticated operations via RESTCONF:/:/bin/false +avahi:x:100:101::/:/bin/false +chrony:x:101:102:Time daemon:/run/chrony:/bin/false +dbus:x:102:103:DBus messagebus user:/run/dbus:/bin/false +frr:x:103:104:FRR user priv:/var/run/frr:/bin/false +sshd:x:105:106:SSH drop priv user:/var/empty:/bin/false +admin:x:1000:1000:Linux User,,,:/home/admin:/bin/bash From d35f4ed7bf64ef3792aef75a640d5d639c5a1771 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 10 Jan 2026 13:27:15 +0100 Subject: [PATCH 19/31] bin: relocate src/show.py to bin/show/ -- all tools in one place - relocate src/show.py to src/bin/show/ - Refactor container() to use run_sysrepo() instead of rolling its own - Replace sysrepocfg with copy which supports nacm - Rename run_sysrepocfg() -> get_json() Signed-off-by: Joachim Wiberg --- configs/aarch32_defconfig | 1 - configs/aarch32_minimal_defconfig | 1 - configs/aarch64_defconfig | 1 - configs/aarch64_minimal_defconfig | 1 - configs/riscv64_defconfig | 1 - configs/x86_64_defconfig | 1 - configs/x86_64_minimal_defconfig | 1 - package/Config.in | 1 - package/bin/bin.mk | 29 ++++- package/show/Config.in | 6 - package/show/show.mk | 19 --- src/{show => bin}/bash_completion.d/show | 0 src/bin/pyproject.toml | 18 +++ src/{show/show.py => bin/show/__init__.py} | 138 +++++++++++---------- src/confd/src/core.c | 2 +- src/klish-plugin-infix/xml/infix.xml | 12 +- src/show/LICENSE | 21 ---- src/show/Makefile | 11 -- 18 files changed, 126 insertions(+), 138 deletions(-) delete mode 100644 package/show/Config.in delete mode 100644 package/show/show.mk rename src/{show => bin}/bash_completion.d/show (100%) create mode 100644 src/bin/pyproject.toml rename src/{show/show.py => bin/show/__init__.py} (83%) delete mode 100644 src/show/LICENSE delete mode 100644 src/show/Makefile diff --git a/configs/aarch32_defconfig b/configs/aarch32_defconfig index 0edd0526d..084cc06af 100644 --- a/configs/aarch32_defconfig +++ b/configs/aarch32_defconfig @@ -141,7 +141,6 @@ BR2_PACKAGE_LOWDOWN=y BR2_PACKAGE_MCD=y BR2_PACKAGE_MDNS_ALIAS=y BR2_PACKAGE_ONIEPROM=y -BR2_PACKAGE_SHOW=y BR2_PACKAGE_ROUSETTE=y BR2_PACKAGE_RAUC_INSTALLATION_STATUS=y IMAGE_ITB_AUX=y diff --git a/configs/aarch32_minimal_defconfig b/configs/aarch32_minimal_defconfig index 639f37300..4ecf798e3 100644 --- a/configs/aarch32_minimal_defconfig +++ b/configs/aarch32_minimal_defconfig @@ -141,7 +141,6 @@ BR2_PACKAGE_LOWDOWN=y BR2_PACKAGE_MCD=y BR2_PACKAGE_MDNS_ALIAS=y BR2_PACKAGE_ONIEPROM=y -BR2_PACKAGE_SHOW=y BR2_PACKAGE_ROUSETTE=y BR2_PACKAGE_RAUC_INSTALLATION_STATUS=y IMAGE_ITB_AUX=y diff --git a/configs/aarch64_defconfig b/configs/aarch64_defconfig index e4fcc51d3..46171f0bb 100644 --- a/configs/aarch64_defconfig +++ b/configs/aarch64_defconfig @@ -179,7 +179,6 @@ BR2_PACKAGE_PODMAN=y BR2_PACKAGE_PODMAN_DRIVER_BTRFS=y BR2_PACKAGE_PODMAN_DRIVER_DEVICEMAPPER=y BR2_PACKAGE_PODMAN_DRIVER_VFS=y -BR2_PACKAGE_SHOW=y BR2_PACKAGE_TETRIS=y BR2_PACKAGE_ROUSETTE=y BR2_PACKAGE_RAUC_INSTALLATION_STATUS=y diff --git a/configs/aarch64_minimal_defconfig b/configs/aarch64_minimal_defconfig index d4cb99c67..19bcb0757 100644 --- a/configs/aarch64_minimal_defconfig +++ b/configs/aarch64_minimal_defconfig @@ -131,7 +131,6 @@ BR2_PACKAGE_CONFD=y BR2_PACKAGE_CONFD_TEST_MODE=y BR2_PACKAGE_GENCERT=y BR2_PACKAGE_STATD=y -BR2_PACKAGE_SHOW=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 30ddff4b1..c23e2ee68 100644 --- a/configs/riscv64_defconfig +++ b/configs/riscv64_defconfig @@ -203,7 +203,6 @@ BR2_PACKAGE_PODMAN=y BR2_PACKAGE_PODMAN_DRIVER_BTRFS=y BR2_PACKAGE_PODMAN_DRIVER_DEVICEMAPPER=y BR2_PACKAGE_PODMAN_DRIVER_VFS=y -BR2_PACKAGE_SHOW=y BR2_PACKAGE_TETRIS=y BR2_PACKAGE_ROUSETTE=y BR2_PACKAGE_RAUC_INSTALLATION_STATUS=y diff --git a/configs/x86_64_defconfig b/configs/x86_64_defconfig index 9a2e604c4..8b7796f19 100644 --- a/configs/x86_64_defconfig +++ b/configs/x86_64_defconfig @@ -176,7 +176,6 @@ BR2_PACKAGE_PODMAN=y BR2_PACKAGE_PODMAN_DRIVER_BTRFS=y BR2_PACKAGE_PODMAN_DRIVER_DEVICEMAPPER=y BR2_PACKAGE_PODMAN_DRIVER_VFS=y -BR2_PACKAGE_SHOW=y BR2_PACKAGE_TETRIS=y BR2_PACKAGE_ROUSETTE=y BR2_PACKAGE_RAUC_INSTALLATION_STATUS=y diff --git a/configs/x86_64_minimal_defconfig b/configs/x86_64_minimal_defconfig index 24ed73f2d..088ac57cd 100644 --- a/configs/x86_64_minimal_defconfig +++ b/configs/x86_64_minimal_defconfig @@ -143,7 +143,6 @@ BR2_PACKAGE_LOWDOWN=y BR2_PACKAGE_MCD=y BR2_PACKAGE_MDNS_ALIAS=y BR2_PACKAGE_ONIEPROM=y -BR2_PACKAGE_SHOW=y BR2_PACKAGE_ROUSETTE=y BR2_PACKAGE_RAUC_INSTALLATION_STATUS=y IMAGE_ITB_AUX=y diff --git a/package/Config.in b/package/Config.in index bf0dcee4d..71cf73cc7 100644 --- a/package/Config.in +++ b/package/Config.in @@ -34,7 +34,6 @@ source "$BR2_EXTERNAL_INFIX_PATH/package/podman/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/python-libyang/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/python-yangdoc/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/skeleton-init-finit/Config.in" -source "$BR2_EXTERNAL_INFIX_PATH/package/show/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/tetris/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/libyang-cpp/Config.in" source "$BR2_EXTERNAL_INFIX_PATH/package/sysrepo-cpp/Config.in" diff --git a/package/bin/bin.mk b/package/bin/bin.mk index 49856cfce..65e7ca1ac 100644 --- a/package/bin/bin.mk +++ b/package/bin/bin.mk @@ -10,7 +10,9 @@ BIN_SITE = $(BR2_EXTERNAL_INFIX_PATH)/src/bin BIN_LICENSE = BSD-3-Clause BIN_LICENSE_FILES = LICENSE BIN_REDISTRIBUTE = NO -BIN_DEPENDENCIES = sysrepo libite +BIN_DEPENDENCIES = sysrepo libite \ + host-python3 python3 host-python-pypa-build host-python-installer \ + host-python-poetry-core BIN_CONF_OPTS = --disable-silent-rules BIN_AUTORECONF = YES @@ -19,7 +21,30 @@ CFLAGS="$(INFIX_CFLAGS)" endef define BIN_PERMISSIONS - /usr/bin/copy d 04750 root sysrepo - - - - - + /usr/bin/copy d 04750 root klish - - - - - endef +define BIN_BUILD_PYTHON + cd $(BIN_SITE) && \ + $(PKG_PYTHON_PEP517_ENV) $(HOST_DIR)/bin/python3 $(PKG_PYTHON_PEP517_BUILD_CMD) -o $(@D)/dist + mkdir -p $(TARGET_DIR)/usr/bin + rm -f $(TARGET_DIR)/usr/bin/show + cd $(@D) && \ + $(HOST_DIR)/bin/python3 $(TOPDIR)/support/scripts/pyinstaller.py \ + dist/*.whl \ + --interpreter=/usr/bin/python3 \ + --script-kind=posix \ + --purelib=$(TARGET_DIR)/usr/lib/python$(PYTHON3_VERSION_MAJOR)/site-packages \ + --headers=$(TARGET_DIR)/usr/include/python$(PYTHON3_VERSION_MAJOR) \ + --scripts=$(TARGET_DIR)/usr/bin \ + --data=$(TARGET_DIR) +endef +BIN_POST_INSTALL_TARGET_HOOKS += BIN_BUILD_PYTHON + +define BIN_INSTALL_BASH_COMPLETION + install -D $(@D)/bash_completion.d/show \ + $(TARGET_DIR)/etc/bash_completion.d/show +endef +BIN_POST_INSTALL_TARGET_HOOKS += BIN_INSTALL_BASH_COMPLETION + $(eval $(autotools-package)) diff --git a/package/show/Config.in b/package/show/Config.in deleted file mode 100644 index 2cc1dc233..000000000 --- a/package/show/Config.in +++ /dev/null @@ -1,6 +0,0 @@ -config BR2_PACKAGE_SHOW - bool "show" - help - Tool to retrieve and present operational data from sysrepo. - - https://github.com/kernelkit/infix diff --git a/package/show/show.mk b/package/show/show.mk deleted file mode 100644 index 3db961be7..000000000 --- a/package/show/show.mk +++ /dev/null @@ -1,19 +0,0 @@ -################################################################################ -# -# show -# -################################################################################ - -SHOW_VERSION = 1.0 -SHOW_LICENSE = MIT -SHOW_LICENSE_FILES = LICENSE -SHOW_SITE_METHOD = local -SHOW_SITE = $(BR2_EXTERNAL_INFIX_PATH)/src/show -SHOW_REDISTRIBUTE = NO - -define SHOW_INSTALL_TARGET_CMDS - $(TARGET_MAKE_ENV) $(TARGET_CONFIGURE_OPTS) $(MAKE) -C $(@D) \ - DESTDIR="$(TARGET_DIR)" install -endef - -$(eval $(generic-package)) diff --git a/src/show/bash_completion.d/show b/src/bin/bash_completion.d/show similarity index 100% rename from src/show/bash_completion.d/show rename to src/bin/bash_completion.d/show diff --git a/src/bin/pyproject.toml b/src/bin/pyproject.toml new file mode 100644 index 000000000..a8553a70c --- /dev/null +++ b/src/bin/pyproject.toml @@ -0,0 +1,18 @@ +[tool.poetry] +name = "infix-show" +version = "1.0" +description = "Infix show commands" +license = "MIT" +packages = [ + { include = "show" } +] +authors = [ + "KernelKit developers" +] + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry.scripts] +show = "show:main" diff --git a/src/show/show.py b/src/bin/show/__init__.py similarity index 83% rename from src/show/show.py rename to src/bin/show/__init__.py index fccafd707..3c1c79cf7 100755 --- a/src/show/show.py +++ b/src/bin/show/__init__.py @@ -10,26 +10,25 @@ RAW_OUTPUT = False -def run_sysrepocfg(xpath: str, datastore: str = "operational") -> dict: + +def get_json(xpath: str, datastore: str = "operational") -> dict: if not isinstance(xpath, str) or not xpath.startswith("/"): print("Invalid XPATH. It must be a valid string starting with '/'.") return {} - safe_xpath = shlex.quote(xpath) - try: - result = subprocess.run([ - "sysrepocfg", "-f", "json", "-X", "-d", datastore, "-x", safe_xpath - ], capture_output=True, text=True, check=True) + result = subprocess.run(["copy", datastore, "-x", shlex.quote(xpath)], + capture_output=True, text=True, check=True) json_data = json.loads(result.stdout) return json_data except subprocess.CalledProcessError as e: - print(f"Error running sysrepocfg: {e}") + print(f"Error running copy: {e}") return {} except json.JSONDecodeError as e: print(f"Error parsing JSON output: {e}") return {} + def cli_pretty(json_data: dict, command: str, *args: str): if not command or not all(isinstance(arg, str) for arg in args): print("Invalid command or arguments. All arguments must be strings.") @@ -46,8 +45,9 @@ def cli_pretty(json_data: dict, command: str, *args: str): except subprocess.CalledProcessError as e: print(f"Error running cli-pretty: {e}") + def dhcp(args: List[str]) -> None: - data = run_sysrepocfg("/infix-dhcp-server:dhcp-server") + data = get_json("/infix-dhcp-server:dhcp-server") if not data: print("No interface data retrieved.") return @@ -57,8 +57,9 @@ def dhcp(args: List[str]) -> None: return cli_pretty(data, "show-dhcp-server") + def hardware(args: List[str]) -> None: - data = run_sysrepocfg("/ietf-hardware:hardware") + data = get_json("/ietf-hardware:hardware") if not data: print("No hardware data retrieved.") return @@ -68,6 +69,7 @@ def hardware(args: List[str]) -> None: return cli_pretty(data, "show-hardware") + def ntp(args: List[str]) -> None: # Create argument parser for ntp subcommands parser = argparse.ArgumentParser(prog='show ntp', add_help=False) @@ -97,8 +99,8 @@ def ntp(args: List[str]) -> None: # Default: show ntp (no subcommand or address) # Fetch both client and server operational data - client_data = run_sysrepocfg("/system-state/ntp") - server_data = run_sysrepocfg("/ietf-ntp:ntp") + client_data = get_json("/system-state/ntp") + server_data = get_json("/ietf-ntp:ntp") # Merge into single data structure data = {} @@ -121,8 +123,9 @@ def ntp(args: List[str]) -> None: # Default show ntp - summary view (no address support at top level) cli_pretty(data, "show-ntp") + def ntp_tracking(args: List[str]) -> None: - data = run_sysrepocfg("/ietf-ntp:ntp") + data = get_json("/ietf-ntp:ntp") if not data: print("No ntp server data retrieved.") return @@ -132,8 +135,9 @@ def ntp_tracking(args: List[str]) -> None: return cli_pretty(data, "show-ntp-tracking") + def ntp_source(args: List[str]) -> None: - data = run_sysrepocfg("/ietf-ntp:ntp") + data = get_json("/ietf-ntp:ntp") if not data: print("No ntp server data retrieved.") return @@ -148,6 +152,7 @@ def ntp_source(args: List[str]) -> None: else: cli_pretty(data, "show-ntp-source") + def is_valid_interface_name(interface_name: str) -> bool: """ Validates a Linux network interface name. @@ -158,14 +163,15 @@ def is_valid_interface_name(interface_name: str) -> bool: pattern = r'^[a-zA-Z0-9._-]+$' return bool(re.match(pattern, interface_name)) + def interface(args: List[str]) -> None: - data = run_sysrepocfg("/ietf-interfaces:interfaces") + data = get_json("/ietf-interfaces:interfaces") if not data: print("No interface data retrieved.") return # Also fetch routing interface list for forwarding indication - routing_data = run_sysrepocfg("/ietf-routing:routing") + routing_data = get_json("/ietf-routing:routing") if routing_data: # Merge routing data into the main data structure data.update(routing_data) @@ -185,8 +191,9 @@ def interface(args: List[str]) -> None: else: print("Too many arguments provided. Only one interface name is expected.") + def stp(args: List[str]) -> None: - data = run_sysrepocfg("/ietf-interfaces:interfaces") + data = get_json("/ietf-interfaces:interfaces") if not data: print("No interface data retrieved.") return @@ -196,8 +203,9 @@ def stp(args: List[str]) -> None: return cli_pretty(data, "show-bridge-stp") + def software(args: List[str]) -> None: - data = run_sysrepocfg("/ietf-system:system-state/infix-system:software") + data = get_json("/ietf-system:system-state/infix-system:software") if not data: print("No software data retrieved.") return @@ -217,8 +225,9 @@ def software(args: List[str]) -> None: else: print("Too many arguments provided. Only one name is expected.") + def boot_order(args: List[str]) -> None: - data = run_sysrepocfg("/ietf-system:system-state/infix-system:software") + data = get_json("/ietf-system:system-state/infix-system:software") if not data: print("No software data retrieved.") return @@ -230,8 +239,9 @@ def boot_order(args: List[str]) -> None: except (KeyError, TypeError): print("No boot order data available.") + def services(args: List[str]) -> None: - data = run_sysrepocfg("/ietf-system:system-state/infix-system:services") + data = get_json("/ietf-system:system-state/infix-system:services") if not data: print("No service data retrieved.") return @@ -240,7 +250,8 @@ def services(args: List[str]) -> None: print(json.dumps(data, indent=2)) return - cli_pretty(data, f"show-services") + cli_pretty(data, "show-services") + def container(args: List[str]) -> None: """Handle show container [name] @@ -249,44 +260,39 @@ def container(args: List[str]) -> None: (none) - Show all containers in table format name - Show detailed view of specific container """ - data = run_sysrepocfg("/infix-containers:containers") + data = get_json("/infix-containers:containers") if not data: print("No container data retrieved.") return - # Fetch interface data for bridge resolution (both table and detailed views) + # Fetch interface data for bridge resolution (table and detailed views) # Fetch operational interface data - iface_oper = run_sysrepocfg("/ietf-interfaces:interfaces") + iface_oper = get_json("/ietf-interfaces:interfaces") # Also fetch config data for veth peer information (not in operational) - try: - result = subprocess.run([ - "sysrepocfg", "-f", "json", "-X", "-d", "running", "-x", "/ietf-interfaces:interfaces" - ], capture_output=True, text=True, check=True) - iface_config = json.loads(result.stdout) - - # Merge config veth peer info into operational data - if iface_oper and iface_config: - oper_ifaces = iface_oper.get('ietf-interfaces:interfaces', {}).get('interface', []) - config_ifaces = iface_config.get('ietf-interfaces:interfaces', {}).get('interface', []) - - # Create a map of config interfaces - config_map = {iface['name']: iface for iface in config_ifaces} - - # Merge veth peer info from config into operational - for oper_iface in oper_ifaces: - name = oper_iface.get('name') - if name in config_map: - config_iface = config_map[name] - # Add veth peer if it exists in config but not in operational - if 'infix-interfaces:veth' in config_iface and 'infix-interfaces:veth' not in oper_iface: - oper_iface['infix-interfaces:veth'] = config_iface['infix-interfaces:veth'] - - data.update(iface_oper) - except (subprocess.CalledProcessError, json.JSONDecodeError): + iface_config = get_json("/ietf-interfaces:interfaces", "running") + + # Merge config veth peer info into operational data + if iface_oper and iface_config: + oper_ifaces = iface_oper.get('ietf-interfaces:interfaces', {}).get('interface', []) + config_ifaces = iface_config.get('ietf-interfaces:interfaces', {}).get('interface', []) + + # Create a map of config interfaces + config_map = {iface['name']: iface for iface in config_ifaces} + + # Merge veth peer info from config into operational + for oper_iface in oper_ifaces: + name = oper_iface.get('name') + if name in config_map: + config_iface = config_map[name] + # Add veth peer if it exists in config but not in operational + if 'infix-interfaces:veth' in config_iface and 'infix-interfaces:veth' not in oper_iface: + oper_iface['infix-interfaces:veth'] = config_iface['infix-interfaces:veth'] + + data.update(iface_oper) + elif iface_oper: # If config fetch fails, just use operational data - if iface_oper: - data.update(iface_oper) + data.update(iface_oper) if RAW_OUTPUT: print(json.dumps(data, indent=2)) @@ -300,6 +306,7 @@ def container(args: List[str]) -> None: else: print("Too many arguments provided. Expected: show container [name]") + def bfd(args: List[str]) -> None: """Handle show bfd [subcommand] [peer] [brief] @@ -309,8 +316,7 @@ def bfd(args: List[str]) -> None: peers brief - Show BFD peers in brief format peer - Show specific BFD peer details """ - # Fetch operational data from sysrepocfg - data = run_sysrepocfg("/ietf-routing:routing/control-plane-protocols/control-plane-protocol") + data = get_json("/ietf-routing:routing/control-plane-protocols/control-plane-protocol") if not data: print("No BFD data retrieved.") return @@ -344,7 +350,7 @@ def bfd(args: List[str]) -> None: # For brief view, fetch interface data to show interface:ip format if brief_flag: - ifaces_data = run_sysrepocfg("/ietf-interfaces:interfaces") + ifaces_data = get_json("/ietf-interfaces:interfaces") if ifaces_data: data['_interfaces'] = ifaces_data @@ -361,6 +367,7 @@ def bfd(args: List[str]) -> None: else: print(f"Unknown BFD subcommand: {subcommand}") + def ospf(args: List[str]) -> None: """Handle show ospf [subcommand] [ifname] [detail] @@ -374,8 +381,7 @@ def ospf(args: List[str]) -> None: ifname - Interface name (for interfaces subcommand) detail - Show detailed information (Cisco-style) """ - # Fetch operational data from sysrepocfg - data = run_sysrepocfg("/ietf-routing:routing/control-plane-protocols/control-plane-protocol") + data = get_json("/ietf-routing:routing/control-plane-protocols/control-plane-protocol") if not data: print("No OSPF data retrieved.") return @@ -407,12 +413,12 @@ def ospf(args: List[str]) -> None: data['_ifname'] = ifname # For detailed interface view, fetch additional data (interfaces and BFD) if subcommand == "interface" or subcommand == "interfaces": - ifaces_data = run_sysrepocfg("/ietf-interfaces:interfaces") + ifaces_data = get_json("/ietf-interfaces:interfaces") if ifaces_data: data['_interfaces'] = ifaces_data # Fetch BFD data for per-interface status - routing_data = run_sysrepocfg("/ietf-routing:routing/control-plane-protocols/control-plane-protocol") + routing_data = get_json("/ietf-routing:routing/control-plane-protocols/control-plane-protocol") if routing_data: data['_routing'] = routing_data @@ -428,6 +434,7 @@ def ospf(args: List[str]) -> None: else: print(f"Unknown OSPF subcommand: {subcommand}") + def rip(args: List[str]) -> None: """Handle show rip [subcommand] [ifname] @@ -440,8 +447,7 @@ def rip(args: List[str]) -> None: Optional: ifname - Interface name (for interface subcommand) """ - # Fetch operational data from sysrepocfg - data = run_sysrepocfg("/ietf-routing:routing/control-plane-protocols/control-plane-protocol") + data = get_json("/ietf-routing:routing/control-plane-protocols/control-plane-protocol") if not data: data = {} @@ -469,10 +475,11 @@ def rip(args: List[str]) -> None: else: print(f"Unknown RIP subcommand: {subcommand}") + def routes(args: List[str]): ip_version = args[0] if args and args[0] in ["ipv4", "ipv6"] else "ipv4" - data = run_sysrepocfg("/ietf-routing:routing/ribs") + data = get_json("/ietf-routing:routing/ribs") if not data: print("No route data retrieved.") return @@ -481,8 +488,9 @@ def routes(args: List[str]): return cli_pretty(data, "show-routing-table", "-i", ip_version) + def lldp(args: List[str]): - data = run_sysrepocfg("/ieee802-dot1ab-lldp:lldp") + data = get_json("/ieee802-dot1ab-lldp:lldp") if not data: print("No lldp data retrieved.") return @@ -494,13 +502,13 @@ def lldp(args: List[str]): def system(args: List[str]) -> None: # Get system state from sysrepo - data = run_sysrepocfg("/ietf-system:system-state") + data = get_json("/ietf-system:system-state") if not data: print("No system data retrieved.") return # Get hardware data (including thermal sensors) - hardware_data = run_sysrepocfg("/ietf-hardware:hardware") + hardware_data = get_json("/ietf-hardware:hardware") # Augment with runtime data runtime = {} @@ -596,8 +604,9 @@ def human_readable(kib_val): cli_pretty(data, "show-system") + def nacm(args: List[str]) -> None: - data = run_sysrepocfg("/ietf-netconf-acm:nacm", "running") + data = get_json("/ietf-netconf-acm:nacm", "running") if not data: print("No NACM data retrieved.") return @@ -614,6 +623,7 @@ def nacm(args: List[str]) -> None: return cli_pretty(data, "show-nacm") + def execute_command(command: str, args: List[str]): command_mapping = { 'bfd': bfd, diff --git a/src/confd/src/core.c b/src/confd/src/core.c index fab04e787..b17ed8dbd 100644 --- a/src/confd/src/core.c +++ b/src/confd/src/core.c @@ -15,7 +15,7 @@ static int startup_save(sr_session_ctx_t *session, uint32_t sub_id, const char * if (systemf("runlevel >/dev/null 2>&1")) return SR_ERR_OK; - if (systemf("sysrepocfg -X/cfg/startup-config.cfg -d startup -f json")) + if (systemf("copy startup /cfg/startup-config.cfg")) return SR_ERR_SYS; return SR_ERR_OK; diff --git a/src/klish-plugin-infix/xml/infix.xml b/src/klish-plugin-infix/xml/infix.xml index 3daf60ed7..7fca67fe9 100644 --- a/src/klish-plugin-infix/xml/infix.xml +++ b/src/klish-plugin-infix/xml/infix.xml @@ -650,12 +650,12 @@ - sysrepocfg -X -d operational -x /infix-firewall:firewall -f json -t 60 | /usr/libexec/statd/cli-pretty show-firewall-log $KLISH_PARAM_limit |pager +G + copy operational -x /infix-firewall:firewall | /usr/libexec/statd/cli-pretty show-firewall-log $KLISH_PARAM_limit |pager +G - sysrepocfg -X -d operational -x /infix-firewall:firewall -f json -t 60 | /usr/libexec/statd/cli-pretty show-firewall-matrix + copy operational -x /infix-firewall:firewall | /usr/libexec/statd/cli-pretty show-firewall-matrix @@ -663,7 +663,7 @@ - sysrepocfg -X -d operational -x /infix-firewall:firewall -f json -t 60 | /usr/libexec/statd/cli-pretty show-firewall-zone "$KLISH_PARAM_name" |pager + copy operational -x /infix-firewall:firewall | /usr/libexec/statd/cli-pretty show-firewall-zone "$KLISH_PARAM_name" |pager @@ -671,7 +671,7 @@ - sysrepocfg -X -d operational -x /infix-firewall:firewall -f json -t 60 | /usr/libexec/statd/cli-pretty show-firewall-policy "$KLISH_PARAM_name" |pager + copy operational -x /infix-firewall:firewall | /usr/libexec/statd/cli-pretty show-firewall-policy "$KLISH_PARAM_name" |pager @@ -679,12 +679,12 @@ - sysrepocfg -X -d operational -x /infix-firewall:firewall -f json -t 60 | /usr/libexec/statd/cli-pretty show-firewall-service "$KLISH_PARAM_name" |pager + copy operational -x /infix-firewall:firewall | /usr/libexec/statd/cli-pretty show-firewall-service "$KLISH_PARAM_name" |pager - sysrepocfg -X -d operational -x /infix-firewall:firewall -f json -t 60 | /usr/libexec/statd/cli-pretty show-firewall |pager + copy operational -x /infix-firewall:firewall | /usr/libexec/statd/cli-pretty show-firewall |pager diff --git a/src/show/LICENSE b/src/show/LICENSE deleted file mode 100644 index fa7d98110..000000000 --- a/src/show/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Richard Alpe - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/src/show/Makefile b/src/show/Makefile deleted file mode 100644 index 54c31e416..000000000 --- a/src/show/Makefile +++ /dev/null @@ -1,11 +0,0 @@ -.PHONY: all clean distclean install - -all: - -clean: - -distclean: clean - -install: - install -D show.py $(DESTDIR)/bin/show - install -D bash_completion.d/show $(DESTDIR)/etc/bash_completion.d/show From e5eb88d547da828fcd5d69e99daffdcdadfbf34e Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 12 Jan 2026 06:38:47 +0100 Subject: [PATCH 20/31] cli: replace sysrepocfg with copy and rpc tools Signed-off-by: Joachim Wiberg --- board/common/rootfs/usr/bin/show-legacy | 16 +++++------ src/bin/support | 4 +-- src/klish-plugin-infix/src/infix.c | 35 +++++-------------------- 3 files changed, 16 insertions(+), 39 deletions(-) diff --git a/board/common/rootfs/usr/bin/show-legacy b/board/common/rootfs/usr/bin/show-legacy index 82a5ee9c4..bb6ba762f 100755 --- a/board/common/rootfs/usr/bin/show-legacy +++ b/board/common/rootfs/usr/bin/show-legacy @@ -91,7 +91,7 @@ EOF is_dhcp_running() { - sysrepocfg -X -f json -m infix-dhcp-server | jq -r ' + copy operational -x /infix-dhcp-server:dhcp-server | jq -r ' ."infix-dhcp-server:dhcp-server".enabled as $global | if ."infix-dhcp-server:dhcp-server".subnet? then (."infix-dhcp-server:dhcp-server".subnet[] | @@ -110,15 +110,15 @@ dhcp() case $1 in detail) - sysrepocfg -f json -X -d operational -m infix-dhcp-server | \ + copy operational -x /infix-dhcp-server:dhcp-server | \ jq -C . ;; stat*) - sysrepocfg -f json -X -d operational -m infix-dhcp-server | \ + copy operational -x /infix-dhcp-server:dhcp-server | \ /usr/libexec/statd/cli-pretty "show-dhcp-server" -s ;; *) - sysrepocfg -f json -X -d operational -m infix-dhcp-server | \ + copy operational -x /infix-dhcp-server:dhcp-server | \ /usr/libexec/statd/cli-pretty "show-dhcp-server" ;; esac @@ -182,13 +182,13 @@ ifaces() else if [ $# -gt 0 ]; then for iface in $*; do - sysrepocfg -f json -X -d operational -x \ + copy operational -x \ "/ietf-interfaces:interfaces/interface[name='$iface']" | \ /usr/libexec/statd/cli-pretty "show-interfaces" -n "$iface" done return fi - sysrepocfg -f json -X -d operational -m ietf-interfaces | \ + copy operational -x /ietf-interfaces:interfaces | \ /usr/libexec/statd/cli-pretty "show-interfaces" fi } @@ -227,7 +227,7 @@ rstp() stp() { - sysrepocfg -f json -X -d operational -m ietf-interfaces | \ + copy operational -x /ietf-interfaces:interfaces | \ /usr/libexec/statd/cli-pretty "show-bridge-stp" } @@ -248,7 +248,7 @@ routes() else arg="-i ipv4" fi - sysrepocfg -f json -X -d operational -x "/ietf-routing:routing/ribs" | \ + copy operational -x /ietf-routing:routing/ribs | \ /usr/libexec/statd/cli-pretty "show-routing-table" $arg } diff --git a/src/bin/support b/src/bin/support index e9865b5d1..805eca8e1 100755 --- a/src/bin/support +++ b/src/bin/support @@ -156,8 +156,8 @@ cmd_collect() collect uptime.txt uptime # Configuration files - collect running-config.json sysrepocfg -f json -d running -X - collect operational-config.json sysrepocfg -f json -d operational -X + collect running-config.json copy running + collect operational-config.json copy operational # Sysrepo YANG modules if command -v sysrepoctl >/dev/null 2>&1; then diff --git a/src/klish-plugin-infix/src/infix.c b/src/klish-plugin-infix/src/infix.c index 2db7d7980..77ec19a22 100644 --- a/src/klish-plugin-infix/src/infix.c +++ b/src/klish-plugin-infix/src/infix.c @@ -301,11 +301,9 @@ static const char *valid_boot_target(const kparg_t *parg) int infix_set_boot_order(kcontext_t *ctx) { - char tmpfile[] = "/tmp/boot-order-XXXXXX"; kpargv_t *pargv = kcontext_pargv(ctx); const char *targets[3]; - int fd, rc = 0; - FILE *fp; + int rc; targets[0] = valid_boot_target(kpargv_find(pargv, "first")); targets[1] = valid_boot_target(kpargv_find(pargv, "second")); @@ -316,32 +314,11 @@ int infix_set_boot_order(kcontext_t *ctx) return -1; } - fd = mkstemp(tmpfile); - if (fd == -1) - goto fail; - - fp = fdopen(fd, "w"); - if (!fp) { - close(fd); - unlink(tmpfile); - fail: - fprintf(stderr, ERRMSG "failed creating temporary file\n"); - return -1; - } - - fputs("{\"infix-system:set-boot-order\":{\"boot-order\":[", fp); - for (size_t i = 0; i < NELEMS(targets); i++) { - if (!targets[i]) - continue; - - fprintf(fp, "%s\"%s\"", i > 0 ? "," : "", targets[i]); - } - fputs("]}}", fp); - - fclose(fp); - - rc = systemf("sysrepocfg -R %s -fjson 2>&1", tmpfile); - unlink(tmpfile); + /* Build RPC command with CLI arguments */ + rc = systemf("rpc /infix-system:set-boot-order%s%s%s%s%s%s 2>&1", + targets[0] ? " boot-order " : "", targets[0] ? targets[0] : "", + targets[1] ? " boot-order " : "", targets[1] ? targets[1] : "", + targets[2] ? " boot-order " : "", targets[2] ? targets[2] : ""); return rc; } From 978d49f5330f2d33d8e7ff7fa3ef152f7ef3dc11 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 12 Jan 2026 16:39:48 +0100 Subject: [PATCH 21/31] bin: add -f flag top force copy to existing file Otherwise the file will not be updated by confd on datastore copy: Jan 12 14:26:37 foo confd[3410]: Overwrite existing file /cfg/startup-config.cfg (y/N)? Jan 12 14:26:37 foo confd[3410]: Error: OK, aborting.:Inappropriate ioctl for device Signed-off-by: Joachim Wiberg --- src/bin/copy.c | 11 ++++++++--- src/confd/src/core.c | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/bin/copy.c b/src/bin/copy.c index 48ccf7433..237eb7e2e 100644 --- a/src/bin/copy.c +++ b/src/bin/copy.c @@ -43,6 +43,7 @@ static const char *prognm; static const char *remote_user; static char *xpath = "/*"; static int debug; +static int force; static int timeout; static int dry_run; static int sanitize; @@ -543,8 +544,8 @@ static int resolve_dst(const char **dst, const struct infix_ds **ds, char **path return 1; } - if (!*ds && !access(*path, F_OK) && !yorn("Overwrite existing file %s", *path)) { - warn("OK, aborting."); + if (!force && !*ds && !access(*path, F_OK) && !yorn("Overwrite existing file %s", *path)) { + warnx("OK, aborting."); return 1; } @@ -603,6 +604,7 @@ static int usage(int rc) "\n" "Options:\n" " -d Enable debug mode, verbose output on stderr\n" + " -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" " -s Sanitize paths for CLI use (restrict path traversal)\n" @@ -741,11 +743,14 @@ static int copy_main(int argc, char *argv[]) timeout = fgetint("/etc/default/confd", "=", "CONFD_TIMEOUT"); - while ((c = getopt(argc, argv, "dhnst:u:vx:")) != EOF) { + while ((c = getopt(argc, argv, "dfhnst:u:vx:")) != EOF) { switch(c) { case 'd': debug = 1; break; + case 'f': + force = 1; + break; case 'h': return usage(0); case 'n': diff --git a/src/confd/src/core.c b/src/confd/src/core.c index b17ed8dbd..55d965244 100644 --- a/src/confd/src/core.c +++ b/src/confd/src/core.c @@ -15,7 +15,7 @@ static int startup_save(sr_session_ctx_t *session, uint32_t sub_id, const char * if (systemf("runlevel >/dev/null 2>&1")) return SR_ERR_OK; - if (systemf("copy startup /cfg/startup-config.cfg")) + if (systemf("copy -f startup /cfg/startup-config.cfg")) return SR_ERR_SYS; return SR_ERR_OK; From fb022bfc6ca63826b5f84e34104fb08d9861c2fa Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Wed, 14 Jan 2026 09:50:33 +0100 Subject: [PATCH 22/31] test/infamy: ensure residual state is cleaned up in dhcp server Fixes #1344 Signed-off-by: Joachim Wiberg --- test/infamy/dhcp.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/test/infamy/dhcp.py b/test/infamy/dhcp.py index 9a3aacd14..099471125 100644 --- a/test/infamy/dhcp.py +++ b/test/infamy/dhcp.py @@ -17,10 +17,14 @@ def __init__(self, netns, start='192.168.0.100', end='192.168.0.110', self._create_files(start, end, netmask, ip, router, prefix, hostname) def __del__(self): - #print(self.config_file) - #os.unlink(self.config_file) - #os.unlink(self.leases_file) - pass + """Clean up config and lease files""" + try: + if os.path.exists(self.config_file): + os.unlink(self.config_file) + if os.path.exists(self.leases_file): + os.unlink(self.leases_file) + except: + pass def __enter__(self): self.start() From 687c26da083f729bf6076aef07cc178feba4780c Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 13 Jan 2026 16:57:02 +0100 Subject: [PATCH 23/31] test/infamy: Use PATCH for RESTCONF merge semantics Switch RESTCONF from PUT to PATCH for configuration updates. This gives RESTCONF the same merge behavior as NETCONF edit-config, allowing NACM to correctly enforce path-specific permissions instead of requiring write access to the entire datastore. Fixes #617 Signed-off-by: Joachim Wiberg --- test/infamy/netconf.py | 12 +-- test/infamy/restconf.py | 170 ++++++++++++++++++++++++++++++---------- 2 files changed, 134 insertions(+), 48 deletions(-) diff --git a/test/infamy/netconf.py b/test/infamy/netconf.py index ebeacb8a2..06fe0de97 100644 --- a/test/infamy/netconf.py +++ b/test/infamy/netconf.py @@ -281,7 +281,7 @@ def get_config_dict(self, xpath): """Get Python dictionary version of XML configuration""" return self.get_config(xpath).print_dict() - def put_config(self, edit): + def put_config(self, edit, retries=3): """Send XML configuration over NETCONF""" yang2nc = { "none": None, @@ -298,7 +298,7 @@ def put_config(self, edit): f"nc:operation=\"{dst}\"" if dst else "") last_error = None - for _ in range(0, 3): + for _ in range(0, retries): try: self.ncc.edit_config(xml, default_operation='merge') last_error = None @@ -313,7 +313,7 @@ def put_config(self, edit): if last_error is not None: raise last_error - def put_config_dicts(self, models): + def put_config_dicts(self, models, retries=3): """PUT full configuration of all models to running-config""" config = "" infer_put_dict(self.name, models) @@ -328,9 +328,9 @@ def put_config_dicts(self, models): lyd = mod.parse_data_dict(models[model], no_state=True, validate=False) config += lyd.print_mem("xml", with_siblings=True, pretty=False) + "\n" # print(f"Send new XML config: {config}") - return self.put_config(config) + return self.put_config(config, retries=retries) - def put_config_dict(self, modname, edit): + def put_config_dict(self, modname, edit, retries=3): """Convert Python dictionary to XMl and send as configuration""" try: mod = self.ly.get_module(modname) @@ -341,7 +341,7 @@ def put_config_dict(self, modname, edit): lyd = mod.parse_data_dict(edit, no_state=True, validate=False) config = lyd.print_mem("xml", with_siblings=True, pretty=False) # print(f"Send new XML config: {config}") - return self.put_config(config) + return self.put_config(config, retries=retries) def call(self, call): """Call RPC, XML version""" diff --git a/test/infamy/restconf.py b/test/infamy/restconf.py index 48654138b..a770ad35a 100644 --- a/test/infamy/restconf.py +++ b/test/infamy/restconf.py @@ -51,8 +51,8 @@ def requests_workaround(method, url, json, headers, auth, verify=False, retry=0) request = requests.Request(method, url, json=json, headers=headers, auth=auth) prepared_request = session.prepare_request(request) - prepared_request.url = prepared_request.url.replace('%25', '%') - prepared_request.url = prepared_request.url.replace('%3A', ':') + prepared_request.url = re.sub(r'%25', '%', prepared_request.url) + prepared_request.url = re.sub(r'%3a', ':', prepared_request.url, flags=re.IGNORECASE) response = session.send(prepared_request, verify=verify) try: # Raise exceptions for HTTP errors @@ -83,6 +83,10 @@ def requests_workaround_post(url, json, headers, auth, verify=False): return requests_workaround('POST', url, json, headers, auth, verify=False) +def requests_workaround_patch(url, json, headers, auth, verify=False): + return requests_workaround('PATCH', url, json, headers, auth, verify=False) + + def requests_workaround_get(url, headers, auth, verify=False): return requests_workaround('GET', url, None, headers, auth, verify=False) @@ -201,7 +205,7 @@ def _get_raw(self, url, parse=True): def get_datastore(self, datastore="operational", path="", parse=True): """Get a datastore""" dspath = f"/ds/ietf-datastores:{datastore}" - if path is not None: + if path is not None and path != "": dspath = f"{dspath}{path}" url = f"{self.restconf_url}{dspath}" @@ -254,36 +258,102 @@ def put_datastore(self, datastore, data): def get_config_dict(self, modname): """Get all configuration for module @modname as dictionary""" - ds = self.get_running(modname) + # Strip leading slash if present (for compatibility with NETCONF xpath style) + modname = modname.lstrip('/') + + # Get the whole module's configuration by requesting the module root + # The modname parameter might be in format "module:container" or just "module" + if ":" in modname: + model, container = modname.split(":", 1) # Split only on first colon + path = f"/{model}:{container}" # This creates something like /ietf-syslog:syslog + else: + # If no colon, assume the whole thing is the module name + model = modname + path = f"/{model}" # This creates something like /ietf-syslog + + ds = self.get_running(path) + if ds is None: + return None ds = json.loads(ds.print_mem("json", with_siblings=True, pretty=False)) - model, container = modname.split(":") - for k, v in ds.items(): - return {container: v} + # If we have module:container format, extract the container part from the result + if ":" in modname: + _, container = modname.split(":", 1) + for k, v in ds.items(): + return {container: v} + else: + # Return the whole result if no specific container was specified + return ds + + def put_config_dicts(self, models, retries=3): + """PATCH configuration of all models to running-config - def put_config_dicts(self, models): - """PUT full configuration of all models to running-config""" + Uses candidate datastore + copy to running to trigger sysrepo + change callbacks, similar to how NETCONF edit-config + commit works. + + Args: + models: Dictionary of models to configure + retries: Number of retry attempts on failure (default 3) + """ infer_put_dict(self.name, models) - running = self.get_running() - for model in models.keys(): + # Copy running to candidate first (to preserve existing config) + self.copy("running", "candidate") + + # PATCH each model to candidate datastore + for model, config in models.items(): try: mod = self.lyctx.get_module(model) except libyang.util.LibyangError: raise Exception(f"YANG model '{model}' not found on device. " f"Model may not be installed or enabled. " f"Available models can be checked with get_schema_list()") from None - lyd = mod.parse_data_dict(models[model], no_state=True, validate=False) - running.merge(lyd) - cfg = running.print_mem("json", with_siblings=True, pretty=True) - # print(f"PUT new running-config: {cfg}") - return self.put_datastore("running", json.loads(cfg)) - - def put_config_dict(self, modname, edit): - """Add @edit to running config and put the whole configuration""" - - # This is hacky, refactor when rousette have PATCH support. - running = self.get_running() + # Parse and convert to get proper structure with module prefix + lyd = mod.parse_data_dict(config, no_state=True, validate=False) + patch_data = json.loads(lyd.print_mem("json", with_siblings=True, pretty=False)) + + # PATCH to candidate datastore + url = f"{self.restconf_url}/ds/ietf-datastores:candidate" + + last_error = None + for attempt in range(0, retries): + try: + response = requests_workaround_patch( + url, + json=patch_data, + headers=self.headers, + auth=self.auth, + verify=False + ) + response.raise_for_status() + last_error = None + break + except Exception as e: + last_error = e + if attempt < retries - 1: + print(f"Failed PATCH to {url}: {e} Retrying ...") + time.sleep(1) + else: + print(f"Failed PATCH to {url}: {e}") + continue + + if last_error is not None: + raise last_error + + # Copy candidate to running (acts as "commit", triggers sysrepo callbacks) + self.copy("candidate", "running") + + def put_config_dict(self, modname, edit, retries=3): + """PATCH configuration for a single model to running-config + + Uses candidate datastore + copy to running to trigger sysrepo + change callbacks, similar to how NETCONF edit-config + commit works. + + Args: + modname: YANG module name + edit: Configuration dictionary + retries: Number of retry attempts on failure (default 3) + """ try: mod = self.lyctx.get_module(modname) except libyang.util.LibyangError: @@ -291,26 +361,42 @@ def put_config_dict(self, modname, edit): f"Model may not be installed or enabled. " f"Available models can be checked with get_schema_list()") from None - for k, _ in edit.items(): - module = modname + ":" + k - break - - # Ugly hack, but this function should be refactored when patch - # is available in rousette anyway. - rundict = json.loads(running.print_mem("json", with_siblings=True, - pretty=False)) - if rundict.get(module) is None: - rundict[module] = {} - running = self.lyctx.parse_data_mem(json.dumps(rundict), "json", - parse_only=True) - - change = mod.parse_data_dict(edit, no_state=True, validate=False) - running.merge_module(change) - cfg = running.print_mem("json", with_siblings=True, pretty=False) - # print(f"PUT new running-config: {cfg}") - data = json.loads(cfg) - - return self.put_datastore("running", data) + # Copy running to candidate first (to preserve existing config) + self.copy("running", "candidate") + + # Parse and convert to get proper structure with module prefix + lyd = mod.parse_data_dict(edit, no_state=True, validate=False) + patch_data = json.loads(lyd.print_mem("json", with_siblings=True, pretty=False)) + + # PATCH to candidate datastore + url = f"{self.restconf_url}/ds/ietf-datastores:candidate" + last_error = None + for attempt in range(0, retries): + try: + response = requests_workaround_patch( + url, + json=patch_data, + headers=self.headers, + auth=self.auth, + verify=False + ) + response.raise_for_status() + last_error = None + break + except Exception as e: + last_error = e + if attempt < retries - 1: + print(f"Failed PATCH to {url}: {e} Retrying ...") + time.sleep(1) + else: + print(f"Failed PATCH to {url}: {e}") + continue + + if last_error is not None: + raise last_error + + # Copy candidate to running (acts as "commit", triggers sysrepo callbacks) + self.copy("candidate", "running") def call_dict(self, model, call): pass # Need implementation From 0e1c30b27eaabdd10dbb7660f11258054bb82341 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sun, 18 Jan 2026 09:53:50 +0100 Subject: [PATCH 24/31] test/infamy: add patch_config() for single operations on running Used by the new NACM basic test to verify ACL rules on explicit XPaths or modules. For this we cannot rely on put_config_dict() since it now uses the candidate datastore for all changes. Signed-off-by: Joachim Wiberg --- test/infamy/netconf.py | 13 +++++++++ test/infamy/restconf.py | 62 ++++++++++++++++++++++++++++++++++++++++ test/infamy/transport.py | 4 +++ 3 files changed, 79 insertions(+) diff --git a/test/infamy/netconf.py b/test/infamy/netconf.py index 06fe0de97..1b59f89e3 100644 --- a/test/infamy/netconf.py +++ b/test/infamy/netconf.py @@ -343,6 +343,19 @@ def put_config_dict(self, modname, edit, retries=3): # print(f"Send new XML config: {config}") return self.put_config(config, retries=retries) + def patch_config(self, modname, edit, retries=3): + """Merge configuration for a single model to running-config + + For NETCONF, this is identical to put_config_dict() since + edit-config already has proper NACM support. + + Args: + modname: YANG module name + edit: Configuration dictionary + retries: Number of retry attempts on failure (default 3) + """ + return self.put_config_dict(modname, edit, retries=retries) + def call(self, call): """Call RPC, XML version""" return self.ncc.dispatch(call) diff --git a/test/infamy/restconf.py b/test/infamy/restconf.py index a770ad35a..b53c8d32a 100644 --- a/test/infamy/restconf.py +++ b/test/infamy/restconf.py @@ -398,6 +398,68 @@ def put_config_dict(self, modname, edit, retries=3): # Copy candidate to running (acts as "commit", triggers sysrepo callbacks) self.copy("candidate", "running") + def patch_config(self, modname, edit, retries=3): + """PATCH configuration directly to running datastore + + This bypasses the candidate datastore, avoiding full datastore + copy operations. Useful for NACM-restricted users who only have + access to specific paths. Note: may not trigger sysrepo callbacks + for all configuration types. + + Args: + modname: YANG module name + edit: Configuration dictionary + retries: Number of retry attempts on failure (default 3) + """ + try: + mod = self.lyctx.get_module(modname) + except libyang.util.LibyangError: + raise Exception(f"YANG model '{modname}' not found on device. " + f"Model may not be installed or enabled. " + f"Available models can be checked with get_schema_list()") from None + + # Parse and convert to get proper structure with module prefix + lyd = mod.parse_data_dict(edit, no_state=True, validate=False) + patch_data = json.loads(lyd.print_mem("json", with_siblings=True, pretty=False)) + + # PATCH directly to running datastore + url = f"{self.restconf_url}/ds/ietf-datastores:running" + last_error = None + for attempt in range(0, retries): + try: + response = requests_workaround_patch( + url, + json=patch_data, + headers=self.headers, + auth=self.auth, + verify=False + ) + response.raise_for_status() + last_error = None + break + except Exception as e: + last_error = e + # Try to extract detailed error message from response + error_detail = str(e) + if hasattr(e, 'response') and e.response is not None: + try: + err_json = e.response.json() + if 'ietf-restconf:errors' in err_json: + errors = err_json['ietf-restconf:errors'].get('error', []) + if errors: + error_detail = errors[0].get('error-message', str(e)) + except: + pass + if attempt < retries - 1: + print(f"Failed PATCH: {error_detail} Retrying ...") + time.sleep(1) + else: + print(f"Failed PATCH: {error_detail}") + continue + + if last_error is not None: + raise last_error + def call_dict(self, model, call): pass # Need implementation diff --git a/test/infamy/transport.py b/test/infamy/transport.py index daaf8c730..0d0db27c5 100644 --- a/test/infamy/transport.py +++ b/test/infamy/transport.py @@ -26,6 +26,10 @@ def get_config_dict(self, modname): def put_config_dict(self, modname, edit): pass + @abstractmethod + def patch_config(self, modname, edit): + pass + @abstractmethod def get_dict(self, xpath=None): pass From 2d7c4afb6eb472d5f8c302fcb1d8317cb83f1009 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 13 Jan 2026 10:12:09 +0100 Subject: [PATCH 25/31] test: new case nacm-basic Signed-off-by: Joachim Wiberg --- test/case/system/Readme.adoc | 4 + test/case/system/all.yaml | 3 + test/case/system/nacm-basic/Readme.adoc | 1 + test/case/system/nacm-basic/test.adoc | 39 ++++ test/case/system/nacm-basic/test.py | 220 +++++++++++++++++++++++ test/case/system/nacm-basic/topology.dot | 1 + test/case/system/nacm-basic/topology.svg | 33 ++++ 7 files changed, 301 insertions(+) create mode 120000 test/case/system/nacm-basic/Readme.adoc create mode 100644 test/case/system/nacm-basic/test.adoc create mode 100755 test/case/system/nacm-basic/test.py create mode 120000 test/case/system/nacm-basic/topology.dot create mode 100644 test/case/system/nacm-basic/topology.svg diff --git a/test/case/system/Readme.adoc b/test/case/system/Readme.adoc index 493094129..8006bf727 100644 --- a/test/case/system/Readme.adoc +++ b/test/case/system/Readme.adoc @@ -23,6 +23,10 @@ include::user_admin/Readme.adoc[] <<< +include::nacm-basic/Readme.adoc[] + +<<< + include::timezone/Readme.adoc[] <<< diff --git a/test/case/system/all.yaml b/test/case/system/all.yaml index 7dd876c8b..ee9bf24e1 100644 --- a/test/case/system/all.yaml +++ b/test/case/system/all.yaml @@ -8,6 +8,9 @@ - name: Add admin user case: user_admin/test.py +- name: Basic NACM permissions + case: nacm-basic/test.py + - name: Set timezone using timezone name case: timezone/test.py diff --git a/test/case/system/nacm-basic/Readme.adoc b/test/case/system/nacm-basic/Readme.adoc new file mode 120000 index 000000000..ae32c8412 --- /dev/null +++ b/test/case/system/nacm-basic/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/system/nacm-basic/test.adoc b/test/case/system/nacm-basic/test.adoc new file mode 100644 index 000000000..62626851c --- /dev/null +++ b/test/case/system/nacm-basic/test.adoc @@ -0,0 +1,39 @@ +=== Basic NACM permissions + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/system/nacm-basic] + +==== Description + +Test that NACM groups (admin, operator, guest) correctly enforce +access control with path-based rules and default policies. + +Creates three user privilege levels from scratch: + +- admin: Full access (permit-all rule) +- operator: Can manage interfaces/routing, cannot modify system config +- guest: Read-only access (write-default: deny, exec deny rule) + +Verifies that: + +- All users can read configuration (read-default: permit) +- Operators can modify interfaces (path-specific permit rule) +- Operators cannot modify system configuration (write-default: deny) +- Guests cannot modify any configuration (write-default: deny) +- Admin can modify all configuration (permit-all rule bypasses defaults) + +==== Topology + +image::topology.svg[Basic NACM permissions topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to target +. Configure NACM groups, rules, and test users +. Verify operator can read configuration +. Verify operator can modify interface configuration +. Verify operator cannot modify system configuration +. Verify guest can read configuration +. Verify guest cannot modify configuration +. Verify admin can modify configuration + + diff --git a/test/case/system/nacm-basic/test.py b/test/case/system/nacm-basic/test.py new file mode 100755 index 000000000..d8a880499 --- /dev/null +++ b/test/case/system/nacm-basic/test.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +""" +Verify basic NACM permission enforcement + +Test that NACM groups (admin, operator, guest) correctly enforce +access control with path-based rules and default policies. + +Creates three user privilege levels from scratch: + +- admin: Full access (permit-all rule) +- operator: Can manage interfaces/routing, cannot modify system config +- guest: Read-only access (write-default: deny, exec deny rule) + +Verifies that: + +- All users can read configuration (read-default: permit) +- Operators can modify interfaces (path-specific permit rule) +- Operators cannot modify system configuration (write-default: deny) +- Guests cannot modify any configuration (write-default: deny) +- Admin can modify all configuration (permit-all rule bypasses defaults) +""" +import infamy + +OPERATOR_USER = "oper" +OPERATOR_PASS = "oper123" +GUEST_USER = "guest" +GUEST_PASS = "guest123" + +with infamy.Test() as test: + with test.step("Set up topology and attach to target"): + env = infamy.Env() + target = env.attach("target", "mgmt") + + with test.step("Configure NACM groups, rules, and test users"): + operator_hash = "$1$gwU5mRgP$1/ASdRwD5ycqdmWpTKHSa0" + guest_hash = "$1$4rdUOhNN$vw3i4FyPvIkzRFwrUXQod1" + + target.put_config_dicts({ + "ietf-system": { + "system": { + "authentication": { + "user": [{ + "name": OPERATOR_USER, + "password": operator_hash, + "shell": "infix-system:bash" + }, { + "name": GUEST_USER, + "password": guest_hash, + "shell": "infix-system:bash" + }] + } + } + }, + "ietf-netconf-acm": { + "nacm": { + "enable-nacm": True, + "read-default": "permit", + "write-default": "deny", + "exec-default": "permit", + "groups": { + "group": [{ + "name": "admin", + "user-name": ["admin"] + }, { + "name": "operator", + "user-name": [OPERATOR_USER] + }, { + "name": "guest", + "user-name": [GUEST_USER] + }] + }, + "rule-list": [{ + "name": "admin-acl", + "group": ["admin"], + "rule": [{ + "name": "permit-all", + "module-name": "*", + "access-operations": "*", + "action": "permit", + "comment": "Admin has full access" + }] + }, { + "name": "operator-acl", + "group": ["operator"], + "rule": [{ + "name": "permit-interfaces", + "path": "/ietf-interfaces:interfaces/interface", + "access-operations": "*", + "action": "permit", + "comment": "Operators can manage interfaces" + }, { + "name": "permit-routing", + "path": "/ietf-routing:routing", + "access-operations": "*", + "action": "permit", + "comment": "Operators can manage routing" + }, { + "name": "deny-users", + "path": "/ietf-system:system/authentication", + "access-operations": "*", + "action": "deny", + "comment": "Operators cannot manage users" + }] + }, { + "name": "guest-acl", + "group": ["guest"], + "rule": [{ + "name": "deny-all-exec", + "module-name": "*", + "access-operations": "exec", + "action": "deny", + "comment": "Guests cannot execute operations" + }] + }, { + "name": "default-deny-passwords", + "group": ["*"], + "rule": [{ + "name": "deny-password-access", + "path": "/ietf-system:system/authentication/user/password", + "access-operations": "*", + "action": "deny", + "comment": "No user can access password hashes" + }] + }] + } + } + }) + + with test.step("Verify operator can read configuration"): + # Attach as operator user + operator = env.attach("target", "mgmt", username=OPERATOR_USER, + password=OPERATOR_PASS, test_reset=False) + + # Operator should be able to read (read-default: permit) + ifaces = operator.get_config_dict("/ietf-interfaces:interfaces") + num_ifaces = len(ifaces.get('interfaces', {}).get('interface', [])) + print(f"Operator successfully read {num_ifaces} interfaces") + + with test.step("Verify operator can modify interface configuration"): + # Use patch_config() which PATCHes directly to running datastore + # This avoids full datastore copy that requires broader permissions + operator.patch_config("ietf-interfaces", { + "interfaces": { + "interface": [{ + "name": "lo", + "description": "Modified by operator" + }] + } + }) + + # Verify the change + ifaces = operator.get_config_dict("/ietf-interfaces:interfaces") + lo_iface = None + for iface in ifaces.get('interfaces', {}).get('interface', []): + if iface.get('name') == 'lo': + lo_iface = iface + break + assert lo_iface and lo_iface.get('description') == "Modified by operator", \ + "Operator failed to modify interface" + print("Operator successfully modified interface configuration") + + with test.step("Verify operator cannot modify system configuration"): + # Try to modify system config - should fail (write-default: deny) + # Use patch_config with retries=1 since NACM denials won't succeed on retry + try: + operator.patch_config("ietf-system", { + "system": { + "hostname": "operator-test" + } + }, retries=1) + assert False, "Operator should NOT be able to modify system config!" + except Exception as e: + error_msg = str(e) + # Check for NACM denial (different error messages for RESTCONF vs NETCONF) + assert any(keyword in error_msg for keyword in ["403", "Forbidden", "denied", "authorization failed"]), \ + f"Expected NACM denial, got: {e}" + print("Operator correctly denied system config access") + + with test.step("Verify guest can read configuration"): + # Attach as guest user + guest = env.attach("target", "mgmt", username=GUEST_USER, + password=GUEST_PASS, test_reset=False) + + # Guest should be able to read (read-default: permit) + ifaces = guest.get_config_dict("/ietf-interfaces:interfaces") + num_ifaces = len(ifaces.get('interfaces', {}).get('interface', [])) + print(f"Guest successfully read {num_ifaces} interfaces") + + with test.step("Verify guest cannot modify configuration"): + # Try to modify hostname - should fail (write-default: deny) + # Use patch_config with retries=1 since NACM denials won't succeed on retry + try: + guest.patch_config("ietf-system", { + "system": { + "hostname": "hacked" + } + }, retries=1) + assert False, "Guest should NOT be able to modify configuration!" + except Exception as e: + error_msg = str(e) + assert any(keyword in error_msg for keyword in ["403", "Forbidden", "denied", "authorization failed"]), \ + f"Expected NACM denial, got: {e}" + print("Guest correctly denied write access") + + with test.step("Verify admin can modify configuration"): + # Admin should have full access (permit-all rule) + target.put_config_dicts({ + "ietf-system": { + "system": { + "hostname": "admin-test" + } + } + }) + # Verify the change + cfg = target.get_config_dict("/ietf-system:system") + assert cfg.get("system", {}).get("hostname") == "admin-test", \ + "Admin hostname change not applied" + print("Admin successfully modified hostname") + + test.succeed() diff --git a/test/case/system/nacm-basic/topology.dot b/test/case/system/nacm-basic/topology.dot new file mode 120000 index 000000000..02b788692 --- /dev/null +++ b/test/case/system/nacm-basic/topology.dot @@ -0,0 +1 @@ +../../../infamy/topologies/1x1.dot \ No newline at end of file diff --git a/test/case/system/nacm-basic/topology.svg b/test/case/system/nacm-basic/topology.svg new file mode 100644 index 000000000..6fc6f47a8 --- /dev/null +++ b/test/case/system/nacm-basic/topology.svg @@ -0,0 +1,33 @@ + + + + + + +1x1 + + + +host + +host + +mgmt + + + +target + +mgmt + +target + + + +host:mgmt--target:mgmt + + + + From 5b407856f6a70035e9c8128f60c607d8ea740a7a Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Wed, 14 Jan 2026 09:47:32 +0100 Subject: [PATCH 26/31] test: fix race condition in DHCP Server Multiple Subnets Need to verify that client hostnames have been set before starting the client, otherwise the test will fail due to clients getting pool lease, which is intended. In a real-world scenario this is not a problem. Here we've booby trapped the server to try to trip up errors. Signed-off-by: Joachim Wiberg --- test/case/dhcp/server_subnets/test.adoc | 4 ++- test/case/dhcp/server_subnets/test.py | 39 ++++++++++++++++++++++--- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/test/case/dhcp/server_subnets/test.adoc b/test/case/dhcp/server_subnets/test.adoc index c5086704e..6075f4c6f 100644 --- a/test/case/dhcp/server_subnets/test.adoc +++ b/test/case/dhcp/server_subnets/test.adoc @@ -30,7 +30,9 @@ image::topology.svg[DHCP Server Multiple Subnets topology, align=center, scaledw ==== Sequence . Set up topology and attach to client and server DUTs -. Configure DHCP server and clients +. Configure DHCP server +. Configure client hostnames +. Configure DHCP clients . Verify DHCP client1 get correct lease . Verify DHCP client1 has default route via server . Verify DHCP client1 has correct DNS server(s) diff --git a/test/case/dhcp/server_subnets/test.py b/test/case/dhcp/server_subnets/test.py index c4267d6cf..ce0647636 100755 --- a/test/case/dhcp/server_subnets/test.py +++ b/test/case/dhcp/server_subnets/test.py @@ -88,7 +88,7 @@ def has_system_servers(dut, dns, ntp=None): client2 = env.attach("client2", "mgmt") client3 = env.attach("client3", "mgmt") - with test.step("Configure DHCP server and clients"): + with test.step("Configure DHCP server"): server.put_config_dicts({ "ietf-interfaces": { "interfaces": { @@ -195,10 +195,44 @@ def has_system_servers(dut, dns, ntp=None): } }}) + with test.step("Configure client hostnames"): + # Set hostnames first, before enabling DHCP clients + client1.put_config_dicts({ + "ietf-system": { + "system": { + "hostname": HOSTNM1, + } + }}) + + client2.put_config_dicts({ + "ietf-system": { + "system": { + "hostname": HOSTNM2, + } + }}) + + client3.put_config_dicts({ + "ietf-system": { + "system": { + "hostname": HOSTNM3, + } + }}) + + # Wait for hostnames to be applied in operational datastore + print("Waiting for client hostnames to take ...") + until(lambda: client1.get_data("/ietf-system:system") + .get("system", {}).get("hostname") == HOSTNM1) + until(lambda: client2.get_data("/ietf-system:system") + .get("system", {}).get("hostname") == HOSTNM2) + until(lambda: client3.get_data("/ietf-system:system") + .get("system", {}).get("hostname") == HOSTNM3) + + with test.step("Configure DHCP clients"): # All clients request/accept the same options. We do this to # both keep fleet configuration simple but also to verify that # the server is behaving correctly. + print("Enable DHCP clients ...") client1.put_config_dicts({ "ietf-interfaces": { "interfaces": { @@ -220,7 +254,6 @@ def has_system_servers(dut, dns, ntp=None): }, "ietf-system": { "system": { - "hostname": HOSTNM1, "ntp": {"enabled": True}, } }}) @@ -246,7 +279,6 @@ def has_system_servers(dut, dns, ntp=None): }, "ietf-system": { "system": { - "hostname": HOSTNM2, "ntp": {"enabled": True}, } }}) @@ -272,7 +304,6 @@ def has_system_servers(dut, dns, ntp=None): }, "ietf-system": { "system": { - "hostname": HOSTNM3, "ntp": {"enabled": True}, "dns-resolver": { "search": [ From d0ca1089c7deefb018315c656953a88f0404d2e5 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 9 Jan 2026 15:18:57 +0100 Subject: [PATCH 27/31] doc: further discourage legacy scripting Signed-off-by: Joachim Wiberg --- doc/scripting-sysrepocfg.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/doc/scripting-sysrepocfg.md b/doc/scripting-sysrepocfg.md index 078a2e537..d1f26b21d 100644 --- a/doc/scripting-sysrepocfg.md +++ b/doc/scripting-sysrepocfg.md @@ -1,6 +1,8 @@ -> [!NOTE] -> This method is a legacy "simple and human-friendly" way to manage the -> system. These days we strongly recommend using [RESTCONF][1] instead. +> [!WARNING] Deprecated - Use RESTCONF Instead +> +> This legacy interface requires elevated privileges (`sudo sysrepocfg`) even +> for admin users and is not intended for production use. All scripting and +> automation should use [RESTCONF][1] as the standard management interface. # Legacy Scripting From 9c7d6d6e58902f9595e5a4ced7dcf2eca76d33f7 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 9 Jan 2026 15:21:35 +0100 Subject: [PATCH 28/31] doc: use 'example' consistently as hostname in cli examples Signed-off-by: Joachim Wiberg --- doc/system.md | 86 +++++++++++++++++++++++++-------------------------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/doc/system.md b/doc/system.md index 02d9b6445..57bc55ab2 100644 --- a/doc/system.md +++ b/doc/system.md @@ -6,7 +6,7 @@ like Message of the Day (login message) and user login shell. More on this later on in this document. For the sake of brevity, the hostname in the following examples has been -shortened to `host`. The default hostname is composed from a product +shortened to `example`. The default hostname is composed from a product specific string followed by the last three octets of the system base MAC address, e.g., `switch-12-34-56`. An example of how to change the hostname is included below. @@ -23,11 +23,11 @@ User management, including passwords, SSH keys, remote authentication is available in the system authentication configuration context. ``` -admin@host:/config/> edit system authentication user admin -admin@host:/config/system/authentication/user/admin/> change password +admin@example:/config/> edit system authentication user admin +admin@example:/config/system/authentication/user/admin/> change password New password: Retype password: -admin@host:/config/system/authentication/user/admin/> leave +admin@example:/config/system/authentication/user/admin/> leave ``` The `change password` command starts an interactive dialogue that asks @@ -57,14 +57,14 @@ With SSH keys in place it is possible to disable password login, just remember to verify SSH login and network connectivity before doing so. ``` -admin@host:/config/> edit system authentication user admin -admin@host:/config/system/authentication/user/admin/> edit authorized-key example@host -admin@host:/config/system/authentication/user/admin/authorized-key/example@host/> set algorithm ssh-rsa -admin@host:/config/system/authentication/user/admin/authorized-key/example@host/> set key-data AAAAB3NzaC1yc2EAAAADAQABAAABgQC8iBL42yeMBioFay7lty1C4ZDTHcHyo739gc91rTTH8SKvAE4g8Rr97KOz/8PFtOObBrE9G21K7d6UBuPqmd0RUF2CkXXN/eN2PBSHJ50YprRFt/z/304bsBYkDdflKlPDjuSmZ/+OMp4pTsq0R0eNFlX9wcwxEzooIb7VPEdvWE7AYoBRUdf41u3KBHuvjGd1M6QYJtbFLQMMTiVe5IUfyVSZ1RCxEyAB9fR9CBhtVheTVsY3iG0fZc9eCEo89ErDgtGUTJK4Hxt5yCNwI88YaVmkE85cNtw8YwubWQL3/tGZHfbbQ0fynfB4kWNloyRHFr7E1kDxuX5+pbv26EqRdcOVGucNn7hnGU6C1+ejLWdBD7vgsoilFrEaBWF41elJEPKDzpszEijQ9gTrrWeYOQ+x++lvmOdssDu4KvGmj2K/MQTL2jJYrMJ7GDzsUu3XikChRL7zNfS2jYYQLzovboUCgqfPUsVba9hqeX3U67GsJo+hy5MG9RSry4+ucHs= -admin@host:/config/system/authentication/user/admin/authorized-key/example@host/> show +admin@example:/config/> edit system authentication user admin +admin@example:/config/system/authentication/user/admin/> edit authorized-key admin@example +admin@example:/config/system/authentication/user/admin/authorized-key/example@host/> set algorithm ssh-rsa +admin@example:/config/system/authentication/user/admin/authorized-key/example@host/> set key-data AAAAB3NzaC1yc2EAAAADAQABAAABgQC8iBL42yeMBioFay7lty1C4ZDTHcHyo739gc91rTTH8SKvAE4g8Rr97KOz/8PFtOObBrE9G21K7d6UBuPqmd0RUF2CkXXN/eN2PBSHJ50YprRFt/z/304bsBYkDdflKlPDjuSmZ/+OMp4pTsq0R0eNFlX9wcwxEzooIb7VPEdvWE7AYoBRUdf41u3KBHuvjGd1M6QYJtbFLQMMTiVe5IUfyVSZ1RCxEyAB9fR9CBhtVheTVsY3iG0fZc9eCEo89ErDgtGUTJK4Hxt5yCNwI88YaVmkE85cNtw8YwubWQL3/tGZHfbbQ0fynfB4kWNloyRHFr7E1kDxuX5+pbv26EqRdcOVGucNn7hnGU6C1+ejLWdBD7vgsoilFrEaBWF41elJEPKDzpszEijQ9gTrrWeYOQ+x++lvmOdssDu4KvGmj2K/MQTL2jJYrMJ7GDzsUu3XikChRL7zNfS2jYYQLzovboUCgqfPUsVba9hqeX3U67GsJo+hy5MG9RSry4+ucHs= +admin@example:/config/system/authentication/user/admin/authorized-key/example@host/> show algorithm ssh-rsa; key-data AAAAB3NzaC1yc2EAAAADAQABAAABgQC8iBL42yeMBioFay7lty1C4ZDTHcHyo739gc91rTTH8SKvAE4g8Rr97KOz/8PFtOObBrE9G21K7d6UBuPqmd0RUF2CkXXN/eN2PBSHJ50YprRFt/z/304bsBYkDdflKlPDjuSmZ/+OMp4pTsq0R0eNFlX9wcwxEzooIb7VPEdvWE7AYoBRUdf41u3KBHuvjGd1M6QYJtbFLQMMTiVe5IUfyVSZ1RCxEyAB9fR9CBhtVheTVsY3iG0fZc9eCEo89ErDgtGUTJK4Hxt5yCNwI88YaVmkE85cNtw8YwubWQL3/tGZHfbbQ0fynfB4kWNloyRHFr7E1kDxuX5+pbv26EqRdcOVGucNn7hnGU6C1+ejLWdBD7vgsoilFrEaBWF41elJEPKDzpszEijQ9gTrrWeYOQ+x++lvmOdssDu4KvGmj2K/MQTL2jJYrMJ7GDzsUu3XikChRL7zNfS2jYYQLzovboUCgqfPUsVba9hqeX3U67GsJo+hy5MG9RSry4+ucHs=; -admin@host:/config/system/authentication/user/admin/authorized-key/example@host/> leave +admin@example:/config/system/authentication/user/admin/authorized-key/example@host/> leave ``` > [!NOTE] @@ -145,25 +145,25 @@ Notice how the hostname in the prompt does not change until the change is committed by issuing the `leave` command. ``` -admin@host:/config/> edit system -admin@host:/config/system/> set hostname example -admin@host:/config/system/> leave -admin@example:/> +admin@example:/config/> edit system +admin@example:/config/system/> set hostname myrouter +admin@example:/config/system/> leave +admin@myrouter:/> ``` The hostname is advertised over mDNS-SD in the `.local` domain. If -another device already has claimed the `example.local` CNAME, in our +another device already has claimed the `myrouter.local` CNAME, in our case, mDNS will advertise a "uniqified" variant, usually suffixing with -an index, e.g., `example-1.local`. Use an mDNS browser to scan for +an index, e.g., `myrouter-1.local`. Use an mDNS browser to scan for available devices on your LAN. In some cases you may want to set the device's *domain name* as well. This is handled the same way: ``` -admin@host:/config/> edit system -admin@host:/config/system/> set hostname foo.example.com -admin@host:/config/system/> leave +admin@example:/config/> edit system +admin@example:/config/system/> set hostname foo.example.com +admin@example:/config/system/> leave admin@foo:/> ``` @@ -188,10 +188,10 @@ built-in [`text-editor` command](cli/text-editor.md). > you may be more familiar with. ``` -admin@host:/config/> edit system -admin@host:/config/system/> text-editor motd-banner -admin@host:/config/system/> leave -admin@host:/> +admin@example:/config/> edit system +admin@example:/config/system/> text-editor motd-banner +admin@example:/config/system/> leave +admin@example:/> ``` Log out and log back in again to inspect the changes. @@ -209,11 +209,11 @@ as the `text-editor` command: To change the editor to GNU Nano: ``` -admin@host:/> configure -admin@host:/config/> edit system -admin@host:/config/system/> set text-editor nano -admin@host:/config/system/> leave -admin@host:/> +admin@example:/> configure +admin@example:/config/> edit system +admin@example:/config/system/> set text-editor nano +admin@example:/config/system/> leave +admin@example:/> ``` > [!IMPORTANT] @@ -229,16 +229,16 @@ locally configured (static) server is preferred over any acquired from a DHCP client. ``` -admin@host:/> configure -admin@host:/config/> edit system dns-resolver -admin@host:/config/system/dns-resolver/> set server google udp-and-tcp address 8.8.8.8 -admin@host:/config/system/dns-resolver/> show +admin@example:/> configure +admin@example:/config/> edit system dns-resolver +admin@example:/config/system/dns-resolver/> set server google udp-and-tcp address 8.8.8.8 +admin@example:/config/system/dns-resolver/> show server google { udp-and-tcp { address 8.8.8.8; } } -admin@host:/config/system/dns-resolver/> leave +admin@example:/config/system/dns-resolver/> leave ``` It is also possible to configure resolver options like timeout and @@ -257,14 +257,14 @@ Below is an example configuration for enabling NTP with a specific server and the `iburst` option for faster initial synchronization. ``` -admin@host:/> configure -admin@host:/config/> edit system ntp -admin@host:/config/system/ntp/> set enabled -admin@host:/config/system/ntp/> set server ntp-pool -admin@host:/config/system/ntp/> set server ntp-pool udp address pool.ntp.org -admin@host:/config/system/ntp/> set server ntp-pool iburst -admin@host:/config/system/ntp/> set server ntp-pool prefer -admin@host:/config/system/ntp/> leave +admin@example:/> configure +admin@example:/config/> edit system ntp +admin@example:/config/system/ntp/> set enabled +admin@example:/config/system/ntp/> set server ntp-pool +admin@example:/config/system/ntp/> set server ntp-pool udp address pool.ntp.org +admin@example:/config/system/ntp/> set server ntp-pool iburst +admin@example:/config/system/ntp/> set server ntp-pool prefer +admin@example:/config/system/ntp/> leave ``` This configuration enables the NTP client and sets the NTP server to @@ -299,7 +299,7 @@ To check the status of NTP synchronization (only availble in CLI), use the following command: ``` -admin@host:/> show ntp tracking +admin@example:/> show ntp tracking Reference ID : C0248F86 (192.36.143.134) Stratum : 2 Ref time (UTC) : Mon Oct 21 10:06:45 2024 @@ -313,7 +313,7 @@ Root delay : 1.024467230 seconds Root dispersion : 0.273462683 seconds Update interval : 0.0 seconds Leap status : Normal -admin@host:/> +admin@example:/> ``` This output provides detailed information about the NTP status, including From 409ab8dd79be52c21aa283bc8e64894d8cbfceff Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 9 Jan 2026 15:29:12 +0100 Subject: [PATCH 29/31] doc: update user guide with more on nacm and user privileges Signed-off-by: Joachim Wiberg --- doc/nacm.md | 563 ++++++++++++++++++++++++++++++++++++++++++++++++++ doc/system.md | 231 +++++++++++++++++---- mkdocs.yml | 1 + 3 files changed, 754 insertions(+), 41 deletions(-) create mode 100644 doc/nacm.md diff --git a/doc/nacm.md b/doc/nacm.md new file mode 100644 index 000000000..98adf1c5c --- /dev/null +++ b/doc/nacm.md @@ -0,0 +1,563 @@ +# Network Access Control Model (NACM) + +NETCONF Access Control Model ([RFC 8341][1]) provides fine-grained access +control for YANG data models. NACM controls who can read, write, and +execute operations on specific parts of the configuration and operational +state. + +This document provides technical details about how NACM works, how rules +are evaluated, and best practices for creating custom access control +policies. + +> [!TIP] +> For a practical introduction to user management and the built-in user +> levels (admin, operator, guest), see the [Multiple Users][2] section +> in the System Configuration guide. + + +## Overview + +NACM provides three types of access control: + +- **Data node access** - Control read/write access to configuration and state +- **Operation access** - Control execution of RPCs (remote procedure calls) +- **Notification access** - Control subscription to event notifications (not covered here) + +Access is controlled through: + +1. **Global defaults** - Default permissions for read/write/exec +2. **YANG-level annotations** - Security markers in YANG modules (see below) +3. **NACM rules** - Explicit permit/deny rules organized in rule-lists + + +## Rule Evaluation + +NACM rules are evaluated in a specific order: + +1. **Rule-lists are processed sequentially** in the order they appear in the configuration +2. **Within each rule-list**, rules are evaluated sequentially +3. **First matching rule wins** - no further rules are evaluated +4. **If no rule matches**, global defaults apply (read-default, write-default, exec-default) +5. **YANG annotations override everything** - nacm:default-deny-all in a YANG module requires an explicit permit rule regardless of global defaults + +### Example Rule Evaluation + +Given this configuration: + +```json +{ + "read-default": "permit", + "write-default": "deny", + "rule-list": [ + { + "name": "operator-acl", + "group": ["operator"], + "rule": [ + { + "name": "deny-passwords", + "path": "/ietf-system:system/authentication/user/password", + "access-operations": "read", + "action": "deny" + }, + { + "name": "permit-interfaces", + "path": "/ietf-interfaces:interfaces/interface", + "access-operations": "*", + "action": "permit" + } + ] + } + ] +} +``` + +When operator user "jacky" tries to: + +- **Read password**: Matches "deny-passwords" → **DENIED** +- **Write interface config**: Matches "permit-interfaces" → **PERMITTED** +- **Read routing config**: No match, uses write-default → **DENIED** (write-default=deny) +- **Read system state**: No match, uses read-default → **PERMITTED** (read-default=permit) + + +## Global Defaults + +NACM has three global defaults that apply when no rule matches: + +```json +{ + "read-default": "permit", + "write-default": "deny", + "exec-default": "permit" +} +``` + +**Factory configuration defaults:** +- `read-default: "permit"` - Users can read configuration and state by default +- `write-default: "deny"` - Users cannot modify configuration unless explicitly permitted +- `exec-default: "permit"` - Users can execute RPCs unless explicitly denied + +This deny-by-default approach for writes provides good security while +allowing read access for monitoring and troubleshooting. + +> [!IMPORTANT] +> YANG modules with `nacm:default-deny-all` or `nacm:default-deny-write` +> annotations override these global defaults. You must create explicit +> permit rules for those operations. + +## Module-Name vs Path + +NACM rules can match operations using either `module-name` or `path`: + +### Module-Name Matching + +Matches all nodes **defined** in a specific YANG module: + +```json +{ + "name": "permit-keystore", + "module-name": "ietf-keystore", + "access-operations": "*", + "action": "permit" +} +``` + +This permits all operations on data **defined** in ietf-keystore, but does +NOT cover augments from other modules. + +**Example:** The `/interfaces/interface/ipv4/address` path: + +- Interface is defined in `ietf-interfaces` +- IPv4 config is defined in `ietf-ip` (augments ietf-interfaces) +- A rule with `module-name: "ietf-interfaces"` does NOT cover ipv4/address + +### Path Matching + +Matches a specific data tree path and **all nodes under it**, including +augments from other modules: + +```json +{ + "name": "permit-network-config", + "path": "/ietf-interfaces:interfaces/interface", + "access-operations": "*", + "action": "permit" +} +``` + +This permits operations on `/interfaces/interface` **and all child nodes**, +including augments like: +- `/interfaces/interface/ipv4` (from ietf-ip) +- `/interfaces/interface/ipv6` (from ietf-ip) +- `/interfaces/interface/bridge-port` (from ieee802-dot1q-bridge) + +**Path syntax:** +- When used **with** module-name: Path is module-relative (no prefix) + ```json + "module-name": "ietf-system", + "path": "/system/authentication/user/password" + ``` + +- When used **without** module-name: Path must include module prefix + ```json + "path": "/ietf-system:system/authentication/user/password" + ``` + +> [!TIP] +> **Use path-based rules** when you want to permit/deny access to a +> configuration subtree including all augments. This is more flexible and +> requires less maintenance as new features are added. + + +## YANG-Level Annotations + +Many YANG modules include NACM annotations that provide baseline security: + +### nacm:default-deny-all + +Requires an explicit permit rule, regardless of global defaults: + +```yang +rpc system-restart { + nacm:default-deny-all; + description "Restart the system"; +} +``` + +Even with `exec-default: "permit"`, users need an explicit permit rule to +execute system-restart. + +### nacm:default-deny-write + +Write operations require an explicit permit rule: + +```yang +container authentication { + nacm:default-deny-write; + description "User authentication configuration"; +} +``` + +Even with `write-default: "permit"`, users need an explicit permit rule to +modify authentication settings. + +### Protected Operations + +The following are protected by YANG annotations and require explicit permits: + +**RPC Operations:** +- `ietf-system:system-restart` ([ietf-system][3]) +- `ietf-system:system-shutdown` ([ietf-system][3]) +- `ietf-system:set-current-datetime` ([ietf-system][3]) +- `infix-factory-default:factory-default` +- `ietf-factory-default:factory-reset` ([RFC 8808][4]) +- `infix-system-software:install-bundle` +- `infix-system-software:set-boot-order` + +**Data Containers:** +- `/system/authentication` (nacm:default-deny-write, [ietf-system][3]) +- `/nacm` (nacm:default-deny-all, [RFC 8341][1]) +- Routing protocol key chains ([ietf-key-chain][5]) +- RADIUS shared secrets ([ietf-system][3]) +- TLS client/server credentials ([ietf-tls-client][6]) + +This provides defense-in-depth - even if NACM rules are misconfigured, these +critical operations remain protected. + + +## Access Operations + +NACM supports the following access operations: + +- `create` - Create new data nodes +- `read` - Read existing data nodes +- `update` - Modify existing data nodes +- `delete` - Delete data nodes +- `exec` - Execute RPC operations +- `*` - All operations (wildcard) + +**Common combinations:** +- `"create update delete"` - All write operations +- `"*"` - Everything (read, write, execute) +- `"read"` - Read-only access + + +## Rule-List Groups + +Each rule-list applies to one or more user groups: + +```json +{ + "name": "operator-acl", + "group": ["operator"], + "rule": [...] +} +``` + +**Special group names:** +- `"*"` - Matches all users (including those not in any NACM group) + +**Evaluation:** +A user can be in multiple NACM groups. All rule-lists matching the user's +groups are evaluated in order until a matching rule is found. + + +## Example: Factory Configuration + +The factory configuration provides a minimal NACM setup using global +defaults and path-based rules: + +```json +{ + "ietf-netconf-acm:nacm": { + "enable-nacm": true, + "read-default": "permit", + "write-default": "deny", + "exec-default": "permit", + "groups": { + "group": [ + {"name": "admin", "user-name": ["admin"]}, + {"name": "operator", "user-name": []}, + {"name": "guest", "user-name": []} + ] + }, + "rule-list": [ + { + "name": "admin-acl", + "group": ["admin"], + "rule": [ + { + "name": "permit-all", + "module-name": "*", + "access-operations": "*", + "action": "permit" + } + ] + }, + { + "name": "operator-acl", + "group": ["operator"], + "rule": [ + { + "name": "permit-interfaces", + "path": "/ietf-interfaces:interfaces/interface", + "access-operations": "*", + "action": "permit" + }, + { + "name": "permit-routing", + "path": "/ietf-routing:routing", + "access-operations": "*", + "action": "permit" + }, + { + "name": "permit-containers", + "path": "/infix-containers:containers", + "access-operations": "*", + "action": "permit" + }, + { + "name": "permit-firewall", + "path": "/infix-firewall:firewall", + "access-operations": "*", + "action": "permit" + }, + { + "name": "permit-system-restart", + "module-name": "ietf-system", + "rpc-name": "system-restart", + "access-operations": "exec", + "action": "permit" + } + ] + }, + { + "name": "guest-acl", + "group": ["guest"], + "rule": [ + { + "name": "deny-all-exec", + "module-name": "*", + "access-operations": "exec", + "action": "deny" + } + ] + }, + { + "name": "default-deny-all", + "group": ["*"], + "rule": [ + { + "name": "deny-password-access", + "path": "/ietf-system:system/authentication/user/password", + "access-operations": "*", + "action": "deny" + }, + { + "name": "deny-keystore-access", + "module-name": "ietf-keystore", + "access-operations": "*", + "action": "deny" + }, + { + "name": "deny-truststore-access", + "module-name": "ietf-truststore", + "access-operations": "*", + "action": "deny" + } + ] + } + ] + } +} +``` + +**Key design decisions:** + +1. **Minimal rules** - Only 10 rules total, leveraging global defaults and YANG annotations +2. **Path-based permits** - Operator rules use paths to cover containers and all augments +3. **Explicit RPC permit** - system-restart explicitly permitted for operators (has nacm:default-deny-all) +4. **Global denials** - Password/keystore/truststore denied for everyone via group "*" +5. **YANG annotations** - Most sensitive operations (factory-reset, software upgrades, etc.) protected by YANG-level nacm:default-deny-all + + +## Common Patterns + +### Deny-by-Default with Explicit Permits + +Most secure approach - deny everything except what's explicitly allowed: + +```json +{ + "write-default": "deny", + "exec-default": "deny", + "rule-list": [ + { + "group": ["limited-user"], + "rule": [ + { + "name": "permit-interface-config", + "path": "/ietf-interfaces:interfaces/interface", + "access-operations": "create update delete", + "action": "permit" + } + ] + } + ] +} +``` + +### Permit-All with Specific Denials + +More permissive - allow everything except sensitive operations: + +```json +{ + "rule-list": [ + { + "group": ["operator"], + "rule": [ + { + "name": "deny-user-management", + "path": "/ietf-system:system/authentication", + "access-operations": "create update delete", + "action": "deny" + }, + { + "name": "permit-all", + "module-name": "*", + "access-operations": "*", + "action": "permit" + } + ] + } + ] +} +``` + +> [!IMPORTANT] +> Specific denials must come **before** broad permits in the rule list, +> since first matching rule wins. + +### Global Restrictions + +Deny access to sensitive data for all users (except admins with permit-all): + +```json +{ + "rule-list": [ + { + "group": ["*"], + "rule": [ + { + "name": "deny-passwords", + "path": "/ietf-system:system/authentication/user/password", + "access-operations": "*", + "action": "deny" + } + ] + } + ] +} +``` + + +## Debugging NACM + +### Viewing Effective Permissions + +Check what NACM groups a user belongs to: + +``` +admin@example:/> show nacm +enabled : yes +default read access : permit +default write access : deny +default exec access : permit +denied operations : 0 +denied data writes : 0 +denied notifications : 0 + +USER SHELL LOGIN +admin bash password+key +jacky bash password +monitor false key + +GROUP USERS +admin admin +operator jacky +guest monitor +``` + +### Testing Access + +The easiest way to test NACM permissions is to log in as the user and try +the operation: + +```bash +$ ssh jacky@host +jacky@example:/> configure +jacky@example:/config/> edit system authentication user admin +jacky@example:/config/system/authentication/user/admin/> set authorized-key foo +Error: Access to the data model "ietf-system" is denied because "jacky" NACM authorization failed. +Error: Failed applying changes (2). +``` + +### NACM Statistics + +NACM tracks denied operations. If you suspect permission issues, check the +statistics: + +``` +admin@example:/> show nacm +... + denied operations : 5 + denied data writes : 12 +... +``` + +Increasing counters indicate permission denials are occurring. + + +## Best Practices + +1. **Start with deny-by-default** - Use `write-default: "deny"` and + `exec-default: "deny"` for new systems, then add permits as needed. + +2. **Use path-based rules** - Prefer path over module-name for broader + coverage including augments. + +3. **Leverage YANG annotations** - Many sensitive operations are already + protected by nacm:default-deny-all in YANG modules. Don't duplicate + these as explicit NACM rules. + +4. **Order matters** - Place specific denials before broad permits in each + rule-list. + +5. **Use global denials** - For restrictions that apply to everyone (except + admins), use `group: ["*"]` rule-list. + +6. **Test thoroughly** - Always test user permissions after changes. NACM + errors can be subtle (nodes may be silently omitted from read operations). + +7. **Keep it simple** - Fewer, broader rules are easier to maintain than + many specific rules. The factory configuration uses only 10 rules for + three user levels (1 admin, 5 operator, 1 guest, 3 global). + +8. **Document exceptions** - Use the "comment" field in rules to explain + why specific permissions are granted or denied. + + +## References + +- [RFC 8341: Network Configuration Access Control Model (NACM)][1] +- [RFC 7317: A YANG Data Model for System Management (ietf-system)][3] +- [RFC 8808: A YANG Data Model for Factory Default Settings (ietf-factory-default)][4] +- [RFC 8177: YANG Key Chain (ietf-key-chain)][5] +- [System Configuration - Multiple Users][2] + +[1]: https://www.rfc-editor.org/rfc/rfc8341 +[2]: system.md#multiple-users +[3]: https://www.rfc-editor.org/rfc/rfc7317 +[4]: https://www.rfc-editor.org/rfc/rfc8808 +[5]: https://www.rfc-editor.org/rfc/rfc8177 +[6]: https://datatracker.ietf.org/doc/html/draft-ietf-netconf-tls-client-server diff --git a/doc/system.md b/doc/system.md index 57bc55ab2..7c5b039be 100644 --- a/doc/system.md +++ b/doc/system.md @@ -72,72 +72,221 @@ admin@example:/config/system/authentication/user/admin/authorized-key/example@ho > so there is no need to use the `text-editor` command, `set` does the > job. - ## Multiple Users -The system supports multiple users and multiple user levels, or groups, -that a user can be a member of. Access control is entirely handled by -the NETCONF ["NACM"][3] YANG model, which provides granular access to -configuration, data, and RPC commands over NETCONF. - -By default the system ships with a single group, `admin`, which the -default user `admin` is a member of. The broad permissions granted by -the `admin` group is what gives its users full system administrator -privileges. There are no restrictions on the number of users with -administrator privileges, nor is the `admin` user reserved or protected -in any way -- it is completely possible to remove the default `admin` -user from the configuration. However, it is recommended to keep at -least one user with administrator privileges in the system, otherwise -the only way to regain full access is to perform a *factory reset*. +The factory configuration provides three hierarchical user group levels by +default: **guest ⊂ operator ⊂ admin**. These levels can be customized or +extended to fit specific requirements. The default levels provide different +access to system resources and configuration: + +- **Admin**: Full system access - can manage users, upgrade software, + restart the system, and modify all configuration including network + settings, routing, and firewall rules. + +- **Operator**: Network configuration access - can manage interfaces, + routing protocols, VLANs, containers, and firewall rules, and can + restart the system, but **cannot** manage users, software upgrades, or + shut down the system. + +- **Guest**: Read-only access - can view operational state and + configuration but cannot modify anything or execute operations. + +System access control is handled by the [ietf-netconf-acm][3] YANG model, +usually referred to as [NACM](nacm.md), which provides granular access to +configuration, data, and RPC commands. The hierarchical levels in the system +are determined by: + +1. **NACM permissions** - what the user can access +2. **Shell setting** - which command-line interface the user can use + +By default the system ships with a single user, `admin`, in the `admin` +group. There are no restrictions on the number of users with admin +privileges, nor is the `admin` user reserved or protected -- it can be +removed from the configuration. However, it is strongly recommended to +keep at least one user with administrator privileges, otherwise the only +way to regain full access is to perform a *factory reset*. + +For an overview of users and groups on the system, there is an admin-exec +command: + +``` +admin@example:/> show nacm +enabled : yes +default read access : permit +default write access : deny +default exec access : permit +denied operations : 0 +denied data writes : 0 +denied notifications : 0 + +USER SHELL LOGIN +admin bash password+key +jacky bash password +monitor false key + +GROUP USERS +admin admin +operator jacky +guest monitor +``` + +> [!TIP] guest ⊂ operator ⊂ admin +> As a general rule, think of *operator* as anything between full access +> (admin) and read-only (guest). However, read-only in this case does +> not mean guests can see user passwords or secrets in the keystore. ### Adding a User -Similar to how to change password, adding a new user is done using the -same set of commands: +Creating a new user starts with defining the user account in the system: ``` -admin@host:/config/> edit system authentication user jacky -admin@host:/config/system/authentication/user/jacky/> change password +admin@example:/config/> edit system authentication user jacky +admin@example:/config/system/authentication/user/jacky/> change password New password: Retype password: -admin@host:/config/system/authentication/user/jacky/> leave +admin@example:/config/system/authentication/user/jacky/> leave +``` + +An authorized SSH key can be added the same way as described in the +previous sections. + +By default, shell access is disabled (`shell false`). To allow CLI/SSH +access, set the shell: + +``` +admin@example:/config/> edit system authentication user jacky +admin@example:/config/system/authentication/user/jacky/> set shell clish +admin@example:/config/system/authentication/user/jacky/> leave +``` + +Available shells: + +- `bash` - Full Bourne-again shell (recommended for admins only) +- `sh` - POSIX shell (recommended for admins only) +- `clish` - Limited CLI-only shell (recommended for operators and guests) +- `false` - No shell access (default) + +> [!WARNING] Security Notice +> For security reasons, it is strongly recommended to limit non-admin users +> to the `clish` shell, which provides CLI access without exposing the +> underlying UNIX system. Reserve `bash` and `sh` for administrators who +> need full system access for debugging and maintenance. +> +> Note that shell and CLI access is not always necessary - the system +> supports NETCONF and RESTCONF for remote management and automation. +> Setting `shell false` for users who only need programmatic access +> minimizes the attack surface and improves overall system security. + +### Adding a User to a Group + +To assign a user to a specific privilege level, add them to the +corresponding NACM group: + +**Operator user:** + +``` +admin@example:/config/> edit nacm group operator +admin@example:/config/nacm/group/operator/> set user-name jacky +admin@example:/config/nacm/group/operator/> leave ``` -An authorized SSH key is added the same way as presented previously. +**Adding another admin:** -### Adding a User to the Admin Group +``` +admin@example:/config/> edit nacm group admin +admin@example:/config/nacm/group/admin/> set user-name alice +admin@example:/config/nacm/group/admin/> leave +``` -The following commands add user `jacky` to the `admin` group. +**Guest user:** ``` -admin@host:/config/> edit nacm group admin -admin@host:/config/nacm/group/admin/> set user-name jacky -admin@host:/config/nacm/group/admin/> leave +admin@example:/config/> edit nacm group guest +admin@example:/config/nacm/group/guest/> set user-name monitor +admin@example:/config/nacm/group/guest/> leave ``` +> [!TIP] +> For technical details about NACM rule evaluation, module-name vs path +> matching, and creating custom access control policies, see the +> [NACM Technical Guide](nacm.md). + +### Access Control Matrix + +The following table shows what each user level can do based on the NACM rules +and shell access configured for each user: + +- **Admin**: `bash` — full system access +- **Operator**: `clish` — CLI-only access without UNIX system exposure +- **Guest**: `false` — no shell access + +| Feature | Admin | Operator | Guest | +|-------------------------|-------|----------|-----------| +| Network interfaces | ✓ | ✓ | Read only | +| Routing (FRR) | ✓ | ✓ | Read only | +| Firewall rules | ✓ | ✓ | Read only | +| VLANs/bridges | ✓ | ✓ | Read only | +| Containers | ✓ | ✓ | Read only | +| CLI/SSH access | ✓ | ✓ | ✗ | +| System restart | ✓ | ✓ | ✗ | +| User management | ✓ | ✗ | ✗ | +| Keystore (certs/keys) | ✓ | ✗ | ✗ | +| NACM rules | ✓ | ✗ | ✗ | +| Factory reset | ✓ | ✗ | ✗ | +| Software upgrade | ✓ | ✗ | ✗ | +| System shutdown | ✓ | ✗ | ✗ | +| Set date/time | ✓ | ✗ | ✗ | +| Read passwords/secrets | ✓ | ✗ | ✗ | + ### Security Aspects -The NACM user levels apply primarily to NETCONF, with exception of the -`admin` group which is granted full system administrator privileges to -the underlying UNIX system with the following ACL rules: +The three default user levels are implemented through a combination of NACM +rules and UNIX group membership. Access control is permission-based, not +name-based - the system detects user levels by examining their NACM +permissions and shell settings. + +**Admin users** have unrestricted NACM access with the following rule: ```json - ... "module-name": "*", "access-operations": "*", - "action": "permit", - ... + "action": "permit" ``` -A user in the `admin` group is allowed to also use a POSIX login shell -and use the `sudo` command to perform system administrative commands. -This makes it possible to use all the underlying UNIX tooling, which -to many can be very useful, in particular when debugging a system, but -please remember to use with care -- the system is not built to require -managing from the shell. The tools available in the CLI and automated -services, started from the system's configuration, are the recommended -way of using the system, in addition to NETCONF tooling. - +Admin users are automatically added to the UNIX `wheel` and `frrvty` +groups, granting them `sudo` privileges and access to FRR routing +protocols. This makes it possible to use all the underlying UNIX +tooling, which can be very useful for debugging, but please use with +care -- the system is designed to be managed through the CLI and +NETCONF, not directly via shell commands. + +**Operator users** have restricted NACM access. With the deny-by-default +configuration (`write-default: "deny"`), operators receive explicit permit +rules for network and container operations: + +- Network interfaces (including IP addresses, VLANs, bridges, etc.) +- Routing protocols (OSPF, RIP, static routes, etc.) +- Containers (start, stop, configure) +- Firewall rules (zones, policies, services) +- System restart (explicit RPC permit) + +Sensitive operations like user management, software upgrades, factory reset, +and system shutdown are protected by YANG-level `nacm:default-deny-all` +annotations and remain restricted to administrators. + +Operators are automatically added to the UNIX `operator` and `frrvty` +groups, granting them `sudo` privileges for network operations and FRR +access. + +**Guest users** have read-only NACM access. The global `exec-default: "permit"` +combined with an explicit deny rule blocks all RPC operations for guests, +while `read-default: "permit"` allows viewing configuration and state. +Guests receive no special UNIX group memberships. The shell setting +determines whether guests can access the CLI (`clish`) or are restricted +from shell access entirely (`false`). + +All users, regardless of level, are denied access to password hashes and +cryptographic key material through global NACM rules. ## Changing Hostname diff --git a/mkdocs.yml b/mkdocs.yml index dd8e5c2c5..8947f4fb2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -42,6 +42,7 @@ nav: - System: - Boot Procedure: boot.md - Configuration: system.md + - Access Control (NACM): nacm.md - Hardware Info & Status: hardware.md - Management: management.md - Syslog Support: syslog.md From 828ff38b985dbac1b873e08a1b281746c1d796d6 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 13 Jan 2026 14:10:57 +0100 Subject: [PATCH 30/31] doc: update restconf scripting with details on patch Signed-off-by: Joachim Wiberg --- doc/scripting-restconf.md | 214 ++++++++++++++++++++++++++++++++++++++ mkdocs.yml | 4 +- 2 files changed, 216 insertions(+), 2 deletions(-) diff --git a/doc/scripting-restconf.md b/doc/scripting-restconf.md index 5f9069b5e..cf466afb6 100644 --- a/doc/scripting-restconf.md +++ b/doc/scripting-restconf.md @@ -112,6 +112,158 @@ command-line options or environment variables: The examples below show both raw `curl` commands and the equivalent using `curl.sh` where applicable. +## HTTP Methods in RESTCONF + +RESTCONF uses standard HTTP methods to perform different operations on +configuration and operational data. Understanding when to use each method is +crucial for correct and safe operations. + +### GET - Read Data + +Retrieve configuration or operational data without making changes. + +**When to use:** + +- Reading current configuration +- Querying operational state (interface statistics, routing tables, etc.) +- Discovering what exists before making changes + +**Characteristics:** + +- Safe operation (no side effects) +- Can be repeated without consequences +- Works on both configuration and operational datastores + +**Example:** +```bash +~$ ./curl.sh -h example.local GET /ietf-interfaces:interfaces +``` + +### PUT - Replace Resource + +> [!DANGER] Heads-up! +> PUT replaces everything - missing fields will be deleted! + +Replace an entire resource with new content. + +**When to use:** + +- Replacing entire datastore (backup/restore scenarios) +- Complete reconfiguration of a container +- When you want to ensure the resource matches exactly what you provide + +**Characteristics:** + +- Replaces all content at the target path +- Any existing data not in the PUT request is **removed** +- Requires complete, valid configuration +- Dangerous if you don't include all necessary fields + +**Example:** +```bash +~$ ./curl.sh -h example.local PUT /ietf-system:system \ + -d '{"ietf-system:system":{"hostname":"newhost","contact":"admin@example.com"}}' +``` + +### PATCH - Merge/Update Resource + +Partially update a resource by merging changes with existing data. + +**When to use:** + +- Modifying specific fields while preserving others +- Working with path-based NACM permissions +- Incremental configuration changes +- Safer alternative to PUT for most use cases + +**Characteristics:** + +- Merges changes with existing configuration +- Only specified fields are modified +- Unspecified fields remain unchanged +- Works with partial data structures +- NACM-friendly (respects path-based permissions) + +**Example:** +```bash +~$ ./curl.sh -h example.local PATCH /ietf-interfaces:interfaces \ + -d '{"ietf-interfaces:interfaces":{"interface":[{"name":"e0","description":"WAN"}]}}' +``` + +> [!TIP] Best practice +> Use PATCH instead of PUT for most configuration updates. + +### POST - Create New Resource + +Create a new resource within a collection or invoke an RPC. + +**When to use:** + +- Adding new list entries (interfaces, users, routes, etc.) +- Creating resources at specific paths +- Invoking RPC operations (reboot, factory-reset, etc.) + +**Characteristics:** + +- Creates new resources +- For lists: adds a new element +- For RPCs: executes the operation +- Returns error if resource already exists (for configuration) + +**Example - Add IP address:** +```bash +~$ ./curl.sh -h example.local POST \ + /ietf-interfaces:interfaces/interface=lo/ietf-ip:ipv4/address=192.168.254.254 \ + -d '{"prefix-length": 32}' +``` + +**Example - Invoke RPC:** +```bash +~$ curl -kX POST -u admin:admin \ + -H "Content-Type: application/yang-data+json" \ + https://example.local/restconf/operations/ietf-system:system-restart +``` + +### DELETE - Remove Resource + +Delete a resource or configuration element. + +**When to use:** + +- Removing interfaces, routes, users, etc. +- Deleting specific configuration items +- Cleaning up unwanted configuration + +**Characteristics:** + +- Removes the resource completely +- Cannot be undone (except by reconfiguration) +- Returns error if resource doesn't exist + +**Example:** +```bash +~$ ./curl.sh -h example.local DELETE \ + /ietf-interfaces:interfaces/interface=lo/ietf-ip:ipv4/address=192.168.254.254 +``` + +### Quick Reference + +| **Method** | **Use Case** | **Safe?** | **Idempotent?** | **NACM Impact** | +|------------|-------------------------|-----------|-----------------|--------------------------| +| GET | Read data | ✓ | ✓ | Read permissions | +| PUT | Replace entire resource | ✗ | ✓ | Full write access needed | +| PATCH | Merge/update fields | ✓ | ✓ | Path-specific write | +| POST | Create new/invoke RPC | ✗ | ✗ | Create/exec permissions | +| DELETE | Remove resource | ✗ | ✓ | Delete permissions | + +**Key takeaways:** + +- **Use PATCH for updates** - Safer than PUT, works with NACM +- **Use PUT sparingly** - Only when you need complete replacement +- **GET is always safe** - Read as much as you need +- **POST for creation/RPCs** - Creating new items or executing operations +- **DELETE with care** - Cannot be undone + ## Discovery & Common Patterns Before working with specific configuration items, you often need to discover @@ -245,6 +397,68 @@ Example of updating configuration with inline JSON data: -d '{"ietf-system:system":{"hostname":"bar"}}' ``` +### Update Interface Description + +PATCH allows you to modify specific parts of the configuration without +replacing the entire container. This is particularly useful when working +with NACM (Network Access Control Model) permissions that grant access to +specific paths. + +**Why use PATCH instead of PUT:** + +- **Partial updates**: Only changes specified fields, preserves others +- **NACM-friendly**: Works with path-based access control rules +- **Safer**: Reduces risk of accidentally removing unrelated configuration + +**Example - Modify an interface description:** + +```bash +~$ curl -kX PATCH -u admin:admin \ + -H 'Content-Type: application/yang-data+json' \ + -d '{"ietf-interfaces:interfaces":{"interface":[{"name":"e0","description":"WAN Port"}]}}' \ + https://example.local/restconf/data/ietf-interfaces:interfaces +``` + +**Using curl.sh:** + +```bash +~$ ./curl.sh -h example.local PATCH /ietf-interfaces:interfaces \ + -d '{"ietf-interfaces:interfaces":{"interface":[{"name":"e0","description":"WAN Port"}]}}' +``` + +**Formatted for readability:** + +```bash +~$ curl -kX PATCH -u admin:admin \ + -H 'Content-Type: application/yang-data+json' \ + -d '{ + "ietf-interfaces:interfaces": { + "interface": [ + { + "name": "e0", + "description": "WAN Port" + } + ] + } + }' \ + https://example.local/restconf/data/ietf-interfaces:interfaces +``` + +**Key points:** + +- PATCH URL targets the **container** (`/interfaces`), not a specific interface +- JSON body includes the full structure with the interface array +- Only specified fields are modified; other interface settings remain unchanged +- The `name` field identifies which interface to update + +**Verify the change:** + +```bash +~$ ./curl.sh -h example.local GET /ietf-interfaces:interfaces/interface=e0 2>/dev/null \ + | jq '.["ietf-interfaces:interface"][0].description' +"WAN Port" +``` + ### Add IP Address to Interface Add an IP address to the loopback interface: diff --git a/mkdocs.yml b/mkdocs.yml index 8947f4fb2..aa66e51cb 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -51,9 +51,9 @@ nav: - Scripting: - Introduction: scripting.md - Legacy Scripting: scripting-sysrepocfg.md + - NETCONF Scripting: scripting-netconf.md + - RESTCONF Scripting: scripting-restconf.md - Production Testing: scripting-prod.md - - With NETCONF: scripting-netconf.md - - With RESTCONF: scripting-restconf.md - Developer's Corner: - Branding & Releases: branding.md - Developer's Guide: developers-guide.md From e75fdaf3d72da04ebca975e82b47fb3204504e7f Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 13 Jan 2026 06:27:31 +0100 Subject: [PATCH 31/31] doc: update ChnageLog Slight refactor of WARNING and NOTE admonintions to improve readability. Signed-off-by: Joachim Wiberg --- doc/ChangeLog.md | 54 ++++++++++++++++++++++++++++-------------------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index 303f23fb9..e90e6aeb6 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -6,29 +6,25 @@ All notable changes to the project are documented in this file. [v26.01.0][UNRELEASED] ------------------------- -> [!WARNING] -> **BREAKING CHANGES:** This release includes breaking changes to WiFi configuration: +> [!IMPORTANT] +> This release includes **breaking changes** to WiFi configuration that will +> result in existing configuration being disabled: > > - WiFi station/client configuration has been restructured. The `wifi` container > now requires a `radio` reference, and station configuration has moved under a -> `wifi/station` container. Existing WiFi configurations must be manually updated. -> - WiFi radios are now configured via `ietf-hardware` instead of the interfaces module. - -> [!NOTE] -> Noteworthy changes and additions in this release: -> -> - WiFi Access Point (AP) mode support with multi-SSID capability -> - RIPv2 routing support -> - WireGuard support +> `wifi/station` container. Existing WiFi configurations must be manually updated +> - WiFi radios are now configured via `ietf-hardware` instead of the interfaces module ### Changes +Noteworthy changes and additions in this release are marked below in bold text. + - Upgrade Linux kernel to 6.12.65 (LTS) - Upgrade libyang to 4.2.2 - Upgrade sysrepo to 4.2.10 - Upgrade netopeer2 (NETCONF) to 2.7.0 -- Add RIPv2 routing support, issue #582 -- Add NTP server support, issue #904 +- Add **RIPv2 routing support**, issue #582 +- Add **NTP server support**, issue #904 - Migrate DHCPv6 client to odhcp6c for improved Router Advertisement integration. Adds support for hybrid RA+DHCPv6 deployments where SLAAC assigns addresses and DHCPv6 provides DNS (common ISP scenario) @@ -45,21 +41,35 @@ All notable changes to the project are documented in this file. policy, keeping snapshots from every 5 minutes (recent) to yearly (historical) - Add support data collection script, useful when troubleshooting issues on deployed systems. Gathers system information, logs, and more. Issue #1287 -- Add WiFi Access Point (AP) mode with multi-SSID support and WPA2/WPA3 security. - **BREAKING:** WiFi architecture refactored with radios configured via - `ietf-hardware` and interfaces requiring `radio` reference. Station config - moved to `wifi/station` container. Existing Wi-Fi interfaces will be - removed during upgrade (for the rest of the configuration to apply) - and you need to reconfigure them again. See [wifi.md](wifi.md) for details -- Add support for WireGuard VPN tunnels. +- Add **WiFi Access Point (AP) mode with multi-SSID support and WPA2/WPA3** + security. **BREAKING:** WiFi architecture refactored with radios configured + via `ietf-hardware` and interfaces requiring `radio` reference. Station + config moved to `wifi/station` container. Existing Wi-Fi interfaces will be + removed during upgrade (for the rest of the configuration to apply) and you + need to reconfigure them again. See the [WiFi][] documentation for details +- Add support for **WireGuard VPN tunnels**. +- New default NACM privilege levels (user levels) in `factory-config`: + `operator` (network & container manager) and `guest` (read-only). For + details, see the updated system configuration documentation, as well as a + new dedicated NACM configuration guide +- New `show nacm` admin-exec command to inspect access control rules +- CLI now uses `copy` and `rpc` tools instead of deprecated `sysrepocfg`. The + latter now also require the use of `sudo` for `admin` level users +- Enhanced `copy` command with XPath filtering support ### Fixes +- Fix #1082: Wi-Fi interfaces always scanned, introduce a `scan-mode` to the + Wi-Fi concept in Infix - Fix #1314: Raspberry Pi 4B with 1 or 8 GiB RAM does not boot. This was due newer EEPROM firmware in newer boards require a newer rpi-firmware package -- Fix #1082: Wi-Fi interfaces always scanned, introduce a `scan-mode` - to the Wi-Fi concept in Infix. +- Fix #1345: firewall not updating when interfaces become bridge/lag ports +- Fix #1346: firewall complains in syslog, no `/etc/firewalld/firewalld.conf` +- Fix default password hash in `do password encrypt` command. New hash is the + same as the more commonly used `change password` command, *yescrypt* +- Prevent MOTD from showing on non-shell user login attempts +[wifi]: https://kernelkit.org/infix/latest/wifi/ [v25.11.0][] - 2025-12-02 -------------------------