Skip to content

Commit f3cc1c1

Browse files
committed
Init
1 parent 820d3d3 commit f3cc1c1

File tree

8 files changed

+234
-61
lines changed

8 files changed

+234
-61
lines changed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
name: Deploy Worker
2+
on:
3+
push:
4+
branches:
5+
- main
6+
repository_dispatch:
7+
jobs:
8+
deploy:
9+
runs-on: ubuntu-latest
10+
timeout-minutes: 10
11+
steps:
12+
- uses: actions/checkout@v4
13+
- name: Build & Deploy Worker
14+
uses: cloudflare/wrangler-action@v3
15+
with:
16+
apiToken: ${{ secrets.CF_API_TOKEN }}
17+
accountId: ${{ secrets.CF_ACCOUNT_ID }}
18+
secrets: |
19+
PUSHOVER_API_TOKEN
20+
PUSHOVER_USER_KEY
21+
TAILSCALE_WEBHOOK_SECRET
22+
env:
23+
PUSHOVER_API_TOKEN: ${{ secrets.PUSHOVER_API_TOKEN }}
24+
PUSHOVER_USER_KEY: ${{ secrets.PUSHOVER_USER_KEY }}
25+
TAILSCALE_WEBHOOK_SECRET: ${{ secrets.TAILSCALE_WEBHOOK_SECRET }}

node_modules/.cache/wrangler/user-id.json

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

node_modules/.cache/wrangler/wrangler-account.json

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

node_modules/.mf/cf.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "tailscale-webhook-receiver",
3-
"version": "0.0.0",
3+
"version": "1.0.0",
44
"private": true,
55
"scripts": {
66
"deploy": "wrangler deploy",

src/index.ts

Lines changed: 172 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,188 @@
11
/**
2-
* Welcome to Cloudflare Workers! This is your first worker.
2+
* Welcome to Cloudflare Workers!
33
*
44
* - Run `npm run dev` in your terminal to start a development server
55
* - Open a browser tab at http://localhost:8787/ to see your worker in action
66
* - Run `npm run deploy` to publish your worker
77
*
88
* Learn more at https://developers.cloudflare.com/workers/
99
*/
10+
import { TailscaleEvents, TailscaleEvent } from './types';
1011

1112
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;
26148
}
27149

28150
export default {
29151
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 });
31187
},
32188
};

src/types.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
export type TailscaleEvents = TailscaleEvent[];
2+
3+
export interface TailscaleEvent {
4+
timestamp: string;
5+
version: number;
6+
type: string;
7+
tailnet: string;
8+
message: string;
9+
data?: TailscaleEventData;
10+
}
11+
12+
export interface TailscaleEventData {
13+
user?: string;
14+
url: string;
15+
actor: string;
16+
oldRoles?: string[];
17+
newRoles?: string[];
18+
nodeID?: string;
19+
deviceName?: string;
20+
managedBy?: string;
21+
expiration?: string;
22+
newPolicy?: string;
23+
oldPolicy?: string;
24+
}

wrangler.toml

Lines changed: 2 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -5,47 +5,5 @@ compatibility_date = "2023-10-02"
55
# Variable bindings. These are arbitrary, plaintext strings (similar to environment variables)
66
# Note: Use secrets to store sensitive data.
77
# Docs: https://developers.cloudflare.com/workers/platform/environment-variables
8-
# [vars]
9-
# MY_VARIABLE = "production_value"
10-
11-
# Bind a KV Namespace. Use KV as persistent storage for small key-value pairs.
12-
# Docs: https://developers.cloudflare.com/workers/runtime-apis/kv
13-
# [[kv_namespaces]]
14-
# binding = "MY_KV_NAMESPACE"
15-
# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
16-
17-
# Bind an R2 Bucket. Use R2 to store arbitrarily large blobs of data, such as files.
18-
# Docs: https://developers.cloudflare.com/r2/api/workers/workers-api-usage/
19-
# [[r2_buckets]]
20-
# binding = "MY_BUCKET"
21-
# bucket_name = "my-bucket"
22-
23-
# Bind a Queue producer. Use this binding to schedule an arbitrary task that may be processed later by a Queue consumer.
24-
# Docs: https://developers.cloudflare.com/queues/get-started
25-
# [[queues.producers]]
26-
# binding = "MY_QUEUE"
27-
# queue = "my-queue"
28-
29-
# Bind a Queue consumer. Queue Consumers can retrieve tasks scheduled by Producers to act on them.
30-
# Docs: https://developers.cloudflare.com/queues/get-started
31-
# [[queues.consumers]]
32-
# queue = "my-queue"
33-
34-
# Bind another Worker service. Use this binding to call another Worker without network overhead.
35-
# Docs: https://developers.cloudflare.com/workers/platform/services
36-
# [[services]]
37-
# binding = "MY_SERVICE"
38-
# service = "/api/*"
39-
40-
# Bind a Durable Object. Durable objects are a scale-to-zero compute primitive based on the actor model.
41-
# Durable Objects can live for as long as needed. Use these when you need a long-running "server", such as in realtime apps.
42-
# Docs: https://developers.cloudflare.com/workers/runtime-apis/durable-objects
43-
# [[durable_objects.bindings]]
44-
# name = "MY_DURABLE_OBJECT"
45-
# class_name = "MyDurableObject"
46-
47-
# Durable Object migrations.
48-
# Docs: https://developers.cloudflare.com/workers/learning/using-durable-objects#configure-durable-object-classes-with-migrations
49-
# [[migrations]]
50-
# tag = "v1"
51-
# new_classes = ["MyDurableObject"]
8+
[vars]
9+
PUSHOVER_API_URL = "https://api.pushover.net"

0 commit comments

Comments
 (0)