Skip to content
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,17 +302,26 @@ export NDFC_SW_USERNAME=admin
export NDFC_SW_PASSWORD=Admin_123
```

#### Handling Secrets

This collection supports two options to securely handle secrets in the NaC VXLAN data model such as passwords and authentication keys:

* **🔐 Ansible Vault**: Encrypts credentials
* **✅ Environment Variables**: Resolves secrets from environment variables

**📖 Guide**: [Handling Secrets Guide](docs/Handling_Secrets_Guide.md)

##### Switch Credential Management

This collection supports flexible credential management for network switches with three security levels:
For switch credentials specifically, this collection supports flexible credential management with three security levels:

* **🔐 Ansible Vault**: Encrypted credentials for production deployments
* **✅ Environment Variables**: Secure credential injection for CI/CD pipelines
* **⚠️ Plain Text**: Simple credentials for lab testing only

The system supports both switch-specific credentials and group-level defaults with automatic fallback. Environment variable lookups can be configured in group_vars for enhanced security and automation compatibility.
NaC VXLAN supports both switch-specific credentials and group-level defaults with automatic fallback. Environment variable lookups can be configured in group_vars for enhanced security and automation compatibility.

**📖 Complete Guide**: [Switch Credentials Configuration](docs/SWITCH_CREDENTIALS_GUIDE.md)
**📖 Guide**: [Switch Credentials Configuration](docs/SWITCH_CREDENTIALS_GUIDE.md)

## Quick Start Guide

Expand Down
136 changes: 136 additions & 0 deletions docs/Handling_Secrets_Guide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# Handling Secrets Guide

This guide describes how to manage secrets such as passwords and authentication keys in the Network as Code VXLAN data model. There are two mechanisms available:

1. **Ansible Vault** - Encrypts entire variable values at rest.
2. **Environment Variable Lookup (`env_var_`)** - Resolves secrets from environment variables at runtime. Suitable for CI/CD integration.

Both approaches ensure that plaintext secrets are not visible in the data YAML files. The key difference is that Ansible Vault encrypts whole values while `env_var_` replaces individual strings — making `env_var_` the only option for secrets embedded in freeform configuration blocks.

---

## Ansible Vault

[Ansible Vault](https://docs.ansible.com/ansible/latest/vault_guide/index.html) encrypts sensitive data so it can be stored safely in data files. Encrypted values are decrypted automatically at playbook runtime when a vault password is provided.

### Encrypting a Value

```bash
ansible-vault encrypt_string 'MySecretPassword' --name 'authentication_key'
```

This produces an encrypted variable which you can paste into your data files:

```yaml
vxlan:
underlay:
bgp:
authentication_enable: true
authentication_key_type: 3
authentication_key: !vault |
$ANSIBLE_VAULT;1.1;AES256
61626364656667686970...
```

### Running Playbooks with Vault

```bash
# Prompt for vault password
ansible-playbook -i inventory deploy.yaml --ask-vault-pass

