From b6a7d0f294f54bd1da04b327f05c64dc6cd6752b Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Fri, 9 Jan 2026 15:27:18 +0100 Subject: [PATCH] cli: new admin-exec level command 'show nacm' Show operational details about nacm and user//group mappings. Signed-off-by: Joachim Wiberg --- src/klish-plugin-infix/xml/infix.xml | 4 + src/show/show.py | 23 +++++- src/statd/python/cli_pretty/cli_pretty.py | 71 +++++++++++++++++ src/statd/python/yanger/ietf_system.py | 79 +++++++++++++++---- test/case/statd/system/ietf-system.json | 3 +- test/case/statd/system/operational.json | 3 +- .../statd/system/system/run/getent_passwd | 17 ++++ 7 files changed, 180 insertions(+), 20 deletions(-) create mode 100644 test/case/statd/system/system/run/getent_passwd diff --git a/src/klish-plugin-infix/xml/infix.xml b/src/klish-plugin-infix/xml/infix.xml index 75f1cc68..3daf60ed 100644 --- a/src/klish-plugin-infix/xml/infix.xml +++ b/src/klish-plugin-infix/xml/infix.xml @@ -324,6 +324,10 @@ + + show nacm + + diff --git a/src/show/show.py b/src/show/show.py index e0fd57a9..fccafd70 100755 --- a/src/show/show.py +++ b/src/show/show.py @@ -10,7 +10,7 @@ import argparse RAW_OUTPUT = False -def run_sysrepocfg(xpath: str) -> dict: +def run_sysrepocfg(xpath: str, datastore: str = "operational") -> dict: if not isinstance(xpath, str) or not xpath.startswith("/"): print("Invalid XPATH. It must be a valid string starting with '/'.") return {} @@ -19,7 +19,7 @@ def run_sysrepocfg(xpath: str) -> dict: try: result = subprocess.run([ - "sysrepocfg", "-f", "json", "-X", "-d", "operational", "-x", safe_xpath + "sysrepocfg", "-f", "json", "-X", "-d", datastore, "-x", safe_xpath ], capture_output=True, text=True, check=True) json_data = json.loads(result.stdout) return json_data @@ -596,6 +596,24 @@ def system(args: List[str]) -> None: cli_pretty(data, "show-system") +def nacm(args: List[str]) -> None: + data = run_sysrepocfg("/ietf-netconf-acm:nacm", "running") + if not data: + print("No NACM data retrieved.") + return + + # Fetch user data from operational (includes shell and authorized-key from yanger) + user_oper = get_json("/ietf-system:system/authentication") + + if user_oper: + oper_users = user_oper.get("ietf-system:system", {}).get("authentication", {}).get("user", []) + data["ietf-system:system"] = {"authentication": {"user": oper_users}} + + if RAW_OUTPUT: + print(json.dumps(data, indent=2)) + return + cli_pretty(data, "show-nacm") + def execute_command(command: str, args: List[str]): command_mapping = { 'bfd': bfd, @@ -605,6 +623,7 @@ def execute_command(command: str, args: List[str]): 'hardware': hardware, 'interface': interface, 'lldp': lldp, + 'nacm': nacm, 'ntp': ntp, 'ospf': ospf, 'rip': rip, diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index 3462f328..ac4f9e0f 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -2906,6 +2906,73 @@ def show_ntp_source(json, address=None): print(row) +def show_nacm(json): + """Display users and NACM (Network Configuration Access Control) groups""" + min_width = 62 + + nacm = json.get("ietf-netconf-acm:nacm", {}) + if not nacm: + print("NACM not configured.") + print() + + if nacm: + enabled = "yes" if nacm.get("enable-nacm", True) else "no" + print(f"{'enabled':<25}: {enabled}") + print(f"{'default read access':<25}: {nacm.get('read-default', 'permit')}") + print(f"{'default write access':<25}: {nacm.get('write-default', 'deny')}") + print(f"{'default exec access':<25}: {nacm.get('exec-default', 'permit')}") + print(f"{'denied operations':<25}: {nacm.get('denied-operations', 0)}") + print(f"{'denied data writes':<25}: {nacm.get('denied-data-writes', 0)}") + print(f"{'denied notifications':<25}: {nacm.get('denied-notifications', 0)}") + print() + + # Users table + system = json.get("ietf-system:system", {}) + users = system.get("authentication", {}).get("user", []) + if users: + user_table = SimpleTable([ + Column('USER', min_width=12), + Column('SHELL'), + Column('LOGIN', flexible=True) + ], min_width=min_width) + + for user in users: + name = user.get("name", "") + shell_data = user.get("infix-system:shell", "false") + shell = shell_data.split(":")[-1] if ":" in shell_data else shell_data + + has_password = bool(user.get("password")) + has_keys = bool(user.get("authorized-key")) + if has_password and has_keys: + login = "password+key" + elif has_password: + login = "password" + elif has_keys: + login = "key" + else: + login = "-" + + user_table.row(name, shell, login) + + user_table.print() + print() + + # Groups table + groups = nacm.get("groups", {}).get("group", []) if nacm else [] + if groups: + group_table = SimpleTable([ + Column('GROUP', min_width=12), + Column('USERS', flexible=True) + ], min_width=min_width) + + for group in groups: + name = group.get("name", "") + members = " ".join(group.get("user-name", [])) + group_table.row(name, members) + + group_table.print() + + def show_system(json): """System information overivew""" if not json.get("ietf-system:system-state"): @@ -4890,6 +4957,8 @@ def main(): subparsers.add_parser('show-firewall-log', help='Show firewall log') \ .add_argument('limit', nargs='?', help='Last N lines, default: all') + subparsers.add_parser('show-nacm', help='Show NACM status and groups') + subparsers.add_parser('show-ntp', help='Show NTP status') \ .add_argument('-a', '--address', help='Show details for specific address') subparsers.add_parser('show-ntp-tracking', help='Show NTP tracking status') @@ -4953,6 +5022,8 @@ def main(): show_firewall_service(json_data, args.name) elif args.command == "show-firewall-log": show_firewall_logs(args.limit) + elif args.command == "show-nacm": + show_nacm(json_data) elif args.command == "show-ntp": show_ntp(json_data, args.address) elif args.command == "show-ntp-tracking": diff --git a/src/statd/python/yanger/ietf_system.py b/src/statd/python/yanger/ietf_system.py index 0ca57ef3..eeca4b71 100644 --- a/src/statd/python/yanger/ietf_system.py +++ b/src/statd/python/yanger/ietf_system.py @@ -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): diff --git a/test/case/statd/system/ietf-system.json b/test/case/statd/system/ietf-system.json index beddd929..a8dde70b 100644 --- a/test/case/statd/system/ietf-system.json +++ b/test/case/statd/system/ietf-system.json @@ -5,7 +5,8 @@ "user": [ { "name": "admin", - "password": "$5$mI/zpOAqZYKLC2WU$i7iPzZiIjOjrBF3NyftS9CCq8dfYwHwrmUK097Jca9A" + "password": "$5$mI/zpOAqZYKLC2WU$i7iPzZiIjOjrBF3NyftS9CCq8dfYwHwrmUK097Jca9A", + "infix-system:shell": "infix-system:bash" } ] }, diff --git a/test/case/statd/system/operational.json b/test/case/statd/system/operational.json index 49128936..42832152 100644 --- a/test/case/statd/system/operational.json +++ b/test/case/statd/system/operational.json @@ -39,7 +39,8 @@ "user": [ { "name": "admin", - "password": "$5$mI/zpOAqZYKLC2WU$i7iPzZiIjOjrBF3NyftS9CCq8dfYwHwrmUK097Jca9A" + "password": "$5$mI/zpOAqZYKLC2WU$i7iPzZiIjOjrBF3NyftS9CCq8dfYwHwrmUK097Jca9A", + "infix-system:shell": "infix-system:bash" } ] }, diff --git a/test/case/statd/system/system/run/getent_passwd b/test/case/statd/system/system/run/getent_passwd new file mode 100644 index 00000000..46fbec07 --- /dev/null +++ b/test/case/statd/system/system/run/getent_passwd @@ -0,0 +1,17 @@ +root:x:0:0:root:/root:/bin/bash +daemon:x:1:1:daemon:/usr/sbin:/bin/false +bin:x:2:2:bin:/bin:/bin/false +sys:x:3:3:sys:/dev:/bin/false +sync:x:4:100:sync:/bin:/bin/sync +mail:x:8:8:mail:/var/spool/mail:/bin/false +www-data:x:33:33:www-data:/var/www:/bin/false +sysrepo:x:60:60:sysrepo:/var:/bin/false +backup:x:34:34:backup:/var/backups:/bin/false +nobody:x:65534:65534:nobody:/home:/bin/false +yangnobody:x:333666:333666:Unauthenticated operations via RESTCONF:/:/bin/false +avahi:x:100:101::/:/bin/false +chrony:x:101:102:Time daemon:/run/chrony:/bin/false +dbus:x:102:103:DBus messagebus user:/run/dbus:/bin/false +frr:x:103:104:FRR user priv:/var/run/frr:/bin/false +sshd:x:105:106:SSH drop priv user:/var/empty:/bin/false +admin:x:1000:1000:Linux User,,,:/home/admin:/bin/bash