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

Creating a full-mesh IPsec VPN from FortiManager VPN Manager is straightforward. Select the FortiGates, build the community, let FortiManager generate the tunnels, and install the configuration.

The problem appears when those tunnel interfaces also need IP addresses.

In the VPN Manager workflow I was using, FortiManager created the Phase 1 interfaces but did not provide an option to assign the tunnel-interface ip and remote-ip values. That becomes a problem when the design requires numbered tunnel interfaces for dynamic routing, troubleshooting, monitoring, or simply consistent point-to-point addressing.

I could have used the FortiManager SD-WAN Overlay template, which includes an overlay network design and can generate the related IPsec, routing, and provisioning templates. But this environment specifically needed VPN Manager.

So I built a Python tool that discovers the VPN Manager tunnels, works out which two interfaces belong to the same tunnel pair, allocates addresses from a pool, and updates the FortiManager Device Database.

The full source code is available on GitHub: FortiManager VPN Manager Tunnel IP Assignment Tool.

In this article, I’ll walk through the design, the full-mesh calculations, the addressing model, the important parts of the code, and the output produced by the script.

The Two Ways to Build This in FortiManager

Before getting into the script, it is important to separate two different FortiManager workflows.

SD-WAN Overlay Template

The SD-WAN Overlay template is the better fit when the full overlay is being designed from scratch and FortiManager should generate the tunnel addressing, IPsec templates, routing configuration, and associated provisioning objects.

The overlay template includes an IP network design that FortiManager uses to allocate addresses to the generated overlay links.

Fortinet documentation:

VPN Manager

VPN Manager is useful when the goal is to create and manage a traditional VPN community. It creates the Phase 1 and Phase 2 configuration and generates the corresponding tunnel interfaces on the member FortiGates.

In the workflow covered here, however, the VPN Manager interface does not expose a place to set the tunnel-interface ip and remote-ip values.

That leaves two practical choices:

  • Use the SD-WAN Overlay template → FortiManager generates the overlay and its addressing
  • Use VPN Manager → FortiManager generates the VPN tunnels → Run this script to assign tunnel-interface addresses afterward

This tool is for the second option.

What the Tool Does

At a high level, the script:

  1. Connects to FortiManager using an API administrator token.
  2. Lists only FortiGate-compatible ADOMs.
  3. Displays the managed devices and Device Manager groups.
  4. Lists the VPN Manager communities in the selected ADOM.
  5. Resolves the exact device and VDOM members of the selected community.
  6. Finds the VPN Manager-generated Phase 1 interfaces.
  7. Matches each tunnel endpoint with its actual peer.
  8. Checks whether tunnel addressing is unset, complete, partial, or conflicting.
  9. Splits a user-supplied pool into /30 allocation blocks.
  10. Reserves one /30 block per tunnel pair.
  11. Writes the local tunnel ip and peer remote-ip as reciprocal /32 host addresses.
  12. Shows the complete plan before making any changes.
  13. Updates only the FortiManager Device Database when --apply is used.
  14. Reads the objects back and verifies the result.
  15. Creates a safe JSON rollback file.

The script never installs the configuration to the FortiGates. The operator still reviews the installation preview and installs the Device Database changes from FortiManager.

How Quickly a Full Mesh Grows

A full mesh creates a direct tunnel between every pair of FortiGates.

For N FortiGates, the number of unique tunnel pairs is:

TEXT
Tunnel pairs = N × (N - 1) ÷ 2

The total number of tunnel interface endpoints is:

TEXT
Tunnel interfaces = Tunnel pairs × 2

Each FortiGate receives:

TEXT
Tunnels per FortiGate = N - 1

Example: 10 FortiGates

For 10 FortiGates:

TEXT
Tunnel pairs = 10 × 9 ÷ 2
             = 45

That means:

TEXT
FortiGates:             10
Tunnels per FortiGate:   9
Tunnel pairs:           45
Tunnel interfaces:      90
/30 allocation blocks:  45
Assigned /32 addresses: 90