# Use a password file
ansible-playbook -i inventory deploy.yaml --vault-password-file ~/.vault_pass
```

### Limitation: Freeform Configurations

Ansible Vault encrypts the entire value of a YAML field. This works for standalone fields like `authentication_key` where the complete value is a secret.

However, freeform configuration blocks may contain a mix of regular configuration and secrets within a single multi-line string:

```yaml
# Does not work with Ansible Vault because only the TACACS key is a secret,
# not the entire block.
aaa_freeform: |
feature tacacs+
tacacs-server key 7 MySecretTacacsKey
ip tacacs source-interface mgmt0
tacacs-server timeout 20
```

Encrypting the entire `aaa_freeform` value with Ansible Vault would encrypt all four lines, which is not desired.

---

## Environment Variable Lookup (`env_var_`)

The `env_var_` prefix instructs NaC to resolve a value from an environment variable at runtime. This works for:

- **Standalone values** - the entire YAML value is an reference to an environment variable
- **Embedded secrets** - `env_var_` references inside larger strings (e.g. freeform configuration blocks)

### How It Works

Any string value in the data model that contains `env_var_` is resolved by the NaC VXLAN `validate` role. The secret is replaced with the value of the corresponding environment variable.

* Variable names in YAML must exactly match the environment variable names (including the env_var_ prefix).
* Valid environment variable names may include letters, digits, and underscores.
* Use single quotes to prevent shell interpretation of special characters
* If the environment variable is not set, a warning is displayed and the secret is left unchanged.

### Setting Environment Variables

```bash
export env_var_BGP_AUTH_KEY='MyBgpP@$$w0rd'
export env_var_MCAST_AUTH_KEY='mcast_secret_123'
export env_var_TACACS_KEY='TacacsSecret!'
export env_var_DCI_PASSWORD='DciP@ssword'
```

### Examples: Standalone Values

```yaml
vxlan:
underlay:
multicast:
ipv4:
authentication_enable: true
authentication_key: env_var_MCAST_AUTH_KEY
bgp:
authentication_enable: true
authentication_key_type: 3
authentication_key: env_var_BGP_AUTH_KEY
```

```yaml
vxlan:
multisite:
overlay_dci:
enable_ebgp_password: True
ebgp_password: env_var_DCI_PASSWORD
ebgp_password_encryption_type: 3
```

### Example: Freeform Configurations

```yaml
vxlan:
global:
ebgp:
aaa_freeform: |
feature tacacs+
tacacs-server key 7 env_var_TACACS_KEY
ip tacacs source-interface mgmt0
tacacs-server timeout 20
```

At runtime, only `env_var_TACACS_KEY` is replaced with the value of the `env_var_TACACS_KEY` environment variable. The rest of the block is unchanged:

```
feature tacacs+
tacacs-server key 7 TacacsSecret!
ip tacacs source-interface mgmt0
tacacs-server timeout 20
```

This pattern works in any freeform field (`aaa_freeform`, `banner_freeform`, `bootstrap_freeform`, `intra_fabric_link_freeform`, `freeform_config`, etc.) and supports multiple `env_var_` tokens in the same block.
159 changes: 159 additions & 0 deletions plugins/action/common/prepare_plugins/prep_005_resolve_env_vars.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# Copyright (c) 2025 Cisco Systems, Inc. and its affiliates
#
# Permission is hereby granted, free of charge, to any person obtaining a copy of
# this software and associated documentation files (the "Software"), to deal in
# the Software without restriction, including without limitation the rights to
# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
# the Software, and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
#
# SPDX-License-Identifier: MIT

import os
import re
from ansible.utils.display import Display

display = Display()

ENV_VAR_PREFIX = 'env_var_'

# Matches env_var_ followed by one or more word characters (a-z, A-Z, 0-9, _)
ENV_VAR_PATTERN = re.compile(r'env_var_\w+')


def resolve_env_var_token(token, path):
"""
Resolve a single env_var_ token to its environment variable value.

Note: Environment variables containing special characters like $, `, \\, etc.
should be properly escaped when setting them in the shell.
Example: export BGP_AUTH_KEY='MyP@$$w0rd' (use single quotes to prevent shell interpretation)
"""
resolved = os.getenv(token)

if resolved is None:
display.warning(
f"Environment variable '{token}' referenced at "
f"'{path}' is not set. The value will not be resolved."
)
return token

display.vvv(f"Resolved '{token}' from environment variable at '{path}'")
return resolved


def resolve_env_vars_in_string(value, path):
resolved_count = 0

def replace_match(match):
nonlocal resolved_count
token = match.group(0)
replacement = resolve_env_var_token(token, path)
if replacement != token:
resolved_count += 1
return replacement

new_value = ENV_VAR_PATTERN.sub(replace_match, value)
return new_value, resolved_count


def resolve_env_vars_recursive(data, path=''):
"""
Resolve all env_var_ tokens in a data structure by replacing them
with the corresponding environment variable values (in-place).

