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