A /25 contains only 32 /30 blocks, so it is not large enough.

A /24 contains 64 /30 blocks, so it is the smallest single CIDR that can support 45 tunnel pairs.

TEXT
Minimum pool:  /24
Example pool:  10.240.0.0/24
Available /30 blocks: 64
Required /30 blocks:  45
Unused /30 blocks:    19

A /24 can support up to 11 FortiGates in a full mesh:

TEXT
11 × 10 ÷ 2 = 55 pairs

Twelve FortiGates would require 66 pairs, which is more than the 64 /30 blocks available in a /24:

TEXT
12 × 11 ÷ 2 = 66 pairs

For a design expected to grow beyond 11 FortiGates, I would start with a /23 instead.

Full-Mesh Capacity Table

FortiGatesTunnel pairsTunnel interfacesSmallest pool/30 blocks in pool
212/301
336/284
51020/2616
104590/2464
20190380/22256
501,2252,450/192,048
1004,9509,900/178,192

This growth is quadratic. Doubling the number of FortiGates produces approximately four times as many tunnel pairs.

A Small Python Calculator

The following standalone function calculates the number of pairs and the smallest CIDR pool that can contain the required number of /30 allocation blocks:

PYTHON
#!/usr/bin/env python3

from __future__ import annotations

import ipaddress
import math


def full_mesh_requirements(device_count: int) -> dict[str, int | str]:
    if device_count < 2:
        raise ValueError("At least two FortiGates are required.")

    tunnel_pairs = device_count * (device_count - 1) // 2
    tunnel_interfaces = tunnel_pairs * 2

    # One /30 allocation block is reserved per tunnel pair.
    required_blocks = tunnel_pairs

    # A prefix P contains 2 ** (30 - P) /30 blocks.
    block_bits = math.ceil(math.log2(required_blocks))
    minimum_prefix = 30 - block_bits

    pool = ipaddress.ip_network(
        f"10.240.0.0/{minimum_prefix}",
        strict=False,
    )

    return {
        "devices": device_count,
        "tunnel_pairs": tunnel_pairs,
        "tunnel_interfaces": tunnel_interfaces,
        "required_30_blocks": required_blocks,
        "minimum_prefix": f"/{minimum_prefix}",
        "example_pool": str(pool),
        "available_30_blocks": 2 ** (30 - minimum_prefix),
    }


if __name__ == "__main__":
    result = full_mesh_requirements(10)

    for key, value in result.items():
        print(f"{key:24}: {value}")

Output:

TEXT
devices                 : 10
tunnel_pairs            : 45
tunnel_interfaces       : 90
required_30_blocks      : 45
minimum_prefix          : /24
example_pool            : 10.240.0.0/24
available_30_blocks     : 64

The Addressing Model

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

Consider this block:

TEXT
10.240.0.0/30

The two usable addresses are:

TEXT
10.240.0.1
10.240.0.2

Those two addresses are configured as reciprocal /32 host addresses:

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

Endpoint B
  ip        = 10.240.0.2/32
  remote-ip = 10.240.0.1/32

Allocation block
  10.240.0.0/30

The /30 is an allocation container. It keeps every tunnel pair in its own predictable block, but the interface fields themselves are written as /32.

The next pair receives the next /30 block:

TEXT
10.240.0.4/30

Its usable addresses are:

TEXT
10.240.0.5/32
10.240.0.6/32

The allocation sequence looks like this:

PairAllocation blockEndpoint AEndpoint B
110.240.0.0/3010.240.0.1/3210.240.0.2/32
210.240.0.4/3010.240.0.5/3210.240.0.6/32
310.240.0.8/3010.240.0.9/3210.240.0.10/32
410.240.0.12/3010.240.0.13/3210.240.0.14/32

What the FortiGate Configuration Looks Like

For one pair, the result is equivalent to the following configuration.

Endpoint A:

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

Endpoint B:

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

VPN Manager-generated interface names commonly follow a pattern such as:

TEXT
Test_Com_1
Test_Com_2
Test_Com_3

