Overview
Managing firewall policies manually across multiple FortiGate devices doesn't scale. This guide covers automating policy management using PowerShell and the FortiOS REST API - from bulk rule deployment to compliance auditing.
What You'll Learn
- FortiOS REST API authentication patterns
- Bulk policy creation and modification
- Configuration backup automation
- Policy compliance auditing
- Change tracking and rollback
Why Automate Firewall Management?
| Manual Approach | Automated Approach |
|---|---|
| Error-prone rule entry | Templated, validated rules |
| Inconsistent across devices | Standardized policies |
| No change tracking | Git-backed versioning |
| Hours per device | Minutes for entire fleet |
API Authentication
FortiGate supports both API tokens and session-based authentication. API tokens are recommended for automation.
Generate API Token
- Navigate to System > Administrators
- Create new REST API Admin
- Set appropriate profile (read-only or read-write)
- Copy the generated token
PowerShell Authentication Module
function Connect-FortiGate {
param(
[Parameter(Mandatory)]
[string]$Hostname,
[Parameter(Mandatory)]
[string]$ApiToken,
[int]$Port = 443,
[switch]$SkipCertificateCheck
)
$script:FGSession = @{
BaseUrl = "https://${Hostname}:${Port}/api/v2"
Headers = @{
"Authorization" = "Bearer $ApiToken"
"Content-Type" = "application/json"
}
SkipCert = $SkipCertificateCheck
}
# Test connection
try {
$params = @{
Uri = "$($script:FGSession.BaseUrl)/cmdb/system/status"
Headers = $script:FGSession.Headers
Method = "GET"
}
if ($SkipCertificateCheck) {
$params.SkipCertificateCheck = $true
}
$response = Invoke-RestMethod @params
Write-Host "Connected to $($response.results.hostname)" -ForegroundColor Green
Write-Host " Version: $($response.results.version)"
Write-Host " Serial: $($response.results.serial)"
return $true
}
catch {
Write-Error "Connection failed: $_"
return $false
}
}Firewall Policy Operations
List All Policies
function Get-FGFirewallPolicy {
param(
[string]$Name,
[string]$VDOM = "root"
)
$uri = "$($script:FGSession.BaseUrl)/cmdb/firewall/policy"
if ($Name) {
$uri += "/$Name"
}
$params = @{
Uri = $uri
Headers = $script:FGSession.Headers
Method = "GET"
}
if ($script:FGSession.SkipCert) {
$params.SkipCertificateCheck = $true
}
$response = Invoke-RestMethod @params
return $response.results
}
# Usage
$policies = Get-FGFirewallPolicy
$policies | Select-Object policyid, name, srcintf, dstintf, action | Format-TableCreate New Policy
function New-FGFirewallPolicy {
param(
[Parameter(Mandatory)]
[string]$Name,
[Parameter(Mandatory)]
[string]$SourceInterface,
[Parameter(Mandatory)]
[string]$DestinationInterface,
[string[]]$SourceAddress = @("all"),
[string[]]$DestinationAddress = @("all"),
[string[]]$Service = @("ALL"),
[ValidateSet("accept", "deny", "ipsec")]
[string]$Action = "accept",
[switch]$NAT,
[switch]$LogTraffic,
[string]$Comment
)
$policy = @{
name = $Name
srcintf = @(@{ name = $SourceInterface })
dstintf = @(@{ name = $DestinationInterface })
srcaddr = $SourceAddress | ForEach-Object { @{ name = $_ } }
dstaddr = $DestinationAddress | ForEach-Object { @{ name = $_ } }
service = $Service | ForEach-Object { @{ name = $_ } }
action = $Action
nat = if ($NAT) { "enable" } else { "disable" }
logtraffic = if ($LogTraffic) { "all" } else { "disable" }
}
if ($Comment) {
$policy.comments = $Comment
}
$params = @{
Uri = "$($script:FGSession.BaseUrl)/cmdb/firewall/policy"
Headers = $script:FGSession.Headers
Method = "POST"
Body = $policy | ConvertTo-Json -Depth 10
}
if ($script:FGSession.SkipCert) {
$params.SkipCertificateCheck = $true
}
$response = Invoke-RestMethod @params
Write-Host "Policy '$Name' created with ID: $($response.mkey)" -ForegroundColor Green
return $response
}Bulk Policy Deployment
Deploy policies from a CSV template:
function Import-FGPoliciesFromCsv {
param(
[Parameter(Mandatory)]
[string]$CsvPath
)
$policies = Import-Csv $CsvPath
$results = @()
foreach ($policy in $policies) {
Write-Host "Creating policy: $($policy.Name)..."
try {
$result = New-FGFirewallPolicy `
-Name $policy.Name `
-SourceInterface $policy.SourceInterface `
-DestinationInterface $policy.DestinationInterface `
-SourceAddress ($policy.SourceAddress -split ",") `
-DestinationAddress ($policy.DestinationAddress -split ",") `
-Service ($policy.Service -split ",") `
-Action $policy.Action `
-NAT:([bool]$policy.NAT) `
-LogTraffic:([bool]$policy.LogTraffic) `
-Comment $policy.Comment
$results += [PSCustomObject]@{
Name = $policy.Name
Status = "Success"
ID = $result.mkey
}
}
catch {
$results += [PSCustomObject]@{
Name = $policy.Name
Status = "Failed"
Error = $_.Exception.Message
}
}
}
return $results
}CSV Template:
Name,SourceInterface,DestinationInterface,SourceAddress,DestinationAddress,Service,Action,NAT,LogTraffic,Comment
Allow-Web-DMZ,internal,dmz,LAN_Servers,Web_Servers,HTTP,HTTPS,accept,false,true,Web server access
Allow-DNS-Out,internal,wan1,all,DNS_Servers,DNS,accept,true,false,Outbound DNS
Block-Telnet,any,any,all,all,TELNET,deny,false,true,Security - No TelnetConfiguration Backup
Automated Backup Script
function Backup-FGConfiguration {
param(
[Parameter(Mandatory)]
[string]$OutputPath,
[ValidateSet("full", "vdom")]
[string]$Scope = "full",
[switch]$IncludeTimestamp
)
$filename = "fortigate-backup"
if ($IncludeTimestamp) {
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$filename = "${filename}-${timestamp}"
}
$uri = "$($script:FGSession.BaseUrl)/monitor/system/config/backup"
$uri += "?scope=$Scope"
$params = @{
Uri = $uri
Headers = $script:FGSession.Headers
Method = "GET"
OutFile = Join-Path $OutputPath "${filename}.conf"
}
if ($script:FGSession.SkipCert) {
$params.SkipCertificateCheck = $true
}
Invoke-RestMethod @params
$backupFile = Join-Path $OutputPath "${filename}.conf"
$size = (Get-Item $backupFile).Length / 1KB
Write-Host "Backup saved: $backupFile ($([math]::Round($size, 2)) KB)" -ForegroundColor Green
return $backupFile
}Scheduled Backup with Git Versioning
function Invoke-FGBackupWithGit {
param(
[Parameter(Mandatory)]
[string]$BackupRepo,
[string]$CommitMessage
)
Push-Location $BackupRepo
# Perform backup
$backupFile = Backup-FGConfiguration -OutputPath $BackupRepo
# Git operations
git add $backupFile
$changes = git status --porcelain
if ($changes) {
$msg = if ($CommitMessage) {
$CommitMessage
} else {
"Automated backup - $(Get-Date -Format 'yyyy-MM-dd HH:mm')"
}
git commit -m $msg
Write-Host "Changes committed to Git" -ForegroundColor Green
}
else {
Write-Host "No configuration changes detected" -ForegroundColor Yellow
}
Pop-Location
}Policy Compliance Auditing
Security Baseline Check
function Test-FGPolicyCompliance {
param(
[switch]$Detailed
)
$policies = Get-FGFirewallPolicy
$findings = @()
foreach ($policy in $policies) {
# Check: Any-to-Any rules
$srcAll = $policy.srcaddr.name -contains "all"
$dstAll = $policy.dstaddr.name -contains "all"
$svcAll = $policy.service.name -contains "ALL"
if ($srcAll -and $dstAll -and $svcAll -and $policy.action -eq "accept") {
$findings += [PSCustomObject]@{
PolicyID = $policy.policyid
PolicyName = $policy.name
Finding = "CRITICAL: Any-to-Any-All rule"
Severity = "Critical"
Remediation = "Restrict source, destination, or services"
}
}
# Check: No logging on allow rules
if ($policy.action -eq "accept" -and $policy.logtraffic -eq "disable") {
$findings += [PSCustomObject]@{
PolicyID = $policy.policyid
PolicyName = $policy.name
Finding = "WARNING: Allow rule without logging"
Severity = "Medium"
Remediation = "Enable traffic logging"
}
}
# Check: Disabled policies
if ($policy.status -eq "disable") {
$findings += [PSCustomObject]@{
PolicyID = $policy.policyid
PolicyName = $policy.name
Finding = "INFO: Disabled policy"
Severity = "Low"
Remediation = "Remove or re-enable policy"
}
}
# Check: Missing comments
if ([string]::IsNullOrWhiteSpace($policy.comments)) {
$findings += [PSCustomObject]@{
PolicyID = $policy.policyid
PolicyName = $policy.name
Finding = "INFO: No policy description"
Severity = "Low"
Remediation = "Add descriptive comment"
}
}
}
# Summary
Write-Host "`nCompliance Summary" -ForegroundColor Cyan
Write-Host "==================" -ForegroundColor Cyan
Write-Host "Total Policies: $($policies.Count)"
Write-Host "Critical: $(($findings | Where-Object Severity -eq 'Critical').Count)" -ForegroundColor Red
Write-Host "Medium: $(($findings | Where-Object Severity -eq 'Medium').Count)" -ForegroundColor Yellow
Write-Host "Low: $(($findings | Where-Object Severity -eq 'Low').Count)" -ForegroundColor Gray
if ($Detailed) {
return $findings
}
return $findings | Where-Object { $_.Severity -in @("Critical", "Medium") }
}VPN Configuration
Site-to-Site IPsec VPN
function New-FGIPsecVPN {
param(
[Parameter(Mandatory)]
[string]$Name,
[Parameter(Mandatory)]
[string]$RemoteGateway,
[Parameter(Mandatory)]
[string]$PreSharedKey,
[Parameter(Mandatory)]
[string]$LocalSubnet,
[Parameter(Mandatory)]
[string]$RemoteSubnet,
[string]$Interface = "wan1"
)
# Phase 1
$phase1 = @{
name = $Name
type = "static"
interface = $Interface
"remote-gw" = $RemoteGateway
psksecret = $PreSharedKey
proposal = "aes256-sha256"
dhgrp = "14"
nattraversal = "enable"
}
$params = @{
Uri = "$($script:FGSession.BaseUrl)/cmdb/vpn.ipsec/phase1-interface"
Headers = $script:FGSession.Headers
Method = "POST"
Body = $phase1 | ConvertTo-Json -Depth 5
}
if ($script:FGSession.SkipCert) {
$params.SkipCertificateCheck = $true
}
Invoke-RestMethod @params
Write-Host "Phase 1 created" -ForegroundColor Green
# Phase 2
$phase2 = @{
name = "${Name}_p2"
"phase1name" = $Name
proposal = "aes256-sha256"
"src-subnet" = $LocalSubnet
"dst-subnet" = $RemoteSubnet
}
$params.Uri = "$($script:FGSession.BaseUrl)/cmdb/vpn.ipsec/phase2-interface"
$params.Body = $phase2 | ConvertTo-Json -Depth 5
Invoke-RestMethod @params
Write-Host "Phase 2 created" -ForegroundColor Green
Write-Host "VPN '$Name' configured successfully" -ForegroundColor Cyan
}Best Practices
- Use API tokens - More secure than storing passwords
- Enable logging - All allow rules should log traffic
- Document policies - Every rule needs a comment
- Version control - Git-back your configurations
- Audit regularly - Run compliance checks weekly
- Test in lab - Never deploy untested policies to production
Troubleshooting
API Connection Issues
# Test connectivity
Test-NetConnection -ComputerName "fortigate.local" -Port 443
# Check API status
curl -k "https://fortigate.local/api/v2/cmdb/system/status" `
-H "Authorization: Bearer YOUR_TOKEN"Common Error Codes
| Code | Meaning |
|---|---|
| -5 | Object already exists |
| -6 | Object not found |
| -10 | Permission denied |
| -15 | Invalid parameter |