Assigning Tunnel IPs to FortiManager VPN Manager Full-Mesh Tunnels with Python
- FA
- Jul 23, 2026
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:
- SD-WAN overlay template IP network design
- Objects and templates created by the SD-WAN overlay template
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:
- Connects to FortiManager using an API administrator token.
- Lists only FortiGate-compatible ADOMs.
- Displays the managed devices and Device Manager groups.
- Lists the VPN Manager communities in the selected ADOM.
- Resolves the exact device and VDOM members of the selected community.
- Finds the VPN Manager-generated Phase 1 interfaces.
- Matches each tunnel endpoint with its actual peer.
- Checks whether tunnel addressing is unset, complete, partial, or conflicting.
- Splits a user-supplied pool into
/30allocation blocks. - Reserves one
/30block per tunnel pair. - Writes the local tunnel
ipand peerremote-ipas reciprocal/32host addresses. - Shows the complete plan before making any changes.
- Updates only the FortiManager Device Database when
--applyis used. - Reads the objects back and verifies the result.
- 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:
Tunnel pairs = N × (N - 1) ÷ 2The total number of tunnel interface endpoints is:
Tunnel interfaces = Tunnel pairs × 2Each FortiGate receives:
Tunnels per FortiGate = N - 1Example: 10 FortiGates
For 10 FortiGates:
Tunnel pairs = 10 × 9 ÷ 2
= 45That means:
FortiGates: 10
Tunnels per FortiGate: 9
Tunnel pairs: 45
Tunnel interfaces: 90
/30 allocation blocks: 45
Assigned /32 addresses: 90A /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.
Minimum pool: /24
Example pool: 10.240.0.0/24
Available /30 blocks: 64
Required /30 blocks: 45
Unused /30 blocks: 19A /24 can support up to 11 FortiGates in a full mesh:
11 × 10 ÷ 2 = 55 pairsTwelve FortiGates would require 66 pairs, which is more than the 64 /30 blocks available in a /24:
12 × 11 ÷ 2 = 66 pairsFor a design expected to grow beyond 11 FortiGates, I would start with a /23 instead.
Full-Mesh Capacity Table
| FortiGates | Tunnel pairs | Tunnel interfaces | Smallest pool | /30 blocks in pool |
|---|---|---|---|---|
| 2 | 1 | 2 | /30 | 1 |
| 3 | 3 | 6 | /28 | 4 |
| 5 | 10 | 20 | /26 | 16 |
| 10 | 45 | 90 | /24 | 64 |
| 20 | 190 | 380 | /22 | 256 |
| 50 | 1,225 | 2,450 | /19 | 2,048 |
| 100 | 4,950 | 9,900 | /17 | 8,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:
#!/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:
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 : 64The Addressing Model
The script reserves one /30 block for each point-to-point tunnel pair.
Consider this block:
10.240.0.0/30The two usable addresses are:
10.240.0.1
10.240.0.2Those two addresses are configured as reciprocal /32 host addresses:
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/30The /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:
10.240.0.4/30Its usable addresses are:
10.240.0.5/32
10.240.0.6/32The allocation sequence looks like this:
| Pair | Allocation block | Endpoint A | Endpoint B |
|---|---|---|---|
| 1 | 10.240.0.0/30 | 10.240.0.1/32 | 10.240.0.2/32 |
| 2 | 10.240.0.4/30 | 10.240.0.5/32 | 10.240.0.6/32 |
| 3 | 10.240.0.8/30 | 10.240.0.9/32 | 10.240.0.10/32 |
| 4 | 10.240.0.12/30 | 10.240.0.13/32 | 10.240.0.14/32 |
What the FortiGate Configuration Looks Like
For one pair, the result is equivalent to the following configuration.
Endpoint A:
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
endEndpoint B:
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
endVPN Manager-generated interface names commonly follow a pattern such as:
Test_Com_1
Test_Com_2
Test_Com_3The 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:
Device
VDOM
Phase 1 name
Outgoing interface
Local public gateway
Phase 1 remote-gw
Current interface ip
Current interface remote-ipTwo endpoints are considered a pair only when their gateway relationship is reciprocal.
Example:
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.10The match is valid because:
FGT_A remote-gw = FGT_B local gateway
FGT_B remote-gw = FGT_A local gatewayThis 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:
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 usableUsing 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:
default_regex = rf"^{re.escape(community_name)}_[0-9]+$"For a community called Test_Com, the expression becomes:
^Test_Com_[0-9]+$A custom expression can also be supplied:
py fmg_vpn_tunnel_ip_final_v8.py ^
--tunnel-regex "^CUSTOM-MESH-[0-9]+$" ^
--pool 10.240.0.0/24The 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:
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 allocationsDetecting Existing Addressing
Before allocating anything, the tool checks all four values belonging to a pair:
Endpoint A ip
Endpoint A remote-ip
Endpoint B ip
Endpoint B remote-ipThe pair is classified as:
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:
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:
--overwrite-existingI 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:
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:
Required /30 networks: 45
Overlapping /30 networks skipped before completing allocation: 3This 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:
py -m pip install requestsClone the repository:
git clone https://github.com/ilfarhanahmed/FortiManager_Py.git
cd FortiManager_Py\Tunnel_IP_Assignment_VPN_ManagerCreate a config.ini file:
[fortimanager]
host = https://192.0.2.10
api_key = REPLACE_WITH_API_TOKEN
verify_ssl = false
timeout = 30For production, use a trusted FortiManager certificate and set:
verify_ssl = trueThe 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.
py fmg_vpn_tunnel_ip_final_v8.py ^
--adom root ^
--community "Test_Com" ^
--pool 10.240.0.0/24To export the plan files during a dry run:
py fmg_vpn_tunnel_ip_final_v8.py ^
--adom root ^
--community "Test_Com" ^
--pool 10.240.0.0/24 ^
--exportThe output directory contains files such as:
plan.json
plan.csv
unresolved.csvApplying the Changes
Use --apply only after reviewing the plan:
py fmg_vpn_tunnel_ip_final_v8.py ^
--adom root ^
--community "Test_Com" ^
--pool 10.240.0.0/24 ^
--applyThe script requires the exact confirmation word:
Type APPLY to update the FortiManager Device Database: APPLYBefore 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:
{
"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:
{
"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:
{
"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.
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:
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:
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/30When the Script Finds Partial Addressing
A message like this means one or more tunnel pairs already contain incomplete or inconsistent values:
ERROR: Partial or conflicting tunnel addressing exists. Resolve it before using --apply.A partial pair may look like:
Endpoint A ip = configured
Endpoint A remote-ip = configured
Endpoint B ip = unset
Endpoint B remote-ip = unsetThe safest choices are:
- Use the correct earlier
rollback.jsonwhen the values came from a previous script run. - Correct or clear both endpoints in the FortiManager Device Database.
- Run the discovery again and confirm the pair becomes
eligibleorconfigured.
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:
{
"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:
device|vdom|phase1
device|vdom|outgoing-interface
device|vdom
deviceRun it with:
py fmg_vpn_tunnel_ip_final_v8.py ^
--gateway-map gateway_map.json ^
--pool 10.240.0.0/24Adding 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.
New tunnel pairs = N
New tunnel interfaces = 2 × N
New /30 blocks needed = NExample: Adding an 11th FortiGate
A 10-device full mesh contains:
10 × 9 ÷ 2 = 45 tunnel pairsAn 11-device full mesh contains:
11 × 10 ÷ 2 = 55 tunnel pairsThe new FortiGate therefore adds:
55 - 45 = 10 new tunnel pairs
10 × 2 = 20 new tunnel interfacesAfter 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:
Matched tunnel pairs: 55
configured : 45
eligible : 10Only 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.
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, skippedSuppose the original 10-device mesh used the first 45 blocks from 10.240.0.0/24. The next run may show:
Address pool: 10.240.0.0/24
Required /30 networks: 10
Overlapping /30 networks skipped before completing allocation: 45
Planned interface updates: 20The 46th block in the pool is:
10.240.0.180/30Its two usable addresses are:
10.240.0.181
10.240.0.182The new pair receives:
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/32Planning Pool Capacity for Future Growth
A /24 contains 64 /30 blocks.
10 FortiGates = 45 pairs
11 FortiGates = 55 pairs
12 FortiGates = 66 pairsA /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:
/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 blocksThe recommended expansion workflow is:
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 changesAdding 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
config vpn ipsec phase1-interface
edit "to-FGT-B"
set interface "port1"
set remote-gw 198.51.100.20
set psksecret ENC ...
next
endThe corresponding tunnel interface already exists:
config system interface
edit "to-FGT-B"
set vdom "root"
set type tunnel
set interface "port1"
next
endThe address can be added without changing Phase 1 or Phase 2:
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
endThe peer uses the reciprocal values:
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
endOnly the existing system-interface objects are updated.
Updating Existing Tunnels Through FortiManager
When the FortiGates are managed by FortiManager, the workflow remains similar:
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 FortiManagerA device-level API update looks like this:
{
"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:
{
"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.
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_cacheThe resulting endpoints are passed to the same pairing function:
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:
[
{
"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:
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 pairsThe rest of the workflow remains unchanged:
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:
rollback.json
ROLLBACK_INSTRUCTIONS.txtTo restore the previous values:
py fmg_vpn_tunnel_ip_final_v8.py ^
--rollback "fmg_vpn_tunnel_ip_Test_Com_YYYYMMDD_HHMMSS\rollback.json"The script requires:
ROLLBACKThe rollback is intentionally conservative. It:
- Reads the original values from
rollback.json. - Retrieves the correct device, VDOM, and interface.
- Confirms that the interface is still a tunnel interface.
- Confirms that its current values still match the values written by the original apply operation.
- Restores the previous
ipandremote-ipvalues. - 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
APPLYunless--yesis used. - Rollback requires the exact word
ROLLBACKunless--yesis used. - Every target interface is revalidated immediately before an update.
- Unresolved endpoints block apply unless
--allow-unresolvedis explicitly used. - Partial and conflicting pairs block apply unless
--overwrite-existingis explicitly used. - Existing address blocks are checked for overlap.
- Applied values are read back and verified.
- Rollback values are read back and verified.
- Only
ipandremote-ipare 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:
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 FortiManagerWhen a new FortiGate is added to the community:
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 addressedFor tunnels created outside VPN Manager:
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 dataFor a 10-FortiGate full mesh, the key numbers are:
45 tunnel pairs
90 tunnel interfaces
45 /30 allocation blocks
90 reciprocal /32 endpoint addresses
Minimum single pool: /24The 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.