A CLI toolkit to manage FortiGate via REST API (policies, addresses, addrgrps, VIPs, etc.)
Project description
FortiGate Policy & Address Toolkit — README (v1.1.3)
A single-file Python CLI that talks to FortiGate’s REST API to manage policies, addresses, address groups, and virtual IPs (VIPs) — plus a few productivity helpers (smart grouping, bulk attach, quick search).
Tested against FortiOS 7.4.4 and 7.2.7. The script includes compatibility shims for common response/payload differences between these versions.
✨ Highlights
- Policies: inspect by ID/name, search, create, update, mutate
srcaddr/dstaddrquickly, attach address-group families. - Addresses: full CRUD, JSON and no-JSON shortcuts, resilient subnet handling.
- Address Groups: full CRUD, validate members, add/remove members, “find groups containing X”.
- Smart chunking: keep each group ≤ 255 members; auto-create
-2,-3, … suffix groups. - VIPs (Virtual IPs): list/search/get/create/update/delete. Supports 1:1 static NAT and port forward (tcp/udp/sctp). FortiOS 7.2/7.4 payload compatibility built in.
- Robustness: VDOM fallback for
424, graceful retries for common 5xx validation shapes, “removeallwhen mixed” safeguard.
🧩 Requirements
-
Python 3.8+
-
Packages:
requests,urllib3pip install requests urllib3
No other third-party deps. ipaddress is stdlib.
🔐 Authentication & Connectivity
- Pass your API token with both headers and query: the tool sets
Authorization: Bearer,X-Auth-Token, and?access_token=...automatically. It's recommended to grand the API administrator permission. - Self-signed certs: use
--insecure(or import the FortiGate CA into your OS trust store).
❗ Place
--insecurebefore the subcommand, otherwise it won’t apply.
Example 1: remove IPs from ip_addr_group with prefix "Blacklist_"
from netknife.adapters.fortigate import FortiGateClient
import ipaddress
import re
BLACK_PREFIX = "Blacklist_"
###
The fw_map you pass in only needs to include host/token/vdom (policy information is not required).
If your address objects are indeed uniformly named as BAN-<ip>, this code will first try to use that name; if there are exceptions in naming, it will fall back to searching the full address list to find the object name matching that /32 and remove it.
If you also want to delete the address object itself (after it has been successfully removed from the group and is no longer referenced), you can add one more step: check the reference relations and then call client.delete_address(name). If you’d like, I can also integrate this “orphan object cleanup” into the process.
###
# --------- helpers ----------
def _names(lst):
out = []
for x in (lst or []):
out.append(x.get("name") if isinstance(x, dict) else x)
return out
def _valid_ip_list(ips):
ok, bad = [], []
for ip in sorted(set(map(str, ips))):
try:
ipaddress.ip_address(ip)
ok.append(ip)
except Exception:
bad.append(ip)
return ok, bad
def _address_matches_ip(addr: dict, ip: str) -> bool:
if not isinstance(addr, dict) or (addr.get("type") != "ipmask"):
return False
subnet = addr.get("subnet")
if isinstance(subnet, str):
# "A.B.C.D MASK"
return subnet.strip().startswith(ip + " ")
if isinstance(subnet, list) and len(subnet) == 2:
return str(subnet[0]) == ip and str(subnet[1]) in ("255.255.255.255", "255.255.255.255 ")
return False
def _candidate_addr_names_for_ip(client: FortiGateClient, ip: str):
"""
Return a list of address object names that should be removed.
Give priority to BAN-<ip>; if it does not exist, then scan the address library for an object name matching the /32.
"""
names = []
ban_name = f"BAN-{ip}"
a = client.get_address(ban_name)
if a:
names.append(ban_name)
else:
# Scan all address objects and find the one whose /32 equals the IP.
for addr in client.list_addresses():
if _address_matches_ip(addr, ip):
nm = addr.get("name")
if nm:
names.append(nm)
# remove duplicate
return sorted(set(names))
def _blacklist_groups(client: FortiGateClient):
"""
Return all address group names that start with Blacklist_ (including shards, such as Blacklist_info-2).
"""
groups = client.list_addrgrps()
pat = re.compile(rf"^{re.escape(BLACK_PREFIX)}.*$")
return sorted([g.get("name") for g in groups if g.get("name") and pat.match(g["name"])])
def _verify_member_not_in_group(client: FortiGateClient, group_name: str, member_name: str) -> bool:
g = client.get_addrgrp(group_name)
mems = set(_names(g.get("member"))) if g else set()
return member_name not in mems
# --------- main API ----------
def unban_ips(fw_map, ips):
"""
Remove the address member corresponding to the given IP from all address groups whose names start with Blacklist_.
The fw_map structure is the same as in ban_ip, but here it only requires host/token/vdom.
Return: (all_ok: bool, report: dict)
"""
valid_ips, invalid_ips = _valid_ip_list(ips)
if not valid_ips:
return False, {"global_error": "no valid IPs", "invalid_ips": invalid_ips}
overall_ok = True
report = {"invalid_ips": invalid_ips, "devices": {}}
for fw_name, cfg in fw_map.items():
host = cfg["host"]
token = cfg["token"]
vdom = cfg.get("vdom")
dev = {
"groups": [],
"ips": {},
"errors": [],
"ok": False,
}
report["devices"][fw_name] = dev
client = FortiGateClient(host=host, token=token, vdom=vdom, verify_ssl=False)
try:
# 1) Find all groups starting with Blacklist_
groups = _blacklist_groups(client)
dev["groups"] = groups
if not groups:
dev["errors"].append("no Blacklist_* groups found")
overall_ok = False
continue
# 2) Process each IP individually.
for ip in valid_ips:
ipres = {
"addr_candidates": [],
"per_group": [], # [{group, attempted, removed, verified, note}]
"ok": False,
}
dev["ips"][ip] = ipres
# 2.1 Parse the possible address object name(s).
candidates = _candidate_addr_names_for_ip(client, ip)
ipres["addr_candidates"] = candidates
if not candidates:
# If there are no candidate object names, it means that the address library also has no address object for this IP; for the purpose of “removing a member,” treat it as already absent from all groups.
ipres["ok"] = True
continue
# 2.2 Iterate over each group to perform removal and verification.
group_all_ok = True
for gname in groups:
# Only initiate a change if the group actually contains any of them.
g = client.get_addrgrp(gname)
cur = set(_names(g.get("member"))) if g else set()
targets = [nm for nm in candidates if nm in cur]
attempted = False
removed = False
verified = False
note = ""
if targets:
attempted = True
try:
# Use the library’s idempotent removal (you can also pass multiple at once).
client.remove_members_from_addrgrp(gname, targets)
# Verify that the removal was successful.
ok_now = all(_verify_member_not_in_group(client, gname, nm) for nm in targets)
removed = True
verified = ok_now
if not ok_now:
note = f"verify failed for {targets}"
group_all_ok = False
overall_ok = False
except Exception as e:
note = f"remove error: {e}"
group_all_ok = False
overall_ok = False
else:
note = "not in group"
ipres["per_group"].append({
"group": gname,
"attempted": attempted,
"removed": removed,
"verified": verified,
"note": note,
})
ipres["ok"] = group_all_ok
# 3) Device level OK?
dev["ok"] = all(v.get("ok") for v in dev["ips"].values())
if not dev["ok"]:
overall_ok = False
finally:
try:
client.session.close()
except Exception:
pass
return overall_ok, report
# ---------------- demo ----------------
if __name__ == "__main__":
fw_info = {
"DC146": {
"host": "https://10.146.1.1:10443",
"token": "4Gps3rzsn5twq7js6ckyxzfQ",
"vdom": "SL-Internet",
},
"DC145": {
"host": "https://10.1.1.1:10443",
"token": "gqw4dnhhfscN67hddksc5mj",
"vdom": None,
},
}
to_unban = ["1.1.1.1"]
ok, rep = unban_ips(fw_info, to_unban)
import json
print(json.dumps(rep, ensure_ascii=False, indent=2))
print("ALL OK?", ok)
▶️ Quick Start
# Show version
python <SCRIPT>.py --version
# Inspect a policy by ID (with VDOM)
python <SCRIPT>.py --host https://10.1.1.1:10443 --token <TOKEN> --vdom test-vdom --insecure \
policy-id --policy-id 209
# Search address groups by substring
python <SCRIPT>.py --host https://10.1.1.1:10443 --token <TOKEN> --vdom test-vdom --insecure \
search-addrgrps --q Black
Replace
<SCRIPT>.pywith your filename. Windows CMD line continuation: use^; PowerShell: use backtick ``` or split lines.
🧭 Global CLI Syntax
python <SCRIPT>.py --host https://<ip>:<port> --token <TOKEN> [--vdom <VDOM>] [--insecure] <SUBCOMMAND> [OPTIONS]
Global options
--host(required) – e.g.https://10.1.1.1:10443--token(required) – FortiGate API token--vdom(optional) – VDOM name (e.g.test-vdom)--insecure– disable SSL verification (self-signed)--version– print tool version (v1.1.0)
Output: JSON to stdout.
Exit codes: 0 success, 1 runtime/API/SSL errors, 2 usage/help.
📚 Subcommands & Examples
1) Policies
Inspect / Search
# By ID
policy-id --policy-id 231
# By name
policy-name --policy-name "From-internet"
# Search by name substring
search-policies --q "blacklist"
# Find policies referencing a given object (addr/addrgrp/vip name)
find-policies-with --object-name "Blacklist"
Create
# JSON payload
create-policy --data @policy.json
# No-JSON “simple” creation
create-policy-simple \
--name "From-internet-副本" \
--srcintf "any" \
--dstintf "any" \
--srcaddr "blacklist-must-deny,blacklist-auto,Blacklist" \
--dstaddr "all" \
--service "ALL" \
--action deny \
--schedule always \
--status enable \
--logtraffic all \
--comment "created by script"
create-policy-simple wraps comma-lists into FortiGate’s [{ "name": ... }] form automatically.
Update
# Full JSON replace/merge (FortiGate semantics)
update-policy --policy-id 263 --data @update.json
# Field-level updates (no JSON)
update-policy-fields --policy-id 263 \
--srcaddr "Blacklist,Code_Group_BlackList" \
--dstaddr "all" \
--logtraffic all \
--comment "updated by script"
# Enable/Disable (note spelling)
update-policy-fields --policy-id 263 --status disable
Quickly mutate src/dst address lists
# Add/remove names (addresses or groups)
policy-set-addrs --policy-id 263 --add-dst "Blacklist" --remove-src "OldGroup"
Safeguard: if a list contains
alland other members, the tool removesallto avoid FortiOS500 -7. VIP notice: VIPs may be used only in dstaddr (destination). Using a VIP insrcaddrwill fail (500 -3).
Attach an address-group family to a policy
# Will attach base and its -2/-3/... siblings (if present)
attach-grps-to-policy --policy-id 263 --base-name "Blacklist" --direction dst
2) Addresses
# List (brief)
list-addresses
# Search by substring
search-addresses --q "BAN-"
# Get one (full)
get-address --name "crl.pki.goog"
# Create (JSON)
create-address --data '{"name":"A-10.0.0.0_24","type":"ipmask","subnet":"10.0.0.0 255.255.255.0","comment":"demo"}'
# Create ipmask (no JSON)
create-address-ipmask --name "A-10.0.0.0_24" --cidr 10.0.0.0/24
# Update (JSON)
update-address --name "BAN-77.90.151.5" --data '{"comment":"updated"}'
# Update comment (no JSON)
update-address-comment --name "BAN-77.90.151.5" --comment "updated"
# Delete
delete-address --name "*.microsoft.com.akadns.net"
Address creation auto-converts
subnetbetween"IP MASK"and["IP","MASK"]to bypass500 -5.
3) Address Groups
# List
list-addrgrps
# Search by substring
search-addrgrps --q "Black"
# Get one (shows members & count)
get-addrgrp --name "Blacklist"
# Create (validate members by default)
create-addrgrp --name "HQ-NETWORKS" --members "A1,A2,A3" --comment "demo"
# Replace fields/members (full replacement)
update-addrgrp --name "HQ-NETWORKS" --members "A-10.0.0.0_24,Another-Addr" --comment "updated"
# Rename
update-addrgrp --name "HQ-NETWORKS" --new-name "HQ-NETWORKS-NEW"
# Add members (idempotent)
add-to-addrgrp --name "HQ-NETWORKS" --members "10.1.6.0/23"
# Remove members
remove-from-addrgrp --name "HQ-NETWORKS" --members "10.1.6.0/23"
# Delete
delete-addrgrp --name "HQ-NETWORKS"
# Find groups that contain a given object
find-groups-with-member --name "BAN-131.226.102.110"
Skip member validation (when you’re sure members exist or are created out of band): add --skip-validate-members.
4) Smart chunking for groups (≤255 members each)
# Provide members inline and/or via file (one per line)
smart-fill-addrgrps \
--base-name "Blacklist" \
--members "A-10.0.0.0_24,HQ-NETWORKS" \
--members-file C:\path\ips.txt \
--chunk-size 255 \
--comment "auto-chunked"
# Then attach all -N groups to a policy (dst or src)
attach-grps-to-policy --policy-id 263 --base-name "Blacklist" --direction dst
The tool:
- reads existing
baseandbase-2/-3/...groups, - fills up to
chunk-size, - creates new groups as needed,
- skips members already present.
5) VIPs (Virtual IPs)
VIPs belong in
dstaddrof policies. They represent “what external users hit”. For DNAT/port-forward scenarios, policynatis usually disable (VIP does the translation); useserviceto restrict ports.
List / Search / Get
list-vips
search-vips --q "openvpn"
get-vip --name "to-internet"
Create (JSON)
# 1:1 static NAT
create-vip --data '{
"name": "Pub_1.1.1.1_1to1_10.1.1.1",
"extip": "1.1.1.1",
"mappedip": [{"range": "10.1.1.1"}], // can also be "10.1.1.1"
"extintf": "any",
"type": "static-nat",
"comment": "by API"
}'
# Port forward (tcp/udp/sctp)
create-vip --data '{
"name": "Pub_1.1.1.1_1to1_10.1.1.1",
"extip": "1.1.1.1_1",
"mappedip": "10.1.1.1",
"portforward": "enable",
"protocol": "tcp",
"extport": "943",
"mappedport": "943",
"extintf": "any",
"type": "static-nat"
}'
Create (no JSON)
# Static 1:1
create-vip-simple \
--name "Pub_1.1.1.1_1to1_10.1.1.1" \
--extip 1.1.1.1 \
--mappedip 10.1.1.1 \
--extintf any \
--comment "by API"
# Port forward
create-vip-portforward \
--name "Pub_1.1.1.1_943_10.1.1.1_943" \
--extip 1.1.1.1 \
--mappedip 10.1.1.1 \
--protocol tcp \
--extport 943 \
--mappedport 943 \
--extintf any \
--comment "by API"
create-vip-simpleis for 1:1 static NAT only (no port args). For port forwarding, usecreate-vip-portforwardorupdate-vip-fields.
Update
# JSON
update-vip --name "Pub_..." --data @vip-update.json
# Field-level (no JSON)
update-vip-fields --name "Pub_..." \
--portforward true \
--protocol tcp \
--extport 943 \
--mappedport 943 \
--mappedip 10.1.1.1 \
--extintf any \
--comment "443 only"
Delete
delete-vip --name "Pub_..."
Attach VIP to policy (destination)
# Add VIP to dstaddr; remove old internal object if present
policy-set-addrs --policy-id 209 --add-dst "Pub_1.1.1.1_943_10.1.1.1_943" --remove-dst "demo"
Version compatibility built in
extipmay be given with/32— the tool strips CIDR.mappedipaccepts"x.x.x.x",["x.x.x.x"], or[{"range":"x.x.x.x"}]— normalized internally.- Ports coerced to strings to satisfy both 7.2/7.4 JSON validators.
🧪 End-to-End Example: Expose OpenVPN on 943
# 1) Create VIP (port forward 943 → 10.1.1.1:943)
create-vip-portforward \
--name "Pub_1.1.1.1_943_10.1.1.1_943" \
--extip 1.1.1.1 --mappedip 10.1.1.1 \
--protocol tcp --extport 943 --mappedport 943 --extintf any
# 2) Attach it to policy 209 (dstaddr), drop old target
policy-set-addrs --policy-id 209 \
--add-dst "Pub_1.1.1.1_943_10.1.1.1_943" \
--remove-dst "demo"
# 3) Verify
policy-id --policy-id 209
🛠️ Troubleshooting
-
SSL:
CERTIFICATE_VERIFY_FAILEDPut--insecurebefore the subcommand:python <SCRIPT>.py --host ... --token ... --vdom test-vdom --insecure list-vipsOr import the FortiGate CA into your system trust store.
-
424with VDOM Usually wrong/absent VDOM or per-endpoint limitation. The tool auto-retries list calls without VDOM; ensure your--vdomis correct (e.g.,test-vdom). -
500 -7on policy update Mixingallwith other members is invalid. The tool auto-removesallwhen you add other names. -
500 -5creating addresses Subnet shape mismatch. The tool retries between"IP MASK"and["IP","MASK"]. Usecreate-address-ipmaskto avoid format issues. -
500 -3when mutating policy addrs Typically caused by putting a VIP intosrcaddr. VIPs belong in dstaddr only. -
-8creating VIP (e.g., “Mapped-ip range is not specified”) Provide--mappedipor correct JSON shape; the tool normalizes but requires an actual value. -
“unrecognized arguments” Ensure global flags (
--host --token [--vdom] [--insecure]) come before the subcommand.
🔒 Best Practices
- Treat API tokens like passwords; avoid shell history leaks.
- Prefer port-forward VIPs if you only need specific ports; otherwise use 1:1 and restrict by
service. - For DNAT policies, keep
natdisable and rely on VIP; useserviceto allow intended ports only. - Use
--skip-validate-membersonly when you’re sure objects exist.
🔁 Changelog (SemVer)
v1.1.0
- New: VIP CRUD/search, simple creators (1:1 & port-forward), field-level updates.
- Compat: Normalize
extip,mappedip,portforward, and port types across 7.2/7.4. - Polish: Stronger policy update fallback (light PUT → full merge PUT), auto-remove
allwhen mixed. - UX:
--version, clearer SSL error guidance, better “not found” suggestions.
v1.0.0
- Policies, addresses, address groups CRUD.
- Smart chunking (≤255 per group) and attach-groups-to-policy.
📦 Files & JSON Payload Examples
policy.json
{
"name": "Example-Policy",
"srcintf": [{"name": "any"}],
"dstintf": [{"name": "any"}],
"srcaddr": [{"name": "all"}],
"dstaddr": [{"name": "all"}],
"service": [{"name": "ALL"}],
"action": "accept",
"schedule": "always",
"status": "enable",
"logtraffic": "all",
"comments": "created by API"
}
vip-update.json
{
"portforward": "enable",
"protocol": "tcp",
"extport": "443",
"mappedport": "443",
"mappedip": [{"range": "10.146.42.239"}],
"extintf": "any",
"comment": "443 only"
}
🧭 Roadmap (optional)
--cafile <path>to validate against a custom CA (safer than--insecure).--dry-runto preview changes without applying.- Retry/backoff knobs and
--timeoutoverride. - VIP groups (
vipgrp) if needed.
📄 License
Add your preferred license here (e.g., MIT/Apache-2.0). If omitted, assume “all rights reserved”.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file netknife-1.2.0.tar.gz.
File metadata
- Download URL: netknife-1.2.0.tar.gz
- Upload date:
- Size: 34.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.9
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bbcd11ea908ede228ae4f9c1c370b941920d64c4c536ea76ca22ddd3f2e468cd
|
|
| MD5 |
6d28d5a1c8fb2825ad8be22c989d1413
|
|
| BLAKE2b-256 |
a8d8c791e1db9c58c954e9d2fae7a1c1a89fd204e3ed64d15d092110abe326e0
|
File details
Details for the file netknife-1.2.0-py3-none-any.whl.
File metadata
- Download URL: netknife-1.2.0-py3-none-any.whl
- Upload date:
- Size: 33.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.9
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8488a9db5225b40e5f4f22a808ddcdcf258c12fba51efa94c983fb969c9868c5
|
|
| MD5 |
556f35472a3a2cd1968f6aba99ec847c
|
|
| BLAKE2b-256 |
2f2b841944c2a4d4bd400672404921ae3bba81ac25cbe81b0da481b378068c8e
|