The numeric suffix cannot safely be used to determine the peer. Test_Com_1 on one FortiGate does not necessarily connect to Test_Com_1 on another FortiGate.

That is why the script uses the gateway relationship instead.

How the Script Finds the Correct Peer

For every candidate Phase 1 interface, the script reads:

TEXT
Device
VDOM
Phase 1 name
Outgoing interface
Local public gateway
Phase 1 remote-gw
Current interface ip
Current interface remote-ip

Two endpoints are considered a pair only when their gateway relationship is reciprocal.

Example:

TEXT
FGT_A/Test_Com_1
  local gateway = 203.0.113.10
  remote-gw     = 198.51.100.20

FGT_B/Test_Com_3
  local gateway = 198.51.100.20
  remote-gw     = 203.0.113.10

The match is valid because:

TEXT
FGT_A remote-gw = FGT_B local gateway
FGT_B remote-gw = FGT_A local gateway

This is much safer than matching tunnel names or numeric suffixes.

Filtering the ADOM List

FortiManager can contain ADOMs for many different products. The script should not show FortiAnalyzer, FortiMail, FortiWeb, Syslog, or other unrelated ADOM types.

The ADOM request uses verbose: 1 and filters the restricted_prds field:

PYTHON
SUPPORTED_ADOM_PRODUCTS = {
    "fos",  # FortiGate
    "foc",  # FortiCarrier
    "ffw",  # FortiFirewall
    "fwc",  # FortiFirewallCarrier
    "fpx",  # FortiProxy
}


def get_adoms(client):
    rows = client.get("/dvmdb/adom", verbose=1)
    usable = []

    for row in rows:
        name = str(row.get("name", "")).strip()
        product = str(row.get("restricted_prds", "")).lower()

        if not name or name == "rootp":
            continue

        if product not in SUPPORTED_ADOM_PRODUCTS:
            continue

        usable.append(row)

    return usable

Using verbose: 1 makes FortiManager return readable product codes instead of only raw enum values.

Discovering the Tunnel Interfaces

The default tunnel-name expression is based on the selected community name:

PYTHON
default_regex = rf"^{re.escape(community_name)}_[0-9]+$"

For a community called Test_Com, the expression becomes:

TEXT
^Test_Com_[0-9]+$

A custom expression can also be supplied:

POWERSHELL
py fmg_vpn_tunnel_ip_final_v8.py ^
  --tunnel-regex "^CUSTOM-MESH-[0-9]+$" ^
  --pool 10.240.0.0/24

The Core Allocation Logic

Once the tunnel pairs have been validated, the script takes the two usable addresses from each reserved /30 and assigns both interface fields as /32:

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,
        )

        endpoint_a, endpoint_b = endpoints
        hosts = list(subnet.hosts())

        if len(hosts) != 2:
            raise ValueError(f"Unexpected /30 host count for {subnet}")

        address_a, address_b = hosts

        allocations.append(
            {
                "subnet": str(subnet),
                "a_device": endpoint_a.key.device,
                "a_interface": endpoint_a.key.phase1,
                "a_local_ip": f"{address_a}/32",
                "a_remote_ip": f"{address_b}/32",
                "b_device": endpoint_b.key.device,
                "b_interface": endpoint_b.key.phase1,
                "b_local_ip": f"{address_b}/32",
                "b_remote_ip": f"{address_a}/32",
            }
        )

    return allocations

Detecting Existing Addressing

Before allocating anything, the tool checks all four values belonging to a pair:

TEXT
Endpoint A ip
Endpoint A remote-ip
Endpoint B ip
Endpoint B remote-ip

The pair is classified as:

TEXT
eligible    All four values are unset.
configured  Both sides already contain the expected reciprocal /32 values.
partial     Only some of the four values are configured.
conflict    All values exist, but they do not form a valid reciprocal pair.

The main validation is equivalent to:

