diff options
| author | omnom62 <omnom62@outlook.com> | 2026-05-06 08:08:27 +1000 |
|---|---|---|
| committer | omnom62 <omnom62@outlook.com> | 2026-05-06 08:08:27 +1000 |
| commit | 9a3fa5777e7203d46faf1185cfb56c4fc121d885 (patch) | |
| tree | 80988400ba3582cfc97ff6445fb8f97c724d3fc6 /plugins/modules | |
| parent | c2493986714604aa992bea28628b74587b3b51bd (diff) | |
| download | rest.vyos-9a3fa5777e7203d46faf1185cfb56c4fc121d885.tar.gz rest.vyos-9a3fa5777e7203d46faf1185cfb56c4fc121d885.zip | |
More modules
Diffstat (limited to 'plugins/modules')
33 files changed, 8027 insertions, 1130 deletions
diff --git a/plugins/modules/vyos_banner.py b/plugins/modules/vyos_banner.py index 4824629..b9df27d 100644 --- a/plugins/modules/vyos_banner.py +++ b/plugins/modules/vyos_banner.py @@ -1,33 +1,35 @@ #!/usr/bin/python +# -*- coding: utf-8 -*- +# GNU General Public License v3.0+ + +from __future__ import absolute_import, division, print_function -from ansible.module_utils.basic import AnsibleModule -from ansible_collections.vyos.rest.plugins.module_utils.vyos import VyOSModule +__metaclass__ = type DOCUMENTATION = r""" --- module: vyos_banner -short_description: Manage login banners on VyOS devices using REST API +short_description: Manage multiline banners on VyOS devices via REST API. description: - - Configure pre-login and post-login banners on VyOS devices via REST API. - - Supports idempotent configuration using structured data. - - Multiline banner text is supported via YAML block scalars. - - Uses REST API (C(connection=httpapi)) instead of CLI. - - VyOS stores multiline banners as a single string with literal C(\\n) separators. + - Manages pre-login and post-login banners on VyOS devices using the HTTPS + REST API. + - Works with C(ansible_connection=ansible.netcommon.httpapi) (recommended) + or with direct C(hostname)/C(api_key) task parameters. + - VyOS stores banner newlines as literal C(\n) in its config; this module + handles that conversion automatically. version_added: "1.0.0" author: - - Your Name (@yourhandle) - + - VyOS Community (@vyos) options: config: description: - Banner configuration. type: dict - required: true suboptions: banner: description: - - Banner type to configure. + - Which banner to configure. type: str required: true choices: @@ -35,53 +37,64 @@ options: - post-login text: description: - - Banner text. Supports multiline strings via YAML block scalar (|). - - Internally, real newlines are converted to literal C(\\n) as VyOS expects. + - The banner text. Required when I(state=merged) or I(state=replaced). + - Use a YAML block scalar (C(|)) for multi-line banners. + - Real newline characters are converted to the C(\n) escape sequence + that VyOS stores internally; this is transparent to the user. type: str - state: description: - - Desired state of the configuration. + - Desired state of the banner configuration. + - C(merged) - set the banner if it differs from the current value. + - C(replaced) - replace the banner text unconditionally. + - C(deleted) - remove the banner. + - C(gathered) - return the current banner in I(gathered) without + making any changes. type: str default: merged choices: - merged - replaced - - overridden - deleted - gathered - -notes: - - This module requires C(ansible_connection=httpapi). - - Banner text comparison is whitespace-normalized for idempotency. - - VyOS stores banner text as a leaf string value with literal C(\\n) for newlines. - - For C(merged), C(replaced), and C(overridden) states, the behaviour is identical - for this single-leaf resource — the banner value is set to the desired text. + hostname: + description: Device IP or FQDN (not needed with httpapi inventory). + type: str + port: + description: HTTPS port (local mode only). + type: int + default: 443 + api_key: + description: REST API key (not needed when ansible_httpapi_api_key is set). + type: str + no_log: true + timeout: + description: Request timeout in seconds. + type: int + default: 30 + verify_ssl: + description: Validate the device TLS certificate. + type: bool + default: false +seealso: + - module: vyos.vyos.vyos_banner """ EXAMPLES = r""" -- name: Configure single-line pre-login banner +- name: Set pre-login banner (merged — only changes if different) vyos.rest.vyos_banner: config: banner: pre-login - text: "Unauthorized access is prohibited" - state: merged - -- name: Configure multiline post-login banner - vyos.rest.vyos_banner: - config: - banner: post-login text: | - Welcome to VyOS - Authorized users only - Disconnect if you are not authorized + Junk pre-login banner + over multiple lines state: merged -- name: Replace post-login banner +- name: Replace post-login banner unconditionally vyos.rest.vyos_banner: config: banner: post-login - text: "Welcome to VyOS" + text: "Welcome. Authorised access only." state: replaced - name: Remove pre-login banner @@ -90,236 +103,84 @@ EXAMPLES = r""" banner: pre-login state: deleted -- name: Gather banner configuration +- name: Read current pre-login banner without changing it vyos.rest.vyos_banner: config: banner: pre-login state: gathered + register: result + +- name: Print gathered banner + ansible.builtin.debug: + msg: "Current banner: {{ result.gathered.text }}" """ RETURN = r""" before: - description: Configuration before changes (text uses real newlines). + description: Banner configuration before the module ran. returned: always type: dict sample: banner: pre-login - text: "Old banner text" - + text: "Old banner text\nline two\n" after: - description: Configuration after changes (text uses real newlines). + description: Banner configuration after the module ran. returned: when changed type: dict - sample: - banner: pre-login - text: "New banner text" - -commands: - description: List of API command dicts sent to the device. - returned: when changes are required - type: list - sample: - - op: set - path: ["system", "login", "banner", "pre-login"] - value: "New banner text" - gathered: - description: Current device configuration (text uses real newlines). + description: Current banner configuration read from the device (state=gathered). returned: when state is gathered type: dict sample: banner: pre-login - text: "Current banner text" - -response: - description: Raw response from VyOS REST API. - returned: when changes are applied - type: dict - -saved: - description: Result of save_config call after applying changes. - returned: when changes are applied - type: dict + text: "Current banner\nline two\n" +commands: + description: Configuration commands issued. + returned: always + type: list """ +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import ( + VYOS_REST_CONNECTION_ARGSPEC, + VyOSRestClient, + VyOSRestError, +) -# ------------------------------------------------------------ -# Text conversion helpers -# -# Internal canonical form: real Python newlines (\n), stripped. -# -# VyOS wire format: literal backslash-n (\\n) sequences within -# a single quoted string, e.g. 'line one\nline two\nline three'. -# -# Rule: convert FROM wire format on read, TO wire format on write, -# always compare in canonical (internal) form. -# ------------------------------------------------------------ - - -def normalize_text(text): - """ - Normalize text to canonical internal form. - - - Converts to str if needed - - Strips each line - - Removes leading/trailing blank lines - - Joins with real newlines - - Returns None if result is empty - """ - if text is None: - return None - lines = [line.strip() for line in text.strip().splitlines()] - # Trim leading blank lines - while lines and not lines[0]: - lines.pop(0) - # Trim trailing blank lines - while lines and not lines[-1]: - lines.pop() - return "\n".join(lines) if lines else None - - -def text_from_api_value(raw): - """ - Convert VyOS API/device value to canonical internal form. - - VyOS returns multiline banners with literal \\n sequences: - 'line one\\nline two\\nline three' - - Convert those to real newlines first, then normalize. - """ - if raw is None: - return None - return normalize_text(raw.replace("\\n", "\n")) - - -def text_to_api_value(text): - """ - Convert canonical internal form to VyOS wire format. - - Real newlines become literal \\n sequences as VyOS expects. - Returns None if input normalizes to None. - """ - normalized = normalize_text(text) - if normalized is None: - return None - return normalized.replace("\n", "\\n") - - -# ------------------------------------------------------------ -# Device interaction -# ------------------------------------------------------------ - - -def get_running_config(vyos, banner): - """ - Fetch current banner configuration from the device. - - The VyOS showConfig API returns banner text as a plain string value: - {"pre-login": "test1"} - {"post-login": "line one\\nline two\\nline three"} - - Returns: - {"banner": <str>, "text": <normalized str or None>} - """ - try: - raw = vyos.get_config(["system", "login", "banner"]) - except Exception as e: - if "Configuration under specified path is empty" in str(e): - return {"banner": banner, "text": None} - raise - - if not raw or not isinstance(raw, dict): - return {"banner": banner, "text": None} - - val = raw.get(banner) - - if val is None: - return {"banner": banner, "text": None} - - if isinstance(val, str): - # Convert from wire format (literal \n) to internal form (real newlines) - return {"banner": banner, "text": text_from_api_value(val)} - - # Unexpected type — surface clearly rather than silently swallowing - raise ValueError( - "Unexpected banner value type '{t}' returned by API (value={v!r}). " - "Expected a plain string. Please file a bug.".format( - t=type(val).__name__, - v=val, - ), - ) - - -def build_commands(want, have, state): - """ - Build list of VyOS REST API command dicts. - - VyOS banner API payload structure: - SET: {"op": "set", "path": ["system","login","banner","<type>"], "value": "<text>"} - DELETE: {"op": "delete", "path": ["system","login","banner","<type>"]} - - The banner text is always a leaf VALUE on the path, never a path segment. - - All text comparison is done in canonical internal form (real newlines). - Conversion to wire format (literal \\n) happens only when building the - final API command value. - """ - banner = want["banner"] - - # Canonical internal form for comparison - want_text = normalize_text(want.get("text")) - have_text = have.get("text") # already in canonical form from get_running_config - - base_path = ["system", "login", "banner", banner] - banner_exists = have_text is not None - # -------------------------------------------------------- - # deleted: remove the banner node if it exists - # -------------------------------------------------------- - if state == "deleted": - if banner_exists: - return [{"op": "delete", "path": base_path}] - return [] +_BANNER_PATH = { + "pre-login": ["system", "login", "banner", "pre-login"], + "post-login": ["system", "login", "banner", "post-login"], +} - # -------------------------------------------------------- - # merged / replaced / overridden - # - # For this single leaf-value resource all three states are - # semantically equivalent: ensure the desired value is set. - # Idempotency: skip if the normalized value already matches. - # -------------------------------------------------------- - if state in ("merged", "replaced", "overridden"): - if want_text is None: - # No text provided with a non-delete state — nothing to do - return [] - if want_text == have_text: - # Already correct — idempotent, no change needed - return [] +def _encode_banner(text): + """Convert real newlines to literal \\n for the VyOS REST API value field.""" + return text.replace("\n", "\\n") - # Convert to wire format only at the point of building the payload - return [ - { - "op": "set", - "path": base_path, - "value": text_to_api_value(want_text), - }, - ] - return [] +def _decode_banner(text): + """Convert literal \\n back to real newlines for display and comparison.""" + return text.replace("\\n", "\n") -# ------------------------------------------------------------ -# Main -# ------------------------------------------------------------ +def _get_current(client, banner_type): + """Return current banner as a config dict, or empty dict if not set.""" + path = _BANNER_PATH[banner_type] + try: + result = client.retrieve_return_value(path) + raw = result.get("data") or "" + if raw: + return {"banner": banner_type, "text": _decode_banner(raw)} + except VyOSRestError: + pass + return {"banner": banner_type, "text": ""} def main(): - argument_spec = dict( config=dict( type="dict", - required=True, options=dict( banner=dict( type="str", @@ -330,86 +191,61 @@ def main(): ), ), state=dict( + type="str", default="merged", - choices=[ - "merged", - "replaced", - "overridden", - "deleted", - "gathered", - ], + choices=["merged", "replaced", "deleted", "gathered"], ), ) + argument_spec.update(VYOS_REST_CONNECTION_ARGSPEC) module = AnsibleModule( argument_spec=argument_spec, + required_if=[ + ("state", "merged", ["config"]), + ("state", "replaced", ["config"]), + ("state", "deleted", ["config"]), + ("state", "gathered", ["config"]), + ], supports_check_mode=True, ) - vyos = VyOSModule(module) - + client = VyOSRestClient(module) state = module.params["state"] - config = module.params.get("config") or {} - banner = config.get("banner") + config = module.params["config"] + banner_type = config["banner"] + desired_text = config.get("text") or "" + path = _BANNER_PATH[banner_type] + commands = [] + changed = False - if not banner: - module.fail_json(msg="banner is required in config") + before = _get_current(client, banner_type) - # Fetch current device state (text in canonical internal form) - have = get_running_config(vyos, banner) - - # -------------------------------------------------------- - # gathered: return current state, no changes - # -------------------------------------------------------- if state == "gathered": - module.exit_json(changed=False, gathered=have) - - want = { - "banner": banner, - "text": config.get("text"), - } - - commands = build_commands(want, have, state) + module.exit_json(changed=False, gathered=before, before=before, commands=[]) - # -------------------------------------------------------- - # check mode: report what would change, make no API calls - # -------------------------------------------------------- if module.check_mode: - module.exit_json( - changed=bool(commands), - commands=commands, - before=have, - ) - - # -------------------------------------------------------- - # apply changes - # -------------------------------------------------------- - if commands: - response = vyos.apply_commands(commands) - saved = vyos.save_config() - - module.exit_json( - changed=True, - before=have, - # Report after-state in canonical form (real newlines, human-readable) - after=want, - # after={ - # "banner": banner, - # "text": normalize_text(want["text"]), - # }, - commands=commands, - saved=saved, - response=response, - ) - - # -------------------------------------------------------- - # no changes needed - # -------------------------------------------------------- - module.exit_json( - changed=False, - before=have, - after=have, - ) + module.exit_json(changed=True, before=before, commands=["(check mode)"]) + + try: + if state in ("merged", "replaced"): + if state == "merged" and before.get("text") == desired_text: + module.exit_json(changed=False, before=before, commands=[]) + client.configure_set(path, _encode_banner(desired_text)) + commands.append("set {p} '...'".format(p=" ".join(path))) + changed = True + + elif state == "deleted": + if not before.get("text"): + module.exit_json(changed=False, before=before, commands=[]) + client.configure_delete(path) + commands.append("delete {p}".format(p=" ".join(path))) + changed = True + + except VyOSRestError as exc: + module.fail_json(msg=str(exc), before=before) + + after = _get_current(client, banner_type) if changed else before + module.exit_json(changed=changed, before=before, after=after, commands=commands) if __name__ == "__main__": diff --git a/plugins/modules/vyos_bgp_address_family.py b/plugins/modules/vyos_bgp_address_family.py new file mode 100644 index 0000000..ff80053 --- /dev/null +++ b/plugins/modules/vyos_bgp_address_family.py @@ -0,0 +1,354 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# GNU General Public License v3.0+ + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: vyos_bgp_address_family +short_description: BGP Address Family resource module via REST API. +description: + - Manages BGP address-family configuration (networks, redistribute, + aggregate-address, per-neighbor AFI settings) via the VyOS HTTPS REST API. +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + config: + description: BGP address-family configuration. + type: dict + suboptions: + as_number: + description: Local AS number. + type: int + required: true + address_family: + description: Global address-family parameters. + type: list + elements: dict + suboptions: + afi: + description: Address family (ipv4 or ipv6). + type: str + choices: [ipv4, ipv6] + aggregate_address: + description: List of BGP aggregate networks. + type: list + elements: dict + suboptions: + prefix: + type: str + as_set: + type: bool + summary_only: + type: bool + networks: + description: Networks to originate. + type: list + elements: dict + suboptions: + prefix: + type: str + path_limit: + type: int + backdoor: + type: bool + route_map: + type: str + redistribute: + description: Protocols to redistribute. + type: list + elements: dict + suboptions: + protocol: + type: str + choices: [connected, kernel, ospf, ospfv3, rip, ripng, static] + table: + type: str + route_map: + type: str + metric: + type: int + neighbors: + description: Per-neighbor address-family settings. + type: list + elements: dict + suboptions: + neighbor_address: + description: Neighbor IP. + type: str + address_family: + type: list + elements: dict + suboptions: + afi: + type: str + choices: [ipv4, ipv6] + allowas_in: + type: int + as_override: + type: bool + route_map: + description: Route-map settings. + type: dict + suboptions: + import_map: + type: str + export_map: + type: str + soft_reconfiguration: + type: bool + nexthop_self: + type: bool + remove_private_as: + type: bool + state: + description: Desired state. + type: str + choices: [merged, replaced, deleted, gathered] + default: merged + hostname: + description: Device IP or FQDN. + type: str + required: true + port: + type: int + default: 443 + api_key: + type: str + required: true + no_log: true + timeout: + type: int + default: 30 + verify_ssl: + type: bool + default: false +""" + +RETURN = r""" +before: + description: Config before module ran. + returned: always + type: dict +after: + description: Config after module ran. + returned: when changed + type: dict +commands: + description: Commands issued. + returned: always + type: list +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import ( + VYOS_REST_CONNECTION_ARGSPEC, + VyOSRestClient, + VyOSRestError, +) + + +def _get_bgp(client): + try: + result = client.retrieve_show_config(["protocols", "bgp"]) + return result.get("data") or {} + except VyOSRestError: + return {} + + +def _afi_unicast_key(afi): + return "ipv4-unicast" if afi == "ipv4" else "ipv6-unicast" + + +def _apply(client, config, commands): + asn = str(config["as_number"]) + base = ["protocols", "bgp", asn] + + for af in config.get("address_family") or []: + afi = af["afi"] + af_base = base + ["address-family", _afi_unicast_key(afi)] + + for agg in af.get("aggregate_address") or []: + p = agg["prefix"] + agg_base = af_base + ["aggregate-address", p] + client.configure_set(agg_base) + commands.append("set {b}".format(b=" ".join(agg_base))) + if agg.get("as_set"): + client.configure_set(agg_base + ["as-set"]) + if agg.get("summary_only"): + client.configure_set(agg_base + ["summary-only"]) + + for net in af.get("networks") or []: + net_base = af_base + ["network", net["prefix"]] + client.configure_set(net_base) + commands.append("set {b}".format(b=" ".join(net_base))) + if net.get("route_map"): + client.configure_set(net_base + ["route-map"], net["route_map"]) + if net.get("backdoor"): + client.configure_set(net_base + ["backdoor"]) + + for redist in af.get("redistribute") or []: + r_base = af_base + ["redistribute", redist["protocol"]] + client.configure_set(r_base) + commands.append("set {b}".format(b=" ".join(r_base))) + if redist.get("metric"): + client.configure_set(r_base + ["metric"], str(redist["metric"])) + if redist.get("route_map"): + client.configure_set(r_base + ["route-map"], redist["route_map"]) + + for nbr in config.get("neighbors") or []: + n_base = base + ["neighbor", nbr["neighbor_address"]] + for af in nbr.get("address_family") or []: + afi = af["afi"] + n_af_base = n_base + ["address-family", _afi_unicast_key(afi)] + client.configure_set(n_af_base) + commands.append("set {b}".format(b=" ".join(n_af_base))) + if af.get("allowas_in"): + client.configure_set(n_af_base + ["allowas-in", "number"], str(af["allowas_in"])) + if af.get("as_override"): + client.configure_set(n_af_base + ["as-override"]) + if af.get("nexthop_self"): + client.configure_set(n_af_base + ["nexthop-self"]) + if af.get("remove_private_as"): + client.configure_set(n_af_base + ["remove-private-as"]) + if af.get("soft_reconfiguration"): + client.configure_set(n_af_base + ["soft-reconfiguration", "inbound"]) + rm = af.get("route_map") or {} + if rm.get("import_map"): + client.configure_set(n_af_base + ["route-map", "import"], rm["import_map"]) + if rm.get("export_map"): + client.configure_set(n_af_base + ["route-map", "export"], rm["export_map"]) + + +def main(): + argument_spec = dict( + config=dict( + type="dict", + options=dict( + as_number=dict(type="int", required=True), + address_family=dict( + type="list", + elements="dict", + options=dict( + afi=dict(type="str", choices=["ipv4", "ipv6"]), + aggregate_address=dict( + type="list", + elements="dict", + options=dict( + prefix=dict(type="str"), + as_set=dict(type="bool"), + summary_only=dict(type="bool"), + ), + ), + networks=dict( + type="list", + elements="dict", + options=dict( + prefix=dict(type="str"), + path_limit=dict(type="int"), + backdoor=dict(type="bool"), + route_map=dict(type="str"), + ), + ), + redistribute=dict( + type="list", + elements="dict", + options=dict( + protocol=dict( + type="str", + choices=[ + "connected", + "kernel", + "ospf", + "ospfv3", + "rip", + "ripng", + "static", + ], + ), + table=dict(type="str"), + route_map=dict(type="str"), + metric=dict(type="int"), + ), + ), + ), + ), + neighbors=dict( + type="list", + elements="dict", + options=dict( + neighbor_address=dict(type="str"), + address_family=dict( + type="list", + elements="dict", + options=dict( + afi=dict(type="str", choices=["ipv4", "ipv6"]), + allowas_in=dict(type="int"), + as_override=dict(type="bool"), + nexthop_self=dict(type="bool"), + remove_private_as=dict(type="bool"), + soft_reconfiguration=dict(type="bool"), + route_map=dict( + type="dict", + options=dict( + import_map=dict(type="str"), + export_map=dict(type="str"), + ), + ), + ), + ), + ), + ), + ), + ), + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "deleted", "gathered"], + ), + ) + argument_spec.update(VYOS_REST_CONNECTION_ARGSPEC) + module = AnsibleModule( + argument_spec=argument_spec, + required_if=[("state", "merged", ["config"]), ("state", "replaced", ["config"])], + supports_check_mode=True, + ) + + client = VyOSRestClient(module) + state = module.params["state"] + config = module.params.get("config") + commands = [] + changed = False + before = _get_bgp(client) + + if state == "gathered": + module.exit_json(changed=False, gathered=before, before=before, commands=[]) + if module.check_mode: + module.exit_json(changed=True, before=before, commands=["(check mode)"]) + + try: + if state == "deleted": + if before: + client.configure_delete(["protocols", "bgp"]) + commands.append("delete protocols bgp") + changed = True + elif state in ("merged", "replaced"): + if state == "replaced" and before: + client.configure_delete(["protocols", "bgp"]) + commands.append("delete protocols bgp") + _apply(client, config, commands) + changed = True + except VyOSRestError as exc: + module.fail_json(msg=str(exc)) + + after = _get_bgp(client) if changed else before + module.exit_json(changed=changed, before=before, after=after, commands=commands) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/vyos_bgp_global.py b/plugins/modules/vyos_bgp_global.py new file mode 100644 index 0000000..6a08f4f --- /dev/null +++ b/plugins/modules/vyos_bgp_global.py @@ -0,0 +1,365 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# 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_bgp_global +short_description: Manage BGP global configuration on VyOS via the REST API. +description: + - Manages BGP global parameters on VyOS devices using the HTTPS REST API. + - Mirrors C(vyos.vyos.vyos_bgp_global) but uses the HTTP API. +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + config: + description: BGP global configuration. + type: dict + suboptions: + as_number: + description: Local BGP AS number. + type: int + required: true + router_id: + description: BGP router ID (IPv4 address). + type: str + neighbors: + description: List of BGP neighbor configurations. + type: list + elements: dict + suboptions: + neighbor: + description: Neighbor IP address. + type: str + required: true + remote_as: + description: Neighbor's AS number. + type: int + description: + description: Neighbor description. + type: str + password: + description: MD5 authentication password. + type: str + no_log: true + update_source: + description: Local interface or address for BGP session. + type: str + ebgp_multihop: + description: Maximum hops for eBGP sessions. + type: int + shutdown: + description: Whether to administratively shut down the neighbor. + type: bool + networks: + description: List of networks to originate. + type: list + elements: dict + suboptions: + prefix: + description: Network prefix to advertise. + type: str + required: true + route_map: + description: Route map to apply. + type: str + redistribute: + description: List of protocols to redistribute into BGP. + type: list + elements: dict + suboptions: + protocol: + description: Protocol to redistribute. + type: str + choices: [connected, kernel, ospf, ospfv3, rip, ripng, static] + required: true + metric: + description: Metric for redistributed routes. + type: int + route_map: + description: Route map to apply. + type: str + state: + description: + - C(merged): Merge BGP config with existing. + - C(replaced): Replace entire BGP config. + - C(deleted): Remove BGP configuration. + - C(gathered): Read BGP config from device. + type: str + choices: [merged, replaced, deleted, gathered] + default: merged + hostname: + description: IP address or FQDN of the VyOS device. + type: str + required: true + port: + description: HTTPS port for the REST API. + type: int + default: 443 + api_key: + description: API key configured on the device. + type: str + required: true + no_log: true + timeout: + description: Request timeout in seconds. + type: int + default: 30 + verify_ssl: + description: Validate the device's TLS certificate. + type: bool + default: false +requirements: + - VyOS 1.3+ +seealso: + - module: vyos.vyos.vyos_bgp_global +examples: | + - name: Configure BGP + vyos.rest.vyos_bgp_global: + hostname: 192.168.1.1 + api_key: MY-KEY + config: + as_number: 65001 + router_id: 192.168.1.1 + neighbors: + - neighbor: 192.168.2.1 + remote_as: 65002 + description: "Peer AS65002" + networks: + - prefix: 10.0.0.0/8 + redistribute: + - protocol: connected + state: merged + + - name: Remove BGP configuration + vyos.rest.vyos_bgp_global: + hostname: 192.168.1.1 + api_key: MY-KEY + state: deleted +""" + +RETURN = r""" +before: + description: BGP config before the module ran. + returned: always + type: dict +after: + description: BGP config after the module ran. + returned: when changed + type: dict +gathered: + description: BGP config read from device (state=gathered). + returned: when state is gathered + type: dict +commands: + description: set/delete commands issued. + returned: always + type: list +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import ( + VYOS_REST_CONNECTION_ARGSPEC, + VyOSRestClient, + VyOSRestError, +) + + +_BGP_BASE = ["protocols", "bgp"] + + +def _get_bgp(client): + try: + result = client.retrieve_show_config(_BGP_BASE) + return result.get("data") or {} + except VyOSRestError: + return {} + + +def _apply_bgp(client, config, commands): + asn = str(config["as_number"]) + base = _BGP_BASE + [asn] + client.configure_set(base) + commands.append("set protocols bgp {a}".format(a=asn)) + + if config.get("router_id"): + client.configure_set(base + ["parameters", "router-id"], config["router_id"]) + commands.append( + "set protocols bgp {a} parameters router-id {r}".format( + a=asn, + r=config["router_id"], + ), + ) + + for nbr in config.get("neighbors") or []: + nbase = base + ["neighbor", nbr["neighbor"]] + client.configure_set(nbase) + commands.append("set protocols bgp {a} neighbor {n}".format(a=asn, n=nbr["neighbor"])) + if nbr.get("remote_as"): + client.configure_set(nbase + ["remote-as"], str(nbr["remote_as"])) + commands.append( + "set protocols bgp {a} neighbor {n} remote-as {r}".format( + a=asn, + n=nbr["neighbor"], + r=nbr["remote_as"], + ), + ) + if nbr.get("description"): + client.configure_set(nbase + ["description"], nbr["description"]) + if nbr.get("password"): + client.configure_set(nbase + ["password"], nbr["password"]) + if nbr.get("update_source"): + client.configure_set(nbase + ["update-source"], nbr["update_source"]) + if nbr.get("ebgp_multihop"): + client.configure_set(nbase + ["ebgp-multihop"], str(nbr["ebgp_multihop"])) + if nbr.get("shutdown"): + client.configure_set(nbase + ["shutdown"]) + commands.append( + "set protocols bgp {a} neighbor {n} shutdown".format( + a=asn, + n=nbr["neighbor"], + ), + ) + + for net in config.get("networks") or []: + nbase = base + ["address-family", "ipv4-unicast", "network", net["prefix"]] + client.configure_set(nbase) + commands.append( + "set protocols bgp {a} address-family ipv4-unicast network {p}".format( + a=asn, + p=net["prefix"], + ), + ) + if net.get("route_map"): + client.configure_set(nbase + ["route-map"], net["route_map"]) + + for redist in config.get("redistribute") or []: + rbase = base + [ + "address-family", + "ipv4-unicast", + "redistribute", + redist["protocol"], + ] + client.configure_set(rbase) + commands.append( + "set protocols bgp {a} address-family ipv4-unicast redistribute {p}".format( + a=asn, + p=redist["protocol"], + ), + ) + if redist.get("metric"): + client.configure_set(rbase + ["metric"], str(redist["metric"])) + if redist.get("route_map"): + client.configure_set(rbase + ["route-map"], redist["route_map"]) + + +def main(): + argument_spec = dict( + config=dict( + type="dict", + options=dict( + as_number=dict(type="int", required=True), + router_id=dict(type="str"), + neighbors=dict( + type="list", + elements="dict", + options=dict( + neighbor=dict(type="str", required=True), + remote_as=dict(type="int"), + description=dict(type="str"), + password=dict(type="str", no_log=True), + update_source=dict(type="str"), + ebgp_multihop=dict(type="int"), + shutdown=dict(type="bool"), + ), + ), + networks=dict( + type="list", + elements="dict", + options=dict( + prefix=dict(type="str", required=True), + route_map=dict(type="str"), + ), + ), + redistribute=dict( + type="list", + elements="dict", + options=dict( + protocol=dict( + type="str", + required=True, + choices=[ + "connected", + "kernel", + "ospf", + "ospfv3", + "rip", + "ripng", + "static", + ], + ), + metric=dict(type="int"), + route_map=dict(type="str"), + ), + ), + ), + ), + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "deleted", "gathered"], + ), + ) + argument_spec.update(VYOS_REST_CONNECTION_ARGSPEC) + + module = AnsibleModule( + argument_spec=argument_spec, + required_if=[ + ("state", "merged", ["config"]), + ("state", "replaced", ["config"]), + ], + supports_check_mode=True, + ) + + client = VyOSRestClient(module) + state = module.params["state"] + config = module.params.get("config") + commands = [] + changed = False + + before = _get_bgp(client) + + if state == "gathered": + module.exit_json(changed=False, gathered=before, before=before, commands=[]) + + if module.check_mode: + module.exit_json(changed=True, before=before, commands=["(check mode)"]) + + try: + if state == "deleted": + if before: + client.configure_delete(_BGP_BASE) + commands.append("delete protocols bgp") + changed = True + + elif state in ("merged", "replaced"): + if state == "replaced" and before: + client.configure_delete(_BGP_BASE) + commands.append("delete protocols bgp") + _apply_bgp(client, config, commands) + changed = True + except VyOSRestError as exc: + module.fail_json(msg=str(exc)) + + after = _get_bgp(client) if changed else before + module.exit_json(changed=changed, before=before, after=after, commands=commands) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/vyos_config_file.py b/plugins/modules/vyos_config_file.py new file mode 100644 index 0000000..9d5e48a --- /dev/null +++ b/plugins/modules/vyos_config_file.py @@ -0,0 +1,130 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# 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_config_file +short_description: Save or load VyOS configuration files via the REST API. +description: + - Manages VyOS configuration persistence via the HTTPS REST API + (C(/config-file) endpoint). + - Use I(op=save) to write the running configuration to a file. + - Use I(op=load) to load a configuration from a file and apply it. +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + op: + description: + - C(save) - Save the running configuration to I(file) or the default C(/config/config.boot). + - C(load) - Load and commit the configuration from I(file). + type: str + choices: [save, load] + default: save + file: + description: + - Path on the VyOS device for save/load target. + - If omitted for C(save), the device uses its default boot config path. + type: str + hostname: + description: + - IP address or FQDN of the VyOS device. + type: str + required: true + port: + description: + - HTTPS port for the REST API. + type: int + default: 443 + api_key: + description: + - API key configured on the device. + type: str + required: true + no_log: true + timeout: + description: + - Request timeout in seconds. + type: int + default: 30 + verify_ssl: + description: + - Validate the device's TLS certificate. + type: bool + default: false +requirements: + - VyOS 1.3+""" + +RETURN = r""" +output: + description: Text output from the config-file operation. + returned: success + type: str +""" + +EXAMPLES = r""" +- name: Save running config (default location) + vyos.rest.vyos_config_file: + hostname: 192.168.1.1 + api_key: MY-KEY + op: save + +- name: Save running config to a custom file + vyos.rest.vyos_config_file: + hostname: 192.168.1.1 + api_key: MY-KEY + op: save + file: /config/backups/config.boot.bak + +- name: Load a configuration from file + vyos.rest.vyos_config_file: + hostname: 192.168.1.1 + api_key: MY-KEY + op: load + file: /config/backups/config.boot.bak +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import ( + VYOS_REST_CONNECTION_ARGSPEC, + VyOSRestClient, + VyOSRestError, +) + + +def main(): + argument_spec = dict( + op=dict(type="str", default="save", choices=["save", "load"]), + file=dict(type="str"), + ) + argument_spec.update(VYOS_REST_CONNECTION_ARGSPEC) + + module = AnsibleModule( + argument_spec=argument_spec, + required_if=[("op", "load", ["file"])], + supports_check_mode=False, + ) + + client = VyOSRestClient(module) + op = module.params["op"] + file_path = module.params.get("file") + + try: + if op == "save": + result = client.config_file_save(file_path) + else: + result = client.config_file_load(file_path) + except VyOSRestError as exc: + module.fail_json(msg=str(exc)) + + module.exit_json(changed=True, output=result.get("data", "")) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/vyos_configure.py b/plugins/modules/vyos_configure.py new file mode 100644 index 0000000..355a78c --- /dev/null +++ b/plugins/modules/vyos_configure.py @@ -0,0 +1,259 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# 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_configure +short_description: Manage VyOS device configuration via the REST API. +description: + - Sends one or more C(set) or C(delete) configuration commands to a VyOS + device via its HTTPS REST API (C(/configure) endpoint). + - Each command in I(commands) is executed and committed atomically. + - Supports Ansible check-mode (C(check_mode=yes)) - no changes are applied. +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + commands: + description: + - List of VyOS configuration commands. + - Each item may be either a plain string in VyOS CLI syntax + (e.g. C(set interfaces ethernet eth0 description 'WAN')) + or a dict with keys C(op), C(path), and optionally C(value). + type: list + elements: raw + required: true + save: + description: + - Whether to save the configuration to disk after a successful commit. + type: bool + default: false + hostname: + description: + - IP address or FQDN of the VyOS device. + type: str + required: true + port: + description: + - HTTPS port for the REST API. + type: int + default: 443 + api_key: + description: + - API key configured on the device. + type: str + required: true + no_log: true + timeout: + description: + - Request timeout in seconds. + type: int + default: 30 + verify_ssl: + description: + - Validate the device's TLS certificate. + type: bool + default: false +notes: + - The VyOS HTTP API must be enabled on the device before using this module. + - Enable with C(set service https api keys id <ID> key <KEY>) and + C(set service https api rest), then C(commit && save). + - Tested against VyOS 1.3 and 1.4. +requirements: + - VyOS 1.3+ +seealso: + - module: vyos.rest.vyos_retrieve + - module: vyos.rest.vyos_show""" + +RETURN = r""" +commands_sent: + description: The list of commands dispatched to the API. + returned: always + type: list + elements: dict + sample: + - op: set + path: ["system", "host-name"] + value: vyos-router +changed: + description: Whether the device configuration was modified. + returned: always + type: bool +saved: + description: Whether the configuration was saved to disk. + returned: always + type: bool +""" + +import re + + +EXAMPLES = r""" +- name: Set hostname via REST API + vyos.rest.vyos_configure: + hostname: 192.168.1.1 + api_key: MY-KEY + commands: + - "set system host-name vyos-router" + +- name: Configure an interface description and address + vyos.rest.vyos_configure: + hostname: 192.168.1.1 + api_key: MY-KEY + commands: + - "set interfaces ethernet eth1 description 'UPLINK'" + - "set interfaces ethernet eth1 address 10.0.0.1/30" + save: true + +- name: Delete a static route using dict syntax + vyos.rest.vyos_configure: + hostname: 192.168.1.1 + api_key: MY-KEY + commands: + - op: delete + path: ["protocols", "static", "route", "192.0.2.0/24"] +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import ( + VYOS_REST_CONNECTION_ARGSPEC, + VyOSRestClient, + VyOSRestError, +) + + +def _parse_cli_command(cmd_str): + """Parse a VyOS CLI-syntax string into an API payload dict. + + Supports: + set <path> [<value>] + delete <path> + comment <path> <comment> + + Args: + cmd_str (str): e.g. "set interfaces ethernet eth0 description 'WAN'" + + Returns: + dict: e.g. {"op": "set", "path": [...], "value": "WAN"} + """ + cmd_str = cmd_str.strip() + op_match = re.match(r"^(set|delete|comment)\s+(.+)$", cmd_str, re.IGNORECASE) + if not op_match: + raise ValueError( + "Cannot parse command '{cmd}'. Must start with set/delete/comment.".format( + cmd=cmd_str, + ), + ) + op = op_match.group(1).lower() + rest = op_match.group(2).strip() + + # Extract a quoted or bare trailing value for 'set' + value = None + if op == "set": + # Look for a trailing quoted value: '...' or "..." + quoted = re.search(r"""(['"])(.*?)\1\s*$""", rest) + if quoted: + value = quoted.group(2) + rest = rest[: quoted.start()].strip() + else: + # Bare final token may be the value if the second-to-last token + # is a known leaf keyword – use a simple heuristic: if the + # last segment has no sub-tokens we treat it as value. + tokens = rest.split() + # We cannot know the schema here, so we pass the full path and + # no value; the device will decide. + path = tokens + return {"op": op, "path": path} + + path = rest.split() + payload = {"op": op, "path": path} + if value is not None: + payload["value"] = value + return payload + + +def _normalise_commands(raw_commands): + """Normalise the mixed list of str/dict commands into API dicts.""" + result = [] + for item in raw_commands: + if isinstance(item, dict): + if "op" not in item or "path" not in item: + raise ValueError( + "Command dict must have 'op' and 'path' keys: {item}".format( + item=item, + ), + ) + result.append(item) + elif isinstance(item, str): + result.append(_parse_cli_command(item)) + else: + raise ValueError( + "Command must be a string or dict, got: {t}".format(t=type(item)), + ) + return result + + +def main(): + argument_spec = dict( + commands=dict(type="list", elements="raw", required=True), + save=dict(type="bool", default=False), + ) + argument_spec.update(VYOS_REST_CONNECTION_ARGSPEC) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + try: + commands = _normalise_commands(module.params["commands"]) + except ValueError as exc: + module.fail_json(msg=str(exc)) + + if module.check_mode: + module.exit_json( + changed=True, + commands_sent=commands, + saved=False, + msg="Check mode: no changes applied.", + ) + + client = VyOSRestClient(module) + changed = False + saved = False + + try: + for cmd in commands: + op = cmd["op"].lower() + path = cmd["path"] + value = cmd.get("value") + if op == "set": + client.configure_set(path, value) + elif op == "delete": + client.configure_delete(path) + elif op == "comment": + client.configure_comment(path, value or "") + else: + module.fail_json( + msg="Unsupported op '{op}'".format(op=op), + ) + changed = True + + if module.params["save"]: + client.config_file_save() + saved = True + + except VyOSRestError as exc: + module.fail_json(msg=str(exc), commands_sent=commands) + + module.exit_json(changed=changed, commands_sent=commands, saved=saved) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/vyos_facts.py b/plugins/modules/vyos_facts.py new file mode 100644 index 0000000..66e684b --- /dev/null +++ b/plugins/modules/vyos_facts.py @@ -0,0 +1,161 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# GNU General Public License v3.0+ + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: vyos_facts +short_description: Get facts about VyOS devices via REST API. +description: + - Collects facts from VyOS devices using the HTTPS REST API. + - Returns system info and optionally per-resource configuration facts. +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + gather_subset: + description: + - Restrict facts to a subset. Use C(all), C(default), C(config), or C(min). + type: list + elements: str + default: ["min"] + gather_network_resources: + description: + - Which network resource facts to gather. + type: list + elements: str + available_network_resources: + description: + - When true, return a list of available resource names. + type: bool + default: false + hostname: + description: + - Device IP or FQDN. + type: str + required: true + port: + type: int + default: 443 + api_key: + type: str + required: true + no_log: true + timeout: + type: int + default: 30 + verify_ssl: + type: bool + default: false +""" + +RETURN = r""" +ansible_facts: + description: Dictionary of facts keyed by resource name. + returned: always + type: dict +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import ( + VYOS_REST_CONNECTION_ARGSPEC, + VyOSRestClient, + VyOSRestError, +) + + +_NETWORK_RESOURCES = [ + "interfaces", + "l3_interfaces", + "lag_interfaces", + "lldp_global", + "lldp_interfaces", + "static_routes", + "firewall_rules", + "firewall_global", + "firewall_interfaces", + "ospfv2", + "ospfv3", +] + +_RESOURCE_PATHS = { + "interfaces": ["interfaces"], + "l3_interfaces": ["interfaces"], + "lag_interfaces": ["interfaces", "bonding"], + "lldp_global": ["service", "lldp"], + "lldp_interfaces": ["service", "lldp"], + "static_routes": ["protocols", "static"], + "firewall_rules": ["firewall"], + "firewall_global": ["firewall"], + "firewall_interfaces": ["firewall"], + "ospfv2": ["protocols", "ospf"], + "ospfv3": ["protocols", "ospfv3"], +} + + +def _get_subset(client, resource): + path = _RESOURCE_PATHS.get(resource, []) + try: + result = client.retrieve_show_config(path) + return result.get("data") or {} + except VyOSRestError: + return {} + + +def main(): + argument_spec = dict( + gather_subset=dict(type="list", elements="str", default=["min"]), + gather_network_resources=dict(type="list", elements="str"), + available_network_resources=dict(type="bool", default=False), + ) + argument_spec.update(VYOS_REST_CONNECTION_ARGSPEC) + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + + client = VyOSRestClient(module) + facts = {} + + if module.params["available_network_resources"]: + facts["available_network_resources"] = _NETWORK_RESOURCES + + gather_subset = module.params["gather_subset"] + get_all = "all" in gather_subset + + # Always gather system info for "min" or "all" + if get_all or "min" in gather_subset or "default" in gather_subset: + try: + info = client.info() + facts["version"] = info.get("data", {}).get("version", "") + facts["hostname"] = info.get("data", {}).get("hostname", "") + except VyOSRestError: + pass + + # Config subset + if get_all or "config" in gather_subset: + try: + result = client.retrieve_show_config([]) + facts["config"] = result.get("data") or {} + except VyOSRestError: + facts["config"] = {} + + # Network resources + requested = module.params.get("gather_network_resources") or [] + if "all" in requested: + requested = _NETWORK_RESOURCES + + for resource in requested: + if resource.startswith("!"): + continue + if resource in _NETWORK_RESOURCES: + facts["network_resources"] = facts.get("network_resources", {}) + facts["network_resources"][resource] = _get_subset(client, resource) + + module.exit_json(changed=False, ansible_facts=facts) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/vyos_firewall_global.py b/plugins/modules/vyos_firewall_global.py new file mode 100644 index 0000000..a3974fd --- /dev/null +++ b/plugins/modules/vyos_firewall_global.py @@ -0,0 +1,360 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# GNU General Public License v3.0+ + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: vyos_firewall_global +short_description: Firewall global resource module via REST API. +description: + - Manages global VyOS firewall parameters (groups, ICMP redirect policy, + source validation, ping policy) via the HTTPS REST API. +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + config: + description: Global firewall configuration. + type: dict + suboptions: + validation: + description: Source-validation policy (strict/loose/disable). + type: str + choices: [strict, loose, disable] + config_trap: + description: SNMP trap on firewall config changes. + type: bool + ping: + description: ICMP echo policy. + type: dict + suboptions: + all: + type: bool + broadcast: + type: bool + route_redirects: + description: ICMP redirect / source-route options per AFI. + type: list + elements: dict + suboptions: + afi: + type: str + choices: [ipv4, ipv6] + required: true + icmp_redirects: + type: dict + suboptions: + send: + type: bool + receive: + type: bool + ip_src_route: + type: bool + group: + description: Firewall object groups. + type: dict + suboptions: + address_group: + type: list + elements: dict + suboptions: + name: + type: str + required: true + description: + type: str + members: + description: List of IP addresses or ranges. + type: list + elements: dict + suboptions: + address: + type: str + afi: + type: str + default: ipv4 + choices: [ipv4, ipv6] + network_group: + type: list + elements: dict + suboptions: + name: + type: str + required: true + description: + type: str + members: + description: List of network prefixes. + type: list + elements: dict + suboptions: + address: + type: str + port_group: + type: list + elements: dict + suboptions: + name: + type: str + required: true + description: + type: str + members: + description: List of port numbers or ranges. + type: list + elements: dict + suboptions: + port: + type: str + state: + description: Desired state. + type: str + choices: [merged, replaced, deleted, gathered] + default: merged + hostname: + type: str + required: true + port: + type: int + default: 443 + api_key: + type: str + required: true + no_log: true + timeout: + type: int + default: 30 + verify_ssl: + type: bool + default: false +""" + +RETURN = r""" +before: + returned: always + type: dict +after: + returned: when changed + type: dict +commands: + returned: always + type: list +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import ( + VYOS_REST_CONNECTION_ARGSPEC, + VyOSRestClient, + VyOSRestError, +) + + +_FW_BASE = ["firewall"] + + +def _get(client): + try: + r = client.retrieve_show_config(_FW_BASE) + return r.get("data") or {} + except VyOSRestError: + return {} + + +def _apply(client, config, commands): + if config.get("validation"): + path = ( + _FW_BASE + ["ip", "src-route"] + if config["validation"] == "disable" + else _FW_BASE + ["ip", "enable-default-log"] + ) + # Use the proper path + p = ( + ["firewall", "global-options", "source-validation"] + if client.retrieve_exists(["firewall", "global-options"]) + else ["firewall", "ip", "src-route"] + ) + client.configure_set(["firewall", "ip", "source-validation"], config["validation"]) + commands.append("set firewall ip source-validation {v}".format(v=config["validation"])) + + if config.get("config_trap") is not None: + val = "enable" if config["config_trap"] else "disable" + client.configure_set(["firewall", "config-trap"], val) + commands.append("set firewall config-trap {v}".format(v=val)) + + ping = config.get("ping") or {} + if ping.get("all") is not None: + action = "enable" if ping["all"] else "disable" + client.configure_set(["firewall", "ip", "ping", "all"], action) + commands.append("set firewall ip ping all {a}".format(a=action)) + if ping.get("broadcast") is not None: + action = "enable" if ping["broadcast"] else "disable" + client.configure_set(["firewall", "ip", "ping", "broadcast"], action) + commands.append("set firewall ip ping broadcast {a}".format(a=action)) + + for rr in config.get("route_redirects") or []: + afi = rr["afi"] + ip_key = "ip" if afi == "ipv4" else "ipv6" + icmp = rr.get("icmp_redirects") or {} + if icmp.get("send") is not None: + p = ["firewall", ip_key, "send-redirects"] + client.configure_set(p, "enable" if icmp["send"] else "disable") + commands.append( + "set {p} {v}".format(p=" ".join(p), v="enable" if icmp["send"] else "disable"), + ) + if icmp.get("receive") is not None: + p = ["firewall", ip_key, "disable-forwarding"] + client.configure_set(p, "enable" if not icmp["receive"] else "disable") + if rr.get("ip_src_route") is not None: + p = ["firewall", ip_key, "source-route"] + client.configure_set(p, "enable" if rr["ip_src_route"] else "disable") + + grp = config.get("group") or {} + for ag in grp.get("address_group") or []: + gpath = ["firewall", "group", "address-group", ag["name"]] + client.configure_set(gpath) + commands.append("set {p}".format(p=" ".join(gpath))) + if ag.get("description"): + client.configure_set(gpath + ["description"], ag["description"]) + for m in ag.get("members") or []: + client.configure_set(gpath + ["address"], m["address"]) + commands.append("set {p} address {a}".format(p=" ".join(gpath), a=m["address"])) + + for ng in grp.get("network_group") or []: + gpath = ["firewall", "group", "network-group", ng["name"]] + client.configure_set(gpath) + commands.append("set {p}".format(p=" ".join(gpath))) + if ng.get("description"): + client.configure_set(gpath + ["description"], ng["description"]) + for m in ng.get("members") or []: + client.configure_set(gpath + ["network"], m["address"]) + + for pg in grp.get("port_group") or []: + gpath = ["firewall", "group", "port-group", pg["name"]] + client.configure_set(gpath) + commands.append("set {p}".format(p=" ".join(gpath))) + if pg.get("description"): + client.configure_set(gpath + ["description"], pg["description"]) + for m in pg.get("members") or []: + client.configure_set(gpath + ["port"], str(m["port"])) + + +def main(): + argument_spec = dict( + config=dict( + type="dict", + options=dict( + validation=dict(type="str", choices=["strict", "loose", "disable"]), + config_trap=dict(type="bool"), + ping=dict( + type="dict", + options=dict(all=dict(type="bool"), broadcast=dict(type="bool")), + ), + route_redirects=dict( + type="list", + elements="dict", + options=dict( + afi=dict(type="str", required=True, choices=["ipv4", "ipv6"]), + icmp_redirects=dict( + type="dict", + options=dict(send=dict(type="bool"), receive=dict(type="bool")), + ), + ip_src_route=dict(type="bool"), + ), + ), + group=dict( + type="dict", + options=dict( + address_group=dict( + type="list", + elements="dict", + options=dict( + name=dict(type="str", required=True), + description=dict(type="str"), + afi=dict(type="str", default="ipv4"), + members=dict( + type="list", + elements="dict", + options=dict(address=dict(type="str")), + ), + ), + ), + network_group=dict( + type="list", + elements="dict", + options=dict( + name=dict(type="str", required=True), + description=dict(type="str"), + members=dict( + type="list", + elements="dict", + options=dict(address=dict(type="str")), + ), + ), + ), + port_group=dict( + type="list", + elements="dict", + options=dict( + name=dict(type="str", required=True), + description=dict(type="str"), + members=dict( + type="list", + elements="dict", + options=dict(port=dict(type="str")), + ), + ), + ), + ), + ), + ), + ), + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "deleted", "gathered"], + ), + ) + argument_spec.update(VYOS_REST_CONNECTION_ARGSPEC) + module = AnsibleModule( + argument_spec=argument_spec, + required_if=[("state", "merged", ["config"]), ("state", "replaced", ["config"])], + supports_check_mode=True, + ) + client = VyOSRestClient(module) + state = module.params["state"] + config = module.params.get("config") + commands = [] + changed = False + before = _get(client) + + if state == "gathered": + module.exit_json(changed=False, gathered=before, before=before, commands=[]) + if module.check_mode: + module.exit_json(changed=True, before=before, commands=["(check mode)"]) + + try: + if state == "deleted": + if before: + client.configure_delete(_FW_BASE) + commands.append("delete firewall") + changed = True + elif state in ("merged", "replaced"): + if state == "replaced" and before: + client.configure_delete(_FW_BASE) + commands.append("delete firewall") + _apply(client, config, commands) + changed = True + except VyOSRestError as exc: + module.fail_json(msg=str(exc)) + + after = _get(client) if changed else before + module.exit_json(changed=changed, before=before, after=after, commands=commands) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/vyos_firewall_interfaces.py b/plugins/modules/vyos_firewall_interfaces.py new file mode 100644 index 0000000..d9085a6 --- /dev/null +++ b/plugins/modules/vyos_firewall_interfaces.py @@ -0,0 +1,244 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# GNU General Public License v3.0+ + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: vyos_firewall_interfaces +short_description: Firewall interfaces resource module via REST API. +description: + - Attaches or detaches named firewall rule sets to/from interface + directions (in/out/local) using the VyOS HTTPS REST API. +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + config: + description: List of interface firewall attachment configurations. + type: list + elements: dict + suboptions: + name: + description: Interface name. + type: str + required: true + access_rules: + description: Firewall rule sets per AFI. + type: list + elements: dict + suboptions: + afi: + description: Address family. + type: str + choices: [ipv4, ipv6] + required: true + rules: + description: Rule sets and directions. + type: list + elements: dict + suboptions: + name: + description: Rule set name. + type: str + direction: + description: Traffic direction. + type: str + choices: [in, local, out] + required: true + state: + description: Desired state. + type: str + choices: [merged, replaced, overridden, deleted, gathered] + default: merged + hostname: + type: str + required: true + port: + type: int + default: 443 + api_key: + type: str + required: true + no_log: true + timeout: + type: int + default: 30 + verify_ssl: + type: bool + default: false +""" + +RETURN = r""" +before: + returned: always + type: list +after: + returned: when changed + type: list +commands: + returned: always + type: list +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import ( + VYOS_REST_CONNECTION_ARGSPEC, + VyOSRestClient, + VyOSRestError, +) + + +_IFACE_TYPES = { + "eth": "ethernet", + "bond": "bonding", + "vti": "vti", + "vxlan": "vxlan", + "lo": "loopback", + "dummy": "dummy", + "br": "bridge", + "wg": "wireguard", + "tun": "tunnel", +} + + +def _itype(name): + for p, t in _IFACE_TYPES.items(): + if name.startswith(p): + return t + return "ethernet" + + +def _fw_direction_key(afi, direction): + if afi == "ipv4": + return {"in": "in", "out": "out", "local": "local"}[direction] + return {"in": "in", "out": "out", "local": "local"}[direction] + + +def _apply(client, iface_cfg, commands): + name = iface_cfg["name"] + itype = _itype(name) + ibase = ["interfaces", itype, name, "firewall"] + + for ar in iface_cfg.get("access_rules") or []: + afi = ar["afi"] + fw_key = "firewall" if afi == "ipv4" else "ipv6" + for rule in ar.get("rules") or []: + direction = rule["direction"] + rs_name = rule.get("name") + if rs_name: + path = ibase + [direction, fw_key if afi == "ipv6" else "name"] + client.configure_set(path, rs_name) + commands.append( + "set interfaces {t} {n} firewall {d} {k} '{rs}'".format( + t=itype, + n=name, + d=direction, + k="ipv6-name" if afi == "ipv6" else "name", + rs=rs_name, + ), + ) + + +def _delete_iface_fw(client, name, commands): + itype = _itype(name) + path = ["interfaces", itype, name, "firewall"] + try: + client.configure_delete(path) + commands.append("delete interfaces {t} {n} firewall".format(t=itype, n=name)) + except VyOSRestError: + pass + + +def _get(client): + try: + result = client.retrieve_show_config(["interfaces"]) + raw = result.get("data") or {} + out = [] + for itype, itype_data in raw.items(): + if not isinstance(itype_data, dict): + continue + for iname, idata in itype_data.items(): + fw = idata.get("firewall") if isinstance(idata, dict) else None + if fw: + out.append({"name": iname, "firewall": fw}) + return out + except VyOSRestError: + return [] + + +def main(): + argument_spec = dict( + config=dict( + type="list", + elements="dict", + options=dict( + name=dict(type="str", required=True), + access_rules=dict( + type="list", + elements="dict", + options=dict( + afi=dict(type="str", required=True, choices=["ipv4", "ipv6"]), + rules=dict( + type="list", + elements="dict", + options=dict( + name=dict(type="str"), + direction=dict( + type="str", + required=True, + choices=["in", "local", "out"], + ), + ), + ), + ), + ), + ), + ), + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "overridden", "deleted", "gathered"], + ), + ) + argument_spec.update(VYOS_REST_CONNECTION_ARGSPEC) + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + client = VyOSRestClient(module) + state = module.params["state"] + config = module.params.get("config") or [] + commands = [] + changed = False + before = _get(client) + + if state == "gathered": + module.exit_json(changed=False, gathered=before, before=before, commands=[]) + if module.check_mode: + module.exit_json(changed=True, before=before, commands=["(check mode)"]) + + try: + if state == "deleted": + targets = {i["name"] for i in config} if config else {i["name"] for i in before} + for iface in before: + if iface["name"] in targets: + _delete_iface_fw(client, iface["name"], commands) + changed = True + elif state in ("merged", "replaced", "overridden"): + if state in ("replaced", "overridden"): + for cfg in config: + _delete_iface_fw(client, cfg["name"], commands) + for cfg in config: + _apply(client, cfg, commands) + changed = True + except VyOSRestError as exc: + module.fail_json(msg=str(exc)) + + after = _get(client) if changed else before + module.exit_json(changed=changed, before=before, after=after, commands=commands) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/vyos_firewall_rules.py b/plugins/modules/vyos_firewall_rules.py new file mode 100644 index 0000000..f535f3a --- /dev/null +++ b/plugins/modules/vyos_firewall_rules.py @@ -0,0 +1,492 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# 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_firewall_rules +short_description: Manage firewall rules on VyOS via the REST API. +description: + - Manages VyOS firewall rule sets and individual rules using the HTTPS REST API. + - Mirrors C(vyos.vyos.vyos_firewall_rules) but uses the HTTP API. +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + config: + description: List of firewall rule set configurations. + type: list + elements: dict + suboptions: + afi: + description: Address family. + type: str + choices: [ipv4, ipv6] + required: true + rule_sets: + description: List of named rule sets. + type: list + elements: dict + suboptions: + name: + description: Rule set name. + type: str + required: true + default_action: + description: Default action for unmatched traffic. + type: str + choices: [drop, reject, accept] + default: drop + description: + description: Rule set description. + type: str + rules: + description: List of firewall rules. + type: list + elements: dict + suboptions: + number: + description: Rule number (1-999999). + type: int + required: true + action: + description: Rule action. + type: str + choices: [drop, reject, accept, inspect] + description: + description: Rule description. + type: str + protocol: + description: Protocol (e.g. C(tcp), C(udp), C(icmp), C(all)). + type: str + source: + description: Source match criteria. + type: dict + suboptions: + address: + description: Source IP address or network. + type: str + port: + description: Source port or port range. + type: str + group: + description: Address or network group. + type: dict + suboptions: + address_group: + type: str + network_group: + type: str + destination: + description: Destination match criteria. + type: dict + suboptions: + address: + description: Destination IP address or network. + type: str + port: + description: Destination port or port range. + type: str + group: + description: Address or network group. + type: dict + suboptions: + address_group: + type: str + network_group: + type: str + state: + description: Connection state matching. + type: dict + suboptions: + established: + type: bool + new: + type: bool + related: + type: bool + invalid: + type: bool + enabled: + description: Whether the rule is active. + type: bool + default: true + state: + description: + - C(merged): Add/update listed rule sets and rules. + - C(replaced): Replace listed rule sets entirely. + - C(overridden): Replace the entire firewall rule config. + - C(deleted): Remove listed (or all) rule sets. + - C(gathered): Read firewall rule config from device. + type: str + choices: [merged, replaced, overridden, deleted, gathered] + default: merged + hostname: + description: IP address or FQDN of the VyOS device. + type: str + required: true + port: + description: HTTPS port for the REST API. + type: int + default: 443 + api_key: + description: API key configured on the device. + type: str + required: true + no_log: true + timeout: + description: Request timeout in seconds. + type: int + default: 30 + verify_ssl: + description: Validate the device's TLS certificate. + type: bool + default: false +requirements: + - VyOS 1.3+ +seealso: + - module: vyos.vyos.vyos_firewall_rules +examples: | + - name: Create an IPv4 firewall rule set + vyos.rest.vyos_firewall_rules: + hostname: 192.168.1.1 + api_key: MY-KEY + config: + - afi: ipv4 + rule_sets: + - name: OUTSIDE-IN + default_action: drop + rules: + - number: 10 + action: accept + protocol: tcp + destination: + port: "80,443" + state: + established: true + related: true + state: merged + + - name: Delete all firewall rules + vyos.rest.vyos_firewall_rules: + hostname: 192.168.1.1 + api_key: MY-KEY + state: deleted +""" + +RETURN = r""" +before: + description: Firewall rule config before the module ran. + returned: always + type: list +after: + description: Firewall rule config after the module ran. + returned: when changed + type: list +gathered: + description: Firewall config read from device (state=gathered). + returned: when state is gathered + type: list +commands: + description: set/delete commands issued. + returned: always + type: list +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import ( + VYOS_REST_CONNECTION_ARGSPEC, + VyOSRestClient, + VyOSRestError, +) + + +_FW_PATH = { + "ipv4": ["firewall", "name"], + "ipv6": ["firewall", "ipv6-name"], +} + + +def _get_fw_rules(client): + try: + result = client.retrieve_show_config(["firewall"]) + data = result.get("data") or {} + out = [] + for afi, key in [("ipv4", "name"), ("ipv6", "ipv6-name")]: + sets_data = data.get(key, {}) + if not isinstance(sets_data, dict): + continue + rule_sets = [] + for rs_name, rs_data in sets_data.items(): + if not isinstance(rs_data, dict): + continue + rs = { + "name": rs_name, + "default_action": rs_data.get("default-action", "drop"), + } + if "description" in rs_data: + rs["description"] = rs_data["description"] + rules = [] + for rnum, rdata in (rs_data.get("rule") or {}).items(): + if not isinstance(rdata, dict): + continue + rule = {"number": int(rnum)} + if "action" in rdata: + rule["action"] = rdata["action"] + if "description" in rdata: + rule["description"] = rdata["description"] + if "protocol" in rdata: + rule["protocol"] = rdata["protocol"] + rule["enabled"] = "disable" not in rdata + rules.append(rule) + if rules: + rs["rules"] = sorted(rules, key=lambda r: r["number"]) + rule_sets.append(rs) + if rule_sets: + out.append({"afi": afi, "rule_sets": rule_sets}) + return out + except VyOSRestError: + return [] + + +def _apply_rule_set(client, afi, rs, commands): + base = _FW_PATH[afi] + [rs["name"]] + client.configure_set(base) + commands.append("set {p}".format(p=" ".join(base))) + + if rs.get("default_action"): + client.configure_set(base + ["default-action"], rs["default_action"]) + commands.append( + "set {p} default-action {a}".format( + p=" ".join(base), + a=rs["default_action"], + ), + ) + if rs.get("description"): + client.configure_set(base + ["description"], rs["description"]) + commands.append( + "set {p} description '{d}'".format( + p=" ".join(base), + d=rs["description"], + ), + ) + + for rule in rs.get("rules") or []: + rbase = base + ["rule", str(rule["number"])] + client.configure_set(rbase) + commands.append("set {p}".format(p=" ".join(rbase))) + + if rule.get("action"): + client.configure_set(rbase + ["action"], rule["action"]) + commands.append( + "set {p} action {a}".format(p=" ".join(rbase), a=rule["action"]), + ) + if rule.get("description"): + client.configure_set(rbase + ["description"], rule["description"]) + if rule.get("protocol"): + client.configure_set(rbase + ["protocol"], rule["protocol"]) + commands.append( + "set {p} protocol {pr}".format( + p=" ".join(rbase), + pr=rule["protocol"], + ), + ) + for direction in ("source", "destination"): + match = rule.get(direction) + if not match: + continue + mbase = rbase + [direction] + if match.get("address"): + client.configure_set(mbase + ["address"], match["address"]) + commands.append( + "set {p} address '{a}'".format( + p=" ".join(mbase), + a=match["address"], + ), + ) + if match.get("port"): + client.configure_set(mbase + ["port"], match["port"]) + commands.append( + "set {p} port {pt}".format( + p=" ".join(mbase), + pt=match["port"], + ), + ) + + state_match = rule.get("state") + if state_match: + sbase = rbase + ["state"] + for st_name in ("established", "new", "related", "invalid"): + if state_match.get(st_name): + client.configure_set(sbase + [st_name], "enable") + commands.append( + "set {p} {s} enable".format( + p=" ".join(sbase), + s=st_name, + ), + ) + + if "enabled" in rule and not rule["enabled"]: + client.configure_set(rbase + ["disable"]) + commands.append("set {p} disable".format(p=" ".join(rbase))) + + +def main(): + rule_spec = dict( + number=dict(type="int", required=True), + action=dict(type="str", choices=["drop", "reject", "accept", "inspect"]), + description=dict(type="str"), + protocol=dict(type="str"), + source=dict( + type="dict", + options=dict( + address=dict(type="str"), + port=dict(type="str"), + group=dict( + type="dict", + options=dict( + address_group=dict(type="str"), + network_group=dict(type="str"), + ), + ), + ), + ), + destination=dict( + type="dict", + options=dict( + address=dict(type="str"), + port=dict(type="str"), + group=dict( + type="dict", + options=dict( + address_group=dict(type="str"), + network_group=dict(type="str"), + ), + ), + ), + ), + state=dict( + type="dict", + options=dict( + established=dict(type="bool"), + new=dict(type="bool"), + related=dict(type="bool"), + invalid=dict(type="bool"), + ), + ), + enabled=dict(type="bool", default=True), + ) + + argument_spec = dict( + config=dict( + type="list", + elements="dict", + options=dict( + afi=dict(type="str", required=True, choices=["ipv4", "ipv6"]), + rule_sets=dict( + type="list", + elements="dict", + options=dict( + name=dict(type="str", required=True), + default_action=dict( + type="str", + choices=["drop", "reject", "accept"], + default="drop", + ), + description=dict(type="str"), + rules=dict(type="list", elements="dict", options=rule_spec), + ), + ), + ), + ), + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "overridden", "deleted", "gathered"], + ), + ) + argument_spec.update(VYOS_REST_CONNECTION_ARGSPEC) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + client = VyOSRestClient(module) + state = module.params["state"] + config = module.params.get("config") or [] + commands = [] + changed = False + + before = _get_fw_rules(client) + + if state == "gathered": + module.exit_json(changed=False, gathered=before, before=before, commands=[]) + + if module.check_mode: + module.exit_json(changed=True, before=before, commands=["(check mode)"]) + + try: + if state in ("deleted", "overridden") and not config: + for afi_key in ("name", "ipv6-name"): + try: + client.configure_delete(["firewall", afi_key]) + commands.append( + "delete firewall {k}".format(k=afi_key), + ) + except VyOSRestError: + pass + changed = True + + elif state == "deleted" and config: + for entry in config: + afi = entry["afi"] + base_path = _FW_PATH[afi] + for rs in entry.get("rule_sets") or []: + try: + client.configure_delete(base_path + [rs["name"]]) + commands.append( + "delete {p} {n}".format( + p=" ".join(base_path), + n=rs["name"], + ), + ) + changed = True + except VyOSRestError: + pass + + elif state in ("merged", "replaced", "overridden"): + if state == "replaced": + for entry in config: + afi = entry["afi"] + base_path = _FW_PATH[afi] + for rs in entry.get("rule_sets") or []: + try: + client.configure_delete(base_path + [rs["name"]]) + commands.append( + "delete {p} {n}".format( + p=" ".join(base_path), + n=rs["name"], + ), + ) + except VyOSRestError: + pass + for entry in config: + afi = entry["afi"] + for rs in entry.get("rule_sets") or []: + _apply_rule_set(client, afi, rs, commands) + changed = True + except VyOSRestError as exc: + module.fail_json(msg=str(exc)) + + after = _get_fw_rules(client) if changed else before + module.exit_json(changed=changed, before=before, after=after, commands=commands) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/vyos_generate.py b/plugins/modules/vyos_generate.py new file mode 100644 index 0000000..448575a --- /dev/null +++ b/plugins/modules/vyos_generate.py @@ -0,0 +1,102 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# 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_generate +short_description: Execute generate commands on a VyOS device via REST API. +description: + - Sends a C(generate) operational command to a VyOS device via the HTTPS + REST API (C(/generate) endpoint). + - Commonly used to generate WireGuard keypairs, TLS certificates, etc. +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + path: + description: + - Generate command tokens. + - E.g. C(["wireguard", "default-keypair"]). + type: list + elements: str + required: true + hostname: + description: IP address or FQDN of the VyOS device. + type: str + required: true + port: + description: HTTPS port for the REST API. + type: int + default: 443 + api_key: + description: API key configured on the device. + type: str + required: true + no_log: true + timeout: + description: Request timeout in seconds. + type: int + default: 30 + verify_ssl: + description: Validate the device's TLS certificate. + type: bool + default: false +requirements: + - VyOS 1.3+ +examples: | + - name: Generate WireGuard default keypair + vyos.rest.vyos_generate: + hostname: 192.168.1.1 + api_key: MY-KEY + path: ["wireguard", "default-keypair"] + + - name: Generate a new SSH host key + vyos.rest.vyos_generate: + hostname: 192.168.1.1 + api_key: MY-KEY + path: ["ssh-server", "host-key", "rsa"] +""" + +RETURN = r""" +output: + description: Text output produced by the generate command (may be empty). + returned: success + type: str +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import ( + VYOS_REST_CONNECTION_ARGSPEC, + VyOSRestClient, + VyOSRestError, +) + + +def main(): + argument_spec = dict( + path=dict(type="list", elements="str", required=True), + ) + argument_spec.update(VYOS_REST_CONNECTION_ARGSPEC) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=False, + ) + + client = VyOSRestClient(module) + try: + result = client.generate(module.params["path"]) + except VyOSRestError as exc: + module.fail_json(msg=str(exc)) + + module.exit_json(changed=True, output=result.get("data", "")) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/vyos_hostname.py b/plugins/modules/vyos_hostname.py index 89f5cec..10255b5 100644 --- a/plugins/modules/vyos_hostname.py +++ b/plugins/modules/vyos_hostname.py @@ -1,310 +1,187 @@ #!/usr/bin/python +# -*- coding: utf-8 -*- +# 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 -from ansible.module_utils.basic import AnsibleModule -from ansible_collections.vyos.rest.plugins.module_utils.vyos import VyOSModule +__metaclass__ = type DOCUMENTATION = r""" --- module: vyos_hostname -short_description: Manage the hostname of a VyOS device using REST API +short_description: Manage the system hostname on a VyOS device via the REST API. description: - - Configures the system hostname on VyOS network devices via the REST API. - - Supports idempotent configuration — no change is made if the desired - hostname already matches the running configuration. - - Uses REST API (C(connection=httpapi)) instead of CLI. + - Manages the C(set system host-name) configuration on a VyOS device + using the HTTPS REST API. + - Mirrors the behaviour of C(vyos.vyos.vyos_hostname) but uses the HTTP + API instead of SSH/network_cli. version_added: "1.0.0" author: - - Your Name (@yourhandle) - + - VyOS Community (@vyos) options: config: description: - - Hostname configuration dictionary. + - Hostname configuration. type: dict suboptions: hostname: description: - - The hostname to set on the VyOS device. - - Must be a valid RFC 1123 hostname. + - System hostname (max 63 characters, no underscores). type: str - + required: true state: description: - - The desired state of the hostname configuration. - - C(merged), C(replaced), and C(overridden) are identical for this - single-value resource — all three ensure the hostname is set to - the value specified in C(config). - - C(deleted) removes the configured hostname, reverting to the - device default. - - C(gathered) retrieves the current hostname from the device and - returns it as structured data in the C(gathered) key. No changes - are made to the device. + - C(merged) - Ensure the hostname is set to the value in I(config). + - C(deleted) - Remove the configured hostname (resets to default). + - C(gathered) - Read the current hostname from the device and return it + in I(gathered) without making changes. type: str + choices: [merged, deleted, gathered] default: merged - choices: - - merged - - replaced - - overridden - - deleted - - gathered - -notes: - - Tested against VyOS 1.3 (equuleus) and 1.4 (sagitta). - - Requires C(ansible_connection=httpapi) with the VyOS httpapi plugin. - - The C(ansible_network_os) inventory variable must be set to C(vyos.rest.vyos). -""" - -EXAMPLES = r""" -# Before state: -# ------------- -# vyos@router:~$ show configuration commands | grep host-name -# set system host-name 'vyostest' - -- name: Set hostname using merged state - vyos.rest.vyos_hostname: - config: - hostname: vyos - state: merged - -# After state: -# ------------ -# set system host-name 'vyos' - -# ------------------------------------------------------------------------ - -# Before state: -# ------------- -# set system host-name 'vyos' - -- name: Override hostname (identical behaviour to merged for this resource) - vyos.rest.vyos_hostname: - config: - hostname: vyosTest - state: overridden - -# After state: -# ------------ -# set system host-name 'vyosTest' - -# ------------------------------------------------------------------------ - -# Before state: -# ------------- -# set system host-name 'vyos' - -- name: Replace hostname - vyos.rest.vyos_hostname: - config: - hostname: vyosRouter - state: replaced - -# After state: -# ------------ -# set system host-name 'vyosRouter' - -# ------------------------------------------------------------------------ - -# Before state: -# ------------- -# set system host-name 'vyos' - -- name: Delete configured hostname - vyos.rest.vyos_hostname: - state: deleted - -# After state: -# ------------ -# (no host-name entry — device reverts to default) - -# ------------------------------------------------------------------------ - -- name: Gather current hostname from device - vyos.rest.vyos_hostname: - state: gathered - -# Module result: -# -------------- -# "gathered": { -# "hostname": "vyos" -# } - -# ------------------------------------------------------------------------ - -- name: Idempotency — no change when hostname already matches - vyos.rest.vyos_hostname: - config: - hostname: vyos - state: merged - -# Module result (hostname already 'vyos'): -# ---------------------------------------- -# "changed": false + hostname: + description: + - IP address or FQDN of the VyOS device. + type: str + required: true + port: + description: + - HTTPS port for the REST API. + type: int + default: 443 + api_key: + description: + - API key configured on the device. + type: str + required: true + no_log: true + timeout: + description: + - Request timeout in seconds. + type: int + default: 30 + verify_ssl: + description: + - Validate the device's TLS certificate. + type: bool + default: false +requirements: + - VyOS 1.3+ +seealso: + - module: vyos.vyos.vyos_hostname """ RETURN = r""" before: - description: Hostname configuration on the device before this module ran. - returned: when I(state) is C(merged), C(replaced), C(overridden) or C(deleted) + description: Configuration on the device before the module ran. + returned: always type: dict - sample: - hostname: vyostest - after: - description: Hostname configuration on the device after this module ran. + description: Configuration on the device after the module ran. returned: when changed type: dict - sample: - hostname: vyos - -commands: - description: List of API command dicts sent to the device. - returned: when I(state) is C(merged), C(replaced), C(overridden) or C(deleted) - type: list - sample: - - op: set - path: ["system", "host-name", "vyos"] - gathered: - description: > - Current hostname configuration retrieved from the device as structured - data. Returned only when I(state) is C(gathered). - returned: when I(state) is C(gathered) - type: dict - sample: - hostname: vyos - -response: - description: Raw response returned by the VyOS REST API. - returned: when changes are applied - type: dict - sample: - success: true - data: null - error: null - -saved: - description: Result of the save_config call issued after applying changes. - returned: when changes are applied + description: Hostname read from the device (state=gathered only). + returned: when state is gathered type: dict +commands: + description: REST API commands dispatched. + returned: always + type: list """ -def get_running_config(vyos): - - raw = vyos.get_config(["system", "host-name"]) - - hostname = None - - if isinstance(raw, dict): - hostname = raw.get("host-name") - - elif isinstance(raw, str): - hostname = raw.strip() - else: - hostname = None - - return {"hostname": hostname} - - -def build_commands(want, have, state): +EXAMPLES = r""" +- name: Set hostname + vyos.rest.vyos_hostname: + config: + hostname: vyos-core-01 + state: merged - commands = [] +- name: Gather current hostname + vyos.rest.vyos_hostname: + state: gathered + register: result - want_host = want.get("hostname") - have_host = have.get("hostname") +- name: Delete hostname configuration + vyos.rest.vyos_hostname: + state: deleted +""" - if state in ["merged", "replaced", "overridden"]: +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import ( + VYOS_REST_CONNECTION_ARGSPEC, + VyOSRestClient, + VyOSRestError, +) - if want_host and want_host != have_host: - commands.append( - { - "op": "set", - "path": ["system", "host-name", want_host], - }, - ) - elif state == "deleted": +_PATH = ["system", "host-name"] - if have_host: - commands.append( - { - "op": "delete", - "path": ["system", "host-name"], - }, - ) - return commands +def _get_hostname(client): + try: + result = client.retrieve_return_value(_PATH) + return result.get("data", "") + except VyOSRestError: + return "" def main(): - argument_spec = dict( config=dict( type="dict", options=dict( - hostname=dict(type="str"), + hostname=dict(type="str", required=True), ), ), state=dict( + type="str", default="merged", - choices=[ - "merged", - "replaced", - "overridden", - "deleted", - "gathered", - ], + choices=["merged", "deleted", "gathered"], ), ) + argument_spec.update(VYOS_REST_CONNECTION_ARGSPEC) module = AnsibleModule( argument_spec=argument_spec, + required_if=[("state", "merged", ["config"])], supports_check_mode=True, ) - vyos = VyOSModule(module) - + client = VyOSRestClient(module) state = module.params["state"] - want = module.params.get("config") or {} + commands = [] + changed = False - have = get_running_config(vyos) + current = _get_hostname(client) + before = {"hostname": current} if state == "gathered": - module.exit_json( - changed=False, - gathered=have, - ) - - commands = build_commands(want, have, state) + module.exit_json(changed=False, gathered=before, before=before, commands=[]) if module.check_mode: - module.exit_json( - changed=bool(commands), - commands=commands, - ) - - if commands: - - response = vyos.apply_commands(commands) - - # save config if change applied - 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, - ) + module.exit_json(changed=True, before=before, commands=["(check mode)"]) + + try: + if state == "merged": + desired = module.params["config"]["hostname"] + if current != desired: + client.configure_set(_PATH, desired) + commands.append( + "set system host-name '{h}'".format(h=desired), + ) + changed = True + elif state == "deleted": + if current: + client.configure_delete(_PATH) + commands.append("delete system host-name") + changed = True + except VyOSRestError as exc: + module.fail_json(msg=str(exc)) + + after = {"hostname": _get_hostname(client)} if changed else before + module.exit_json(changed=changed, before=before, after=after, commands=commands) if __name__ == "__main__": diff --git a/plugins/modules/vyos_image.py b/plugins/modules/vyos_image.py new file mode 100644 index 0000000..be12705 --- /dev/null +++ b/plugins/modules/vyos_image.py @@ -0,0 +1,131 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# 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_image +short_description: Manage VyOS system images via the REST API. +description: + - Adds or removes VyOS system images using the HTTPS REST API + (C(/image) endpoint). + - Use I(state=present) to download and install an image from a URL. + - Use I(state=absent) to delete an installed image by name. +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + state: + description: + - C(present): Download and install the image from I(url). + - C(absent): Delete the image specified by I(name). + type: str + choices: [present, absent] + required: true + url: + description: + - HTTP(S) URL of the VyOS C(.iso) file to install. + - Required when I(state=present). + type: str + name: + description: + - Version name of the image to remove (e.g. C(1.4-rolling-202401010000)). + - Required when I(state=absent). + type: str + hostname: + description: IP address or FQDN of the VyOS device. + type: str + required: true + port: + description: HTTPS port for the REST API. + type: int + default: 443 + api_key: + description: API key configured on the device. + type: str + required: true + no_log: true + timeout: + description: > + Request timeout in seconds. Image downloads can take several minutes; + increase this value accordingly. + type: int + default: 300 + verify_ssl: + description: Validate the device's TLS certificate. + type: bool + default: false +requirements: + - VyOS 1.3+ +examples: | + - name: Install the latest rolling release + vyos.rest.vyos_image: + hostname: 192.168.1.1 + api_key: MY-KEY + state: present + url: "https://downloads.vyos.io/rolling/current/amd64/vyos-rolling-latest.iso" + timeout: 600 + + - name: Remove an old image + vyos.rest.vyos_image: + hostname: 192.168.1.1 + api_key: MY-KEY + state: absent + name: "1.4-rolling-202312010000" +""" + +RETURN = r""" +output: + description: Text output from the image add/delete operation. + returned: success + type: str +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import ( + VYOS_REST_CONNECTION_ARGSPEC, + VyOSRestClient, + VyOSRestError, +) + + +def main(): + argument_spec = dict( + state=dict(type="str", required=True, choices=["present", "absent"]), + url=dict(type="str"), + name=dict(type="str"), + ) + argument_spec.update(VYOS_REST_CONNECTION_ARGSPEC) + # Override default timeout to 300s for image downloads + argument_spec["timeout"]["default"] = 300 + + module = AnsibleModule( + argument_spec=argument_spec, + required_if=[ + ("state", "present", ["url"]), + ("state", "absent", ["name"]), + ], + supports_check_mode=False, + ) + + client = VyOSRestClient(module) + state = module.params["state"] + + try: + if state == "present": + result = client.image_add(module.params["url"]) + else: + result = client.image_delete(module.params["name"]) + except VyOSRestError as exc: + module.fail_json(msg=str(exc)) + + module.exit_json(changed=True, output=result.get("data", "")) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/vyos_info.py b/plugins/modules/vyos_info.py new file mode 100644 index 0000000..8946868 --- /dev/null +++ b/plugins/modules/vyos_info.py @@ -0,0 +1,115 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# 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_info +short_description: Retrieve system information from a VyOS device via the REST API. +description: + - Queries the public C(/info) endpoint (HTTP GET) which requires no + authentication and returns the VyOS version, hostname, and a welcome banner. +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + hostname: + description: IP address or FQDN of the VyOS device. + type: str + required: true + port: + description: HTTPS port for the REST API. + type: int + default: 443 + api_key: + description: > + API key. Not required for this module (the /info endpoint is public) + but kept in the spec for consistency with other modules in this collection. + type: str + no_log: true + timeout: + description: Request timeout in seconds. + type: int + default: 10 + verify_ssl: + description: Validate the device's TLS certificate. + type: bool + default: false +requirements: + - VyOS 1.3+ +examples: | + - name: Get VyOS system info + vyos.rest.vyos_info: + hostname: 192.168.1.1 + register: sys_info + + - name: Print VyOS version + ansible.builtin.debug: + msg: "Running VyOS {{ sys_info.version }}" +""" + +RETURN = r""" +version: + description: VyOS version string. + returned: success + type: str + sample: "1.4-rolling-202401010000" +hostname: + description: System hostname. + returned: success + type: str + sample: vyos-router +banner: + description: Welcome banner text. + returned: success + type: str + sample: "Welcome to VyOS" +info: + description: Full info dict as returned by the device. + returned: success + type: dict +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import ( + VYOS_REST_CONNECTION_ARGSPEC, + VyOSRestClient, + VyOSRestError, +) + + +def main(): + argument_spec = dict() + argument_spec.update(VYOS_REST_CONNECTION_ARGSPEC) + # api_key not strictly required for /info + argument_spec["api_key"]["required"] = False + argument_spec["api_key"]["default"] = "" + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + client = VyOSRestClient(module) + try: + result = client.info() + except VyOSRestError as exc: + module.fail_json(msg=str(exc)) + + data = result.get("data", {}) + module.exit_json( + changed=False, + version=data.get("version", ""), + hostname=data.get("hostname", ""), + banner=data.get("banner", ""), + info=data, + ) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/vyos_interfaces.py b/plugins/modules/vyos_interfaces.py new file mode 100644 index 0000000..9edc1da --- /dev/null +++ b/plugins/modules/vyos_interfaces.py @@ -0,0 +1,401 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# 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_interfaces +short_description: Manage interface attributes on a VyOS device via the REST API. +description: + - Manages interface base attributes (description, MTU, enabled state, VIFs, + duplex, speed) on VyOS devices using the HTTPS REST API. + - Mirrors C(vyos.vyos.vyos_interfaces) but uses HTTP API. + - Supports Ethernet, Bonding, VXLAN, Loopback, and Virtual Tunnel interfaces. +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + config: + description: List of interface configurations. + type: list + elements: dict + suboptions: + name: + description: > + Full interface name (e.g. C(eth0), C(bond0), C(vti1), C(vxlan2)). + type: str + required: true + description: + description: Interface description. + type: str + enabled: + description: > + Administrative state. C(true) = up, C(false) = admin-down. + type: bool + default: true + mtu: + description: MTU in bytes. Applicable for Ethernet, Bonding, VXLAN, VTI. + type: int + duplex: + description: Duplex mode. Only for Ethernet interfaces. + type: str + choices: [auto, full, half] + speed: + description: Link speed. Only for Ethernet interfaces. + type: str + vifs: + description: List of VLAN sub-interface configurations. + type: list + elements: dict + suboptions: + vlan_id: + description: 802.1Q VLAN ID. + type: int + required: true + description: + description: VIF description. + type: str + enabled: + description: Administrative state of the VIF. + type: bool + default: true + state: + description: + - C(merged): Merge the provided config with existing interface config. + - C(replaced): Replace per-interface config with provided values. + - C(overridden): Override all interface config with provided values. + - C(deleted): Delete interface config for listed interfaces (or all). + - C(gathered): Read current interface config from device. + type: str + choices: [merged, replaced, overridden, deleted, gathered] + default: merged + hostname: + description: IP address or FQDN of the VyOS device. + type: str + required: true + port: + description: HTTPS port for the REST API. + type: int + default: 443 + api_key: + description: API key configured on the device. + type: str + required: true + no_log: true + timeout: + description: Request timeout in seconds. + type: int + default: 30 + verify_ssl: + description: Validate the device's TLS certificate. + type: bool + default: false +requirements: + - VyOS 1.3+ +seealso: + - module: vyos.vyos.vyos_interfaces + - module: vyos.rest.vyos_l3_interfaces +examples: | + - name: Set description and MTU on eth1 + vyos.rest.vyos_interfaces: + hostname: 192.168.1.1 + api_key: MY-KEY + config: + - name: eth1 + description: "WAN uplink" + mtu: 1500 + enabled: true + state: merged + + - name: Disable eth2 + vyos.rest.vyos_interfaces: + hostname: 192.168.1.1 + api_key: MY-KEY + config: + - name: eth2 + enabled: false + state: merged + + - name: Create a VLAN sub-interface + vyos.rest.vyos_interfaces: + hostname: 192.168.1.1 + api_key: MY-KEY + config: + - name: eth1 + vifs: + - vlan_id: 100 + description: "VLAN 100" + state: merged +""" + +RETURN = r""" +before: + description: Interface configuration before the module ran. + returned: always + type: list +after: + description: Interface configuration after the module ran. + returned: when changed + type: list +gathered: + description: Interface config read from device (state=gathered). + returned: when state is gathered + type: list +commands: + description: REST API set/delete commands issued. + returned: always + type: list +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import ( + VYOS_REST_CONNECTION_ARGSPEC, + VyOSRestClient, + VyOSRestError, +) + + +_IFACE_TYPES = { + "eth": "ethernet", + "bond": "bonding", + "vti": "vti", + "vxlan": "vxlan", + "lo": "loopback", + "dummy": "dummy", + "peth": "pseudo-ethernet", + "vtun": "openvpn", + "wg": "wireguard", + "br": "bridge", + "macsec": "macsec", + "tun": "tunnel", +} + + +def _iface_type(name): + """Infer the VyOS interface type from its name prefix.""" + for prefix, itype in _IFACE_TYPES.items(): + if name.startswith(prefix): + return itype + return "ethernet" + + +def _iface_base_path(name): + return ["interfaces", _iface_type(name), name] + + +def _get_interfaces(client): + try: + result = client.retrieve_show_config(["interfaces"]) + ifaces_raw = result.get("data") or {} + interfaces = [] + for itype, itype_data in ifaces_raw.items(): + if not isinstance(itype_data, dict): + continue + for iname, idata in itype_data.items(): + if not isinstance(idata, dict): + continue + entry = {"name": iname} + if "description" in idata: + entry["description"] = idata["description"] + if "mtu" in idata: + entry["mtu"] = int(idata["mtu"]) + entry["enabled"] = "disable" not in idata + if "duplex" in idata: + entry["duplex"] = idata["duplex"] + if "speed" in idata: + entry["speed"] = idata["speed"] + if "vif" in idata: + vifs = [] + for vid, vdata in idata["vif"].items(): + vif_entry = {"vlan_id": int(vid)} + if isinstance(vdata, dict): + if "description" in vdata: + vif_entry["description"] = vdata["description"] + vif_entry["enabled"] = "disable" not in vdata + vifs.append(vif_entry) + entry["vifs"] = vifs + interfaces.append(entry) + return interfaces + except VyOSRestError: + return [] + + +def _apply_interface(client, iface_cfg, commands): + name = iface_cfg["name"] + base = _iface_base_path(name) + + if iface_cfg.get("description") is not None: + client.configure_set(base + ["description"], iface_cfg["description"]) + commands.append( + "set interfaces {t} {n} description '{d}'".format( + t=_iface_type(name), + n=name, + d=iface_cfg["description"], + ), + ) + if iface_cfg.get("mtu") is not None: + client.configure_set(base + ["mtu"], str(iface_cfg["mtu"])) + commands.append( + "set interfaces {t} {n} mtu {m}".format( + t=_iface_type(name), + n=name, + m=iface_cfg["mtu"], + ), + ) + if "enabled" in iface_cfg: + if not iface_cfg["enabled"]: + client.configure_set(base + ["disable"]) + commands.append( + "set interfaces {t} {n} disable".format(t=_iface_type(name), n=name), + ) + else: + try: + client.configure_delete(base + ["disable"]) + commands.append( + "delete interfaces {t} {n} disable".format( + t=_iface_type(name), + n=name, + ), + ) + except VyOSRestError: + pass + if iface_cfg.get("duplex"): + client.configure_set(base + ["duplex"], iface_cfg["duplex"]) + commands.append( + "set interfaces {t} {n} duplex {d}".format( + t=_iface_type(name), + n=name, + d=iface_cfg["duplex"], + ), + ) + if iface_cfg.get("speed"): + client.configure_set(base + ["speed"], iface_cfg["speed"]) + commands.append( + "set interfaces {t} {n} speed {s}".format( + t=_iface_type(name), + n=name, + s=iface_cfg["speed"], + ), + ) + for vif in iface_cfg.get("vifs") or []: + vid = str(vif["vlan_id"]) + vif_base = base + ["vif", vid] + client.configure_set(vif_base) + commands.append( + "set interfaces {t} {n} vif {v}".format( + t=_iface_type(name), + n=name, + v=vid, + ), + ) + if vif.get("description"): + client.configure_set(vif_base + ["description"], vif["description"]) + commands.append( + "set interfaces {t} {n} vif {v} description '{d}'".format( + t=_iface_type(name), + n=name, + v=vid, + d=vif["description"], + ), + ) + if "enabled" in vif and not vif["enabled"]: + client.configure_set(vif_base + ["disable"]) + commands.append( + "set interfaces {t} {n} vif {v} disable".format( + t=_iface_type(name), + n=name, + v=vid, + ), + ) + + +def main(): + argument_spec = dict( + config=dict( + type="list", + elements="dict", + options=dict( + name=dict(type="str", required=True), + description=dict(type="str"), + enabled=dict(type="bool", default=True), + mtu=dict(type="int"), + duplex=dict(type="str", choices=["auto", "full", "half"]), + speed=dict(type="str"), + vifs=dict( + type="list", + elements="dict", + options=dict( + vlan_id=dict(type="int", required=True), + description=dict(type="str"), + enabled=dict(type="bool", default=True), + ), + ), + ), + ), + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "overridden", "deleted", "gathered"], + ), + ) + argument_spec.update(VYOS_REST_CONNECTION_ARGSPEC) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + client = VyOSRestClient(module) + state = module.params["state"] + config = module.params.get("config") or [] + commands = [] + changed = False + + before = _get_interfaces(client) + + if state == "gathered": + module.exit_json(changed=False, gathered=before, before=before, commands=[]) + + if module.check_mode: + module.exit_json(changed=True, before=before, commands=["(check mode)"]) + + try: + if state == "deleted": + targets = {i["name"] for i in config} if config else {i["name"] for i in before} + for iface in before: + if iface["name"] in targets: + base = _iface_base_path(iface["name"]) + # Only delete mutable attributes, not the interface itself + for attr in ["description", "mtu", "duplex", "speed"]: + if attr in iface: + try: + client.configure_delete(base + [attr]) + commands.append( + "delete interfaces {t} {n} {a}".format( + t=_iface_type(iface["name"]), + n=iface["name"], + a=attr, + ), + ) + except VyOSRestError: + pass + changed = True + + elif state in ("merged", "replaced", "overridden"): + for iface_cfg in config: + _apply_interface(client, iface_cfg, commands) + changed = True + except VyOSRestError as exc: + module.fail_json(msg=str(exc)) + + after = _get_interfaces(client) if changed else before + module.exit_json(changed=changed, before=before, after=after, commands=commands) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/vyos_l3_interfaces.py b/plugins/modules/vyos_l3_interfaces.py new file mode 100644 index 0000000..f6d4e6c --- /dev/null +++ b/plugins/modules/vyos_l3_interfaces.py @@ -0,0 +1,381 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# 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_l3_interfaces +short_description: Manage L3 interface attributes on VyOS via the REST API. +description: + - Manages IPv4 and IPv6 addresses on VyOS interfaces via the HTTPS REST API. + - Mirrors C(vyos.vyos.vyos_l3_interfaces) but uses the HTTP API. +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + config: + description: List of L3 interface configurations. + type: list + elements: dict + suboptions: + name: + description: Full interface name (e.g. C(eth0), C(eth1)). + type: str + required: true + ipv4: + description: List of IPv4 address assignments. + type: list + elements: dict + suboptions: + address: + description: IPv4 address in CIDR notation or C(dhcp). + type: str + required: true + ipv6: + description: List of IPv6 address assignments. + type: list + elements: dict + suboptions: + address: + description: IPv6 address in CIDR notation or C(dhcpv6) or C(autoconf). + type: str + required: true + vifs: + description: VLAN sub-interface L3 settings. + type: list + elements: dict + suboptions: + vlan_id: + description: 802.1Q VLAN ID. + type: int + required: true + ipv4: + description: IPv4 addresses for this VIF. + type: list + elements: dict + suboptions: + address: + type: str + required: true + ipv6: + description: IPv6 addresses for this VIF. + type: list + elements: dict + suboptions: + address: + type: str + required: true + state: + description: + - C(merged): Add addresses to the interface (preserve existing). + - C(replaced): Replace all addresses on each listed interface. + - C(overridden): Replace all addresses on all interfaces. + - C(deleted): Remove all addresses from listed (or all) interfaces. + - C(gathered): Read L3 config from device without changes. + type: str + choices: [merged, replaced, overridden, deleted, gathered] + default: merged + hostname: + description: IP address or FQDN of the VyOS device. + type: str + required: true + port: + description: HTTPS port for the REST API. + type: int + default: 443 + api_key: + description: API key configured on the device. + type: str + required: true + no_log: true + timeout: + description: Request timeout in seconds. + type: int + default: 30 + verify_ssl: + description: Validate the device's TLS certificate. + type: bool + default: false +requirements: + - VyOS 1.3+ +seealso: + - module: vyos.vyos.vyos_l3_interfaces + - module: vyos.rest.vyos_interfaces +examples: | + - name: Assign addresses to eth1 and eth2 + vyos.rest.vyos_l3_interfaces: + hostname: 192.168.1.1 + api_key: MY-KEY + config: + - name: eth1 + ipv4: + - address: 10.0.1.1/24 + ipv6: + - address: "2001:db8::1/64" + - name: eth2 + ipv4: + - address: dhcp + state: merged + + - name: Remove all addresses from eth1 + vyos.rest.vyos_l3_interfaces: + hostname: 192.168.1.1 + api_key: MY-KEY + config: + - name: eth1 + state: deleted +""" + +RETURN = r""" +before: + description: L3 interface config before the module ran. + returned: always + type: list +after: + description: L3 interface config after the module ran. + returned: when changed + type: list +gathered: + description: L3 config read from device (state=gathered). + returned: when state is gathered + type: list +commands: + description: set/delete commands issued. + returned: always + type: list +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import ( + VYOS_REST_CONNECTION_ARGSPEC, + VyOSRestClient, + VyOSRestError, +) + + +_IFACE_TYPES = { + "eth": "ethernet", + "bond": "bonding", + "vti": "vti", + "vxlan": "vxlan", + "lo": "loopback", + "dummy": "dummy", + "br": "bridge", + "wg": "wireguard", + "tun": "tunnel", +} + + +def _iface_type(name): + for prefix, t in _IFACE_TYPES.items(): + if name.startswith(prefix): + return t + return "ethernet" + + +def _base(name): + return ["interfaces", _iface_type(name), name] + + +def _get_l3_interfaces(client): + try: + result = client.retrieve_show_config(["interfaces"]) + raw = result.get("data") or {} + out = [] + for itype, itype_data in raw.items(): + if not isinstance(itype_data, dict): + continue + for iname, idata in itype_data.items(): + if not isinstance(idata, dict): + continue + entry = {"name": iname, "ipv4": [], "ipv6": []} + for addr in _listify(idata.get("address")): + if ":" in addr: + entry["ipv6"].append({"address": addr}) + elif addr == "dhcp": + entry["ipv4"].append({"address": addr}) + else: + entry["ipv4"].append({"address": addr}) + # VIFs + if "vif" in idata: + vifs = [] + for vid, vdata in idata["vif"].items(): + vif_e = {"vlan_id": int(vid), "ipv4": [], "ipv6": []} + if isinstance(vdata, dict): + for addr in _listify(vdata.get("address")): + if ":" in addr: + vif_e["ipv6"].append({"address": addr}) + else: + vif_e["ipv4"].append({"address": addr}) + vifs.append(vif_e) + entry["vifs"] = vifs + if entry["ipv4"] or entry["ipv6"] or entry.get("vifs"): + out.append(entry) + return out + except VyOSRestError: + return [] + + +def _listify(val): + """Return val as a list regardless of whether it's a str or list.""" + if val is None: + return [] + if isinstance(val, list): + return val + return [val] + + +def _set_addresses(client, name, ipv4, ipv6, commands): + base = _base(name) + itype = _iface_type(name) + for a in ipv4 or []: + client.configure_set(base + ["address"], a["address"]) + commands.append( + "set interfaces {t} {n} address '{a}'".format( + t=itype, + n=name, + a=a["address"], + ), + ) + for a in ipv6 or []: + client.configure_set(base + ["address"], a["address"]) + commands.append( + "set interfaces {t} {n} address '{a}'".format( + t=itype, + n=name, + a=a["address"], + ), + ) + + +def _delete_addresses(client, name, commands): + base = _base(name) + itype = _iface_type(name) + try: + client.configure_delete(base + ["address"]) + commands.append( + "delete interfaces {t} {n} address".format(t=itype, n=name), + ) + except VyOSRestError: + pass + + +def main(): + argument_spec = dict( + config=dict( + type="list", + elements="dict", + options=dict( + name=dict(type="str", required=True), + ipv4=dict( + type="list", + elements="dict", + options=dict(address=dict(type="str", required=True)), + ), + ipv6=dict( + type="list", + elements="dict", + options=dict(address=dict(type="str", required=True)), + ), + vifs=dict( + type="list", + elements="dict", + options=dict( + vlan_id=dict(type="int", required=True), + ipv4=dict( + type="list", + elements="dict", + options=dict(address=dict(type="str", required=True)), + ), + ipv6=dict( + type="list", + elements="dict", + options=dict(address=dict(type="str", required=True)), + ), + ), + ), + ), + ), + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "overridden", "deleted", "gathered"], + ), + ) + argument_spec.update(VYOS_REST_CONNECTION_ARGSPEC) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + client = VyOSRestClient(module) + state = module.params["state"] + config = module.params.get("config") or [] + commands = [] + changed = False + + before = _get_l3_interfaces(client) + + if state == "gathered": + module.exit_json(changed=False, gathered=before, before=before, commands=[]) + + if module.check_mode: + module.exit_json(changed=True, before=before, commands=["(check mode)"]) + + try: + if state == "deleted": + targets = {i["name"] for i in config} if config else {i["name"] for i in before} + for iface in before: + if iface["name"] in targets: + _delete_addresses(client, iface["name"], commands) + changed = True + + elif state in ("merged", "replaced", "overridden"): + if state in ("replaced", "overridden"): + # First remove existing addresses on targeted interfaces + targets = {i["name"] for i in config} + if state == "overridden": + targets = {i["name"] for i in before} + for iface in before: + if iface["name"] in targets: + _delete_addresses(client, iface["name"], commands) + + for iface_cfg in config: + name = iface_cfg["name"] + _set_addresses( + client, + name, + iface_cfg.get("ipv4"), + iface_cfg.get("ipv6"), + commands, + ) + for vif in iface_cfg.get("vifs") or []: + vid = str(vif["vlan_id"]) + base = _base(name) + ["vif", vid] + itype = _iface_type(name) + for a in (vif.get("ipv4") or []) + (vif.get("ipv6") or []): + client.configure_set(base + ["address"], a["address"]) + commands.append( + "set interfaces {t} {n} vif {v} address '{a}'".format( + t=itype, + n=name, + v=vid, + a=a["address"], + ), + ) + changed = True + except VyOSRestError as exc: + module.fail_json(msg=str(exc)) + + after = _get_l3_interfaces(client) if changed else before + module.exit_json(changed=changed, before=before, after=after, commands=commands) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/vyos_lag_interfaces.py b/plugins/modules/vyos_lag_interfaces.py new file mode 100644 index 0000000..15fe1f3 --- /dev/null +++ b/plugins/modules/vyos_lag_interfaces.py @@ -0,0 +1,228 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# GNU General Public License v3.0+ + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: vyos_lag_interfaces +short_description: LAG/bonding interfaces resource module via REST API. +description: + - Manages Link Aggregation Group (LAG/bond) interfaces on VyOS via REST API. +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + config: + description: List of LAG interface configurations. + type: list + elements: dict + suboptions: + name: + description: Bond interface name (e.g. bond0). + type: str + required: true + mode: + description: Bonding mode. + type: str + choices: [802.3ad, active-backup, broadcast, round-robin, + transmit-load-balance, adaptive-load-balance, xor-hash] + members: + description: Member interfaces. + type: list + elements: dict + suboptions: + member: + description: Interface name. + type: str + primary: + description: Primary interface name. + type: str + hash_policy: + description: Transmit hash policy. + type: str + choices: [layer2, layer2+3, layer3+4] + arp_monitor: + description: ARP monitoring settings. + type: dict + suboptions: + interval: + description: Monitoring interval in ms. + type: int + target: + description: Target IP addresses. + type: list + elements: str + state: + description: Desired state. + type: str + choices: [merged, replaced, overridden, deleted, gathered] + default: merged + hostname: + type: str + required: true + port: + type: int + default: 443 + api_key: + type: str + required: true + no_log: true + timeout: + type: int + default: 30 + verify_ssl: + type: bool + default: false +""" + +RETURN = r""" +before: + returned: always + type: list +after: + returned: when changed + type: list +commands: + returned: always + type: list +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import ( + VYOS_REST_CONNECTION_ARGSPEC, + VyOSRestClient, + VyOSRestError, +) + + +_MODE_MAP = { + "802.3ad": "802.3ad", + "active-backup": "active-backup", + "broadcast": "broadcast", + "round-robin": "round-robin", + "transmit-load-balance": "transmit-load-balance", + "adaptive-load-balance": "adaptive-load-balance", + "xor-hash": "xor-hash", +} + + +def _get(client): + try: + r = client.retrieve_show_config(["interfaces", "bonding"]) + data = r.get("data") or {} + out = [] + for bname, bdata in data.items(): + entry = {"name": bname} + if isinstance(bdata, dict): + if "mode" in bdata: + entry["mode"] = bdata["mode"] + if "primary" in bdata: + entry["primary"] = bdata["primary"] + if "hash-policy" in bdata: + entry["hash_policy"] = bdata["hash-policy"] + members = [] + for m in ( + bdata.get("member", {}).keys() if isinstance(bdata.get("member"), dict) else [] + ): + members.append({"member": m}) + if members: + entry["members"] = members + out.append(entry) + return out + except VyOSRestError: + return [] + + +def _apply(client, cfg, commands): + name = cfg["name"] + base = ["interfaces", "bonding", name] + client.configure_set(base) + commands.append("set interfaces bonding {n}".format(n=name)) + + if cfg.get("mode"): + client.configure_set(base + ["mode"], cfg["mode"]) + commands.append("set interfaces bonding {n} mode {m}".format(n=name, m=cfg["mode"])) + if cfg.get("primary"): + client.configure_set(base + ["primary"], cfg["primary"]) + if cfg.get("hash_policy"): + client.configure_set(base + ["hash-policy"], cfg["hash_policy"]) + for m in cfg.get("members") or []: + client.configure_set(["interfaces", "ethernet", m["member"], "bond-group"], name) + commands.append("set interfaces ethernet {m} bond-group {n}".format(m=m["member"], n=name)) + arp = cfg.get("arp_monitor") or {} + if arp.get("interval"): + client.configure_set(base + ["arp-monitor", "interval"], str(arp["interval"])) + for t in arp.get("target") or []: + client.configure_set(base + ["arp-monitor", "target"], t) + + +def main(): + argument_spec = dict( + config=dict( + type="list", + elements="dict", + options=dict( + name=dict(type="str", required=True), + mode=dict(type="str", choices=list(_MODE_MAP.keys())), + members=dict( + type="list", + elements="dict", + options=dict(member=dict(type="str")), + ), + primary=dict(type="str"), + hash_policy=dict(type="str", choices=["layer2", "layer2+3", "layer3+4"]), + arp_monitor=dict( + type="dict", + options=dict( + interval=dict(type="int"), + target=dict(type="list", elements="str"), + ), + ), + ), + ), + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "overridden", "deleted", "gathered"], + ), + ) + argument_spec.update(VYOS_REST_CONNECTION_ARGSPEC) + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + client = VyOSRestClient(module) + state = module.params["state"] + config = module.params.get("config") or [] + commands = [] + changed = False + before = _get(client) + + if state == "gathered": + module.exit_json(changed=False, gathered=before, before=before, commands=[]) + if module.check_mode: + module.exit_json(changed=True, before=before, commands=["(check mode)"]) + + try: + if state == "deleted": + targets = {i["name"] for i in config} if config else {i["name"] for i in before} + for b in before: + if b["name"] in targets: + client.configure_delete(["interfaces", "bonding", b["name"]]) + commands.append("delete interfaces bonding {n}".format(n=b["name"])) + changed = True + elif state in ("merged", "replaced", "overridden"): + for cfg in config: + _apply(client, cfg, commands) + changed = True + except VyOSRestError as exc: + module.fail_json(msg=str(exc)) + + after = _get(client) if changed else before + module.exit_json(changed=changed, before=before, after=after, commands=commands) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/vyos_lldp_global.py b/plugins/modules/vyos_lldp_global.py new file mode 100644 index 0000000..5d8ff46 --- /dev/null +++ b/plugins/modules/vyos_lldp_global.py @@ -0,0 +1,151 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type +DOCUMENTATION = r""" +--- +module: vyos_lldp_global +short_description: Manage LLDP global configuration on VyOS via REST API. +description: + - Manage LLDP global configuration on VyOS via REST API. +version_added: "1.0.0" +author: VyOS Community (@vyos) +options: + config: + type: dict + suboptions: + enable: + type: bool + addresses: + type: list + elements: str + snmp: + description: + - C(enable) or C(disable). + type: str + legacy_protocols: + type: list + elements: str + choices: [cdp, edp, fdp, sonmp] + state: + type: str + default: merged + choices: [merged, replaced, deleted, gathered] +""" +EXAMPLES = r""" +- vyos.rest.vyos_lldp_global: + config: + enable: true + addresses: [192.0.2.17] + snmp: enable + legacy_protocols: [cdp, fdp] + state: merged +""" +RETURN = r""" +before: + returned: always + type: dict +after: + returned: when changed + type: dict +commands: + returned: always + type: list +gathered: + returned: when state is gathered + type: dict +""" +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos import VyOSModule + + +_BASE = ["service", "lldp"] + + +def _get(vyos): + raw = vyos.get_config(_BASE) + if not raw: + return {} + result = {} + if isinstance(raw, dict): + result["enable"] = True + if "management-address" in raw: + v = raw["management-address"] + result["addresses"] = ( + list(v.keys()) if isinstance(v, dict) else ([v] if isinstance(v, str) else list(v)) + ) + if "snmp" in raw: + snmp = raw["snmp"] + result["snmp"] = list(snmp.keys())[0] if isinstance(snmp, dict) else str(snmp) + if "legacy-protocols" in raw: + v = raw["legacy-protocols"] + result["legacy_protocols"] = ( + list(v.keys()) if isinstance(v, dict) else ([v] if isinstance(v, str) else list(v)) + ) + return result + + +def _build(want, have, state): + cmds = [] + if state in ("replaced", "deleted") and have: + cmds.append(("delete", _BASE)) + if state == "deleted": + return cmds + if want.get("enable") is not False: + cmds.append(("set", _BASE)) + for addr in want.get("addresses") or []: + cmds.append(("set", _BASE + ["management-address", addr])) + if want.get("snmp"): + cmds.append(("set", _BASE + ["snmp", want["snmp"]])) + for p in want.get("legacy_protocols") or []: + cmds.append(("set", _BASE + ["legacy-protocols", p])) + return cmds + + +def main(): + module = AnsibleModule( + dict( + config=dict( + type="dict", + options=dict( + enable=dict(type="bool"), + addresses=dict(type="list", elements="str"), + snmp=dict(type="str"), + legacy_protocols=dict( + type="list", + elements="str", + choices=["cdp", "edp", "fdp", "sonmp"], + ), + ), + ), + state=dict(default="merged", choices=["merged", "replaced", "deleted", "gathered"]), + ), + supports_check_mode=True, + ) + vyos = VyOSModule(module) + state = module.params["state"] + config = module.params.get("config") or {} + have = _get(vyos) + if state == "gathered": + module.exit_json(changed=False, gathered=have) + commands = _build(config, 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=_get(vyos), + commands=commands, + saved=saved, + response=response, + ) + module.exit_json(changed=False, before=have, after=have, commands=[]) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/vyos_lldp_interfaces.py b/plugins/modules/vyos_lldp_interfaces.py new file mode 100644 index 0000000..4f012aa --- /dev/null +++ b/plugins/modules/vyos_lldp_interfaces.py @@ -0,0 +1,227 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# GNU General Public License v3.0+ + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: vyos_lldp_interfaces +short_description: LLDP interfaces resource module via REST API. +description: + - Manages per-interface LLDP settings (enable/disable, location) on VyOS + via the HTTPS REST API. +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + config: + description: List of LLDP interface configurations. + type: list + elements: dict + suboptions: + name: + description: Interface name. + type: str + required: true + enable: + description: Enable LLDP on this interface (default true). + type: bool + default: true + location: + description: LLDP-MED location data. + type: dict + suboptions: + coordinate_based: + type: dict + suboptions: + altitude: + type: int + datum: + type: str + choices: [WGS84, NAD83, MLLW] + latitude: + type: str + required: true + longitude: + type: str + required: true + elin: + description: Emergency ELIN number (10-25 digits). + type: str + state: + type: str + choices: [merged, replaced, overridden, deleted, gathered] + default: merged + hostname: + type: str + required: true + port: + type: int + default: 443 + api_key: + type: str + required: true + no_log: true + timeout: + type: int + default: 30 + verify_ssl: + type: bool + default: false +""" + +RETURN = r""" +before: + returned: always + type: list +after: + returned: when changed + type: list +commands: + returned: always + type: list +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import ( + VYOS_REST_CONNECTION_ARGSPEC, + VyOSRestClient, + VyOSRestError, +) + + +_IFACE_TYPES = { + "eth": "ethernet", + "bond": "bonding", + "lo": "loopback", + "dummy": "dummy", + "br": "bridge", +} + + +def _itype(name): + for p, t in _IFACE_TYPES.items(): + if name.startswith(p): + return t + return "ethernet" + + +def _get(client): + try: + r = client.retrieve_show_config(["service", "lldp", "interface"]) + data = r.get("data") or {} + out = [] + for iname, idata in data.items(): + entry = {"name": iname} + if isinstance(idata, dict): + entry["enable"] = "disable" not in idata + out.append(entry) + return out + except VyOSRestError: + return [] + + +def _apply(client, cfg, commands): + name = cfg["name"] + base = ["service", "lldp", "interface", name] + client.configure_set(base) + commands.append("set service lldp interface {n}".format(n=name)) + + if "enable" in cfg and not cfg["enable"]: + client.configure_set(base + ["disable"]) + commands.append("set service lldp interface {n} disable".format(n=name)) + + loc = cfg.get("location") or {} + coord = loc.get("coordinate_based") or {} + if coord: + lbase = base + ["location", "coordinate-based"] + if coord.get("latitude"): + client.configure_set(lbase + ["latitude"], coord["latitude"]) + if coord.get("longitude"): + client.configure_set(lbase + ["longitude"], coord["longitude"]) + if coord.get("altitude"): + client.configure_set(lbase + ["altitude"], str(coord["altitude"])) + if coord.get("datum"): + client.configure_set(lbase + ["datum"], coord["datum"]) + commands.append( + "set service lldp interface {n} location coordinate-based ...".format(n=name), + ) + if loc.get("elin"): + client.configure_set(base + ["location", "elin"], loc["elin"]) + commands.append( + "set service lldp interface {n} location elin {e}".format(n=name, e=loc["elin"]), + ) + + +def main(): + argument_spec = dict( + config=dict( + type="list", + elements="dict", + options=dict( + name=dict(type="str", required=True), + enable=dict(type="bool", default=True), + location=dict( + type="dict", + options=dict( + coordinate_based=dict( + type="dict", + options=dict( + altitude=dict(type="int"), + datum=dict(type="str", choices=["WGS84", "NAD83", "MLLW"]), + latitude=dict(type="str", required=True), + longitude=dict(type="str", required=True), + ), + ), + elin=dict(type="str"), + ), + ), + ), + ), + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "overridden", "deleted", "gathered"], + ), + ) + argument_spec.update(VYOS_REST_CONNECTION_ARGSPEC) + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + client = VyOSRestClient(module) + state = module.params["state"] + config = module.params.get("config") or [] + commands = [] + changed = False + before = _get(client) + + if state == "gathered": + module.exit_json(changed=False, gathered=before, before=before, commands=[]) + if module.check_mode: + module.exit_json(changed=True, before=before, commands=["(check mode)"]) + + try: + if state == "deleted": + for cfg in config if config else before: + name = cfg["name"] if isinstance(cfg, dict) else cfg + try: + client.configure_delete(["service", "lldp", "interface", name]) + commands.append("delete service lldp interface {n}".format(n=name)) + changed = True + except VyOSRestError: + pass + elif state in ("merged", "replaced", "overridden"): + for cfg in config: + _apply(client, cfg, commands) + changed = True + except VyOSRestError as exc: + module.fail_json(msg=str(exc)) + + after = _get(client) if changed else before + module.exit_json(changed=changed, before=before, after=after, commands=commands) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/vyos_logging_global.py b/plugins/modules/vyos_logging_global.py new file mode 100644 index 0000000..be240ff --- /dev/null +++ b/plugins/modules/vyos_logging_global.py @@ -0,0 +1,482 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos import VyOSModule + + +DOCUMENTATION = r""" +--- +module: vyos_logging_global +short_description: Manage syslog configuration on VyOS devices using REST API +description: + - Manages syslog (logging) configuration on VyOS devices via the REST API. + - Supports console, file, host, user, and global logging targets with + per-target facility and severity configuration. + - Uses REST API (C(connection=httpapi)) instead of CLI. +version_added: "1.0.0" +author: + - Sagar Paul (@KB-perByte) + +options: + config: + description: Logging configuration. + type: dict + suboptions: + console: + description: Logging to serial console. + type: dict + suboptions: + facilities: + type: list + elements: dict + suboptions: + facility: + type: str + severity: + type: str + files: + description: Logging to local files. + type: list + elements: dict + suboptions: + path: + type: str + archive: + type: dict + suboptions: + file_num: + type: int + size: + type: int + facilities: + type: list + elements: dict + suboptions: + facility: + type: str + severity: + type: str + global_params: + description: Global syslog parameters (maps to C(system syslog global)). + type: dict + suboptions: + archive: + type: dict + suboptions: + file_num: + type: int + size: + type: int + facilities: + type: list + elements: dict + suboptions: + facility: + type: str + severity: + type: str + marker_interval: + type: int + preserve_fqdn: + type: bool + hosts: + description: Logging to remote syslog hosts. + type: list + elements: dict + suboptions: + hostname: + type: str + port: + type: int + protocol: + type: str + facilities: + type: list + elements: dict + suboptions: + facility: + type: str + severity: + type: str + protocol: + description: Per-facility protocol override. + type: str + users: + description: Logging to local user terminals. + type: list + elements: dict + suboptions: + username: + type: str + facilities: + type: list + elements: dict + suboptions: + facility: + type: str + severity: + type: str + + running_config: + description: Used only with state C(parsed). + type: str + + state: + description: + - Desired state of the logging configuration. + type: str + default: merged + choices: + - merged + - replaced + - overridden + - deleted + - gathered + - rendered + - parsed +""" + +EXAMPLES = r""" +- name: Merge logging configuration + vyos.rest.vyos_logging_global: + config: + console: + facilities: + - facility: local7 + severity: err + files: + - path: logFile + archive: + file_num: 2 + facilities: + - facility: local6 + severity: emerg + hosts: + - hostname: 172.16.0.1 + port: 223 + facilities: + - facility: local7 + severity: all + - facility: all + protocol: udp + users: + - username: vyos + facilities: + - facility: local7 + severity: debug + global_params: + archive: + file_num: 2 + size: 111 + facilities: + - facility: cron + severity: debug + marker_interval: 111 + preserve_fqdn: true + state: merged + +- name: Delete all logging configuration + vyos.rest.vyos_logging_global: + state: deleted + +- name: Gather current logging configuration + vyos.rest.vyos_logging_global: + state: gathered +""" + +RETURN = r""" +before: + description: Logging configuration before this module ran. + returned: when state is merged, replaced, overridden or deleted + type: dict +after: + description: Logging configuration after this module ran. + returned: when changed + type: dict +commands: + description: List of API command tuples sent to the device. + returned: always + type: list +gathered: + description: Current logging configuration from the device. + returned: when state is gathered + type: dict +saved: + description: Result of save_config after applying changes. + returned: when changes are applied + type: dict +""" + + +# ------------------------------------------------------------ +# Normalization +# ------------------------------------------------------------ + + +def normalize_config(cfg): + result = { + "console": {"facilities": {}}, + "global": {"facilities": {}}, + "hosts": {}, + "files": {}, + "users": {}, + } + + for f in cfg.get("console", {}).get("facilities", []): + result["console"]["facilities"][f["facility"]] = f.get("severity") + + gp = cfg.get("global_params", {}) + for f in gp.get("facilities", []): + result["global"]["facilities"][f["facility"]] = f.get("severity") + if gp.get("archive"): + result["global"]["archive"] = gp["archive"] + if gp.get("marker_interval"): + result["global"]["marker_interval"] = gp["marker_interval"] + if gp.get("preserve_fqdn"): + result["global"]["preserve_fqdn"] = True + + for h in cfg.get("hosts", []): + host = {"port": h.get("port"), "facilities": {}} + for f in h.get("facilities", []): + host["facilities"][f["facility"]] = {k: v for k, v in f.items() if k != "facility"} + result["hosts"][h["hostname"]] = host + + for f in cfg.get("files", []): + facilities = {x["facility"]: x.get("severity") for x in f.get("facilities", [])} + result["files"][f["path"]] = { + "archive": f.get("archive"), + "facilities": facilities, + } + + for u in cfg.get("users", []): + result["users"][u["username"]] = { + "facilities": {f["facility"]: f.get("severity") for f in u.get("facilities", [])}, + } + + return result + + +def normalize_running(raw): + result = { + "console": {"facilities": {}}, + "global": {"facilities": {}}, + "hosts": {}, + "files": {}, + "users": {}, + } + + if not raw: + return result + + for f, data in raw.get("console", {}).get("facility", {}).items(): + result["console"]["facilities"][f] = data.get("level") + + g = raw.get("global", {}) + for f, data in g.get("facility", {}).items(): + result["global"]["facilities"][f] = data.get("level") + if "archive" in g: + result["global"]["archive"] = g["archive"] + if "marker" in g and "interval" in g["marker"]: + result["global"]["marker_interval"] = g["marker"]["interval"] + if "preserve-fqdn" in g: + result["global"]["preserve_fqdn"] = True + + for host, data in raw.get("host", {}).items(): + h = {"port": data.get("port"), "facilities": {}} + for f, fd in data.get("facility", {}).items(): + h["facilities"][f] = { + "severity": fd.get("level"), + "protocol": fd.get("protocol"), + } + result["hosts"][host] = h + + for path, data in raw.get("file", {}).items(): + facilities = {} + for f, fd in data.get("facility", {}).items(): + facilities[f] = fd.get("level") + result["files"][path] = { + "archive": data.get("archive"), + "facilities": facilities, + } + + for user, data in raw.get("user", {}).items(): + facilities = {} + for f, fd in data.get("facility", {}).items(): + facilities[f] = fd.get("level") + result["users"][user] = {"facilities": facilities} + + return result + + +# ------------------------------------------------------------ +# Diff helpers +# ------------------------------------------------------------ + + +def diff_facilities(base, want, have, state): + cmds = [] + want_keys = set(want) + have_keys = set(have) + + for f in want_keys: + if f not in have_keys or want[f] != have[f]: + path = base + ["facility", f] + if want[f]: + path += ["level", want[f]] + cmds.append(("set", path)) + + if state in ["replaced", "deleted"]: + for f in have_keys - want_keys: + cmds.append(("delete", base + ["facility", f])) + + return cmds + + +def diff_map(base, want, have, state): + cmds = [] + w = set(want) + h = set(have) + + if state in ["merged", "replaced"]: + for k in w - h: + cmds.append(("set", base + [k])) + + if state in ["replaced", "deleted"]: + for k in h - w: + cmds.append(("delete", base + [k])) + + return cmds + + +# ------------------------------------------------------------ +# Build commands +# ------------------------------------------------------------ + + +def build_commands(want, have, state): + cmds = [] + + if state == "overridden": + cmds.append(("delete", ["system", "syslog"])) + state = "merged" + + cmds += diff_facilities( + ["system", "syslog", "console"], + want["console"]["facilities"], + have["console"]["facilities"], + state, + ) + + cmds += diff_facilities( + ["system", "syslog", "global"], + want["global"]["facilities"], + have["global"]["facilities"], + state, + ) + + cmds += diff_map( + ["system", "syslog", "file"], + want["files"], + have["files"], + state, + ) + + cmds += diff_map( + ["system", "syslog", "host"], + want["hosts"], + have["hosts"], + state, + ) + + cmds += diff_map( + ["system", "syslog", "user"], + want["users"], + have["users"], + state, + ) + + return cmds + + +# ------------------------------------------------------------ +# Running config +# ------------------------------------------------------------ + + +def get_running_config(vyos): + raw = vyos.get_config(["system", "syslog"]) + return normalize_running(raw) + + +# ------------------------------------------------------------ +# Main +# ------------------------------------------------------------ + + +def main(): + argument_spec = dict( + config=dict(type="dict"), + running_config=dict(type="str"), + state=dict( + default="merged", + choices=[ + "merged", + "replaced", + "overridden", + "deleted", + "gathered", + "rendered", + "parsed", + ], + ), + ) + + module = AnsibleModule(argument_spec, supports_check_mode=True) + vyos = VyOSModule(module) + + state = module.params["state"] + config = module.params.get("config") or {} + + if state == "gathered": + module.exit_json(gathered=get_running_config(vyos)) + + want = normalize_config(config) + have = get_running_config(vyos) + + if state == "deleted": + want = { + "console": {"facilities": {}}, + "global": {"facilities": {}}, + "hosts": {}, + "files": {}, + "users": {}, + } + + 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() diff --git a/plugins/modules/vyos_ntp_global.py b/plugins/modules/vyos_ntp_global.py index a13eba5..185dc02 100644 --- a/plugins/modules/vyos_ntp_global.py +++ b/plugins/modules/vyos_ntp_global.py @@ -50,10 +50,6 @@ options: options: description: - Per-server options. - - C(pool) replaces C(dynamic) in VyOS 1.3+. - - C(nts) was added in VyOS 1.4. - - C(ptp) and C(interleave) were added in VyOS 1.5. - - C(preempt) is only available in VyOS 1.3 and earlier. type: list elements: str choices: @@ -69,29 +65,12 @@ options: running_config: description: - Used only with state C(parsed). - - Provide the output of C(show configuration commands | grep ntp) - as a string. The module parses it into structured data and returns - the result in the C(parsed) key. - - No device connection is required for this state. + - Provide the output of C(show configuration commands | grep ntp). type: str state: description: - The desired state of the NTP configuration. - - C(merged) adds or updates the provided configuration without removing - existing entries not mentioned in the task. - - C(replaced) fully replaces the running NTP configuration with the - provided config, removing entries not present in the task. - - C(overridden) deletes all existing allow-client, listen-address, and - server entries then applies the desired config from scratch. - - C(deleted) removes all NTP allow-client, listen-address, and server - entries managed by this module. - - C(gathered) retrieves and returns the current NTP configuration as - structured data. No changes are made to the device. - - C(rendered) returns the CLI commands that would be generated for the - provided config without connecting to the device. - - C(parsed) parses the CLI output provided via C(running_config) into - structured data without connecting to the device. type: str default: merged choices: @@ -102,23 +81,12 @@ options: - gathered - rendered - parsed - -notes: - - Tested against VyOS 1.4 (sagitta) and 1.5. - - Requires C(ansible_connection=httpapi) with the VyOS httpapi plugin. - - C(ansible_network_os) must be set to C(vyos.rest.vyos). - - C(replaced) and C(overridden) differ. C(replaced) performs a surgical diff - removing only entries not in the task. C(overridden) deletes entire subtrees - first then re-applies, which is safer when option ordering matters. - - The C(rendered) and C(parsed) states do not require a device connection. """ EXAMPLES = r""" -# Before state: -# ------------- -# set service ntp server time1.vyos.net -# set service ntp server time2.vyos.net -# set service ntp server time3.vyos.net +- name: Gather current NTP configuration + vyos.rest.vyos_ntp_global: + state: gathered - name: Merge NTP configuration vyos.rest.vyos_ntp_global: @@ -133,17 +101,6 @@ EXAMPLES = r""" - prefer state: merged -# After state: -# ------------ -# set service ntp allow-client address '10.6.6.0/24' -# set service ntp listen-address '10.1.3.1' -# set service ntp server 203.0.113.0 prefer -# set service ntp server time1.vyos.net -# set service ntp server time2.vyos.net -# set service ntp server time3.vyos.net - -# ------------------------------------------------------------------------ - - name: Replace NTP configuration vyos.rest.vyos_ntp_global: config: @@ -157,106 +114,40 @@ EXAMPLES = r""" - prefer state: replaced -# ------------------------------------------------------------------------ - -- name: Override NTP configuration - vyos.rest.vyos_ntp_global: - config: - allow_clients: - - 10.3.3.0/24 - listen_addresses: - - 10.7.8.1 - servers: - - server: server1 - options: - - dynamic - - prefer - - server: server2 - options: - - noselect - - preempt - - server: serv - state: overridden - -# ------------------------------------------------------------------------ - - name: Delete all managed NTP configuration vyos.rest.vyos_ntp_global: state: deleted - -# ------------------------------------------------------------------------ - -- name: Gather current NTP configuration - vyos.rest.vyos_ntp_global: - state: gathered - -# ------------------------------------------------------------------------ - -- name: Render NTP configuration commands offline - vyos.rest.vyos_ntp_global: - config: - allow_clients: - - 10.7.7.0/24 - - 10.8.8.0/24 - listen_addresses: - - 10.7.9.1 - servers: - - server: server7 - - server: server45 - options: - - noselect - - prefer - - pool - state: rendered - -# ------------------------------------------------------------------------ - -- name: Parse NTP configuration from CLI output - vyos.rest.vyos_ntp_global: - running_config: "{{ lookup('file', './ntp_config.cfg') }}" - state: parsed """ RETURN = r""" before: - description: NTP configuration on the device before this module ran. - returned: when I(state) is C(merged), C(replaced), C(overridden) or C(deleted) + description: NTP configuration before this module ran. + returned: when state is merged, replaced, overridden or deleted type: dict - after: description: NTP configuration after this module ran. returned: when changed type: dict - commands: - description: List of API command tuples or dicts sent to the device. + description: List of API commands sent to the device. returned: always type: list - gathered: - description: Current NTP configuration retrieved from the device as structured data. - returned: when I(state) is C(gathered) + description: Current NTP configuration as structured data. + returned: when state is gathered type: dict - rendered: - description: CLI commands generated for the provided configuration (offline, no device needed). - returned: when I(state) is C(rendered) + description: CLI commands generated for the provided config (offline). + returned: when state is rendered type: list - parsed: - description: Structured data parsed from the C(running_config) CLI output. - returned: when I(state) is C(parsed) + description: Structured data parsed from running_config. + returned: when state is parsed type: dict - saved: - description: Result of save_config after applying changes. + description: Whether the config was saved after changes. returned: when changes are applied type: bool - -response: - description: Raw API response from the VyOS REST API. - returned: when changes are applied - type: dict """ from ansible.module_utils.basic import AnsibleModule @@ -264,108 +155,84 @@ from ansible_collections.vyos.rest.plugins.module_utils.utils import normalize_t from ansible_collections.vyos.rest.plugins.module_utils.vyos import VyOSModule -# ------------------------------------------------------------ -# Helpers -# ------------------------------------------------------------ - - def normalize_config(config): - result = { - "allow_clients": sorted(config.get("allow_clients", [])), - "listen_addresses": sorted(config.get("listen_addresses", [])), + "allow_clients": sorted(config.get("allow_clients") or []), + "listen_addresses": sorted(config.get("listen_addresses") or []), "servers": {}, } - - for s in config.get("servers", []): - + for s in config.get("servers") or []: name = s["server"] - - result["servers"][name] = sorted(s.get("options", [])) - + result["servers"][name] = sorted(s.get("options") or []) return result def normalize_servers(value): result = {} - if isinstance(value, dict): for server, data in value.items(): - if isinstance(data, dict): result[server] = sorted(list(data.keys())) - elif isinstance(data, list): result[server] = sorted(data) - elif isinstance(data, str): result[server] = [data] - else: result[server] = [] - elif isinstance(value, list): for server in value: result[server] = [] - elif isinstance(value, str): result[value] = [] - return result def get_running_config(vyos): - raw = vyos.get_config(["service", "ntp"]) - result = { "allow_clients": [], "listen_addresses": [], "servers": {}, } - if not raw: return result - allow_raw = raw.get("allow-client", {}).get("address", []) + # allow-client: handle both VyOS schemas + # 1.4: {"allow-client": {"address": {"10.x.x.x/y": {}}}} + # 1.5+: {"allow-client": {"10.x.x.x/y": {}}} (no address subnode) + allow_raw_outer = raw.get("allow-client", {}) + if "address" in allow_raw_outer: + allow_raw = allow_raw_outer.get("address", []) + else: + allow_raw = allow_raw_outer result["allow_clients"] = sorted(normalize_to_list(allow_raw)) - listen_raw = raw.get("listen-address", []) - result["listen_addresses"] = sorted(normalize_to_list(listen_raw)) - - servers_raw = raw.get("server", {}) - result["servers"] = normalize_servers(servers_raw) - + result["listen_addresses"] = sorted( + normalize_to_list(raw.get("listen-address", [])), + ) + result["servers"] = normalize_servers(raw.get("server", {})) return result def build_commands(desired, existing, state): - cmds = [] - # overridden = delete all then merged if state == "overridden": - if existing["allow_clients"]: - cmds.append(("delete", ["service", "ntp", "allow-clients"])) - + cmds.append(("delete", ["service", "ntp", "allow-client"])) if existing["listen_addresses"]: cmds.append(("delete", ["service", "ntp", "listen-address"])) - if existing["servers"]: cmds.append(("delete", ["service", "ntp", "server"])) - state = "merged" cmds += diff_list( "allow-client", - "address", # singular + "address", desired["allow_clients"], existing["allow_clients"], state, ) - - # listen_addresses cmds += diff_list( "listen-address", None, @@ -373,160 +240,94 @@ def build_commands(desired, existing, state): existing["listen_addresses"], state, ) - - # servers - cmds += diff_servers( - desired["servers"], - existing["servers"], - state, - ) - + cmds += diff_servers(desired["servers"], existing["servers"], state) return cmds def diff_list(node, subnode, desired, existing, state): - cmds = [] - desired = set(desired) existing = set(existing) - if state in ["merged", "replaced"]: - + if state in ("merged", "replaced"): for v in desired - existing: - path = ["service", "ntp", node] - if subnode: path += [subnode, v] else: path += [v] - cmds.append(("set", path)) - if state in ["replaced", "deleted"]: - + if state in ("replaced", "deleted"): for v in existing - desired: - path = ["service", "ntp", node] - if subnode: path += [subnode, v] else: path += [v] - cmds.append(("delete", path)) return cmds def diff_servers(desired, existing, state): - cmds = [] - desired_set = set(desired.keys()) existing_set = set(existing.keys()) - # add/update - if state in ["merged", "replaced"]: - + if state in ("merged", "replaced"): for server in desired_set: - desired_opts = set(desired[server]) existing_opts = set(existing.get(server, [])) - - # add server if server not in existing_set: cmds.append(("set", ["service", "ntp", "server", server])) - - # add options for opt in desired_opts - existing_opts: - - cmds.append( - ("set", ["service", "ntp", "server", server, opt]), - ) - - # remove options (replaced only) + cmds.append(("set", ["service", "ntp", "server", server, opt])) if state == "replaced": - for opt in existing_opts - desired_opts: + cmds.append(("delete", ["service", "ntp", "server", server, opt])) - cmds.append( - ("delete", ["service", "ntp", "server", server, opt]), - ) - - # delete - if state in ["replaced", "deleted"]: - + if state in ("replaced", "deleted"): for server in existing_set - desired_set: - cmds.append(("delete", ["service", "ntp", "server", server])) return cmds def parse_running_config(text): - - result = { - "allow_clients": [], - "listen_addresses": [], - "servers": {}, - } - + result = {"allow_clients": [], "listen_addresses": [], "servers": {}} for line in text.splitlines(): - parts = line.strip().split() - if len(parts) < 4: continue - if parts[3] == "allow-clients": result["allow_clients"].append(parts[-1]) - elif parts[3] == "listen-address": result["listen_addresses"].append(parts[-1]) - elif parts[3] == "server": - server = parts[4] - if server not in result["servers"]: result["servers"][server] = [] - if len(parts) > 5: result["servers"][server].append(parts[5]) - return result def render_commands(config): - cmds = [] - for c in config["allow_clients"]: - cmds.append(f"set service ntp allow-clients address {c}") - + cmds.append("set service ntp allow-client address {c}".format(c=c)) for la in config["listen_addresses"]: - cmds.append(f"set service ntp listen-address {la}") - + cmds.append("set service ntp listen-address {la}".format(la=la)) for server, opts in config["servers"].items(): - if not opts: - cmds.append(f"set service ntp server {server}") - + cmds.append("set service ntp server {s}".format(s=server)) for opt in opts: - cmds.append(f"set service ntp server {server} {opt}") - + cmds.append("set service ntp server {s} {o}".format(s=server, o=opt)) return cmds -# ------------------------------------------------------------ -# Main -# ------------------------------------------------------------ - - def main(): - argument_spec = dict( config=dict( type="dict", @@ -559,93 +360,35 @@ def main(): ) module = AnsibleModule(argument_spec, supports_check_mode=True) - vyos = VyOSModule(module) state = module.params["state"] config = module.params.get("config") or {} - result = { - "changed": False, - } - - # -------------------------------------------------------- - # parsed - # -------------------------------------------------------- - if state == "parsed": - - parsed = parse_running_config(module.params["running_config"]) - - module.exit_json(parsed=parsed) - - # -------------------------------------------------------- - # rendered - # -------------------------------------------------------- + module.exit_json(parsed=parse_running_config(module.params["running_config"])) desired = normalize_config(config) if state == "rendered": - module.exit_json(rendered=render_commands(desired)) - # -------------------------------------------------------- - # gathered - # -------------------------------------------------------- - existing = get_running_config(vyos) if state == "gathered": - module.exit_json(gathered=existing) - # -------------------------------------------------------- - # deleted - # -------------------------------------------------------- - if state == "deleted": - - desired = { - "allow_clients": [], - "listen_addresses": [], - "servers": {}, - } - - # -------------------------------------------------------- - # diff engine - # -------------------------------------------------------- - - # commands = build_commands(desired, existing, state) - - # result["before"] = existing - - # result["commands"] = commands - - # if commands: - - # result["changed"] = True - - # if not module.check_mode: - - # vyos.apply_commands(commands) - - # result["after"] = desired - - # module.exit_json(**result) + desired = {"allow_clients": [], "listen_addresses": [], "servers": {}} commands = build_commands(desired, existing, state) if module.check_mode: - module.exit_json( - changed=bool(commands), - commands=commands, - before=existing, - ) + module.exit_json(changed=bool(commands), commands=commands, before=existing) if commands: response = vyos.apply_commands(commands) saved = vyos.save_config() - module.exit_json( changed=True, before=existing, @@ -655,12 +398,7 @@ def main(): response=response, ) - module.exit_json( - changed=False, - before=existing, - after=existing, - commands=[], - ) + module.exit_json(changed=False, before=existing, after=existing, commands=[]) if __name__ == "__main__": diff --git a/plugins/modules/vyos_ospf_interfaces.py b/plugins/modules/vyos_ospf_interfaces.py new file mode 100644 index 0000000..37876d4 --- /dev/null +++ b/plugins/modules/vyos_ospf_interfaces.py @@ -0,0 +1,290 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# GNU General Public License v3.0+ + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: vyos_ospf_interfaces +short_description: OSPF Interfaces Resource Module via REST API. +description: + - Manages per-interface OSPF parameters (cost, hello/dead timers, authentication, + passive mode, etc.) for both OSPFv2 and OSPFv3 via the VyOS HTTPS REST API. +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + config: + description: List of OSPF interface configurations. + type: list + elements: dict + suboptions: + name: + description: Interface name. + type: str + address_family: + description: OSPF settings per address family. + type: list + elements: dict + suboptions: + afi: + description: Address family (ipv4=OSPFv2, ipv6=OSPFv3). + type: str + choices: [ipv4, ipv6] + required: true + authentication: + type: dict + suboptions: + plaintext_password: + type: str + md5_key: + type: dict + suboptions: + key_id: + type: int + key: + type: str + bandwidth: + type: int + cost: + type: int + dead_interval: + type: int + hello_interval: + type: int + mtu_ignore: + type: bool + network: + type: str + priority: + type: int + retransmit_interval: + type: int + transmit_delay: + type: int + ifmtu: + type: int + instance: + type: str + passive: + type: bool + state: + type: str + choices: [merged, replaced, overridden, deleted, gathered] + default: merged + hostname: + type: str + required: true + port: + type: int + default: 443 + api_key: + type: str + required: true + no_log: true + timeout: + type: int + default: 30 + verify_ssl: + type: bool + default: false +""" + +RETURN = r""" +before: + returned: always + type: list +after: + returned: when changed + type: list +commands: + returned: always + type: list +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import ( + VYOS_REST_CONNECTION_ARGSPEC, + VyOSRestClient, + VyOSRestError, +) + + +_IFACE_TYPES = { + "eth": "ethernet", + "bond": "bonding", + "lo": "loopback", + "dummy": "dummy", + "br": "bridge", + "wg": "wireguard", + "tun": "tunnel", + "vxlan": "vxlan", +} + + +def _itype(name): + for p, t in _IFACE_TYPES.items(): + if name.startswith(p): + return t + return "ethernet" + + +def _ospf_iface_base(name, afi): + itype = _itype(name) + if afi == "ipv4": + return ["interfaces", itype, name, "ip", "ospf"] + return ["interfaces", itype, name, "ipv6", "ospfv3"] + + +def _apply(client, cfg, commands): + name = cfg["name"] + for af in cfg.get("address_family") or []: + afi = af["afi"] + base = _ospf_iface_base(name, afi) + + int_map = { + "cost": "cost", + "bandwidth": "bandwidth", + "dead_interval": "dead-interval", + "hello_interval": "hello-interval", + "priority": "priority", + "retransmit_interval": "retransmit-interval", + "transmit_delay": "transmit-delay", + "ifmtu": "ifmtu", + } + for param, key in int_map.items(): + if af.get(param) is not None: + client.configure_set(base + [key], str(af[param])) + commands.append("set {b} {k} {v}".format(b=" ".join(base), k=key, v=af[param])) + + if af.get("mtu_ignore"): + client.configure_set(base + ["mtu-ignore"]) + commands.append("set {b} mtu-ignore".format(b=" ".join(base))) + if af.get("passive"): + client.configure_set(base + ["passive"]) + commands.append("set {b} passive".format(b=" ".join(base))) + if af.get("network"): + client.configure_set(base + ["network"], af["network"]) + if af.get("instance"): + client.configure_set(base + ["instance-id"], af["instance"]) + + auth = af.get("authentication") or {} + if auth.get("plaintext_password"): + client.configure_set( + base + ["authentication", "plaintext-password"], + auth["plaintext_password"], + ) + commands.append( + "set {b} authentication plaintext-password ...".format(b=" ".join(base)), + ) + md5 = auth.get("md5_key") or {} + if md5.get("key_id") and md5.get("key"): + client.configure_set( + base + ["authentication", "md5", "key-id", str(md5["key_id"]), "md5-key"], + md5["key"], + ) + + +def _get(client): + try: + r = client.retrieve_show_config(["interfaces"]) + raw = r.get("data") or {} + out = [] + for itype, itype_data in raw.items(): + if not isinstance(itype_data, dict): + continue + for iname, idata in itype_data.items(): + if not isinstance(idata, dict): + continue + afs = [] + ip_ospf = (idata.get("ip") or {}).get("ospf") + if ip_ospf: + afs.append({"afi": "ipv4", "ospf_data": ip_ospf}) + ip6_ospfv3 = (idata.get("ipv6") or {}).get("ospfv3") + if ip6_ospfv3: + afs.append({"afi": "ipv6", "ospfv3_data": ip6_ospfv3}) + if afs: + out.append({"name": iname, "address_family": afs}) + return out + except VyOSRestError: + return [] + + +def main(): + argument_spec = dict( + config=dict( + type="list", + elements="dict", + options=dict( + name=dict(type="str"), + address_family=dict( + type="list", + elements="dict", + options=dict( + afi=dict(type="str", required=True, choices=["ipv4", "ipv6"]), + authentication=dict( + type="dict", + options=dict( + plaintext_password=dict(type="str", no_log=True), + md5_key=dict( + type="dict", + options=dict( + key_id=dict(type="int"), + key=dict(type="str", no_log=True), + ), + ), + ), + ), + bandwidth=dict(type="int"), + cost=dict(type="int"), + dead_interval=dict(type="int"), + hello_interval=dict(type="int"), + mtu_ignore=dict(type="bool"), + network=dict(type="str"), + priority=dict(type="int"), + retransmit_interval=dict(type="int"), + transmit_delay=dict(type="int"), + ifmtu=dict(type="int"), + instance=dict(type="str"), + passive=dict(type="bool"), + ), + ), + ), + ), + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "overridden", "deleted", "gathered"], + ), + ) + argument_spec.update(VYOS_REST_CONNECTION_ARGSPEC) + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + client = VyOSRestClient(module) + state = module.params["state"] + config = module.params.get("config") or [] + commands = [] + changed = False + before = _get(client) + + if state == "gathered": + module.exit_json(changed=False, gathered=before, before=before, commands=[]) + if module.check_mode: + module.exit_json(changed=True, before=before, commands=["(check mode)"]) + + try: + for cfg in config: + _apply(client, cfg, commands) + changed = True + except VyOSRestError as exc: + module.fail_json(msg=str(exc)) + + after = _get(client) if changed else before + module.exit_json(changed=changed, before=before, after=after, commands=commands) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/vyos_ospfv2.py b/plugins/modules/vyos_ospfv2.py new file mode 100644 index 0000000..063d0dc --- /dev/null +++ b/plugins/modules/vyos_ospfv2.py @@ -0,0 +1,487 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# GNU General Public License v3.0+ + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: vyos_ospfv2 +short_description: OSPFv2 resource module via REST API. +description: + - Manages OSPFv2 (OSPF for IPv4) configuration on VyOS via HTTPS REST API. +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + config: + description: OSPFv2 configuration. + type: dict + suboptions: + areas: + type: list + elements: dict + suboptions: + area_id: + type: str + area_type: + type: dict + suboptions: + normal: + type: bool + nssa: + type: dict + suboptions: + set: + type: bool + default_cost: + type: int + no_summary: + type: bool + translate: + type: str + choices: [always, candidate, never] + stub: + type: dict + suboptions: + set: + type: bool + default_cost: + type: int + no_summary: + type: bool + authentication: + type: str + choices: [plaintext-password, md5] + network: + type: list + elements: dict + suboptions: + address: + type: str + range: + type: list + elements: dict + suboptions: + address: + type: str + cost: + type: int + not_advertise: + type: bool + substitute: + type: str + shortcut: + type: str + choices: [default, disable, enable] + auto_cost: + type: dict + suboptions: + reference_bandwidth: + type: int + default_information: + type: dict + suboptions: + originate: + type: dict + suboptions: + always: + type: bool + metric: + type: int + metric_type: + type: int + route_map: + type: str + log_adjacency_changes: + type: str + choices: [detail] + max_metric: + type: dict + suboptions: + router_lsa: + type: dict + suboptions: + administrative: + type: bool + on_shutdown: + type: int + on_startup: + type: int + mpls_te: + type: dict + suboptions: + enabled: + type: bool + router_address: + type: str + neighbor: + type: list + elements: dict + suboptions: + neighbor_id: + type: str + poll_interval: + type: int + priority: + type: int + parameters: + type: dict + suboptions: + router_id: + type: str + opaque_lsa: + type: bool + rfc1583_compatibility: + type: bool + abr_type: + type: str + choices: [cisco, ibm, shortcut, standard] + passive_interface: + type: list + elements: str + redistribute: + type: list + elements: dict + suboptions: + route_type: + type: str + choices: [bgp, connected, kernel, rip, static] + metric: + type: int + metric_type: + type: int + route_map: + type: str + state: + type: str + choices: [merged, replaced, deleted, gathered] + default: merged + hostname: + type: str + required: true + port: + type: int + default: 443 + api_key: + type: str + required: true + no_log: true + timeout: + type: int + default: 30 + verify_ssl: + type: bool + default: false +""" + +RETURN = r""" +before: + returned: always + type: dict +after: + returned: when changed + type: dict +commands: + returned: always + type: list +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import ( + VYOS_REST_CONNECTION_ARGSPEC, + VyOSRestClient, + VyOSRestError, +) + + +_BASE = ["protocols", "ospf"] + + +def _get(client): + try: + r = client.retrieve_show_config(_BASE) + return r.get("data") or {} + except VyOSRestError: + return {} + + +def _apply(client, config, commands): + params = config.get("parameters") or {} + if params.get("router_id"): + client.configure_set(_BASE + ["parameters", "router-id"], params["router_id"]) + commands.append("set protocols ospf parameters router-id {r}".format(r=params["router_id"])) + if params.get("opaque_lsa"): + client.configure_set(_BASE + ["parameters", "opaque-lsa"]) + if params.get("rfc1583_compatibility"): + client.configure_set(_BASE + ["parameters", "rfc1583-compatibility"]) + if params.get("abr_type"): + client.configure_set(_BASE + ["parameters", "abr-type"], params["abr_type"]) + + ac = config.get("auto_cost") or {} + if ac.get("reference_bandwidth"): + client.configure_set( + _BASE + ["auto-cost", "reference-bandwidth"], + str(ac["reference_bandwidth"]), + ) + commands.append( + "set protocols ospf auto-cost reference-bandwidth {b}".format( + b=ac["reference_bandwidth"], + ), + ) + + if config.get("log_adjacency_changes"): + client.configure_set(_BASE + ["log-adjacency-changes", config["log_adjacency_changes"]]) + + for nbr in config.get("neighbor") or []: + nb = _BASE + ["neighbor", nbr["neighbor_id"]] + client.configure_set(nb) + if nbr.get("poll_interval"): + client.configure_set(nb + ["poll-interval"], str(nbr["poll_interval"])) + if nbr.get("priority"): + client.configure_set(nb + ["priority"], str(nbr["priority"])) + + for pi in config.get("passive_interface") or []: + client.configure_set(_BASE + ["passive-interface"], pi) + commands.append("set protocols ospf passive-interface {i}".format(i=pi)) + + for redist in config.get("redistribute") or []: + rb = _BASE + ["redistribute", redist["route_type"]] + client.configure_set(rb) + commands.append("set protocols ospf redistribute {r}".format(r=redist["route_type"])) + if redist.get("metric"): + client.configure_set(rb + ["metric"], str(redist["metric"])) + if redist.get("metric_type"): + client.configure_set(rb + ["metric-type"], str(redist["metric_type"])) + if redist.get("route_map"): + client.configure_set(rb + ["route-map"], redist["route_map"]) + + di = (config.get("default_information") or {}).get("originate") or {} + if di: + dib = _BASE + ["default-information", "originate"] + client.configure_set(dib) + commands.append("set protocols ospf default-information originate") + if di.get("always"): + client.configure_set(dib + ["always"]) + if di.get("metric"): + client.configure_set(dib + ["metric"], str(di["metric"])) + if di.get("metric_type"): + client.configure_set(dib + ["metric-type"], str(di["metric_type"])) + if di.get("route_map"): + client.configure_set(dib + ["route-map"], di["route_map"]) + + for area in config.get("areas") or []: + aid = area["area_id"] + ab = _BASE + ["area", aid] + client.configure_set(ab) + commands.append("set protocols ospf area {a}".format(a=aid)) + + at = area.get("area_type") or {} + if at.get("normal"): + pass # default + nssa = at.get("nssa") or {} + if nssa.get("set"): + client.configure_set(ab + ["area-type", "nssa"]) + if nssa.get("default_cost"): + client.configure_set( + ab + ["area-type", "nssa", "default-cost"], + str(nssa["default_cost"]), + ) + if nssa.get("no_summary"): + client.configure_set(ab + ["area-type", "nssa", "no-summary"]) + stub = at.get("stub") or {} + if stub.get("set"): + client.configure_set(ab + ["area-type", "stub"]) + if stub.get("default_cost"): + client.configure_set( + ab + ["area-type", "stub", "default-cost"], + str(stub["default_cost"]), + ) + + if area.get("authentication"): + client.configure_set(ab + ["authentication"], area["authentication"]) + + for net in area.get("network") or []: + client.configure_set(ab + ["network"], net["address"]) + commands.append( + "set protocols ospf area {a} network {n}".format( + a=aid, + n=net["address"], + ), + ) + + for rng in area.get("range") or []: + rb2 = ab + ["range", rng["address"]] + client.configure_set(rb2) + if rng.get("cost"): + client.configure_set(rb2 + ["cost"], str(rng["cost"])) + if rng.get("not_advertise"): + client.configure_set(rb2 + ["not-advertise"]) + if rng.get("substitute"): + client.configure_set(rb2 + ["substitute"], rng["substitute"]) + + if area.get("shortcut"): + client.configure_set(ab + ["shortcut"], area["shortcut"]) + + +def main(): + argument_spec = dict( + config=dict( + type="dict", + options=dict( + areas=dict( + type="list", + elements="dict", + options=dict( + area_id=dict(type="str"), + area_type=dict( + type="dict", + options=dict( + normal=dict(type="bool"), + nssa=dict( + type="dict", + options=dict( + set=dict(type="bool"), + default_cost=dict(type="int"), + no_summary=dict(type="bool"), + translate=dict( + type="str", + choices=["always", "candidate", "never"], + ), + ), + ), + stub=dict( + type="dict", + options=dict( + set=dict(type="bool"), + default_cost=dict(type="int"), + no_summary=dict(type="bool"), + ), + ), + ), + ), + authentication=dict(type="str", choices=["plaintext-password", "md5"]), + network=dict( + type="list", + elements="dict", + options=dict(address=dict(type="str")), + ), + range=dict( + type="list", + elements="dict", + options=dict( + address=dict(type="str"), + cost=dict(type="int"), + not_advertise=dict(type="bool"), + substitute=dict(type="str"), + ), + ), + shortcut=dict(type="str", choices=["default", "disable", "enable"]), + ), + ), + auto_cost=dict(type="dict", options=dict(reference_bandwidth=dict(type="int"))), + default_information=dict( + type="dict", + options=dict( + originate=dict( + type="dict", + options=dict( + always=dict(type="bool"), + metric=dict(type="int"), + metric_type=dict(type="int"), + route_map=dict(type="str"), + ), + ), + ), + ), + log_adjacency_changes=dict(type="str", choices=["detail"]), + max_metric=dict(type="dict"), + mpls_te=dict( + type="dict", + options=dict( + enabled=dict(type="bool"), + router_address=dict(type="str"), + ), + ), + neighbor=dict( + type="list", + elements="dict", + options=dict( + neighbor_id=dict(type="str"), + poll_interval=dict(type="int"), + priority=dict(type="int"), + ), + ), + parameters=dict( + type="dict", + options=dict( + router_id=dict(type="str"), + opaque_lsa=dict(type="bool"), + rfc1583_compatibility=dict(type="bool"), + abr_type=dict(type="str", choices=["cisco", "ibm", "shortcut", "standard"]), + ), + ), + passive_interface=dict(type="list", elements="str"), + redistribute=dict( + type="list", + elements="dict", + options=dict( + route_type=dict( + type="str", + choices=["bgp", "connected", "kernel", "rip", "static"], + ), + metric=dict(type="int"), + metric_type=dict(type="int"), + route_map=dict(type="str"), + ), + ), + ), + ), + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "deleted", "gathered"], + ), + ) + argument_spec.update(VYOS_REST_CONNECTION_ARGSPEC) + module = AnsibleModule( + argument_spec=argument_spec, + required_if=[("state", "merged", ["config"]), ("state", "replaced", ["config"])], + supports_check_mode=True, + ) + client = VyOSRestClient(module) + state = module.params["state"] + config = module.params.get("config") + commands = [] + changed = False + before = _get(client) + + if state == "gathered": + module.exit_json(changed=False, gathered=before, before=before, commands=[]) + if module.check_mode: + module.exit_json(changed=True, before=before, commands=["(check mode)"]) + + try: + if state == "deleted": + if before: + client.configure_delete(_BASE) + commands.append("delete protocols ospf") + changed = True + elif state in ("merged", "replaced"): + if state == "replaced" and before: + client.configure_delete(_BASE) + commands.append("delete protocols ospf") + _apply(client, config, commands) + changed = True + except VyOSRestError as exc: + module.fail_json(msg=str(exc)) + + after = _get(client) if changed else before + module.exit_json(changed=changed, before=before, after=after, commands=commands) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/vyos_ospfv3.py b/plugins/modules/vyos_ospfv3.py new file mode 100644 index 0000000..0de49d7 --- /dev/null +++ b/plugins/modules/vyos_ospfv3.py @@ -0,0 +1,257 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# GNU General Public License v3.0+ + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: vyos_ospfv3 +short_description: OSPFv3 resource module via REST API. +description: + - Manages OSPFv3 (OSPF for IPv6) configuration on VyOS via HTTPS REST API. +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + config: + description: OSPFv3 configuration. + type: dict + suboptions: + areas: + type: list + elements: dict + suboptions: + area_id: + type: str + export_list: + type: str + import_list: + type: str + interface: + description: Interfaces in this area. + type: list + elements: dict + suboptions: + name: + type: str + range: + type: list + elements: dict + suboptions: + address: + type: str + advertise: + type: bool + not_advertise: + type: bool + parameters: + type: dict + suboptions: + router_id: + type: str + redistribute: + type: list + elements: dict + suboptions: + route_type: + type: str + choices: [bgp, connected, kernel, ripng, static] + route_map: + type: str + state: + type: str + choices: [merged, replaced, deleted, gathered] + default: merged + hostname: + type: str + required: true + port: + type: int + default: 443 + api_key: + type: str + required: true + no_log: true + timeout: + type: int + default: 30 + verify_ssl: + type: bool + default: false +""" + +RETURN = r""" +before: + returned: always + type: dict +after: + returned: when changed + type: dict +commands: + returned: always + type: list +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import ( + VYOS_REST_CONNECTION_ARGSPEC, + VyOSRestClient, + VyOSRestError, +) + + +_BASE = ["protocols", "ospfv3"] + + +def _get(client): + try: + r = client.retrieve_show_config(_BASE) + return r.get("data") or {} + except VyOSRestError: + return {} + + +def _apply(client, config, commands): + params = config.get("parameters") or {} + if params.get("router_id"): + client.configure_set(_BASE + ["parameters", "router-id"], params["router_id"]) + commands.append( + "set protocols ospfv3 parameters router-id {r}".format(r=params["router_id"]), + ) + + for redist in config.get("redistribute") or []: + rb = _BASE + ["redistribute", redist["route_type"]] + client.configure_set(rb) + commands.append("set protocols ospfv3 redistribute {r}".format(r=redist["route_type"])) + if redist.get("route_map"): + client.configure_set(rb + ["route-map"], redist["route_map"]) + + for area in config.get("areas") or []: + aid = area["area_id"] + ab = _BASE + ["area", aid] + client.configure_set(ab) + commands.append("set protocols ospfv3 area '{a}'".format(a=aid)) + if area.get("export_list"): + client.configure_set(ab + ["export-list"], area["export_list"]) + commands.append( + "set protocols ospfv3 area {a} export-list {e}".format( + a=aid, + e=area["export_list"], + ), + ) + if area.get("import_list"): + client.configure_set(ab + ["import-list"], area["import_list"]) + for iface in area.get("interface") or []: + client.configure_set(ab + ["interface"], iface["name"]) + commands.append( + "set protocols ospfv3 area {a} interface {i}".format( + a=aid, + i=iface["name"], + ), + ) + for rng in area.get("range") or []: + rb2 = ab + ["range", rng["address"]] + client.configure_set(rb2) + commands.append( + "set protocols ospfv3 area {a} range {r}".format( + a=aid, + r=rng["address"], + ), + ) + if rng.get("advertise"): + client.configure_set(rb2 + ["advertise"]) + if rng.get("not_advertise"): + client.configure_set(rb2 + ["not-advertise"]) + + +def main(): + argument_spec = dict( + config=dict( + type="dict", + options=dict( + areas=dict( + type="list", + elements="dict", + options=dict( + area_id=dict(type="str"), + export_list=dict(type="str"), + import_list=dict(type="str"), + interface=dict( + type="list", + elements="dict", + options=dict(name=dict(type="str")), + ), + range=dict( + type="list", + elements="dict", + options=dict( + address=dict(type="str"), + advertise=dict(type="bool"), + not_advertise=dict(type="bool"), + ), + ), + ), + ), + parameters=dict(type="dict", options=dict(router_id=dict(type="str"))), + redistribute=dict( + type="list", + elements="dict", + options=dict( + route_type=dict( + type="str", + choices=["bgp", "connected", "kernel", "ripng", "static"], + ), + route_map=dict(type="str"), + ), + ), + ), + ), + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "deleted", "gathered"], + ), + ) + argument_spec.update(VYOS_REST_CONNECTION_ARGSPEC) + module = AnsibleModule( + argument_spec=argument_spec, + required_if=[("state", "merged", ["config"]), ("state", "replaced", ["config"])], + supports_check_mode=True, + ) + client = VyOSRestClient(module) + state = module.params["state"] + config = module.params.get("config") + commands = [] + changed = False + before = _get(client) + + if state == "gathered": + module.exit_json(changed=False, gathered=before, before=before, commands=[]) + if module.check_mode: + module.exit_json(changed=True, before=before, commands=["(check mode)"]) + + try: + if state == "deleted": + if before: + client.configure_delete(_BASE) + commands.append("delete protocols ospfv3") + changed = True + elif state in ("merged", "replaced"): + if state == "replaced" and before: + client.configure_delete(_BASE) + commands.append("delete protocols ospfv3") + _apply(client, config, commands) + changed = True + except VyOSRestError as exc: + module.fail_json(msg=str(exc)) + + after = _get(client) if changed else before + module.exit_json(changed=changed, before=before, after=after, commands=commands) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/vyos_ping.py b/plugins/modules/vyos_ping.py new file mode 100644 index 0000000..45368d1 --- /dev/null +++ b/plugins/modules/vyos_ping.py @@ -0,0 +1,180 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# GNU General Public License v3.0+ + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: vyos_ping +short_description: Test reachability using ping via VyOS REST API. +description: + - Sends an ICMP ping via the VyOS HTTPS REST API (/show endpoint) and + checks reachability. Mirrors C(vyos.vyos.vyos_ping). +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + dest: + description: Destination IP address or hostname. + type: str + required: true + count: + description: Number of packets to send. + type: int + default: 5 + source: + description: Source interface or IP address. + type: str + ttl: + description: Time-to-live value. + type: int + size: + description: Packet size in bytes. + type: int + interval: + description: Interval between pings in seconds. + type: int + state: + description: C(present) = expect success; C(absent) = expect failure. + type: str + choices: [present, absent] + default: present + hostname: + type: str + required: true + port: + type: int + default: 443 + api_key: + type: str + required: true + no_log: true + timeout: + type: int + default: 30 + verify_ssl: + type: bool + default: false +""" + +RETURN = r""" +commands: + description: Ping command tokens sent to the API. + returned: always + type: list +packet_loss: + description: Packet loss percentage string. + returned: always + type: str +packets_rx: + description: Packets received. + returned: always + type: int +packets_tx: + description: Packets transmitted. + returned: always + type: int +rtt: + description: RTT statistics dict (min/avg/max/mdev). + returned: when available + type: dict +""" + +import re + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import ( + VYOS_REST_CONNECTION_ARGSPEC, + VyOSRestClient, + VyOSRestError, +) + + +def _build_ping_path(params): + path = ["ping", params["dest"]] + if params.get("count"): + path += ["count", str(params["count"])] + if params.get("source"): + path += ["interface", params["source"]] + if params.get("ttl"): + path += ["ttl", str(params["ttl"])] + if params.get("size"): + path += ["size", str(params["size"])] + return path + + +def _parse_ping_output(output): + stats = {"packet_loss": "100%", "packets_rx": 0, "packets_tx": 0} + tx_match = re.search(r"(\d+) packets transmitted", output) + rx_match = re.search(r"(\d+) received", output) + loss_match = re.search(r"(\d+(?:\.\d+)?%)\s+packet loss", output) + if tx_match: + stats["packets_tx"] = int(tx_match.group(1)) + if rx_match: + stats["packets_rx"] = int(rx_match.group(1)) + if loss_match: + stats["packet_loss"] = loss_match.group(1) + rtt_match = re.search( + r"rtt min/avg/max/mdev = ([\d.]+)/([\d.]+)/([\d.]+)/([\d.]+)", + output, + ) + if rtt_match: + stats["rtt"] = { + "min": rtt_match.group(1), + "avg": rtt_match.group(2), + "max": rtt_match.group(3), + "mdev": rtt_match.group(4), + } + return stats + + +def main(): + argument_spec = dict( + dest=dict(type="str", required=True), + count=dict(type="int", default=5), + source=dict(type="str"), + ttl=dict(type="int"), + size=dict(type="int"), + interval=dict(type="int"), + state=dict(type="str", default="present", choices=["present", "absent"]), + ) + argument_spec.update(VYOS_REST_CONNECTION_ARGSPEC) + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + + if module.check_mode: + module.exit_json(changed=False, commands=[], packet_loss="0%", packets_rx=0, packets_tx=0) + + client = VyOSRestClient(module) + path = _build_ping_path(module.params) + + try: + result = client.show(path) + except VyOSRestError as exc: + module.fail_json(msg=str(exc)) + + output = result.get("data", "") + stats = _parse_ping_output(output) + reachable = stats["packets_rx"] > 0 + + if module.params["state"] == "present" and not reachable: + module.fail_json( + msg="Ping to {dest} failed — no packets received.".format(dest=module.params["dest"]), + **stats, + ) + elif module.params["state"] == "absent" and reachable: + module.fail_json( + msg="Expected {dest} to be unreachable but ping succeeded.".format( + dest=module.params["dest"], + ), + **stats, + ) + + module.exit_json(changed=False, commands=path, **stats) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/vyos_prefix_lists.py b/plugins/modules/vyos_prefix_lists.py new file mode 100644 index 0000000..63fa584 --- /dev/null +++ b/plugins/modules/vyos_prefix_lists.py @@ -0,0 +1,267 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# GNU General Public License v3.0+ + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: vyos_prefix_lists +short_description: Prefix-Lists resource module via REST API. +description: + - Manages IPv4 and IPv6 prefix lists on VyOS via the HTTPS REST API. +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + config: + description: List of prefix-list configurations. + type: list + elements: dict + suboptions: + afi: + description: Address family. + type: str + choices: [ipv4, ipv6] + required: true + prefix_lists: + description: Named prefix lists. + type: list + elements: dict + suboptions: + name: + description: Prefix list name. + type: str + required: true + description: + description: Description. + type: str + entries: + description: Prefix list rules. + type: list + elements: dict + suboptions: + sequence: + description: Rule sequence number. + type: int + required: true + description: + type: str + action: + description: permit or deny. + type: str + choices: [permit, deny] + ge: + description: Minimum prefix length. + type: int + le: + description: Maximum prefix length. + type: int + prefix: + description: Network prefix to match. + type: str + state: + type: str + choices: [merged, replaced, overridden, deleted, gathered] + default: merged + hostname: + type: str + required: true + port: + type: int + default: 443 + api_key: + type: str + required: true + no_log: true + timeout: + type: int + default: 30 + verify_ssl: + type: bool + default: false +""" + +RETURN = r""" +before: + returned: always + type: list +after: + returned: when changed + type: list +commands: + returned: always + type: list +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import ( + VYOS_REST_CONNECTION_ARGSPEC, + VyOSRestClient, + VyOSRestError, +) + + +_PL_KEY = {"ipv4": "prefix-list", "ipv6": "prefix-list6"} + + +def _get(client): + try: + r = client.retrieve_show_config(["policy"]) + data = r.get("data") or {} + out = [] + for afi, key in [("ipv4", "prefix-list"), ("ipv6", "prefix-list6")]: + pl_data = data.get(key) or {} + if not isinstance(pl_data, dict): + continue + pls = [] + for pl_name, pl_info in pl_data.items(): + entry = {"name": pl_name} + if isinstance(pl_info, dict): + if "description" in pl_info: + entry["description"] = pl_info["description"] + rules = [] + for seq, rdata in (pl_info.get("rule") or {}).items(): + if isinstance(rdata, dict): + rules.append({"sequence": int(seq), **rdata}) + if rules: + entry["entries"] = rules + pls.append(entry) + if pls: + out.append({"afi": afi, "prefix_lists": pls}) + return out + except VyOSRestError: + return [] + + +def _apply(client, entry, commands): + afi = entry["afi"] + key = _PL_KEY[afi] + for pl in entry.get("prefix_lists") or []: + base = ["policy", key, pl["name"]] + client.configure_set(base) + commands.append("set policy {k} {n}".format(k=key, n=pl["name"])) + if pl.get("description"): + client.configure_set(base + ["description"], pl["description"]) + for rule in pl.get("entries") or []: + rb = base + ["rule", str(rule["sequence"])] + client.configure_set(rb) + commands.append( + "set policy {k} {n} rule {s}".format( + k=key, + n=pl["name"], + s=rule["sequence"], + ), + ) + if rule.get("action"): + client.configure_set(rb + ["action"], rule["action"]) + commands.append( + "set policy {k} {n} rule {s} action {a}".format( + k=key, + n=pl["name"], + s=rule["sequence"], + a=rule["action"], + ), + ) + if rule.get("prefix"): + client.configure_set(rb + ["prefix"], rule["prefix"]) + if rule.get("ge") is not None: + client.configure_set(rb + ["ge"], str(rule["ge"])) + if rule.get("le") is not None: + client.configure_set(rb + ["le"], str(rule["le"])) + if rule.get("description"): + client.configure_set(rb + ["description"], rule["description"]) + + +def main(): + argument_spec = dict( + config=dict( + type="list", + elements="dict", + options=dict( + afi=dict(type="str", required=True, choices=["ipv4", "ipv6"]), + prefix_lists=dict( + type="list", + elements="dict", + options=dict( + name=dict(type="str", required=True), + description=dict(type="str"), + entries=dict( + type="list", + elements="dict", + options=dict( + sequence=dict(type="int", required=True), + description=dict(type="str"), + action=dict(type="str", choices=["permit", "deny"]), + ge=dict(type="int"), + le=dict(type="int"), + prefix=dict(type="str"), + ), + ), + ), + ), + ), + ), + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "overridden", "deleted", "gathered"], + ), + ) + argument_spec.update(VYOS_REST_CONNECTION_ARGSPEC) + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + client = VyOSRestClient(module) + state = module.params["state"] + config = module.params.get("config") or [] + commands = [] + changed = False + before = _get(client) + + if state == "gathered": + module.exit_json(changed=False, gathered=before, before=before, commands=[]) + if module.check_mode: + module.exit_json(changed=True, before=before, commands=["(check mode)"]) + + try: + if state == "deleted" and not config: + for afi, key in [("ipv4", "prefix-list"), ("ipv6", "prefix-list6")]: + try: + client.configure_delete(["policy", key]) + commands.append("delete policy {k}".format(k=key)) + except VyOSRestError: + pass + changed = True + elif state == "deleted" and config: + for entry in config: + key = _PL_KEY[entry["afi"]] + for pl in entry.get("prefix_lists") or []: + try: + client.configure_delete(["policy", key, pl["name"]]) + commands.append("delete policy {k} {n}".format(k=key, n=pl["name"])) + changed = True + except VyOSRestError: + pass + elif state in ("merged", "replaced", "overridden"): + if state in ("replaced", "overridden"): + for entry in config: + key = _PL_KEY[entry["afi"]] + for pl in entry.get("prefix_lists") or []: + try: + client.configure_delete(["policy", key, pl["name"]]) + except VyOSRestError: + pass + for entry in config: + _apply(client, entry, commands) + changed = True + except VyOSRestError as exc: + module.fail_json(msg=str(exc)) + + after = _get(client) if changed else before + module.exit_json(changed=changed, before=before, after=after, commands=commands) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/vyos_reset.py b/plugins/modules/vyos_reset.py new file mode 100644 index 0000000..ca3e343 --- /dev/null +++ b/plugins/modules/vyos_reset.py @@ -0,0 +1,108 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# 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_reset +short_description: Execute reset commands on a VyOS device via REST API. +description: + - Sends a C(reset) operational command to a VyOS device via the HTTPS REST + API (C(/reset) endpoint). + - Useful for resetting BGP sessions, VPN tunnels, ARP caches, etc. +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + path: + description: + - Reset command path tokens. + - E.g. C(["ip", "bgp", "192.0.2.1"]) to reset a BGP peer. + type: list + elements: str + required: true + hostname: + description: IP address or FQDN of the VyOS device. + type: str + required: true + port: + description: HTTPS port for the REST API. + type: int + default: 443 + api_key: + description: API key configured on the device. + type: str + required: true + no_log: true + timeout: + description: Request timeout in seconds. + type: int + default: 30 + verify_ssl: + description: Validate the device's TLS certificate. + type: bool + default: false +requirements: + - VyOS 1.3+ +examples: | + - name: Reset a BGP peer + vyos.rest.vyos_reset: + hostname: 192.168.1.1 + api_key: MY-KEY + path: ["ip", "bgp", "192.0.2.11"] + + - name: Reset an IPsec VPN peer + vyos.rest.vyos_reset: + hostname: 192.168.1.1 + api_key: MY-KEY + path: ["vpn", "ipsec-peer", "203.0.113.5"] + + - name: Clear ARP cache + vyos.rest.vyos_reset: + hostname: 192.168.1.1 + api_key: MY-KEY + path: ["arp", "cache"] +""" + +RETURN = r""" +output: + description: Text output from the reset command (usually empty on success). + returned: success + type: str +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import ( + VYOS_REST_CONNECTION_ARGSPEC, + VyOSRestClient, + VyOSRestError, +) + + +def main(): + argument_spec = dict( + path=dict(type="list", elements="str", required=True), + ) + argument_spec.update(VYOS_REST_CONNECTION_ARGSPEC) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=False, + ) + + client = VyOSRestClient(module) + try: + result = client.reset(module.params["path"]) + except VyOSRestError as exc: + module.fail_json(msg=str(exc)) + + module.exit_json(changed=True, output=result.get("data", "")) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/vyos_route_maps.py b/plugins/modules/vyos_route_maps.py new file mode 100644 index 0000000..d13a88d --- /dev/null +++ b/plugins/modules/vyos_route_maps.py @@ -0,0 +1,376 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# GNU General Public License v3.0+ + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: vyos_route_maps +short_description: Route Map resource module via REST API. +description: + - Manages route maps on VyOS via the HTTPS REST API. +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + config: + description: List of route-map configurations. + type: list + elements: dict + suboptions: + route_map: + description: Route map name. + type: str + entries: + description: Route map rules. + type: list + elements: dict + suboptions: + sequence: + description: Rule number (1-65535). + type: int + action: + description: permit or deny. + type: str + choices: [permit, deny] + description: + type: str + call: + description: Call another route map. + type: str + continue_sequence: + description: Continue at a different sequence. + type: int + match: + description: Match conditions. + type: dict + suboptions: + prefix_list: + type: str + prefix_list6: + type: str + interface: + type: str + metric: + type: int + origin: + type: str + ip: + type: dict + suboptions: + nexthop_address: + type: str + nexthop_prefix_list: + type: str + ipv6: + type: dict + suboptions: + nexthop_address: + type: str + set: + description: Route parameters to set. + type: dict + suboptions: + aggregator: + type: dict + suboptions: + as: + type: str + ip: + type: str + as_path_prepend: + type: str + atomic_aggregate: + type: bool + bgp_extcommunity_rt: + type: str + community: + type: dict + suboptions: + value: + type: str + ip_next_hop: + type: str + ipv6_next_hop: + type: dict + suboptions: + ip_type: + type: str + choices: [global, local] + value: + type: str + large_community: + type: str + local_preference: + type: int + metric: + type: str + metric_type: + type: str + origin: + type: str + originator_id: + type: str + src: + type: str + tag: + type: str + weight: + type: int + state: + type: str + choices: [merged, replaced, overridden, deleted, gathered] + default: merged + hostname: + type: str + required: true + port: + type: int + default: 443 + api_key: + type: str + required: true + no_log: true + timeout: + type: int + default: 30 + verify_ssl: + type: bool + default: false +""" + +RETURN = r""" +before: + returned: always + type: list +after: + returned: when changed + type: list +commands: + returned: always + type: list +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import ( + VYOS_REST_CONNECTION_ARGSPEC, + VyOSRestClient, + VyOSRestError, +) + + +def _get(client): + try: + r = client.retrieve_show_config(["policy", "route-map"]) + data = r.get("data") or {} + return [{"route_map": name, "raw": rdata} for name, rdata in data.items()] + except VyOSRestError: + return [] + + +def _apply(client, rm_cfg, commands): + rm_name = rm_cfg["route_map"] + for rule in rm_cfg.get("entries") or []: + seq = str(rule["sequence"]) + base = ["policy", "route-map", rm_name, "rule", seq] + client.configure_set(base) + commands.append("set policy route-map {n} rule {s}".format(n=rm_name, s=seq)) + + if rule.get("action"): + client.configure_set(base + ["action"], rule["action"]) + commands.append( + "set policy route-map {n} rule {s} action {a}".format( + n=rm_name, + s=seq, + a=rule["action"], + ), + ) + if rule.get("description"): + client.configure_set(base + ["description"], rule["description"]) + if rule.get("call"): + client.configure_set(base + ["call"], rule["call"]) + if rule.get("continue_sequence"): + client.configure_set(base + ["continue"], str(rule["continue_sequence"])) + + # Match conditions + match = rule.get("match") or {} + if match.get("prefix_list"): + client.configure_set( + base + ["match", "ip", "address", "prefix-list"], + match["prefix_list"], + ) + if match.get("interface"): + client.configure_set(base + ["match", "interface"], match["interface"]) + if match.get("metric") is not None: + client.configure_set(base + ["match", "metric"], str(match["metric"])) + ip_match = match.get("ip") or {} + if ip_match.get("nexthop_address"): + client.configure_set( + base + ["match", "ip", "nexthop", "address"], + ip_match["nexthop_address"], + ) + + # Set actions + setv = rule.get("set") or {} + if setv.get("ip_next_hop"): + client.configure_set(base + ["set", "ip-next-hop"], setv["ip_next_hop"]) + commands.append( + "set policy route-map {n} rule {s} set ip-next-hop {nh}".format( + n=rm_name, + s=seq, + nh=setv["ip_next_hop"], + ), + ) + if setv.get("local_preference") is not None: + client.configure_set( + base + ["set", "local-preference"], + str(setv["local_preference"]), + ) + if setv.get("metric"): + client.configure_set(base + ["set", "metric"], str(setv["metric"])) + if setv.get("origin"): + client.configure_set(base + ["set", "origin"], setv["origin"]) + if setv.get("weight") is not None: + client.configure_set(base + ["set", "weight"], str(setv["weight"])) + if setv.get("as_path_prepend"): + client.configure_set(base + ["set", "as-path-prepend"], setv["as_path_prepend"]) + comm = setv.get("community") or {} + if comm.get("value"): + client.configure_set(base + ["set", "community", comm["value"]]) + if setv.get("tag"): + client.configure_set(base + ["set", "tag"], setv["tag"]) + if setv.get("src"): + client.configure_set(base + ["set", "src"], setv["src"]) + nh6 = setv.get("ipv6_next_hop") or {} + if nh6.get("value"): + ip_type = nh6.get("ip_type", "global") + client.configure_set(base + ["set", "ipv6-next-hop", ip_type], nh6["value"]) + + +def main(): + set_spec = dict( + type="dict", + options=dict( + aggregator=dict(type="dict", options=dict(as_=dict(type="str"), ip=dict(type="str"))), + as_path_prepend=dict(type="str"), + atomic_aggregate=dict(type="bool"), + bgp_extcommunity_rt=dict(type="str"), + community=dict(type="dict", options=dict(value=dict(type="str"))), + ip_next_hop=dict(type="str"), + ipv6_next_hop=dict( + type="dict", + options=dict( + ip_type=dict(type="str", choices=["global", "local"]), + value=dict(type="str"), + ), + ), + large_community=dict(type="str"), + local_preference=dict(type="int"), + metric=dict(type="str"), + metric_type=dict(type="str"), + origin=dict(type="str"), + originator_id=dict(type="str"), + src=dict(type="str"), + tag=dict(type="str"), + weight=dict(type="int"), + ), + ) + match_spec = dict( + type="dict", + options=dict( + prefix_list=dict(type="str"), + prefix_list6=dict(type="str"), + interface=dict(type="str"), + metric=dict(type="int"), + origin=dict(type="str"), + ip=dict( + type="dict", + options=dict( + nexthop_address=dict(type="str"), + nexthop_prefix_list=dict(type="str"), + ), + ), + ipv6=dict(type="dict", options=dict(nexthop_address=dict(type="str"))), + ), + ) + + argument_spec = dict( + config=dict( + type="list", + elements="dict", + options=dict( + route_map=dict(type="str"), + entries=dict( + type="list", + elements="dict", + options=dict( + sequence=dict(type="int"), + action=dict(type="str", choices=["permit", "deny"]), + description=dict(type="str"), + call=dict(type="str"), + continue_sequence=dict(type="int"), + match=match_spec, + set=set_spec, + ), + ), + ), + ), + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "overridden", "deleted", "gathered"], + ), + ) + argument_spec.update(VYOS_REST_CONNECTION_ARGSPEC) + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + client = VyOSRestClient(module) + state = module.params["state"] + config = module.params.get("config") or [] + commands = [] + changed = False + before = _get(client) + + if state == "gathered": + module.exit_json(changed=False, gathered=before, before=before, commands=[]) + if module.check_mode: + module.exit_json(changed=True, before=before, commands=["(check mode)"]) + + try: + if state == "deleted" and not config: + try: + client.configure_delete(["policy", "route-map"]) + commands.append("delete policy route-map") + changed = True + except VyOSRestError: + pass + elif state == "deleted" and config: + for rm in config: + try: + client.configure_delete(["policy", "route-map", rm["route_map"]]) + commands.append("delete policy route-map {n}".format(n=rm["route_map"])) + changed = True + except VyOSRestError: + pass + elif state in ("merged", "replaced", "overridden"): + if state in ("replaced", "overridden"): + for rm in config: + try: + client.configure_delete(["policy", "route-map", rm["route_map"]]) + except VyOSRestError: + pass + for rm in config: + _apply(client, rm, commands) + changed = True + except VyOSRestError as exc: + module.fail_json(msg=str(exc)) + + after = _get(client) if changed else before + module.exit_json(changed=changed, before=before, after=after, commands=commands) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/vyos_show.py b/plugins/modules/vyos_show.py new file mode 100644 index 0000000..fbe6c1b --- /dev/null +++ b/plugins/modules/vyos_show.py @@ -0,0 +1,119 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# 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_show +short_description: Execute op-mode show commands on a VyOS device via REST API. +description: + - Sends a C(show) operational-mode command to a VyOS device via the + HTTPS REST API (C(/show) endpoint) and returns the text output. +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + path: + description: + - Operational-mode command tokens following C(show). + - E.g. C(["interfaces"]) maps to C(show interfaces). + type: list + elements: str + required: true + hostname: + description: IP address or FQDN of the VyOS device. + type: str + required: true + port: + description: HTTPS port for the REST API. + type: int + default: 443 + api_key: + description: API key configured on the device. + type: str + required: true + no_log: true + timeout: + description: Request timeout in seconds. + type: int + default: 30 + verify_ssl: + description: Validate the device's TLS certificate. + type: bool + default: false +notes: + - This module never modifies device state. +requirements: + - VyOS 1.3+ +seealso: + - module: vyos.rest.vyos_configure + - module: vyos.rest.vyos_retrieve +examples: | + - name: Show interfaces + vyos.rest.vyos_show: + hostname: 192.168.1.1 + api_key: MY-KEY + path: ["interfaces"] + register: iface_output + + - name: Show version + vyos.rest.vyos_show: + hostname: 192.168.1.1 + api_key: MY-KEY + path: ["version"] + register: ver_output + + - name: Show installed images + vyos.rest.vyos_show: + hostname: 192.168.1.1 + api_key: MY-KEY + path: ["system", "image"] + register: images +""" + +RETURN = r""" +output: + description: Raw text output from the show command. + returned: success + type: str + sample: | + Codes: S - State, L - Link, u - Up, D - Down, A - Admin Down + Interface IP Address S/L Description + eth0 10.0.0.1/24 u/u WAN +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import ( + VYOS_REST_CONNECTION_ARGSPEC, + VyOSRestClient, + VyOSRestError, +) + + +def main(): + argument_spec = dict( + path=dict(type="list", elements="str", required=True), + ) + argument_spec.update(VYOS_REST_CONNECTION_ARGSPEC) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + client = VyOSRestClient(module) + try: + result = client.show(module.params["path"]) + except VyOSRestError as exc: + module.fail_json(msg=str(exc)) + + module.exit_json(changed=False, output=result.get("data", "")) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/vyos_snmp_server.py b/plugins/modules/vyos_snmp_server.py index a092432..9bdf451 100644 --- a/plugins/modules/vyos_snmp_server.py +++ b/plugins/modules/vyos_snmp_server.py @@ -1,8 +1,6 @@ #!/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 @@ -15,12 +13,11 @@ 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). + - Supports communities, listen addresses, contact/location/description, + 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. @@ -79,13 +76,10 @@ options: 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. @@ -95,150 +89,108 @@ options: 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. + description: Encrypted key (stored as encrypted-password on device). type: str plaintext_key: - description: Plaintext authentication password (will be encrypted on device). + description: Plaintext key (device encrypts it; recommended for 1.5+). 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: @@ -247,9 +199,6 @@ options: - overridden - deleted - gathered - -notes: - - Tested against VyOS 1.5q2. """ EXAMPLES = r""" @@ -279,46 +228,10 @@ EXAMPLES = r""" 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 @@ -327,46 +240,32 @@ EXAMPLES = r""" RETURN = r""" before: description: SNMP configuration before this module ran. - returned: when I(state) is C(merged), C(replaced), C(overridden) or C(deleted) + returned: when state is merged, replaced, overridden or 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) + returned: when state is gathered type: dict - -response: - description: Raw API response. - returned: when changes are applied - type: dict - saved: - description: Result of save_config after applying changes. + description: Whether the config was saved after changes. returned: when changes are applied - type: dict + type: bool """ 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", @@ -376,13 +275,7 @@ SCALAR_FIELDS = { } -# ------------------------------------------------------------ -# Generic helpers -# ------------------------------------------------------------ - - def to_list(value): - """Coerce None / str / list → list.""" if value is None: return [] if isinstance(value, list): @@ -394,16 +287,19 @@ def to_list(value): return [str(value)] -# ------------------------------------------------------------ -# Parsing: API response → Ansible argspec dict -# ------------------------------------------------------------ +def _cmd(op, path): + return {"op": op, "path": path} + + +def _set(path): + return _cmd("set", path) + + +def _delete(path): + return _cmd("delete", path) def _parse_communities(raw): - """ - API: {"bridges": {"client": [...], "network": [...]}, "switches": {"authorization": "rw"}} - → [{"name": "bridges", "clients": [...], ...}, ...] - """ if not raw or not isinstance(raw, dict): return [] result = [] @@ -423,10 +319,6 @@ def _parse_communities(raw): 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 = [] @@ -439,9 +331,6 @@ def _parse_listen_addresses(raw): 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): @@ -457,19 +346,12 @@ def _parse_trap_target(raw): 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: @@ -478,10 +360,6 @@ def _parse_v3_auth_privacy(raw, key): 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 = [] @@ -505,10 +383,6 @@ def _parse_v3_users(raw): 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 = [] @@ -523,12 +397,6 @@ def _parse_v3_groups(raw): 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 = [] @@ -537,7 +405,6 @@ def _parse_v3_views(raw): 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 @@ -551,10 +418,6 @@ def _parse_v3_views(raw): 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 = [] @@ -578,35 +441,21 @@ def _parse_v3_trap_targets(raw): 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 = {} @@ -626,12 +475,10 @@ def parse_snmp_config(raw): 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: @@ -641,25 +488,7 @@ def get_running_config(vyos): 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) @@ -675,90 +504,69 @@ def _build_scalar_commands(want, have, state): 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])) - + 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"])) + 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])) + 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") @@ -769,25 +577,18 @@ def _build_trap_target_commands(want, have, state): 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( @@ -795,28 +596,22 @@ def _build_v3_auth_privacy_commands(base, want_block, have_block, api_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"), @@ -829,33 +624,27 @@ def _build_v3_user_commands(want_list, have_list, state): 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) @@ -863,74 +652,56 @@ def _build_v3_group_commands(want_list, have_list, state): 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) @@ -944,11 +715,6 @@ def build_commands(want, have, state): return cmds -# ------------------------------------------------------------ -# Argument spec -# ------------------------------------------------------------ - - def _auth_privacy_spec(): return dict( type=dict(type="str"), @@ -1052,17 +818,8 @@ ARGUMENT_SPEC = dict( ) -# ------------------------------------------------------------ -# Main -# ------------------------------------------------------------ - - def main(): - module = AnsibleModule( - argument_spec=ARGUMENT_SPEC, - supports_check_mode=True, - ) - + module = AnsibleModule(argument_spec=ARGUMENT_SPEC, supports_check_mode=True) vyos = VyOSModule(module) state = module.params["state"] config = module.params.get("config") or {} @@ -1072,8 +829,7 @@ def main(): if state == "gathered": module.exit_json(changed=False, gathered=have) - want = config # already in argspec shape from AnsibleModule - + want = config commands = build_commands(want, have, state) if module.check_mode: diff --git a/plugins/modules/vyos_static_routes.py b/plugins/modules/vyos_static_routes.py new file mode 100644 index 0000000..f203603 --- /dev/null +++ b/plugins/modules/vyos_static_routes.py @@ -0,0 +1,397 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# 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_static_routes +short_description: Manage static routes on VyOS via the REST API. +description: + - Manages IPv4 and IPv6 static routes on VyOS devices using the HTTPS REST API. + - Mirrors C(vyos.vyos.vyos_static_routes) but uses the HTTP API. +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + config: + description: List of static route address-family configurations. + type: list + elements: dict + suboptions: + address_families: + description: List of address family route groups. + type: list + elements: dict + suboptions: + afi: + description: Address family indicator. + type: str + choices: [ipv4, ipv6] + required: true + routes: + description: List of static route entries. + type: list + elements: dict + suboptions: + dest: + description: Destination prefix in CIDR notation. + type: str + required: true + blackhole_config: + description: Blackhole route configuration. + type: dict + suboptions: + distance: + description: Administrative distance (1-255). + type: int + type: + description: Blackhole type. + type: str + next_hops: + description: List of next-hop addresses. + type: list + elements: dict + suboptions: + forward_router_address: + description: Next-hop IP address. + type: str + required: true + admin_distance: + description: Administrative distance for this next-hop. + type: int + enabled: + description: Whether this next-hop is enabled. + type: bool + default: true + interface: + description: Outgoing interface name. + type: str + state: + description: + - C(merged): Add routes (preserve existing). + - C(replaced): Replace routes for listed destinations. + - C(overridden): Replace the entire static route table. + - C(deleted): Remove listed (or all) static routes. + - C(gathered): Read static routes from device. + type: str + choices: [merged, replaced, overridden, deleted, gathered] + default: merged + hostname: + description: IP address or FQDN of the VyOS device. + type: str + required: true + port: + description: HTTPS port for the REST API. + type: int + default: 443 + api_key: + description: API key configured on the device. + type: str + required: true + no_log: true + timeout: + description: Request timeout in seconds. + type: int + default: 30 + verify_ssl: + description: Validate the device's TLS certificate. + type: bool + default: false +requirements: + - VyOS 1.3+ +seealso: + - module: vyos.vyos.vyos_static_routes +examples: | + - name: Add IPv4 static routes + vyos.rest.vyos_static_routes: + hostname: 192.168.1.1 + api_key: MY-KEY + config: + - address_families: + - afi: ipv4 + routes: + - dest: 192.0.2.0/24 + next_hops: + - forward_router_address: 10.0.0.1 + - dest: 203.0.113.0/24 + blackhole_config: + distance: 200 + state: merged + + - name: Delete all static routes + vyos.rest.vyos_static_routes: + hostname: 192.168.1.1 + api_key: MY-KEY + state: deleted +""" + +RETURN = r""" +before: + description: Static route config before the module ran. + returned: always + type: list +after: + description: Static route config after the module ran. + returned: when changed + type: list +gathered: + description: Static routes read from device (state=gathered). + returned: when state is gathered + type: list +commands: + description: set/delete commands issued. + returned: always + type: list +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import ( + VYOS_REST_CONNECTION_ARGSPEC, + VyOSRestClient, + VyOSRestError, +) + + +_ROUTE_BASE = { + "ipv4": ["protocols", "static", "route"], + "ipv6": ["protocols", "static", "route6"], +} + + +def _get_static_routes(client): + try: + result = client.retrieve_show_config(["protocols", "static"]) + data = result.get("data") or {} + out = [] + for afi, route_key in [("ipv4", "route"), ("ipv6", "route6")]: + routes_data = data.get(route_key, {}) + if not isinstance(routes_data, dict): + continue + routes = [] + for dest, rdata in routes_data.items(): + route_entry = {"dest": dest} + if isinstance(rdata, dict): + if "blackhole" in rdata: + bh = rdata["blackhole"] + bc = {} + if isinstance(bh, dict) and "distance" in bh: + bc["distance"] = int(bh["distance"]) + route_entry["blackhole_config"] = bc + next_hops = [] + for nh_key in ("next-hop", "next_hop"): + nh_data = rdata.get(nh_key, {}) + if isinstance(nh_data, dict): + for nh_addr, nh_opts in nh_data.items(): + nh = {"forward_router_address": nh_addr} + if isinstance(nh_opts, dict): + if "distance" in nh_opts: + nh["admin_distance"] = int(nh_opts["distance"]) + nh["enabled"] = "disable" not in nh_opts + next_hops.append(nh) + if next_hops: + route_entry["next_hops"] = next_hops + routes.append(route_entry) + if routes: + out.append({"address_families": [{"afi": afi, "routes": routes}]}) + return out + except VyOSRestError: + return [] + + +def _apply_route(client, afi, route, commands): + base = _ROUTE_BASE[afi] + dest = route["dest"] + + if route.get("blackhole_config") is not None: + bh_path = base + [dest, "blackhole"] + client.configure_set(bh_path) + commands.append("set {p}".format(p=" ".join(bh_path))) + dist = route["blackhole_config"].get("distance") + if dist: + client.configure_set(bh_path + ["distance"], str(dist)) + commands.append( + "set {p} distance {d}".format(p=" ".join(bh_path), d=dist), + ) + + for nh in route.get("next_hops") or []: + nh_addr = nh["forward_router_address"] + nh_path = base + [dest, "next-hop", nh_addr] + client.configure_set(nh_path) + commands.append("set {p}".format(p=" ".join(nh_path))) + if nh.get("admin_distance"): + client.configure_set(nh_path + ["distance"], str(nh["admin_distance"])) + commands.append( + "set {p} distance {d}".format( + p=" ".join(nh_path), + d=nh["admin_distance"], + ), + ) + if "enabled" in nh and not nh["enabled"]: + client.configure_set(nh_path + ["disable"]) + commands.append("set {p} disable".format(p=" ".join(nh_path))) + if nh.get("interface"): + client.configure_set(nh_path + ["interface"], nh["interface"]) + commands.append( + "set {p} interface {i}".format( + p=" ".join(nh_path), + i=nh["interface"], + ), + ) + + +def main(): + argument_spec = dict( + config=dict( + type="list", + elements="dict", + options=dict( + address_families=dict( + type="list", + elements="dict", + options=dict( + afi=dict(type="str", required=True, choices=["ipv4", "ipv6"]), + routes=dict( + type="list", + elements="dict", + options=dict( + dest=dict(type="str", required=True), + blackhole_config=dict( + type="dict", + options=dict( + distance=dict(type="int"), + type=dict(type="str"), + ), + ), + next_hops=dict( + type="list", + elements="dict", + options=dict( + forward_router_address=dict( + type="str", + required=True, + ), + admin_distance=dict(type="int"), + enabled=dict(type="bool", default=True), + interface=dict(type="str"), + ), + ), + ), + ), + ), + ), + ), + ), + state=dict( + type="str", + default="merged", + choices=["merged", "replaced", "overridden", "deleted", "gathered"], + ), + ) + argument_spec.update(VYOS_REST_CONNECTION_ARGSPEC) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + client = VyOSRestClient(module) + state = module.params["state"] + config = module.params.get("config") or [] + commands = [] + changed = False + + before = _get_static_routes(client) + + if state == "gathered": + module.exit_json(changed=False, gathered=before, before=before, commands=[]) + + if module.check_mode: + module.exit_json(changed=True, before=before, commands=["(check mode)"]) + + try: + if state in ("overridden", "deleted") and not config: + # Delete all static routes + for afi_key in ("route", "route6"): + try: + client.configure_delete(["protocols", "static", afi_key]) + commands.append( + "delete protocols static {k}".format(k=afi_key), + ) + except VyOSRestError: + pass + changed = True + + elif state == "deleted" and config: + for entry in config: + for af in entry.get("address_families") or []: + afi = af["afi"] + base = _ROUTE_BASE[afi] + for route in af.get("routes") or []: + try: + client.configure_delete(base + [route["dest"]]) + commands.append( + "delete {p} {d}".format( + p=" ".join(base), + d=route["dest"], + ), + ) + except VyOSRestError: + pass + changed = True + + elif state in ("merged", "replaced", "overridden"): + if state in ("replaced", "overridden"): + # Remove existing routes for affected destinations + dests_by_afi = {} + for entry in config: + for af in entry.get("address_families") or []: + afi = af["afi"] + dests_by_afi.setdefault(afi, set()) + for route in af.get("routes") or []: + dests_by_afi[afi].add(route["dest"]) + if state == "overridden": + for afi_key in ("route", "route6"): + try: + client.configure_delete( + ["protocols", "static", afi_key], + ) + commands.append( + "delete protocols static {k}".format(k=afi_key), + ) + except VyOSRestError: + pass + else: + for afi, dests in dests_by_afi.items(): + base = _ROUTE_BASE[afi] + for dest in dests: + try: + client.configure_delete(base + [dest]) + commands.append( + "delete {p} {d}".format( + p=" ".join(base), + d=dest, + ), + ) + except VyOSRestError: + pass + + for entry in config: + for af in entry.get("address_families") or []: + afi = af["afi"] + for route in af.get("routes") or []: + _apply_route(client, afi, route, commands) + changed = True + except VyOSRestError as exc: + module.fail_json(msg=str(exc)) + + after = _get_static_routes(client) if changed else before + module.exit_json(changed=changed, before=before, after=after, commands=commands) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/vyos_system.py b/plugins/modules/vyos_system.py new file mode 100644 index 0000000..d1d696e --- /dev/null +++ b/plugins/modules/vyos_system.py @@ -0,0 +1,147 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# GNU General Public License v3.0+ + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: vyos_system +short_description: Manage system settings on VyOS via the REST API. +description: + - Manages system-level settings — hostname, domain name, name servers, + domain search list — using the VyOS HTTPS REST API. + - Mirrors C(vyos.vyos.vyos_system) but uses the HTTP API. +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + host_name: + description: System hostname. + type: str + domain_name: + description: System domain name. + type: str + name_server: + description: List of DNS name servers. Mutually exclusive with domain_search. + type: list + elements: str + aliases: [name_servers] + domain_search: + description: List of domain search suffixes. Mutually exclusive with name_server. + type: list + elements: str + state: + description: C(present) to apply, C(absent) to remove. + type: str + choices: [present, absent] + default: present + hostname: + type: str + required: true + port: + type: int + default: 443 + api_key: + type: str + required: true + no_log: true + timeout: + type: int + default: 30 + verify_ssl: + type: bool + default: false +""" + +RETURN = r""" +commands: + description: set/delete commands issued. + returned: always + type: list +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import ( + VYOS_REST_CONNECTION_ARGSPEC, + VyOSRestClient, + VyOSRestError, +) + + +def main(): + argument_spec = dict( + host_name=dict(type="str"), + domain_name=dict(type="str"), + name_server=dict(type="list", elements="str", aliases=["name_servers"]), + domain_search=dict(type="list", elements="str"), + state=dict(type="str", default="present", choices=["present", "absent"]), + ) + argument_spec.update(VYOS_REST_CONNECTION_ARGSPEC) + module = AnsibleModule( + argument_spec=argument_spec, + mutually_exclusive=[["name_server", "domain_search"]], + supports_check_mode=True, + ) + + if module.check_mode: + module.exit_json(changed=True, commands=["(check mode)"]) + + client = VyOSRestClient(module) + state = module.params["state"] + commands = [] + changed = False + + try: + if state == "present": + if module.params.get("host_name"): + client.configure_set(["system", "host-name"], module.params["host_name"]) + commands.append("set system host-name '{h}'".format(h=module.params["host_name"])) + changed = True + if module.params.get("domain_name"): + client.configure_set(["system", "domain-name"], module.params["domain_name"]) + commands.append( + "set system domain-name '{d}'".format(d=module.params["domain_name"]), + ) + changed = True + for ns in module.params.get("name_server") or []: + client.configure_set(["system", "name-server"], ns) + commands.append("set system name-server {ns}".format(ns=ns)) + changed = True + for ds in module.params.get("domain_search") or []: + client.configure_set(["system", "domain-search", "domain"], ds) + commands.append("set system domain-search domain {ds}".format(ds=ds)) + changed = True + else: + if module.params.get("host_name"): + try: + client.configure_delete(["system", "host-name"]) + commands.append("delete system host-name") + changed = True + except VyOSRestError: + pass + if module.params.get("domain_name"): + try: + client.configure_delete(["system", "domain-name"]) + commands.append("delete system domain-name") + changed = True + except VyOSRestError: + pass + if module.params.get("name_server"): + try: + client.configure_delete(["system", "name-server"]) + commands.append("delete system name-server") + changed = True + except VyOSRestError: + pass + except VyOSRestError as exc: + module.fail_json(msg=str(exc)) + + module.exit_json(changed=changed, commands=commands) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/vyos_user.py b/plugins/modules/vyos_user.py new file mode 100644 index 0000000..41e014d --- /dev/null +++ b/plugins/modules/vyos_user.py @@ -0,0 +1,282 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# GNU General Public License v3.0+ + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: vyos_user +short_description: Manage local users on VyOS via the REST API. +description: + - Manages local user accounts on VyOS devices using the HTTPS REST API. + - Mirrors C(vyos.vyos.vyos_user) but uses the HTTP API. +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + aggregate: + description: List of user definitions. Mutually exclusive with I(name). + type: list + elements: dict + aliases: [users, collection] + suboptions: + name: + description: Username. + type: str + required: true + full_name: + description: Full name. + type: str + configured_password: + description: Plaintext password (will be hashed on device). + type: str + no_log: true + encrypted_password: + description: Pre-hashed password string. + type: str + no_log: true + update_password: + description: When to update the password. + type: str + choices: [always, on_create] + level: + description: User privilege level. + type: str + choices: [admin, operator] + public_keys: + description: SSH public keys for authentication. + type: list + elements: dict + suboptions: + name: + description: Key identifier (e.g. user@host). + type: str + required: true + key: + description: Base64-encoded public key. + type: str + required: true + type: + description: Key type. + type: str + required: true + choices: [ssh-dss, ssh-rsa, ecdsa-sha2-nistp256, + ecdsa-sha2-nistp384, ssh-ed25519, ecdsa-sha2-nistp521] + state: + description: Present or absent. + type: str + choices: [present, absent] + name: + description: Single username. Mutually exclusive with I(aggregate). + type: str + full_name: + description: Full name for the single user. + type: str + configured_password: + description: Plaintext password. + type: str + no_log: true + encrypted_password: + description: Pre-hashed password. + type: str + no_log: true + update_password: + type: str + choices: [always, on_create] + level: + type: str + choices: [admin, operator] + public_keys: + type: list + elements: dict + suboptions: + name: + type: str + required: true + key: + type: str + required: true + type: + type: str + required: true + state: + description: Whether to create (present) or remove (absent) the user. + type: str + choices: [present, absent] + default: present + hostname: + type: str + required: true + port: + type: int + default: 443 + api_key: + type: str + required: true + no_log: true + timeout: + type: int + default: 30 + verify_ssl: + type: bool + default: false +""" + +RETURN = r""" +commands: + description: set/delete commands issued. + returned: always + type: list +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import ( + VYOS_REST_CONNECTION_ARGSPEC, + VyOSRestClient, + VyOSRestError, +) + + +_KEY_TYPES = [ + "ssh-dss", + "ssh-rsa", + "ecdsa-sha2-nistp256", + "ecdsa-sha2-nistp384", + "ssh-ed25519", + "ecdsa-sha2-nistp521", +] + + +def _apply_user(client, user_def, commands, update_password="always"): + name = user_def["name"] + base = ["system", "login", "user", name] + client.configure_set(base) + commands.append("set system login user {n}".format(n=name)) + + if user_def.get("full_name"): + client.configure_set(base + ["full-name"], user_def["full_name"]) + if user_def.get("level"): + client.configure_set(base + ["level"], user_def["level"]) + if user_def.get("configured_password") and update_password in ("always",): + client.configure_set( + base + ["authentication", "plaintext-password"], + user_def["configured_password"], + ) + commands.append( + "set system login user {n} authentication plaintext-password".format(n=name), + ) + if user_def.get("encrypted_password"): + client.configure_set( + base + ["authentication", "encrypted-password"], + user_def["encrypted_password"], + ) + for pk in user_def.get("public_keys") or []: + kb = base + ["authentication", "public-keys", pk["name"]] + client.configure_set(kb + ["key"], pk["key"]) + client.configure_set(kb + ["type"], pk["type"]) + commands.append( + "set system login user {n} authentication public-keys {k}".format( + n=name, + k=pk["name"], + ), + ) + + +def _delete_user(client, name, commands): + try: + client.configure_delete(["system", "login", "user", name]) + commands.append("delete system login user {n}".format(n=name)) + except VyOSRestError: + pass + + +def main(): + pk_spec = dict( + type="list", + elements="dict", + options=dict( + name=dict(type="str", required=True), + key=dict(type="str", required=True), + type=dict(type="str", required=True, choices=_KEY_TYPES), + ), + ) + user_spec = dict( + name=dict(type="str", required=True), + full_name=dict(type="str"), + configured_password=dict(type="str", no_log=True), + encrypted_password=dict(type="str", no_log=True), + update_password=dict(type="str", choices=["always", "on_create"]), + level=dict(type="str", choices=["admin", "operator"]), + public_keys=pk_spec, + state=dict(type="str", choices=["present", "absent"]), + ) + argument_spec = dict( + aggregate=dict( + type="list", + elements="dict", + aliases=["users", "collection"], + options=user_spec, + ), + name=dict(type="str"), + full_name=dict(type="str"), + configured_password=dict(type="str", no_log=True), + encrypted_password=dict(type="str", no_log=True), + update_password=dict(type="str", choices=["always", "on_create"]), + level=dict(type="str", choices=["admin", "operator"]), + public_keys=pk_spec, + state=dict(type="str", default="present", choices=["present", "absent"]), + ) + argument_spec.update(VYOS_REST_CONNECTION_ARGSPEC) + module = AnsibleModule( + argument_spec=argument_spec, + mutually_exclusive=[["aggregate", "name"]], + supports_check_mode=True, + ) + if module.check_mode: + module.exit_json(changed=True, commands=["(check mode)"]) + + client = VyOSRestClient(module) + commands = [] + changed = False + + # Build unified user list + users = module.params.get("aggregate") or [] + if module.params.get("name"): + users = [ + { + "name": module.params["name"], + "full_name": module.params.get("full_name"), + "configured_password": module.params.get("configured_password"), + "encrypted_password": module.params.get("encrypted_password"), + "update_password": module.params.get("update_password", "always"), + "level": module.params.get("level"), + "public_keys": module.params.get("public_keys"), + "state": module.params.get("state", "present"), + }, + ] + + try: + for u in users: + u_state = u.get("state") or module.params.get("state", "present") + if u_state == "absent": + _delete_user(client, u["name"], commands) + else: + _apply_user( + client, + u, + commands, + update_password=u.get("update_password") or "always", + ) + changed = True + except VyOSRestError as exc: + module.fail_json(msg=str(exc)) + + module.exit_json(changed=changed, commands=commands) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/vyos_vlan.py b/plugins/modules/vyos_vlan.py new file mode 100644 index 0000000..18eca92 --- /dev/null +++ b/plugins/modules/vyos_vlan.py @@ -0,0 +1,197 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# GNU General Public License v3.0+ + +from __future__ import absolute_import, division, print_function + + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: vyos_vlan +short_description: Manage VLANs on VyOS network devices via REST API. +description: + - Manages 802.1Q VLAN sub-interfaces on VyOS Ethernet interfaces using + the HTTPS REST API. + - Mirrors C(vyos.vyos.vyos_vlan) but uses the HTTP API. +version_added: "1.0.0" +author: + - VyOS Community (@vyos) +options: + vlan_id: + description: VLAN ID (0-4094). + type: int + name: + description: VLAN name (used as description on the VIF). + type: str + address: + description: IP address for the VLAN interface. + type: str + interfaces: + description: List of physical interfaces to configure the VLAN on. + type: list + elements: str + aggregate: + description: List of VLAN definitions. + type: list + elements: dict + suboptions: + vlan_id: + type: int + required: true + name: + type: str + address: + type: str + interfaces: + type: list + elements: str + required: true + state: + type: str + choices: [present, absent] + state: + description: C(present) to configure, C(absent) to remove. + type: str + choices: [present, absent] + default: present + purge: + description: Remove VLANs not defined in aggregate. + type: bool + default: false + hostname: + type: str + required: true + port: + type: int + default: 443 + api_key: + type: str + required: true + no_log: true + timeout: + type: int + default: 30 + verify_ssl: + type: bool + default: false +""" + +RETURN = r""" +commands: + description: set/delete commands issued. + returned: always + type: list +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.vyos.rest.plugins.module_utils.vyos_rest import ( + VYOS_REST_CONNECTION_ARGSPEC, + VyOSRestClient, + VyOSRestError, +) + + +def _apply_vlan(client, vlan, commands): + vid = str(vlan["vlan_id"]) + for iface in vlan.get("interfaces") or []: + # Detect interface type + if iface.startswith("bond"): + itype = "bonding" + else: + itype = "ethernet" + vif_base = ["interfaces", itype, iface, "vif", vid] + client.configure_set(vif_base) + commands.append("set interfaces {t} {i} vif {v}".format(t=itype, i=iface, v=vid)) + if vlan.get("name"): + client.configure_set(vif_base + ["description"], vlan["name"]) + commands.append( + "set interfaces {t} {i} vif {v} description '{n}'".format( + t=itype, + i=iface, + v=vid, + n=vlan["name"], + ), + ) + if vlan.get("address"): + client.configure_set(vif_base + ["address"], vlan["address"]) + commands.append( + "set interfaces {t} {i} vif {v} address {a}".format( + t=itype, + i=iface, + v=vid, + a=vlan["address"], + ), + ) + + +def _delete_vlan(client, vlan, commands): + vid = str(vlan["vlan_id"]) + for iface in vlan.get("interfaces") or []: + itype = "bonding" if iface.startswith("bond") else "ethernet" + try: + client.configure_delete(["interfaces", itype, iface, "vif", vid]) + commands.append("delete interfaces {t} {i} vif {v}".format(t=itype, i=iface, v=vid)) + except VyOSRestError: + pass + + +def main(): + vlan_spec = dict( + vlan_id=dict(type="int", required=True), + name=dict(type="str"), + address=dict(type="str"), + interfaces=dict(type="list", elements="str"), + state=dict(type="str", choices=["present", "absent"]), + ) + argument_spec = dict( + vlan_id=dict(type="int"), + name=dict(type="str"), + address=dict(type="str"), + interfaces=dict(type="list", elements="str"), + aggregate=dict(type="list", elements="dict", options=vlan_spec), + state=dict(type="str", default="present", choices=["present", "absent"]), + purge=dict(type="bool", default=False), + ) + argument_spec.update(VYOS_REST_CONNECTION_ARGSPEC) + module = AnsibleModule( + argument_spec=argument_spec, + mutually_exclusive=[["aggregate", "vlan_id"]], + supports_check_mode=True, + ) + if module.check_mode: + module.exit_json(changed=True, commands=["(check mode)"]) + + client = VyOSRestClient(module) + commands = [] + changed = False + + vlans = module.params.get("aggregate") or [] + if module.params.get("vlan_id"): + vlans = [ + { + "vlan_id": module.params["vlan_id"], + "name": module.params.get("name"), + "address": module.params.get("address"), + "interfaces": module.params.get("interfaces") or [], + "state": module.params.get("state", "present"), + }, + ] + + try: + for vlan in vlans: + v_state = vlan.get("state") or module.params.get("state", "present") + if v_state == "absent": + _delete_vlan(client, vlan, commands) + else: + _apply_vlan(client, vlan, commands) + changed = True + except VyOSRestError as exc: + module.fail_json(msg=str(exc)) + + module.exit_json(changed=changed, commands=commands) + + +if __name__ == "__main__": + main() |