Used at runtime by build_resource_data to resolve tokens in
module_data (a deep copy) before sending to NDFC modules.
"""
resolved_count = 0

if isinstance(data, dict):
for key, value in data.items():
current_path = f"{path}.{key}" if path else key
if isinstance(value, str) and ENV_VAR_PREFIX in value:
data[key], count = resolve_env_vars_in_string(value, current_path)
resolved_count += count
elif isinstance(value, (dict, list)):
resolved_count += resolve_env_vars_recursive(value, current_path)
elif isinstance(data, list):
for index, item in enumerate(data):
current_path = f"{path}[{index}]"
if isinstance(item, str) and ENV_VAR_PREFIX in item:
data[index], count = resolve_env_vars_in_string(item, current_path)
resolved_count += count
elif isinstance(item, (dict, list)):
resolved_count += resolve_env_vars_recursive(item, current_path)

return resolved_count


def _validate_env_var_token(token, path):
if os.getenv(token) is None:
display.warning(
f"Environment variable '{token}' referenced at "
f"'{path}' is not set. The value will not be resolved at runtime."
)
return False

display.vvv(f"Validated '{token}' exists as environment variable at '{path}'")
return True


def validate_env_vars_recursive(data, path=''):
"""
Walk the data structure and validate that all env_var_ tokens have
corresponding environment variables set, without resolving them.

