Assigning Tunnel IPs to FortiManager VPN Manager Full-Mesh Tunnels with Python
- FA
- Jul 23, 2026
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:
- Connects to FortiManager using an API token.
- Lists FortiGate-compatible ADOMs.
- Lets the operator select a VPN Manager community.
- Resolves the device and VDOM members.
- Reads the generated Phase 1 interfaces.
- Reads the related
system interfaceobjects. - Matches both ends of every tunnel using reciprocal gateways.
- Checks whether tunnel addressing already exists.
- Allocates one
/30block for every eligible tunnel pair. - Assigns the two usable addresses as reciprocal
/32values. - Shows a dry-run plan.
- Updates only the FortiManager Device Database when
--applyis used. - Reads the interfaces again to verify the result.
- 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:
Allocation block: 10.240.0.0/30The two usable addresses are:
10.240.0.1
10.240.0.2They are written as reciprocal /32 values.
Endpoint A
ip = 10.240.0.1/32
remote-ip = 10.240.0.2/32Endpoint B
ip = 10.240.0.2/32
remote-ip = 10.240.0.1/32The /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:
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
endThe peer uses the reverse values:
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
endThe allocation logic is simple:
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 allocationsFull-Mesh Address Sizing
In a full mesh, every FortiGate connects directly to every other FortiGate.
The number of unique tunnel pairs is:
N × (N - 1) / 2For 10 FortiGates:
10 × 9 / 2 = 45 tunnel pairsThat means:
Tunnel pairs: 45
Tunnel interfaces: 90
Required /30 blocks: 45
Assigned /32 IPs: 90A /24 contains 64 /30 blocks, so it is sufficient for a 10-device full mesh.
| FortiGates | Tunnel pairs | Tunnel interfaces | Required /30 blocks | Suggested pool |
|---|---|---|---|---|
| 3 | 3 | 6 | 3 | /28 |
| 5 | 10 | 20 | 10 | /26 |
| 10 | 45 | 90 | 45 | /24 |
| 11 | 55 | 110 | 55 | /24 |
| 12 | 66 | 132 | 66 | /23 |
| 20 | 190 | 380 | 190 | /22 |
| 50 | 1,225 | 2,450 | 1,225 | /19 |
A quick calculator can be written as:
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.
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:
/pm/config/adom/<ADOM>/obj/vpnmgr/vpntableIt reads community members from:
/pm/config/adom/<ADOM>/obj/vpnmgr/nodeIt reads Phase 1 interfaces from:
/pm/config/device/<DEVICE>/vdom/<VDOM>/vpn/ipsec/phase1-interfaceIt reads tunnel-interface objects from:
/pm/config/device/<DEVICE>/global/system/interfaceThe interface table is retrieved with verbose: 1:
{
"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
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:
py fmg_vpn_tunnel_ip_final.py ^
--adom root ^
--community "Test_Com" ^
--pool 10.240.0.0/24To update the FortiManager Device Database:
py fmg_vpn_tunnel_ip_final.py ^
--adom root ^
--community "Test_Com" ^
--pool 10.240.0.0/24 ^
--applyBefore 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:
{
"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:
New tunnel pairs = N
New tunnel interfaces = 2 × N
New /30 blocks needed = NAdding an 11th FortiGate to a 10-device mesh creates 10 new tunnel pairs and 20 new tunnel interfaces.
When the script is run again:
Matched tunnel pairs: 55
configured : 45
eligible : 10The 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:
config vpn ipsec phase1-interface
edit "to-FGT-B"
set interface "port1"
set remote-gw 198.51.100.20
set psksecret ENC ...
next
endThe related tunnel interface may already exist without an address:
config system interface
edit "to-FGT-B"
set vdom "root"
set type tunnel
set interface "port1"
next
endThere is no need to recreate the VPN. The existing interface can be updated later:
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 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:
@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:
pairs, unresolved = pair_endpoints(endpoints)
validate_pairs(pairs)Then reuse the same allocation logic:
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:
[
{
"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:
eligible
configured
partial
conflict- Eligible: all four
ipandremote-ipvalues are unset. - Configured: both ends already contain valid reciprocal
/32values from the same/30block. - 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:
{
"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:
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:
py -m pip install requestsClone the repository:
git clone https://github.com/ilfarhanahmed/FortiManager_Py.gitChange to the project directory:
cd FortiManager_Py/Tunnel_IP_Assignment_VPN_ManagerInteractive dry run:
py fmg_vpn_tunnel_ip_final.pyApply:
py fmg_vpn_tunnel_ip_final.py ^
--adom root ^
--community "Test_Com" ^
--pool 10.240.0.0/24 ^
--applyRollback:
py fmg_vpn_tunnel_ip_final.py ^
--rollback ".\output-folder\rollback.json"Final Thoughts
There are three practical approaches:
- Use an SD-WAN Overlay template when tunnel addressing should be part of a new overlay design.
- Use VPN Manager to create and manage the tunnels, then run this script to assign the tunnel-interface addresses.
- For existing non-VPN Manager tunnels, update the current
system interfaceobjects manually or extend the same automation model to discover or explicitly map the tunnel pairs.
The important design choices are:
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 changesThe complete script is available here: