Assigning Tunnel IPs to FortiManager VPN Manager Full-Mesh Tunnels with Python

FortiManager VPN Manager makes it easy to build IPsec VPN communities across multiple FortiGates. It can generate the required Phase 1 and Phase 2 configuration and install the VPN topology to the participating devices.

One limitation I ran into is that, in the VPN Manager workflow I was using, there was no option to assign an ip and remote-ip to the generated tunnel interfaces.

That is not always a problem, but tunnel-interface addressing becomes useful when the design requires:

  • BGP or OSPF over IPsec
  • Point-to-point routing
  • Tunnel monitoring
  • Performance SLA checks
  • Ping-based troubleshooting
  • Predictable tunnel addressing

For new SD-WAN deployments, an SD-WAN Overlay template may be a better fit because it can build the overlay and derive the tunnel addressing as part of the design.

However, if VPN Manager must be used, or the VPN tunnels already exist, the tunnel-interface IPs still need to be assigned afterward.

To automate that task, I created a Python tool that discovers the VPN Manager community, matches the tunnel peers, allocates addresses, updates the FortiManager Device Database, verifies the changes, and creates rollback data.

The complete script is available here:

GitHub: FortiManager VPN Manager Tunnel IP Assignment


How the Tool Works

The script performs the following workflow:

  1. Connects to FortiManager using an API token.
  2. Lists FortiGate-compatible ADOMs.
  3. Lets the operator select a VPN Manager community.
  4. Resolves the device and VDOM members.
  5. Reads the generated Phase 1 interfaces.
  6. Reads the related system interface objects.
  7. Matches both ends of every tunnel using reciprocal gateways.
  8. Checks whether tunnel addressing already exists.
  9. Allocates one /30 block for every eligible tunnel pair.
  10. Assigns the two usable addresses as reciprocal /32 values.
  11. Shows a dry-run plan.
  12. Updates only the FortiManager Device Database when --apply is used.
  13. Reads the interfaces again to verify the result.
  14. Creates rollback data before changing anything.

The script does not automatically install the changes to the FortiGates.


The Addressing Design

The script reserves one /30 allocation block for each point-to-point tunnel pair.

For example:

TEXT
Allocation block: 10.240.0.0/30

The two usable addresses are:

TEXT
10.240.0.1
10.240.0.2

They are written as reciprocal /32 values.

Endpoint A

TEXT
ip        = 10.240.0.1/32
remote-ip = 10.240.0.2/32

Endpoint B

TEXT
ip        = 10.240.0.2/32
remote-ip = 10.240.0.1/32

The /30 is used only as the allocation boundary. It is not written as the local or remote interface mask.

The resulting FortiGate configuration is equivalent to:

FORTIOS
config system interface
    edit "Full_Mesh_1"
        set ip 10.240.0.1 255.255.255.255
        set remote-ip 10.240.0.2 255.255.255.255
    next
end

The peer uses the reverse values:

FORTIOS
config system interface
    edit "Full_Mesh_2"
        set ip 10.240.0.2 255.255.255.255
        set remote-ip 10.240.0.1 255.255.255.255
    next
end

The allocation logic is simple:

PYTHON
def allocate_pairs(pairs, subnets):
    allocations = []

    for pair, subnet in zip(
        sorted(pairs, key=lambda item: item.pair_id().lower()),
        subnets,
        strict=True,
    ):
        endpoints = sorted(
            (pair.a, pair.b),
            key=lambda endpoint: endpoint.key,
        )

        a, b = endpoints
        a_ip, b_ip = list(subnet.hosts())

        allocations.append(
            Allocation(
                subnet=str(subnet),
                a_device=a.key.device,
                a_vdom=a.key.vdom,
                a_tunnel=a.key.phase1,
                a_local_ip=f"{a_ip}/32",
                a_remote_ip=f"{b_ip}/32",
                b_device=b.key.device,
                b_vdom=b.key.vdom,
                b_tunnel=b.key.phase1,
                b_local_ip=f"{b_ip}/32",
                b_remote_ip=f"{a_ip}/32",
            )
        )

    return allocations

Full-Mesh Address Sizing

In a full mesh, every FortiGate connects directly to every other FortiGate.

The number of unique tunnel pairs is:

TEXT
N × (N - 1) / 2

For 10 FortiGates:

TEXT
10 × 9 / 2 = 45 tunnel pairs

That means:

TEXT
Tunnel pairs:        45
Tunnel interfaces:   90
Required /30 blocks: 45
Assigned /32 IPs:    90

A /24 contains 64 /30 blocks, so it is sufficient for a 10-device full mesh.

FortiGatesTunnel pairsTunnel interfacesRequired /30 blocksSuggested pool
3363/28
5102010/26
10459045/24
115511055/24
126613266/23
20190380190/22
501,2252,4501,225/19

A quick calculator can be written as:

PYTHON
import math