Tokens remain as-is in the data so that rendered files do not
contain secrets. Runtime resolution happens in build_resource_data.
"""
validated_count = 0

if isinstance(data, dict):
for key, value in data.items():
current_path = f"{path}.{key}" if path else key
if isinstance(value, str) and ENV_VAR_PREFIX in value:
for match in ENV_VAR_PATTERN.finditer(value):
if _validate_env_var_token(match.group(0), current_path):
validated_count += 1
elif isinstance(value, (dict, list)):
validated_count += validate_env_vars_recursive(value, current_path)
elif isinstance(data, list):
for index, item in enumerate(data):
current_path = f"{path}[{index}]"
if isinstance(item, str) and ENV_VAR_PREFIX in item:
for match in ENV_VAR_PATTERN.finditer(item):
if _validate_env_var_token(match.group(0), current_path):
validated_count += 1
elif isinstance(item, (dict, list)):
validated_count += validate_env_vars_recursive(item, current_path)

return validated_count


class PreparePlugin:
def __init__(self, **kwargs):
self.kwargs = kwargs
self.keys = []

def prepare(self):
data_model = self.kwargs['results']['model_extended']

# Validate env vars exist but keep tokens as placeholders.
# Runtime resolution happens in build_resource_data.
validated_count = validate_env_vars_recursive(data_model)
if validated_count > 0:
display.v(f"Validated {validated_count} environment variable(s) in the data model")

self.kwargs['results']['model_extended'] = data_model
return self.kwargs['results']
13 changes: 13 additions & 0 deletions plugins/action/dtc/build_resource_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@

__metaclass__ = type

import copy
import hashlib
import os
import re
Expand All @@ -61,6 +62,9 @@
from ansible_collections.cisco.nac_dc_vxlan.plugins.plugin_utils.registry_loader import (
RegistryLoader,
)
from ansible_collections.cisco.nac_dc_vxlan.plugins.action.common.prepare_plugins.prep_005_resolve_env_vars import (
resolve_env_vars_recursive,
)

display = Display()

Expand Down Expand Up @@ -329,6 +333,15 @@ def _build_resource(self, resource_name, rt, template):
module_data = hook_result['module_data']
break

resolved_data = copy.deepcopy(module_data)
env_count = resolve_env_vars_recursive(resolved_data)
if env_count > 0:
module_data = resolved_data
display.vvv(
f"COMMON [{self.fabric_name}] Resolved {env_count} env_var_ "
f"token(s) in module_data for {resource_name}"
)

if module_data is not data:
resource_entry['module_data'] = module_data

Expand Down
1 change: 1 addition & 0 deletions tests/sanity/ignore-2.14.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ plugins/action/common/prepare_plugins/prep_001_fabric.py action-plugin-docs # ac
plugins/action/common/prepare_plugins/prep_002_global.py action-plugin-docs # action plugin has no matching module to provide documentation
plugins/action/common/prepare_plugins/prep_003_list_defaults.py action-plugin-docs # action plugin has no matching module to provide documentation
plugins/action/common/prepare_plugins/prep_004_calculate_md5_policies.py action-plugin-docs # action plugin has no matching module to provide documentation
plugins/action/common/prepare_plugins/prep_005_resolve_env_vars.py action-plugin-docs # action plugin has no matching module to provide documentation
plugins/action/common/prepare_plugins/prep_104_topology_switches.py action-plugin-docs # action plugin has no matching module to provide documentation
plugins/action/common/prepare_plugins/prep_105_fabric_overlay.py action-plugin-docs # action plugin has no matching module to provide documentation
plugins/action/common/prepare_plugins/prep_106_topology_interfaces.py action-plugin-docs # action plugin has no matching module to provide documentation
Expand Down
1 change: 1 addition & 0 deletions tests/sanity/ignore-2.15.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ plugins/action/common/prepare_plugins/prep_001_fabric.py action-plugin-docs # ac
plugins/action/common/prepare_plugins/prep_002_global.py action-plugin-docs # action plugin has no matching module to provide documentation
plugins/action/common/prepare_plugins/prep_003_list_defaults.py action-plugin-docs # action plugin has no matching module to provide documentation
plugins/action/common/prepare_plugins/prep_004_calculate_md5_policies.py action-plugin-docs # action plugin has no matching module to provide documentation
plugins/action/common/prepare_plugins/prep_005_resolve_env_vars.py action-plugin-docs # action plugin has no matching module to provide documentation
plugins/action/common/prepare_plugins/prep_104_topology_switches.py action-plugin-docs # action plugin has no matching module to provide documentation
plugins/action/common/prepare_plugins/prep_105_fabric_overlay.py action-plugin-docs # action plugin has no matching module to provide documentation
plugins/action/common/prepare_plugins/prep_106_topology_interfaces.py action-plugin-docs # action plugin has no matching module to provide documentation
Expand Down
1 change: 1 addition & 0 deletions tests/sanity/ignore-2.16.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ plugins/action/common/prepare_plugins/prep_001_fabric.py action-plugin-docs # ac
plugins/action/common/prepare_plugins/prep_002_global.py action-plugin-docs # action plugin has no matching module to provide documentation
plugins/action/common/prepare_plugins/prep_003_list_defaults.py action-plugin-docs # action plugin has no matching module to provide documentation
plugins/action/common/prepare_plugins/prep_004_calculate_md5_policies.py action-plugin-docs # action plugin has no matching module to provide documentation
plugins/action/common/prepare_plugins/prep_005_resolve_env_vars.py action-plugin-docs # action plugin has no matching module to provide documentation
plugins/action/common/prepare_plugins/prep_104_topology_switches.py action-plugin-docs # action plugin has no matching module to provide documentation
plugins/action/common/prepare_plugins/prep_105_fabric_overlay.py action-plugin-docs # action plugin has no matching module to provide documentation
plugins/action/common/prepare_plugins/prep_106_topology_interfaces.py action-plugin-docs # action plugin has no matching module to provide documentation
Expand Down
1 change: 1 addition & 0 deletions tests/sanity/ignore-2.17.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ plugins/action/common/prepare_plugins/prep_001_fabric.py action-plugin-docs # ac
plugins/action/common/prepare_plugins/prep_002_global.py action-plugin-docs # action plugin has no matching module to provide documentation
plugins/action/common/prepare_plugins/prep_003_list_defaults.py action-plugin-docs # action plugin has no matching module to provide documentation
plugins/action/common/prepare_plugins/prep_004_calculate_md5_policies.py action-plugin-docs # action plugin has no matching module to provide documentation
plugins/action/common/prepare_plugins/prep_005_resolve_env_vars.py action-plugin-docs # action plugin has no matching module to provide documentation
plugins/action/common/prepare_plugins/prep_104_topology_switches.py action-plugin-docs # action plugin has no matching module to provide documentation
plugins/action/common/prepare_plugins/prep_105_fabric_overlay.py action-plugin-docs # action plugin has no matching module to provide documentation
plugins/action/common/prepare_plugins/prep_106_topology_interfaces.py action-plugin-docs # action plugin has no matching module to provide documentation
Expand Down
Loading
Loading