|
1 | 1 | /** |
2 | | - * Welcome to Cloudflare Workers! This is your first worker. |
| 2 | + * Welcome to Cloudflare Workers! |
3 | 3 | * |
4 | 4 | * - Run `npm run dev` in your terminal to start a development server |
5 | 5 | * - Open a browser tab at http://localhost:8787/ to see your worker in action |
6 | 6 | * - Run `npm run deploy` to publish your worker |
7 | 7 | * |
8 | 8 | * Learn more at https://developers.cloudflare.com/workers/ |
9 | 9 | */ |
| 10 | +import { TailscaleEvents, TailscaleEvent } from './types'; |
10 | 11 |
|
11 | 12 | export interface Env { |
12 | | - // Example binding to KV. Learn more at https://developers.cloudflare.com/workers/runtime-apis/kv/ |
13 | | - // MY_KV_NAMESPACE: KVNamespace; |
14 | | - // |
15 | | - // Example binding to Durable Object. Learn more at https://developers.cloudflare.com/workers/runtime-apis/durable-objects/ |
16 | | - // MY_DURABLE_OBJECT: DurableObjectNamespace; |
17 | | - // |
18 | | - // Example binding to R2. Learn more at https://developers.cloudflare.com/workers/runtime-apis/r2/ |
19 | | - // MY_BUCKET: R2Bucket; |
20 | | - // |
21 | | - // Example binding to a Service. Learn more at https://developers.cloudflare.com/workers/runtime-apis/service-bindings/ |
22 | | - // MY_SERVICE: Fetcher; |
23 | | - // |
24 | | - // Example binding to a Queue. Learn more at https://developers.cloudflare.com/queues/javascript-apis/ |
25 | | - // MY_QUEUE: Queue; |
| 13 | + // Environment variables defined in the Cloudflare Workers UI or wrangler.toml are exposed to your Worker via the global `ENV` object. |
| 14 | + |
| 15 | + // PUSHOVER_API_URL is the base URL for the Pushover API, e.g. https://api.pushover.net |
| 16 | + PUSHOVER_API_URL: string; |
| 17 | + |
| 18 | + /** |
| 19 | + * Secrets (below) are exposed to your Worker via the global `SECRETS` object. Learn more at https://developers.cloudflare.com/workers/cli-wrangler/commands#secret |
| 20 | + **/ |
| 21 | + |
| 22 | + // Create using wrangler, e.g. wrangler secret put PUSHOVER_API_TOKEN |
| 23 | + |
| 24 | + // PUSHOVER_API_TOKEN is the Pushover API token, which can be found at https://pushover.net/apps/build or your existing app at https://pushover.net/<your_app_name>. |
| 25 | + PUSHOVER_API_TOKEN: string; |
| 26 | + |
| 27 | + // PUSHOVER_USER_KEY is the Pushover user key, which can be found at https://pushover.net/ |
| 28 | + PUSHOVER_USER_KEY: string; |
| 29 | + |
| 30 | + // TAILSCALE_WEBHOOK_SECRET is the secret used to sign the webhook request. This is configured in the Tailscale admin console at https://login.tailscale.com/admin/settings/webhooks. |
| 31 | + TAILSCALE_WEBHOOK_SECRET: string; |
| 32 | +} |
| 33 | + |
| 34 | +const pushoverMessageEndpoint = '/1/messages.json'; |
| 35 | + |
| 36 | +function parseRequest(request: Request): TailscaleEvents { |
| 37 | + let tailscaleEvents: TailscaleEvents; |
| 38 | + |
| 39 | + try { |
| 40 | + const requestBody = request.json() as Object; |
| 41 | + // Convert the request body to TailscaleEvents type |
| 42 | + tailscaleEvents = requestBody as TailscaleEvents; |
| 43 | + } catch (e) { |
| 44 | + throw new Error(`Failed to parse request body: ${e}`); |
| 45 | + } |
| 46 | + |
| 47 | + return tailscaleEvents; |
| 48 | +} |
| 49 | + |
| 50 | +function handleEvents(env: Env, events: TailscaleEvents): void { |
| 51 | + for (const event of events) { |
| 52 | + try { |
| 53 | + sendPushoverNotification(env, event); |
| 54 | + } catch (error) { |
| 55 | + throw new Error(`Failed to send Pushover notification: ${error}`); |
| 56 | + } |
| 57 | + } |
| 58 | + |
| 59 | + return; |
| 60 | +} |
| 61 | + |
| 62 | +async function sendPushoverNotification(env: Env, event: TailscaleEvent): Promise<void> { |
| 63 | + const body = { |
| 64 | + token: env.PUSHOVER_API_TOKEN, |
| 65 | + title: `${event.tailnet} - ${event.type}`, |
| 66 | + message: event.message, |
| 67 | + url: event.data?.url, |
| 68 | + user: env.PUSHOVER_USER_KEY, |
| 69 | + }; |
| 70 | + |
| 71 | + try { |
| 72 | + const response = await fetch(env.PUSHOVER_API_URL + pushoverMessageEndpoint, { |
| 73 | + method: 'POST', |
| 74 | + headers: { |
| 75 | + 'Content-Type': 'application/json', |
| 76 | + }, |
| 77 | + body: JSON.stringify(body), |
| 78 | + }); |
| 79 | + |
| 80 | + if (!response.ok) { |
| 81 | + throw new Error(`Pushover API returned ${response.status} ${response.statusText} - ${await response.text()}`); |
| 82 | + } |
| 83 | + |
| 84 | + console.info('Pushover notification sent successfully.'); |
| 85 | + } catch (error) { |
| 86 | + console.error(`Failed to send Pushover notification: ${error}.`); |
| 87 | + } |
| 88 | + |
| 89 | + return; |
| 90 | +} |
| 91 | + |
| 92 | +function hexStringToArrayBuffer(hexString: string): ArrayBuffer | undefined { |
| 93 | + hexString = hexString.replace(/^0x/, ''); |
| 94 | + |
| 95 | + if (hexString.length % 2 !== 0) { |
| 96 | + return undefined; |
| 97 | + } |
| 98 | + |
| 99 | + if (hexString.match(/[G-Z\s]/i)) { |
| 100 | + return undefined; |
| 101 | + } |
| 102 | + |
| 103 | + return new Uint8Array(hexString.match(/[\dA-F]{2}/gi)?.map((s) => parseInt(s, 16)) ?? []).buffer; |
| 104 | +} |
| 105 | + |
| 106 | +async function verifyTailscaleWebhookSignature(secret: string, signatureHeader: string, requestBody: string): Promise<boolean> { |
| 107 | + const parts = signatureHeader.split(','); |
| 108 | + let timestamp: string = ''; |
| 109 | + let signature: string = ''; |
| 110 | + |
| 111 | + parts.forEach((part) => { |
| 112 | + if (part.startsWith('t=')) { |
| 113 | + timestamp = part.substring(2); |
| 114 | + } else if (part.startsWith('v1=')) { |
| 115 | + signature = part.substring(3); |
| 116 | + } |
| 117 | + }); |
| 118 | + |
| 119 | + const currentTimestamp = Math.floor(Date.now() / 1000); |
| 120 | + const eventTimestamp = parseInt(timestamp, 10); |
| 121 | + |
| 122 | + const encoder = new TextEncoder(); |
| 123 | + |
| 124 | + // Verify timestamp within a certain time window (e.g., 5 minutes) |
| 125 | + if (currentTimestamp - eventTimestamp > 300) { |
| 126 | + console.warn(`Event timestamp ${eventTimestamp} is too old. Current timestamp is ${currentTimestamp}.`); |
| 127 | + return false; // Consider the event as a replay attack |
| 128 | + } |
| 129 | + |
| 130 | + const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']); |
| 131 | + |
| 132 | + const authenticationInput = `${timestamp}.${decodeURIComponent(requestBody)}`; |
| 133 | + |
| 134 | + const signatureArrayBuffer = hexStringToArrayBuffer(signature); |
| 135 | + |
| 136 | + if (!signatureArrayBuffer) { |
| 137 | + console.warn('Failed to convert signature to ArrayBuffer.'); |
| 138 | + return false; |
| 139 | + } |
| 140 | + |
| 141 | + const verified = await crypto.subtle.verify('HMAC', key, signatureArrayBuffer, encoder.encode(authenticationInput)); |
| 142 | + |
| 143 | + if (!verified) { |
| 144 | + return false; |
| 145 | + } |
| 146 | + |
| 147 | + return true; |
26 | 148 | } |
27 | 149 |
|
28 | 150 | export default { |
29 | 151 | async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { |
30 | | - return new Response('Hello World!'); |
| 152 | + // Verify the request method and content type |
| 153 | + if (request.method !== 'POST') { |
| 154 | + return new Response('Method Not Allowed', { status: 405 }); |
| 155 | + } |
| 156 | + |
| 157 | + if (request.headers.get('Content-Type') !== 'application/json') { |
| 158 | + return new Response('Bad Request', { status: 400 }); |
| 159 | + } |
| 160 | + |
| 161 | + // Verify the request signature |
| 162 | + const signatureHeader = request.headers.get('tailscale-webhook-signature'); |
| 163 | + if (!signatureHeader) { |
| 164 | + console.warn('Missing tailscale-webhook-signature header.'); |
| 165 | + return new Response('Unauthorized', { status: 401 }); |
| 166 | + } |
| 167 | + |
| 168 | + // Verify the request signature |
| 169 | + const requestBody = await request.clone().text(); |
| 170 | + if (!(await verifyTailscaleWebhookSignature(env.TAILSCALE_WEBHOOK_SECRET, signatureHeader, requestBody))) { |
| 171 | + console.warn('Failed to verify request signature.'); |
| 172 | + return new Response('Unauthorized', { status: 401 }); |
| 173 | + } |
| 174 | + |
| 175 | + // Parse the request body |
| 176 | + const webhookBody = await parseRequest(request); |
| 177 | + |
| 178 | + try { |
| 179 | + await handleEvents(env, webhookBody); |
| 180 | + } catch (error) { |
| 181 | + console.error(`Failed to handle events: ${error}.`); |
| 182 | + return new Response('Internal Server Error', { status: 500 }); |
| 183 | + } |
| 184 | + |
| 185 | + console.info(`${webhookBody.length} events handled successfully.`); |
| 186 | + return new Response('OK', { status: 200 }); |
31 | 187 | }, |
32 | 188 | }; |
0 commit comments