OPNsense Destination Nat

This role manages Destination NAT (port forwarding) rules on OPNsense via the REST API. It enables forwarding external traffic to internal hosts and redirecting traffic between interfaces.

Ansible DNS HTTPS JSON NTP OPNsense REST API SSH

OPNsense Destination NAT Role

Overview

This role manages Destination NAT (port forwarding) rules on OPNsense via the REST API. It enables forwarding external traffic to internal hosts and redirecting traffic between interfaces. The role provides full lifecycle management: creating new rules, updating existing rules when configuration changes, and deleting orphaned rules that exist on the firewall but are no longer defined in the vars files.

Purpose

  • Port Forwarding: Forward external traffic to internal services
  • Traffic Redirection: Redirect traffic between interfaces (e.g., NTP redirect)
  • Code as Configuration: Define DNAT rules in YAML
  • Centralized Control: Manage all port forwarding from Ansible
  • API-Based: Reliable automation via OPNsense REST API
  • Full Lifecycle Management: Create, update, and delete rules
  • Idempotent: Safe to run multiple times using sequence-based idempotency
  • Automatic Cleanup: Removes orphaned rules not defined in vars

Requirements

  • Ansible 2.9 or higher
  • OPNsense firewall with API access enabled
  • API key and secret stored in Ansible Vault
  • Network connectivity to OPNsense (VLAN10)
  • OPNsense user with NAT rule permissions

What is Destination NAT?

Destination NAT (DNAT) modifies the destination address/port of incoming packets:

  • Port Forwarding: External IP:port → Internal IP:port
  • Traffic Redirection: Redirect traffic to a different destination

Example use cases:

  • Forward WAN port 25565 to internal Minecraft server
  • Redirect all NTP traffic from IoT devices to the firewall
  • Expose internal web servers to the internet

Role Variables

Required Variables

VariableRequiredDescription
vault_opnsense_bjoffrey_user_api_keyYesOPNsense API key (in vault)
vault_opnsense_bjoffrey_user_api_secretYesOPNsense API secret (in vault)
opnsense_destination_nat_rulesYesList of DNAT rules

Optional Variables

VariableDefaultDescription
opnsense_destination_nat_validate_certstrueValidate SSL certificates

Rule Structure

Each rule in the list has these fields:

Required fields:

- sequence: "100"              # Unique sequence number for idempotency
  interface: "wan"             # Incoming interface
  destination_port: "25565"    # External port to match
  target: "192.168.x.x"       # Internal IP to forward to
  local_port: "25565"          # Internal port to forward to
  description: "Rule description"

Optional fields:

  disabled: "0"                # Disabled state (0=active, 1=disabled)
  ipprotocol: "inet"           # IP version (inet=IPv4, inet6=IPv6)
  protocol: "tcp"              # Protocol (tcp, udp, tcp/udp)
  source_network: ""           # Source network to match (empty=any)
  source_port: ""              # Source port to match (empty=any)
  source_not: "0"              # Invert source match
  destination_network: ""      # Destination address to match (WAN IP)
  destination_not: "0"         # Invert destination match
  log: "1"                     # Log matched packets

Complete example:

opnsense_destination_nat_rules:
  # Port forward from WAN to internal server
  - sequence: "100"
    disabled: "0"
    interface: "wan"
    protocol: "tcp/udp"
    destination_network: "192.168.x.x"
    destination_port: "25565"
    target: "192.168.x.x"
    local_port: "25565"
    log: "1"
    description: "Minecraft server"

  # Redirect NTP traffic to firewall
  - sequence: "200"
    disabled: "0"
    interface: "opt6"
    protocol: "udp"
    source_network: "opt6"
    destination_port: "123"
    target: "192.168.x.x"
    local_port: "123"
    log: "1"
    description: "Redirect cameras NTP to firewall"

sequence

The sequence number is the key field for idempotency. It uniquely identifies a rule.

