1 | |
2 |
|
3 | |
4 | * Upsert network device |
5 | * Declaratively set a network access device by name: reads the current device, returns the difference and, when dry_run is false, creates the device or updates the fields that differ. Fields left empty are not managed. Creating a new device requires ip_addresses. Shared secrets are write-only in ISE (never returned by GET), so they cannot be compared: they are set on create, and update_secrets true rotates them on an existing device. Updating a device that has RADIUS or TACACS+ settings requires radius_shared_secret or tacacs_shared_secret (the current secret, or a new one), because the update resends it; without it the update is refused. Devices with TrustSec settings, RADIUS key wrap or a second shared secret are not updated. |
6 | */ |
7 | export async function main( |
8 | auth: RT.CiscoIse, |
9 | name: string, |
10 | ip_addresses: { ipaddress: string; mask?: number }[] | undefined, |
11 | network_device_groups: string[] | undefined, |
12 | description: string | undefined, |
13 | radius_shared_secret: string | undefined, |
14 | tacacs_shared_secret: string | undefined, |
15 | profile_name: string | undefined, |
16 | coa_port: number | undefined, |
17 | model_name: string | undefined, |
18 | software_version: string | undefined, |
19 | update_secrets: boolean = false, |
20 | dry_run: boolean = true |
21 | ) { |
22 | const base = new URL(auth.base_url) |
23 | if (auth.ers_port) base.port = String(auth.ers_port) |
24 | const headers = { |
25 | Authorization: `Basic ${btoa(`${auth.username}:${auth.password}`)}`, |
26 | "Content-Type": "application/json", |
27 | Accept: "application/json", |
28 | } |
29 | const ers = async (method: string, url: string | URL, body?: unknown) => { |
30 | const response = await fetch(new URL(url, base), { |
31 | method, |
32 | headers, |
33 | body: body === undefined ? undefined : JSON.stringify(body), |
34 | }) |
35 | if (!response.ok) { |
36 | throw new Error(`${response.status} ${await response.text()}`) |
37 | } |
38 | return response |
39 | } |
40 |
|
41 | |
42 | |
43 | let id: string | null = null |
44 | const filter = encodeURIComponent(`name.EQ.${name}`) |
45 | for (let page = 1; !id; page++) { |
46 | const { SearchResult } = await ( |
47 | await ers( |
48 | "GET", |
49 | `/ers/config/networkdevice?size=100&page=${page}&filter=${filter}` |
50 | ) |
51 | ).json() |
52 | const resources = (SearchResult.resources ?? []) as { |
53 | id: string |
54 | name: string |
55 | }[] |
56 | id = resources.find((r) => r.name === name)?.id ?? null |
57 | if (!SearchResult.nextPage) break |
58 | } |
59 | const current: { [key: string]: any } | null = id |
60 | ? (await (await ers("GET", `/ers/config/networkdevice/${id}`)).json()) |
61 | .NetworkDevice |
62 | : null |
63 |
|
64 | |
65 | type Field = { send: any; want: any; have: any } |
66 | const fields: { [key: string]: Field } = {} |
67 | const ipKey = (l: { ipaddress: string; mask?: number }[] = []) => |
68 | l.map((ip) => `${ip.ipaddress}/${ip.mask ?? 32}`).sort() |
69 | if (ip_addresses?.length) { |
70 | fields.NetworkDeviceIPList = { |
71 | send: ip_addresses.map((ip) => ({ |
72 | ipaddress: ip.ipaddress, |
73 | mask: ip.mask ?? 32, |
74 | })), |
75 | want: ipKey(ip_addresses), |
76 | have: current ? ipKey(current.NetworkDeviceIPList) : null, |
77 | } |
78 | } |
79 | if (network_device_groups?.length) { |
80 | fields.NetworkDeviceGroupList = { |
81 | send: network_device_groups, |
82 | want: [...network_device_groups].sort(), |
83 | have: current ? [...(current.NetworkDeviceGroupList ?? [])].sort() : null, |
84 | } |
85 | } |
86 | const scalars: { [key: string]: string | number | undefined } = { |
87 | description, |
88 | profileName: profile_name, |
89 | coaPort: coa_port, |
90 | modelName: model_name, |
91 | softwareVersion: software_version, |
92 | } |
93 | for (const [key, value] of Object.entries(scalars)) { |
94 | if (value != null && value !== "") { |
95 | fields[key] = { send: value, want: value, have: current?.[key] ?? null } |
96 | } |
97 | } |
98 |
|
99 | const diff: { field: string; current: any; desired: any }[] = Object.entries( |
100 | fields |
101 | ) |
102 | .filter(([, f]) => JSON.stringify(f.want) !== JSON.stringify(f.have)) |
103 | .map(([field, f]) => ({ field, current: f.have, desired: f.want })) |
104 | const changes: { [key: string]: any } = Object.fromEntries( |
105 | diff.map((d) => [d.field, fields[d.field].send]) |
106 | ) |
107 |
|
108 | |
109 | const radiusSection = () => { |
110 | const settings = { |
111 | ...(current?.authenticationSettings ?? { networkProtocol: "RADIUS" }), |
112 | } |
113 | if ( |
114 | settings.enableKeyWrap === true || |
115 | settings.enableMultiSecret === true |
116 | ) { |
117 | throw new Error( |
118 | "The device uses RADIUS key wrap or a second shared secret: their write-only keys would have to be resent, which this action does not manage" |
119 | ) |
120 | } |
121 | for (const k of [ |
122 | "radiusSharedSecret", |
123 | "secondRadiusSharedSecret", |
124 | "keyEncryptionKey", |
125 | "messageAuthenticatorCodeKey", |
126 | ]) { |
127 | delete settings[k] |
128 | } |
129 | return { ...settings, radiusSharedSecret: radius_shared_secret } |
130 | } |
131 | const tacacsSection = () => { |
132 | const settings = { ...(current?.tacacsSettings ?? {}) } |
133 | delete settings.sharedSecret |
134 | return { ...settings, sharedSecret: tacacs_shared_secret } |
135 | } |
136 |
|
137 | |
138 | |
139 | const setSecrets = !current || update_secrets === true |
140 | if (radius_shared_secret && setSecrets) { |
141 | changes.authenticationSettings = radiusSection() |
142 | diff.push({ |
143 | field: "authenticationSettings.radiusSharedSecret", |
144 | current: current ? "(write-only)" : null, |
145 | desired: "********", |
146 | }) |
147 | } |
148 | if (tacacs_shared_secret && setSecrets) { |
149 | changes.tacacsSettings = tacacsSection() |
150 | diff.push({ |
151 | field: "tacacsSettings.sharedSecret", |
152 | current: current ? "(write-only)" : null, |
153 | desired: "********", |
154 | }) |
155 | } |
156 |
|
157 | |
158 | |
159 | |
160 | |
161 | if (current && diff.length > 0) { |
162 | const has = (section: any) => |
163 | section != null && |
164 | typeof section === "object" && |
165 | Object.keys(section).length > 0 |
166 | if ( |
167 | has(current.authenticationSettings) && |
168 | !changes.authenticationSettings |
169 | ) { |
170 | if (!radius_shared_secret) { |
171 | throw new Error( |
172 | "This device has RADIUS settings, whose shared secret ISE never returns: pass radius_shared_secret (the current secret, or a new one) so the update resends it" |
173 | ) |
174 | } |
175 | changes.authenticationSettings = radiusSection() |
176 | } |
177 | if (has(current.tacacsSettings) && !changes.tacacsSettings) { |
178 | if (!tacacs_shared_secret) { |
179 | throw new Error( |
180 | "This device has TACACS+ settings, whose shared secret ISE never returns: pass tacacs_shared_secret (the current secret, or a new one) so the update resends it" |
181 | ) |
182 | } |
183 | changes.tacacsSettings = tacacsSection() |
184 | } |
185 | if (has(current.trustsecsettings)) { |
186 | throw new Error( |
187 | "This device has TrustSec settings, whose write-only keys this action does not manage: update it in ISE" |
188 | ) |
189 | } |
190 | } |
191 |
|
192 | if (!current) { |
193 | if (!changes.NetworkDeviceIPList) { |
194 | throw new Error( |
195 | "Network device does not exist: ip_addresses is required to create it" |
196 | ) |
197 | } |
198 | const result = { |
199 | changed: true, |
200 | action: "create", |
201 | id: null as string | null, |
202 | diff, |
203 | applied: false, |
204 | } |
205 | if (dry_run !== false) return result |
206 | const response = await ers("POST", "/ers/config/networkdevice", { |
207 | NetworkDevice: { name, ...changes }, |
208 | }) |
209 | result.id = response.headers.get("Location")?.split("/").pop() ?? null |
210 | result.applied = true |
211 | return result |
212 | } |
213 |
|
214 | const result = { |
215 | changed: diff.length > 0, |
216 | action: diff.length ? "update" : "none", |
217 | id: current.id as string, |
218 | diff, |
219 | applied: false, |
220 | } |
221 | if (dry_run !== false || diff.length === 0) return result |
222 | |
223 | |
224 | const { |
225 | link: _link, |
226 | authenticationSettings: _auth, |
227 | tacacsSettings: _tacacs, |
228 | trustsecsettings: _trustsec, |
229 | ...unchanged |
230 | } = current |
231 | await ers("PUT", `/ers/config/networkdevice/${current.id}`, { |
232 | NetworkDevice: { ...unchanged, ...changes }, |
233 | }) |
234 | result.applied = true |
235 | return result |
236 | } |
237 |
|