|
| 1 | +import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; |
| 2 | + |
| 3 | +export async function lambdaHandler(ev: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> { |
| 4 | + // TODO: https://tailscale.com/kb/1213/webhooks#verifying-an-event-signature |
| 5 | + // console.log(`Received event: ${JSON.stringify(ev)}`); // TODO: add verbose logging flag? |
| 6 | + |
| 7 | + let processedCount = 0; |
| 8 | + let ignoredCount = 0; |
| 9 | + let erroredCount = 0; |
| 10 | + try { |
| 11 | + let decodedBody = ev.body; |
| 12 | + if (ev.isBase64Encoded) { |
| 13 | + decodedBody = Buffer.from(ev.body!, 'base64').toString('utf8'); |
| 14 | + } |
| 15 | + const tailnetEvents: TailnetEvent[] = JSON.parse(decodedBody!); |
| 16 | + const results: ProcessingResult[] = []; |
| 17 | + for (const event of tailnetEvents) { |
| 18 | + try { |
| 19 | + switch (event.type) { // https://tailscale.com/kb/1213/webhooks#events |
| 20 | + case "nodeNeedsApproval": |
| 21 | + results.push(await nodeNeedsApprovalHandler(event)); |
| 22 | + break; |
| 23 | + default: |
| 24 | + results.push(await unhandledHandler(event)); |
| 25 | + break; |
| 26 | + } |
| 27 | + } |
| 28 | + catch (err: any) { |
| 29 | + results.push({ event: event, result: "ERROR", error: err, } as ProcessingResult); |
| 30 | + } |
| 31 | + } |
| 32 | + results.forEach(it => { |
| 33 | + switch (it.result) { |
| 34 | + case "SUCCESS": |
| 35 | + processedCount++; |
| 36 | + break; |
| 37 | + case "IGNORED": |
| 38 | + ignoredCount++; |
| 39 | + break; |
| 40 | + case "ERROR": |
| 41 | + console.log(`Error processing event [${JSON.stringify(it.event)}]: ${it.error}`); |
| 42 | + erroredCount++; |
| 43 | + break; |
| 44 | + } |
| 45 | + }); |
| 46 | + |
| 47 | + return generateResponseBody((erroredCount > 0 ? 500 : 200), ev, processedCount, erroredCount, ignoredCount); |
| 48 | + } catch (err) { |
| 49 | + console.log(err); |
| 50 | + return generateResponseBody(500, ev, processedCount, erroredCount, ignoredCount); |
| 51 | + } |
| 52 | +} |
| 53 | + |
| 54 | +function generateResponseBody(statusCode: number, ev: APIGatewayProxyEvent, processedCount: number, erroredCount: number, ignoredCount: number): APIGatewayProxyResult { |
| 55 | + const result = { |
| 56 | + statusCode: statusCode, |
| 57 | + body: JSON.stringify({ |
| 58 | + message: (statusCode == 200 ? "ok" : "An error occurred."), |
| 59 | + // requestId: ev.requestContext.requestId, // TODO: This requestId doesn't match what's in the lambda logs. |
| 60 | + eventResults: { |
| 61 | + processed: processedCount, |
| 62 | + errored: erroredCount, |
| 63 | + ignored: ignoredCount, |
| 64 | + }, |
| 65 | + }), |
| 66 | + }; |
| 67 | + console.log(`returning response: ${JSON.stringify(result)}`); |
| 68 | + return result |
| 69 | +} |
| 70 | + |
| 71 | +async function unhandledHandler(event: TailnetEvent): Promise<ProcessingResult> { |
| 72 | + console.log(`Ignoring event type [${event.type}]`); |
| 73 | + return { event: event, result: "IGNORED", } as ProcessingResult; |
| 74 | +} |
| 75 | + |
| 76 | +async function nodeNeedsApprovalHandler(event: TailnetEvent): Promise<ProcessingResult> { |
| 77 | + try { |
| 78 | + console.log(`Handling event type [${event.type}]`); |
| 79 | + |
| 80 | + const eventData = event.data as TailnetEventDeviceData; |
| 81 | + |
| 82 | + // get device details and attributes |
| 83 | + const deviceResponse = await getDevice(eventData); |
| 84 | + if (!deviceResponse.ok) { |
| 85 | + throw new Error(`Failed to get device [${eventData.nodeID}]`); |
| 86 | + } |
| 87 | + |
| 88 | + const attributesResponse = await getDeviceAttributes(eventData); |
| 89 | + if (!attributesResponse.ok) { |
| 90 | + throw new Error(`Failed to get device attributes [${eventData.nodeID}]`); |
| 91 | + } |
| 92 | + |
| 93 | + // inspect device details |
| 94 | + const deviceResponseJson = await deviceResponse.json(); |
| 95 | + console.log(`Device response [${JSON.stringify(deviceResponseJson)}]`); |
| 96 | + const attributesResponseJson = await attributesResponse.json(); |
| 97 | + console.log(`Device attributes response [${JSON.stringify(attributesResponseJson)}]`); |
| 98 | + |
| 99 | + /** |
| 100 | + * Customize approval logic here. |
| 101 | + */ |
| 102 | + if ( |
| 103 | + ["windows", "macos", "linux"].includes(attributesResponseJson["attributes"]["node:os"]) |
| 104 | + && attributesResponseJson["attributes"]["node:tsReleaseTrack"] == "stable" |
| 105 | + ) { |
| 106 | + // approve device |
| 107 | + await approveDevice(eventData); |
| 108 | + } |
| 109 | + else { |
| 110 | + console.log(`NOT approving device [${eventData.nodeID}:${eventData.deviceName}] with attributes [${JSON.stringify(attributesResponseJson)}]`); |
| 111 | + } |
| 112 | + |
| 113 | + return { event: event, result: "SUCCESS", } as ProcessingResult; |
| 114 | + } catch (err: any) { |
| 115 | + return { event: event, result: "ERROR", error: err, } as ProcessingResult; |
| 116 | + } |
| 117 | +} |
| 118 | + |
| 119 | +export const ENV_TAILSCALE_OAUTH_CLIENT_ID = "OAUTH_CLIENT_ID"; |
| 120 | +export const ENV_TAILSCALE_OAUTH_CLIENT_SECRET = "OAUTH_CLIENT_SECRET"; |
| 121 | +const TAILSCALE_CONTROL_URL = "https://login.tailscale.com"; |
| 122 | + |
| 123 | +// https://github.com/tailscale/tailscale/blob/main/publicapi/device.md#get-device-posture-attributes |
| 124 | +async function getDeviceAttributes(event: TailnetEventDeviceData): Promise<Response> { |
| 125 | + console.log(`Getting device attributes [${event.nodeID}]`); |
| 126 | + const data = await makeAuthenticatedRequest("GET", `${TAILSCALE_CONTROL_URL}/api/v2/device/${event.nodeID}/attributes`); |
| 127 | + if (!data.ok) { |
| 128 | + throw new Error(`Failed to get device [${event.nodeID}]`); |
| 129 | + } |
| 130 | + return data; |
| 131 | +} |
| 132 | + |
| 133 | +// https://github.com/tailscale/tailscale/blob/main/publicapi/device.md#get-device |
| 134 | +async function getDevice(event: TailnetEventDeviceData): Promise<Response> { |
| 135 | + console.log(`Getting device [${event.nodeID}]`); |
| 136 | + const data = await makeAuthenticatedRequest("GET", `${TAILSCALE_CONTROL_URL}/api/v2/device/${event.nodeID}`); |
| 137 | + if (!data.ok) { |
| 138 | + throw new Error(`Failed to get device [${event.nodeID}]`); |
| 139 | + } |
| 140 | + return data; |
| 141 | +} |
| 142 | + |
| 143 | +// https://github.com/tailscale/tailscale/blob/main/publicapi/device.md#authorize-device |
| 144 | +async function approveDevice(device: TailnetEventDeviceData) { |
| 145 | + console.log(`Approving device [${device.nodeID}:${device.deviceName}]`); |
| 146 | + const data = await makeAuthenticatedRequest("POST", `${TAILSCALE_CONTROL_URL}/api/v2/device/${device.nodeID}/authorized`, JSON.stringify({ "authorized": true })); |
| 147 | + if (!data.ok) { |
| 148 | + throw new Error(`Failed to approve device [${device.nodeID}:${device.deviceName}]`); |
| 149 | + } |
| 150 | +} |
| 151 | + |
| 152 | +// https://tailscale.com/kb/1215/oauth-clients |
| 153 | +export async function getAccessToken(): Promise<Response> { |
| 154 | + const oauthClientId = process.env[ENV_TAILSCALE_OAUTH_CLIENT_ID]; |
| 155 | + const oauthClientSecret = process.env[ENV_TAILSCALE_OAUTH_CLIENT_SECRET]; |
| 156 | + if (!oauthClientId || !oauthClientSecret) { |
| 157 | + throw new Error(`Missing required environment variables [${ENV_TAILSCALE_OAUTH_CLIENT_ID}] and [${ENV_TAILSCALE_OAUTH_CLIENT_SECRET}]. See https://tailscale.com/kb/1215/oauth-clients.`); |
| 158 | + } |
| 159 | + |
| 160 | + const options: RequestInit = { |
| 161 | + method: "POST", |
| 162 | + headers: { "Content-Type": "application/x-www-form-urlencoded" }, |
| 163 | + body: `client_id=${oauthClientId}&client_secret=${oauthClientSecret}`, |
| 164 | + }; |
| 165 | + |
| 166 | + // console.log(`getting access token`); |
| 167 | + const data = await httpsRequest(`${TAILSCALE_CONTROL_URL}/api/v2/oauth/token`, options); |
| 168 | + if (!data.ok) { |
| 169 | + throw new Error(`Failed to get an access token.`); |
| 170 | + } |
| 171 | + return data; |
| 172 | +} |
| 173 | + |
| 174 | +const makeAuthenticatedRequest = async function (method: "GET" | "POST", url: string, body?: string): Promise<Response> { |
| 175 | + const accessTokenResponse = await getAccessToken(); |
| 176 | + const result = await accessTokenResponse.json(); |
| 177 | + |
| 178 | + const options: RequestInit = { |
| 179 | + method: method, |
| 180 | + headers: { "Authorization": `Bearer ${result.access_token}` }, |
| 181 | + body: body, |
| 182 | + }; |
| 183 | + |
| 184 | + return await httpsRequest(url, options); |
| 185 | +} |
| 186 | + |
| 187 | +async function httpsRequest(url: string, options: any): Promise<Response> { |
| 188 | + // console.log(`Making HTTP request to [${url}] with options [${JSON.stringify(options)}]`); // TODO: add verbose logging flag? |
| 189 | + return await fetch(url, options); |
| 190 | +} |
| 191 | + |
| 192 | +interface TailnetEvent { |
| 193 | + timestamp: string; |
| 194 | + version: number; |
| 195 | + type: string; |
| 196 | + tailnet: string; |
| 197 | + message: string; |
| 198 | + data: any |
| 199 | +}; |
| 200 | + |
| 201 | +interface TailnetEventDeviceData { |
| 202 | + nodeID: string; |
| 203 | + deviceName: string; |
| 204 | + managedBy: string; |
| 205 | + actor: string; |
| 206 | + url: string; |
| 207 | +}; |
| 208 | + |
| 209 | +interface ProcessingResult { |
| 210 | + event: TailnetEvent; |
| 211 | + result: "SUCCESS" | "ERROR" | "IGNORED"; |
| 212 | + error?: Error; |
| 213 | +}; |
0 commit comments