0

Upsert network device

by
Published today

Declaratively create or update a Cisco ISE network device by name, with a dry-run diff. Updating a device that has RADIUS or TACACS+ settings requires its shared secret (radius_shared_secret or tacacs_shared_secret), which the update resends; without it the update is refused.

Script cisco_ise Verified

The script

Submitted by hugo989 Typescript (fetch-only)
Verified 5 hours ago
1
//native
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
  // Current state. The name filter is matched again client-side and pages are followed,
42
  // so the lookup stays correct on ISE versions that ignore the filter.
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
  // Non-secret fields: the value to send and a comparable form of desired and current
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
  // Write-only secrets: ISE never returns them, so they can't be compared or read back.
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
  // Set the secrets on create, and on an existing device when update_secrets is true
138
  // (a rotation, which counts as a change).
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
  // An update is a full PUT, and a PUT has to carry every configured secret: leaving a
158
  // section out, or sending it without its secret, is not documented to keep the old one.
159
  // So the secrets of a configured section are resent, and the update is refused when
160
  // the caller didn't pass them.
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
  // Send the full current object with the changes, as Cisco's Ansible module and Terraform
223
  // provider do. The secret sections come only from changes, with their secrets (see above).
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