Purpose:

  • Maps rules in vars to rules on the firewall
  • Enables create/update/delete logic
  • Must be unique across all DNAT rules

Important:

  • Sequence numbers don’t need to be consecutive
  • Use gaps (e.g., 100, 200, 300) to allow inserting rules later
  • Removing a rule from vars will delete it from the firewall
  • Changing a sequence number creates a new rule and orphans the old one

disabled vs enabled

Note: DNAT rules use disabled instead of enabled (inverted logic):

  • disabled: "0" = Rule is active
  • disabled: "1" = Rule is disabled

Dependencies

This role has no dependencies on other Ansible roles, but requires:

  • OPNsense firewall with API enabled
  • API key with NAT rule permissions
  • Ansible Vault for storing API credentials

Example Playbook

Basic Usage

---
- name: Configure OPNsense Destination NAT Rules
  hosts: mint-vm
  gather_facts: false

  vars_files:
    - ../../roles/opnsense_destination_nat/vars/dnat_rules.yml

  tasks:
    - name: Configure destination NAT rules on OPNsense
      ansible.builtin.include_role:
        name: opnsense_destination_nat

Port Forwarding Example

---
- name: Configure Port Forwarding
  hosts: mint-vm
  gather_facts: false

  vars:
    opnsense_destination_nat_rules:
      # Web server on port 443
      - sequence: "100"
        interface: "wan"
        protocol: "tcp"
        destination_port: "443"
        target: "192.168.x.x"
        local_port: "443"
        log: "1"
        description: "HTTPS to web server"

      # SSH on non-standard port
      - sequence: "200"
        interface: "wan"
        protocol: "tcp"
        destination_port: "2222"
        target: "192.168.x.x"
        local_port: "22"
        log: "1"
        description: "SSH to admin server"

  roles:
    - opnsense_destination_nat

Traffic Redirection Example

---
- name: Configure Traffic Redirection
  hosts: mint-vm
  gather_facts: false

  vars:
    opnsense_destination_nat_rules:
      # Force all DNS to firewall
      - sequence: "100"
        interface: "opt5"
        protocol: "udp"
        source_network: "opt5"
        destination_port: "53"
        target: "192.168.x.x"
        local_port: "53"
        log: "0"
        description: "Redirect guest DNS to firewall"

      # Force all NTP to firewall
      - sequence: "200"
        interface: "opt6"
        protocol: "udp"
        source_network: "opt6"
        destination_port: "123"
        target: "192.168.x.x"
        local_port: "123"
        log: "1"
        description: "Redirect CCTV NTP to firewall"

  roles:
    - opnsense_destination_nat

What This Role Does

  1. Fetches existing rules via /api/firewall/d_nat/search_rule
  2. Builds sequence → UUID mapping for idempotency
  3. Creates new rules (sequence doesn’t exist) via /api/firewall/d_nat/addRule
  4. Updates existing rules (sequence exists but fields differ) via /api/firewall/d_nat/setRule
  5. Deletes orphaned rules (sequence exists on firewall but not in vars) via /api/firewall/d_nat/delRule
  6. Applies configuration via /api/firewall/d_nat/apply
  7. Displays summary of configured rules

OPNsense API Endpoints

Add DNAT Rule

POST /api/firewall/d_nat/addRule
Authorization: Basic (API key:secret)
Content-Type: application/json

Request body:

{
  "rule": {
    "disabled": "0",
    "sequence": "100",
    "interface": "wan",
    "ipprotocol": "inet",
    "protocol": "tcp",
    "source.network": "",
    "source.port": "",
    "source.not": "0",
    "destination.network": "192.168.x.x",
    "destination.port": "25565",
    "destination.not": "0",
    "target": "192.168.x.x",
    "local-port": "25565",
    "log": "1",
    "descr": "Minecraft server"
  }
}

Response:

{
  "result": "saved",
  "uuid": "12345678-1234-1234-1234-123456789abc"
}

Update DNAT Rule

