Merge pull request #1354 from kernelkit/srperms

This commit is contained in:
Joachim Wiberg
2026-01-23 11:51:57 +01:00
committed by GitHub
81 changed files with 3393 additions and 633 deletions
@@ -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"
-58
View File
@@ -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
View File
@@ -1 +0,0 @@
/etc/alternatives/editor
+8 -8
View File
@@ -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 -2
View File
@@ -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
+1 -2
View File
@@ -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
+1 -2
View File
@@ -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
+1 -2
View File
@@ -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
+1 -2
View File
@@ -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
+1 -2
View File
@@ -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
+1 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+214
View File
@@ -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:
+6 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
-1
View File
@@ -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"
+4
View File
@@ -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
View File
@@ -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))
+2 -2
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
# Locally calculated
sha256 9d9d33b873917ca5d0bdcc47a36d2fd385971ab0c045d1472fcadf95ee5bcf5b LICENCE
sha256 d3148fd6d3fc106384c88efe5a4ce3c7b1d512bd919b864d84c13b7b9ad523f0 klish-4fd82287cfe74c6bbafea8d6c0ba8d8b4c65cdd0-git4.tar.gz
sha256 4c1b6b461a805f0cf80f797f85415e0663ee371df6495641bb494bfb18829fe8 klish-7c5f1c4045d9a868dea547bd7ef0143bff5a84d7-git4.tar.gz
+1 -1
View File
@@ -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
+7
View File
@@ -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
-6
View File
@@ -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
-19
View File
@@ -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,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,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(-)
@@ -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
+4
View File
@@ -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)
+1
View File
@@ -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
View File
@@ -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);
}
+18
View File
@@ -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"
+124 -61
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+1
View File
@@ -6,6 +6,7 @@
#include <libite/lite.h>
#define ERRMSG "Error: "
#define DBGMSG "Debug: "
#define INFMSG "Note: "
int yorn (const char *fmt, ...);
+59 -3
View File
@@ -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 -1
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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;
+118 -49
View File
@@ -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));
+38 -10
View File
@@ -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>
-21
View File
@@ -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.
-11
View File
@@ -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
+492 -1
View File
@@ -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":
+63 -16
View File
@@ -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):
+3 -1
View File
@@ -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)
+35 -4
View File
@@ -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
<<<
+2 -2
View File
@@ -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()
+2 -1
View File
@@ -5,7 +5,8 @@
"user": [
{
"name": "admin",
"password": "$5$mI/zpOAqZYKLC2WU$i7iPzZiIjOjrBF3NyftS9CCq8dfYwHwrmUK097Jca9A"
"password": "$5$mI/zpOAqZYKLC2WU$i7iPzZiIjOjrBF3NyftS9CCq8dfYwHwrmUK097Jca9A",
"infix-system:shell": "infix-system:bash"
}
]
},
+2 -1
View File
@@ -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:*:::::::
+4
View File
@@ -23,6 +23,10 @@ include::user_admin/Readme.adoc[]
<<<
include::nacm-basic/Readme.adoc[]
<<<
include::timezone/Readme.adoc[]
<<<
+3
View File
@@ -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
View File
@@ -0,0 +1 @@
test.adoc
+39
View File
@@ -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
+257
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
../../../infamy/topologies/1x1.dot
+33
View File
@@ -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&#45;&#45;target -->
<g id="edge1" class="edge">
<title>host:mgmt&#45;&#45;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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+4
View File
@@ -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
View File
@@ -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