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