summaryrefslogtreecommitdiff
path: root/plugins/modules/vyos_snmp_server.py
diff options
context:
space:
mode:
authoromnom62 <omnom62@outlook.com>2026-04-15 07:24:24 +1000
committeromnom62 <omnom62@outlook.com>2026-04-15 07:24:24 +1000
commitc3671c4e3e2ad7efa119982f2ee41334fcacd073 (patch)
tree0084638d1b9f71b6be671324779238439f9da5d6 /plugins/modules/vyos_snmp_server.py
parentc9e5c5f6d91bae587a6ce8b7f055b0d068129fe2 (diff)
downloadrest.vyos-c3671c4e3e2ad7efa119982f2ee41334fcacd073.tar.gz
rest.vyos-c3671c4e3e2ad7efa119982f2ee41334fcacd073.zip
Rest API module
Diffstat (limited to 'plugins/modules/vyos_snmp_server.py')
-rw-r--r--plugins/modules/vyos_snmp_server.py1098
1 files changed, 1098 insertions, 0 deletions
diff --git a/plugins/modules/vyos_snmp_server.py b/plugins/modules/vyos_snmp_server.py
new file mode 100644
index 0000000..a092432
--- /dev/null
+++ b/plugins/modules/vyos_snmp_server.py
@@ -0,0 +1,1098 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+# Copyright 2024 Red Hat
+# GNU General Public License v3.0+
+# (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import absolute_import, division, print_function
+
+
+__metaclass__ = type
+
+DOCUMENTATION = r"""
+---
+module: vyos_snmp_server
+short_description: Manage SNMP server configuration on VyOS devices using REST API
+description:
+ - Manages SNMP server configuration on VyOS devices via the REST API.
+ - Supports communities, listen addresses, contact/location/description scalar
+ fields, trap target, and SNMPv3 (engine ID, groups, users, views).
+ - Uses REST API (C(connection=httpapi)) instead of CLI.
+version_added: "1.0.0"
+author: your_name (@yourhandle)
+
+options:
+ config:
+ description: SNMP server configuration.
+ type: dict
+ suboptions:
+ communities:
+ description: SNMP community configuration.
+ type: list
+ elements: dict
+ suboptions:
+ name:
+ description: Community name.
+ type: str
+ required: true
+ clients:
+ description: IP addresses of SNMP clients allowed to contact the system.
+ type: list
+ elements: str
+ networks:
+ description: Subnets of SNMP clients allowed to contact the system.
+ type: list
+ elements: str
+ authorization_type:
+ description: Authorization type.
+ type: str
+ choices: ['ro', 'rw']
+ contact:
+ description: Contact person for the system.
+ type: str
+ description:
+ description: System description.
+ type: str
+ location:
+ description: System location.
+ type: str
+ smux_peer:
+ description: Register a subtree for SMUX-based processing.
+ type: str
+ trap_source:
+ description: SNMP trap source address.
+ type: str
+ listen_addresses:
+ description: IP addresses to listen for incoming SNMP requests.
+ type: list
+ elements: dict
+ suboptions:
+ address:
+ description: IP address.
+ type: str
+ required: true
+ port:
+ description: UDP port (default 161).
+ type: int
+ trap_target:
+ description: SNMP trap target.
+ type: dict
+ suboptions:
+ address:
+ description: IP address of trap target.
+ type: str
+ community:
+ description: Community string for traps.
+ type: str
+ port:
+ description: Destination port for trap notifications.
+ type: int
+ snmp_v3:
+ description: SNMPv3 configuration.
+ type: dict
+ suboptions:
+ engine_id:
+ description: EngineID as a hex string.
+ type: str
+ groups:
+ description: SNMPv3 groups.
+ type: list
+ elements: dict
+ suboptions:
+ group:
+ description: Group name.
+ type: str
+ required: true
+ mode:
+ description: Access mode.
+ type: str
+ choices: ['ro', 'rw']
+ seclevel:
+ description: Security level.
+ type: str
+ choices: ['auth', 'priv']
+ view:
+ description: View name for this group.
+ type: str
+ users:
+ description: SNMPv3 users.
+ type: list
+ elements: dict
+ suboptions:
+ user:
+ description: Username.
+ type: str
+ required: true
+ authentication:
+ description: Authentication configuration.
+ type: dict
+ suboptions:
+ type:
+ description: Authentication protocol.
+ type: str
+ choices: ['md5', 'sha']
+ encrypted_key:
+ description: Encrypted authentication password.
+ type: str
+ plaintext_key:
+ description: Plaintext authentication password (will be encrypted on device).
+ type: str
+ no_log: true
+ privacy:
+ description: Privacy configuration.
+ type: dict
+ suboptions:
+ type:
+ description: Privacy protocol.
+ type: str
+ choices: ['des', 'aes']
+ encrypted_key:
+ description: Encrypted privacy password.
+ type: str
+ plaintext_key:
+ description: Plaintext privacy password (will be encrypted on device).
+ type: str
+ no_log: true
+ group:
+ description: Group membership for this user.
+ type: str
+ mode:
+ description: Access mode.
+ type: str
+ choices: ['ro', 'rw']
+ tsm_key:
+ description: TSM certificate fingerprint or filename.
+ type: str
+ trap_targets:
+ description: SNMPv3 trap/inform targets.
+ type: list
+ elements: dict
+ suboptions:
+ address:
+ description: IP/IPv6 address of trap target.
+ type: str
+ port:
+ description: TCP/UDP port for traps/informs.
+ type: int
+ protocol:
+ description: Notification protocol.
+ type: str
+ choices: ['tcp', 'udp']
+ type:
+ description: Notification type.
+ type: str
+ choices: ['inform', 'trap']
+ authentication:
+ description: Authentication for this trap target.
+ type: dict
+ suboptions:
+ type:
+ description: Authentication protocol.
+ type: str
+ choices: ['md5', 'sha']
+ encrypted_key:
+ description: Encrypted authentication password.
+ type: str
+ plaintext_key:
+ description: Plaintext authentication password (will be encrypted on device).
+ type: str
+ no_log: true
+ privacy:
+ description: Privacy for this trap target.
+ type: dict
+ suboptions:
+ type:
+ description: Privacy protocol.
+ type: str
+ choices: ['des', 'aes']
+ encrypted_key:
+ description: Encrypted privacy password.
+ type: str
+ plaintext_key:
+ description: Plaintext privacy password (will be encrypted on device).
+ type: str
+ no_log: true
+ views:
+ description: SNMPv3 views.
+ type: list
+ elements: dict
+ suboptions:
+ view:
+ description: View name.
+ type: str
+ required: true
+ oid:
+ description: OID for this view.
+ type: str
+ exclude:
+ description: Excluded OID subtree.
+ type: str
+ mask:
+ description: Bit-mask for OID subidentifiers.
+ type: str
+
+ state:
+ description:
+ - Desired state of SNMP configuration.
+ - C(merged) adds or updates provided config without removing existing entries.
+ - C(replaced) and C(overridden) perform a full replacement — entries not in
+ the task config are removed.
+ - C(deleted) removes all SNMP configuration with a single delete command.
+ - C(gathered) returns current device config as structured data, no changes made.
+ type: str
+ default: merged
+ choices:
+ - merged
+ - replaced
+ - overridden
+ - deleted
+ - gathered
+
+notes:
+ - Tested against VyOS 1.5q2.
+"""
+
+EXAMPLES = r"""
+- name: Merge SNMP configuration
+ vyos.rest.vyos_snmp_server:
+ config:
+ communities:
+ - name: switches
+ authorization_type: rw
+ - name: bridges
+ clients:
+ - 1.1.1.1
+ - 12.1.1.10
+ contact: admin2@ex.com
+ listen_addresses:
+ - address: 20.1.1.1
+ - address: 100.1.2.1
+ port: 33
+ snmp_v3:
+ users:
+ - user: admin_user
+ authentication:
+ plaintext_key: abc1234567
+ type: sha
+ privacy:
+ plaintext_key: abc1234567
+ type: aes
+ state: merged
+
+# ------------------------------------------------------------------------
+
+- name: Replace SNMP configuration
+ vyos.rest.vyos_snmp_server:
+ config:
+ communities:
+ - name: bridges
+ networks:
+ - 1.1.1.0/24
+ - 12.1.1.0/24
+ location: "RDU, NC"
+ listen_addresses:
+ - address: 100.1.2.1
+ port: 33
+ snmp_v3:
+ groups:
+ - group: default
+ view: default
+ users:
+ - user: admin_user
+ authentication:
+ encrypted_key: 33f8bfd6b69ee03a184818a4daea503c9e579633
+ type: sha
+ privacy:
+ encrypted_key: 33f8bfd6b69ee03a184818a4daea503c9e579633
+ type: aes
+ group: default
+ views:
+ - view: default
+ oid: "1"
+ state: replaced
+
+# ------------------------------------------------------------------------
+
+- name: Delete all SNMP configuration
+ vyos.rest.vyos_snmp_server:
+ state: deleted
+
+# ------------------------------------------------------------------------
+
+- name: Gather current SNMP configuration
+ vyos.rest.vyos_snmp_server:
+ state: gathered
+"""
+
+RETURN = r"""
+before:
+ description: SNMP configuration before this module ran.
+ returned: when I(state) is C(merged), C(replaced), C(overridden) or C(deleted)
+ type: dict
+
+after:
+ description: SNMP configuration after this module ran.
+ returned: when changed
+ type: dict
+
+commands:
+ description: List of API command dicts sent to the device.
+ returned: always
+ type: list
+
+gathered:
+ description: Current SNMP configuration as structured data.
+ returned: when I(state) is C(gathered)
+ type: dict
+
+response:
+ description: Raw API response.
+ returned: when changes are applied
+ type: dict
+
+saved:
+ description: Result of save_config after applying changes.
+ returned: when changes are applied
+ type: dict
+"""
+
+from ansible.module_utils.basic import AnsibleModule
+from ansible_collections.vyos.rest.plugins.module_utils.vyos import VyOSModule
+
+
+# ------------------------------------------------------------
+# Constants
+# ------------------------------------------------------------
+
+SNMP_BASE = ["service", "snmp"]
+
+# Scalar fields that map directly: argspec_key → api_key
+SCALAR_FIELDS = {
+ "contact": "contact",
+ "description": "description",
+ "location": "location",
+ "smux_peer": "smux-peer",
+ "trap_source": "trap-source",
+}
+
+
+# ------------------------------------------------------------
+# Generic helpers
+# ------------------------------------------------------------
+
+
+def to_list(value):
+ """Coerce None / str / list → list."""
+ if value is None:
+ return []
+ if isinstance(value, list):
+ return value
+ if isinstance(value, str):
+ return [value]
+ if isinstance(value, dict):
+ return list(value.keys())
+ return [str(value)]
+
+
+# ------------------------------------------------------------
+# Parsing: API response → Ansible argspec dict
+# ------------------------------------------------------------
+
+
+def _parse_communities(raw):
+ """
+ API: {"bridges": {"client": [...], "network": [...]}, "switches": {"authorization": "rw"}}
+ → [{"name": "bridges", "clients": [...], ...}, ...]
+ """
+ if not raw or not isinstance(raw, dict):
+ return []
+ result = []
+ for name, data in sorted(raw.items()):
+ entry = {"name": name}
+ if not isinstance(data, dict):
+ result.append(entry)
+ continue
+ if "authorization" in data:
+ entry["authorization_type"] = data["authorization"]
+ if "client" in data:
+ entry["clients"] = sorted(to_list(data["client"]))
+ if "network" in data:
+ entry["networks"] = sorted(to_list(data["network"]))
+ result.append(entry)
+ return result
+
+
+def _parse_listen_addresses(raw):
+ """
+ API: {"198.51.100.10": {"port": "33"}, "203.0.113.65": {}}
+ → [{"address": "198.51.100.10", "port": 33}, {"address": "203.0.113.65"}]
+ """
+ if not raw or not isinstance(raw, dict):
+ return []
+ result = []
+ for addr, data in sorted(raw.items()):
+ entry = {"address": addr}
+ if isinstance(data, dict) and "port" in data:
+ entry["port"] = int(data["port"])
+ result.append(entry)
+ return result
+
+
+def _parse_trap_target(raw):
+ """
+ API: {"address": "x.x.x.x"} or plain string address, optional port/community.
+ """
+ if not raw:
+ return None
+ if isinstance(raw, str):
+ return {"address": raw}
+ entry = {}
+ if "address" in raw:
+ entry["address"] = raw["address"]
+ if "community" in raw:
+ entry["community"] = raw["community"]
+ if "port" in raw:
+ entry["port"] = int(raw["port"])
+ return entry if entry else None
+
+
+def _parse_v3_auth_privacy(raw, key):
+ """
+ Parse auth or privacy block.
+ API key is 'auth', argspec key is 'authentication'.
+ API key is 'privacy', argspec key is 'privacy'.
+ Returns dict with type and encrypted_key (never plaintext — device never returns it).
+ """
+ block = raw.get(key) if isinstance(raw, dict) else None
+ if not block:
+ return None
+ result = {}
+ if "type" in block:
+ result["type"] = block["type"]
+ # API uses 'encrypted-password'; map to argspec 'encrypted_key'
+ if "encrypted-password" in block:
+ result["encrypted_key"] = block["encrypted-password"]
+ if "plaintext-key" in block:
+ result["plaintext_key"] = block["plaintext-key"]
+ return result if result else None
+
+
+def _parse_v3_users(raw):
+ """
+ API: {"adminuser": {"auth": {...}, "group": "testgroup", "privacy": {...}}}
+ → [{"user": "adminuser", "authentication": {...}, "group": "testgroup", "privacy": {...}}]
+ """
+ if not raw or not isinstance(raw, dict):
+ return []
+ result = []
+ for username, data in sorted(raw.items()):
+ entry = {"user": username}
+ auth = _parse_v3_auth_privacy(data, "auth")
+ if auth:
+ entry["authentication"] = auth
+ priv = _parse_v3_auth_privacy(data, "privacy")
+ if priv:
+ entry["privacy"] = priv
+ if isinstance(data, dict):
+ if "group" in data:
+ entry["group"] = data["group"]
+ if "mode" in data:
+ entry["mode"] = data["mode"]
+ if "tsm-key" in data:
+ entry["tsm_key"] = data["tsm-key"]
+ result.append(entry)
+ return result
+
+
+def _parse_v3_groups(raw):
+ """
+ API: {"testgroup": {"mode": "ro", "view": "default"}}
+ → [{"group": "testgroup", "mode": "ro", "view": "default"}]
+ """
+ if not raw or not isinstance(raw, dict):
+ return []
+ result = []
+ for name, data in sorted(raw.items()):
+ entry = {"group": name}
+ if isinstance(data, dict):
+ for key in ("mode", "seclevel", "view"):
+ if key in data:
+ entry[key] = data[key]
+ result.append(entry)
+ return result
+
+
+def _parse_v3_views(raw):
+ """
+ API: {"default": {"oid": {"1": {}}}}
+ → [{"view": "default", "oid": "1"}]
+
+ OID is stored as a dict key with an empty dict value.
+ """
+ if not raw or not isinstance(raw, dict):
+ return []
+ result = []
+ for name, data in sorted(raw.items()):
+ entry = {"view": name}
+ if isinstance(data, dict) and "oid" in data:
+ oid_data = data["oid"]
+ if isinstance(oid_data, dict) and oid_data:
+ # Extract the first (and usually only) OID key
+ entry["oid"] = str(list(oid_data.keys())[0])
+ elif isinstance(oid_data, str):
+ entry["oid"] = oid_data
+ if isinstance(data, dict):
+ if "exclude" in data:
+ entry["exclude"] = data["exclude"]
+ if "mask" in data:
+ entry["mask"] = data["mask"]
+ result.append(entry)
+ return result
+
+
+def _parse_v3_trap_targets(raw):
+ """
+ API: {"x.x.x.x": {"auth": {...}, "privacy": {...}, "port": "162", ...}}
+ → [{"address": "x.x.x.x", ...}]
+ """
+ if not raw or not isinstance(raw, dict):
+ return []
+ result = []
+ for addr, data in sorted(raw.items()):
+ entry = {"address": addr}
+ if isinstance(data, dict):
+ if "port" in data:
+ entry["port"] = int(data["port"])
+ if "protocol" in data:
+ entry["protocol"] = data["protocol"]
+ if "type" in data:
+ entry["type"] = data["type"]
+ auth = _parse_v3_auth_privacy(data, "auth")
+ if auth:
+ entry["authentication"] = auth
+ priv = _parse_v3_auth_privacy(data, "privacy")
+ if priv:
+ entry["privacy"] = priv
+ result.append(entry)
+ return result
+
+
+def parse_snmp_config(raw):
+ """
+ Convert full API SNMP response dict → Ansible argspec dict.
+ """
+ if not raw or not isinstance(raw, dict):
+ return {}
+
+ result = {}
+
+ # Scalar fields
+ for argspec_key, api_key in SCALAR_FIELDS.items():
+ if api_key in raw:
+ result[argspec_key] = raw[api_key]
+
+ # Communities
+ communities = _parse_communities(raw.get("community"))
+ if communities:
+ result["communities"] = communities
+
+ # Listen addresses
+ listen = _parse_listen_addresses(raw.get("listen-address"))
+ if listen:
+ result["listen_addresses"] = listen
+
+ # Trap target
+ trap = _parse_trap_target(raw.get("trap-target"))
+ if trap:
+ result["trap_target"] = trap
+
+ # SNMPv3
+ v3_raw = raw.get("v3")
+ if v3_raw and isinstance(v3_raw, dict):
+ v3 = {}
+ if "engineid" in v3_raw:
+ v3["engine_id"] = v3_raw["engineid"]
+ groups = _parse_v3_groups(v3_raw.get("group"))
+ if groups:
+ v3["groups"] = groups
+ users = _parse_v3_users(v3_raw.get("user"))
+ if users:
+ v3["users"] = users
+ views = _parse_v3_views(v3_raw.get("view"))
+ if views:
+ v3["views"] = views
+ trap_targets = _parse_v3_trap_targets(v3_raw.get("trap-target"))
+ if trap_targets:
+ v3["trap_targets"] = trap_targets
+ if v3:
+ result["snmp_v3"] = v3
+
+ return result
+
+
+def get_running_config(vyos):
+ """Fetch and parse current SNMP config from device."""
+ try:
+ raw = vyos.get_config(SNMP_BASE)
+ except Exception as e:
+ if "Configuration under specified path is empty" in str(e):
+ return {}
+ raise
+ return parse_snmp_config(raw)
+
+
+# ------------------------------------------------------------
+# Command builders: Ansible argspec dict → API command list
+# ------------------------------------------------------------
+
+
+def _cmd(op, path):
+ return {"op": op, "path": path}
+
+
+def _set(path):
+ return _cmd("set", path)
+
+
+def _delete(path):
+ return _cmd("delete", path)
+
+
+def _build_scalar_commands(want, have, state):
+ """Build commands for simple string scalar fields."""
+ cmds = []
+ for argspec_key, api_key in SCALAR_FIELDS.items():
+ want_val = want.get(argspec_key)
+ have_val = have.get(argspec_key)
+ path = SNMP_BASE + [api_key]
+ if state in ("merged", "replaced", "overridden"):
+ if want_val and want_val != have_val:
+ cmds.append(_set(path + [want_val]))
+ if state in ("replaced", "overridden"):
+ if have_val and want_val != have_val:
+ cmds.append(_delete(path))
+ return cmds
+
+
+def _build_community_commands(want_list, have_list, state):
+ """Build commands for SNMP communities."""
+ cmds = []
+ want_map = {c["name"]: c for c in (want_list or [])}
+ have_map = {c["name"]: c for c in (have_list or [])}
+
+ if state in ("replaced", "overridden"):
+ # Delete communities not in want
+ for name in have_map:
+ if name not in want_map:
+ cmds.append(_delete(SNMP_BASE + ["community", name]))
+
+ for name, want_comm in want_map.items():
+ have_comm = have_map.get(name, {})
+ base = SNMP_BASE + ["community", name]
+
+ if state in ("merged", "replaced", "overridden"):
+ # authorization_type
+ want_auth = want_comm.get("authorization_type")
+ have_auth = have_comm.get("authorization_type")
+ if want_auth and want_auth != have_auth:
+ cmds.append(_set(base + ["authorization", want_auth]))
+ if state in ("replaced", "overridden") and have_auth and want_auth != have_auth:
+ cmds.append(_delete(base + ["authorization"]))
+
+ # clients
+ want_clients = set(want_comm.get("clients") or [])
+ have_clients = set(have_comm.get("clients") or [])
+ for c in want_clients - have_clients:
+ cmds.append(_set(base + ["client", c]))
+ if state in ("replaced", "overridden"):
+ for c in have_clients - want_clients:
+ cmds.append(_delete(base + ["client", c]))
+
+ # networks
+ want_nets = set(want_comm.get("networks") or [])
+ have_nets = set(have_comm.get("networks") or [])
+ for n in want_nets - have_nets:
+ cmds.append(_set(base + ["network", n]))
+ if state in ("replaced", "overridden"):
+ for n in have_nets - want_nets:
+ cmds.append(_delete(base + ["network", n]))
+
+ return cmds
+
+
+def _build_listen_address_commands(want_list, have_list, state):
+ """Build commands for listen-address entries."""
+ cmds = []
+ want_map = {e["address"]: e for e in (want_list or [])}
+ have_map = {e["address"]: e for e in (have_list or [])}
+ base = SNMP_BASE + ["listen-address"]
+
+ if state in ("replaced", "overridden"):
+ for addr in have_map:
+ if addr not in want_map:
+ cmds.append(_delete(base + [addr]))
+
+ for addr, want_entry in want_map.items():
+ have_entry = have_map.get(addr, {})
+ want_port = want_entry.get("port")
+ have_port = have_entry.get("port")
+
+ if addr not in have_map:
+ # New address
+ if want_port:
+ cmds.append(_set(base + [addr, "port", str(want_port)]))
+ else:
+ cmds.append(_set(base + [addr]))
+ elif want_port != have_port:
+ # Address exists but port changed — delete and re-set
+ cmds.append(_delete(base + [addr]))
+ if want_port:
+ cmds.append(_set(base + [addr, "port", str(want_port)]))
+ else:
+ cmds.append(_set(base + [addr]))
+
+ return cmds
+
+
+def _build_trap_target_commands(want, have, state):
+ """Build commands for the v2 trap-target scalar dict."""
+ cmds = []
+ base = SNMP_BASE + ["trap-target"]
+
+ if state in ("merged", "replaced", "overridden"):
+ if want:
+ want_addr = want.get("address")
+ have_addr = have.get("address") if have else None
+ if want_addr and want_addr != have_addr:
+ cmds.append(_set(base + [want_addr]))
+ if want.get("community"):
+ cmds.append(_set(base + [want_addr, "community", want["community"]]))
+ if want.get("port"):
+ cmds.append(_set(base + [want_addr, "port", str(want["port"])]))
+
+ if state in ("replaced", "overridden"):
+ if have and (not want or have.get("address") != (want or {}).get("address")):
+ cmds.append(_delete(base))
+
+ return cmds
+
+
+def _build_v3_auth_privacy_commands(base, want_block, have_block, api_key):
+ """
+ Build set commands for a v3 auth or privacy block.
+ api_key: 'auth' or 'privacy'
+ """
+ cmds = []
+ if not want_block:
+ return cmds
+ block_base = base + [api_key]
+ have_block = have_block or {}
+
+ if want_block.get("type") and want_block["type"] != have_block.get("type"):
+ cmds.append(_set(block_base + ["type", want_block["type"]]))
+ if want_block.get("encrypted_key") and want_block["encrypted_key"] != have_block.get(
+ "encrypted_key",
+ ):
+ cmds.append(_set(block_base + ["encrypted-password", want_block["encrypted_key"]]))
+ if want_block.get("plaintext_key"):
+ # Always set plaintext — we cannot compare it to the stored encrypted value
+ cmds.append(_set(block_base + ["plaintext-key", want_block["plaintext_key"]]))
+
+ return cmds
+
+
+def _build_v3_user_commands(want_list, have_list, state):
+ """Build commands for SNMPv3 users."""
+ cmds = []
+ want_map = {u["user"]: u for u in (want_list or [])}
+ have_map = {u["user"]: u for u in (have_list or [])}
+ base = SNMP_BASE + ["v3", "user"]
+
+ if state in ("replaced", "overridden"):
+ for username in have_map:
+ if username not in want_map:
+ cmds.append(_delete(base + [username]))
+
+ for username, want_user in want_map.items():
+ have_user = have_map.get(username, {})
+ user_base = base + [username]
+
+ cmds += _build_v3_auth_privacy_commands(
+ user_base,
+ want_user.get("authentication"),
+ have_user.get("authentication"),
+ "auth",
+ )
+ cmds += _build_v3_auth_privacy_commands(
+ user_base,
+ want_user.get("privacy"),
+ have_user.get("privacy"),
+ "privacy",
+ )
+
+ if want_user.get("group") and want_user["group"] != have_user.get("group"):
+ cmds.append(_set(user_base + ["group", want_user["group"]]))
+ if want_user.get("mode") and want_user["mode"] != have_user.get("mode"):
+ cmds.append(_set(user_base + ["mode", want_user["mode"]]))
+ if want_user.get("tsm_key") and want_user["tsm_key"] != have_user.get("tsm_key"):
+ cmds.append(_set(user_base + ["tsm-key", want_user["tsm_key"]]))
+
+ return cmds
+
+
+def _build_v3_group_commands(want_list, have_list, state):
+ """Build commands for SNMPv3 groups."""
+ cmds = []
+ want_map = {g["group"]: g for g in (want_list or [])}
+ have_map = {g["group"]: g for g in (have_list or [])}
+ base = SNMP_BASE + ["v3", "group"]
+
+ if state in ("replaced", "overridden"):
+ for name in have_map:
+ if name not in want_map:
+ cmds.append(_delete(base + [name]))
+
+ for name, want_group in want_map.items():
+ have_group = have_map.get(name, {})
+ group_base = base + [name]
+
+ for key, api_key in [("mode", "mode"), ("seclevel", "seclevel"), ("view", "view")]:
+ want_val = want_group.get(key)
+ have_val = have_group.get(key)
+ if want_val and want_val != have_val:
+ cmds.append(_set(group_base + [api_key, want_val]))
+ if state in ("replaced", "overridden") and have_val and want_val != have_val:
+ cmds.append(_delete(group_base + [api_key]))
+
+ return cmds
+
+
+def _build_v3_view_commands(want_list, have_list, state):
+ """Build commands for SNMPv3 views."""
+ cmds = []
+ want_map = {v["view"]: v for v in (want_list or [])}
+ have_map = {v["view"]: v for v in (have_list or [])}
+ base = SNMP_BASE + ["v3", "view"]
+
+ if state in ("replaced", "overridden"):
+ for name in have_map:
+ if name not in want_map:
+ cmds.append(_delete(base + [name]))
+
+ for name, want_view in want_map.items():
+ have_view = have_map.get(name, {})
+ view_base = base + [name]
+
+ want_oid = str(want_view["oid"]) if want_view.get("oid") else None
+ have_oid = str(have_view.get("oid")) if have_view.get("oid") else None
+
+ if want_oid and want_oid != have_oid:
+ cmds.append(_set(view_base + ["oid", want_oid]))
+ if state in ("replaced", "overridden") and have_oid and want_oid != have_oid:
+ cmds.append(_delete(view_base + ["oid", have_oid]))
+
+ for key in ("exclude", "mask"):
+ want_val = want_view.get(key)
+ have_val = have_view.get(key)
+ if want_val and want_val != have_val:
+ cmds.append(_set(view_base + [key, want_val]))
+
+ return cmds
+
+
+def _build_v3_commands(want_v3, have_v3, state):
+ """Build all SNMPv3 commands."""
+ cmds = []
+ want_v3 = want_v3 or {}
+ have_v3 = have_v3 or {}
+
+ # engine_id (maps to API key 'engineid')
+ want_eid = want_v3.get("engine_id")
+ have_eid = have_v3.get("engine_id")
+ if want_eid and want_eid != have_eid:
+ cmds.append(_set(SNMP_BASE + ["v3", "engineid", want_eid]))
+ if state in ("replaced", "overridden") and have_eid and want_eid != have_eid:
+ cmds.append(_delete(SNMP_BASE + ["v3", "engineid"]))
+
+ cmds += _build_v3_group_commands(want_v3.get("groups"), have_v3.get("groups"), state)
+ cmds += _build_v3_user_commands(want_v3.get("users"), have_v3.get("users"), state)
+ cmds += _build_v3_view_commands(want_v3.get("views"), have_v3.get("views"), state)
+
+ return cmds
+
+
+def build_commands(want, have, state):
+ """
+ Build the full list of API command dicts to move from have → want.
+ For deleted state, a single delete of the entire SNMP subtree is issued.
+ """
+ if state == "deleted":
+ if have:
+ return [_delete(SNMP_BASE)]
+ return []
+
+ cmds = []
+ cmds += _build_scalar_commands(want, have, state)
+ cmds += _build_community_commands(want.get("communities"), have.get("communities"), state)
+ cmds += _build_listen_address_commands(
+ want.get("listen_addresses"),
+ have.get("listen_addresses"),
+ state,
+ )
+ cmds += _build_trap_target_commands(want.get("trap_target"), have.get("trap_target"), state)
+ cmds += _build_v3_commands(want.get("snmp_v3"), have.get("snmp_v3"), state)
+ return cmds
+
+
+# ------------------------------------------------------------
+# Argument spec
+# ------------------------------------------------------------
+
+
+def _auth_privacy_spec():
+ return dict(
+ type=dict(type="str"),
+ encrypted_key=dict(type="str", no_log=False),
+ plaintext_key=dict(type="str", no_log=True),
+ )
+
+
+ARGUMENT_SPEC = dict(
+ config=dict(
+ type="dict",
+ options=dict(
+ communities=dict(
+ type="list",
+ elements="dict",
+ options=dict(
+ name=dict(type="str", required=True),
+ clients=dict(type="list", elements="str"),
+ networks=dict(type="list", elements="str"),
+ authorization_type=dict(type="str", choices=["ro", "rw"]),
+ ),
+ ),
+ contact=dict(type="str"),
+ description=dict(type="str"),
+ location=dict(type="str"),
+ smux_peer=dict(type="str"),
+ trap_source=dict(type="str"),
+ listen_addresses=dict(
+ type="list",
+ elements="dict",
+ options=dict(
+ address=dict(type="str", required=True),
+ port=dict(type="int"),
+ ),
+ ),
+ trap_target=dict(
+ type="dict",
+ options=dict(
+ address=dict(type="str"),
+ community=dict(type="str"),
+ port=dict(type="int"),
+ ),
+ ),
+ snmp_v3=dict(
+ type="dict",
+ options=dict(
+ engine_id=dict(type="str"),
+ groups=dict(
+ type="list",
+ elements="dict",
+ options=dict(
+ group=dict(type="str", required=True),
+ mode=dict(type="str", choices=["ro", "rw"]),
+ seclevel=dict(type="str", choices=["auth", "priv"]),
+ view=dict(type="str"),
+ ),
+ ),
+ users=dict(
+ type="list",
+ elements="dict",
+ options=dict(
+ user=dict(type="str", required=True),
+ authentication=dict(type="dict", options=_auth_privacy_spec()),
+ privacy=dict(type="dict", options=_auth_privacy_spec()),
+ group=dict(type="str"),
+ mode=dict(type="str", choices=["ro", "rw"]),
+ tsm_key=dict(type="str"),
+ ),
+ ),
+ trap_targets=dict(
+ type="list",
+ elements="dict",
+ options=dict(
+ address=dict(type="str"),
+ port=dict(type="int"),
+ protocol=dict(type="str", choices=["tcp", "udp"]),
+ type=dict(type="str", choices=["inform", "trap"]),
+ authentication=dict(type="dict", options=_auth_privacy_spec()),
+ privacy=dict(type="dict", options=_auth_privacy_spec()),
+ ),
+ ),
+ views=dict(
+ type="list",
+ elements="dict",
+ options=dict(
+ view=dict(type="str", required=True),
+ oid=dict(type="str"),
+ exclude=dict(type="str"),
+ mask=dict(type="str"),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ state=dict(
+ type="str",
+ default="merged",
+ choices=["merged", "replaced", "overridden", "deleted", "gathered"],
+ ),
+)
+
+
+# ------------------------------------------------------------
+# Main
+# ------------------------------------------------------------
+
+
+def main():
+ module = AnsibleModule(
+ argument_spec=ARGUMENT_SPEC,
+ supports_check_mode=True,
+ )
+
+ vyos = VyOSModule(module)
+ state = module.params["state"]
+ config = module.params.get("config") or {}
+
+ have = get_running_config(vyos)
+
+ if state == "gathered":
+ module.exit_json(changed=False, gathered=have)
+
+ want = config # already in argspec shape from AnsibleModule
+
+ commands = build_commands(want, have, state)
+
+ if module.check_mode:
+ module.exit_json(changed=bool(commands), commands=commands, before=have)
+
+ if commands:
+ response = vyos.apply_commands(commands)
+ saved = vyos.save_config()
+ module.exit_json(
+ changed=True,
+ before=have,
+ after=want,
+ commands=commands,
+ saved=saved,
+ response=response,
+ )
+
+ module.exit_json(changed=False, before=have, after=have, commands=[])
+
+
+if __name__ == "__main__":
+ main()