def full_mesh_requirements(device_count: int) -> dict[str, int]:
    tunnel_pairs = device_count * (device_count - 1) // 2
    tunnel_interfaces = tunnel_pairs * 2
    required_addresses = tunnel_pairs * 4

    prefix = 32 - math.ceil(
        math.log2(required_addresses)
    )

    return {
        "devices": device_count,
        "tunnel_pairs": tunnel_pairs,
        "tunnel_interfaces": tunnel_interfaces,
        "required_30_blocks": tunnel_pairs,
        "minimum_pool_prefix": prefix,
    }

Identifying the Correct Tunnel Peers

The script does not pair tunnels by their numeric suffix.

For example, FGT-A/Test_Com_1 may connect to FGT-B/Test_Com_3. The suffix is generated locally and is not a reliable pair identifier.

Instead, the script checks the Phase 1 remote-gw and compares it with the peer's local gateway.

PYTHON
def reciprocal_match(a, b):
    if not a.remote_gw or not b.remote_gw:
        return False

    return (
        a.remote_gw in b.local_gateways
        and b.remote_gw in a.local_gateways
    )

This is safer than assuming that identical tunnel suffixes belong together.


FortiManager API Objects Used

The script reads VPN Manager communities from:

TEXT
/pm/config/adom/<ADOM>/obj/vpnmgr/vpntable

It reads community members from:

TEXT
/pm/config/adom/<ADOM>/obj/vpnmgr/node

It reads Phase 1 interfaces from:

TEXT
/pm/config/device/<DEVICE>/vdom/<VDOM>/vpn/ipsec/phase1-interface

It reads tunnel-interface objects from:

TEXT
/pm/config/device/<DEVICE>/global/system/interface

The interface table is retrieved with verbose: 1:

JSON
{
  "method": "get",
  "params": [
    {
      "url": "/pm/config/device/FGT-A/global/system/interface",
      "verbose": 1
    }
  ]
}

This is important because FortiManager may otherwise return interface type as numeric enum 4 instead of tunnel.


Sample Dry-Run Output

TEXT
Candidate tunnel endpoints: 6

Matched tunnel pairs: 3
  eligible: 3

Address pool: 10.240.0.0/24
Required /30 networks: 3
Overlapping /30 networks skipped before completing allocation: 0

Planned interface updates: 6

Device           VDOM  Tunnel      Local IP        Remote IP       Subnet
---------------  ----  ----------  --------------  --------------  -------------
FGT_B            root  Test_Com_2  10.240.0.1/32   10.240.0.2/32   10.240.0.0/30
FortiGate-81E    root  Test_Com_1  10.240.0.2/32   10.240.0.1/32   10.240.0.0/30
FGT_B            root  Test_Com_3  10.240.0.5/32   10.240.0.6/32   10.240.0.4/30
Local-FortiGate  root  Test_Com_1  10.240.0.6/32   10.240.0.5/32   10.240.0.4/30
FortiGate-81E    root  Test_Com_3  10.240.0.9/32   10.240.0.10/32  10.240.0.8/30
Local-FortiGate  root  Test_Com_2  10.240.0.10/32  10.240.0.9/32   10.240.0.8/30

[DRY RUN] No FortiManager configuration was changed.

Applying the Changes

Dry run is the default:

POWERSHELL
py fmg_vpn_tunnel_ip_final.py ^
  --adom root ^
  --community "Test_Com" ^
  --pool 10.240.0.0/24

To update the FortiManager Device Database:

POWERSHELL
py fmg_vpn_tunnel_ip_final.py ^
  --adom root ^
  --community "Test_Com" ^
  --pool 10.240.0.0/24 ^
  --apply

Before applying, the script saves rollback data, revalidates every target interface, confirms the VDOM and tunnel type, and verifies that the current values have not changed.

A simplified update request is:

JSON
{
  "method": "update",
  "params": [
    {
      "url": "/pm/config/device/FGT_B/global/system/interface/Test_Com_2",
      "data": {
        "ip": "10.240.0.1/32",
        "remote-ip": "10.240.0.2/32"
      }
    }
  ]
}

After the update, the script reads each interface again and verifies the result.


What Happens When a New FortiGate Is Added?

The script can be run again when the VPN community grows.

If a mesh currently contains N FortiGates, adding one new FortiGate creates:

TEXT
New tunnel pairs      = N
New tunnel interfaces = 2 × N
New /30 blocks needed = N

Adding an 11th FortiGate to a 10-device mesh creates 10 new tunnel pairs and 20 new tunnel interfaces.

When the script is run again:

TEXT
Matched tunnel pairs: 55
  configured : 45
  eligible   : 10

The existing pairs are left unchanged. Only the new pairs receive addresses.

The same original pool should normally be used again. The tool skips the already-used /30 blocks and continues with the next available ranges.

A /24 supports 64 tunnel pairs, so it can support an 11-device mesh with 55 pairs. A 12-device mesh requires 66 pairs and therefore needs at least a /23.


What if the Tunnels Already Exist?

VPN Manager is not the only situation where tunnel-interface addressing may be required.

The tunnels may already have been created manually, through a CLI template, Terraform, another automation platform, or during a migration.

