├── .github └── workflows │ └── test.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── deno.json ├── deno.lock ├── import_map.json ├── mod.ts └── webhook ├── mod.ts └── mod_test.ts /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test Deno Module 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Clone code 15 | uses: actions/checkout@v3 16 | - name: Set up Deno 17 | uses: denoland/setup-deno@v1 18 | with: 19 | deno-version: v1.x 20 | - name: Lint Deno Module 21 | run: deno fmt --check 22 | - name: Build Deno Module 23 | run: deno run --reload mod.ts 24 | - name: Test Deno Module 25 | run: deno test --allow-none -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "[markdown]": { 4 | "editor.defaultFormatter": "denoland.vscode-deno" 5 | }, 6 | "[typescript]": { 7 | "editor.defaultFormatter": "denoland.vscode-deno" 8 | }, 9 | "[jsonc]": { 10 | "editor.defaultFormatter": "denoland.vscode-deno" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020 Tailscale Inc & AUTHORS. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tailscale-deno 2 | 3 | This is a collection of small utilities to help you use Tailscale with 4 | [Deno](https://deno.land). Currently it has support for 5 | [Tailscale webhooks](https://tailscale.com/kb/1213/webhooks/). See 6 | [Funnel 101: sharing your local developer preview with the world](https://tailscale.dev/blog/funnel-101) 7 | for more detail. 8 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "importMap": "./import_map.json" 3 | } 4 | -------------------------------------------------------------------------------- /deno.lock: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2", 3 | "remote": { 4 | "https://deno.land/std@0.178.0/_util/asserts.ts": "178dfc49a464aee693a7e285567b3d0b555dc805ff490505a8aae34f9cfb1462", 5 | "https://deno.land/std@0.178.0/crypto/timing_safe_equal.ts": "29a3e05afa48277ab4d9588c0b61f4afe542529302af180c866a4f2a09524169", 6 | "https://deno.land/std@0.178.0/fmt/colors.ts": "938c5d44d889fb82eff6c358bea8baa7e85950a16c9f6dae3ec3a7a729164471", 7 | "https://deno.land/std@0.178.0/testing/_diff.ts": "1a3c044aedf77647d6cac86b798c6417603361b66b54c53331b312caeb447aea", 8 | "https://deno.land/std@0.178.0/testing/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7", 9 | "https://deno.land/std@0.178.0/testing/asserts.ts": "984ab0bfb3faeed92ffaa3a6b06536c66811185328c5dd146257c702c41b01ab" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /import_map.json: -------------------------------------------------------------------------------- 1 | { 2 | "imports": { 3 | "std/": "https://deno.land/std@0.178.0/" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | import * as webhook from "./webhook/mod.ts"; 2 | 3 | export { webhook }; 4 | -------------------------------------------------------------------------------- /webhook/mod.ts: -------------------------------------------------------------------------------- 1 | import { timingSafeEqual } from "std/crypto/timing_safe_equal.ts"; 2 | 3 | export interface Payload { 4 | timestamp: string; 5 | version: number; 6 | type: string; 7 | tailnet: string; 8 | message: string; 9 | data: Node | NodeExpiration | PolicyUpdate | UserRole | null; 10 | } 11 | 12 | export interface Node { 13 | nodeID: string; 14 | deviceName: string; 15 | managedBy: string; 16 | actor: string; 17 | url: string; 18 | } 19 | 20 | export interface NodeExpiration extends Node { 21 | expiration: string; 22 | } 23 | 24 | export interface PolicyUpdate { 25 | newPolicy: string; 26 | oldPolicy: string; 27 | url: string; 28 | actor: string; 29 | } 30 | 31 | export interface UserRole { 32 | user: string; 33 | url: string; 34 | actor: string; 35 | oldRoles: string[]; 36 | newRoles: string[]; 37 | } 38 | 39 | export const splitHeader = (header: string): Record => { 40 | const result: Record = {}; 41 | header.split(",").forEach((str) => { 42 | if (!str.includes("=")) { 43 | return; 44 | } 45 | 46 | const [k, v] = str.split("="); 47 | result[k] = v; 48 | }); 49 | return result; 50 | }; 51 | 52 | export const validateSignature = async ( 53 | toSign: string, 54 | serverSigV1: string, 55 | secretKey: string, 56 | ): Promise => { 57 | const encoder = new TextEncoder(); 58 | const messageBytes = encoder.encode(toSign); 59 | const secretBytes = encoder.encode(secretKey); 60 | 61 | const key = await crypto.subtle.importKey( 62 | "raw", // raw key for hmac secret 63 | secretBytes, 64 | { 65 | name: "HMAC", 66 | hash: { name: "SHA-256" }, 67 | }, 68 | false, // is the key extractable? 69 | ["sign", "verify"], // uses of the key 70 | ); 71 | 72 | const sig = await crypto.subtle.sign("HMAC", key, messageBytes); 73 | 74 | const serverSig = hexDecode(serverSigV1); 75 | 76 | if (!crypto.subtle.verify("HMAC", key, serverSig, messageBytes)) { 77 | return false; 78 | } 79 | 80 | return timingSafeEqual(sig, serverSig); 81 | }; 82 | 83 | export const hexDecode = (inp: string): ArrayBuffer => { 84 | const bytes: number[] = []; 85 | inp.replace(/../g, (pair) => { 86 | bytes.push(parseInt(pair, 16)); 87 | return ""; 88 | }); 89 | return new Uint8Array(bytes).buffer; 90 | }; 91 | 92 | export const validate = async ( 93 | req: Request, 94 | secretKey: string, 95 | ): Promise<{ ok: boolean; body: string }> => { 96 | if (!req.headers.has("Tailscale-Webhook-Signature")) { 97 | return { ok: false, body: await req.text() }; 98 | } 99 | 100 | const body = await req.text(); 101 | 102 | const { t, v1 } = splitHeader( 103 | req.headers.get("Tailscale-Webhook-Signature") as string, 104 | ); 105 | 106 | const then = new Date(0); 107 | then.setUTCSeconds(parseInt(t, 10)); 108 | const now = new Date(); 109 | const FIVE_MIN = 5 * 60 * 1000; // 5 minutes in milliseconds 110 | if ((now.getTime() - then.getTime()) > FIVE_MIN) { 111 | return { ok: false, body }; 112 | } 113 | 114 | const stringToSign = `${t}.${body}`; 115 | return { ok: await validateSignature(stringToSign, v1, secretKey), body }; 116 | }; 117 | -------------------------------------------------------------------------------- /webhook/mod_test.ts: -------------------------------------------------------------------------------- 1 | import * as mod from "./mod.ts"; 2 | import { 3 | assert, 4 | assertEquals, 5 | } from "https://deno.land/std@0.178.0/testing/asserts.ts"; 6 | 7 | Deno.test("splitHeader splits headers right", () => { 8 | const result = mod.splitHeader( 9 | "t=1663781880,v1=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", 10 | ); 11 | assertEquals(result, { 12 | t: "1663781880", 13 | v1: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", 14 | }); 15 | }); 16 | 17 | Deno.test("validateSignature works", async () => { 18 | assert( 19 | await mod.validateSignature( 20 | "hi there", 21 | "d4f6f042ffb3ed59cf023a75065ea6c543ec034e765130eb5249a7f0eb1692f6", 22 | "foobar", 23 | ), 24 | "hmac signing doesn't work", 25 | ); 26 | }); 27 | --------------------------------------------------------------------------------