0

Upsert firewall address

by
Published today

Declaratively ensure an IPv4 firewall address object exists in an ADOM with the given values. Reads the current object, returns the field-level diff and, unless dry_run is true (the default), creates the object or updates only the fields that differ. Fields left empty are not managed.

Script fortimanager Verified

The script

Submitted by hugo989 Typescript (fetch-only)
Verified 6 hours ago
1
//native
2

3
/**
4
 * Upsert firewall address
5
 * Declaratively ensure an IPv4 firewall address object exists in an ADOM with the given values. Reads the current object, returns the field-level diff and, unless dry_run is true (the default), creates the object or updates only the fields that differ. Fields left empty are not managed.
6
 */
7
export async function main(
8
  auth: RT.Fortimanager,
9
  adom: string,
10
  name: string,
11
  type: "ipmask" | "iprange" | "fqdn",
12
  subnet: string | undefined,
13
  start_ip: string | undefined,
14
  end_ip: string | undefined,
15
  fqdn: string | undefined,
16
  comment: string | undefined,
17
  dry_run: boolean = true
18
) {
19
  // The ADOM is a segment of the JSON-RPC url: a "/" would address another table.
20
  if (adom.includes("/")) throw new Error(`Invalid adom: ${adom}`)
21
  const table = `/pm/config/adom/${adom}/obj/firewall/address`
22

23
  const rpc = async (method: string, params: { [key: string]: any }) => {
24
    const response = await fetch(
25
      `${auth.base_url.replace(/\/+$/, "")}/jsonrpc`,
26
      {
27
        method: "POST",
28
        headers: {
29
          Authorization: `Bearer ${auth.token}`,
30
          "Content-Type": "application/json",
31
          Accept: "application/json",
32
        },
33
        body: JSON.stringify({ id: 1, method, params: [params], verbose: 1 }),
34
      }
35
    )
36
    if (!response.ok) {
37
      throw new Error(`${response.status} ${await response.text()}`)
38
    }
39
    const json = await response.json()
40
    const result = json.result?.[0]
41
    if (result?.status?.code !== 0) {
42
      throw new Error(
43
        `FortiManager error: ${JSON.stringify(json.result ?? json)}`
44
      )
45
    }
46
    return result
47
  }
48

49
  // Intended state. FortiManager stores subnet as [address, netmask].
50
  const desired: { [key: string]: any } = { type }
51
  if (type === "ipmask") {
52
    if (!subnet) throw new Error("subnet is required when type is ipmask")
53
    desired.subnet = toAddressAndMask(subnet)
54
  } else if (type === "iprange") {
55
    if (!start_ip || !end_ip)
56
      throw new Error("start_ip and end_ip are required when type is iprange")
57
    desired["start-ip"] = start_ip
58
    desired["end-ip"] = end_ip
59
  } else {
60
    if (!fqdn) throw new Error("fqdn is required when type is fqdn")
61
    desired.fqdn = fqdn
62
  }
63
  if (comment) desired.comment = comment
64

65
  // Current state.
66
  const found = await rpc("get", { url: table, filter: ["name", "==", name] })
67
  const current = (found.data ?? [])[0]
68

69
  const diff: { [key: string]: { current: any; desired: any } } = {}
70
  for (const [key, value] of Object.entries(desired)) {
71
    const now = current?.[key]
72
    if (normalize(now) !== normalize(value)) {
73
      diff[key] = { current: now ?? null, desired: value }
74
    }
75
  }
76

77
  const changed = Object.keys(diff).length > 0
78
  const operation = !current ? "create" : changed ? "update" : "none"
79
  if (dry_run !== false || !changed) {
80
    return { changed, operation, diff, applied: false }
81
  }
82

83
  let result
84
  if (!current) {
85
    result = await rpc("add", { url: table, data: { name, ...desired } })
86
  } else {
87
    // update on the table URL, keyed by name in the data: no escaping of names containing "/".
88
    const update: { [key: string]: any } = { name }
89
    for (const key of Object.keys(diff)) update[key] = desired[key]
90
    result = await rpc("update", { url: table, data: update })
91
  }
92

93
  return { changed, operation, diff, applied: true, result }
94
}
95

96
// Accepts "10.0.0.0/24" or "10.0.0.0 255.255.255.0"; returns ["10.0.0.0", "255.255.255.0"].
97
function toAddressAndMask(value: string): string[] {
98
  const trimmed = value.trim()
99
  if (trimmed.includes("/")) {
100
    const [address, bits] = trimmed.split("/")
101
    const prefix = Number(bits)
102
    if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) {
103
      throw new Error(`Invalid prefix length in subnet: ${value}`)
104
    }
105
    const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0
106
    return [address, [24, 16, 8, 0].map((s) => (mask >>> s) & 255).join(".")]
107
  }
108
  const parts = trimmed.split(/\s+/)
109
  if (parts.length !== 2) {
110
    throw new Error(
111
      `subnet must be "address/prefix" or "address netmask": ${value}`
112
    )
113
  }
114
  return parts
115
}
116

117
function normalize(value: any): string {
118
  if (value == null) return ""
119
  if (Array.isArray(value)) return value.map(String).join(" ")
120
  return String(value).trim()
121
}
122