PYTHON
valid = (
    a_local.network.prefixlen == 32
    and b_local.network.prefixlen == 32
    and a_remote.network.prefixlen == 32
    and b_remote.network.prefixlen == 32
    and a_remote.ip == b_local.ip
    and b_remote.ip == a_local.ip
    and a_allocation_block == b_allocation_block
)

The script refuses to apply when it finds partial or conflicting addressing unless the operator explicitly uses:

TEXT
--overwrite-existing

I would only use that option after reviewing the current values on both endpoints because it intentionally replaces them.

Avoiding Address Overlaps

The pool may already contain addresses used on other interfaces. Before selecting a /30, the script collects existing ip and remote-ip networks from the managed devices and checks each candidate block for overlap.

The overlap test is simple:

PYTHON
def subnet_overlaps_used(candidate, used_networks):
    return any(candidate.overlaps(network) for network in used_networks)

When a block overlaps an existing address, the script skips it and continues through the pool:

TEXT
Required /30 networks: 45
Overlapping /30 networks skipped before completing allocation: 3

This means the mathematical minimum pool may not always be enough. A /24 contains 64 /30 blocks, but if 25 of them overlap existing addressing, only 39 remain available.

Project Setup

The tool requires Python 3.10 or later and the requests package.

Install the dependency:

POWERSHELL
py -m pip install requests

Clone the repository:

POWERSHELL
git clone https://github.com/ilfarhanahmed/FortiManager_Py.git
cd FortiManager_Py\Tunnel_IP_Assignment_VPN_Manager

Create a config.ini file:

INI
[fortimanager]
host = https://192.0.2.10
api_key = REPLACE_WITH_API_TOKEN
verify_ssl = false
timeout = 30

For production, use a trusted FortiManager certificate and set:

INI
verify_ssl = true

The API token should be protected like any other administrative credential.

Running a Dry Run

Dry run is the default. Without --apply, the script performs discovery, pairing, validation, overlap checks, and allocation, but it does not change FortiManager.

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

To export the plan files during a dry run:

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

The output directory contains files such as:

TEXT
plan.json
plan.csv
unresolved.csv

Applying the Changes

Use --apply only after reviewing the plan:

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

The script requires the exact confirmation word:

TEXT
Type APPLY to update the FortiManager Device Database: APPLY

Before updating anything, it creates rollback.json. It then re-reads every target interface to make sure the object still exists and that its current values have not changed since the plan was generated.

After the update, it reads the interfaces again and verifies the new ip and remote-ip values.

The FortiManager API Update

One endpoint update is conceptually equivalent to:

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

The peer receives the reciprocal values:

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

The script batches interface updates. The default batch size is 50 and can be changed with --batch-size.

Why Interface Reads Use verbose: 1

The script retrieves the full interface table using a request equivalent to:

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

The full table is used for discovery, pre-apply validation, post-apply verification, and rollback.

Some FortiManager versions do not return the expected object structure when the interface name is appended directly to the GET URL. Reading the complete table and selecting the interface locally by name and VDOM proved more reliable.

Sample Output from a Three-Device Mesh

A three-device full mesh contains three tunnel pairs and six tunnel endpoints.

TEXT
Candidate tunnel endpoints: 6
  - FGT_B/root/Test_Com_2
      local=10.119.210.33  remote=10.119.210.11
  - FGT_B/root/Test_Com_3
      local=10.119.210.33  remote=10.119.210.19
  - FortiGate-81E/root/Test_Com_1
      local=10.119.210.11  remote=10.119.210.33
  - FortiGate-81E/root/Test_Com_3
      local=10.119.210.11  remote=10.119.210.19
  - Local-FortiGate/root/Test_Com_1
      local=10.119.210.19  remote=10.119.210.33
  - Local-FortiGate/root/Test_Com_2
      local=10.119.210.19  remote=10.119.210.11

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.

Sample Output for 10 FortiGates

A 10-device mesh produces 45 tunnel pairs and 90 interface updates.

The full table is long, but the important summary looks like this:

TEXT
Community members: 10
Candidate tunnel endpoints: 90
Matched tunnel pairs: 45
  eligible: 45