The existing Phase 1 may look like:

FORTIOS
config vpn ipsec phase1-interface
    edit "to-FGT-B"
        set interface "port1"
        set remote-gw 198.51.100.20
        set psksecret ENC ...
    next
end

The related tunnel interface may already exist without an address:

FORTIOS
config system interface
    edit "to-FGT-B"
        set vdom "root"
        set type tunnel
        set interface "port1"
    next
end

There is no need to recreate the VPN. The existing interface can be updated later:

FORTIOS
config system interface
    edit "to-FGT-B"
        set ip 10.240.0.1 255.255.255.255
        set remote-ip 10.240.0.2 255.255.255.255
    next
end

The peer uses reciprocal values.

Only config system interface changes. Phase 1 and Phase 2 remain intact.


Automating Existing Non-VPN Manager Tunnels

The current published script starts from a VPN Manager community. For manually created tunnels, there is no community object that provides the device scope.

The same pairing and allocation logic can still be reused, but the discovery stage must accept a device and VDOM list or an explicit pair-mapping file.

Example target definition:

PYTHON
@dataclass
class Target:
    device: str
    vdom: str = "root"

The tool can then read Phase 1 interfaces from each selected FortiGate, build endpoint objects, and pass them to the same reciprocal matching function:

PYTHON
pairs, unresolved = pair_endpoints(endpoints)
validate_pairs(pairs)

Then reuse the same allocation logic:

PYTHON
eligible = [
    pair
    for pair in pairs
    if pair.status == "eligible"
]

used_networks = collect_used_networks(interface_cache)

subnets, skipped = available_subnets(
    pool,
    used_networks,
    len(eligible),
)

allocations = allocate_pairs(
    eligible,
    subnets,
)

When peer relationships cannot be derived safely, an explicit mapping file is better:

JSON
[
  {
    "endpoint_a": {
      "device": "FGT-A",
      "vdom": "root",
      "interface": "to-FGT-B"
    },
    "endpoint_b": {
      "device": "FGT-B",
      "vdom": "root",
      "interface": "to-FGT-A"
    }
  }
]

This avoids guessing when peers are behind NAT, use dynamic addresses, or share the same public gateway.


Existing Address Validation

Before allocating anything, the script classifies every pair as:

TEXT
eligible
configured
partial
conflict
  • Eligible: all four ip and remote-ip values are unset.
  • Configured: both ends already contain valid reciprocal /32 values from the same /30 block.
  • Partial: only some values are present.
  • Conflict: values exist but are not reciprocal or do not belong to the same block.

The script stops before applying if partial or conflicting values are detected.


Rollback

Before applying any update, the script creates rollback.json.

Example:

JSON
{
  "device": "FGT_B",
  "vdom": "root",
  "interface": "Test_Com_2",
  "old_ip": null,
  "old_remote_ip": null,
  "new_ip": "10.240.0.1/32",
  "new_remote_ip": "10.240.0.2/32",
  "subnet": "10.240.0.0/30"
}

Rollback is performed through the FortiManager API:

POWERSHELL
py fmg_vpn_tunnel_ip_final.py ^
  --rollback ".\output-folder\rollback.json"

Before restoring anything, the script verifies that the interface still contains the values written by the original apply operation. This prevents a stale rollback file from overwriting later administrator changes.

Rollback restores only the FortiManager Device Database. If the configuration was already installed to the FortiGates, the restored configuration must also be installed afterward.


Running the Tool

Install the dependency:

POWERSHELL
py -m pip install requests

Clone the repository:

BASH
git clone https://github.com/ilfarhanahmed/FortiManager_Py.git

Change to the project directory:

BASH
cd FortiManager_Py/Tunnel_IP_Assignment_VPN_Manager

Interactive dry run:

POWERSHELL
py fmg_vpn_tunnel_ip_final.py

Apply:

POWERSHELL
py fmg_vpn_tunnel_ip_final.py ^
  --adom root ^
  --community "Test_Com" ^
  --pool 10.240.0.0/24 ^
  --apply

Rollback:

POWERSHELL
py fmg_vpn_tunnel_ip_final.py ^
  --rollback ".\output-folder\rollback.json"

Final Thoughts

There are three practical approaches:

  1. Use an SD-WAN Overlay template when tunnel addressing should be part of a new overlay design.
  2. Use VPN Manager to create and manage the tunnels, then run this script to assign the tunnel-interface addresses.
  3. For existing non-VPN Manager tunnels, update the current system interface objects manually or extend the same automation model to discover or explicitly map the tunnel pairs.

The important design choices are:

TEXT
One /30 allocation block per tunnel pair
Two usable addresses selected from that block
Local tunnel ip written as /32
Peer remote-ip written as /32
Peers matched using reciprocal gateways
Existing configured pairs left unchanged
New pairs addressed incrementally
Partial or conflicting pairs stopped for review
No automatic installation to FortiGate
Rollback data created before updates
Read-back verification after changes

The complete script is available here:

GitHub: FortiManager VPN Manager Tunnel IP Assignment

Scroll to Top