//native
/**
* Upsert network device
* 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.
*/
export async function main(
auth: RT.CiscoIse,
name: string,
ip_addresses: { ipaddress: string; mask?: number }[] | undefined,
network_device_groups: string[] | undefined,
description: string | undefined,
radius_shared_secret: string | undefined,
tacacs_shared_secret: string | undefined,
profile_name: string | undefined,
coa_port: number | undefined,
model_name: string | undefined,
software_version: string | undefined,
update_secrets: boolean = false,
dry_run: boolean = true
) {
const base = new URL(auth.base_url)
if (auth.ers_port) base.port = String(auth.ers_port)
const headers = {
Authorization: `Basic ${btoa(`${auth.username}:${auth.password}`)}`,
"Content-Type": "application/json",
Accept: "application/json",
}
const ers = async (method: string, url: string | URL, body?: unknown) => {
const response = await fetch(new URL(url, base), {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
})
if (!response.ok) {
throw new Error(`${response.status} ${await response.text()}`)
}
return response
}
// Current state. The name filter is matched again client-side and pages are followed,
// so the lookup stays correct on ISE versions that ignore the filter.
let id: string | null = null
const filter = encodeURIComponent(`name.EQ.${name}`)
for (let page = 1; !id; page++) {
const { SearchResult } = await (
await ers(
"GET",
`/ers/config/networkdevice?size=100&page=${page}&filter=${filter}`
)
).json()
const resources = (SearchResult.resources ?? []) as {
id: string
name: string
}[]
id = resources.find((r) => r.name === name)?.id ?? null
if (!SearchResult.nextPage) break
}
const current: { [key: string]: any } | null = id
? (await (await ers("GET", `/ers/config/networkdevice/${id}`)).json())
.NetworkDevice
: null
// Non-secret fields: the value to send and a comparable form of desired and current
type Field = { send: any; want: any; have: any }
const fields: { [key: string]: Field } = {}
const ipKey = (l: { ipaddress: string; mask?: number }[] = []) =>
l.map((ip) => `${ip.ipaddress}/${ip.mask ?? 32}`).sort()
if (ip_addresses?.length) {
fields.NetworkDeviceIPList = {
send: ip_addresses.map((ip) => ({
ipaddress: ip.ipaddress,
mask: ip.mask ?? 32,
})),
want: ipKey(ip_addresses),
have: current ? ipKey(current.NetworkDeviceIPList) : null,
}
}
if (network_device_groups?.length) {
fields.NetworkDeviceGroupList = {
send: network_device_groups,
want: [...network_device_groups].sort(),
have: current ? [...(current.NetworkDeviceGroupList ?? [])].sort() : null,
}
}
const scalars: { [key: string]: string | number | undefined } = {
description,
profileName: profile_name,
coaPort: coa_port,
modelName: model_name,
softwareVersion: software_version,
}
for (const [key, value] of Object.entries(scalars)) {
if (value != null && value !== "") {
fields[key] = { send: value, want: value, have: current?.[key] ?? null }
}
}
const diff: { field: string; current: any; desired: any }[] = Object.entries(
fields
)
.filter(([, f]) => JSON.stringify(f.want) !== JSON.stringify(f.have))
.map(([field, f]) => ({ field, current: f.have, desired: f.want }))
const changes: { [key: string]: any } = Object.fromEntries(
diff.map((d) => [d.field, fields[d.field].send])
)
// Write-only secrets: ISE never returns them, so they can't be compared or read back.
const radiusSection = () => {
const settings = {
...(current?.authenticationSettings ?? { networkProtocol: "RADIUS" }),
}
if (
settings.enableKeyWrap === true ||
settings.enableMultiSecret === true
) {
throw new Error(
"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"
)
}
for (const k of [
"radiusSharedSecret",
"secondRadiusSharedSecret",
"keyEncryptionKey",
"messageAuthenticatorCodeKey",
]) {
delete settings[k]
}
return { ...settings, radiusSharedSecret: radius_shared_secret }
}
const tacacsSection = () => {
const settings = { ...(current?.tacacsSettings ?? {}) }
delete settings.sharedSecret
return { ...settings, sharedSecret: tacacs_shared_secret }
}
// Set the secrets on create, and on an existing device when update_secrets is true
// (a rotation, which counts as a change).
const setSecrets = !current || update_secrets === true
if (radius_shared_secret && setSecrets) {
changes.authenticationSettings = radiusSection()
diff.push({
field: "authenticationSettings.radiusSharedSecret",
current: current ? "(write-only)" : null,
desired: "********",
})
}
if (tacacs_shared_secret && setSecrets) {
changes.tacacsSettings = tacacsSection()
diff.push({
field: "tacacsSettings.sharedSecret",
current: current ? "(write-only)" : null,
desired: "********",
})
}
// An update is a full PUT, and a PUT has to carry every configured secret: leaving a
// section out, or sending it without its secret, is not documented to keep the old one.
// So the secrets of a configured section are resent, and the update is refused when
// the caller didn't pass them.
if (current && diff.length > 0) {
const has = (section: any) =>
section != null &&
typeof section === "object" &&
Object.keys(section).length > 0
if (
has(current.authenticationSettings) &&
!changes.authenticationSettings
) {
if (!radius_shared_secret) {
throw new Error(
"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"
)
}
changes.authenticationSettings = radiusSection()
}
if (has(current.tacacsSettings) && !changes.tacacsSettings) {
if (!tacacs_shared_secret) {
throw new Error(
"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"
)
}
changes.tacacsSettings = tacacsSection()
}
if (has(current.trustsecsettings)) {
throw new Error(
"This device has TrustSec settings, whose write-only keys this action does not manage: update it in ISE"
)
}
}
if (!current) {
if (!changes.NetworkDeviceIPList) {
throw new Error(
"Network device does not exist: ip_addresses is required to create it"
)
}
const result = {
changed: true,
action: "create",
id: null as string | null,
diff,
applied: false,
}
if (dry_run !== false) return result
const response = await ers("POST", "/ers/config/networkdevice", {
NetworkDevice: { name, ...changes },
})
result.id = response.headers.get("Location")?.split("/").pop() ?? null
result.applied = true
return result
}
const result = {
changed: diff.length > 0,
action: diff.length ? "update" : "none",
id: current.id as string,
diff,
applied: false,
}
if (dry_run !== false || diff.length === 0) return result
// Send the full current object with the changes, as Cisco's Ansible module and Terraform
// provider do. The secret sections come only from changes, with their secrets (see above).
const {
link: _link,
authenticationSettings: _auth,
tacacsSettings: _tacacs,
trustsecsettings: _trustsec,
...unchanged
} = current
await ers("PUT", `/ers/config/networkdevice/${current.id}`, {
NetworkDevice: { ...unchanged, ...changes },
})
result.applied = true
return result
}
Submitted by hugo989 6 hours ago