1 | |
2 |
|
3 | |
4 | * Upsert address object |
5 | * Declaratively create or update an address object in a firewall vsys, a Panorama device group or the shared location (PAN-OS REST API). Reads the current object, returns the difference and, unless dry_run is true (the default), applies only that difference to the candidate configuration. Empty description or tags leave the current values unchanged. Changes take effect after a commit. |
6 | */ |
7 | export async function main( |
8 | auth: RT.PaloAltoPanos, |
9 | name: string, |
10 | type: "ip-netmask" | "ip-range" | "ip-wildcard" | "fqdn", |
11 | value: string, |
12 | location: "vsys" | "device-group" | "shared" | undefined, |
13 | vsys: string | undefined, |
14 | device_group: string | undefined, |
15 | description: string | undefined, |
16 | tags: string[] | undefined, |
17 | dry_run: boolean = true |
18 | ) { |
19 | const version = await restApiVersion(auth) |
20 | const url = new URL(`/restapi/${version}/Objects/Addresses`, auth.base_url) |
21 | const attrs = setLocation(url, location, vsys, device_group) |
22 |
|
23 | |
24 | const current = ((await rest(auth, url, "GET")).result?.entry ?? []).find( |
25 | (e: any) => e["@name"] === name |
26 | ) |
27 |
|
28 | const desired: { [key: string]: any } = { [type]: value } |
29 | if (description) desired.description = description |
30 | if (tags?.length) desired.tag = { member: tags } |
31 |
|
32 | const diff: { [key: string]: { from: any; to: any } } = {} |
33 | for (const [key, to] of Object.entries(desired)) { |
34 | const from = current?.[key] ?? null |
35 | if (canon(from) !== canon(to)) diff[key] = { from, to } |
36 | } |
37 | |
38 | for (const other of ADDRESS_TYPES) { |
39 | if (other !== type && current?.[other] != null) { |
40 | diff[other] = { from: current[other], to: null } |
41 | } |
42 | } |
43 |
|
44 | const changed = Object.keys(diff).length > 0 |
45 | const operation = !current ? "create" : changed ? "update" : "none" |
46 | if (!changed || dry_run !== false) { |
47 | return { |
48 | changed, |
49 | operation, |
50 | diff, |
51 | applied: false, |
52 | dry_run: dry_run !== false, |
53 | } |
54 | } |
55 |
|
56 | |
57 | |
58 | const entry: { [key: string]: any } = {} |
59 | for (const [key, v] of Object.entries(current ?? {})) { |
60 | if (!ADDRESS_TYPES.includes(key)) entry[key] = v |
61 | } |
62 | Object.assign(entry, { "@name": name, ...attrs }, desired) |
63 |
|
64 | url.searchParams.set("name", name) |
65 | await rest(auth, url, current ? "PUT" : "POST", { entry: [entry] }) |
66 | return { changed, operation, diff, applied: true, dry_run: false } |
67 | } |
68 |
|
69 | const ADDRESS_TYPES = ["ip-netmask", "ip-range", "ip-wildcard", "fqdn"] |
70 |
|
71 | |
72 | function canon(v: any): string { |
73 | if (Array.isArray(v)) return JSON.stringify(v.map(canon).sort()) |
74 | if (v && typeof v === "object") { |
75 | return JSON.stringify( |
76 | Object.keys(v) |
77 | .sort() |
78 | .map((k) => [ |
79 | k, |
80 | canon(k === "member" && !Array.isArray(v[k]) ? [v[k]] : v[k]), |
81 | ]) |
82 | ) |
83 | } |
84 | return JSON.stringify(v ?? null) |
85 | } |
86 |
|
87 | async function rest(auth: RT.PaloAltoPanos, url: URL, method: string, body?: any) { |
88 | const response = await fetch(url, { |
89 | method, |
90 | headers: { |
91 | "X-PAN-KEY": auth.api_key, |
92 | ...(body ? { "Content-Type": "application/json" } : {}), |
93 | Accept: "application/json", |
94 | }, |
95 | body: body ? JSON.stringify(body) : undefined, |
96 | }) |
97 | const text = await response.text() |
98 | if (!response.ok) { |
99 | throw new Error(`${response.status} ${text}`) |
100 | } |
101 | const data = JSON.parse(text) |
102 | if (data["@status"] !== "success") { |
103 | throw new Error(`${response.status} ${text}`) |
104 | } |
105 | return data |
106 | } |
107 |
|
108 | function setLocation( |
109 | url: URL, |
110 | location: string | undefined, |
111 | vsys: string | undefined, |
112 | device_group: string | undefined |
113 | ) { |
114 | const loc = location || "vsys" |
115 | url.searchParams.set("location", loc) |
116 | if (loc === "vsys") { |
117 | url.searchParams.set("vsys", vsys || "vsys1") |
118 | return { "@location": loc, "@vsys": vsys || "vsys1" } |
119 | } |
120 | if (loc === "device-group") { |
121 | if (!device_group) { |
122 | throw new Error("device_group is required when location is device-group") |
123 | } |
124 | url.searchParams.set("device-group", device_group) |
125 | return { "@location": loc, "@device-group": device_group } |
126 | } |
127 | return { "@location": loc } |
128 | } |
129 |
|
130 | async function restApiVersion(auth: RT.PaloAltoPanos) { |
131 | if (auth.rest_api_version) return auth.rest_api_version |
132 | const url = new URL("/api/", auth.base_url) |
133 | url.searchParams.set("type", "op") |
134 | url.searchParams.set("cmd", "<show><system><info></info></system></show>") |
135 | const response = await fetch(url, { headers: { "X-PAN-KEY": auth.api_key } }) |
136 | const text = await response.text() |
137 | if (!response.ok || text.includes('status="error"')) { |
138 | throw new Error(`${response.status} ${text}`) |
139 | } |
140 | const match = text.match(/<sw-version>(\d+)\.(\d+)/) |
141 | if (!match) { |
142 | throw new Error( |
143 | `Could not read sw-version from show system info; set rest_api_version on the resource. ${text}` |
144 | ) |
145 | } |
146 | return `v${match[1]}.${match[2]}` |
147 | } |
148 |
|