1 | |
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 | |
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 | |
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 | |
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 |
|