mirror of
https://github.com/kernelkit/infix.git
synced 2026-07-22 17:23:00 +02:00
Merge pull request #1354 from kernelkit/srperms
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
/etc/alternatives/editor
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+1
-1
Submodule buildroot updated: 4166c59afd...4a1f18fb9a
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -63,7 +63,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
|
||||
@@ -178,7 +178,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
|
||||
|
||||
@@ -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_DBUS_CXX=y
|
||||
@@ -128,7 +128,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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -59,7 +59,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
|
||||
@@ -174,7 +174,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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
+37
-28
@@ -6,35 +6,29 @@ 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.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Users of Raspberry Pi must upgrade the bootloader of the system**
|
||||
> the easieast way to do it is to backup your startup-config.cfg and
|
||||
> reflash the SD card with a new [sd card image](https://github.com/kernelkit/infix/releases/download/latest-boot/infix-rpi64-sdcard.img)
|
||||
|
||||
> [!NOTE]
|
||||
> Noteworthy changes and additions in this release:
|
||||
> `wifi/station` container. Existing WiFi configurations must be manually updated
|
||||
> - WiFi radios are now configured via `ietf-hardware` instead of the interfaces module
|
||||
>
|
||||
> - WiFi Access Point (AP) mode support with multi-SSID capability
|
||||
> - RIPv2 routing support
|
||||
> - WireGuard support
|
||||
> - New major version of linux kernel 6.18, previous 6.12
|
||||
> Also, **Raspberry Pi users must upgrade the bootloader** before upgrading to
|
||||
> this release. We recommend backing up your `startup-config.cfg` and reflash
|
||||
> the SD card with a new [sd card image][].
|
||||
|
||||
### Changes
|
||||
|
||||
- Upgrade Linux kernel to 6.18.6 (LTS)
|
||||
Noteworthy changes and additions in this release are marked below in bold text.
|
||||
|
||||
- Upgrade **Linux kernel to 6.18.6** (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)
|
||||
@@ -51,21 +45,36 @@ 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, missing `/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/
|
||||
[sd card image]: https://github.com/kernelkit/infix/releases/download/latest-boot/infix-rpi64-sdcard.img
|
||||
|
||||
[v25.11.0][] - 2025-12-02
|
||||
-------------------------
|
||||
|
||||
+1
-1
@@ -157,7 +157,7 @@ built-in help system can also be useful:
|
||||
<b>DESCRIPTION</b>
|
||||
Enable masquerading (SNAT) for traffic matching this policy.<br/>
|
||||
Matching traffic will have their source IP address changed on egress,
|
||||
using the IP address of the interface the traffic egresses.";<br/>
|
||||
using the IP address of the interface the traffic egresses.<br/>
|
||||
admin@example:/config/firewall/policy/lan-to-dmz/>
|
||||
</pre></code>
|
||||
|
||||
|
||||
+644
@@ -0,0 +1,644 @@
|
||||
# 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.
|
||||
|
||||
|
||||
## TL;DR - The Factory Defaults Work
|
||||
|
||||
**You don't need to understand NACM to use the system securely.**
|
||||
|
||||
The factory configuration implements a "permit by default, deny sensitive
|
||||
items" policy that provides:
|
||||
|
||||
- **Immediate usability** - Operators can configure the entire system
|
||||
without custom NACM rules
|
||||
- **Automatic security** - Passwords, cryptographic keys, and dangerous
|
||||
operations are protected out-of-the-box
|
||||
- **Future-proof design** - New features work immediately without NACM
|
||||
updates
|
||||
|
||||
The three built-in user levels (admin, operator, guest) cover most use
|
||||
cases. Only read on if you need to create custom access control policies.
|
||||
|
||||
|
||||
## 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": "permit",
|
||||
"rule-list": [
|
||||
{
|
||||
"name": "operator-acl",
|
||||
"group": ["operator"],
|
||||
"rule": [
|
||||
{
|
||||
"name": "permit-system-rpcs",
|
||||
"module-name": "ietf-system",
|
||||
"access-operations": "exec",
|
||||
"action": "permit"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "default-deny-all",
|
||||
"group": ["*"],
|
||||
"rule": [
|
||||
{
|
||||
"name": "deny-passwords",
|
||||
"path": "/ietf-system:system/authentication/user/password",
|
||||
"access-operations": "*",
|
||||
"action": "deny"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
When operator user "jacky" tries to:
|
||||
|
||||
- **Read password**: Matches "deny-passwords" → **DENIED**
|
||||
- **Write interface config**: No match, uses write-default → **PERMITTED** (write-default=permit)
|
||||
- **Write hostname**: No match, uses write-default → **PERMITTED** (write-default=permit)
|
||||
- **Reboot system**: Matches "permit-system-rpcs" → **PERMITTED** (overrides `nacm:default-deny-all`)
|
||||
|
||||
## Global Defaults
|
||||
|
||||
NACM has three global defaults that apply when no rule matches:
|
||||
|
||||
```json
|
||||
{
|
||||
"read-default": "permit",
|
||||
"write-default": "permit",
|
||||
"exec-default": "permit"
|
||||
}
|
||||
```
|
||||
|
||||
**Factory configuration defaults:**
|
||||
|
||||
- `read-default: "permit"` - Users can read configuration and state by default
|
||||
- `write-default: "permit"` - Users can modify configuration by default
|
||||
- `exec-default: "permit"` - Users can execute RPCs by default
|
||||
|
||||
This permit-by-default approach, combined with targeted denials for
|
||||
sensitive items (passwords, cryptographic keys), provides a balance
|
||||
between usability and security. It also makes the system "future proof" -
|
||||
when new YANG modules are added, operators can immediately configure them
|
||||
without updating NACM rules.
|
||||
|
||||
> [!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`.
|
||||
The following sub-sections provide detailed information and examples of
|
||||
both.
|
||||
|
||||
### 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 infix-interfaces)
|
||||
|
||||
**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, e.g., all IP settings below
|
||||
> `/ietf-interfaces:interfaces/`. 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 uses a "permit by default, deny sensitive items"
|
||||
approach. This design is "future proof" - when new YANG modules are added,
|
||||
operators can immediately configure them without updating NACM rules.
|
||||
|
||||
```json
|
||||
{
|
||||
"ietf-netconf-acm:nacm": {
|
||||
"enable-nacm": true,
|
||||
"read-default": "permit",
|
||||
"write-default": "permit",
|
||||
"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": "Admin has full unrestricted access"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "operator-acl",
|
||||
"group": ["operator"],
|
||||
"rule": [
|
||||
{
|
||||
"name": "permit-system-rpcs",
|
||||
"module-name": "ietf-system",
|
||||
"rpc-name": "*",
|
||||
"access-operations": "exec",
|
||||
"action": "permit",
|
||||
"comment": "Operators can reboot, shutdown, and set system time"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "guest-acl",
|
||||
"group": ["guest"],
|
||||
"rule": [
|
||||
{
|
||||
"name": "deny-all-write+exec",
|
||||
"module-name": "*",
|
||||
"access-operations": "create update delete exec",
|
||||
"action": "deny",
|
||||
"comment": "Guests can only read, not modify or execute"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "default-deny-all",
|
||||
"group": ["*"],
|
||||
"rule": [
|
||||
{
|
||||
"name": "deny-password-access",
|
||||
"path": "/ietf-system:system/authentication/user/password",
|
||||
"access-operations": "*",
|
||||
"action": "deny",
|
||||
"comment": "No user except admins can access password hashes"
|
||||
},
|
||||
{
|
||||
"name": "deny-keystore-access",
|
||||
"module-name": "ietf-keystore",
|
||||
"access-operations": "*",
|
||||
"action": "deny",
|
||||
"comment": "No user except admins can access cryptographic keys"
|
||||
},
|
||||
{
|
||||
"name": "deny-truststore-access",
|
||||
"module-name": "ietf-truststore",
|
||||
"access-operations": "*",
|
||||
"action": "deny",
|
||||
"comment": "No user except admins can access trust store"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key design decisions:**
|
||||
|
||||
1. **Permit by default** - `write-default: permit` allows operators to configure any module
|
||||
2. **Minimal operator rules** - Only one rule to permit system RPCs (reboot, set time)
|
||||
3. **Future proof** - New YANG modules automatically configurable by operators
|
||||
4. **Targeted denials** - Only sensitive items (passwords, keys) are explicitly denied
|
||||
5. **Global denials** - Password/keystore/truststore denied for everyone via group "*"
|
||||
6. **YANG annotations** - Sensitive operations (factory-reset, software upgrades) still protected by `nacm:default-deny-all` in YANG modules
|
||||
|
||||
**Effective permissions by group:**
|
||||
|
||||
| Group | Read | Write | Exec | Exceptions |
|
||||
|-------|------|-------|------|------------|
|
||||
| admin | All | All | All | None |
|
||||
| operator | All | All | All | Cannot access passwords, keystore, truststore |
|
||||
| guest | All | None | None | Read-only access |
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Permit-by-Default
|
||||
|
||||
The factory default approach - allow everything except sensitive items:
|
||||
|
||||
```json
|
||||
{
|
||||
"write-default": "permit",
|
||||
"exec-default": "permit",
|
||||
"rule-list": [
|
||||
{
|
||||
"name": "admin-acl",
|
||||
"group": ["admin"],
|
||||
"rule": [
|
||||
{
|
||||
"name": "permit-all",
|
||||
"module-name": "*",
|
||||
"access-operations": "*",
|
||||
"action": "permit"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "global-denials",
|
||||
"group": ["*"],
|
||||
"rule": [
|
||||
{
|
||||
"name": "deny-passwords",
|
||||
"path": "/ietf-system:system/authentication/user/password",
|
||||
"access-operations": "*",
|
||||
"action": "deny"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
This approach is "future proof" - new YANG modules are automatically
|
||||
accessible without rule updates. Admins bypass the global denials
|
||||
because their `permit-all` rule is evaluated first.
|
||||
|
||||
### Deny-by-Default
|
||||
|
||||
More restrictive approach - deny everything except what is 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> This requires updating NACM rules whenever new features are added.
|
||||
|
||||
### 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 : permit
|
||||
default exec access : permit
|
||||
denied operations : 0
|
||||
denied data writes : 0
|
||||
denied notifications : 0
|
||||
|
||||
┌──────────┬─────────┬─────────┬─────────┐
|
||||
│ GROUP │ READ │ WRITE │ EXEC │
|
||||
├──────────┼─────────┼─────────┼─────────┤
|
||||
│ admin │ ✓ │ ✓ │ ✓ │
|
||||
│ operator │ ⚠ │ ⚠ │ ⚠ │
|
||||
│ guest │ ⚠ │ ✗ │ ✗ │
|
||||
└──────────┴─────────┴─────────┴─────────┘
|
||||
✓ Full ⚠ Restricted ✗ Denied
|
||||
|
||||
USER SHELL LOGIN
|
||||
admin bash password+key
|
||||
jacky bash password
|
||||
monitor false key
|
||||
|
||||
GROUP USERS
|
||||
admin admin
|
||||
operator jacky
|
||||
guest monitor
|
||||
```
|
||||
|
||||
For details about a group's restrictions, use `show nacm group <name>`:
|
||||
|
||||
```
|
||||
admin@example:/> show nacm group operator
|
||||
members : jacky
|
||||
read permission : restricted
|
||||
write permission : restricted
|
||||
exec permission : restricted
|
||||
applicable rules : 4
|
||||
──────────────────────────────────────────────────────────────────────
|
||||
permit-system-rpcs
|
||||
action : permit
|
||||
operations : exec
|
||||
target : ietf-system (rpc: *)
|
||||
|
||||
──────────────────────────────────────────────────────────────────────
|
||||
deny-password-access (via '*')
|
||||
action : deny
|
||||
operations : *
|
||||
target : /ietf-system:system/authentication/user/password
|
||||
|
||||
──────────────────────────────────────────────────────────────────────
|
||||
deny-keystore-access (via '*')
|
||||
action : deny
|
||||
operations : *
|
||||
target : ietf-keystore
|
||||
|
||||
──────────────────────────────────────────────────────────────────────
|
||||
deny-truststore-access (via '*')
|
||||
action : deny
|
||||
operations : *
|
||||
target : ietf-truststore
|
||||
```
|
||||
|
||||
### 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. **Leverage permit-by-default** - The factory configuration uses
|
||||
`write-default: "permit"` with targeted denials. This is "future proof" -
|
||||
new features work immediately without NACM updates.
|
||||
|
||||
2. **Protect sensitive items globally** - Use `group: ["*"]` rule-list to
|
||||
deny access to passwords, cryptographic keys, and similar sensitive data.
|
||||
Admin's permit-all rule (evaluated first) bypasses these denials.
|
||||
|
||||
3. **Leverage YANG annotations** - Many sensitive operations are already
|
||||
protected by `nacm:default-deny-all` in YANG modules (factory-reset,
|
||||
software upgrades, etc.). Only add explicit permit rules when needed.
|
||||
|
||||
4. **Order matters** - Rule-lists are evaluated in order. Place admin's
|
||||
permit-all rule first so it bypasses global denials.
|
||||
|
||||
5. **Use path-based denials** - For protecting specific data (like password
|
||||
hashes), use path rules. For protecting entire modules (like keystore),
|
||||
use module-name rules.
|
||||
|
||||
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** - The factory configuration uses only 6 rules for
|
||||
three user levels. Fewer rules are easier to understand and maintain.
|
||||
|
||||
8. **Document rules** - Use the "comment" field 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
|
||||
@@ -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:
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
> [!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 no longer intended for production use. All scripting
|
||||
> should use [RESTCONF][1] as the standard management interface. There is
|
||||
> however an NACM compliant replacement `copy` that can be used without sudo.
|
||||
|
||||
# Legacy Scripting
|
||||
|
||||
|
||||
+302
-83
@@ -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]
|
||||
@@ -72,72 +72,291 @@ admin@host:/config/system/authentication/user/admin/authorized-key/example@host/
|
||||
> 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.
|
||||
The factory configuration provides three hierarchical user group levels by
|
||||
default: **guest ⊂ operator ⊂ admin**. These levels work out-of-the-box
|
||||
with sensible permissions - operators can configure the system immediately,
|
||||
while sensitive items (passwords, cryptographic keys) remain protected.
|
||||
|
||||
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 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**: Configuration access - can modify most system settings
|
||||
including network interfaces, routing, firewall, hostname, and more.
|
||||
*Cannot access* password hashes, cryptographic keys, or perform
|
||||
sensitive operations (factory reset, software upgrade).
|
||||
|
||||
- **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 : permit
|
||||
default exec access : permit
|
||||
denied operations : 0
|
||||
denied data writes : 0
|
||||
denied notifications : 0
|
||||
|
||||
┌──────────┬─────────┬─────────┬─────────┐
|
||||
│ GROUP │ READ │ WRITE │ EXEC │
|
||||
├──────────┼─────────┼─────────┼─────────┤
|
||||
│ admin │ ✓ │ ✓ │ ✓ │
|
||||
│ operator │ ⚠ │ ⚠ │ ⚠ │
|
||||
│ guest │ ⚠ │ ✗ │ ✗ │
|
||||
└──────────┴─────────┴─────────┴─────────┘
|
||||
✓ Full ⚠ Restricted ✗ Denied
|
||||
|
||||
USER SHELL LOGIN
|
||||
admin bash password+key
|
||||
jacky clish password
|
||||
monitor false key
|
||||
|
||||
GROUP USERS
|
||||
admin admin
|
||||
operator jacky
|
||||
guest monitor
|
||||
```
|
||||
|
||||
The permissions matrix shows effective access for each NACM group:
|
||||
|
||||
- **✓ Full** (green) - unrestricted access
|
||||
- **⚠ Restricted** (yellow) - access with exceptions, use `show nacm group`
|
||||
for details
|
||||
- **✗ Denied** (red) - no access
|
||||
|
||||
For detailed information about a specific group's rules:
|
||||
|
||||
```
|
||||
admin@example:/> show nacm group operator
|
||||
members : jacky
|
||||
read permission : restricted
|
||||
write permission : restricted
|
||||
exec permission : restricted
|
||||
applicable rules : 4
|
||||
──────────────────────────────────────────────────────────────────────
|
||||
permit-system-rpcs
|
||||
action : permit
|
||||
operations : exec
|
||||
target : ietf-system (rpc: *)
|
||||
comment : Operators can reboot, shutdown, and set system time.
|
||||
|
||||
──────────────────────────────────────────────────────────────────────
|
||||
deny-password-access (via '*')
|
||||
action : deny
|
||||
operations : *
|
||||
target : /ietf-system:system/authentication/user/password
|
||||
comment : No user except admins can access password hashes.
|
||||
|
||||
──────────────────────────────────────────────────────────────────────
|
||||
deny-keystore-access (via '*')
|
||||
action : deny
|
||||
operations : *
|
||||
target : ietf-keystore
|
||||
comment : No user except admins can access cryptographic keys.
|
||||
|
||||
──────────────────────────────────────────────────────────────────────
|
||||
deny-truststore-access (via '*')
|
||||
action : deny
|
||||
operations : *
|
||||
target : ietf-truststore
|
||||
comment : No user except admins can access trust store.
|
||||
```
|
||||
|
||||
For user details:
|
||||
|
||||
```
|
||||
admin@example:/> show nacm user jacky
|
||||
shell : clish
|
||||
login : password
|
||||
nacm group : operator
|
||||
read permission : restricted
|
||||
write permission : restricted
|
||||
exec permission : restricted
|
||||
|
||||
For detailed rules, use: show nacm group <name>
|
||||
```
|
||||
|
||||
### 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 is added the same way as presented previously.
|
||||
An authorized SSH key can be added the same way as described in the
|
||||
previous sections.
|
||||
|
||||
### Adding a User to the Admin Group
|
||||
|
||||
The following commands add user `jacky` to the `admin` group.
|
||||
By default, shell access is disabled (`shell false`). To allow CLI/SSH
|
||||
access, set the shell:
|
||||
|
||||
```
|
||||
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 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
|
||||
```
|
||||
|
||||
**Adding another admin:**
|
||||
|
||||
```
|
||||
admin@example:/config/> edit nacm group admin
|
||||
admin@example:/config/nacm/group/admin/> set user-name alice
|
||||
admin@example:/config/nacm/group/admin/> leave
|
||||
```
|
||||
|
||||
**Guest user:**
|
||||
|
||||
```
|
||||
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 |
|
||||
| Hostname/system config | ✓ | ✓ | Read only |
|
||||
| CLI/SSH access | ✓ | ✓ | ✗ |
|
||||
| System restart | ✓ | ✓ | ✗ |
|
||||
| Set date/time | ✓ | ✓ | ✗ |
|
||||
| System reboot | ✓ | ✓ | ✗ |
|
||||
| System shutdown | ✓ | ✓ | ✗ |
|
||||
| User management | ✓ | ✗ | Read only |
|
||||
| Keystore (certs/keys) | ✓ | ✗ | ✗ |
|
||||
| Truststore | ✓ | ✗ | ✗ |
|
||||
| Read passwords/secrets | ✓ | ✗ | ✗ |
|
||||
| NACM rules | ✓ | ✗ | ✗ |
|
||||
| Factory reset | ✓ | ✗ | ✗ |
|
||||
| Software upgrade | ✓ | ✗ | ✗ |
|
||||
|
||||
### 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** use the permit-by-default NACM model (`write-default:
|
||||
"permit"`), which means they can configure most system settings without
|
||||
explicit permit rules. This design is "future proof" - when new features
|
||||
are added, operators can immediately use them.
|
||||
|
||||
The following are explicitly denied to operators through global NACM rules:
|
||||
|
||||
- Password hashes (`/ietf-system:system/authentication/user/password`)
|
||||
- Cryptographic keys (`ietf-keystore` module)
|
||||
- Trust store certificates (`ietf-truststore` module)
|
||||
|
||||
Additionally, sensitive operations like factory reset, software upgrades,
|
||||
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 through an explicit deny rule
|
||||
that blocks all write and exec operations (`create update delete exec`),
|
||||
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
|
||||
|
||||
@@ -145,25 +364,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 +407,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 +428,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 +448,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 +476,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 +518,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 +532,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
|
||||
|
||||
+3
-2
@@ -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
|
||||
@@ -50,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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
+30
-1
@@ -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
|
||||
|
||||
@@ -18,4 +20,31 @@ define BIN_CONF_ENV
|
||||
CFLAGS="$(INFIX_CFLAGS)"
|
||||
endef
|
||||
|
||||
define BIN_PERMISSIONS
|
||||
/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))
|
||||
|
||||
@@ -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)*
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
From abaad560f0bf1f7de832a55222e20e2a9a61986f Mon Sep 17 00:00:00 2001
|
||||
From: Aaron Andersen <aaron@fosslib.net>
|
||||
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 <troglobit@gmail.com>
|
||||
---
|
||||
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
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
From 34bf9a77765db4d963fc66dde29b415ecc8ab611 Mon Sep 17 00:00:00 2001
|
||||
From: Joachim Wiberg <troglobit@gmail.com>
|
||||
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 <troglobit@gmail.com>
|
||||
---
|
||||
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
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# Locally calculated
|
||||
sha256 9d9d33b873917ca5d0bdcc47a36d2fd385971ab0c045d1472fcadf95ee5bcf5b LICENCE
|
||||
sha256 d3148fd6d3fc106384c88efe5a4ce3c7b1d512bd919b864d84c13b7b9ad523f0 klish-4fd82287cfe74c6bbafea8d6c0ba8d8b4c65cdd0-git4.tar.gz
|
||||
sha256 4c1b6b461a805f0cf80f797f85415e0663ee371df6495641bb494bfb18829fe8 klish-7c5f1c4045d9a868dea547bd7ef0143bff5a84d7-git4.tar.gz
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 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.
|
||||
SocketGroup=klish
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@ sys:*:::::::
|
||||
sync:*:::::::
|
||||
mail:*:::::::
|
||||
www-data:*:::::::
|
||||
sys-cli:*:::::::
|
||||
sysrepo:*:::::::
|
||||
klish:*:::::::
|
||||
backup:*:::::::
|
||||
nobody:*:::::::
|
||||
|
||||
+1
-5
@@ -1,15 +1,11 @@
|
||||
From efe7706fd7397c2feb384afea00ee97e74287df0 Mon Sep 17 00:00:00 2001
|
||||
From: Joachim Wiberg <troglobit@gmail.com>
|
||||
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 <troglobit@gmail.com>
|
||||
Signed-off-by: Mattias Walström <lazzer@gmail.com>
|
||||
---
|
||||
src/executables/sysrepo-plugind.c | 10 +++++++++-
|
||||
1 file changed, 9 insertions(+), 1 deletion(-)
|
||||
|
||||
+1
-5
@@ -1,10 +1,7 @@
|
||||
From 11b9938206cf2bafc456bb22e14c7f85a604760c Mon Sep 17 00:00:00 2001
|
||||
From: Joachim Wiberg <troglobit@gmail.com>
|
||||
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 <troglobit@gmail.com>
|
||||
Signed-off-by: Mattias Walström <lazzer@gmail.com>
|
||||
---
|
||||
src/shm_sub.c | 40 +++++++++++++++++++++++++++++++---------
|
||||
src/shm_sub.h | 2 +-
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
From 9e0267d4f20733b2a26df6d0ee0bc4019db8b13f Mon Sep 17 00:00:00 2001
|
||||
From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= <lazzer@gmail.com>
|
||||
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 <lazzer@gmail.com>
|
||||
Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
|
||||
---
|
||||
src/sysrepo.c | 2 +-
|
||||
1 file changed, 1 insertion(+), 1 deletion(-)
|
||||
|
||||
+2
-5
@@ -1,16 +1,13 @@
|
||||
From e0c899ba266b959544d7cc08c917cebba7ac91c7 Mon Sep 17 00:00:00 2001
|
||||
From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= <lazzer@gmail.com>
|
||||
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 <lazzer@gmail.com>
|
||||
Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
|
||||
---
|
||||
src/executables/sysrepoctl.c | 20 +++++++++++++--
|
||||
src/lyd_mods.h | 7 ++++++
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
From c7602dc8eabb941e0a163208aaf4de92dd5ef526 Mon Sep 17 00:00:00 2001
|
||||
From: Joachim Wiberg <troglobit@gmail.com>
|
||||
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 <troglobit@gmail.com>
|
||||
Signed-off-by: Mattias Walström <lazzer@gmail.com>
|
||||
---
|
||||
src/log.c | 18 +++++++++++++++++-
|
||||
src/log.h | 1 +
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
From a86dfdd4a5cb74c1f8c90c8d5aea6f5505c1b88c Mon Sep 17 00:00:00 2001
|
||||
From: Joachim Wiberg <troglobit@gmail.com>
|
||||
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 <troglobit@gmail.com>
|
||||
Signed-off-by: Mattias Walström <lazzer@gmail.com>
|
||||
---
|
||||
src/sysrepo.c | 12 ++++++++++++
|
||||
1 file changed, 12 insertions(+)
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
From dbf08c67d8f17bdf98466b18fd72a230269e5d46 Mon Sep 17 00:00:00 2001
|
||||
From: Joachim Wiberg <troglobit@gmail.com>
|
||||
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 <troglobit@gmail.com>
|
||||
Signed-off-by: Mattias Walström <lazzer@gmail.com>
|
||||
---
|
||||
src/shm_sub.c | 2 ++
|
||||
1 file changed, 2 insertions(+)
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
From 2549c966c090dd38a7a09907d27d13107d15aedd Mon Sep 17 00:00:00 2001
|
||||
From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= <lazzer@gmail.com>
|
||||
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 <lazzer@gmail.com>
|
||||
Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
|
||||
---
|
||||
CMakeModules/SetupPrintedContext.cmake | 9 +++++++++
|
||||
1 file changed, 9 insertions(+)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
From 78d62382bf9d665764844a0f686b27e42d73bea9 Mon Sep 17 00:00:00 2001
|
||||
From: Joachim Wiberg <troglobit@gmail.com>
|
||||
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 <troglobit@gmail.com>
|
||||
---
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+343
-100
@@ -14,9 +14,16 @@
|
||||
|
||||
#include <sysrepo.h>
|
||||
#include <sysrepo/netconf_acm.h>
|
||||
#include <sysrepo/values.h>
|
||||
|
||||
#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 */
|
||||
@@ -32,26 +39,28 @@ 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;
|
||||
static int force;
|
||||
static int timeout;
|
||||
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) {
|
||||
perror("getpwuid");
|
||||
exit(1);
|
||||
}
|
||||
if (!pw)
|
||||
err(1, "failed querying user info for uid %d", uid);
|
||||
|
||||
return pw->pw_name;
|
||||
}
|
||||
@@ -86,7 +95,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 +136,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 : "<unknown>", gid, fn, strerror(_errno));
|
||||
warn("setting group owner %s (%d) on %s",
|
||||
gr ? gr->gr_name : "<unknown>", gid, fn);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +163,16 @@ static bool is_uri(const char *str)
|
||||
return strstr(str, "://") != NULL;
|
||||
}
|
||||
|
||||
static bool is_stdout(const char *path)
|
||||
{
|
||||
if (!path)
|
||||
return 1;
|
||||
|
||||
return !strcmp(path, "-") ||
|
||||
!strcmp(path, "/dev/stdout") ||
|
||||
!strcmp(path, "/dev/fd/1");
|
||||
}
|
||||
|
||||
static char *mktmp(void)
|
||||
{
|
||||
mode_t oldmask;
|
||||
@@ -172,6 +190,9 @@ static char *mktmp(void)
|
||||
if (fd < 0)
|
||||
goto err;
|
||||
|
||||
if (chown(path, getuid(), -1))
|
||||
dbg("Failed to chown %s: %s", path, strerror(errno));
|
||||
|
||||
close(fd);
|
||||
return path;
|
||||
err:
|
||||
@@ -185,29 +206,73 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
|
||||
fprintf(stderr, ERRMSG "%s (%d)\n", erri->err->message, erri->err->err_code);
|
||||
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;
|
||||
|
||||
sr_subscription_ctx_t *sub = NULL;
|
||||
const char *user = getuser();
|
||||
sr_conn_ctx_t *conn = NULL;
|
||||
int err;
|
||||
|
||||
@@ -218,88 +283,58 @@ 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;
|
||||
}
|
||||
|
||||
if (!sess) {
|
||||
err = sr_connect(0, &conn);
|
||||
err = sysrepo_init(&conn, &sess, &sub);
|
||||
if (err != SR_ERR_OK) {
|
||||
sysrepo_print_error(sess);
|
||||
fprintf(stderr, ERRMSG "could not connect to %s\n", 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);
|
||||
if (err != SR_ERR_OK) {
|
||||
sysrepo_print_error(sess);
|
||||
fprintf(stderr, ERRMSG "%s session setup failed\n", 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);
|
||||
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);
|
||||
goto err_nacm_destroy;
|
||||
warnx("Failed to initialize session for %s", ds->name);
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return sess;
|
||||
|
||||
err_nacm_destroy:
|
||||
sr_nacm_destroy();
|
||||
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)
|
||||
{
|
||||
sr_session_ctx_t *sess;
|
||||
sr_data_t *data;
|
||||
sr_data_t *data = NULL;
|
||||
int err;
|
||||
|
||||
sess = sysrepo_session(ds);
|
||||
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);
|
||||
fprintf(stderr, ERRMSG "retrieval of %s data failed\n", ds->name);
|
||||
warnx("failed retrieving %s data", ds->name);
|
||||
return err;
|
||||
}
|
||||
|
||||
if (!data)
|
||||
return 0;
|
||||
|
||||
err = lyd_print_path(path, data->tree, LYD_JSON, LYD_PRINT_SIBLINGS);
|
||||
sr_release_data(data);
|
||||
|
||||
if (err) {
|
||||
sysrepo_print_error(sess);
|
||||
fprintf(stderr, ERRMSG "failed to store %s data\n", ds->name);
|
||||
return err;
|
||||
warnx("failed storing %s data", ds->name);
|
||||
}
|
||||
|
||||
return 0;
|
||||
return err;
|
||||
}
|
||||
|
||||
static int sysrepo_import(const struct infix_ds *ds, const char *path)
|
||||
@@ -319,14 +354,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 +425,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,15 +435,33 @@ 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;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int cat(const char *srcpath)
|
||||
{
|
||||
char *argv[] = { "cat", NULL, NULL };
|
||||
int err;
|
||||
|
||||
argv[1] = strdup(srcpath);
|
||||
if (!argv[1])
|
||||
return 1;
|
||||
|
||||
err = subprocess(argv);
|
||||
if (err)
|
||||
warnx("failed writing to stdout, exit code %d", err);
|
||||
|
||||
free(argv[1]);
|
||||
return err;
|
||||
}
|
||||
|
||||
static int cp(const char *srcpath, const char *dstpath)
|
||||
{
|
||||
char *argv[] = {
|
||||
@@ -423,7 +476,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]);
|
||||
@@ -437,6 +490,8 @@ static int put(const char *srcpath, const char *dst,
|
||||
|
||||
if (ds)
|
||||
err = sysrepo_import(ds, srcpath);
|
||||
else if (is_stdout(dst))
|
||||
err = cat(srcpath);
|
||||
else if (is_uri(dst))
|
||||
err = curl_upload(srcpath, dst);
|
||||
|
||||
@@ -480,7 +535,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;
|
||||
}
|
||||
|
||||
@@ -490,11 +545,14 @@ static int resolve_src(const char **src, const struct infix_ds **ds, char **path
|
||||
|
||||
static int resolve_dst(const char **dst, const struct infix_ds **ds, char **path)
|
||||
{
|
||||
if (is_stdout(*dst) || is_uri(*dst))
|
||||
return 0;
|
||||
|
||||
*dst = infix_ds(*dst, ds);
|
||||
|
||||
if (*ds) {
|
||||
if (!(*ds)->rw) {
|
||||
fprintf(stderr, ERRMSG "%s is not writable", (*ds)->name);
|
||||
warn("%s is not writable", (*ds)->name);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -502,19 +560,17 @@ static int resolve_dst(const char **dst, const struct infix_ds **ds, char **path
|
||||
return 0;
|
||||
|
||||
*path = strdup((*ds)->path);
|
||||
} else if (is_uri(*dst)) {
|
||||
return 0;
|
||||
} else {
|
||||
*path = cfg_adjust(*dst, NULL, sanitize);
|
||||
}
|
||||
|
||||
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");
|
||||
if (!force && !*ds && !access(*path, F_OK) && !yorn("Overwrite existing file %s", *path)) {
|
||||
warnx("OK, aborting.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -532,8 +588,8 @@ static int copy(const char *src, const char *dst)
|
||||
/* rw for user and group only */
|
||||
oldmask = umask(0006);
|
||||
|
||||
if (!strcmp(src, dst)) {
|
||||
fprintf(stderr, ERRMSG "source and destination are the same, aborting.\n");
|
||||
if (dst && !strcmp(src, dst)) {
|
||||
warn("source and destination are the same, aborting.");
|
||||
goto err;
|
||||
}
|
||||
|
||||
@@ -558,7 +614,8 @@ err:
|
||||
if (rmsrc)
|
||||
rmtmp(srcpath);
|
||||
|
||||
free(dstpath);
|
||||
if (dstpath)
|
||||
free(dstpath);
|
||||
free(srcpath);
|
||||
|
||||
sync();
|
||||
@@ -568,38 +625,158 @@ err:
|
||||
|
||||
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"
|
||||
" -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"
|
||||
" -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"
|
||||
" -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 A file or datastore, except factory-config\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:\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);
|
||||
"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"
|
||||
"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)
|
||||
{
|
||||
const char *src = NULL, *dst = NULL;
|
||||
printf("Usage: %s [OPTIONS] <rpc-xpath> [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 *dst = "/dev/stdout";
|
||||
const char *src = NULL;
|
||||
int c;
|
||||
|
||||
timeout = fgetint("/etc/default/confd", "=", "CONFD_TIMEOUT");
|
||||
|
||||
while ((c = getopt(argc, argv, "hnst:u:v")) != 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':
|
||||
@@ -617,17 +794,83 @@ int main(int argc, char *argv[])
|
||||
case 'v':
|
||||
puts(PACKAGE_VERSION);
|
||||
return 0;
|
||||
case 'x':
|
||||
xpath = optarg;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (timeout < 0)
|
||||
timeout = 120;
|
||||
|
||||
if (argc - optind != 2)
|
||||
switch (argc - optind) {
|
||||
case 2:
|
||||
src = argv[optind++];
|
||||
dst = argv[optind++];
|
||||
break;
|
||||
case 1:
|
||||
src = argv[optind++];
|
||||
break;
|
||||
default:
|
||||
return usage(1);
|
||||
|
||||
src = argv[optind++];
|
||||
dst = argv[optind++];
|
||||
}
|
||||
|
||||
return copy(src, dst);
|
||||
}
|
||||
|
||||
static int rpc_main(int argc, char *argv[])
|
||||
{
|
||||
int c;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/* Validate argument count (must be key-value pairs) */
|
||||
argc -= optind + 1;
|
||||
if (argc % 2 != 0) {
|
||||
warnx("Arguments must be key-value pairs after RPC xpath");
|
||||
return usage_rpc(1);
|
||||
}
|
||||
|
||||
return rpc_exec(argv[optind], argc, &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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -10,26 +10,30 @@ import argparse
|
||||
|
||||
RAW_OUTPUT = False
|
||||
|
||||
def run_sysrepocfg(xpath: str) -> dict:
|
||||
|
||||
def get_json(xpath: str, datastore: str = "operational", quiet: bool = False) -> dict:
|
||||
if not isinstance(xpath, str) or not xpath.startswith("/"):
|
||||
print("Invalid XPATH. It must be a valid string starting with '/'.")
|
||||
if not quiet:
|
||||
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", "operational", "-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)
|
||||
if not result.stdout.strip():
|
||||
return {}
|
||||
json_data = json.loads(result.stdout)
|
||||
return json_data
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error running sysrepocfg: {e}")
|
||||
if not quiet:
|
||||
print(f"Error running copy: {e}")
|
||||
return {}
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error parsing JSON output: {e}")
|
||||
if not quiet:
|
||||
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 +50,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 +62,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 +74,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 +104,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 +128,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 +140,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 +157,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 +168,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 +196,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 +208,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 +230,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 +244,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 +255,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 +265,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)
|
||||
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', [])
|
||||
# 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}
|
||||
# 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']
|
||||
# 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):
|
||||
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 +311,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 +321,7 @@ def bfd(args: List[str]) -> None:
|
||||
peers brief - Show BFD peers in brief format
|
||||
peer <addr> - 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 +355,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 +372,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 +386,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 +418,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 +439,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 +452,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 +480,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 +493,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 +507,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,6 +609,55 @@ def system(args: List[str]) -> None:
|
||||
|
||||
cli_pretty(data, "show-system")
|
||||
|
||||
|
||||
def nacm(args: List[str]) -> None:
|
||||
"""Handle show nacm [subcommand] [name]
|
||||
|
||||
Subcommands:
|
||||
(none) - Overview with users, groups, and permissions matrix
|
||||
group NAME - Detailed view of a specific group's rules
|
||||
user NAME - Detailed view of a specific user's permissions
|
||||
"""
|
||||
data = get_json("/ietf-netconf-acm:nacm", "running", quiet=True)
|
||||
if not data:
|
||||
user = os.environ.get('USER', 'unknown')
|
||||
print(f'Error: Access denied because "{user}" NACM authorization failed.')
|
||||
return
|
||||
|
||||
# Fetch user data from operational (includes shell and authorized-key from yanger)
|
||||
user_oper = get_json("/ietf-system:system/authentication", quiet=True)
|
||||
|
||||
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
|
||||
|
||||
# Parse arguments: subcommand and optional name
|
||||
subcommand = args[0] if len(args) > 0 and args[0] else ""
|
||||
name = args[1] if len(args) > 1 else None
|
||||
|
||||
# Route to appropriate formatter
|
||||
if subcommand == "":
|
||||
cli_pretty(data, "show-nacm")
|
||||
elif subcommand == "group":
|
||||
if not name:
|
||||
print("Usage: show nacm group <name>")
|
||||
return
|
||||
data['_group_name'] = name
|
||||
cli_pretty(data, "show-nacm-group")
|
||||
elif subcommand == "user":
|
||||
if not name:
|
||||
print("Usage: show nacm user <name>")
|
||||
return
|
||||
data['_user_name'] = name
|
||||
cli_pretty(data, "show-nacm-user")
|
||||
else:
|
||||
print(f"Unknown NACM subcommand: {subcommand}")
|
||||
|
||||
|
||||
def execute_command(command: str, args: List[str]):
|
||||
command_mapping = {
|
||||
'bfd': bfd,
|
||||
@@ -605,6 +667,7 @@ def execute_command(command: str, args: List[str]):
|
||||
'hardware': hardware,
|
||||
'interface': interface,
|
||||
'lldp': lldp,
|
||||
'nacm': nacm,
|
||||
'ntp': ntp,
|
||||
'ospf': ospf,
|
||||
'rip': rip,
|
||||
+2
-2
@@ -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
|
||||
|
||||
+11
-6
@@ -129,14 +129,19 @@ char *cfg_adjust(const char *path, const char *template, bool sanitize)
|
||||
if (!path_allowed(path))
|
||||
goto err;
|
||||
}
|
||||
|
||||
/* CLI users save to /cfg by default, unless abs. path */
|
||||
if (asprintf(&expanded, "%s%.*s/%s%s",
|
||||
path[0] == '/' ? "" : "/cfg/",
|
||||
dlen, path,
|
||||
basename,
|
||||
strchr(basename, '.') ? "" : ".cfg") < 0)
|
||||
goto err;
|
||||
} else {
|
||||
/* Shell users expect copy to behave more like cp */
|
||||
expanded = strdup(path);
|
||||
}
|
||||
|
||||
if (asprintf(&expanded, "%s%.*s/%s%s",
|
||||
path[0] == '/' ? "" : "/cfg/",
|
||||
dlen, path,
|
||||
basename,
|
||||
strchr(basename, '.') ? "" : ".cfg") < 0)
|
||||
goto err;
|
||||
|
||||
if (sanitize) {
|
||||
resolved = realpath(expanded, NULL);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <libite/lite.h>
|
||||
|
||||
#define ERRMSG "Error: "
|
||||
#define DBGMSG "Debug: "
|
||||
#define INFMSG "Note: "
|
||||
|
||||
int yorn (const char *fmt, ...);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"ietf-netconf-acm:nacm": {
|
||||
"enable-nacm": true,
|
||||
"read-default": "permit",
|
||||
"write-default": "permit",
|
||||
"exec-default": "permit",
|
||||
"groups": {
|
||||
"group": [
|
||||
{
|
||||
@@ -8,6 +11,14 @@
|
||||
"user-name": [
|
||||
"admin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "operator",
|
||||
"user-name": []
|
||||
},
|
||||
{
|
||||
"name": "guest",
|
||||
"user-name": []
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -27,16 +38,61 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "operator-acl",
|
||||
"group": [
|
||||
"operator"
|
||||
],
|
||||
"rule": [
|
||||
{
|
||||
"name": "permit-system-rpcs",
|
||||
"module-name": "ietf-system",
|
||||
"rpc-name": "*",
|
||||
"access-operations": "exec",
|
||||
"action": "permit",
|
||||
"comment": "Operators can reboot, shutdown, and set system time."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "guest-acl",
|
||||
"group": [
|
||||
"guest"
|
||||
],
|
||||
"rule": [
|
||||
{
|
||||
"name": "deny-all-write+exec",
|
||||
"module-name": "*",
|
||||
"access-operations": "create update delete exec",
|
||||
"action": "deny",
|
||||
"comment": "Guests cannot change anything or exec rpcs."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "default-deny-all",
|
||||
"group": ["*"],
|
||||
"rule": [
|
||||
{
|
||||
"name": "deny-password-read",
|
||||
"module-name": "ietf-system",
|
||||
"name": "deny-password-access",
|
||||
"path": "/ietf-system:system/authentication/user/password",
|
||||
"access-operations": "*",
|
||||
"action": "deny"
|
||||
"action": "deny",
|
||||
"comment": "No user except admins can access password hashes."
|
||||
},
|
||||
{
|
||||
"name": "deny-keystore-access",
|
||||
"module-name": "ietf-keystore",
|
||||
"access-operations": "*",
|
||||
"action": "deny",
|
||||
"comment": "No user except admins can access cryptographic keys."
|
||||
},
|
||||
{
|
||||
"name": "deny-truststore-access",
|
||||
"module-name": "ietf-truststore",
|
||||
"access-operations": "*",
|
||||
"action": "deny",
|
||||
"comment": "No user except admins can access trust store."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
factorydir = $(pkgdatadir)/factory.d
|
||||
dist_factory_DATA = 10-nacm.json 10-netconf-server.json \
|
||||
10-infix-services.json 10-system.json
|
||||
10-infix-services.json 10-system.json
|
||||
|
||||
@@ -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 -f startup /cfg/startup-config.cfg"))
|
||||
return SR_ERR_SYS;
|
||||
|
||||
return SR_ERR_OK;
|
||||
|
||||
+21
-9
@@ -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"))
|
||||
add_group(user, "sys-cli");
|
||||
else
|
||||
del_group(user, "sys-cli");
|
||||
if (strcmp(shell, "/bin/false")) {
|
||||
add_group(user, "klish");
|
||||
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 */
|
||||
@@ -579,17 +583,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);
|
||||
}
|
||||
@@ -615,7 +627,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;
|
||||
|
||||
@@ -47,7 +47,43 @@ static const char *cd_home(kcontext_t *ctx)
|
||||
return user;
|
||||
}
|
||||
|
||||
static int systemf(const char *fmt, ...)
|
||||
/*
|
||||
* Safe subprocess execution without shell interpretation.
|
||||
* Use this instead of system()/systemf() when arguments come from user input.
|
||||
*/
|
||||
static int run(char *const argv[])
|
||||
{
|
||||
pid_t pid;
|
||||
int status;
|
||||
|
||||
pid = fork();
|
||||
if (pid == 0) {
|
||||
execvp(argv[0], argv);
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
if (pid < 0)
|
||||
return -1;
|
||||
|
||||
if (waitpid(pid, &status, 0) < 0)
|
||||
return -1;
|
||||
|
||||
if (WIFEXITED(status))
|
||||
return WEXITSTATUS(status);
|
||||
|
||||
if (WIFSIGNALED(status)) {
|
||||
errno = EINTR;
|
||||
return -1;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/*
|
||||
* Shell command execution - only use with hardcoded commands or when
|
||||
* shell features (pipes, redirects) are needed. Never use with user input.
|
||||
*/
|
||||
static int shellf(const char *fmt, ...)
|
||||
{
|
||||
va_list ap;
|
||||
char *cmd;
|
||||
@@ -68,8 +104,6 @@ static int systemf(const char *fmt, ...)
|
||||
vsnprintf(cmd, len, fmt, ap);
|
||||
va_end(ap);
|
||||
|
||||
// fprintf(stderr, INFMSG "CMD: '%s'\n", cmd);
|
||||
|
||||
rc = system(cmd);
|
||||
if (rc == -1)
|
||||
return -1;
|
||||
@@ -87,6 +121,7 @@ static int systemf(const char *fmt, ...)
|
||||
|
||||
int infix_datastore(kcontext_t *ctx)
|
||||
{
|
||||
char *argv[] = { "files", "/cfg", NULL };
|
||||
const char *ds;
|
||||
|
||||
ds = kcontext_script(ctx);
|
||||
@@ -104,13 +139,14 @@ int infix_datastore(kcontext_t *ctx)
|
||||
}
|
||||
|
||||
done:
|
||||
return systemf("files /cfg");
|
||||
return run(argv);
|
||||
}
|
||||
|
||||
int infix_erase(kcontext_t *ctx)
|
||||
{
|
||||
kpargv_t *pargv = kcontext_pargv(ctx);
|
||||
const char *path;
|
||||
char *argv[4];
|
||||
|
||||
path = kparg_value(kpargv_find(pargv, "file"));
|
||||
if (!path) {
|
||||
@@ -120,12 +156,18 @@ int infix_erase(kcontext_t *ctx)
|
||||
|
||||
cd_home(ctx);
|
||||
|
||||
return systemf("erase -s %s", path);
|
||||
argv[0] = "erase";
|
||||
argv[1] = "-s";
|
||||
argv[2] = (char *)path;
|
||||
argv[3] = NULL;
|
||||
|
||||
return run(argv);
|
||||
}
|
||||
|
||||
int infix_files(kcontext_t *ctx)
|
||||
{
|
||||
const char *path;
|
||||
char *argv[3];
|
||||
|
||||
cd_home(ctx);
|
||||
path = kcontext_script(ctx);
|
||||
@@ -134,7 +176,11 @@ int infix_files(kcontext_t *ctx)
|
||||
return -1;
|
||||
}
|
||||
|
||||
return systemf("files %s", path);
|
||||
argv[0] = "files";
|
||||
argv[1] = (char *)path;
|
||||
argv[2] = NULL;
|
||||
|
||||
return run(argv);
|
||||
}
|
||||
|
||||
int infix_ifaces(kcontext_t *ctx)
|
||||
@@ -144,12 +190,13 @@ int infix_ifaces(kcontext_t *ctx)
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Note: uses shellf() for pipes, but all arguments are hardcoded by callers */
|
||||
static int firewall_dbus_completion(const char *interface, const char *method, const char *parser)
|
||||
{
|
||||
return systemf("gdbus call --system --dest org.fedoraproject.FirewallD1 "
|
||||
"--object-path /org/fedoraproject/FirewallD1 "
|
||||
"--method org.fedoraproject.FirewallD1.%s.%s 2>/dev/null "
|
||||
"| %s", interface, method, parser);
|
||||
return shellf("gdbus call --system --dest org.fedoraproject.FirewallD1 "
|
||||
"--object-path /org/fedoraproject/FirewallD1 "
|
||||
"--method org.fedoraproject.FirewallD1.%s.%s 2>/dev/null "
|
||||
"| %s", interface, method, parser);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -190,38 +237,52 @@ int infix_firewall_policies(kcontext_t *ctx)
|
||||
* - tr converts single to double quotes
|
||||
* - jq extracts array items
|
||||
*/
|
||||
/* Note: uses shellf() for pipes, but command is hardcoded */
|
||||
int infix_firewall_services(kcontext_t *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
return systemf("gdbus call --system --dest org.fedoraproject.FirewallD1 "
|
||||
"--object-path /org/fedoraproject/FirewallD1 "
|
||||
"--method org.fedoraproject.FirewallD1.listServices 2>/dev/null "
|
||||
"| sed 's/^(//; s/,)$//' | tr \"'\" '\"' | jq -r '.[]' 2>/dev/null");
|
||||
return shellf("gdbus call --system --dest org.fedoraproject.FirewallD1 "
|
||||
"--object-path /org/fedoraproject/FirewallD1 "
|
||||
"--method org.fedoraproject.FirewallD1.listServices 2>/dev/null "
|
||||
"| sed 's/^(//; s/,)$//' | tr \"'\" '\"' | jq -r '.[]' 2>/dev/null");
|
||||
}
|
||||
|
||||
int infix_copy(kcontext_t *ctx)
|
||||
{
|
||||
kpargv_t *pargv = kcontext_pargv(ctx);
|
||||
const char *src, *dst;
|
||||
char user[256] = "";
|
||||
char validate[8] = "";
|
||||
const char *src, *dst, *remote_user;
|
||||
char *argv[12];
|
||||
kparg_t *parg;
|
||||
int i = 0;
|
||||
|
||||
src = kparg_value(kpargv_find(pargv, "src"));
|
||||
dst = kparg_value(kpargv_find(pargv, "dst"));
|
||||
if (!src || !dst)
|
||||
return -1;
|
||||
|
||||
parg = kpargv_find(pargv, "user");
|
||||
if (parg)
|
||||
snprintf(user, sizeof(user), "-u %s", kparg_value(parg));
|
||||
/* Ensure we run the copy command as the logged-in user, not root (klishd) */
|
||||
argv[i++] = "doas";
|
||||
argv[i++] = "-u";
|
||||
argv[i++] = (char *)cd_home(ctx);
|
||||
argv[i++] = "copy";
|
||||
argv[i++] = "-s";
|
||||
|
||||
parg = kpargv_find(pargv, "validate");
|
||||
if (parg)
|
||||
strlcpy(validate, "-n", sizeof(validate));
|
||||
argv[i++] = "-n";
|
||||
|
||||
/* Ensure we run the copy command as the logged-in user, not root (klishd) */
|
||||
return systemf("doas -u %s copy -s %s %s %s %s", cd_home(ctx), validate, user, src, dst);
|
||||
parg = kpargv_find(pargv, "user");
|
||||
if (parg) {
|
||||
remote_user = kparg_value(parg);
|
||||
argv[i++] = "-u";
|
||||
argv[i++] = (char *)remote_user;
|
||||
}
|
||||
|
||||
argv[i++] = (char *)src;
|
||||
argv[i++] = (char *)dst;
|
||||
argv[i] = NULL;
|
||||
|
||||
return run(argv);
|
||||
}
|
||||
|
||||
int infix_shell(kcontext_t *ctx)
|
||||
@@ -299,13 +360,12 @@ static const char *valid_boot_target(const kparg_t *parg)
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int infix_set_boot_order(kcontext_t *ctx)
|
||||
static 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;
|
||||
char *argv[16];
|
||||
int i = 0;
|
||||
|
||||
targets[0] = valid_boot_target(kpargv_find(pargv, "first"));
|
||||
targets[1] = valid_boot_target(kpargv_find(pargv, "second"));
|
||||
@@ -316,34 +376,41 @@ int infix_set_boot_order(kcontext_t *ctx)
|
||||
return -1;
|
||||
}
|
||||
|
||||
fd = mkstemp(tmpfile);
|
||||
if (fd == -1)
|
||||
goto fail;
|
||||
argv[i++] = "doas";
|
||||
argv[i++] = "-u";
|
||||
argv[i++] = (char *)cd_home(ctx);
|
||||
argv[i++] = "rpc";
|
||||
argv[i++] = "/infix-system:set-boot-order";
|
||||
|
||||
fp = fdopen(fd, "w");
|
||||
if (!fp) {
|
||||
close(fd);
|
||||
unlink(tmpfile);
|
||||
fail:
|
||||
fprintf(stderr, ERRMSG "failed creating temporary file\n");
|
||||
return -1;
|
||||
if (targets[0]) {
|
||||
argv[i++] = "boot-order";
|
||||
argv[i++] = (char *)targets[0];
|
||||
}
|
||||
|
||||
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]);
|
||||
if (targets[1]) {
|
||||
argv[i++] = "boot-order";
|
||||
argv[i++] = (char *)targets[1];
|
||||
}
|
||||
fputs("]}}", fp);
|
||||
if (targets[2]) {
|
||||
argv[i++] = "boot-order";
|
||||
argv[i++] = (char *)targets[2];
|
||||
}
|
||||
argv[i] = NULL;
|
||||
|
||||
fclose(fp);
|
||||
return run(argv);
|
||||
}
|
||||
|
||||
rc = systemf("sysrepocfg -R %s -fjson 2>&1", tmpfile);
|
||||
unlink(tmpfile);
|
||||
int infix_users(kcontext_t *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
return shellf("copy oper -x /system/authentication/user/name "
|
||||
"| jq -r '.\"ietf-system:system\".authentication.user[].name'");
|
||||
}
|
||||
|
||||
return rc;
|
||||
int infix_groups(kcontext_t *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
return shellf("copy oper -x /nacm/groups "
|
||||
"| jq -r '.\"ietf-netconf-acm:nacm\".groups.group[].name'");
|
||||
}
|
||||
|
||||
int kplugin_infix_fini(kcontext_t *ctx)
|
||||
@@ -363,6 +430,8 @@ int kplugin_infix_init(kcontext_t *ctx)
|
||||
kplugin_add_syms(plugin, ksym_new("erase", infix_erase));
|
||||
kplugin_add_syms(plugin, ksym_new("files", infix_files));
|
||||
kplugin_add_syms(plugin, ksym_new("ifaces", infix_ifaces));
|
||||
kplugin_add_syms(plugin, ksym_new("users", infix_users));
|
||||
kplugin_add_syms(plugin, ksym_new("groups", infix_groups));
|
||||
kplugin_add_syms(plugin, ksym_new("firewall_zones", infix_firewall_zones));
|
||||
kplugin_add_syms(plugin, ksym_new("firewall_policies", infix_firewall_policies));
|
||||
kplugin_add_syms(plugin, ksym_new("firewall_services", infix_firewall_services));
|
||||
|
||||
@@ -141,6 +141,20 @@
|
||||
<ACTION sym="STRING"/>
|
||||
</PTYPE>
|
||||
|
||||
<PTYPE name="USERS">
|
||||
<COMPL>
|
||||
<ACTION sym="users@infix"/>
|
||||
</COMPL>
|
||||
<ACTION sym="STRING"/>
|
||||
</PTYPE>
|
||||
|
||||
<PTYPE name="GROUPS">
|
||||
<COMPL>
|
||||
<ACTION sym="groups@infix"/>
|
||||
</COMPL>
|
||||
<ACTION sym="STRING"/>
|
||||
</PTYPE>
|
||||
|
||||
<VIEW name="main">
|
||||
<HOTKEY key="^D" cmd="exit"/>
|
||||
|
||||
@@ -238,7 +252,7 @@
|
||||
<PARAM name="pwpass" ptype="/STRING" help="Optional clear text password (no spaces)"/>
|
||||
</SWITCH>
|
||||
<ACTION sym="script" in="tty" out="tty" interrupt="true">
|
||||
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
|
||||
</ACTION>
|
||||
@@ -324,6 +338,20 @@
|
||||
</ACTION>
|
||||
</COMMAND>
|
||||
|
||||
<COMMAND name="nacm" help="Show users and NACM status and groups">
|
||||
<SWITCH name="subcommands" min="0">
|
||||
<COMMAND name="group" help="Show details for a specific NACM group">
|
||||
<PARAM name="name" ptype="/GROUPS" help="Group name"/>
|
||||
<ACTION sym="script" in="tty" out="tty" interrupt="true">show nacm group $KLISH_PARAM_name</ACTION>
|
||||
</COMMAND>
|
||||
<COMMAND name="user" help="Show NACM details for a specific user">
|
||||
<PARAM name="name" ptype="/USERS" help="User name"/>
|
||||
<ACTION sym="script" in="tty" out="tty" interrupt="true">show nacm user $KLISH_PARAM_name</ACTION>
|
||||
</COMMAND>
|
||||
</SWITCH>
|
||||
<ACTION sym="script" in="tty" out="tty" interrupt="true">show nacm</ACTION>
|
||||
</COMMAND>
|
||||
|
||||
<COMMAND name="ntp" help="Show NTP status">
|
||||
<SWITCH name="subcommands" min="0">
|
||||
<COMMAND name="source" help="Show NTP source(s)">
|
||||
@@ -628,15 +656,15 @@
|
||||
</COMMAND>
|
||||
|
||||
<COMMAND name="factory-config" help="Show factory-config">
|
||||
<ACTION sym="script" in="tty" out="tty" interrupt="true">jq -C . /etc/factory-config.cfg |pager</ACTION>
|
||||
<ACTION sym="script" in="tty" out="tty" interrupt="true">copy factory | jq -C . |pager</ACTION>
|
||||
</COMMAND>
|
||||
|
||||
<COMMAND name="running-config" help="Show running-config">
|
||||
<ACTION sym="script" in="tty" out="tty" interrupt="true">sysrepocfg -X -f json | jq -C . |pager</ACTION>
|
||||
<ACTION sym="script" in="tty" out="tty" interrupt="true">copy running | jq -C . |pager</ACTION>
|
||||
</COMMAND>
|
||||
|
||||
<COMMAND name="startup-config" help="Show startup-config">
|
||||
<ACTION sym="script" in="tty" out="tty" interrupt="true">jq -C . /cfg/startup-config.cfg |pager</ACTION>
|
||||
<ACTION sym="script" in="tty" out="tty" interrupt="true">copy startup | jq -C . |pager</ACTION>
|
||||
</COMMAND>
|
||||
|
||||
<COMMAND name="firewall" help="Show firewall status and configuration">
|
||||
@@ -646,12 +674,12 @@
|
||||
<PARAM name="limit" ptype="/UINT" help="Last N lines, default: all"/>
|
||||
</SWITCH>
|
||||
<ACTION sym="script" in="tty" out="tty" interrupt="true">
|
||||
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
|
||||
</ACTION>
|
||||
</COMMAND>
|
||||
<COMMAND name="matrix" help="Show firewall zone matrix">
|
||||
<ACTION sym="script" in="tty" out="tty" interrupt="true">
|
||||
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
|
||||
</ACTION>
|
||||
</COMMAND>
|
||||
<COMMAND name="zone" help="Show firewall zones">
|
||||
@@ -659,7 +687,7 @@
|
||||
<PARAM name="name" ptype="/FIREWALL_ZONES" help="Zone name"/>
|
||||
</SWITCH>
|
||||
<ACTION sym="script" in="tty" out="tty" interrupt="true">
|
||||
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
|
||||
</ACTION>
|
||||
</COMMAND>
|
||||
<COMMAND name="policy" help="Show firewall policies">
|
||||
@@ -667,7 +695,7 @@
|
||||
<PARAM name="name" ptype="/FIREWALL_POLICIES" help="Policy name"/>
|
||||
</SWITCH>
|
||||
<ACTION sym="script" in="tty" out="tty" interrupt="true">
|
||||
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
|
||||
</ACTION>
|
||||
</COMMAND>
|
||||
<COMMAND name="service" help="Show firewall services">
|
||||
@@ -675,12 +703,12 @@
|
||||
<PARAM name="name" ptype="/FIREWALL_SERVICES" help="Service name"/>
|
||||
</SWITCH>
|
||||
<ACTION sym="script" in="tty" out="tty" interrupt="true">
|
||||
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
|
||||
</ACTION>
|
||||
</COMMAND>
|
||||
</SWITCH>
|
||||
<ACTION sym="script" in="tty" out="tty" interrupt="true">
|
||||
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
|
||||
</ACTION>
|
||||
</COMMAND>
|
||||
</COMMAND>
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -216,11 +216,12 @@ def format_uptime_seconds(seconds):
|
||||
|
||||
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"""
|
||||
@@ -307,6 +308,11 @@ class SimpleTable:
|
||||
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):
|
||||
@@ -2900,6 +2906,481 @@ def show_ntp_source(json, address=None):
|
||||
print(row)
|
||||
|
||||
|
||||
def _analyze_group_permissions(nacm):
|
||||
"""Analyze NACM rules to determine effective permissions per group.
|
||||
|
||||
NACM rule evaluation order:
|
||||
1. Rule-lists are processed sequentially in configuration order
|
||||
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
|
||||
5. permit-all (module-name=*, access-operations=*) bypasses everything
|
||||
"""
|
||||
read_default = nacm.get('read-default', 'permit') == 'permit'
|
||||
write_default = nacm.get('write-default', 'permit') == 'permit'
|
||||
exec_default = nacm.get('exec-default', 'permit') == 'permit'
|
||||
|
||||
rule_lists = nacm.get('rule-list', [])
|
||||
groups_config = nacm.get('groups', {}).get('group', [])
|
||||
|
||||
# Collect all deny rules that apply to "*" (all groups)
|
||||
# These create restrictions for groups without permit-all
|
||||
global_denials = []
|
||||
for rule_list in rule_lists:
|
||||
if '*' in rule_list.get('group', []):
|
||||
for rule in rule_list.get('rule', []):
|
||||
if rule.get('action') == 'deny':
|
||||
global_denials.append(rule)
|
||||
|
||||
results = []
|
||||
|
||||
for group in groups_config:
|
||||
group_name = group.get('name', '')
|
||||
|
||||
# Defaults
|
||||
can_read = read_default
|
||||
can_write = write_default
|
||||
can_exec = exec_default
|
||||
restrictions = []
|
||||
has_permit_all = False
|
||||
|
||||
# Process rule-lists in order (first match wins)
|
||||
for rule_list in rule_lists:
|
||||
list_groups = rule_list.get('group', [])
|
||||
|
||||
# Check if this rule-list applies to this group specifically
|
||||
if group_name not in list_groups:
|
||||
continue
|
||||
|
||||
for rule in rule_list.get('rule', []):
|
||||
action = rule.get('action')
|
||||
access_ops = rule.get('access-operations', '')
|
||||
module_name = rule.get('module-name', '')
|
||||
|
||||
# permit-all pattern: bypasses ALL other rules including global denials
|
||||
if action == 'permit' and module_name == '*' and access_ops == '*':
|
||||
has_permit_all = True
|
||||
break
|
||||
|
||||
# deny pattern for write+exec (like guest)
|
||||
if action == 'deny' and module_name == '*':
|
||||
ops = access_ops.lower()
|
||||
if all(op in ops for op in ['create', 'update', 'delete']):
|
||||
can_write = False
|
||||
if 'exec' in ops:
|
||||
can_exec = False
|
||||
|
||||
if has_permit_all:
|
||||
break
|
||||
|
||||
# If not permit-all, global denials create restrictions
|
||||
# These affect READ too (access-operations: "*" includes read)
|
||||
if not has_permit_all:
|
||||
for rule in global_denials:
|
||||
path = rule.get('path', '')
|
||||
module = rule.get('module-name', '')
|
||||
|
||||
if path:
|
||||
# Extract meaningful part: /ietf-system:system/.../password -> password
|
||||
restriction = path.rstrip('/').split('/')[-1]
|
||||
elif module:
|
||||
# Remove ietf- prefix for brevity: ietf-keystore -> keystore
|
||||
restriction = module.replace('ietf-', '')
|
||||
else:
|
||||
continue
|
||||
|
||||
if restriction and restriction not in restrictions:
|
||||
restrictions.append(restriction)
|
||||
|
||||
results.append({
|
||||
'group': group_name,
|
||||
'read': can_read,
|
||||
'write': can_write,
|
||||
'exec': can_exec,
|
||||
'restrictions': restrictions,
|
||||
'has_permit_all': has_permit_all
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _nacm_permissions_matrix(nacm, width=None):
|
||||
"""Render NACM group permissions as a colored matrix.
|
||||
|
||||
Symbols: ✓=full access, ⚠=restricted, ✗=denied
|
||||
|
||||
Returns:
|
||||
Multi-line string containing the rendered matrix, or None if no groups
|
||||
"""
|
||||
permissions = _analyze_group_permissions(nacm)
|
||||
if not permissions:
|
||||
return None
|
||||
|
||||
# Column widths
|
||||
group_col_width = max(len(p['group']) for p in permissions)
|
||||
group_col_width = max(group_col_width, 5) # Minimum for "GROUP"
|
||||
cell_width = 7 # Width for READ/WRITE/EXEC columns
|
||||
|
||||
# Box drawing
|
||||
top_border = "┌" + "─" * (group_col_width + 2) + "┬"
|
||||
top_border += "─" * (cell_width + 2) + "┬"
|
||||
top_border += "─" * (cell_width + 2) + "┬"
|
||||
top_border += "─" * (cell_width + 2) + "┐"
|
||||
|
||||
header_border = "├" + "─" * (group_col_width + 2) + "┼"
|
||||
header_border += "─" * (cell_width + 2) + "┼"
|
||||
header_border += "─" * (cell_width + 2) + "┼"
|
||||
header_border += "─" * (cell_width + 2) + "┤"
|
||||
|
||||
bottom_border = "└" + "─" * (group_col_width + 2) + "┴"
|
||||
bottom_border += "─" * (cell_width + 2) + "┴"
|
||||
bottom_border += "─" * (cell_width + 2) + "┴"
|
||||
bottom_border += "─" * (cell_width + 2) + "┘"
|
||||
|
||||
# Header row
|
||||
header = f"│ {'GROUP':<{group_col_width}} │"
|
||||
header += f" {'READ':^{cell_width}} │"
|
||||
header += f" {'WRITE':^{cell_width}} │"
|
||||
header += f" {'EXEC':^{cell_width}} │"
|
||||
|
||||
# Calculate centering
|
||||
matrix_width = len(top_border)
|
||||
if width and width > matrix_width:
|
||||
padding = (width - matrix_width) // 2
|
||||
indent = " " * padding
|
||||
else:
|
||||
indent = ""
|
||||
|
||||
lines = []
|
||||
lines.append(indent + top_border)
|
||||
lines.append(indent + header)
|
||||
lines.append(indent + header_border)
|
||||
|
||||
# Data rows
|
||||
for perm in permissions:
|
||||
group_name = perm['group']
|
||||
has_restrictions = bool(perm['restrictions'])
|
||||
|
||||
# Determine symbols and colors for each cell
|
||||
def get_cell(has_access, has_restrictions):
|
||||
if not has_access:
|
||||
return Decore.red_bg(f" {'✗':^{cell_width}} ")
|
||||
elif has_restrictions:
|
||||
return Decore.yellow_bg(f" {'⚠':^{cell_width}} ")
|
||||
else:
|
||||
return Decore.green_bg(f" {'✓':^{cell_width}} ")
|
||||
|
||||
read_cell = get_cell(perm['read'], has_restrictions)
|
||||
write_cell = get_cell(perm['write'], has_restrictions)
|
||||
exec_cell = get_cell(perm['exec'], has_restrictions)
|
||||
|
||||
row = f"│ {group_name:<{group_col_width}} │{read_cell}│{write_cell}│{exec_cell}│"
|
||||
lines.append(indent + row)
|
||||
|
||||
lines.append(indent + bottom_border)
|
||||
|
||||
# Legend
|
||||
legend_data = [
|
||||
("✓ Full", Decore.green_bg),
|
||||
("⚠ Restricted", Decore.yellow_bg),
|
||||
("✗ Denied", Decore.red_bg)
|
||||
]
|
||||
colorized_parts = [bg_func(f" {text} ") for text, bg_func in legend_data]
|
||||
legend = " ".join(colorized_parts)
|
||||
|
||||
if width:
|
||||
visible_legend = " ".join([f" {text} " for text, _ in legend_data])
|
||||
legend_padding = max(0, (width - len(visible_legend)) // 2)
|
||||
lines.append(" " * legend_padding + legend)
|
||||
else:
|
||||
lines.append(indent + legend)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
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':<21}: {enabled}")
|
||||
print(f"{'default read access':<21}: {nacm.get('read-default', 'permit')}")
|
||||
print(f"{'default write access':<21}: {nacm.get('write-default', 'deny')}")
|
||||
print(f"{'default exec access':<21}: {nacm.get('exec-default', 'permit')}")
|
||||
print(f"{'denied operations':<21}: {nacm.get('denied-operations', 0)}")
|
||||
print(f"{'denied data writes':<21}: {nacm.get('denied-data-writes', 0)}")
|
||||
print(f"{'denied notifications':<21}: {nacm.get('denied-notifications', 0)}")
|
||||
print()
|
||||
|
||||
|
||||
# Group permissions matrix
|
||||
if nacm:
|
||||
matrix = _nacm_permissions_matrix(nacm, min_width)
|
||||
if matrix:
|
||||
print(matrix)
|
||||
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()
|
||||
print()
|
||||
|
||||
|
||||
def show_nacm_group(json):
|
||||
"""Display detailed information about a specific NACM group."""
|
||||
group_name = json.get('_group_name')
|
||||
nacm = json.get("ietf-netconf-acm:nacm", {})
|
||||
|
||||
if not nacm:
|
||||
print("NACM not configured.")
|
||||
return
|
||||
|
||||
# Find the group
|
||||
groups = nacm.get("groups", {}).get("group", [])
|
||||
group = None
|
||||
for g in groups:
|
||||
if g.get("name") == group_name:
|
||||
group = g
|
||||
break
|
||||
|
||||
if not group:
|
||||
print(f"Group '{group_name}' not found.")
|
||||
return
|
||||
|
||||
# Group info
|
||||
members = group.get("user-name", [])
|
||||
members_str = ", ".join(members) if members else "(none)"
|
||||
print(f"{'members':<17}: {members_str}")
|
||||
|
||||
# Analyze permissions
|
||||
permissions = _analyze_group_permissions(nacm)
|
||||
group_perm = None
|
||||
for p in permissions:
|
||||
if p['group'] == group_name:
|
||||
group_perm = p
|
||||
break
|
||||
|
||||
if group_perm:
|
||||
def perm_str(has_access, has_restrictions):
|
||||
if not has_access:
|
||||
return Decore.red("no")
|
||||
elif has_restrictions:
|
||||
return Decore.yellow("restricted")
|
||||
else:
|
||||
return Decore.green("yes")
|
||||
|
||||
has_restrictions = bool(group_perm['restrictions'])
|
||||
print(f"{'read permission':<17}: {perm_str(group_perm['read'], has_restrictions)}")
|
||||
print(f"{'write permission':<17}: {perm_str(group_perm['write'], has_restrictions)}")
|
||||
print(f"{'exec permission':<17}: {perm_str(group_perm['exec'], has_restrictions)}")
|
||||
|
||||
# Find applicable rules, respecting NACM evaluation order
|
||||
# Rule-lists are evaluated in order; first matching rule wins
|
||||
# If a permit-all rule is found, no further rules would be evaluated
|
||||
rule_lists = nacm.get("rule-list", [])
|
||||
applicable_rules = []
|
||||
has_permit_all = group_perm.get('has_permit_all', False) if group_perm else False
|
||||
|
||||
for rule_list in rule_lists:
|
||||
list_groups = rule_list.get("group", [])
|
||||
|
||||
# Check if this rule-list applies to this group specifically (not via "*")
|
||||
if group_name in list_groups:
|
||||
list_name = rule_list.get("name", "")
|
||||
for rule in rule_list.get("rule", []):
|
||||
applicable_rules.append({
|
||||
'list': list_name,
|
||||
'rule': rule,
|
||||
'applies_via': group_name
|
||||
})
|
||||
|
||||
# Check if this is a permit-all rule
|
||||
action = rule.get('action')
|
||||
module_name = rule.get('module-name', '')
|
||||
access_ops = rule.get('access-operations', '')
|
||||
if action == 'permit' and module_name == '*' and access_ops == '*':
|
||||
# permit-all matches everything, stop here
|
||||
break
|
||||
|
||||
if has_permit_all:
|
||||
# Don't process any more rule-lists
|
||||
break
|
||||
|
||||
# Only add rules from "*" group if we don't have permit-all
|
||||
if not has_permit_all:
|
||||
for rule_list in rule_lists:
|
||||
list_groups = rule_list.get("group", [])
|
||||
if "*" in list_groups and group_name not in list_groups:
|
||||
list_name = rule_list.get("name", "")
|
||||
for rule in rule_list.get("rule", []):
|
||||
applicable_rules.append({
|
||||
'list': list_name,
|
||||
'rule': rule,
|
||||
'applies_via': "*"
|
||||
})
|
||||
|
||||
if applicable_rules:
|
||||
print(f"{'applicable rules':<17}: {len(applicable_rules)}")
|
||||
|
||||
for item in applicable_rules:
|
||||
rule = item['rule']
|
||||
rule_name = rule.get('name', '(unnamed)')
|
||||
action = rule.get('action', 'permit')
|
||||
access_ops = rule.get('access-operations', '*')
|
||||
module_name = rule.get('module-name', '')
|
||||
path = rule.get('path', '')
|
||||
rpc_name = rule.get('rpc-name', '')
|
||||
comment = rule.get('comment', '')
|
||||
|
||||
# Format action with color
|
||||
if action == 'permit':
|
||||
action_str = Decore.green("permit")
|
||||
else:
|
||||
action_str = Decore.red("deny")
|
||||
|
||||
# Build target description
|
||||
target = ""
|
||||
if path:
|
||||
target = path
|
||||
elif module_name:
|
||||
target = module_name
|
||||
if rpc_name:
|
||||
target += f" (rpc: {rpc_name})"
|
||||
|
||||
applies_note = f" (via '*')" if item['applies_via'] == '*' else ""
|
||||
|
||||
Decore.title(f"{rule_name}{applies_note}", width=70)
|
||||
print(f"{' action':<13}: {action_str}")
|
||||
print(f"{' operations':<13}: {access_ops}")
|
||||
if target:
|
||||
print(f"{' target':<13}: {target}")
|
||||
if comment:
|
||||
print(f"{' comment':<13}: {comment}")
|
||||
print()
|
||||
|
||||
|
||||
def show_nacm_user(json):
|
||||
"""Display detailed information about a specific user's NACM permissions."""
|
||||
user_name = json.get('_user_name')
|
||||
nacm = json.get("ietf-netconf-acm:nacm", {})
|
||||
system = json.get("ietf-system:system", {})
|
||||
|
||||
# Find the user
|
||||
users = system.get("authentication", {}).get("user", [])
|
||||
user = None
|
||||
for u in users:
|
||||
if u.get("name") == user_name:
|
||||
user = u
|
||||
break
|
||||
|
||||
if not user:
|
||||
print(f"User '{user_name}' not found.")
|
||||
return
|
||||
|
||||
# User details
|
||||
shell_data = user.get("infix-system:shell", "false")
|
||||
shell = shell_data.split(":")[-1] if ":" in shell_data else shell_data
|
||||
print(f"{'shell':<17}: {shell}")
|
||||
|
||||
has_password = bool(user.get("password"))
|
||||
has_keys = bool(user.get("authorized-key"))
|
||||
if has_password and has_keys:
|
||||
login = "password + SSH key"
|
||||
elif has_password:
|
||||
login = "password"
|
||||
elif has_keys:
|
||||
login = "SSH key"
|
||||
else:
|
||||
login = "(none configured)"
|
||||
print(f"{'login':<17}: {login}")
|
||||
|
||||
# Find which NACM groups this user belongs to
|
||||
if not nacm:
|
||||
print("NACM not configured.")
|
||||
return
|
||||
|
||||
groups = nacm.get("groups", {}).get("group", [])
|
||||
user_groups = []
|
||||
for group in groups:
|
||||
if user_name in group.get("user-name", []):
|
||||
user_groups.append(group.get("name"))
|
||||
|
||||
groups_str = ", ".join(user_groups) if user_groups else "(none)"
|
||||
print(f"{'nacm group':<17}: {groups_str}")
|
||||
|
||||
# Show effective permissions for each group
|
||||
if user_groups:
|
||||
permissions = _analyze_group_permissions(nacm)
|
||||
|
||||
for group_name in user_groups:
|
||||
for p in permissions:
|
||||
if p['group'] == group_name:
|
||||
def perm_str(has_access, has_restrictions):
|
||||
if not has_access:
|
||||
return Decore.red("no")
|
||||
elif has_restrictions:
|
||||
return Decore.yellow("restricted")
|
||||
else:
|
||||
return Decore.green("yes")
|
||||
|
||||
has_restrictions = bool(p['restrictions'])
|
||||
print(f"{'read permission':<17}: {perm_str(p['read'], has_restrictions)}")
|
||||
print(f"{'write permission':<17}: {perm_str(p['write'], has_restrictions)}")
|
||||
print(f"{'exec permission':<17}: {perm_str(p['exec'], has_restrictions)}")
|
||||
break
|
||||
|
||||
print()
|
||||
print("For detailed rules, use: show nacm group <name>")
|
||||
|
||||
|
||||
def show_system(json):
|
||||
"""System information overivew"""
|
||||
if not json.get("ietf-system:system-state"):
|
||||
@@ -4884,6 +5365,10 @@ 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-nacm-group', help='Show NACM group details')
|
||||
subparsers.add_parser('show-nacm-user', help='Show NACM user details')
|
||||
|
||||
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')
|
||||
@@ -4947,6 +5432,12 @@ 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-nacm-group":
|
||||
show_nacm_group(json_data)
|
||||
elif args.command == "show-nacm-user":
|
||||
show_nacm_user(json_data)
|
||||
elif args.command == "show-ntp":
|
||||
show_ntp(json_data, args.address)
|
||||
elif args.command == "show-ntp-tracking":
|
||||
|
||||
@@ -262,28 +262,75 @@ 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]
|
||||
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
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
insert(out, "authentication", "user", users)
|
||||
|
||||
def add_clock(out):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -88,7 +88,7 @@ with infamy.Test() as test:
|
||||
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 @@ with infamy.Test() as test:
|
||||
}
|
||||
}})
|
||||
|
||||
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 @@ with infamy.Test() as test:
|
||||
},
|
||||
"ietf-system": {
|
||||
"system": {
|
||||
"hostname": HOSTNM1,
|
||||
"ntp": {"enabled": True},
|
||||
}
|
||||
}})
|
||||
@@ -246,7 +279,6 @@ with infamy.Test() as test:
|
||||
},
|
||||
"ietf-system": {
|
||||
"system": {
|
||||
"hostname": HOSTNM2,
|
||||
"ntp": {"enabled": True},
|
||||
}
|
||||
}})
|
||||
@@ -272,7 +304,6 @@ with infamy.Test() as test:
|
||||
},
|
||||
"ietf-system": {
|
||||
"system": {
|
||||
"hostname": HOSTNM3,
|
||||
"ntp": {"enabled": True},
|
||||
"dns-resolver": {
|
||||
"search": [
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
<<<
|
||||
|
||||
@@ -24,8 +24,8 @@ with infamy.Test() as test:
|
||||
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()
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"user": [
|
||||
{
|
||||
"name": "admin",
|
||||
"password": "$5$mI/zpOAqZYKLC2WU$i7iPzZiIjOjrBF3NyftS9CCq8dfYwHwrmUK097Jca9A"
|
||||
"password": "$5$mI/zpOAqZYKLC2WU$i7iPzZiIjOjrBF3NyftS9CCq8dfYwHwrmUK097Jca9A",
|
||||
"infix-system:shell": "infix-system:bash"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -39,7 +39,8 @@
|
||||
"user": [
|
||||
{
|
||||
"name": "admin",
|
||||
"password": "$5$mI/zpOAqZYKLC2WU$i7iPzZiIjOjrBF3NyftS9CCq8dfYwHwrmUK097Jca9A"
|
||||
"password": "$5$mI/zpOAqZYKLC2WU$i7iPzZiIjOjrBF3NyftS9CCq8dfYwHwrmUK097Jca9A",
|
||||
"infix-system:shell": "infix-system:bash"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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
|
||||
@@ -5,7 +5,7 @@ sys:*:::::::
|
||||
sync:*:::::::
|
||||
mail:*:::::::
|
||||
www-data:*:::::::
|
||||
sys-cli:*:::::::
|
||||
sysrepo:*:::::::
|
||||
backup:*:::::::
|
||||
nobody:*:::::::
|
||||
yangnobody:*:::::::
|
||||
|
||||
@@ -23,6 +23,10 @@ include::user_admin/Readme.adoc[]
|
||||
|
||||
<<<
|
||||
|
||||
include::nacm-basic/Readme.adoc[]
|
||||
|
||||
<<<
|
||||
|
||||
include::timezone/Readme.adoc[]
|
||||
|
||||
<<<
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
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
|
||||
|
||||
|
||||
Executable
+257
@@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Verify basic NACM permission enforcement
|
||||
|
||||
Test that NACM groups (admin, operator, guest) correctly enforce
|
||||
access control with permissive defaults and targeted denials.
|
||||
|
||||
The NACM design is "permit by default, deny sensitive items":
|
||||
|
||||
- admin: Full unrestricted access (permit-all rule)
|
||||
- operator: Can configure everything EXCEPT passwords, keystore, truststore
|
||||
- guest: Read-only access (explicit deny of create/update/delete/exec)
|
||||
|
||||
Verifies that:
|
||||
|
||||
- Operators can read and modify most configuration (hostname, interfaces)
|
||||
- Operators CANNOT read or write password hashes (protected path)
|
||||
- Guests can read but cannot modify any configuration
|
||||
- Admin can access everything including passwords
|
||||
"""
|
||||
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": "permit",
|
||||
"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-system-rpcs",
|
||||
"module-name": "ietf-system",
|
||||
"access-operations": "exec",
|
||||
"action": "permit",
|
||||
"comment": "Operators can reboot, shutdown, and set system time"
|
||||
}]
|
||||
}, {
|
||||
"name": "guest-acl",
|
||||
"group": ["guest"],
|
||||
"rule": [{
|
||||
"name": "deny-all-exec",
|
||||
"module-name": "*",
|
||||
"access-operations": "create update delete exec",
|
||||
"action": "deny",
|
||||
"comment": "Guests cannot change anything or call rpcs"
|
||||
}]
|
||||
}, {
|
||||
"name": "default-deny-all",
|
||||
"group": ["*"],
|
||||
"rule": [{
|
||||
"name": "deny-password-access",
|
||||
"path": "/ietf-system:system/authentication/user/password",
|
||||
"access-operations": "*",
|
||||
"action": "deny",
|
||||
"comment": "No user except admins can access password hashes."
|
||||
}, {
|
||||
"name": "deny-keystore-access",
|
||||
"module-name": "ietf-keystore",
|
||||
"access-operations": "*",
|
||||
"action": "deny",
|
||||
"comment": "No user except admins can access cryptographic keys."
|
||||
}, {
|
||||
"name": "deny-truststore-access",
|
||||
"module-name": "ietf-truststore",
|
||||
"access-operations": "*",
|
||||
"action": "deny",
|
||||
"comment": "No user except admins can access trust store."
|
||||
}]
|
||||
}]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
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 can modify hostname"):
|
||||
# Operators can now modify most system config (write-default: permit)
|
||||
operator.patch_config("ietf-system", {
|
||||
"system": {
|
||||
"hostname": "operator-test"
|
||||
}
|
||||
})
|
||||
|
||||
# Verify the change
|
||||
cfg = operator.get_config_dict("/ietf-system:system")
|
||||
assert cfg.get("system", {}).get("hostname") == "operator-test", \
|
||||
"Operator failed to modify hostname"
|
||||
print("Operator successfully modified hostname")
|
||||
|
||||
with test.step("Verify operator cannot read password hashes"):
|
||||
# Password hashes are protected by deny-password-access rule
|
||||
cfg = operator.get_config_dict("/ietf-system:system")
|
||||
users = cfg.get("system", {}).get("authentication", {}).get("user", [])
|
||||
|
||||
# Check that no user entry contains a password field
|
||||
for user in users:
|
||||
assert "password" not in user, \
|
||||
f"Operator should NOT be able to read password for user '{user.get('name')}'"
|
||||
print("Operator correctly denied read access to password hashes")
|
||||
|
||||
with test.step("Verify operator cannot write password hashes"):
|
||||
# Try to change a password - should fail due to NACM deny rule
|
||||
# Use a valid MD5 hash format to ensure we test NACM, not YANG validation
|
||||
valid_hash = "$1$testsalt$YvPTBnV5RhkWwXLzR7kK/1"
|
||||
try:
|
||||
operator.patch_config("ietf-system", {
|
||||
"system": {
|
||||
"authentication": {
|
||||
"user": [{
|
||||
"name": OPERATOR_USER,
|
||||
"password": valid_hash
|
||||
}]
|
||||
}
|
||||
}
|
||||
}, retries=1)
|
||||
assert False, "Operator should NOT be able to modify passwords!"
|
||||
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("Operator correctly denied write access to password hashes")
|
||||
|
||||
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 access passwords"):
|
||||
# Admin should have full access including protected paths
|
||||
cfg = target.get_config_dict("/ietf-system:system")
|
||||
users = cfg.get("system", {}).get("authentication", {}).get("user", [])
|
||||
|
||||
# Admin should be able to see password hashes
|
||||
has_password = any("password" in user for user in users)
|
||||
assert has_password, "Admin should be able to read password hashes"
|
||||
print("Admin successfully read password hashes")
|
||||
|
||||
# Admin can also modify passwords
|
||||
target.put_config_dicts({
|
||||
"ietf-system": {
|
||||
"system": {
|
||||
"hostname": "admin-test"
|
||||
}
|
||||
}
|
||||
})
|
||||
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()
|
||||
@@ -0,0 +1 @@
|
||||
../../../infamy/topologies/1x1.dot
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
|
||||
<!-- Title: 1x1 Pages: 1 -->
|
||||
<svg width="424pt" height="45pt"
|
||||
viewBox="0.00 0.00 424.03 45.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 41)">
|
||||
<title>1x1</title>
|
||||
<polygon fill="white" stroke="transparent" points="-4,4 -4,-41 420.03,-41 420.03,4 -4,4"/>
|
||||
<!-- host -->
|
||||
<g id="node1" class="node">
|
||||
<title>host</title>
|
||||
<polygon fill="none" stroke="black" points="0,-0.5 0,-36.5 100,-36.5 100,-0.5 0,-0.5"/>
|
||||
<text text-anchor="middle" x="25" y="-14.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">host</text>
|
||||
<polyline fill="none" stroke="black" points="50,-0.5 50,-36.5 "/>
|
||||
<text text-anchor="middle" x="75" y="-14.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
|
||||
</g>
|
||||
<!-- target -->
|
||||
<g id="node2" class="node">
|
||||
<title>target</title>
|
||||
<polygon fill="none" stroke="black" points="300.03,-0.5 300.03,-36.5 416.03,-36.5 416.03,-0.5 300.03,-0.5"/>
|
||||
<text text-anchor="middle" x="325.03" y="-14.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
|
||||
<polyline fill="none" stroke="black" points="350.03,-0.5 350.03,-36.5 "/>
|
||||
<text text-anchor="middle" x="383.03" y="-14.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">target</text>
|
||||
</g>
|
||||
<!-- host--target -->
|
||||
<g id="edge1" class="edge">
|
||||
<title>host:mgmt--target:mgmt</title>
|
||||
<path fill="none" stroke="lightgray" stroke-width="2" d="M100,-18.5C100,-18.5 300.03,-18.5 300.03,-18.5"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
+8
-4
@@ -17,10 +17,14 @@ class Server:
|
||||
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()
|
||||
|
||||
+19
-6
@@ -281,7 +281,7 @@ class Device(Transport):
|
||||
"""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 @@ class Device(Transport):
|
||||
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 @@ class Device(Transport):
|
||||
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 @@ class Device(Transport):
|
||||
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,20 @@ class Device(Transport):
|
||||
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 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"""
|
||||
|
||||
+190
-42
@@ -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 @@ class Device(Transport):
|
||||
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}"
|
||||
@@ -252,65 +256,209 @@ class Device(Transport):
|
||||
# Raise exceptions for HTTP errors
|
||||
response.raise_for_status()
|
||||
|
||||
def get_config_dict(self, modname):
|
||||
"""Get all configuration for module @modname as dictionary"""
|
||||
ds = self.get_running(modname)
|
||||
def get_config_dict(self, xpath):
|
||||
"""Get all configuration matching @xpath as dictionary"""
|
||||
# Strip leading slash if present (for compatibility with NETCONF xpath style)
|
||||
xpath = xpath.lstrip('/')
|
||||
|
||||
# Get the whole module's configuration by requesting the module root
|
||||
# The xpath parameter might be in format "module:container" or just "module"
|
||||
if ":" in xpath:
|
||||
model, container = xpath.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 = xpath
|
||||
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 xpath:
|
||||
_, container = xpath.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):
|
||||
"""PUT full configuration of all models to running-config"""
|
||||
def put_config_dicts(self, models, retries=3):
|
||||
"""PATCH 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))
|
||||
# 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))
|
||||
|
||||
def put_config_dict(self, modname, edit):
|
||||
"""Add @edit to running config and put the whole configuration"""
|
||||
# PATCH to candidate datastore
|
||||
url = f"{self.restconf_url}/ds/ietf-datastores:candidate"
|
||||
|
||||
# This is hacky, refactor when rousette have PATCH support.
|
||||
running = self.get_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
|
||||
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, xpath, 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:
|
||||
xpath: YANG module name
|
||||
edit: Configuration dictionary
|
||||
retries: Number of retry attempts on failure (default 3)
|
||||
"""
|
||||
try:
|
||||
mod = self.lyctx.get_module(modname)
|
||||
mod = self.lyctx.get_module(xpath)
|
||||
except libyang.util.LibyangError:
|
||||
raise Exception(f"YANG model '{modname}' not found on device. "
|
||||
raise Exception(f"YANG model '{xpath}' not found on device. "
|
||||
f"Model may not be installed or enabled. "
|
||||
f"Available models can be checked with get_schema_list()") from None
|
||||
|
||||
# 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 patch_config(self, xpath, 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:
|
||||
xpath: YANG module name
|
||||
edit: Configuration dictionary
|
||||
retries: Number of retry attempts on failure (default 3)
|
||||
"""
|
||||
try:
|
||||
mod = self.lyctx.get_module(xpath)
|
||||
except libyang.util.LibyangError:
|
||||
raise Exception(f"YANG model '{xpath}' not found on device. "
|
||||
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
|
||||
# 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))
|
||||
|
||||
# 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)
|
||||
# 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
|
||||
|
||||
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)
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
|
||||
def call_dict(self, model, call):
|
||||
pass # Need implementation
|
||||
|
||||
@@ -26,6 +26,10 @@ class Transport(ABC):
|
||||
def put_config_dict(self, modname, edit):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def patch_config(self, modname, edit):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_dict(self, xpath=None):
|
||||
pass
|
||||
|
||||
+5
-3
@@ -29,9 +29,11 @@ export INFAMY_ARGS := --transport=netconf
|
||||
endif
|
||||
|
||||
test:
|
||||
$(test-dir)/env -r $(base) $(mode) $(binaries) $(pkg-$(ARCH)) $(ninepm) -v $(TESTS)
|
||||
$(ninepm_report) github $(test-dir)/.log/last/result.json
|
||||
$(ninepm_report) asciidoc $(test-dir)/.log/last/result.json
|
||||
$(test-dir)/env -r $(base) $(mode) $(binaries) $(pkg-$(ARCH)) \
|
||||
sh -c '$(ninepm) -v $(TESTS); \
|
||||
$(ninepm_report) github $(test-dir)/.log/last/result.json; \
|
||||
$(ninepm_report) asciidoc $(test-dir)/.log/last/result.json; \
|
||||
chmod -R 777 $(test-dir)/.log'
|
||||
|
||||
test-sh:
|
||||
$(test-dir)/env $(base) $(mode) $(binaries) $(pkg-$(ARCH)) -i /bin/sh
|
||||
|
||||
Reference in New Issue
Block a user