summaryrefslogtreecommitdiff
path: root/plugins
diff options
context:
space:
mode:
authoromnom62 <75066712+omnom62@users.noreply.github.com>2026-08-21 22:12:11 +1000
committerGitHub <noreply@github.com>2026-08-21 15:12:11 +0300
commit899a6bf7955592ec40670944a860a1bee97b432c (patch)
treefa35031ba10df9ab41376529363664dda0ea7d4c /plugins
parentd80f14eef50403f0851c9a12b77890c9bf1f3c4d (diff)
downloadvyos.vyos-899a6bf7955592ec40670944a860a1bee97b432c.tar.gz
vyos.vyos-899a6bf7955592ec40670944a860a1bee97b432c.zip
T8321: vpn_ipsec modules (#489)
Add VPN IPsec module
Diffstat (limited to 'plugins')
-rw-r--r--plugins/module_utils/network/vyos/argspec/vpn_ipsec/__init__.py0
-rw-r--r--plugins/module_utils/network/vyos/argspec/vpn_ipsec/vpn_ipsec.py206
-rw-r--r--plugins/module_utils/network/vyos/argspec/vpn_ipsec_s2s/__init__.py0
-rw-r--r--plugins/module_utils/network/vyos/argspec/vpn_ipsec_s2s/vpn_ipsec_s2s.py186
-rw-r--r--plugins/module_utils/network/vyos/config/vpn_ipsec/__init__.py0
-rw-r--r--plugins/module_utils/network/vyos/config/vpn_ipsec/vpn_ipsec.py665
-rw-r--r--plugins/module_utils/network/vyos/config/vpn_ipsec_s2s/__init__.py0
-rw-r--r--plugins/module_utils/network/vyos/config/vpn_ipsec_s2s/vpn_ipsec_s2s.py355
-rw-r--r--plugins/module_utils/network/vyos/facts/facts.py8
-rw-r--r--plugins/module_utils/network/vyos/facts/vpn_ipsec/__init__.py0
-rw-r--r--plugins/module_utils/network/vyos/facts/vpn_ipsec/vpn_ipsec.py113
-rw-r--r--plugins/module_utils/network/vyos/facts/vpn_ipsec_s2s/__init__.py0
-rw-r--r--plugins/module_utils/network/vyos/facts/vpn_ipsec_s2s/vpn_ipsec_s2s.py115
-rw-r--r--plugins/module_utils/network/vyos/rm_templates/vpn_ipsec.py997
-rw-r--r--plugins/module_utils/network/vyos/rm_templates/vpn_ipsec_s2s.py670
-rw-r--r--plugins/modules/vyos_vpn_ipsec.py454
-rw-r--r--plugins/modules/vyos_vpn_ipsec_s2s.py337
17 files changed, 4106 insertions, 0 deletions
diff --git a/plugins/module_utils/network/vyos/argspec/vpn_ipsec/__init__.py b/plugins/module_utils/network/vyos/argspec/vpn_ipsec/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/plugins/module_utils/network/vyos/argspec/vpn_ipsec/__init__.py
diff --git a/plugins/module_utils/network/vyos/argspec/vpn_ipsec/vpn_ipsec.py b/plugins/module_utils/network/vyos/argspec/vpn_ipsec/vpn_ipsec.py
new file mode 100644
index 00000000..75fc1ce5
--- /dev/null
+++ b/plugins/module_utils/network/vyos/argspec/vpn_ipsec/vpn_ipsec.py
@@ -0,0 +1,206 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Red Hat
+# GNU General Public License v3.0+
+# (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import absolute_import, division, print_function
+
+
+__metaclass__ = type
+
+#############################################
+# WARNING #
+#############################################
+#
+# This file is auto generated by the
+# cli_rm_builder.
+#
+# Manually editing this file is not advised.
+#
+# To update the argspec make the desired changes
+# in the module docstring and re-run
+# cli_rm_builder.
+#
+#############################################
+
+"""
+The arg spec for the vyos_vpn_ipsec module
+"""
+
+
+class Vpn_ipsecArgs(object): # pylint: disable=R0903
+ """The arg spec for the vyos_vpn_ipsec module"""
+
+ argument_spec = {
+ "config": {
+ "type": "dict",
+ "options": {
+ "ike_group": {
+ "type": "list",
+ "elements": "dict",
+ "options": {
+ "name": {"type": "str", "required": True},
+ "close_action": {
+ "type": "str",
+ "choices": ["none", "trap", "start"],
+ },
+ "dead_peer_detection": {
+ "type": "dict",
+ "options": {
+ "action": {
+ "type": "str",
+ "choices": ["trap", "clear", "restart"],
+ },
+ "interval": {"type": "int"},
+ "timeout": {"type": "int"},
+ },
+ },
+ "disable_mobike": {"type": "bool"},
+ "ikev2_reauth": {"type": "bool"},
+ "key_exchange": {
+ "type": "str",
+ "choices": ["ikev1", "ikev2"],
+ },
+ "lifetime": {"type": "int"},
+ "mode": {"type": "str", "choices": ["main", "aggressive"]},
+ "proposal": {
+ "type": "list",
+ "elements": "dict",
+ "options": {
+ "proposal_id": {"type": "int"},
+ "dh_group": {"type": "int"},
+ "encryption": {"type": "str"},
+ "hash": {"type": "str"},
+ "prf": {"type": "str"},
+ },
+ },
+ },
+ },
+ "esp_group": {
+ "type": "list",
+ "elements": "dict",
+ "options": {
+ "name": {"type": "str", "required": True},
+ "compression": {"type": "bool"},
+ "disable_rekey": {"type": "bool"},
+ "life_bytes": {"type": "int"},
+ "life_packets": {"type": "int"},
+ "lifetime": {"type": "int"},
+ "mode": {
+ "type": "str",
+ "choices": ["tunnel", "transport"],
+ },
+ "pfs": {"type": "str"},
+ "proposal": {
+ "type": "list",
+ "elements": "dict",
+ "options": {
+ "proposal_id": {"type": "int"},
+ "encryption": {"type": "str"},
+ "hash": {"type": "str"},
+ },
+ },
+ },
+ },
+ "authentication": {
+ "type": "dict",
+ "options": {
+ "psk": {
+ "type": "list",
+ "elements": "dict",
+ "options": {
+ "name": {"type": "str", "required": True},
+ "id": {"type": "list", "elements": "str"},
+ "dhcp_interface": {
+ "type": "list",
+ "elements": "str",
+ },
+ "secret": {"type": "str", "no_log": True},
+ "secret_type": {
+ "type": "str",
+ "choices": ["base64", "hex", "plaintext"],
+ },
+ },
+ },
+ "ppk": {
+ "type": "list",
+ "elements": "dict",
+ "options": {
+ "name": {"type": "str", "required": True},
+ "id": {"type": "list", "elements": "str"},
+ "secret": {"type": "str", "no_log": True},
+ "secret_type": {
+ "type": "str",
+ "choices": ["base64", "hex", "plaintext"],
+ },
+ },
+ },
+ },
+ },
+ "profile": {
+ "type": "list",
+ "elements": "dict",
+ "options": {
+ "name": {"type": "str", "required": True},
+ "authentication": {
+ "type": "dict",
+ "options": {
+ "mode": {
+ "type": "str",
+ "choices": ["pre-shared-secret"],
+ },
+ "pre_shared_secret": {"type": "str", "no_log": True},
+ },
+ },
+ "bind_tunnel": {"type": "list", "elements": "str"},
+ "disable": {"type": "bool"},
+ "esp_group": {"type": "str"},
+ "ike_group": {"type": "str"},
+ },
+ },
+ "interface": {"type": "list", "elements": "str"},
+ "log": {
+ "type": "dict",
+ "options": {
+ "level": {"type": "int"},
+ "subsystem": {
+ "type": "list",
+ "elements": "str",
+ },
+ },
+ },
+ "options": {
+ "type": "dict",
+ "options": {
+ "disable_route_autoinstall": {"type": "bool"},
+ "flexvpn": {"type": "bool"},
+ "interface": {"type": "str"},
+ "retransmission": {
+ "type": "dict",
+ "options": {
+ "attempts": {"type": "int"},
+ "base": {"type": "float"},
+ "timeout": {"type": "int"},
+ },
+ },
+ "virtual_ip": {"type": "bool"},
+ },
+ },
+ "disable_uniqreqids": {"type": "bool"},
+ },
+ },
+ "running_config": {"type": "str"},
+ "state": {
+ "type": "str",
+ "choices": [
+ "merged",
+ "replaced",
+ "overridden",
+ "deleted",
+ "gathered",
+ "rendered",
+ "parsed",
+ ],
+ "default": "merged",
+ },
+ } # pylint: disable=C0301
diff --git a/plugins/module_utils/network/vyos/argspec/vpn_ipsec_s2s/__init__.py b/plugins/module_utils/network/vyos/argspec/vpn_ipsec_s2s/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/plugins/module_utils/network/vyos/argspec/vpn_ipsec_s2s/__init__.py
diff --git a/plugins/module_utils/network/vyos/argspec/vpn_ipsec_s2s/vpn_ipsec_s2s.py b/plugins/module_utils/network/vyos/argspec/vpn_ipsec_s2s/vpn_ipsec_s2s.py
new file mode 100644
index 00000000..bd23d4d3
--- /dev/null
+++ b/plugins/module_utils/network/vyos/argspec/vpn_ipsec_s2s/vpn_ipsec_s2s.py
@@ -0,0 +1,186 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Red Hat
+# GNU General Public License v3.0+
+# (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import absolute_import, division, print_function
+
+
+__metaclass__ = type
+
+#############################################
+# WARNING #
+#############################################
+#
+# This file is auto generated by the
+# cli_rm_builder.
+#
+# Manually editing this file is not advised.
+#
+# To update the argspec make the desired changes
+# in the module docstring and re-run
+# cli_rm_builder.
+#
+#############################################
+
+"""
+The arg spec for the vyos_vpn_ipsec_s2s module
+"""
+
+
+class Vpn_ipsec_s2sArgs(object): # pylint: disable=R0903
+ """The arg spec for the vyos_vpn_ipsec_s2s module"""
+
+ argument_spec = {
+ "config": {
+ "type": "dict",
+ "options": {
+ "peer": {
+ "type": "list",
+ "elements": "dict",
+ "options": {
+ "name": {"type": "str", "required": True},
+ "disable": {"type": "bool"},
+ "authentication": {
+ "type": "dict",
+ "options": {
+ "local_id": {"type": "str"},
+ "ppk": {
+ "type": "dict",
+ "options": {
+ "id": {"type": "str"},
+ "required": {"type": "bool"},
+ },
+ },
+ "rsa": {
+ "type": "dict",
+ "options": {
+ "local_key": {"type": "str", "no_log": True},
+ "passphrase": {"type": "str", "no_log": True},
+ "remote_key": {"type": "str", "no_log": True},
+ },
+ },
+ "x509": {
+ "type": "dict",
+ "options": {
+ "certificate": {"type": "str"},
+ "passphrase": {"type": "str", "no_log": True},
+ "ca_certificate": {
+ "type": "list",
+ "elements": "str",
+ },
+ },
+ },
+ "mode": {
+ "type": "str",
+ "choices": [
+ "pre-shared-secret",
+ "rsa",
+ "x509",
+ ],
+ },
+ "remote_id": {"type": "str"},
+ "use_x509_id": {"type": "bool"},
+ },
+ },
+ "childless": {
+ "type": "str",
+ "choices": ["allow", "prefer", "force", "never"],
+ },
+ "connection_type": {
+ "type": "str",
+ "choices": ["initiate", "trap", "none"],
+ },
+ "default_esp_group": {"type": "str"},
+ "description": {"type": "str"},
+ "dhcp_interface": {"type": "str"},
+ "force_udp_encapsulation": {"type": "bool"},
+ "ike_group": {"type": "str"},
+ "ikev2_reauth": {
+ "type": "str",
+ "choices": ["yes", "no", "inherit"],
+ },
+ "local_address": {"type": "str"},
+ "remote_address": {"type": "list", "elements": "str"},
+ "replay_window": {"type": "int"},
+ "tunnel": {
+ "type": "list",
+ "elements": "dict",
+ "options": {
+ "tunnel_id": {"type": "int", "required": True},
+ "disable": {"type": "bool"},
+ "esp_group": {"type": "str"},
+ "local": {
+ "type": "dict",
+ "options": {
+ "port": {"type": "int"},
+ "prefix": {
+ "type": "list",
+ "elements": "str",
+ },
+ },
+ },
+ "protocol": {"type": "str"},
+ "priority": {"type": "int"},
+ "remote": {
+ "type": "dict",
+ "options": {
+ "port": {"type": "int"},
+ "prefix": {
+ "type": "list",
+ "elements": "str",
+ },
+ },
+ },
+ },
+ },
+ "virtual_address": {"type": "list", "elements": "str"},
+ "vti": {
+ "type": "dict",
+ "options": {
+ "bind": {"type": "str"},
+ "esp_group": {"type": "str"},
+ "traffic_selector": {
+ "type": "dict",
+ "options": {
+ "local": {
+ "type": "dict",
+ "options": {
+ "prefix": {
+ "type": "list",
+ "elements": "str",
+ },
+ },
+ },
+ "remote": {
+ "type": "dict",
+ "options": {
+ "prefix": {
+ "type": "list",
+ "elements": "str",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ "running_config": {"type": "str"},
+ "state": {
+ "type": "str",
+ "choices": [
+ "merged",
+ "replaced",
+ "overridden",
+ "deleted",
+ "gathered",
+ "rendered",
+ "parsed",
+ ],
+ "default": "merged",
+ },
+ } # pylint: disable=C0301
diff --git a/plugins/module_utils/network/vyos/config/vpn_ipsec/__init__.py b/plugins/module_utils/network/vyos/config/vpn_ipsec/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/plugins/module_utils/network/vyos/config/vpn_ipsec/__init__.py
diff --git a/plugins/module_utils/network/vyos/config/vpn_ipsec/vpn_ipsec.py b/plugins/module_utils/network/vyos/config/vpn_ipsec/vpn_ipsec.py
new file mode 100644
index 00000000..2d986cb4
--- /dev/null
+++ b/plugins/module_utils/network/vyos/config/vpn_ipsec/vpn_ipsec.py
@@ -0,0 +1,665 @@
+#
+# -*- coding: utf-8 -*-
+# Copyright 2026 Red Hat
+# GNU General Public License v3.0+
+# (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+#
+
+from __future__ import absolute_import, division, print_function
+
+
+__metaclass__ = type
+
+"""
+The vyos_vpn_ipsec config file.
+It is in this file where the current configuration (as dict)
+is compared to the provided configuration (as dict) and the command set
+necessary to bring the current configuration to its desired end-state is
+created.
+
+Follows the established per-module convention used by vyos_ha/vyos_nat
+(list-to-dict conversion + explicit per-state branching in
+generate_commands), rather than a shared generic engine.
+
+State semantics (standard Ansible RM convention, confirmed against a
+real device run that caught a bug in an earlier version of this file):
+ - merged: only items/fields named in `want` are touched. Nothing
+ absent from `want` is ever deleted.
+ - replaced: only items NAMED in `want` are touched (same item scope
+ as merged) -- but for each named item, its full state is
+ reconciled to exactly match `want` (fields present in
+ `have` but omitted from `want` ARE deleted). Items not
+ named in `want` at all are left completely alone.
+ - overridden: every item is in scope, including ones absent from
+ `want` entirely -- those get deleted wholesale. Named
+ items are reconciled the same way as `replaced`.
+
+This is implemented via two independent flags:
+ - select_all: whether item iteration considers have-only items too
+ (True only for overridden; False for merged/replaced).
+ - reconcile: whether omitted fields within an already-selected item
+ get deleted (True for replaced/overridden; False for
+ merged/rendered).
+"""
+
+from copy import deepcopy
+
+from ansible_collections.ansible.netcommon.plugins.module_utils.network.common.rm_base.resource_module import (
+ ResourceModule,
+)
+from ansible_collections.ansible.netcommon.plugins.module_utils.network.common.utils import (
+ dict_merge,
+)
+
+from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.facts.facts import (
+ Facts,
+)
+from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.rm_templates.vpn_ipsec import (
+ Vpn_ipsecTemplate,
+)
+
+
+class Vpn_ipsec(ResourceModule):
+ """
+ The vyos_vpn_ipsec config class
+ """
+
+ def __init__(self, module):
+ super(Vpn_ipsec, self).__init__(
+ empty_fact_val={},
+ facts_module=Facts(module),
+ module=module,
+ resource="vpn_ipsec",
+ tmplt=Vpn_ipsecTemplate(),
+ )
+ self.parsers = [
+ "esp_group",
+ "esp_group.proposal",
+ "esp_group.proposal.encryption",
+ "esp_group.proposal.hash",
+ "esp_group.compression",
+ "esp_group.disable_rekey",
+ "esp_group.life_bytes",
+ "esp_group.life_packets",
+ "esp_group.lifetime",
+ "esp_group.mode",
+ "esp_group.pfs",
+ "ike_group",
+ "ike_group.key_exchange",
+ "ike_group.proposal",
+ "ike_group.proposal.dh_group",
+ "ike_group.proposal.encryption",
+ "ike_group.proposal.hash",
+ "ike_group.close_action",
+ "ike_group.dead_peer_detection.action",
+ "ike_group.dead_peer_detection.interval",
+ "ike_group.dead_peer_detection.timeout",
+ "ike_group.disable_mobike",
+ "ike_group.ikev2_reauth",
+ "ike_group.lifetime",
+ "ike_group.mode",
+ "profile",
+ "profile.authentication.mode",
+ "profile.authentication.pre_shared_secret",
+ "profile.esp_group",
+ "profile.ike_group",
+ "profile.disable",
+ "authentication.psk.secret_type",
+ "authentication.psk.dhcp_interface",
+ "authentication.ppk",
+ "authentication.ppk.id",
+ "authentication.ppk.secret",
+ "authentication.ppk.secret_type",
+ "interface",
+ "log.level",
+ "log.subsystem",
+ "options.disable_route_autoinstall",
+ "options.flexvpn",
+ "options.interface",
+ "options.retransmission.attempts",
+ "options.retransmission.base",
+ "options.retransmission.timeout",
+ "options.virtual_ip",
+ "disable_uniqreqids",
+ ]
+
+ def execute_module(self):
+ """Execute the module
+
+ :rtype: A dictionary
+ :returns: The result from module execution
+ """
+ if self.state not in ["parsed", "gathered"]:
+ self.generate_commands()
+ self.run_commands()
+ return self.result
+
+ def generate_commands(self):
+ """Generate configuration commands to send based on
+ want, have and desired state.
+ """
+ wantd = deepcopy(self.want) or {}
+ haved = deepcopy(self.have) or {}
+
+ for entry in (wantd, haved):
+ self._list_to_dict(entry)
+
+ scoped_delete = None
+ if self.state == "deleted":
+ if wantd:
+ # user named specific items -- surgical removal of just
+ # those, everything else preserved (vyos_vrf precedent:
+ # deleted + instances:[{name: vrf-blue}] removes only
+ # vrf-blue). Capture what was named before wiping wantd.
+ scoped_delete = wantd
+ wantd = {}
+
+ if self.state == "merged":
+ # NOTE: list_to_dict() above must run BEFORE this. dict_merge
+ # concatenates lists rather than merging matching entries by
+ # key, so merging while ike_group/esp_group/etc are still
+ # lists would duplicate entries instead of filling in omitted
+ # fields from `have`. Once they're name-keyed dicts, dict_merge
+ # recurses per-key correctly, which is what lets a partial
+ # update (e.g. specifying only key_exchange) leave other
+ # existing fields on that same group untouched.
+ wantd = dict_merge(haved, wantd)
+
+ select_all = self.state in ("overridden", "deleted")
+ reconcile = self.state in ("replaced", "overridden", "deleted")
+
+ self._compare_esp_groups(wantd, haved, select_all, reconcile, scoped_delete)
+ self._compare_ike_groups(wantd, haved, select_all, reconcile, scoped_delete)
+ self._compare_profiles(wantd, haved, select_all, reconcile, scoped_delete)
+ self._compare_psks(wantd, haved, select_all, reconcile, scoped_delete)
+ self._compare_ppks(wantd, haved, select_all, reconcile, scoped_delete)
+ self._compare_top_level(wantd, haved, select_all, reconcile, scoped_delete)
+
+ self.commands = list(dict.fromkeys(self.commands))
+
+ # -------------------------------------------------------------------
+ # List -> name-keyed dict conversion (matches vyos_ha/vyos_nat style)
+ # -------------------------------------------------------------------
+
+ def _list_to_dict(self, config):
+ for key in ("ike_group", "esp_group", "profile"):
+ items = config.get(key)
+ if isinstance(items, list):
+ config[key] = {item["name"]: item for item in items}
+ for item in config[key].values():
+ if isinstance(item.get("proposal"), list):
+ item["proposal"] = {p["proposal_id"]: p for p in item["proposal"]}
+
+ auth = config.get("authentication", {})
+ for key in ("psk", "ppk"):
+ items = auth.get(key)
+ if isinstance(items, list):
+ auth[key] = {item["name"]: item for item in items}
+
+ # -------------------------------------------------------------------
+ # ESP groups
+ # -------------------------------------------------------------------
+
+ def _compare_esp_groups(self, wantd, haved, select_all, reconcile, scoped_delete=None):
+ have_groups = haved.get("esp_group", {})
+
+ if scoped_delete is not None:
+ for name in set(scoped_delete.get("esp_group", {})):
+ if name in have_groups:
+ self.commands.append("delete vpn ipsec esp-group {0}".format(name))
+ return
+
+ want_groups = wantd.get("esp_group", {})
+ names = set(want_groups) | set(have_groups) if select_all else set(want_groups)
+
+ for name in names:
+ w = want_groups.get(name, {})
+ h = have_groups.get(name, {})
+ if w == h:
+ continue
+
+ if name in have_groups and name not in want_groups:
+ # only reached when select_all (overridden): item entirely
+ # absent from want -> delete wholesale
+ self.commands.append("delete vpn ipsec esp-group {0}".format(name))
+ continue
+
+ if name not in have_groups:
+ self.addcmd({"name": name}, "esp_group", False)
+
+ for field in ("mode", "pfs", "lifetime", "life_bytes", "life_packets"):
+ self._cmp_scalar(
+ w,
+ h,
+ field,
+ {"name": name},
+ "esp_group.{0}".format(field),
+ reconcile,
+ )
+ for field in ("compression", "disable_rekey"):
+ self._cmp_bool(
+ w,
+ h,
+ field,
+ {"name": name},
+ "esp_group.{0}".format(field),
+ reconcile,
+ )
+
+ self._compare_proposals(
+ w.get("proposal", {}),
+ h.get("proposal", {}),
+ {"name": name},
+ "esp_group.proposal",
+ "esp_group.proposal.encryption",
+ "esp_group.proposal.hash",
+ None,
+ reconcile,
+ )
+
+ # -------------------------------------------------------------------
+ # IKE groups
+ # -------------------------------------------------------------------
+
+ def _compare_ike_groups(self, wantd, haved, select_all, reconcile, scoped_delete=None):
+ have_groups = haved.get("ike_group", {})
+
+ if scoped_delete is not None:
+ for name in set(scoped_delete.get("ike_group", {})):
+ if name in have_groups:
+ self.commands.append("delete vpn ipsec ike-group {0}".format(name))
+ return
+
+ want_groups = wantd.get("ike_group", {})
+ names = set(want_groups) | set(have_groups) if select_all else set(want_groups)
+
+ for name in names:
+ w = want_groups.get(name, {})
+ h = have_groups.get(name, {})
+ if w == h:
+ continue
+
+ if name in have_groups and name not in want_groups:
+ self.commands.append("delete vpn ipsec ike-group {0}".format(name))
+ continue
+
+ if name not in have_groups:
+ self.addcmd({"name": name}, "ike_group", False)
+
+ self._cmp_scalar(
+ w,
+ h,
+ "key_exchange",
+ {"name": name},
+ "ike_group.key_exchange",
+ reconcile,
+ )
+ for field in ("close_action", "lifetime", "mode"):
+ self._cmp_scalar(
+ w,
+ h,
+ field,
+ {"name": name},
+ "ike_group.{0}".format(field),
+ reconcile,
+ )
+ for field in ("disable_mobike", "ikev2_reauth"):
+ self._cmp_bool(
+ w,
+ h,
+ field,
+ {"name": name},
+ "ike_group.{0}".format(field),
+ reconcile,
+ )
+
+ w_dpd = w.get("dead_peer_detection", {})
+ h_dpd = h.get("dead_peer_detection", {})
+ for field in ("action", "interval", "timeout"):
+ self._cmp_scalar(
+ w_dpd,
+ h_dpd,
+ field,
+ {"name": name},
+ "ike_group.dead_peer_detection.{0}".format(field),
+ reconcile,
+ )
+
+ self._compare_proposals(
+ w.get("proposal", {}),
+ h.get("proposal", {}),
+ {"name": name},
+ "ike_group.proposal",
+ "ike_group.proposal.encryption",
+ "ike_group.proposal.hash",
+ "ike_group.proposal.dh_group",
+ reconcile,
+ )
+
+ # -------------------------------------------------------------------
+ # Proposals (shared by esp_group / ike_group)
+ # -------------------------------------------------------------------
+
+ def _compare_proposals(
+ self,
+ want_props,
+ have_props,
+ group_ctx,
+ bare_parser,
+ encryption_parser,
+ hash_parser,
+ dh_group_parser,
+ reconcile,
+ ):
+ # a proposal collection lives entirely inside an already-selected
+ # group -- once that group is in scope, its own proposals always
+ # get full reconciliation under replaced/overridden (never a
+ # separate select_all concern of their own).
+ ids = set(want_props) | set(have_props) if reconcile else set(want_props)
+ for pid in ids:
+ w = want_props.get(pid, {})
+ h = have_props.get(pid, {})
+ if w == h:
+ continue
+
+ if pid in have_props and pid not in want_props:
+ self.addcmd(dict(group_ctx, proposal_id=pid), bare_parser, True)
+ continue
+
+ if pid not in have_props:
+ self.addcmd(dict(group_ctx, proposal_id=pid), bare_parser, False)
+
+ ctx = dict(group_ctx, proposal_id=pid)
+ self._cmp_scalar(w, h, "encryption", ctx, encryption_parser, reconcile)
+ self._cmp_scalar(w, h, "hash", ctx, hash_parser, reconcile)
+ if dh_group_parser:
+ self._cmp_scalar(w, h, "dh_group", ctx, dh_group_parser, reconcile)
+
+ # -------------------------------------------------------------------
+ # Profiles
+ # -------------------------------------------------------------------
+
+ def _compare_profiles(self, wantd, haved, select_all, reconcile, scoped_delete=None):
+ have_profiles = haved.get("profile", {})
+
+ if scoped_delete is not None:
+ for name in set(scoped_delete.get("profile", {})):
+ if name in have_profiles:
+ self.commands.append("delete vpn ipsec profile {0}".format(name))
+ return
+
+ want_profiles = wantd.get("profile", {})
+ names = set(want_profiles) | set(have_profiles) if select_all else set(want_profiles)
+
+ for name in names:
+ w = want_profiles.get(name, {})
+ h = have_profiles.get(name, {})
+ if w == h:
+ continue
+
+ if name in have_profiles and name not in want_profiles:
+ self.commands.append("delete vpn ipsec profile {0}".format(name))
+ continue
+
+ if name not in have_profiles:
+ self.addcmd({"name": name}, "profile", False)
+
+ ctx = {"name": name}
+ w_auth = w.get("authentication", {})
+ h_auth = h.get("authentication", {})
+ self._cmp_scalar(
+ w_auth,
+ h_auth,
+ "mode",
+ ctx,
+ "profile.authentication.mode",
+ reconcile,
+ )
+ self._cmp_scalar(
+ w_auth,
+ h_auth,
+ "pre_shared_secret",
+ ctx,
+ "profile.authentication.pre_shared_secret",
+ reconcile,
+ )
+ self._cmp_scalar(w, h, "esp_group", ctx, "profile.esp_group", reconcile)
+ self._cmp_scalar(w, h, "ike_group", ctx, "profile.ike_group", reconcile)
+ self._cmp_bool(w, h, "disable", ctx, "profile.disable", reconcile)
+
+ w_tunnels = set(w.get("bind_tunnel") or [])
+ h_tunnels = set(h.get("bind_tunnel") or [])
+ for tun in w_tunnels - h_tunnels:
+ self.addcmd(dict(ctx, bind_tunnel=tun), "profile.bind_tunnel", False)
+ if reconcile:
+ for tun in h_tunnels - w_tunnels:
+ self.addcmd(dict(ctx, bind_tunnel=tun), "profile.bind_tunnel", True)
+
+ # -------------------------------------------------------------------
+ # PSKs
+ # -------------------------------------------------------------------
+
+ def _compare_psks(self, wantd, haved, select_all, reconcile, scoped_delete=None):
+ have_psks = haved.get("authentication", {}).get("psk", {})
+
+ if scoped_delete is not None:
+ for name in set(scoped_delete.get("authentication", {}).get("psk", {})):
+ if name in have_psks:
+ self.commands.append(
+ "delete vpn ipsec authentication psk {0}".format(name),
+ )
+ return
+
+ want_psks = wantd.get("authentication", {}).get("psk", {})
+ names = set(want_psks) | set(have_psks) if select_all else set(want_psks)
+
+ for name in names:
+ w = want_psks.get(name, {})
+ h = have_psks.get(name, {})
+ if w == h:
+ continue
+
+ if name in have_psks and name not in want_psks:
+ self.commands.append("delete vpn ipsec authentication psk {0}".format(name))
+ continue
+
+ if name not in have_psks:
+ self.addcmd({"name": name}, "authentication.psk", False)
+
+ ctx = {"name": name}
+ self._cmp_scalar(w, h, "secret", ctx, "authentication.psk.secret", reconcile)
+ self._cmp_scalar(
+ w,
+ h,
+ "secret_type",
+ ctx,
+ "authentication.psk.secret_type",
+ reconcile,
+ )
+
+ w_ids = set(w.get("id") or [])
+ h_ids = set(h.get("id") or [])
+ for i in w_ids - h_ids:
+ self.addcmd(dict(ctx, id=i), "authentication.psk.id", False)
+ if reconcile:
+ for i in h_ids - w_ids:
+ self.addcmd(dict(ctx, id=i), "authentication.psk.id", True)
+
+ w_dhcp = set(w.get("dhcp_interface") or [])
+ h_dhcp = set(h.get("dhcp_interface") or [])
+ for i in w_dhcp - h_dhcp:
+ self.addcmd(dict(ctx, dhcp_interface=i), "authentication.psk.dhcp_interface", False)
+ if reconcile:
+ for i in h_dhcp - w_dhcp:
+ self.addcmd(
+ dict(ctx, dhcp_interface=i),
+ "authentication.psk.dhcp_interface",
+ True,
+ )
+
+ def _compare_ppks(self, wantd, haved, select_all, reconcile, scoped_delete=None):
+ have_ppks = haved.get("authentication", {}).get("ppk", {})
+
+ if scoped_delete is not None:
+ for name in set(scoped_delete.get("authentication", {}).get("ppk", {})):
+ if name in have_ppks:
+ self.commands.append(
+ "delete vpn ipsec authentication ppk {0}".format(name),
+ )
+ return
+
+ want_ppks = wantd.get("authentication", {}).get("ppk", {})
+ names = set(want_ppks) | set(have_ppks) if select_all else set(want_ppks)
+
+ for name in names:
+ w = want_ppks.get(name, {})
+ h = have_ppks.get(name, {})
+ if w == h:
+ continue
+
+ if name in have_ppks and name not in want_ppks:
+ self.commands.append("delete vpn ipsec authentication ppk {0}".format(name))
+ continue
+
+ if name not in have_ppks:
+ self.addcmd({"name": name}, "authentication.ppk", False)
+
+ ctx = {"name": name}
+ self._cmp_scalar(w, h, "secret", ctx, "authentication.ppk.secret", reconcile)
+ self._cmp_scalar(
+ w,
+ h,
+ "secret_type",
+ ctx,
+ "authentication.ppk.secret_type",
+ reconcile,
+ )
+
+ w_ids = set(w.get("id") or [])
+ h_ids = set(h.get("id") or [])
+ for i in w_ids - h_ids:
+ self.addcmd(dict(ctx, id=i), "authentication.ppk.id", False)
+ if reconcile:
+ for i in h_ids - w_ids:
+ self.addcmd(dict(ctx, id=i), "authentication.ppk.id", True)
+
+ # -------------------------------------------------------------------
+ # Top-level scalar / list / bool fields
+ #
+ # NOTE: these are all direct fields of the single top-level config
+ # object, not named collections -- there is no "item entirely absent
+ # from want" concept here, only "field omitted from want". So only
+ # `reconcile` applies; `select_all` is irrelevant at this level (it's
+ # accepted for a consistent call signature but unused).
+ # -------------------------------------------------------------------
+
+ def _compare_top_level(self, wantd, haved, select_all, reconcile, scoped_delete=None):
+ if scoped_delete is not None:
+ # Principle: naming a parameter under scoped `deleted` means
+ # "delete this specific value" -- a scalar/bool key present
+ # (regardless of value) signals whole-field removal; a list
+ # value present means "delete exactly these elements", not
+ # the whole list, mirroring vyos_vrf's bind_to_all precedent
+ # extended consistently to list- and nested-dict-shaped
+ # fields.
+ if "disable_uniqreqids" in scoped_delete and haved.get("disable_uniqreqids"):
+ self.commands.append("delete vpn ipsec disable-uniqreqids")
+
+ h_ifaces = set(haved.get("interface") or [])
+ for i in set(scoped_delete.get("interface") or []) & h_ifaces:
+ self.addcmd({"interface": i}, "interface", True)
+
+ s_log = scoped_delete.get("log", {})
+ h_log = haved.get("log", {})
+ if "level" in s_log and "level" in h_log:
+ self.addcmd({"level": h_log["level"]}, "log.level", True)
+ h_sub = set(h_log.get("subsystem") or [])
+ for s in set(s_log.get("subsystem") or []) & h_sub:
+ self.addcmd({"subsystem": s}, "log.subsystem", True)
+
+ s_opt = scoped_delete.get("options", {})
+ h_opt = haved.get("options", {})
+ for field in ("disable_route_autoinstall", "flexvpn", "virtual_ip"):
+ if field in s_opt and h_opt.get(field):
+ self.addcmd({}, "options.{0}".format(field), True)
+ if "interface" in s_opt and "interface" in h_opt:
+ self.addcmd({"interface": h_opt["interface"]}, "options.interface", True)
+
+ s_retrans = s_opt.get("retransmission", {})
+ h_retrans = h_opt.get("retransmission", {})
+ for field in ("attempts", "base", "timeout"):
+ if field in s_retrans and field in h_retrans:
+ self.addcmd(
+ {field: h_retrans[field]},
+ "options.retransmission.{0}".format(field),
+ True,
+ )
+ return
+
+ self._cmp_bool(wantd, haved, "disable_uniqreqids", {}, "disable_uniqreqids", reconcile)
+
+ w_ifaces = set(wantd.get("interface") or [])
+ h_ifaces = set(haved.get("interface") or [])
+ for i in w_ifaces - h_ifaces:
+ self.addcmd({"interface": i}, "interface", False)
+ if reconcile:
+ for i in h_ifaces - w_ifaces:
+ self.addcmd({"interface": i}, "interface", True)
+
+ w_log = wantd.get("log", {})
+ h_log = haved.get("log", {})
+ self._cmp_scalar(w_log, h_log, "level", {}, "log.level", reconcile)
+ w_sub = set(w_log.get("subsystem") or [])
+ h_sub = set(h_log.get("subsystem") or [])
+ for s in w_sub - h_sub:
+ self.addcmd({"subsystem": s}, "log.subsystem", False)
+ if reconcile:
+ for s in h_sub - w_sub:
+ self.addcmd({"subsystem": s}, "log.subsystem", True)
+
+ w_opt = wantd.get("options", {})
+ h_opt = haved.get("options", {})
+ for field in ("disable_route_autoinstall", "flexvpn", "virtual_ip"):
+ self._cmp_bool(w_opt, h_opt, field, {}, "options.{0}".format(field), reconcile)
+ self._cmp_scalar(w_opt, h_opt, "interface", {}, "options.interface", reconcile)
+
+ w_retrans = w_opt.get("retransmission", {})
+ h_retrans = h_opt.get("retransmission", {})
+ for field in ("attempts", "base", "timeout"):
+ self._cmp_scalar(
+ w_retrans,
+ h_retrans,
+ field,
+ {},
+ "options.retransmission.{0}".format(field),
+ reconcile,
+ )
+
+ # -------------------------------------------------------------------
+ # Field-level helpers (mirrors vyos_nat's _cmp_scalar / _cmp_bool)
+ # -------------------------------------------------------------------
+
+ def _cmp_scalar(self, want, have, field, ctx, parser, reconcile=False):
+ w = want.get(field)
+ h = have.get(field)
+ if w != h:
+ if w is not None:
+ self.addcmd(dict(ctx, **{field: w}), parser, False)
+ elif reconcile and h is not None:
+ self.addcmd(dict(ctx, **{field: h}), parser, True)
+
+ def _cmp_bool(self, want, have, field, ctx, parser, reconcile=False):
+ # An explicitly-specified value (even False) is always enforced,
+ # regardless of state -- that's the user directly saying what
+ # they want. An OMITTED field is only enforced (i.e. deleted if
+ # currently True) under full reconciliation (replaced/overridden).
+ # Under merged, an omitted field is left alone -- protected
+ # further upstream by dict_merge backfilling `want` from `have`
+ # before this is ever reached, but this still needs to be correct
+ # in isolation (e.g. for a field nested inside a dict that wasn't
+ # part of the dict_merge'd top-level structure).
+ explicit = field in want
+ w = bool(want.get(field))
+ h = bool(have.get(field))
+ if w != h and (w or explicit or reconcile):
+ self.addcmd(dict(ctx), parser, not w)
diff --git a/plugins/module_utils/network/vyos/config/vpn_ipsec_s2s/__init__.py b/plugins/module_utils/network/vyos/config/vpn_ipsec_s2s/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/plugins/module_utils/network/vyos/config/vpn_ipsec_s2s/__init__.py
diff --git a/plugins/module_utils/network/vyos/config/vpn_ipsec_s2s/vpn_ipsec_s2s.py b/plugins/module_utils/network/vyos/config/vpn_ipsec_s2s/vpn_ipsec_s2s.py
new file mode 100644
index 00000000..65232ed7
--- /dev/null
+++ b/plugins/module_utils/network/vyos/config/vpn_ipsec_s2s/vpn_ipsec_s2s.py
@@ -0,0 +1,355 @@
+#
+# -*- coding: utf-8 -*-
+# Copyright 2026 Red Hat
+# GNU General Public License v3.0+
+# (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+#
+
+from __future__ import absolute_import, division, print_function
+
+
+__metaclass__ = type
+
+"""
+The vyos_vpn_ipsec_s2s config file.
+It is in this file where the current configuration (as dict)
+is compared to the provided configuration (as dict) and the command set
+necessary to bring the current configuration to its desired end-state is
+created.
+
+Mirrors vyos_vpn_ipsec's config.py exactly -- same list-to-dict
+conversion + explicit per-state branching, same select_all/reconcile
+two-flag design for the replaced/overridden distinction, same scoped
+deleted handling. See that file's own docstring for the full state
+semantics; the summary:
+
+ - merged: only items/fields named in `want` are touched.
+ - replaced: only items NAMED in `want` are touched, but each named
+ item is fully reconciled (omitted fields removed).
+ - overridden: every item is in scope, including ones absent from
+ `want` -- those get deleted wholesale. Named items
+ reconciled the same way as replaced.
+ - deleted: bare (no config) deletes everything; a scoped config
+ deletes only what's named, down to individual list
+ elements.
+"""
+
+from copy import deepcopy
+
+from ansible_collections.ansible.netcommon.plugins.module_utils.network.common.rm_base.resource_module import (
+ ResourceModule,
+)
+from ansible_collections.ansible.netcommon.plugins.module_utils.network.common.utils import (
+ dict_merge,
+)
+
+from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.facts.facts import (
+ Facts,
+)
+from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.rm_templates.vpn_ipsec_s2s import (
+ Vpn_ipsec_s2sTemplate,
+)
+
+
+class Vpn_ipsec_s2s(ResourceModule):
+ """
+ The vyos_vpn_ipsec_s2s config class
+ """
+
+ def __init__(self, module):
+ super(Vpn_ipsec_s2s, self).__init__(
+ empty_fact_val={},
+ facts_module=Facts(module),
+ module=module,
+ resource="vpn_ipsec_s2s",
+ tmplt=Vpn_ipsec_s2sTemplate(),
+ )
+
+ def execute_module(self):
+ if self.state not in ["parsed", "gathered"]:
+ self.generate_commands()
+ self.run_commands()
+ return self.result
+
+ def generate_commands(self):
+ wantd = deepcopy(self.want) or {}
+ haved = deepcopy(self.have) or {}
+
+ for entry in (wantd, haved):
+ self._list_to_dict(entry)
+
+ scoped_delete = None
+ if self.state == "deleted":
+ if wantd:
+ scoped_delete = wantd
+ wantd = {}
+
+ if self.state == "merged":
+ # list_to_dict() above must run BEFORE this -- dict_merge
+ # concatenates raw lists rather than merging matching items
+ # by key, so it only does the right thing once both sides
+ # are already name-keyed dicts.
+ wantd = dict_merge(haved, wantd)
+
+ select_all = self.state in ("overridden", "deleted")
+ reconcile = self.state in ("replaced", "overridden", "deleted")
+
+ self._compare_peers(wantd, haved, select_all, reconcile, scoped_delete)
+
+ self.commands = list(dict.fromkeys(self.commands))
+
+ # -------------------------------------------------------------------
+ # List -> name-keyed dict conversion
+ # -------------------------------------------------------------------
+
+ def _list_to_dict(self, config):
+ peers = config.get("peer")
+ if isinstance(peers, list):
+ config["peer"] = {p["name"]: p for p in peers}
+ for peer in config["peer"].values():
+ if isinstance(peer.get("tunnel"), list):
+ peer["tunnel"] = {t["tunnel_id"]: t for t in peer["tunnel"]}
+
+ # -------------------------------------------------------------------
+ # Peers
+ # -------------------------------------------------------------------
+
+ def _compare_peers(self, wantd, haved, select_all, reconcile, scoped_delete=None):
+ have_peers = haved.get("peer", {})
+
+ if scoped_delete is not None:
+ for name in set(scoped_delete.get("peer", {})):
+ if name in have_peers:
+ self.commands.append(
+ "delete vpn ipsec site-to-site peer {0}".format(name),
+ )
+ return
+
+ want_peers = wantd.get("peer", {})
+ names = set(want_peers) | set(have_peers) if select_all else set(want_peers)
+
+ for name in names:
+ w = want_peers.get(name, {})
+ h = have_peers.get(name, {})
+ if w == h:
+ continue
+
+ if name in have_peers and name not in want_peers:
+ self.commands.append(
+ "delete vpn ipsec site-to-site peer {0}".format(name),
+ )
+ continue
+
+ if name not in have_peers:
+ self.addcmd({"name": name}, "peer", False)
+
+ ctx = {"name": name}
+ self._cmp_bool(w, h, "disable", ctx, "peer.disable", reconcile)
+
+ w_auth = w.get("authentication", {})
+ h_auth = h.get("authentication", {})
+ for field in ("local_id", "remote_id", "mode"):
+ self._cmp_scalar(
+ w_auth,
+ h_auth,
+ field,
+ ctx,
+ "peer.authentication.{0}".format(field),
+ reconcile,
+ )
+ self._cmp_bool(
+ w_auth,
+ h_auth,
+ "use_x509_id",
+ ctx,
+ "peer.authentication.use_x509_id",
+ reconcile,
+ )
+
+ w_ppk = w_auth.get("ppk", {})
+ h_ppk = h_auth.get("ppk", {})
+ self._cmp_scalar(w_ppk, h_ppk, "id", ctx, "peer.authentication.ppk.id", reconcile)
+ self._cmp_bool(
+ w_ppk,
+ h_ppk,
+ "required",
+ ctx,
+ "peer.authentication.ppk.required",
+ reconcile,
+ )
+
+ w_rsa = w_auth.get("rsa", {})
+ h_rsa = h_auth.get("rsa", {})
+ for field in ("local_key", "remote_key", "passphrase"):
+ self._cmp_scalar(
+ w_rsa,
+ h_rsa,
+ field,
+ ctx,
+ "peer.authentication.rsa.{0}".format(field),
+ reconcile,
+ )
+
+ w_x509 = w_auth.get("x509", {})
+ h_x509 = h_auth.get("x509", {})
+ for field in ("certificate", "passphrase"):
+ self._cmp_scalar(
+ w_x509,
+ h_x509,
+ field,
+ ctx,
+ "peer.authentication.x509.{0}".format(field),
+ reconcile,
+ )
+ w_ca = set(w_x509.get("ca_certificate") or [])
+ h_ca = set(h_x509.get("ca_certificate") or [])
+ for cert in w_ca - h_ca:
+ self.addcmd(
+ dict(ctx, ca_certificate=cert),
+ "peer.authentication.x509.ca_certificate",
+ False,
+ )
+ if reconcile:
+ for cert in h_ca - w_ca:
+ self.addcmd(
+ dict(ctx, ca_certificate=cert),
+ "peer.authentication.x509.ca_certificate",
+ True,
+ )
+
+ for field in (
+ "childless",
+ "connection_type",
+ "default_esp_group",
+ "description",
+ "dhcp_interface",
+ "ike_group",
+ "ikev2_reauth",
+ "local_address",
+ ):
+ self._cmp_scalar(w, h, field, ctx, "peer.{0}".format(field), reconcile)
+ self._cmp_bool(
+ w,
+ h,
+ "force_udp_encapsulation",
+ ctx,
+ "peer.force_udp_encapsulation",
+ reconcile,
+ )
+ self._cmp_scalar(w, h, "replay_window", ctx, "peer.replay_window", reconcile)
+
+ w_remote_addr = set(w.get("remote_address") or [])
+ h_remote_addr = set(h.get("remote_address") or [])
+ for addr in w_remote_addr - h_remote_addr:
+ self.addcmd(dict(ctx, remote_address=addr), "peer.remote_address", False)
+ if reconcile:
+ for addr in h_remote_addr - w_remote_addr:
+ self.addcmd(dict(ctx, remote_address=addr), "peer.remote_address", True)
+
+ w_virt_addr = set(w.get("virtual_address") or [])
+ h_virt_addr = set(h.get("virtual_address") or [])
+ for addr in w_virt_addr - h_virt_addr:
+ self.addcmd(dict(ctx, virtual_address=addr), "peer.virtual_address", False)
+ if reconcile:
+ for addr in h_virt_addr - w_virt_addr:
+ self.addcmd(dict(ctx, virtual_address=addr), "peer.virtual_address", True)
+
+ self._compare_tunnels(w.get("tunnel", {}), h.get("tunnel", {}), ctx, reconcile)
+ self._compare_vti(w.get("vti", {}), h.get("vti", {}), ctx, reconcile)
+
+ # -------------------------------------------------------------------
+ # Tunnels (nested collection within a peer)
+ # -------------------------------------------------------------------
+
+ def _compare_tunnels(self, want_tunnels, have_tunnels, peer_ctx, reconcile):
+ # A tunnel collection lives entirely inside an already-selected
+ # peer -- once that peer is in scope, its own tunnels always get
+ # full reconciliation under replaced/overridden, matching how
+ # esp_group/ike_group's own nested proposals behave in the
+ # profile module.
+ ids = set(want_tunnels) | set(have_tunnels) if reconcile else set(want_tunnels)
+ for tid in ids:
+ w = want_tunnels.get(tid, {})
+ h = have_tunnels.get(tid, {})
+ if w == h:
+ continue
+
+ if tid in have_tunnels and tid not in want_tunnels:
+ self.addcmd(dict(peer_ctx, tunnel_id=tid), "peer.tunnel", True)
+ continue
+
+ if tid not in have_tunnels:
+ self.addcmd(dict(peer_ctx, tunnel_id=tid), "peer.tunnel", False)
+
+ ctx = dict(peer_ctx, tunnel_id=tid)
+ self._cmp_bool(w, h, "disable", ctx, "peer.tunnel.disable", reconcile)
+ for field in ("esp_group", "protocol"):
+ self._cmp_scalar(w, h, field, ctx, "peer.tunnel.{0}".format(field), reconcile)
+ self._cmp_scalar(w, h, "priority", ctx, "peer.tunnel.priority", reconcile)
+
+ for side in ("local", "remote"):
+ w_side = w.get(side, {})
+ h_side = h.get(side, {})
+ self._cmp_scalar(
+ w_side,
+ h_side,
+ "port",
+ ctx,
+ "peer.tunnel.{0}.port".format(side),
+ reconcile,
+ )
+ w_prefix = set(w_side.get("prefix") or [])
+ h_prefix = set(h_side.get("prefix") or [])
+ for p in w_prefix - h_prefix:
+ self.addcmd(
+ dict(ctx, prefix=p),
+ "peer.tunnel.{0}.prefix".format(side),
+ False,
+ )
+ if reconcile:
+ for p in h_prefix - w_prefix:
+ self.addcmd(
+ dict(ctx, prefix=p),
+ "peer.tunnel.{0}.prefix".format(side),
+ True,
+ )
+
+ # -------------------------------------------------------------------
+ # VTI (nested dict within a peer, not a collection)
+ # -------------------------------------------------------------------
+
+ def _compare_vti(self, w_vti, h_vti, peer_ctx, reconcile):
+ for field in ("bind", "esp_group"):
+ self._cmp_scalar(w_vti, h_vti, field, peer_ctx, "peer.vti.{0}".format(field), reconcile)
+
+ w_ts = w_vti.get("traffic_selector", {})
+ h_ts = h_vti.get("traffic_selector", {})
+ for side in ("local", "remote"):
+ w_prefix = set(w_ts.get(side, {}).get("prefix") or [])
+ h_prefix = set(h_ts.get(side, {}).get("prefix") or [])
+ parser = "peer.vti.traffic_selector.{0}.prefix".format(side)
+ for p in w_prefix - h_prefix:
+ self.addcmd(dict(peer_ctx, prefix=p), parser, False)
+ if reconcile:
+ for p in h_prefix - w_prefix:
+ self.addcmd(dict(peer_ctx, prefix=p), parser, True)
+
+ # -------------------------------------------------------------------
+ # Field-level helpers (mirrors vyos_vpn_ipsec's own)
+ # -------------------------------------------------------------------
+
+ def _cmp_scalar(self, want, have, field, ctx, parser, reconcile=False):
+ w = want.get(field)
+ h = have.get(field)
+ if w != h:
+ if w is not None:
+ self.addcmd(dict(ctx, **{field: w}), parser, False)
+ elif reconcile and h is not None:
+ self.addcmd(dict(ctx, **{field: h}), parser, True)
+
+ def _cmp_bool(self, want, have, field, ctx, parser, reconcile=False):
+ explicit = field in want
+ w = bool(want.get(field))
+ h = bool(have.get(field))
+ if w != h and (w or explicit or reconcile):
+ self.addcmd(dict(ctx), parser, not w)
diff --git a/plugins/module_utils/network/vyos/facts/facts.py b/plugins/module_utils/network/vyos/facts/facts.py
index 2a789103..69cb0d0b 100644
--- a/plugins/module_utils/network/vyos/facts/facts.py
+++ b/plugins/module_utils/network/vyos/facts/facts.py
@@ -83,6 +83,12 @@ from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.facts.snmp_
from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.facts.static_routes.static_routes import (
Static_routesFacts,
)
+from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.facts.vpn_ipsec.vpn_ipsec import (
+ Vpn_ipsecFacts,
+)
+from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.facts.vpn_ipsec_s2s.vpn_ipsec_s2s import (
+ Vpn_ipsec_s2sFacts,
+)
from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.facts.vrf.vrf import VrfFacts
FACT_LEGACY_SUBSETS = dict(default=Default, neighbors=Neighbors, config=Config)
@@ -109,6 +115,8 @@ FACT_RESOURCE_SUBSETS = dict(
snmp_server=Snmp_serverFacts,
hostname=HostnameFacts,
vrf=VrfFacts,
+ vpn_ipsec=Vpn_ipsecFacts,
+ vpn_ipsec_s2s=Vpn_ipsec_s2sFacts,
)
diff --git a/plugins/module_utils/network/vyos/facts/vpn_ipsec/__init__.py b/plugins/module_utils/network/vyos/facts/vpn_ipsec/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/plugins/module_utils/network/vyos/facts/vpn_ipsec/__init__.py
diff --git a/plugins/module_utils/network/vyos/facts/vpn_ipsec/vpn_ipsec.py b/plugins/module_utils/network/vyos/facts/vpn_ipsec/vpn_ipsec.py
new file mode 100644
index 00000000..695b9b8d
--- /dev/null
+++ b/plugins/module_utils/network/vyos/facts/vpn_ipsec/vpn_ipsec.py
@@ -0,0 +1,113 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Red Hat
+# GNU General Public License v3.0+
+# (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import absolute_import, division, print_function
+
+
+__metaclass__ = type
+
+"""
+The vyos vpn_ipsec fact class
+It is in this file the configuration is collected from the device
+for a given resource, parsed, and the facts tree is populated
+based on the configuration.
+
+Follows the established per-key conversion convention used by
+vyos_logging_global/vyos_ha (explicit process_facts() naming each
+name-keyed dict that needs converting to a list), matching the config.py
+convention for this module, rather than a generic argspec-driven walker.
+"""
+
+from ansible_collections.ansible.netcommon.plugins.module_utils.network.common import (
+ utils,
+)
+
+from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.argspec.vpn_ipsec.vpn_ipsec import (
+ Vpn_ipsecArgs,
+)
+from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.rm_templates.vpn_ipsec import (
+ Vpn_ipsecTemplate,
+)
+
+
+class Vpn_ipsecFacts(object):
+ """The vyos vpn_ipsec facts class"""
+
+ def __init__(self, module, subspec="config", options="options"):
+ self._module = module
+ self.argument_spec = Vpn_ipsecArgs.argument_spec
+
+ def get_vpn_ipsec_data(self, connection):
+ return connection.get('show configuration commands | match "vpn ipsec"')
+
+ def process_facts(self, objFinal):
+ """Convert the name-keyed dicts produced by the parser into the
+ lists the argspec expects. Each key handled explicitly, matching
+ the vyos_logging_global/vyos_ha convention.
+ """
+ if not objFinal:
+ return objFinal
+
+ for key in ("ike_group", "esp_group"):
+ if key in objFinal:
+ items = list(objFinal[key].values())
+ for item in items:
+ if "proposal" in item:
+ item["proposal"] = sorted(
+ item["proposal"].values(),
+ key=lambda p: int(p["proposal_id"]),
+ )
+ objFinal[key] = sorted(items, key=lambda item: item["name"])
+
+ if "profile" in objFinal:
+ objFinal["profile"] = sorted(
+ objFinal["profile"].values(),
+ key=lambda item: item["name"],
+ )
+
+ if "authentication" in objFinal:
+ auth = objFinal["authentication"]
+ for key in ("psk", "ppk"):
+ if key in auth:
+ auth[key] = sorted(
+ auth[key].values(),
+ key=lambda item: item["name"],
+ )
+
+ return objFinal
+
+ def populate_facts(self, connection, ansible_facts, data=None):
+ """Populate the facts for Vpn_ipsec network resource
+
+ :param connection: the device connection
+ :param ansible_facts: Facts dictionary
+ :param data: previously collected conf
+
+ :rtype: dictionary
+ :returns: facts
+ """
+ facts = {}
+
+ if not data:
+ data = self.get_vpn_ipsec_data(connection)
+
+ vpn_ipsec_parser = Vpn_ipsecTemplate(lines=data.splitlines(), module=self._module)
+ objs = vpn_ipsec_parser.parse()
+
+ ansible_facts["ansible_network_resources"].pop("vpn_ipsec", None)
+ objs = self.process_facts(objs)
+
+ params = utils.remove_empties(
+ vpn_ipsec_parser.validate_config(
+ self.argument_spec,
+ {"config": objs},
+ redact=True,
+ ),
+ )
+
+ facts["vpn_ipsec"] = params.get("config", {})
+ ansible_facts["ansible_network_resources"].update(facts)
+
+ return ansible_facts
diff --git a/plugins/module_utils/network/vyos/facts/vpn_ipsec_s2s/__init__.py b/plugins/module_utils/network/vyos/facts/vpn_ipsec_s2s/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/plugins/module_utils/network/vyos/facts/vpn_ipsec_s2s/__init__.py
diff --git a/plugins/module_utils/network/vyos/facts/vpn_ipsec_s2s/vpn_ipsec_s2s.py b/plugins/module_utils/network/vyos/facts/vpn_ipsec_s2s/vpn_ipsec_s2s.py
new file mode 100644
index 00000000..6c9b5d8e
--- /dev/null
+++ b/plugins/module_utils/network/vyos/facts/vpn_ipsec_s2s/vpn_ipsec_s2s.py
@@ -0,0 +1,115 @@
+#
+# -*- coding: utf-8 -*-
+# Copyright 2026 Red Hat
+# GNU General Public License v3.0+
+# (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+#
+
+from __future__ import absolute_import, division, print_function
+
+
+__metaclass__ = type
+
+"""
+The vyos vpn_ipsec_s2s fact class
+It is in this file the configuration is collected from the device
+for a given resource, parsed, and the facts tree is populated
+based on the configuration.
+
+Follows the established per-key conversion convention used by
+vyos_logging_global/vyos_ha/vyos_vpn_ipsec (explicit process_facts()
+naming each name-keyed dict that needs converting to a list), matching
+the config.py convention for this module, rather than a generic
+argspec-driven walker.
+"""
+
+from ansible_collections.ansible.netcommon.plugins.module_utils.network.common import (
+ utils,
+)
+
+from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.argspec.vpn_ipsec_s2s.vpn_ipsec_s2s import (
+ Vpn_ipsec_s2sArgs,
+)
+from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.rm_templates.vpn_ipsec_s2s import (
+ Vpn_ipsec_s2sTemplate,
+)
+
+
+class Vpn_ipsec_s2sFacts(object):
+ """The vyos vpn_ipsec_s2s facts class"""
+
+ def __init__(self, module, subspec="config", options="options"):
+ self._module = module
+ self.argument_spec = Vpn_ipsec_s2sArgs.argument_spec
+
+ def get_vpn_ipsec_s2s_data(self, connection):
+ return connection.get(
+ 'show configuration commands | match "vpn ipsec site-to-site"',
+ )
+
+ def process_facts(self, objFinal):
+ """Convert the name-keyed dicts produced by the parser into the
+ lists the argspec expects.
+
+ NOTE: every PARSERS result template in rm_templates.py nests its
+ output under "site_to_site" -> "peer" (mirroring the CLI's own
+ tree: `vpn ipsec site-to-site peer <name> ...`), but the
+ argspec's `config` has `peer` directly at the top level -- there
+ is no `site_to_site` wrapper in the argspec, since that's the
+ one node wrap_docstring.py unwrapped when building the
+ docstring (its own children became config's children directly).
+ So this needs to strip that outer key, not just convert the
+ name-keyed dicts to lists.
+ """
+ if not objFinal:
+ return objFinal
+
+ site_to_site = objFinal.get("site_to_site", {})
+ peers = site_to_site.get("peer", {})
+
+ items = list(peers.values())
+ for item in items:
+ if "tunnel" in item:
+ item["tunnel"] = sorted(
+ item["tunnel"].values(),
+ key=lambda t: int(t["tunnel_id"]),
+ )
+
+ return {"peer": sorted(items, key=lambda item: item["name"])}
+
+ def populate_facts(self, connection, ansible_facts, data=None):
+ """Populate the facts for Vpn_ipsec_s2s network resource
+
+ :param connection: the device connection
+ :param ansible_facts: Facts dictionary
+ :param data: previously collected conf
+
+ :rtype: dictionary
+ :returns: facts
+ """
+ facts = {}
+
+ if not data:
+ data = self.get_vpn_ipsec_s2s_data(connection)
+
+ vpn_ipsec_s2s_parser = Vpn_ipsec_s2sTemplate(
+ lines=data.splitlines(),
+ module=self._module,
+ )
+ objs = vpn_ipsec_s2s_parser.parse()
+
+ ansible_facts["ansible_network_resources"].pop("vpn_ipsec_s2s", None)
+ objs = self.process_facts(objs)
+
+ params = utils.remove_empties(
+ vpn_ipsec_s2s_parser.validate_config(
+ self.argument_spec,
+ {"config": objs},
+ redact=True,
+ ),
+ )
+
+ facts["vpn_ipsec_s2s"] = params.get("config", {})
+ ansible_facts["ansible_network_resources"].update(facts)
+
+ return ansible_facts
diff --git a/plugins/module_utils/network/vyos/rm_templates/vpn_ipsec.py b/plugins/module_utils/network/vyos/rm_templates/vpn_ipsec.py
new file mode 100644
index 00000000..ff889526
--- /dev/null
+++ b/plugins/module_utils/network/vyos/rm_templates/vpn_ipsec.py
@@ -0,0 +1,997 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Red Hat
+# GNU General Public License v3.0+
+# (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import absolute_import, division, print_function
+
+
+__metaclass__ = type
+
+"""
+The VPN IPSEC parser templates file. This contains
+a list of parser definitions and associated functions that
+facilitates both facts gathering and native command generation for
+the given network resource.
+"""
+
+import re
+
+from ansible_collections.ansible.netcommon.plugins.module_utils.network.common.rm_base.network_template import (
+ NetworkTemplate,
+)
+
+
+class Vpn_ipsecTemplate(NetworkTemplate):
+ def __init__(self, lines=None, module=None):
+ prefix = {"set": "set", "remove": "delete"}
+ super(Vpn_ipsecTemplate, self).__init__(
+ lines=lines,
+ tmplt=self,
+ prefix=prefix,
+ module=module,
+ )
+
+ # fmt: off
+ PARSERS = [
+ # ---------------------------------------------------------------
+ # esp-group
+ # ---------------------------------------------------------------
+ {
+ "name": "esp_group",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sesp-group\s(?P<esp_group>\S+)
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec esp-group {{ name }}",
+ "result": {
+ "esp_group": {
+ "{{ esp_group }}": {
+ "name": "{{ esp_group }}",
+ },
+ },
+ },
+ },
+ {
+ "name": "esp_group.proposal",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sesp-group\s(?P<esp_group>\S+)
+ \sproposal\s(?P<proposal_id>\d+)
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec esp-group {{ name }} proposal {{ proposal_id }}",
+ "result": {
+ "esp_group": {
+ "{{ esp_group }}": {
+ "name": "{{ esp_group }}",
+ "proposal": {
+ "{{ proposal_id }}": {
+ "proposal_id": "{{ proposal_id }}",
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "esp_group.proposal.encryption",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sesp-group\s(?P<esp_group>\S+)
+ \sproposal\s(?P<proposal_id>\d+)
+ \sencryption\s'?(?P<encryption>[\w-]+)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec esp-group {{ name }} proposal {{ proposal_id }} encryption {{ encryption }}",
+ "result": {
+ "esp_group": {
+ "{{ esp_group }}": {
+ "name": "{{ esp_group }}",
+ "proposal": {
+ "{{ proposal_id }}": {
+ "proposal_id": "{{ proposal_id }}",
+ "encryption": "{{ encryption }}",
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "esp_group.proposal.hash",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sesp-group\s(?P<esp_group>\S+)
+ \sproposal\s(?P<proposal_id>\d+)
+ \shash\s'?(?P<hash>[\w-]+)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec esp-group {{ name }} proposal {{ proposal_id }} hash {{ hash }}",
+ "result": {
+ "esp_group": {
+ "{{ esp_group }}": {
+ "name": "{{ esp_group }}",
+ "proposal": {
+ "{{ proposal_id }}": {
+ "proposal_id": "{{ proposal_id }}",
+ "hash": "{{ hash }}",
+ },
+ },
+ },
+ },
+ },
+ },
+
+ # ---------------------------------------------------------------
+ # ike-group
+ # ---------------------------------------------------------------
+ {
+ "name": "ike_group",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sike-group\s(?P<ike_group>\S+)
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec ike-group {{ name }}",
+ "result": {
+ "ike_group": {
+ "{{ ike_group }}": {
+ "name": "{{ ike_group }}",
+ },
+ },
+ },
+ },
+ {
+ "name": "ike_group.key_exchange",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sike-group\s(?P<ike_group>\S+)
+ \skey-exchange\s'?(?P<key_exchange>\w+)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec ike-group {{ name }} key-exchange {{ key_exchange }}",
+ "result": {
+ "ike_group": {
+ "{{ ike_group }}": {
+ "name": "{{ ike_group }}",
+ "key_exchange": "{{ key_exchange }}",
+ },
+ },
+ },
+ },
+ {
+ "name": "ike_group.proposal",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sike-group\s(?P<ike_group>\S+)
+ \sproposal\s(?P<proposal_id>\d+)
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec ike-group {{ name }} proposal {{ proposal_id }}",
+ "result": {
+ "ike_group": {
+ "{{ ike_group }}": {
+ "name": "{{ ike_group }}",
+ "proposal": {
+ "{{ proposal_id }}": {
+ "proposal_id": "{{ proposal_id }}",
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "ike_group.proposal.dh_group",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sike-group\s(?P<ike_group>\S+)
+ \sproposal\s(?P<proposal_id>\d+)
+ \sdh-group\s'?(?P<dh_group>\d+)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec ike-group {{ name }} proposal {{ proposal_id }} dh-group {{ dh_group }}",
+ "result": {
+ "ike_group": {
+ "{{ ike_group }}": {
+ "name": "{{ ike_group }}",
+ "proposal": {
+ "{{ proposal_id }}": {
+ "proposal_id": "{{ proposal_id }}",
+ "dh_group": "{{ dh_group }}",
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "ike_group.proposal.encryption",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sike-group\s(?P<ike_group>\S+)
+ \sproposal\s(?P<proposal_id>\d+)
+ \sencryption\s'?(?P<encryption>[\w-]+)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec ike-group {{ name }} proposal {{ proposal_id }} encryption {{ encryption }}",
+ "result": {
+ "ike_group": {
+ "{{ ike_group }}": {
+ "name": "{{ ike_group }}",
+ "proposal": {
+ "{{ proposal_id }}": {
+ "proposal_id": "{{ proposal_id }}",
+ "encryption": "{{ encryption }}",
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "ike_group.proposal.hash",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sike-group\s(?P<ike_group>\S+)
+ \sproposal\s(?P<proposal_id>\d+)
+ \shash\s'?(?P<hash>[\w-]+)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec ike-group {{ name }} proposal {{ proposal_id }} hash {{ hash }}",
+ "result": {
+ "ike_group": {
+ "{{ ike_group }}": {
+ "name": "{{ ike_group }}",
+ "proposal": {
+ "{{ proposal_id }}": {
+ "proposal_id": "{{ proposal_id }}",
+ "hash": "{{ hash }}",
+ },
+ },
+ },
+ },
+ },
+ },
+
+ # ---------------------------------------------------------------
+ # authentication psk
+ # ---------------------------------------------------------------
+ {
+ "name": "authentication.psk",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sauthentication\spsk\s(?P<psk>\S+)
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec authentication psk {{ name }}",
+ "result": {
+ "authentication": {
+ "psk": {
+ "{{ psk }}": {
+ "name": "{{ psk }}",
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "authentication.psk.id",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sauthentication\spsk\s(?P<psk>\S+)
+ \sid\s'?(?P<id>\S+?)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec authentication psk {{ name }} id {{ id }}",
+ "result": {
+ "authentication": {
+ "psk": {
+ "{{ psk }}": {
+ "name": "{{ psk }}",
+ "id": ["{{ id }}"],
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "authentication.psk.secret",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sauthentication\spsk\s(?P<psk>\S+)
+ \ssecret\s'?(?P<secret>[^']+)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec authentication psk {{ name }} secret '{{ secret }}'",
+ "result": {
+ "authentication": {
+ "psk": {
+ "{{ psk }}": {
+ "name": "{{ psk }}",
+ "secret": "{{ secret }}",
+ },
+ },
+ },
+ },
+ },
+
+ # ---------------------------------------------------------------
+ # profile
+ # ---------------------------------------------------------------
+ {
+ "name": "profile",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sprofile\s(?P<profile>\S+)
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec profile {{ name }}",
+ "result": {
+ "profile": {
+ "{{ profile }}": {
+ "name": "{{ profile }}",
+ },
+ },
+ },
+ },
+ {
+ "name": "profile.authentication.mode",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sprofile\s(?P<profile>\S+)
+ \sauthentication\smode\s'?(?P<mode>[\w-]+)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec profile {{ name }} authentication mode {{ mode }}",
+ "result": {
+ "profile": {
+ "{{ profile }}": {
+ "name": "{{ profile }}",
+ "authentication": {
+ "mode": "{{ mode }}",
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "profile.authentication.pre_shared_secret",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sprofile\s(?P<profile>\S+)
+ \sauthentication\spre-shared-secret\s'?(?P<pre_shared_secret>[^']+)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec profile {{ name }} authentication pre-shared-secret '{{ pre_shared_secret }}'",
+ "result": {
+ "profile": {
+ "{{ profile }}": {
+ "name": "{{ profile }}",
+ "authentication": {
+ "pre_shared_secret": "{{ pre_shared_secret }}",
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "profile.bind_tunnel",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sprofile\s(?P<profile>\S+)
+ \sbind\stunnel\s'?(?P<bind_tunnel>\S+?)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec profile {{ name }} bind tunnel {{ bind_tunnel }}",
+ "result": {
+ "profile": {
+ "{{ profile }}": {
+ "name": "{{ profile }}",
+ "bind_tunnel": ["{{ bind_tunnel }}"],
+ },
+ },
+ },
+ },
+ {
+ "name": "profile.esp_group",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sprofile\s(?P<profile>\S+)
+ \sesp-group\s'?(?P<esp_group>\S+?)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec profile {{ name }} esp-group {{ esp_group }}",
+ "result": {
+ "profile": {
+ "{{ profile }}": {
+ "name": "{{ profile }}",
+ "esp_group": "{{ esp_group }}",
+ },
+ },
+ },
+ },
+ {
+ "name": "profile.ike_group",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sprofile\s(?P<profile>\S+)
+ \sike-group\s'?(?P<ike_group>\S+?)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec profile {{ name }} ike-group {{ ike_group }}",
+ "result": {
+ "profile": {
+ "{{ profile }}": {
+ "name": "{{ profile }}",
+ "ike_group": "{{ ike_group }}",
+ },
+ },
+ },
+ },
+
+ # ---------------------------------------------------------------
+ # ike-group: remaining fields
+ # ---------------------------------------------------------------
+ {
+ "name": "ike_group.close_action",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sike-group\s(?P<ike_group>\S+)
+ \sclose-action\s'?(?P<close_action>[\w-]+)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec ike-group {{ name }} close-action {{ close_action }}",
+ "result": {
+ "ike_group": {
+ "{{ ike_group }}": {
+ "name": "{{ ike_group }}",
+ "close_action": "{{ close_action }}",
+ },
+ },
+ },
+ },
+ {
+ "name": "ike_group.dead_peer_detection.action",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sike-group\s(?P<ike_group>\S+)
+ \sdead-peer-detection\saction\s'?(?P<action>[\w-]+)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec ike-group {{ name }} dead-peer-detection action {{ action }}",
+ "result": {
+ "ike_group": {
+ "{{ ike_group }}": {
+ "name": "{{ ike_group }}",
+ "dead_peer_detection": {"action": "{{ action }}"},
+ },
+ },
+ },
+ },
+ {
+ "name": "ike_group.dead_peer_detection.interval",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sike-group\s(?P<ike_group>\S+)
+ \sdead-peer-detection\sinterval\s'?(?P<interval>\d+)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec ike-group {{ name }} dead-peer-detection interval {{ interval }}",
+ "result": {
+ "ike_group": {
+ "{{ ike_group }}": {
+ "name": "{{ ike_group }}",
+ "dead_peer_detection": {"interval": "{{ interval }}"},
+ },
+ },
+ },
+ },
+ {
+ "name": "ike_group.dead_peer_detection.timeout",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sike-group\s(?P<ike_group>\S+)
+ \sdead-peer-detection\stimeout\s'?(?P<timeout>\d+)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec ike-group {{ name }} dead-peer-detection timeout {{ timeout }}",
+ "result": {
+ "ike_group": {
+ "{{ ike_group }}": {
+ "name": "{{ ike_group }}",
+ "dead_peer_detection": {"timeout": "{{ timeout }}"},
+ },
+ },
+ },
+ },
+ {
+ "name": "ike_group.disable_mobike",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sike-group\s(?P<ike_group>\S+)
+ \sdisable-mobike
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec ike-group {{ name }} disable-mobike",
+ "result": {
+ "ike_group": {
+ "{{ ike_group }}": {
+ "name": "{{ ike_group }}",
+ "disable_mobike": True,
+ },
+ },
+ },
+ },
+ {
+ "name": "ike_group.ikev2_reauth",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sike-group\s(?P<ike_group>\S+)
+ \sikev2-reauth
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec ike-group {{ name }} ikev2-reauth",
+ "result": {
+ "ike_group": {
+ "{{ ike_group }}": {
+ "name": "{{ ike_group }}",
+ "ikev2_reauth": True,
+ },
+ },
+ },
+ },
+ {
+ "name": "ike_group.lifetime",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sike-group\s(?P<ike_group>\S+)
+ \slifetime\s'?(?P<lifetime>\d+)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec ike-group {{ name }} lifetime {{ lifetime }}",
+ "result": {
+ "ike_group": {
+ "{{ ike_group }}": {
+ "name": "{{ ike_group }}",
+ "lifetime": "{{ lifetime }}",
+ },
+ },
+ },
+ },
+ {
+ "name": "ike_group.mode",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sike-group\s(?P<ike_group>\S+)
+ \smode\s'?(?P<mode>[\w-]+)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec ike-group {{ name }} mode {{ mode }}",
+ "result": {
+ "ike_group": {
+ "{{ ike_group }}": {
+ "name": "{{ ike_group }}",
+ "mode": "{{ mode }}",
+ },
+ },
+ },
+ },
+
+ # ---------------------------------------------------------------
+ # esp-group: remaining fields
+ # ---------------------------------------------------------------
+ {
+ "name": "esp_group.compression",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sesp-group\s(?P<esp_group>\S+)
+ \scompression
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec esp-group {{ name }} compression",
+ "result": {
+ "esp_group": {
+ "{{ esp_group }}": {
+ "name": "{{ esp_group }}",
+ "compression": True,
+ },
+ },
+ },
+ },
+ {
+ "name": "esp_group.disable_rekey",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sesp-group\s(?P<esp_group>\S+)
+ \sdisable-rekey
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec esp-group {{ name }} disable-rekey",
+ "result": {
+ "esp_group": {
+ "{{ esp_group }}": {
+ "name": "{{ esp_group }}",
+ "disable_rekey": True,
+ },
+ },
+ },
+ },
+ {
+ "name": "esp_group.life_bytes",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sesp-group\s(?P<esp_group>\S+)
+ \slife-bytes\s'?(?P<life_bytes>\d+)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec esp-group {{ name }} life-bytes {{ life_bytes }}",
+ "result": {
+ "esp_group": {
+ "{{ esp_group }}": {
+ "name": "{{ esp_group }}",
+ "life_bytes": "{{ life_bytes }}",
+ },
+ },
+ },
+ },
+ {
+ "name": "esp_group.life_packets",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sesp-group\s(?P<esp_group>\S+)
+ \slife-packets\s'?(?P<life_packets>\d+)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec esp-group {{ name }} life-packets {{ life_packets }}",
+ "result": {
+ "esp_group": {
+ "{{ esp_group }}": {
+ "name": "{{ esp_group }}",
+ "life_packets": "{{ life_packets }}",
+ },
+ },
+ },
+ },
+ {
+ "name": "esp_group.lifetime",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sesp-group\s(?P<esp_group>\S+)
+ \slifetime\s'?(?P<lifetime>\d+)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec esp-group {{ name }} lifetime {{ lifetime }}",
+ "result": {
+ "esp_group": {
+ "{{ esp_group }}": {
+ "name": "{{ esp_group }}",
+ "lifetime": "{{ lifetime }}",
+ },
+ },
+ },
+ },
+ {
+ "name": "esp_group.mode",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sesp-group\s(?P<esp_group>\S+)
+ \smode\s'?(?P<mode>[\w-]+)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec esp-group {{ name }} mode {{ mode }}",
+ "result": {
+ "esp_group": {
+ "{{ esp_group }}": {
+ "name": "{{ esp_group }}",
+ "mode": "{{ mode }}",
+ },
+ },
+ },
+ },
+ {
+ "name": "esp_group.pfs",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sesp-group\s(?P<esp_group>\S+)
+ \spfs\s'?(?P<pfs>[\w-]+)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec esp-group {{ name }} pfs {{ pfs }}",
+ "result": {
+ "esp_group": {
+ "{{ esp_group }}": {
+ "name": "{{ esp_group }}",
+ "pfs": "{{ pfs }}",
+ },
+ },
+ },
+ },
+
+ # ---------------------------------------------------------------
+ # authentication.psk: remaining fields
+ # ---------------------------------------------------------------
+ {
+ "name": "authentication.psk.secret_type",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sauthentication\spsk\s(?P<psk>\S+)
+ \ssecret-type\s'?(?P<secret_type>[\w-]+)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec authentication psk {{ name }} secret-type {{ secret_type }}",
+ "result": {
+ "authentication": {
+ "psk": {
+ "{{ psk }}": {
+ "name": "{{ psk }}",
+ "secret_type": "{{ secret_type }}",
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "authentication.psk.dhcp_interface",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sauthentication\spsk\s(?P<psk>\S+)
+ \sdhcp-interface\s'?(?P<dhcp_interface>\S+?)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec authentication psk {{ name }} dhcp-interface {{ dhcp_interface }}",
+ "result": {
+ "authentication": {
+ "psk": {
+ "{{ psk }}": {
+ "name": "{{ psk }}",
+ "dhcp_interface": ["{{ dhcp_interface }}"],
+ },
+ },
+ },
+ },
+ },
+
+ # ---------------------------------------------------------------
+ # authentication.ppk
+ # ---------------------------------------------------------------
+ {
+ "name": "authentication.ppk",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sauthentication\sppk\s(?P<ppk>\S+)
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec authentication ppk {{ name }}",
+ "result": {
+ "authentication": {
+ "ppk": {
+ "{{ ppk }}": {
+ "name": "{{ ppk }}",
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "authentication.ppk.id",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sauthentication\sppk\s(?P<ppk>\S+)
+ \sid\s'?(?P<id>\S+?)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec authentication ppk {{ name }} id {{ id }}",
+ "result": {
+ "authentication": {
+ "ppk": {
+ "{{ ppk }}": {
+ "name": "{{ ppk }}",
+ "id": ["{{ id }}"],
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "authentication.ppk.secret",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sauthentication\sppk\s(?P<ppk>\S+)
+ \ssecret\s'?(?P<secret>[^']+)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec authentication ppk {{ name }} secret '{{ secret }}'",
+ "result": {
+ "authentication": {
+ "ppk": {
+ "{{ ppk }}": {
+ "name": "{{ ppk }}",
+ "secret": "{{ secret }}",
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "authentication.ppk.secret_type",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sauthentication\sppk\s(?P<ppk>\S+)
+ \ssecret-type\s'?(?P<secret_type>[\w-]+)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec authentication ppk {{ name }} secret-type {{ secret_type }}",
+ "result": {
+ "authentication": {
+ "ppk": {
+ "{{ ppk }}": {
+ "name": "{{ ppk }}",
+ "secret_type": "{{ secret_type }}",
+ },
+ },
+ },
+ },
+ },
+
+ # ---------------------------------------------------------------
+ # profile: remaining fields
+ # ---------------------------------------------------------------
+ {
+ "name": "profile.disable",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sprofile\s(?P<profile>\S+)
+ \sdisable
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec profile {{ name }} disable",
+ "result": {
+ "profile": {
+ "{{ profile }}": {
+ "name": "{{ profile }}",
+ "disable": True,
+ },
+ },
+ },
+ },
+
+ # ---------------------------------------------------------------
+ # top-level: interface, log, options, disable_uniqreqids
+ # ---------------------------------------------------------------
+ {
+ "name": "interface",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sinterface\s'?(?P<interface>\S+?)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec interface {{ interface }}",
+ "result": {
+ "interface": ["{{ interface }}"],
+ },
+ },
+ {
+ "name": "log.level",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\slog\slevel\s'?(?P<level>\d+)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec log level {{ level }}",
+ "result": {
+ "log": {"level": "{{ level }}"},
+ },
+ },
+ {
+ "name": "log.subsystem",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\slog\ssubsystem\s'?(?P<subsystem>\S+?)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec log subsystem {{ subsystem }}",
+ "result": {
+ "log": {"subsystem": ["{{ subsystem }}"]},
+ },
+ },
+ {
+ "name": "options.disable_route_autoinstall",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\soptions\sdisable-route-autoinstall
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec options disable-route-autoinstall",
+ "result": {
+ "options": {"disable_route_autoinstall": True},
+ },
+ },
+ {
+ "name": "options.flexvpn",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\soptions\sflexvpn
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec options flexvpn",
+ "result": {
+ "options": {"flexvpn": True},
+ },
+ },
+ {
+ "name": "options.interface",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\soptions\sinterface\s'?(?P<interface>\S+?)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec options interface {{ interface }}",
+ "result": {
+ "options": {"interface": "{{ interface }}"},
+ },
+ },
+ {
+ "name": "options.retransmission.attempts",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\soptions\sretransmission\sattempts\s'?(?P<attempts>\d+)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec options retransmission attempts {{ attempts }}",
+ "result": {
+ "options": {"retransmission": {"attempts": "{{ attempts }}"}},
+ },
+ },
+ {
+ "name": "options.retransmission.base",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\soptions\sretransmission\sbase\s'?(?P<base>[\d.]+)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec options retransmission base {{ base }}",
+ "result": {
+ "options": {"retransmission": {"base": "{{ base }}"}},
+ },
+ },
+ {
+ "name": "options.retransmission.timeout",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\soptions\sretransmission\stimeout\s'?(?P<timeout>\d+)'?
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec options retransmission timeout {{ timeout }}",
+ "result": {
+ "options": {"retransmission": {"timeout": "{{ timeout }}"}},
+ },
+ },
+ {
+ "name": "options.virtual_ip",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\soptions\svirtual-ip
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec options virtual-ip",
+ "result": {
+ "options": {"virtual_ip": True},
+ },
+ },
+ {
+ "name": "disable_uniqreqids",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\sdisable-uniqreqids
+ \s*$""", re.VERBOSE,
+ ),
+ "setval": "vpn ipsec disable-uniqreqids",
+ "result": {
+ "disable_uniqreqids": True,
+ },
+ },
+ ]
+ # fmt: on
diff --git a/plugins/module_utils/network/vyos/rm_templates/vpn_ipsec_s2s.py b/plugins/module_utils/network/vyos/rm_templates/vpn_ipsec_s2s.py
new file mode 100644
index 00000000..4319019c
--- /dev/null
+++ b/plugins/module_utils/network/vyos/rm_templates/vpn_ipsec_s2s.py
@@ -0,0 +1,670 @@
+#
+# -*- coding: utf-8 -*-
+# Copyright 2026 Red Hat
+# GNU General Public License v3.0+
+# (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+#
+
+from __future__ import absolute_import, division, print_function
+
+
+__metaclass__ = type
+
+import re
+
+from ansible_collections.ansible.netcommon.plugins.module_utils.network.common.rm_base.network_template import (
+ NetworkTemplate,
+)
+
+
+class Vpn_ipsec_s2sTemplate(NetworkTemplate):
+ def __init__(self, lines=None, module=None):
+ prefix = {"set": "set", "remove": "delete"}
+ super(Vpn_ipsec_s2sTemplate, self).__init__(
+ lines=lines,
+ tmplt=self,
+ module=module,
+ prefix=prefix,
+ )
+
+ # fmt: off
+ PARSERS = [
+ {
+ "name": "peer",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }}",
+ "result": {
+ "site_to_site": {
+ "peer": {
+ "{{ name }}": {
+ "name": "{{ name }}",
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "peer.disable",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)\sdisable$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} disable",
+ "result": {
+ "site_to_site": {"peer": {"{{ name }}": {"name": "{{ name }}", "disable": True}}},
+ },
+ },
+ {
+ "name": "peer.authentication.local_id",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \sauthentication\slocal-id\s'(?P<local_id>[^']+)'$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} authentication local-id '{{ local_id }}'",
+ "result": {
+ "site_to_site": {
+ "peer": {"{{ name }}": {"name": "{{ name }}", "authentication": {"local_id": "{{ local_id }}"}}},
+ },
+ },
+ },
+ {
+ "name": "peer.authentication.remote_id",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \sauthentication\sremote-id\s'(?P<remote_id>[^']+)'$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} authentication remote-id '{{ remote_id }}'",
+ "result": {
+ "site_to_site": {
+ "peer": {"{{ name }}": {"name": "{{ name }}", "authentication": {"remote_id": "{{ remote_id }}"}}},
+ },
+ },
+ },
+ {
+ "name": "peer.authentication.mode",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \sauthentication\smode\s'(?P<mode>[^']+)'$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} authentication mode '{{ mode }}'",
+ "result": {
+ "site_to_site": {
+ "peer": {"{{ name }}": {"name": "{{ name }}", "authentication": {"mode": "{{ mode }}"}}},
+ },
+ },
+ },
+ {
+ "name": "peer.authentication.use_x509_id",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \sauthentication\suse-x509-id$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} authentication use-x509-id",
+ "result": {
+ "site_to_site": {
+ "peer": {"{{ name }}": {"name": "{{ name }}", "authentication": {"use_x509_id": True}}},
+ },
+ },
+ },
+ {
+ "name": "peer.authentication.ppk.id",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \sauthentication\sppk\sid\s'(?P<id>[^']+)'$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} authentication ppk id '{{ id }}'",
+ "result": {
+ "site_to_site": {
+ "peer": {"{{ name }}": {"name": "{{ name }}", "authentication": {"ppk": {"id": "{{ id }}"}}}},
+ },
+ },
+ },
+ {
+ "name": "peer.authentication.ppk.required",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \sauthentication\sppk\srequired$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} authentication ppk required",
+ "result": {
+ "site_to_site": {
+ "peer": {"{{ name }}": {"name": "{{ name }}", "authentication": {"ppk": {"required": True}}}},
+ },
+ },
+ },
+ {
+ "name": "peer.authentication.rsa.local_key",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \sauthentication\srsa\slocal-key\s'(?P<local_key>[^']+)'$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} authentication rsa local-key '{{ local_key }}'",
+ "result": {
+ "site_to_site": {
+ "peer": {"{{ name }}": {"name": "{{ name }}", "authentication": {"rsa": {"local_key": "{{ local_key }}"}}}},
+ },
+ },
+ },
+ {
+ "name": "peer.authentication.rsa.remote_key",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \sauthentication\srsa\sremote-key\s'(?P<remote_key>[^']+)'$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} authentication rsa remote-key '{{ remote_key }}'",
+ "result": {
+ "site_to_site": {
+ "peer": {"{{ name }}": {"name": "{{ name }}", "authentication": {"rsa": {"remote_key": "{{ remote_key }}"}}}},
+ },
+ },
+ },
+ {
+ "name": "peer.authentication.rsa.passphrase",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \sauthentication\srsa\spassphrase\s'(?P<passphrase>[^']+)'$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} authentication rsa passphrase '{{ passphrase }}'",
+ "result": {
+ "site_to_site": {
+ "peer": {"{{ name }}": {"name": "{{ name }}", "authentication": {"rsa": {"passphrase": "{{ passphrase }}"}}}},
+ },
+ },
+ },
+ {
+ "name": "peer.authentication.x509.certificate",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \sauthentication\sx509\scertificate\s'(?P<certificate>[^']+)'$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} authentication x509 certificate '{{ certificate }}'",
+ "result": {
+ "site_to_site": {
+ "peer": {"{{ name }}": {"name": "{{ name }}", "authentication": {"x509": {"certificate": "{{ certificate }}"}}}},
+ },
+ },
+ },
+ {
+ "name": "peer.authentication.x509.passphrase",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \sauthentication\sx509\spassphrase\s'(?P<passphrase>[^']+)'$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} authentication x509 passphrase '{{ passphrase }}'",
+ "result": {
+ "site_to_site": {
+ "peer": {"{{ name }}": {"name": "{{ name }}", "authentication": {"x509": {"passphrase": "{{ passphrase }}"}}}},
+ },
+ },
+ },
+ {
+ "name": "peer.authentication.x509.ca_certificate",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \sauthentication\sx509\sca-certificate\s'(?P<ca_certificate>[^']+)'$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} authentication x509 ca-certificate '{{ ca_certificate }}'",
+ "result": {
+ "site_to_site": {
+ "peer": {"{{ name }}": {"name": "{{ name }}", "authentication": {"x509": {"ca_certificate": ["{{ ca_certificate }}"]}}}},
+ },
+ },
+ },
+ {
+ "name": "peer.childless",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \schildless\s'(?P<childless>[^']+)'$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} childless '{{ childless }}'",
+ "result": {
+ "site_to_site": {"peer": {"{{ name }}": {"name": "{{ name }}", "childless": "{{ childless }}"}}},
+ },
+ },
+ {
+ "name": "peer.connection_type",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \sconnection-type\s'(?P<connection_type>[^']+)'$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} connection-type '{{ connection_type }}'",
+ "result": {
+ "site_to_site": {"peer": {"{{ name }}": {"name": "{{ name }}", "connection_type": "{{ connection_type }}"}}},
+ },
+ },
+ {
+ "name": "peer.default_esp_group",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \sdefault-esp-group\s'(?P<default_esp_group>[^']+)'$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} default-esp-group '{{ default_esp_group }}'",
+ "result": {
+ "site_to_site": {"peer": {"{{ name }}": {"name": "{{ name }}", "default_esp_group": "{{ default_esp_group }}"}}},
+ },
+ },
+ {
+ "name": "peer.description",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \sdescription\s'(?P<description>[^']+)'$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} description '{{ description }}'",
+ "result": {
+ "site_to_site": {"peer": {"{{ name }}": {"name": "{{ name }}", "description": "{{ description }}"}}},
+ },
+ },
+ {
+ "name": "peer.dhcp_interface",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \sdhcp-interface\s'(?P<dhcp_interface>[^']+)'$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} dhcp-interface '{{ dhcp_interface }}'",
+ "result": {
+ "site_to_site": {"peer": {"{{ name }}": {"name": "{{ name }}", "dhcp_interface": "{{ dhcp_interface }}"}}},
+ },
+ },
+ {
+ "name": "peer.force_udp_encapsulation",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \sforce-udp-encapsulation$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} force-udp-encapsulation",
+ "result": {
+ "site_to_site": {"peer": {"{{ name }}": {"name": "{{ name }}", "force_udp_encapsulation": True}}},
+ },
+ },
+ {
+ "name": "peer.ike_group",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \sike-group\s'(?P<ike_group>[^']+)'$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} ike-group '{{ ike_group }}'",
+ "result": {
+ "site_to_site": {"peer": {"{{ name }}": {"name": "{{ name }}", "ike_group": "{{ ike_group }}"}}},
+ },
+ },
+ {
+ "name": "peer.ikev2_reauth",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \sikev2-reauth\s'(?P<ikev2_reauth>[^']+)'$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} ikev2-reauth '{{ ikev2_reauth }}'",
+ "result": {
+ "site_to_site": {"peer": {"{{ name }}": {"name": "{{ name }}", "ikev2_reauth": "{{ ikev2_reauth }}"}}},
+ },
+ },
+ {
+ "name": "peer.local_address",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \slocal-address\s'(?P<local_address>[^']+)'$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} local-address '{{ local_address }}'",
+ "result": {
+ "site_to_site": {"peer": {"{{ name }}": {"name": "{{ name }}", "local_address": "{{ local_address }}"}}},
+ },
+ },
+ {
+ "name": "peer.remote_address",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \sremote-address\s'(?P<remote_address>[^']+)'$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} remote-address '{{ remote_address }}'",
+ "result": {
+ "site_to_site": {"peer": {"{{ name }}": {"name": "{{ name }}", "remote_address": ["{{ remote_address }}"]}}},
+ },
+ },
+ {
+ "name": "peer.replay_window",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \sreplay-window\s'(?P<replay_window>\d+)'$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} replay-window '{{ replay_window }}'",
+ "result": {
+ "site_to_site": {"peer": {"{{ name }}": {"name": "{{ name }}", "replay_window": "{{ replay_window }}"}}},
+ },
+ },
+ {
+ "name": "peer.virtual_address",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \svirtual-address\s'(?P<virtual_address>[^']+)'$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} virtual-address '{{ virtual_address }}'",
+ "result": {
+ "site_to_site": {"peer": {"{{ name }}": {"name": "{{ name }}", "virtual_address": ["{{ virtual_address }}"]}}},
+ },
+ },
+ {
+ "name": "peer.tunnel",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \stunnel\s(?P<tunnel_id>\d+)$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} tunnel {{ tunnel_id }}",
+ "result": {
+ "site_to_site": {
+ "peer": {"{{ name }}": {"name": "{{ name }}", "tunnel": {"{{ tunnel_id }}": {"tunnel_id": "{{ tunnel_id }}"}}}},
+ },
+ },
+ },
+ {
+ "name": "peer.tunnel.disable",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \stunnel\s(?P<tunnel_id>\d+)\sdisable$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} tunnel {{ tunnel_id }} disable",
+ "result": {
+ "site_to_site": {
+ "peer": {
+ "{{ name }}": {
+ "name": "{{ name }}",
+ "tunnel": {
+ "{{ tunnel_id }}": {
+ "tunnel_id": "{{ tunnel_id }}",
+ "disable": True,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "peer.tunnel.esp_group",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \stunnel\s(?P<tunnel_id>\d+)\sesp-group\s'(?P<esp_group>[^']+)'$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} tunnel {{ tunnel_id }} esp-group '{{ esp_group }}'",
+ "result": {
+ "site_to_site": {
+ "peer": {
+ "{{ name }}": {
+ "name": "{{ name }}",
+ "tunnel": {
+ "{{ tunnel_id }}": {
+ "tunnel_id": "{{ tunnel_id }}",
+ "esp_group": "{{ esp_group }}",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "peer.tunnel.protocol",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \stunnel\s(?P<tunnel_id>\d+)\sprotocol\s'(?P<protocol>[^']+)'$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} tunnel {{ tunnel_id }} protocol '{{ protocol }}'",
+ "result": {
+ "site_to_site": {
+ "peer": {
+ "{{ name }}": {
+ "name": "{{ name }}",
+ "tunnel": {
+ "{{ tunnel_id }}": {
+ "tunnel_id": "{{ tunnel_id }}",
+ "protocol": "{{ protocol }}",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "peer.tunnel.priority",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \stunnel\s(?P<tunnel_id>\d+)\spriority\s'(?P<priority>\d+)'$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} tunnel {{ tunnel_id }} priority '{{ priority }}'",
+ "result": {
+ "site_to_site": {
+ "peer": {
+ "{{ name }}": {
+ "name": "{{ name }}",
+ "tunnel": {
+ "{{ tunnel_id }}": {
+ "tunnel_id": "{{ tunnel_id }}",
+ "priority": "{{ priority }}",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "peer.tunnel.local.port",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \stunnel\s(?P<tunnel_id>\d+)\slocal\sport\s'(?P<port>\d+)'$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} tunnel {{ tunnel_id }} local port '{{ port }}'",
+ "result": {
+ "site_to_site": {
+ "peer": {
+ "{{ name }}": {
+ "name": "{{ name }}",
+ "tunnel": {
+ "{{ tunnel_id }}": {
+ "tunnel_id": "{{ tunnel_id }}",
+ "local": {"port": "{{ port }}"},
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "peer.tunnel.local.prefix",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \stunnel\s(?P<tunnel_id>\d+)\slocal\sprefix\s'(?P<prefix>[^']+)'$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} tunnel {{ tunnel_id }} local prefix '{{ prefix }}'",
+ "result": {
+ "site_to_site": {
+ "peer": {
+ "{{ name }}": {
+ "name": "{{ name }}",
+ "tunnel": {"{{ tunnel_id }}": {"tunnel_id": "{{ tunnel_id }}", "local": {"prefix": ["{{ prefix }}"]}}},
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "peer.tunnel.remote.port",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \stunnel\s(?P<tunnel_id>\d+)\sremote\sport\s'(?P<port>\d+)'$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} tunnel {{ tunnel_id }} remote port '{{ port }}'",
+ "result": {
+ "site_to_site": {
+ "peer": {
+ "{{ name }}": {
+ "name": "{{ name }}",
+ "tunnel": {
+ "{{ tunnel_id }}": {
+ "tunnel_id": "{{ tunnel_id }}",
+ "remote": {"port": "{{ port }}"},
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "peer.tunnel.remote.prefix",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \stunnel\s(?P<tunnel_id>\d+)\sremote\sprefix\s'(?P<prefix>[^']+)'$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} tunnel {{ tunnel_id }} remote prefix '{{ prefix }}'",
+ "result": {
+ "site_to_site": {
+ "peer": {
+ "{{ name }}": {
+ "name": "{{ name }}",
+ "tunnel": {"{{ tunnel_id }}": {"tunnel_id": "{{ tunnel_id }}", "remote": {"prefix": ["{{ prefix }}"]}}},
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "peer.vti.bind",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \svti\sbind\s'(?P<bind>[^']+)'$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} vti bind '{{ bind }}'",
+ "result": {
+ "site_to_site": {"peer": {"{{ name }}": {"name": "{{ name }}", "vti": {"bind": "{{ bind }}"}}}},
+ },
+ },
+ {
+ "name": "peer.vti.esp_group",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \svti\sesp-group\s'(?P<esp_group>[^']+)'$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} vti esp-group '{{ esp_group }}'",
+ "result": {
+ "site_to_site": {"peer": {"{{ name }}": {"name": "{{ name }}", "vti": {"esp_group": "{{ esp_group }}"}}}},
+ },
+ },
+ {
+ "name": "peer.vti.traffic_selector.local.prefix",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \svti\straffic-selector\slocal\sprefix\s'(?P<prefix>[^']+)'$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} vti traffic-selector local prefix '{{ prefix }}'",
+ "result": {
+ "site_to_site": {
+ "peer": {
+ "{{ name }}": {
+ "name": "{{ name }}",
+ "vti": {"traffic_selector": {"local": {"prefix": ["{{ prefix }}"]}}},
+ },
+ },
+ },
+ },
+ },
+ {
+ "name": "peer.vti.traffic_selector.remote.prefix",
+ "getval": re.compile(
+ r"""
+ ^set\svpn\sipsec\ssite-to-site\speer\s(?P<name>\S+)
+ \svti\straffic-selector\sremote\sprefix\s'(?P<prefix>[^']+)'$
+ """, re.VERBOSE,
+ ),
+ "setval": "vpn ipsec site-to-site peer {{ name }} vti traffic-selector remote prefix '{{ prefix }}'",
+ "result": {
+ "site_to_site": {
+ "peer": {
+ "{{ name }}": {
+ "name": "{{ name }}",
+ "vti": {"traffic_selector": {"remote": {"prefix": ["{{ prefix }}"]}}},
+ },
+ },
+ },
+ },
+ },
+ ]
+ # fmt: on
diff --git a/plugins/modules/vyos_vpn_ipsec.py b/plugins/modules/vyos_vpn_ipsec.py
new file mode 100644
index 00000000..9af12ff7
--- /dev/null
+++ b/plugins/modules/vyos_vpn_ipsec.py
@@ -0,0 +1,454 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+# Copyright 2026 Red Hat
+# GNU General Public License v3.0+
+# (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+"""
+The module file for vyos_vpn_ipsec
+"""
+
+from __future__ import absolute_import, division, print_function
+
+
+__metaclass__ = type
+
+DOCUMENTATION = """
+module: vyos_vpn_ipsec
+short_description: Manages global IPsec (ike-group, esp-group, profile, authentication, options) attributes of VyOS network devices.
+description: This module manages global VPN IPsec configuration on VyOS devices
+ -- IKE groups, ESP groups, PSK/PPK authentication, IPsec profiles, and global
+ options. Site-to-site peers and IKEv2 remote-access connections are handled by
+ separate modules.
+version_added: 1.0.0
+author: Evgeny Molotkov (@omnom62)
+extends_documentation_fragment:
+ - vyos.vyos.vyos
+notes:
+ - Tested against VyOS 1.4 and 1.5.
+ - "Source of truth for field types/choices: device node.def templates under /opt/vyatta/share/vyatta-cfg/templates/vpn/ipsec/."
+options:
+ config:
+ description: IPsec global configuration.
+ type: dict
+ suboptions:
+ ike_group:
+ description: List of IKE groups.
+ type: list
+ elements: dict
+ suboptions:
+ name:
+ description: The name of the IKE group.
+ type: str
+ required: true
+ close_action:
+ description: Action to take if a child SA is unexpectedly closed.
+ type: str
+ choices: [none, trap, start]
+ dead_peer_detection:
+ description: Dead Peer Detection (DPD).
+ type: dict
+ suboptions:
+ action:
+ description: Keep-alive failure action.
+ type: str
+ choices: [trap, clear, restart]
+ interval:
+ description: Keep-alive interval in seconds.
+ type: int
+ timeout:
+ description: Dead Peer Detection keep-alive timeout (IKEv1 only), in seconds.
+ type: int
+ disable_mobike:
+ description: Disable MOBIKE support (IKEv2 only).
+ type: bool
+ ikev2_reauth:
+ description: Re-authentication of the remote peer during an IKE re-key (IKEv2 only).
+ type: bool
+ key_exchange:
+ description: IKE version.
+ type: str
+ choices: [ikev1, ikev2]
+ lifetime:
+ description: IKE lifetime in seconds.
+ type: int
+ mode:
+ description: IKEv1 phase 1 mode.
+ type: str
+ choices: [main, aggressive]
+ proposal:
+ description: List of IKE proposals.
+ type: list
+ elements: dict
+ suboptions:
+ proposal_id:
+ description: The proposal identifier.
+ type: int
+ dh_group:
+ description: Diffie-Hellman group. See VyOS/strongSwan documentation for the
+ full set of valid values -- validated device-side, not enumerated here since
+ the set is version-dependent.
+ type: int
+ encryption:
+ description: Encryption algorithm. See VyOS/strongSwan documentation for the
+ full set of valid values -- validated device-side, not enumerated here since
+ the set is version-dependent.
+ type: str
+ hash:
+ description: Hash algorithm. See VyOS/strongSwan documentation for the
+ full set of valid values -- validated device-side.
+ type: str
+ prf:
+ description: Pseudo-Random Function. See VyOS/strongSwan documentation for the
+ full set of valid values -- validated device-side.
+ type: str
+ esp_group:
+ description: List of ESP groups.
+ type: list
+ elements: dict
+ suboptions:
+ name:
+ description: The name of the ESP group.
+ type: str
+ required: true
+ compression:
+ description: Enable ESP compression.
+ type: bool
+ disable_rekey:
+ description: Do not locally initiate a re-key of the SA; remote peer must re-key before expiration.
+ type: bool
+ life_bytes:
+ description: Security Association byte count to expire.
+ type: int
+ life_packets:
+ description: Security Association packet count to expire.
+ type: int
+ lifetime:
+ description: Security Association time to expire, in seconds.
+ type: int
+ mode:
+ description: ESP mode.
+ type: str
+ choices: [tunnel, transport]
+ pfs:
+ description: ESP Perfect Forward Secrecy. See VyOS/strongSwan documentation for the
+ full set of valid values -- validated device-side, not enumerated here since
+ the set is version-dependent.
+ type: str
+ proposal:
+ description: List of ESP proposals.
+ type: list
+ elements: dict
+ suboptions:
+ proposal_id:
+ description: The proposal identifier.
+ type: int
+ encryption:
+ description: Encryption algorithm. See VyOS/strongSwan documentation for the
+ full set of valid values -- validated device-side, not enumerated here since
+ the set is version-dependent.
+ type: str
+ hash:
+ description: Hash algorithm. See VyOS/strongSwan documentation for the
+ full set of valid values -- validated device-side.
+ type: str
+ authentication:
+ description: Global pre-shared-key and post-quantum pre-shared-key definitions.
+ type: dict
+ suboptions:
+ psk:
+ description: List of pre-shared keys.
+ type: list
+ elements: dict
+ suboptions:
+ name:
+ description: Pre-shared key name.
+ type: str
+ required: true
+ id:
+ description: ID(s) for authentication.
+ type: list
+ elements: str
+ dhcp_interface:
+ description: DHCP interface(s) supplying next-hop IP address.
+ type: list
+ elements: str
+ secret:
+ description: IKE pre-shared secret key.
+ type: str
+ secret_type:
+ description: Secret encoding type.
+ type: str
+ choices: [base64, hex, plaintext]
+ ppk:
+ description: List of post-quantum pre-shared keys.
+ type: list
+ elements: dict
+ suboptions:
+ name:
+ description: Post-quantum pre-shared key name.
+ type: str
+ required: true
+ id:
+ description: ID(s) for PPK.
+ type: list
+ elements: str
+ secret:
+ description: Post-quantum pre-shared secret key.
+ type: str
+ secret_type:
+ description: Secret encoding type.
+ type: str
+ choices: [base64, hex, plaintext]
+ profile:
+ description: List of VPN IPsec profiles (used for e.g. DMVPN/GRE tunnel binding).
+ type: list
+ elements: dict
+ suboptions:
+ name:
+ description: Profile name.
+ type: str
+ required: true
+ authentication:
+ description: Authentication settings for this profile.
+ type: dict
+ suboptions:
+ mode:
+ description: Authentication mode.
+ type: str
+ choices: [pre-shared-secret]
+ pre_shared_secret:
+ description: Pre-shared secret key.
+ type: str
+ bind_tunnel:
+ description: Tunnel interface(s) associated with this profile.
+ type: list
+ elements: str
+ disable:
+ description: Disable this profile.
+ type: bool
+ esp_group:
+ description: ESP group name to use for this profile.
+ type: str
+ ike_group:
+ description: IKE group name to use for this profile.
+ type: str
+ interface:
+ description: Interface(s) IPsec listens on. If omitted, listens on all interfaces.
+ type: list
+ elements: str
+ log:
+ description: IPsec logging settings.
+ type: dict
+ suboptions:
+ level:
+ description: Global IPsec logging level.
+ type: int
+ subsystem:
+ description: Per-subsystem logging levels to enable.
+ type: list
+ elements: str
+ options:
+ description: Global IPsec options.
+ type: dict
+ suboptions:
+ disable_route_autoinstall:
+ description: Do not automatically install routes to remote networks.
+ type: bool
+ flexvpn:
+ description: Allow FlexVPN vendor ID payload (IKEv2 only).
+ type: bool
+ interface:
+ description: Single interface for IPsec options scope (distinct from top-level interface list).
+ type: str
+ retransmission:
+ description: IPsec retransmission settings.
+ type: dict
+ suboptions:
+ attempts:
+ description: Maximum number of retransmissions.
+ type: int
+ base:
+ description: Base of exponential backoff.
+ type: float
+ timeout:
+ description: Timeout in seconds before the first retransmission.
+ type: int
+ virtual_ip:
+ description: Allow install of virtual-ip addresses.
+ type: bool
+ disable_uniqreqids:
+ description: Disable requirement for unique IDs in the Security Database.
+ type: bool
+ running_config:
+ description:
+ - This option is used only with state I(parsed).
+ - The value of this option should be the output received from the VyOS device by
+ executing the command B(show configuration commands | match "vpn ipsec").
+ - The states I(replaced) and I(overridden) have identical behaviour for this module
+ with respect to named collections (ike_group, esp_group, profile, authentication),
+ but differ in scope -- see the module description for detail.
+ - The state I(parsed) reads the configuration from the C(running_config) option and
+ transforms it into Ansible structured data as per the resource module's argspec,
+ returned in the I(parsed) key within the result.
+ type: str
+ state:
+ description: The state the configuration should be left in.
+ type: str
+ choices: [merged, replaced, overridden, deleted, gathered, rendered, parsed]
+ default: merged
+"""
+
+EXAMPLES = """
+- name: Merge provided configuration with device configuration
+ vyos.vyos.vyos_vpn_ipsec:
+ config:
+ esp_group:
+ - name: ESP-TEST
+ proposal:
+ - proposal_id: 1
+ encryption: aes256
+ hash: sha256
+ ike_group:
+ - name: IKE-TEST
+ key_exchange: ikev2
+ proposal:
+ - proposal_id: 1
+ encryption: aes256
+ hash: sha256
+ dh_group: 14
+ state: merged
+
+- name: Replace one named esp-group, leaving all other groups untouched
+ vyos.vyos.vyos_vpn_ipsec:
+ config:
+ esp_group:
+ - name: ESP-TEST
+ proposal:
+ - proposal_id: 1
+ encryption: aes128
+ hash: sha256
+ state: replaced
+
+- name: Override the whole configuration -- anything not listed here is removed
+ vyos.vyos.vyos_vpn_ipsec:
+ config:
+ esp_group:
+ - name: ESP-TEST
+ proposal:
+ - proposal_id: 1
+ encryption: aes256
+ hash: sha256
+ state: overridden
+
+- name: Delete one named esp-group, leaving all other groups untouched
+ vyos.vyos.vyos_vpn_ipsec:
+ config:
+ esp_group:
+ - name: ESP-TEST
+ state: deleted
+
+- name: Remove all vpn_ipsec configuration
+ vyos.vyos.vyos_vpn_ipsec:
+ state: deleted
+
+- name: Gather current vpn_ipsec configuration
+ vyos.vyos.vyos_vpn_ipsec:
+ state: gathered
+
+- name: Render configuration without touching the device
+ vyos.vyos.vyos_vpn_ipsec:
+ config:
+ esp_group:
+ - name: ESP-TEST
+ proposal:
+ - proposal_id: 1
+ encryption: aes256
+ hash: sha256
+ state: rendered
+
+- name: Parse raw config text into structured facts
+ vyos.vyos.vyos_vpn_ipsec:
+ running_config: "{{ lookup('file', './vpn_ipsec.cfg') }}"
+ state: parsed
+"""
+
+RETURN = """
+before:
+ description: The configuration prior to the module execution.
+ returned: when I(state) is C(merged), C(replaced), C(overridden) or C(deleted)
+ type: dict
+ sample: >
+ This output will always be in the same format as the
+ module argspec.
+after:
+ description: The resulting configuration after module execution.
+ returned: when changed
+ type: dict
+ sample: >
+ This output will always be in the same format as the
+ module argspec.
+commands:
+ description: The set of commands pushed to the remote device.
+ returned: when I(state) is C(merged), C(replaced), C(overridden) or C(deleted)
+ type: list
+ sample:
+ - set vpn ipsec esp-group ESP-TEST proposal 1 encryption aes256
+ - set vpn ipsec ike-group IKE-TEST key-exchange ikev2
+rendered:
+ description: The provided configuration in the task rendered in device-native format (offline).
+ returned: when I(state) is C(rendered)
+ type: list
+ sample:
+ - set vpn ipsec esp-group ESP-TEST proposal 1 encryption aes256
+gathered:
+ description: Facts about the network resource gathered from the remote device as structured data.
+ returned: when I(state) is C(gathered)
+ type: dict
+ sample: >
+ This output will always be in the same format as the
+ module argspec.
+parsed:
+ description: The device native config provided in I(running_config) option parsed into structured data as per module argspec.
+ returned: when I(state) is C(parsed)
+ type: dict
+ sample: >
+ This output will always be in the same format as the
+ module argspec.
+"""
+
+from ansible.module_utils.basic import AnsibleModule
+
+from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.argspec.vpn_ipsec.vpn_ipsec import (
+ Vpn_ipsecArgs,
+)
+from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.config.vpn_ipsec.vpn_ipsec import (
+ Vpn_ipsec,
+)
+
+
+def main():
+ """
+ Main entry point for module execution
+
+ :returns: the result form module invocation
+ """
+ module = AnsibleModule(
+ argument_spec=Vpn_ipsecArgs.argument_spec,
+ mutually_exclusive=[["config", "running_config"]],
+ required_if=[
+ ["state", "merged", ["config"]],
+ ["state", "replaced", ["config"]],
+ ["state", "overridden", ["config"]],
+ ["state", "rendered", ["config"]],
+ ["state", "parsed", ["running_config"]],
+ ],
+ supports_check_mode=True,
+ )
+
+ result = Vpn_ipsec(module).execute_module()
+ module.exit_json(**result)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/plugins/modules/vyos_vpn_ipsec_s2s.py b/plugins/modules/vyos_vpn_ipsec_s2s.py
new file mode 100644
index 00000000..7458381e
--- /dev/null
+++ b/plugins/modules/vyos_vpn_ipsec_s2s.py
@@ -0,0 +1,337 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+# Copyright 2026 Red Hat
+# GNU General Public License v3.0+
+# (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+"""
+The module file for vyos_vpn_ipsec_s2s
+"""
+
+from __future__ import absolute_import, division, print_function
+
+
+__metaclass__ = type
+
+DOCUMENTATION = """
+module: vyos_vpn_ipsec_s2s
+short_description: Manages IPsec site-to-site VPN peers on VyOS network devices.
+description: This module manages VPN IPsec site-to-site peer configuration on VyOS
+ devices -- policy-based tunnels and route-based (VTI) connections. IKE/ESP groups,
+ PSK/PPK authentication, and IPsec profiles are managed by the separate vyos_vpn_ipsec
+ module; peers here reference those by name.
+version_added: 1.0.0
+author: Evgeny Molotkov (@omnom62)
+extends_documentation_fragment:
+ - vyos.vyos.vyos
+notes:
+ - Tested against VyOS 1.4 and 1.5.
+ - "Source of truth: vyos-1x's interface-definitions/vpn_ipsec.xml.in, resolved and
+ drafted via this collection's fetch_vyos_xml_definition.py / parse_xml_definitions.py
+ helper scripts, then hand-reviewed."
+ - "The argspec only requires I(name) on a peer, but VyOS itself enforces
+ several more requirements at commit time -- confirmed via real device
+ testing, not visible in the argspec: every peer needs C(authentication),
+ a real C(remote_address) (not just omitted), a C(local_address) or
+ C(dhcp_interface), and at least one of C(tunnel) or C(vti). A peer
+ missing any of these will pass Ansible's own argument validation but
+ fail the device commit with a specific error naming what's missing."
+options:
+ config:
+ description: IPsec site-to-site configuration.
+ type: dict
+ suboptions:
+ peer:
+ description: List of site-to-site peers.
+ type: list
+ elements: dict
+ suboptions:
+ name:
+ description: Connection name of the peer.
+ type: str
+ required: true
+ disable:
+ description: Disable this peer.
+ type: bool
+ authentication:
+ description: Peer authentication settings.
+ type: dict
+ suboptions:
+ local_id:
+ description: Local ID for peer authentication.
+ type: str
+ remote_id:
+ description: ID for remote authentication.
+ type: str
+ mode:
+ description: Authentication mode.
+ type: str
+ choices: [pre-shared-secret, rsa, x509]
+ use_x509_id:
+ description: Use certificate common name as ID.
+ type: bool
+ ppk:
+ description: Post-quantum preshared key reference for this peer.
+ type: dict
+ suboptions:
+ id:
+ description: Post-quantum preshared key ID for this connection.
+ type: str
+ required:
+ description: Require a valid PPK for the connection to establish.
+ type: bool
+ rsa:
+ description: RSA key authentication.
+ type: dict
+ suboptions:
+ local_key:
+ description: Name of the PKI key-pair with the local private key.
+ type: str
+ remote_key:
+ description: Name of the PKI key-pair with the remote public key.
+ type: str
+ passphrase:
+ description: Local private key passphrase.
+ type: str
+ x509:
+ description: X.509 certificate authentication.
+ type: dict
+ suboptions:
+ certificate:
+ description: Certificate in PKI configuration.
+ type: str
+ passphrase:
+ description: Private key passphrase.
+ type: str
+ ca_certificate:
+ description: Certificate Authority chain in PKI configuration.
+ type: list
+ elements: str
+ childless:
+ description: Childless IKE SA initiation support.
+ type: str
+ choices: [allow, prefer, force, never]
+ connection_type:
+ description: Connection type.
+ type: str
+ choices: [initiate, trap, none]
+ default_esp_group:
+ description: Default ESP group name for tunnels under this peer that
+ don't specify their own.
+ type: str
+ description:
+ description: Description.
+ type: str
+ dhcp_interface:
+ description: DHCP interface supplying the next-hop IP address.
+ type: str
+ force_udp_encapsulation:
+ description: Force UDP encapsulation.
+ type: bool
+ ike_group:
+ description: IKE group name.
+ type: str
+ ikev2_reauth:
+ description: Re-authentication of the remote peer during an IKE re-key
+ (IKEv2 only).
+ type: str
+ choices: ["yes", "no", inherit]
+ local_address:
+ description: IPv4 or IPv6 address of a local interface to use for the
+ VPN, or "any".
+ type: str
+ remote_address:
+ description: IPv4 or IPv6 address(es) of the remote peer, or "any".
+ type: list
+ elements: str
+ replay_window:
+ description: IPsec replay window to configure for this CHILD_SA.
+ type: int
+ virtual_address:
+ description: Initiator-requested virtual address(es) from the peer.
+ type: list
+ elements: str
+ tunnel:
+ description: Policy-based tunnel definitions for this peer.
+ type: list
+ elements: dict
+ suboptions:
+ tunnel_id:
+ description: The tunnel identifier.
+ type: int
+ required: true
+ disable:
+ description: Disable this tunnel.
+ type: bool
+ esp_group:
+ description: ESP group name for this tunnel (overrides the peer's
+ default_esp_group).
+ type: str
+ protocol:
+ description: Protocol to match for this tunnel's traffic selector.
+ type: str
+ priority:
+ description: Priority for this IPsec policy (lowest value is most
+ preferred).
+ type: int
+ local:
+ description: Local traffic selector for this tunnel.
+ type: dict
+ suboptions:
+ port:
+ description: Local port to match.
+ type: int
+ prefix:
+ description: Local IPv4 or IPv6 prefix(es) to match.
+ type: list
+ elements: str
+ remote:
+ description: Remote traffic selector for this tunnel.
+ type: dict
+ suboptions:
+ port:
+ description: Remote port to match.
+ type: int
+ prefix:
+ description: Remote IPv4 or IPv6 prefix(es) to match.
+ type: list
+ elements: str
+ vti:
+ description: Route-based (VTI) connection settings for this peer.
+ type: dict
+ suboptions:
+ bind:
+ description: VTI tunnel interface associated with this connection.
+ type: str
+ esp_group:
+ description: ESP group name for this VTI connection.
+ type: str
+ traffic_selector:
+ description: Traffic selector for the VTI connection.
+ type: dict
+ suboptions:
+ local:
+ description: Local traffic-selector parameters.
+ type: dict
+ suboptions:
+ prefix:
+ description: Local IPv4 or IPv6 prefix(es).
+ type: list
+ elements: str
+ remote:
+ description: Remote traffic-selector parameters.
+ type: dict
+ suboptions:
+ prefix:
+ description: Remote IPv4 or IPv6 prefix(es).
+ type: list
+ elements: str
+ running_config:
+ description:
+ - This option is used only with state I(parsed).
+ - The value of this option should be the output received from the VyOS device
+ by executing the command B(show configuration commands | match "vpn ipsec
+ site-to-site").
+ - The state I(parsed) reads the configuration from the C(running_config) option
+ and transforms it into Ansible structured data as per the resource module's
+ argspec, returned in the I(parsed) key within the result.
+ type: str
+ state:
+ description: The state the configuration should be left in.
+ type: str
+ choices: [merged, replaced, overridden, deleted, gathered, rendered, parsed]
+ default: merged
+"""
+
+EXAMPLES = """
+- name: Merge a site-to-site peer
+ vyos.vyos.vyos_vpn_ipsec_s2s:
+ config:
+ peer:
+ - name: PEER-TEST
+ ike_group: IKE-TEST
+ default_esp_group: ESP-TEST
+ remote_address:
+ - 203.0.113.1
+ state: merged
+"""
+
+RETURN = """
+before:
+ description: The configuration prior to the module execution.
+ returned: when I(state) is C(merged), C(replaced), C(overridden) or C(deleted)
+ type: dict
+ sample: >
+ This output will always be in the same format as the
+ module argspec.
+after:
+ description: The resulting configuration after module execution.
+ returned: when changed
+ type: dict
+ sample: >
+ This output will always be in the same format as the
+ module argspec.
+commands:
+ description: The set of commands pushed to the remote device.
+ returned: when I(state) is C(merged), C(replaced), C(overridden) or C(deleted)
+ type: list
+ sample:
+ - set vpn ipsec site-to-site peer PEER-TEST ike-group 'IKE-TEST'
+ - set vpn ipsec site-to-site peer PEER-TEST default-esp-group 'ESP-TEST'
+rendered:
+ description: The provided configuration in the task rendered in device-native format (offline).
+ returned: when I(state) is C(rendered)
+ type: list
+ sample:
+ - set vpn ipsec site-to-site peer PEER-TEST ike-group 'IKE-TEST'
+gathered:
+ description: Facts about the network resource gathered from the remote device as structured data.
+ returned: when I(state) is C(gathered)
+ type: dict
+ sample: >
+ This output will always be in the same format as the
+ module argspec.
+parsed:
+ description: The device native config provided in I(running_config) option parsed into structured data as per module argspec.
+ returned: when I(state) is C(parsed)
+ type: dict
+ sample: >
+ This output will always be in the same format as the
+ module argspec.
+"""
+
+from ansible.module_utils.basic import AnsibleModule
+
+from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.argspec.vpn_ipsec_s2s.vpn_ipsec_s2s import (
+ Vpn_ipsec_s2sArgs,
+)
+from ansible_collections.vyos.vyos.plugins.module_utils.network.vyos.config.vpn_ipsec_s2s.vpn_ipsec_s2s import (
+ Vpn_ipsec_s2s,
+)
+
+
+def main():
+ """
+ Main entry point for module execution
+
+ :returns: the result form module invocation
+ """
+ module = AnsibleModule(
+ argument_spec=Vpn_ipsec_s2sArgs.argument_spec,
+ mutually_exclusive=[["config", "running_config"]],
+ required_if=[
+ ["state", "merged", ["config"]],
+ ["state", "replaced", ["config"]],
+ ["state", "overridden", ["config"]],
+ ["state", "rendered", ["config"]],
+ ["state", "parsed", ["running_config"]],
+ ],
+ supports_check_mode=True,
+ )
+
+ result = Vpn_ipsec_s2s(module).execute_module()
+ module.exit_json(**result)
+
+
+if __name__ == "__main__":
+ main()