Address pool: 10.240.0.0/24
Required /30 networks: 45
Overlapping /30 networks skipped before completing allocation: 0
Planned interface updates: 90

[DRY RUN] No FortiManager configuration was changed.

The first few rows would look like:

TEXT
Device  VDOM  Tunnel      Local IP        Remote IP       Subnet
------  ----  ----------  --------------  --------------  -------------
FGT-01  root  Mesh_1      10.240.0.1/32   10.240.0.2/32   10.240.0.0/30
FGT-02  root  Mesh_1      10.240.0.2/32   10.240.0.1/32   10.240.0.0/30
FGT-01  root  Mesh_2      10.240.0.5/32   10.240.0.6/32   10.240.0.4/30
FGT-03  root  Mesh_1      10.240.0.6/32   10.240.0.5/32   10.240.0.4/30

When the Script Finds Partial Addressing

A message like this means one or more tunnel pairs already contain incomplete or inconsistent values:

TEXT
ERROR: Partial or conflicting tunnel addressing exists. Resolve it before using --apply.

A partial pair may look like:

TEXT
Endpoint A ip        = configured
Endpoint A remote-ip = configured
Endpoint B ip        = unset
Endpoint B remote-ip = unset

The safest choices are:

  1. Use the correct earlier rollback.json when the values came from a previous script run.
  2. Correct or clear both endpoints in the FortiManager Device Database.
  3. Run the discovery again and confirm the pair becomes eligible or configured.

The tool will not silently replace partial or conflicting values.

Gateway Overrides

Sometimes FortiManager cannot determine the local public gateway from the outgoing interface. A JSON override file can be supplied for those cases:

JSON
{
  "FGT-01|root|Mesh_1": "203.0.113.10",
  "FGT-02|root|port1": "198.51.100.20",
  "FGT-03|root": "192.0.2.30",
  "FGT-04": "192.0.2.40"
}

The script checks the keys from most specific to least specific:

TEXT
device|vdom|phase1
device|vdom|outgoing-interface
device|vdom
device

Run it with:

POWERSHELL
py fmg_vpn_tunnel_ip_final_v8.py ^
  --gateway-map gateway_map.json ^
  --pool 10.240.0.0/24

Adding a New FortiGate to an Existing Mesh

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

If a full mesh already contains N FortiGates, adding one new FortiGate creates a tunnel between the new device and every existing member.

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

Example: Adding an 11th FortiGate

A 10-device full mesh contains:

TEXT
10 × 9 ÷ 2 = 45 tunnel pairs

An 11-device full mesh contains:

TEXT
11 × 10 ÷ 2 = 55 tunnel pairs

The new FortiGate therefore adds:

TEXT
55 - 45 = 10 new tunnel pairs
10 × 2 = 20 new tunnel interfaces

After VPN Manager creates the new Phase 1 and Phase 2 objects, run the script again using the same community and address pool.

The existing pairs should be classified as configured, while the newly generated pairs should be classified as eligible:

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

Only the 10 new pairs are allocated. The original 45 pairs are not renumbered.

Why the Original Addresses Are Not Reused

Before allocating a new block, the script reads the existing tunnel-interface ip and remote-ip values and checks every candidate /30 for overlap.

PYTHON
def available_subnets(pool, used_networks, required):
    selected = []
    skipped = 0

    for subnet in pool.subnets(new_prefix=30):
        if any(subnet.overlaps(network) for network in used_networks):
            skipped += 1
            continue

        selected.append(subnet)

        if len(selected) == required:
            break

    if len(selected) < required:
        raise ToolError(
            f"Pool {pool} does not contain "
            f"{required} available /30 networks."
        )

    return selected, skipped

Suppose the original 10-device mesh used the first 45 blocks from 10.240.0.0/24. The next run may show:

TEXT
Address pool: 10.240.0.0/24
Required /30 networks: 10
Overlapping /30 networks skipped before completing allocation: 45
Planned interface updates: 20

The 46th block in the pool is:

TEXT
10.240.0.180/30

