cli: new admin-exec level command 'show nacm'

Show operational details about nacm and user//group mappings.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2026-01-23 06:12:21 +01:00
parent 3c697ede67
commit b6a7d0f294
7 changed files with 180 additions and 20 deletions
+4
View File
@@ -324,6 +324,10 @@
</ACTION>
</COMMAND>
<COMMAND name="nacm" help="Show users and NACM status and groups">
<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)">
+21 -2
View File
@@ -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,
+71
View File
@@ -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":
+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):
+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