0

Upsert endpoint

by
Published today

Declaratively create or update a Cisco ISE endpoint's identity group and description by MAC, with a dry-run diff.

Script cisco_ise Verified

The script

Submitted by hugo989 Typescript (fetch-only)
Verified 6 hours ago
1
//native
2

3
/**
4
 * Upsert endpoint
5
 * Declaratively set an endpoint's identity group and description by MAC: reads the current endpoint, returns the difference and, when dry_run is false, creates the endpoint or updates the fields that differ. Fields left empty are not managed. Creating a new endpoint requires identity_group.
6
 */
7
export async function main(
8
  auth: RT.CiscoIse,
9
  mac: string,
10
  identity_group: string | undefined,
11
  description: string | undefined,
12
  dry_run: boolean = true
13
) {
14
  const base = new URL(auth.base_url)
15
  if (auth.ers_port) base.port = String(auth.ers_port)
16
  const headers = {
17
    Authorization: `Basic ${btoa(`${auth.username}:${auth.password}`)}`,
18
    "Content-Type": "application/json",
19
    Accept: "application/json",
20
  }
21
  const ers = async (method: string, path: string, body?: unknown) => {
22
    const response = await fetch(new URL(path, base), {
23
      method,
24
      headers,
25
      body: body === undefined ? undefined : JSON.stringify(body),
26
    })
27
    if (!response.ok) {
28
      throw new Error(`${response.status} ${await response.text()}`)
29
    }
30
    return response
31
  }
32
  // Find an object id by filter, matching client-side and following pages, so the lookup
33
  // stays correct on ISE versions that ignore the filter.
34
  const find = async (
35
    path: string,
36
    filter: string,
37
    match: (r: any) => boolean
38
  ) => {
39
    for (let page = 1; ; page++) {
40
      const { SearchResult } = await (
41
        await ers(
42
          "GET",
43
          `${path}?size=100&page=${page}&filter=${encodeURIComponent(filter)}`
44
        )
45
      ).json()
46
      const hit = (SearchResult.resources ?? []).find(match)
47
      if (hit) return hit.id as string
48
      if (!SearchResult.nextPage) return null
49
    }
50
  }
51

52
  // ISE stores MACs as AA:BB:CC:DD:EE:FF; accept any separator or case
53
  const hex = mac.replace(/[-:.\s]/g, "").toUpperCase()
54
  if (!/^[0-9A-F]{12}$/.test(hex))
55
    throw new Error(`Invalid MAC address: ${mac}`)
56
  const macAddress = hex.match(/../g)!.join(":")
57

58
  // Desired state, only for the fields the caller set
59
  const desired: { [key: string]: any } = {}
60
  if (identity_group) {
61
    const groupId = await find(
62
      "/ers/config/endpointgroup",
63
      `name.EQ.${identity_group}`,
64
      (r) => r.name === identity_group
65
    )
66
    if (!groupId) {
67
      throw new Error(`Endpoint identity group not found: ${identity_group}`)
68
    }
69
    desired.groupId = groupId
70
    desired.staticGroupAssignment = true
71
  }
72
  if (description) desired.description = description
73

74
  // Current state (endpoint list items are named by MAC)
75
  const id = await find(
76
    "/ers/config/endpoint",
77
    `mac.EQ.${macAddress}`,
78
    (r) => String(r.name).replace(/[-:.]/g, "").toUpperCase() === hex
79
  )
80
  const current = id
81
    ? (await (await ers("GET", `/ers/config/endpoint/${id}`)).json())
82
        .ERSEndPoint
83
    : null
84

85
  const diff = Object.entries(desired)
86
    .filter(([field, value]) => current?.[field] !== value)
87
    .map(([field, value]) => ({
88
      field,
89
      current: current?.[field] ?? null,
90
      desired: value,
91
    }))
92

93
  if (!current) {
94
    if (!desired.groupId) {
95
      throw new Error(
96
        "Endpoint does not exist: identity_group is required to create it"
97
      )
98
    }
99
    const result = {
100
      changed: true,
101
      action: "create",
102
      id: null as string | null,
103
      diff,
104
      applied: false,
105
    }
106
    if (dry_run !== false) return result
107
    const response = await ers("POST", "/ers/config/endpoint", {
108
      ERSEndPoint: {
109
        name: macAddress,
110
        mac: macAddress,
111
        staticProfileAssignment: false,
112
        ...desired,
113
      },
114
    })
115
    result.id = response.headers.get("Location")?.split("/").pop() ?? null
116
    result.applied = true
117
    return result
118
  }
119

120
  const result = {
121
    changed: diff.length > 0,
122
    action: diff.length ? "update" : "none",
123
    id: current.id as string,
124
    diff,
125
    applied: false,
126
  }
127
  if (dry_run !== false || diff.length === 0) return result
128
  // Send the identifiers and the managed fields only, as Cisco's ansible-ise endpoint module
129
  // does (ciscoisesdk drops the fields it isn't given)
130
  await ers("PUT", `/ers/config/endpoint/${current.id}`, {
131
    ERSEndPoint: {
132
      id: current.id,
133
      name: current.name,
134
      mac: current.mac ?? macAddress,
135
      ...desired,
136
    },
137
  })
138
  result.applied = true
139
  return result
140
}
141