Its two usable addresses are:

TEXT
10.240.0.181
10.240.0.182

The new pair receives:

TEXT
Endpoint A
  ip        = 10.240.0.181/32
  remote-ip = 10.240.0.182/32

Endpoint B
  ip        = 10.240.0.182/32
  remote-ip = 10.240.0.181/32

Planning Pool Capacity for Future Growth

A /24 contains 64 /30 blocks.

TEXT
10 FortiGates = 45 pairs
11 FortiGates = 55 pairs
12 FortiGates = 66 pairs

A /24 supports an 11-device full mesh, but it does not support 12 devices because 66 pairs exceed the 64 available blocks.

For environments expected to grow, reserve a larger pool from the beginning:

TEXT
/24 =    64 /30 blocks
/23 =   128 /30 blocks
/22 =   256 /30 blocks
/21 =   512 /30 blocks
/20 = 1,024 /30 blocks
/19 = 2,048 /30 blocks

The recommended expansion workflow is:

TEXT
Add the FortiGate to the VPN Manager community
                ↓
Install the VPN Manager-generated tunnel configuration
                ↓
Run the tunnel-IP script again using the same pool
                ↓
Existing pairs are detected as configured
                ↓
New pairs are detected as eligible
                ↓
Used /30 blocks are skipped
                ↓
Only the new interfaces are addressed
                ↓
Review and install the Device Database changes

Adding Tunnel IPs to Existing Non-VPN Manager Tunnels

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

A FortiGate may already have route-based IPsec tunnels that were created:

  • Manually on the FortiGate
  • Through a FortiManager CLI template
  • Through Terraform or another automation platform
  • During an earlier migration
  • Before dynamic routing was introduced
  • Before tunnel monitoring or health checks were required

The existing VPN does not need to be recreated. The ip and remote-ip values can be added later under the existing config system interface object.

Existing Phase 1 Configuration

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 corresponding tunnel interface already exists:

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

The address can be added without changing Phase 1 or Phase 2:

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 the reciprocal values:

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

Only the existing system-interface objects are updated.

Updating Existing Tunnels Through FortiManager

When the FortiGates are managed by FortiManager, the workflow remains similar:

TEXT
Read the existing Phase 1 interfaces
        ↓
Identify both ends of every tunnel
        ↓
Read the associated system interfaces
        ↓
Validate current addressing
        ↓
Allocate reciprocal /32 addresses
        ↓
Update the FortiManager Device Database
        ↓
Read back and verify
        ↓
Review and install through FortiManager

A device-level API update looks like this:

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

The peer is updated with the reverse values:

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

Discovering Tunnels Without a VPN Manager Community

The published script starts with a VPN Manager community. That community gives the script a trusted scope containing the participating devices, VDOMs, and expected Phase 1 names.

For tunnels created outside VPN Manager, the discovery stage must be changed. The pairing, validation, allocation, update, verification, and rollback functions can still be reused.

There are two safe approaches.

Option 1: Select Devices and Match Reciprocal Gateways

The script can accept an explicit list of devices and VDOMs, retrieve their existing Phase 1 interfaces, and reuse the reciprocal-gateway matching logic.

PYTHON
from dataclasses import dataclass
import re


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


