Skip to content

Commit ea6bdd4

Browse files
authored
pulumi, aws-lambda-device-approval-handler: add example (#15)
1 parent 509e667 commit ea6bdd4

File tree

10 files changed

+4030
-0
lines changed

10 files changed

+4030
-0
lines changed

pulumi/README.md

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# pulumi
2+
3+
## Overview
4+
5+
This directory contains [Pulumi](https://www.pulumi.com) examples for common Tailscale deployments across Amazon Web Services, Microsoft Azure, and Google Cloud Platform.
6+
7+
## Prerequisites
8+
9+
The examples assume prior experience with Pulumi - its concepts, operations, and configuration.
10+
11+
## To use
12+
13+
Each example subdirectory contains a readme explaining its use.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
/bin/
2+
/node_modules/
3+
**/Pulumi.*.yaml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
name: aws-lambda-device-approval-handler
2+
runtime: nodejs
3+
description: Example of handling a Tailscale Device Approval Webhook in Lambda
4+
config:
5+
pulumi:tags:
6+
value:
7+
pulumi:template: aws-typescript
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# aws-lambda-device-approval-handler
2+
3+
This example creates the following:
4+
5+
- a API Gateway REST API
6+
- a Lambda function to receive [Tailscale webhooks](https://tailscale.com/kb/1213/webhooks) and approve devices based on device details and attributes
7+
8+
## To use
9+
10+
Follow the documentation to configure the Terraform providers:
11+
12+
- [AWS](https://www.pulumi.com/registry/packages/aws/installation-configuration/)
13+
14+
### Deploy
15+
16+
Create a [Tailscale OAuth Client](https://tailscale.com/kb/1215/oauth-clients#setting-up-an-oauth-client) with scope `all` and provide the client ID and client secret with `pulumi config set ...` as shown below.
17+
18+
```shell
19+
pulumi stack init
20+
pulumi config set tailscaleOauthClientId
21+
pulumi config set tailscaleOauthClientSecret --secret
22+
pulumi up
23+
```
24+
25+
## To destroy
26+
27+
```shell
28+
pulumi down
29+
```
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
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+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import * as pulumi from "@pulumi/pulumi";
2+
import * as aws from "@pulumi/aws";
3+
4+
import * as handler from "./handler";
5+
6+
const pulumiConfig = new pulumi.Config();
7+
8+
export const getPulumiHandler = (name: string) => {
9+
return new aws.lambda.CallbackFunction(name, {
10+
environment: {
11+
variables: {
12+
[handler.ENV_TAILSCALE_OAUTH_CLIENT_ID]: pulumiConfig.require("tailscaleOauthClientId"),
13+
[handler.ENV_TAILSCALE_OAUTH_CLIENT_SECRET]: pulumiConfig.requireSecret("tailscaleOauthClientSecret"),
14+
},
15+
},
16+
runtime: "nodejs20.x",
17+
callback: async (ev: any, ctx) => {
18+
return handler.lambdaHandler(ev);
19+
},
20+
});
21+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import * as apigateway from "@pulumi/aws-apigateway";
2+
import * as path from 'path';
3+
import * as handler from "./handlerPulumi";
4+
5+
const name = `example-${path.basename(process.cwd())}`;
6+
7+
const api = new apigateway.RestAPI(name, {
8+
stageName: "tailscale-device-approval",
9+
binaryMediaTypes: ["application/json"],
10+
routes: [
11+
{
12+
path: "/",
13+
method: "POST",
14+
eventHandler: handler.getPulumiHandler(`${name}-fn`),
15+
},
16+
],
17+
});
18+
19+
export const url = api.url;

0 commit comments

Comments
 (0)