POST /api/firewall/d_nat/setRule/{uuid}
Authorization: Basic (API key:secret)
Content-Type: application/json

Request body: Same as addRule

Response:

{
  "result": "saved"
}

Delete DNAT Rule

POST /api/firewall/d_nat/delRule/{uuid}
Authorization: Basic (API key:secret)

Response:

{
  "result": "deleted"
}

Search Rules

GET /api/firewall/d_nat/search_rule
Authorization: Basic (API key:secret)

Returns all DNAT rules with their UUIDs and sequence numbers.

Apply Configuration

POST /api/firewall/d_nat/apply

Activates pending DNAT rule changes.

Idempotency

Role uses sequence-based idempotency:

  • Each rule has a unique sequence number
  • Sequence numbers map to UUIDs on the firewall
  • Create: New sequence → creates rule
  • Update: Existing sequence with changed fields → updates rule
  • Delete: Sequence on firewall but not in vars → deletes rule
  • Only applies configuration if changes were made
  • Safe to run repeatedly

Important: Removing a rule from your vars file will delete it from the firewall on the next run.

Security Considerations

  • API Credentials: Stored in Ansible Vault
  • HTTPS: Uses SSL/TLS for API calls
  • Basic Auth: API key/secret authentication
  • Certificate Validation: Enabled by default
  • Firewall Rules: Remember to create corresponding firewall rules for forwarded traffic
  • Logging: Enable logging for security-sensitive port forwards

Notes

  • become: false recommended (no root needed)
  • Sequence numbers must be unique
  • Removing a rule from vars will delete it from the firewall
  • DNAT rules require corresponding firewall rules to allow the traffic
  • Uses disabled instead of enabled (inverted logic)
  • API field names use dots (source.network) and hyphens (local-port)

Common Use Cases

Expose Web Server

- sequence: "100"
  interface: "wan"
  protocol: "tcp"
  destination_port: "443"
  target: "192.168.x.x"
  local_port: "443"
  description: "HTTPS to web server"

Game Server with Non-Standard Port

- sequence: "200"
  interface: "wan"
  protocol: "tcp/udp"
  destination_port: "25565"
  target: "192.168.x.x"
  local_port: "25565"
  description: "Minecraft server"

Redirect IoT DNS to Pi-hole

- sequence: "300"
  interface: "opt6"
  protocol: "udp"
  source_network: "opt6"
  destination_port: "53"
  target: "192.168.x.x"
  local_port: "53"
  description: "Redirect IoT DNS to Pi-hole"

Force NTP to Firewall

- sequence: "400"
  interface: "opt6"
  protocol: "udp"
  source_network: "opt6"
  destination_port: "123"
  target: "192.168.x.x"
  local_port: "123"
  description: "Force CCTV NTP to firewall"

Troubleshooting

”Authentication failed” errors

Cause: Invalid API key/secret

Solution: Verify credentials in vault and test API manually.

Rules not working

Check:

  1. Firewall rules exist to allow the forwarded traffic
  2. Target IP is correct and reachable
  3. Service is listening on the target port
  4. NAT reflection settings if accessing from internal network

Traffic not being forwarded

Check firewall logs:

  • OPNsense → Firewall → Log Files → Live View
  • Look for blocked traffic on the target port

Best Practices

  1. Use descriptive names: Make descriptions clear
  2. Log sensitive rules: Enable logging for external-facing services
  3. Disable unused rules: Set disabled: "1" instead of deleting
  4. Document purpose: Add comments in vars files
  5. Sequence gaps: Use 100, 200, 300 to allow insertions
  6. Review before running: Removing rules from vars deletes them
  7. Firewall rules: Always create matching firewall rules
  8. Version control: Store rules in git

This role is often used with:

  • opnsense_firewall: Create firewall rules for forwarded traffic
  • opnsense_source_nat: Configure outbound NAT
  • opnsense_aliases: Use aliases in rules

License

MIT

Author

Created for homelab infrastructure management.