def discover_existing_tunnels(
    client,
    targets: list[Target],
    tunnel_pattern: re.Pattern,
    gateway_overrides: dict,
):
    endpoints = []
    unresolved = []
    interface_cache = {}

    for target in targets:
        interfaces = get_system_interfaces(
            client,
            target.device,
        )

        interface_cache[target.device] = interfaces

        phase1s = get_phase1_interfaces(
            client,
            target.device,
            target.vdom,
        )

        for phase1 in phase1s:
            phase1_name = str(phase1.get("name", ""))

            if not tunnel_pattern.fullmatch(phase1_name):
                continue

            system_interface = find_interface(
                interfaces,
                phase1_name,
                target.vdom,
            )

            if not system_interface:
                unresolved.append(
                    {
                        "endpoint": (
                            f"{target.device}/"
                            f"{target.vdom}/"
                            f"{phase1_name}"
                        ),
                        "reason": (
                            "Associated system interface "
                            "was not found."
                        ),
                    }
                )
                continue

            remote_gateway = ipv4_text(
                phase1.get("remote-gw")
            )

            outgoing_interface = ref_name(
                phase1.get("interface")
            )

            local_gateways = resolve_local_gateways(
                interfaces,
                outgoing_interface,
                target.vdom,
            )

            override = gateway_override(
                gateway_overrides,
                target.device,
                target.vdom,
                phase1_name,
                outgoing_interface,
            )

            if override:
                local_gateways = [override]

            endpoints.append(
                Endpoint(
                    key=EndpointKey(
                        device=target.device,
                        vdom=target.vdom,
                        phase1=phase1_name,
                    ),
                    outgoing_interface=outgoing_interface,
                    phase1_type=str(phase1.get("type", "")),
                    remote_gw=remote_gateway,
                    local_gateways=local_gateways,
                    phase1_raw=phase1,
                    interface_raw=system_interface,
                )
            )

    return endpoints, unresolved, interface_cache

The resulting endpoints are passed to the same pairing function:

PYTHON
pairs, pairing_unresolved = pair_endpoints(endpoints)
unresolved.extend(pairing_unresolved)

validate_pairs(pairs)

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)

This approach works well when:

  • The peer gateways are static.
  • The local gateway can be determined or overridden.
  • The tunnel names can be filtered with a regular expression.
  • Each endpoint has exactly one reciprocal peer.

Option 2: Use an Explicit Tunnel-Pair Mapping

Automatic discovery may not be reliable when:

  • Multiple tunnels use the same public gateway.
  • Both sides are behind NAT.
  • Dial-up or dynamic peers are involved.
  • WAN addresses are learned through DHCP.
  • Tunnel names are inconsistent.
  • The local public address is not visible in the configuration.

In those cases, an explicit mapping file is safer.

Example tunnel-pairs.json:

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

The mapping can be converted into the same TunnelPair objects used by the VPN Manager workflow:

PYTHON
def load_explicit_pairs(client, mapping_file: Path):
    raw_pairs = json.loads(
        mapping_file.read_text(encoding="utf-8")
    )

    pairs = []

    for item in raw_pairs:
        a_data = item["endpoint_a"]
        b_data = item["endpoint_b"]

        a_interface = get_interface_object(
            client,
            a_data["device"],
            a_data["interface"],
            a_data.get("vdom", "root"),
        )

        b_interface = get_interface_object(
            client,
            b_data["device"],
            b_data["interface"],
            b_data.get("vdom", "root"),
        )

        if not is_tunnel_interface(a_interface):
            raise ToolError(
                f"{a_data['device']}/{a_data['interface']} "
                "is not a tunnel interface."
            )

        if not is_tunnel_interface(b_interface):
            raise ToolError(
                f"{b_data['device']}/{b_data['interface']} "
                "is not a tunnel interface."
            )

        a_endpoint = Endpoint(
            key=EndpointKey(
                a_data["device"],
                a_data.get("vdom", "root"),
                a_data["interface"],
            ),
            outgoing_interface="",
            phase1_type="explicit",
            remote_gw=None,
            local_gateways=[],
            phase1_raw={},
            interface_raw=a_interface,
        )

        b_endpoint = Endpoint(
            key=EndpointKey(
                b_data["device"],
                b_data.get("vdom", "root"),
                b_data["interface"],
            ),
            outgoing_interface="",
            phase1_type="explicit",
            remote_gw=None,
            local_gateways=[],
            phase1_raw={},
            interface_raw=b_interface,
        )

        pairs.append(TunnelPair(a=a_endpoint, b=b_endpoint))

    return pairs

The rest of the workflow remains unchanged:

PYTHON
validate_pairs(pairs)

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

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

allocations = allocate_pairs(eligible, subnets)

