0

Upsert firewall address

by
Published today

Declaratively ensure an IPv4 firewall address object exists 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 fortigate 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 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.Fortigate,
9
  name: string,
10
  type: "ipmask" | "iprange" | "fqdn",
11
  subnet: string | undefined,
12
  start_ip: string | undefined,
13
  end_ip: string | undefined,
14
  fqdn: string | undefined,
15
  comment: string | undefined,
16
  vdom: string | undefined,
17
  dry_run: boolean = true
18
) {
19
  const base = `${auth.base_url.replace(/\/+$/, "")}/api/v2/cmdb/firewall/address`
20
  const withVdom = (path: string) => {
21
    const url = new URL(path)
22
    if (vdom != null && vdom !== "") {
23
      url.searchParams.append("vdom", vdom)
24
    }
25
    return url
26
  }
27
  const headers = {
28
    Authorization: `Bearer ${auth.token}`,
29
    "Content-Type": "application/json",
30
    Accept: "application/json",
31
  }
32

33
  // Intended state. FortiOS stores subnet as "address netmask".
34
  const desired: { [key: string]: any } = { type }
35
  if (type === "ipmask") {
36
    if (!subnet) throw new Error("subnet is required when type is ipmask")
37
    desired.subnet = toAddressAndMask(subnet)
38
  } else if (type === "iprange") {
39
    if (!start_ip || !end_ip)
40
      throw new Error("start_ip and end_ip are required when type is iprange")
41
    desired["start-ip"] = start_ip
42
    desired["end-ip"] = end_ip
43
  } else {
44
    if (!fqdn) throw new Error("fqdn is required when type is fqdn")
45
    desired.fqdn = fqdn
46
  }
47
  if (comment) desired.comment = comment
48

49
  // Current state: 404 means the object does not exist yet.
50
  const objectUrl = withVdom(`${base}/${encodeURIComponent(name)}`)
51
  const getResponse = await fetch(objectUrl, { headers })
52
  let current: { [key: string]: any } | undefined
53
  if (getResponse.status !== 404) {
54
    if (!getResponse.ok) {
55
      throw new Error(`${getResponse.status} ${await getResponse.text()}`)
56
    }
57
    current = ((await getResponse.json()).results ?? [])[0]
58
  }
59

60
  const diff: { [key: string]: { current: any; desired: any } } = {}
61
  for (const [key, value] of Object.entries(desired)) {
62
    const now = current?.[key]
63
    if (normalize(now) !== normalize(value)) {
64
      diff[key] = { current: now ?? null, desired: value }
65
    }
66
  }
67

68
  const changed = Object.keys(diff).length > 0
69
  const operation = !current ? "create" : changed ? "update" : "none"
70
  if (dry_run !== false || !changed) {
71
    return { changed, operation, diff, applied: false }
72
  }
73

74
  let response
75
  if (!current) {
76
    response = await fetch(withVdom(base), {
77
      method: "POST",
78
      headers,
79
      body: JSON.stringify({ name, ...desired }),
80
    })
81
  } else {
82
    const update: { [key: string]: any } = {}
83
    for (const key of Object.keys(diff)) update[key] = desired[key]
84
    response = await fetch(objectUrl, {
85
      method: "PUT",
86
      headers,
87
      body: JSON.stringify(update),
88
    })
89
  }
90

91
  if (!response.ok) {
92
    throw new Error(`${response.status} ${await response.text()}`)
93
  }
94

95
  return {
96
    changed,
97
    operation,
98
    diff,
99
    applied: true,
100
    result: await response.json(),
101
  }
102
}
103

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

125
function normalize(value: any): string {
126
  if (value == null) return ""
127
  return String(value).trim().split(/\s+/).join(" ")
128
}
129