apply_changes(
    client,
    adom,
    allocations,
    output_dir,
    batch_size=50,
)

The current GitHub script focuses on VPN Manager communities. Supporting arbitrary existing tunnels requires either a device-discovery mode or an explicit pair-mapping mode, but the same safe allocation and update model applies.

Rollback

Before an apply operation, the script creates:

TEXT
rollback.json
ROLLBACK_INSTRUCTIONS.txt

To restore the previous values:

POWERSHELL
py fmg_vpn_tunnel_ip_final_v8.py ^
  --rollback "fmg_vpn_tunnel_ip_Test_Com_YYYYMMDD_HHMMSS\rollback.json"

The script requires:

TEXT
ROLLBACK

The rollback is intentionally conservative. It:

  1. Reads the original values from rollback.json.
  2. Retrieves the correct device, VDOM, and interface.
  3. Confirms that the interface is still a tunnel interface.
  4. Confirms that its current values still match the values written by the original apply operation.
  5. Restores the previous ip and remote-ip values.
  6. Reads the interfaces again and verifies the result.

This prevents an old rollback file from overwriting a later administrator change.

Rollback updates only the FortiManager Device Database. When the changed configuration has already been installed to the FortiGates, the restored configuration must also be installed from FortiManager.

Safety Features I Added

The tool is deliberately cautious:

  • Dry run is the default.
  • No changes occur without --apply.
  • Apply requires the exact word APPLY unless --yes is used.
  • Rollback requires the exact word ROLLBACK unless --yes is used.
  • Every target interface is revalidated immediately before an update.
  • Unresolved endpoints block apply unless --allow-unresolved is explicitly used.
  • Partial and conflicting pairs block apply unless --overwrite-existing is explicitly used.
  • Existing address blocks are checked for overlap.
  • Applied values are read back and verified.
  • Rollback values are read back and verified.
  • Only ip and remote-ip are modified.
  • The script does not create or delete VPN communities.
  • It does not modify Phase 1 or Phase 2 settings.
  • It does not install configuration to the FortiGates.

Putting It All Together

For a VPN Manager community, the full workflow looks like this:

TEXT
VPN Manager creates the full-mesh tunnels
            ↓
Script selects the ADOM and VPN community
            ↓
Community device/VDOM members are resolved
            ↓
Generated Phase 1 interfaces are discovered
            ↓
Endpoints are paired using reciprocal gateways
            ↓
Existing addressing and pool overlaps are checked
            ↓
One /30 block is reserved per eligible tunnel pair
            ↓
Local ip and remote-ip are planned as reciprocal /32 values
            ↓
Dry-run table and export files are generated
            ↓
Optional --apply updates the FMG Device Database
            ↓
The script reads the interfaces back and verifies them
            ↓
The operator reviews and installs the changes from FortiManager

When a new FortiGate is added to the community:

TEXT
VPN Manager creates tunnels to every existing member
            ↓
Run the script again using the same address pool
            ↓
Existing pairs are detected as configured
            ↓
New pairs are detected as eligible
            ↓
Previously used /30 blocks are skipped
            ↓
Only the new tunnel interfaces are addressed

For tunnels created outside VPN Manager:

TEXT
Select the devices or provide an explicit pair map
            ↓
Read the existing Phase 1 and system interfaces
            ↓
Match or explicitly define both tunnel endpoints
            ↓
Reuse the same validation and allocation logic
            ↓
Update only ip and remote-ip
            ↓
Read back, verify, and retain rollback data

For a 10-FortiGate full mesh, the key numbers are:

TEXT
45 tunnel pairs
90 tunnel interfaces
45 /30 allocation blocks
90 reciprocal /32 endpoint addresses
Minimum single pool: /24

The SD-WAN Overlay template remains the better option when FortiManager should build the complete overlay design, including addressing and routing templates.

When VPN Manager must be used, this script fills the missing tunnel-addressing step without manually editing dozens or hundreds of generated interfaces.

Full source code: GitHub - Tunnel IP Assignment for FortiManager VPN Manager.

Scroll to Top