├── .devcontainer └── with-tailscale │ └── devcontainer.json ├── .github └── workflows │ └── terraform-examples.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── pulumi ├── README.md └── aws │ ├── aws-lambda-device-approval-handler │ ├── .gitignore │ ├── Pulumi.yaml │ ├── README.md │ ├── handler.ts │ ├── index.ts │ ├── package-lock.json │ ├── package.json │ └── tsconfig.json │ └── aws-lambda-device-lookup-table │ ├── .gitignore │ ├── Pulumi.yaml │ ├── README.md │ ├── handler.ts │ ├── index.ts │ ├── package-lock.json │ ├── package.json │ └── tsconfig.json └── terraform ├── Makefile ├── README.md ├── aws ├── README.md ├── aws-ec2-autoscaling-dual-subnet │ ├── README.md │ ├── assets │ │ ├── diagram-aws-ec2-autoscaling-dual-subnet.excalidraw │ │ └── diagram-aws-ec2-autoscaling-dual-subnet.png │ ├── main.tf │ ├── outputs.tf │ └── versions.tf ├── aws-ec2-autoscaling-session-recorder │ ├── README.md │ ├── main.tf │ ├── outputs.tf │ ├── scripts │ │ └── tsrecorder_docker.tftpl │ └── versions.tf ├── aws-ec2-autoscaling │ ├── README.md │ ├── main.tf │ ├── outputs.tf │ └── versions.tf ├── aws-ec2-instance-dual-stack-ipv4-ipv6 │ ├── README.md │ ├── main.tf │ ├── outputs.tf │ └── versions.tf ├── aws-ec2-instance │ ├── README.md │ ├── main.tf │ ├── outputs.tf │ └── versions.tf └── internal-modules │ ├── README.md │ ├── aws-ec2-autoscaling │ ├── README.md │ ├── main.tf │ ├── outputs.tf │ ├── variables-tailscale-install-scripts.tf │ ├── variables.tf │ └── versions.tf │ ├── aws-ec2-instance │ ├── README.md │ ├── main.tf │ ├── outputs.tf │ ├── variables-tailscale-install-scripts.tf │ ├── variables.tf │ └── versions.tf │ └── aws-vpc │ ├── README.md │ ├── main.tf │ ├── outputs.tf │ ├── variables.tf │ └── versions.tf ├── azure ├── README.md ├── azure-linux-vm │ ├── README.md │ ├── main.tf │ ├── outputs.tf │ ├── providers.tf │ ├── variables.tf │ └── versions.tf └── internal-modules │ ├── README.md │ ├── azure-linux-vm │ ├── README.md │ ├── main.tf │ ├── outputs.tf │ ├── variables-tailscale-install-scripts.tf │ ├── variables.tf │ └── versions.tf │ └── azure-network │ ├── README.md │ ├── main.tf │ ├── outputs.tf │ ├── variables.tf │ └── versions.tf ├── google ├── README.md ├── google-compute-instance │ ├── README.md │ ├── main.tf │ ├── outputs.tf │ ├── variables.tf │ └── versions.tf └── internal-modules │ ├── README.md │ ├── google-compute-instance │ ├── README.md │ ├── main.tf │ ├── outputs.tf │ ├── variables-tailscale-install-scripts.tf │ ├── variables.tf │ └── versions.tf │ └── google-vpc │ ├── README.md │ ├── main.tf │ ├── outputs.tf │ ├── variables.tf │ └── versions.tf ├── internal-modules ├── README.md ├── tailscale-advertise-routes │ ├── README.md │ ├── aws.tf │ ├── main.tf │ ├── outputs.tf │ ├── scripts │ │ ├── advertise-routes.tftpl │ │ └── get-routes-aws.tftpl │ └── variables.tf └── tailscale-install-scripts │ ├── README.md │ ├── main.tf │ ├── outputs.tf │ ├── scripts │ ├── additional-scripts │ │ ├── ethtool-udp.tftpl │ │ ├── ip-forwarding.tftpl │ │ └── netplan-dual-subnet.tftpl │ └── tailscale-ubuntu.tftpl │ ├── variables-for-modules.tf │ └── variables.tf └── repo-scripts ├── README.md ├── check-terraform-fmt.sh ├── check-variables-tailscale-install-scripts.sh ├── fix-terraform-fmt.sh └── fix-variables-tailscale-install-scripts.sh /.devcontainer/with-tailscale/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Codespace with Tailscale", 3 | "features": { 4 | "ghcr.io/tailscale/codespace/tailscale": {} 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.github/workflows/terraform-examples.yml: -------------------------------------------------------------------------------- 1 | name: Check Terraform examples 2 | 3 | on: 4 | pull_request: 5 | types: [opened, reopened, synchronize, closed] 6 | paths: 7 | - ".github/workflows/**" 8 | - "terraform/**" 9 | 10 | jobs: 11 | 12 | check-terraform-fmt: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Check out code 16 | uses: actions/checkout@v4 17 | 18 | - name: Install Terraform 19 | uses: hashicorp/setup-terraform@v2 20 | 21 | - name: Check Terraform formatting 22 | run: | 23 | bash terraform/repo-scripts/check-terraform-fmt.sh terraform 24 | 25 | check-terraform-variables-tailscale-install-scripts: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Check out code 29 | uses: actions/checkout@v4 30 | 31 | # - name: tree 32 | # working-directory: terraform 33 | # run: | 34 | # apt-get -y update 35 | # apt-get -y install tree 36 | # tree -a 37 | 38 | - name: Check variables-tailscale-install-scripts.tf files 39 | run: | 40 | bash terraform/repo-scripts/check-variables-tailscale-install-scripts.sh terraform 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Terraform-related files 2 | *.tfstate 3 | *.tfstate.* 4 | *.tfstate.lock.info 5 | **/.terraform.lock.hcl 6 | **/.terraform 7 | **/terraform.tfstate 8 | **/terraform.tfstate.backup 9 | 10 | # Terraform Variables 11 | *.tfvars 12 | 13 | # Keys 14 | *.pem 15 | *.key 16 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | Contributions are welcome! That being said, this repository is not meant to contain every combination of every infrastructure as code tool, infrastructure provider, or possible deployment type available. It contains examples for the most common of each, and they are _examples_, not production-ready configurations. 4 | 5 | ## Making a contribution 6 | 7 | - **An example** - please [open an issue](https://github.com/tailscale-dev/examples-infrastructure-as-code/issues) describing your proposed example before spending your time and resources developing the example and submitting a pull request. Contributions for unique or less common deployments may not be accepted. 8 | - **A bug fix** - please [open an issue](https://github.com/tailscale-dev/examples-infrastructure-as-code/issues) describing the bug if one does not already exist and feel free to submit a pull request with a fix. 9 | - **Have a question?** - please [open an issue](https://github.com/tailscale-dev/examples-infrastructure-as-code/issues) describing your contribution before spending your time and resources developing it. 10 | - **Something else** - please [open an issue](https://github.com/tailscale-dev/examples-infrastructure-as-code/issues) describing your contribution before spending your time and resources developing it. 11 | 12 | ## Guiding principles for this repository 13 | 14 | The examples in this repository: 15 | 16 | - Are _examples_. They are not production-ready and don't claim to be. 17 | - Are meant to be generally applicable. If you have a highly-specific use case, this repository is probably not the best place for it. 18 | - Strive to follow the [style guide](#style-guide) below. 19 | 20 | ## Style guide 21 | 22 | - Include a `README.md` file that describes the use case and the Cloud and Tailscale resources that will be created. 23 | - Use up-to-date versions of providers, modules, and third-party libraries and avoid deprecated features or arguments of providers, modules, etc. 24 | - Put all customizable options, such as VPC CIDR blocks, common resource tags, etc., in common variables: 25 | - In Terraform, use [local values](https://developer.hashicorp.com/terraform/language/values/locals) - e.g. a single `locals { }` block at the top of `main.tf`. 26 | - In other languages use idiomatic coding practices appropriate to the language - e.g. in TypeScript use [const declarations](https://www.typescriptlang.org/docs/handbook/variable-declarations.html#const-declarations) in as few blocks as possible. 27 | - Prefix all provisioned resource names with `"example-${basename(path.cwd)}"`, ideally using a local variable for the prefix. 28 | - Tag all resources with a `Name` tag matching the resource name (e.g. `"example-${basename(path.cwd)}"`). 29 | - Use community modules for undifferentiated heavy lifting, such as cloud VPCs or virtual networks. When possible, make these modules easy to remove without requiring lots of changes throughout the rest of the example. 30 | - Format your example code with `terraform fmt` or equivalent fo the language you're using. 31 | 32 | ### Mock Terraform example 33 | 34 | ```hcl 35 | // All customizable parameters in locals for easy customization. 36 | locals { 37 | // common name based on the directory name 38 | name = "example-${basename(path.cwd)}" 39 | 40 | // common tags used across all resources 41 | tags = { 42 | Name = local.name 43 | } 44 | 45 | // tailscale-specific arguments 46 | tailscale_acl_tags = [ 47 | "tag:example-infra", 48 | "tag:example-exitnode", 49 | ] 50 | tailscale_set_preferences = [ 51 | "--auto-update", 52 | "--ssh", 53 | "--advertise-connector", 54 | // ... 55 | ] 56 | 57 | // other arguments that are easily customized in one place 58 | vpc_id = module.vpc.vpc_id 59 | vpc_cidr_block = "10.0.80.0/22" 60 | vpc_public_subnet_cidr_blocks = ["10.0.80.0/24"] 61 | vpc_private_subnet_cidr_blocks = ["10.0.81.0/24"] 62 | 63 | instance_subnet_id = module.vpc.public_subnets[0] 64 | instance_security_group_ids = [fake_security_group.tailscale.id] 65 | instance_type = "c7g.medium" 66 | } 67 | 68 | // Remove this to use your own VPC. 69 | module "vpc" { 70 | source = "#url" 71 | 72 | // customize the name as applicable 73 | name = "${local.name}-primary" 74 | tags = merge(local.tags, { 75 | Name = "${local.name}-primary", 76 | }) 77 | 78 | // most critical arguments sourced from locals 79 | cidr = local.vpc_cidr_block 80 | public_subnets = local.vpc_public_subnet_cidr_blocks 81 | private_subnets = local.vpc_private_subnet_cidr_blocks 82 | } 83 | 84 | resource "fake_security_group" "main" { 85 | // customize the name as applicable 86 | name = "${local.name}-main" 87 | tags = merge(local.tags, { 88 | Name = "${local.name}-main", 89 | }) 90 | 91 | // ... 92 | } 93 | 94 | resource "fake_instance" "main" { 95 | // customize the name as applicable 96 | name = "${local.name}-main" 97 | tags = merge(local.tags, { 98 | Name = "${local.name}-main", 99 | }) 100 | 101 | // most critical arguments sourced from locals 102 | subnet_id = local.instance_subnet_id 103 | security_group_ids = local.instance_security_group_ids 104 | type = local.instance_type 105 | 106 | // ... 107 | } 108 | ``` 109 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2024, Tailscale Community 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. 29 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | default: help 2 | 3 | .PHONY: check-terraform-examples 4 | check-terraform-examples: ## Run specific 'check' github actions with https://github.com/nektos/act 5 | act -j check-terraform-fmt 6 | act -j check-variables-tailscale-install-scripts 7 | 8 | .PHONY: help 9 | help: ## Display this information. Default target. 10 | @echo "Valid targets:" 11 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-25s\033[0m %s\n", $$1, $$2}' 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # examples-infrastructure-as-code 2 | 3 | [![status: experimental](https://img.shields.io/badge/status-experimental-blue)](https://tailscale.com/kb/1167/release-stages/#experimental) 4 | 5 | Open in GitHub Codespaces with Tailscale 6 | 7 | ## Overview 8 | 9 | This repository contains Infrastructure as code (IaC) examples for common Tailscale deployments across common infrastructure providers - Amazon Web Services, Azure, and Google Cloud. 10 | 11 | ## To use 12 | 13 | Each tool-specific subdirectory in this repo contains a readme explaining its use. 14 | 15 | ## Bugs 16 | 17 | Please file any issues on [the issue tracker](./issues). 18 | 19 | ## To contribute 20 | 21 | Please refer to the [contributing guide](CONTRIBUTING.md). 22 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | You can report vulnerabilities privately to 6 | [security@tailscale.com](mailto:security@tailscale.com). Tailscale 7 | staff will triage the issue, and work with you on a coordinated 8 | disclosure timeline. 9 | -------------------------------------------------------------------------------- /pulumi/README.md: -------------------------------------------------------------------------------- 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. 14 | -------------------------------------------------------------------------------- /pulumi/aws/aws-lambda-device-approval-handler/.gitignore: -------------------------------------------------------------------------------- 1 | /bin/ 2 | /node_modules/ 3 | **/Pulumi.*.yaml 4 | -------------------------------------------------------------------------------- /pulumi/aws/aws-lambda-device-approval-handler/Pulumi.yaml: -------------------------------------------------------------------------------- 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 8 | -------------------------------------------------------------------------------- /pulumi/aws/aws-lambda-device-approval-handler/README.md: -------------------------------------------------------------------------------- 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 Pulumi 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 | ``` 30 | -------------------------------------------------------------------------------- /pulumi/aws/aws-lambda-device-approval-handler/handler.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; 2 | 3 | export async function lambdaHandler(ev: APIGatewayProxyEvent): Promise { 4 | // TODO: https://tailscale.com/kb/1213/webhooks#verifying-an-event-signature 5 | // console.log(`Received event: ${JSON.stringify(ev)}`); 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 | eventResults: { 60 | processed: processedCount, 61 | errored: erroredCount, 62 | ignored: ignoredCount, 63 | }, 64 | }), 65 | }; 66 | console.log(`returning response: ${JSON.stringify(result)}`); 67 | return result 68 | } 69 | 70 | async function unhandledHandler(event: TailnetEvent): Promise { 71 | console.log(`Ignoring event type [${event.type}]`); 72 | return { event: event, result: "IGNORED", } as ProcessingResult; 73 | } 74 | 75 | async function nodeNeedsApprovalHandler(event: TailnetEvent): Promise { 76 | try { 77 | console.log(`Handling event type [${event.type}]`); 78 | 79 | const eventData = event.data as TailnetEventDeviceData; 80 | 81 | // get device details and attributes 82 | const deviceResponse = await getDevice(eventData); 83 | if (!deviceResponse.ok) { 84 | throw new Error(`Failed to get device [${eventData.nodeID}]`); 85 | } 86 | 87 | const attributesResponse = await getDeviceAttributes(eventData); 88 | if (!attributesResponse.ok) { 89 | throw new Error(`Failed to get device attributes [${eventData.nodeID}]`); 90 | } 91 | 92 | // inspect device details 93 | const deviceResponseJson = await deviceResponse.json(); 94 | console.log(`Device response [${JSON.stringify(deviceResponseJson)}]`); 95 | const attributesResponseJson = await attributesResponse.json(); 96 | console.log(`Device attributes response [${JSON.stringify(attributesResponseJson)}]`); 97 | 98 | /** 99 | * Customize approval logic here. 100 | */ 101 | if ( 102 | ["windows", "macos", "linux"].includes(attributesResponseJson["attributes"]["node:os"]) 103 | && attributesResponseJson["attributes"]["node:tsReleaseTrack"] == "stable" 104 | ) { 105 | // authorize device 106 | await authorizeDevice(eventData); 107 | } 108 | else { 109 | console.log(`NOT authorizing device [${eventData.nodeID}:${eventData.deviceName}] with attributes [${JSON.stringify(attributesResponseJson)}]`); 110 | } 111 | 112 | return { event: event, result: "SUCCESS", } as ProcessingResult; 113 | } catch (err: any) { 114 | return { event: event, result: "ERROR", error: err, } as ProcessingResult; 115 | } 116 | } 117 | 118 | export const ENV_TAILSCALE_OAUTH_CLIENT_ID = "OAUTH_CLIENT_ID"; 119 | export const ENV_TAILSCALE_OAUTH_CLIENT_SECRET = "OAUTH_CLIENT_SECRET"; 120 | const TAILSCALE_CONTROL_URL = "https://login.tailscale.com"; 121 | 122 | // https://tailscale.com/api#tag/devices/GET/device/{deviceId}/attributes 123 | async function getDeviceAttributes(event: TailnetEventDeviceData): Promise { 124 | console.log(`Getting device attributes [${event.nodeID}]`); 125 | const data = await makeAuthenticatedRequest("GET", `${TAILSCALE_CONTROL_URL}/api/v2/device/${event.nodeID}/attributes`); 126 | if (!data.ok) { 127 | throw new Error(`Failed to get device [${event.nodeID}]`); 128 | } 129 | return data; 130 | } 131 | 132 | // https://tailscale.com/api#tag/devices/GET/device/{deviceId} 133 | async function getDevice(event: TailnetEventDeviceData): Promise { 134 | console.log(`Getting device [${event.nodeID}]`); 135 | const data = await makeAuthenticatedRequest("GET", `${TAILSCALE_CONTROL_URL}/api/v2/device/${event.nodeID}`); 136 | if (!data.ok) { 137 | throw new Error(`Failed to get device [${event.nodeID}]`); 138 | } 139 | return data; 140 | } 141 | 142 | // https://tailscale.com/api#tag/devices/POST/device/{deviceId}/authorized 143 | async function authorizeDevice(device: TailnetEventDeviceData) { 144 | console.log(`Authorizing device [${device.nodeID}:${device.deviceName}]`); 145 | const data = await makeAuthenticatedRequest("POST", `${TAILSCALE_CONTROL_URL}/api/v2/device/${device.nodeID}/authorized`, JSON.stringify({ "authorized": true })); 146 | if (!data.ok) { 147 | throw new Error(`Failed to authorize device [${device.nodeID}:${device.deviceName}]`); 148 | } 149 | } 150 | 151 | // https://tailscale.com/kb/1215/oauth-clients 152 | export async function getAccessToken(): Promise { 153 | const oauthClientId = process.env[ENV_TAILSCALE_OAUTH_CLIENT_ID]; 154 | const oauthClientSecret = process.env[ENV_TAILSCALE_OAUTH_CLIENT_SECRET]; 155 | if (!oauthClientId || !oauthClientSecret) { 156 | 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.`); 157 | } 158 | 159 | const options: RequestInit = { 160 | method: "POST", 161 | headers: { "Content-Type": "application/x-www-form-urlencoded" }, 162 | body: `client_id=${oauthClientId}&client_secret=${oauthClientSecret}`, 163 | }; 164 | 165 | // console.log(`getting access token`); 166 | const data = await httpsRequest(`${TAILSCALE_CONTROL_URL}/api/v2/oauth/token`, options); 167 | if (!data.ok) { 168 | throw new Error(`Failed to get an access token.`); 169 | } 170 | return data; 171 | } 172 | 173 | const makeAuthenticatedRequest = async function (method: "GET" | "POST", url: string, body?: string): Promise { 174 | const accessTokenResponse = await getAccessToken(); 175 | const result = await accessTokenResponse.json(); 176 | 177 | const options: RequestInit = { 178 | method: method, 179 | headers: { "Authorization": `Bearer ${result.access_token}` }, 180 | body: body, 181 | }; 182 | 183 | return await httpsRequest(url, options); 184 | } 185 | 186 | async function httpsRequest(url: string, options: any): Promise { 187 | // console.log(`Making HTTP request to [${url}] with options [${JSON.stringify(options)}]`); 188 | return await fetch(url, options); 189 | } 190 | 191 | interface TailnetEvent { 192 | timestamp: string; 193 | version: number; 194 | type: string; 195 | tailnet: string; 196 | message: string; 197 | data: any 198 | }; 199 | 200 | interface TailnetEventDeviceData { 201 | nodeID: string; 202 | deviceName: string; 203 | managedBy: string; 204 | actor: string; 205 | url: string; 206 | }; 207 | 208 | interface ProcessingResult { 209 | event: TailnetEvent; 210 | result: "SUCCESS" | "ERROR" | "IGNORED"; 211 | error?: Error; 212 | }; 213 | -------------------------------------------------------------------------------- /pulumi/aws/aws-lambda-device-approval-handler/index.ts: -------------------------------------------------------------------------------- 1 | import * as pulumi from "@pulumi/pulumi"; 2 | import * as aws from "@pulumi/aws"; 3 | import * as apigateway from "@pulumi/aws-apigateway"; 4 | import * as path from "path"; 5 | 6 | import * as handler from "./handler"; 7 | 8 | const name = `example-${path.basename(process.cwd())}`; 9 | const pulumiConfig = new pulumi.Config(); 10 | 11 | const api = new apigateway.RestAPI(name, { 12 | stageName: "tailscale-device-approval", 13 | binaryMediaTypes: ["application/json"], 14 | routes: [ 15 | { 16 | path: "/", 17 | method: "POST", 18 | eventHandler: new aws.lambda.CallbackFunction(`${name}-fn`, { 19 | environment: { 20 | variables: { 21 | [handler.ENV_TAILSCALE_OAUTH_CLIENT_ID]: pulumiConfig.require("tailscaleOauthClientId"), 22 | [handler.ENV_TAILSCALE_OAUTH_CLIENT_SECRET]: pulumiConfig.requireSecret("tailscaleOauthClientSecret"), 23 | }, 24 | }, 25 | runtime: "nodejs20.x", 26 | callback: async (ev: any, ctx) => { 27 | return handler.lambdaHandler(ev); 28 | }, 29 | }), 30 | }, 31 | ], 32 | }); 33 | 34 | export const url = api.url; 35 | -------------------------------------------------------------------------------- /pulumi/aws/aws-lambda-device-approval-handler/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tailscale-lambda-device-approval-handler", 3 | "main": "index.ts", 4 | "devDependencies": { 5 | "@types/node": "^18", 6 | "typescript": "^5.0.0" 7 | }, 8 | "dependencies": { 9 | "@pulumi/aws": "^6.56.1", 10 | "@pulumi/aws-apigateway": "^2.6.1", 11 | "@pulumi/pulumi": "^3.137.0", 12 | "@types/aws-lambda": "^8.10.140", 13 | "aws-lambda": "^1.0.7" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /pulumi/aws/aws-lambda-device-approval-handler/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "outDir": "bin", 5 | "target": "es2020", 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "sourceMap": true, 9 | "experimentalDecorators": true, 10 | "pretty": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "noImplicitReturns": true, 13 | "forceConsistentCasingInFileNames": true 14 | }, 15 | "files": [ 16 | "index.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /pulumi/aws/aws-lambda-device-lookup-table/.gitignore: -------------------------------------------------------------------------------- 1 | /bin/ 2 | /node_modules/ 3 | **/Pulumi.*.yaml 4 | -------------------------------------------------------------------------------- /pulumi/aws/aws-lambda-device-lookup-table/Pulumi.yaml: -------------------------------------------------------------------------------- 1 | name: aws-lambda-device-lookup-table 2 | runtime: nodejs 3 | description: Example of handling a Tailscale nodeCreated Webhook to generate a csv of device ids and names 4 | config: 5 | pulumi:tags: 6 | value: 7 | pulumi:template: aws-typescript 8 | -------------------------------------------------------------------------------- /pulumi/aws/aws-lambda-device-lookup-table/README.md: -------------------------------------------------------------------------------- 1 | # aws-lambda-device-lookup-table 2 | 3 | This example creates the following: 4 | 5 | - a API Gateway REST API 6 | - a Lambda function to handle `nodeCreated` [Tailscale webhooks](https://tailscale.com/kb/1213/webhooks) and generate a CSV of device data in the form: 7 | 8 | ```csv 9 | Device ID, Device Name, Associated User or ACL Tags 10 | ``` 11 | 12 | ## To use 13 | 14 | ### Customize 15 | 16 | In its current form the Lambda function prints the generated CSV to `console.log(...)` and does not persist it anywhere. Modify [`handler.ts`](./handler.ts) to push the generated CSV to your SIEM or logging tool for use as a lookup table to augment Tailscale's network flow log data - e.g. [Sumo Logic's lookup tables](https://help.sumologic.com/docs/search/lookup-tables/), [Datadog's reference tables](https://docs.datadoghq.com/integrations/guide/reference-tables/), etc. 17 | 18 | ### Configure 19 | 20 | Follow the documentation to configure the Pulumi providers: 21 | 22 | - [AWS](https://www.pulumi.com/registry/packages/aws/installation-configuration/) 23 | 24 | ### Deploy 25 | 26 | 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. 27 | 28 | ```shell 29 | pulumi stack init 30 | pulumi config set tailscaleOauthClientId 31 | pulumi config set tailscaleOauthClientSecret --secret 32 | pulumi up 33 | ``` 34 | 35 | ## To destroy 36 | 37 | ```shell 38 | pulumi down 39 | ``` 40 | -------------------------------------------------------------------------------- /pulumi/aws/aws-lambda-device-lookup-table/handler.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; 2 | 3 | export async function lambdaHandler(ev: APIGatewayProxyEvent): Promise { 4 | // TODO: https://tailscale.com/kb/1213/webhooks#verifying-an-event-signature 5 | // console.log(`Received event: ${JSON.stringify(ev)}`); 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 "nodeCreated": 21 | results.push(await nodeCreatedHandler(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 | eventResults: { 60 | processed: processedCount, 61 | errored: erroredCount, 62 | ignored: ignoredCount, 63 | }, 64 | }), 65 | }; 66 | console.log(`returning response: ${JSON.stringify(result)}`); 67 | return result 68 | } 69 | 70 | async function unhandledHandler(event: TailnetEvent): Promise { 71 | console.log(`Ignoring event type [${event.type}]`); 72 | return { event: event, result: "IGNORED", } as ProcessingResult; 73 | } 74 | 75 | async function nodeCreatedHandler(event: TailnetEvent): Promise { 76 | try { 77 | console.log(`Handling event type [${event.type}]`); 78 | 79 | const eventData = event.data as TailnetEventDeviceData; 80 | 81 | const devicesResponse = await listTailnetDevices(eventData); 82 | if (!devicesResponse.ok) { 83 | throw new Error(`Failed to list tailnet devices, response status [${devicesResponse.status}]`); 84 | } 85 | 86 | const deviceCSV = generateDeviceCSV(devicesResponse); 87 | console.log(`Devices CSV:\n${deviceCSV}`); // TODO: persist to SIEM or Logging service 88 | 89 | return { event: event, result: "SUCCESS", } as ProcessingResult; 90 | } catch (err: any) { 91 | return { event: event, result: "ERROR", error: err, } as ProcessingResult; 92 | } 93 | } 94 | 95 | export const ENV_TAILSCALE_OAUTH_CLIENT_ID = "OAUTH_CLIENT_ID"; 96 | export const ENV_TAILSCALE_OAUTH_CLIENT_SECRET = "OAUTH_CLIENT_SECRET"; 97 | const TAILSCALE_CONTROL_URL = "https://login.tailscale.com"; 98 | 99 | function parseTailnet(domain: string): string | null { 100 | const regex = /^[^.]+\.(.+\.ts\.net)$/; 101 | const match = domain.match(regex); 102 | return match ? match[1] : null; 103 | } 104 | 105 | async function generateDeviceCSV(devicesResponse: Response): Promise { 106 | const devicesResponseJson = await devicesResponse.json(); 107 | 108 | let devicesCsv = ""; 109 | devicesResponseJson["devices"].forEach((device: any) => { 110 | devicesCsv += `${device.nodeId},${device.name},${device.user || device.tags}\n`; 111 | }); 112 | 113 | return devicesCsv 114 | } 115 | 116 | // https://tailscale.com/api#tag/devices/GET/tailnet/{tailnet}/devices 117 | async function listTailnetDevices(event: TailnetEventDeviceData): Promise { 118 | const tailnet = parseTailnet(event.deviceName); 119 | console.log(`List tailnet devices for [${tailnet}]`); 120 | const data = await makeAuthenticatedRequest("GET", `${TAILSCALE_CONTROL_URL}/api/v2/tailnet/${tailnet}/devices`); 121 | return data; 122 | } 123 | 124 | // https://tailscale.com/kb/1215/oauth-clients 125 | export async function getAccessToken(): Promise { 126 | const oauthClientId = process.env[ENV_TAILSCALE_OAUTH_CLIENT_ID]; 127 | const oauthClientSecret = process.env[ENV_TAILSCALE_OAUTH_CLIENT_SECRET]; 128 | if (!oauthClientId || !oauthClientSecret) { 129 | 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.`); 130 | } 131 | 132 | const options: RequestInit = { 133 | method: "POST", 134 | headers: { "Content-Type": "application/x-www-form-urlencoded" }, 135 | body: `client_id=${oauthClientId}&client_secret=${oauthClientSecret}`, 136 | }; 137 | 138 | // console.log(`getting access token`); 139 | const data = await httpsRequest(`${TAILSCALE_CONTROL_URL}/api/v2/oauth/token`, options); 140 | if (!data.ok) { 141 | throw new Error(`Failed to get an access token.`); 142 | } 143 | return data; 144 | } 145 | 146 | const makeAuthenticatedRequest = async function (method: "GET" | "POST", url: string, body?: string): Promise { 147 | const accessTokenResponse = await getAccessToken(); 148 | const result = await accessTokenResponse.json(); 149 | 150 | const options: RequestInit = { 151 | method: method, 152 | headers: { "Authorization": `Bearer ${result.access_token}` }, 153 | body: body, 154 | }; 155 | 156 | return await httpsRequest(url, options); 157 | } 158 | 159 | async function httpsRequest(url: string, options: any): Promise { 160 | // console.log(`Making HTTP request to [${url}] with options [${JSON.stringify(options)}]`); 161 | return await fetch(url, options); 162 | } 163 | 164 | interface TailnetEvent { 165 | timestamp: string; 166 | version: number; 167 | type: string; 168 | tailnet: string; 169 | message: string; 170 | data: any 171 | }; 172 | 173 | interface TailnetEventDeviceData { 174 | nodeID: string; 175 | deviceName: string; 176 | managedBy: string; 177 | actor: string; 178 | url: string; 179 | }; 180 | 181 | interface ProcessingResult { 182 | event: TailnetEvent; 183 | result: "SUCCESS" | "ERROR" | "IGNORED"; 184 | error?: Error; 185 | }; 186 | -------------------------------------------------------------------------------- /pulumi/aws/aws-lambda-device-lookup-table/index.ts: -------------------------------------------------------------------------------- 1 | import * as pulumi from "@pulumi/pulumi"; 2 | import * as aws from "@pulumi/aws"; 3 | import * as apigateway from "@pulumi/aws-apigateway"; 4 | import * as path from "path"; 5 | 6 | import * as handler from "./handler"; 7 | 8 | const name = `example-${path.basename(process.cwd())}`; 9 | const pulumiConfig = new pulumi.Config(); 10 | 11 | const api = new apigateway.RestAPI(name, { 12 | stageName: `${name}`, 13 | binaryMediaTypes: ["application/json"], 14 | routes: [ 15 | { 16 | path: "/", 17 | method: "POST", 18 | eventHandler: new aws.lambda.CallbackFunction(`${name}-fn`, { 19 | environment: { 20 | variables: { 21 | [handler.ENV_TAILSCALE_OAUTH_CLIENT_ID]: pulumiConfig.require("tailscaleOauthClientId"), 22 | [handler.ENV_TAILSCALE_OAUTH_CLIENT_SECRET]: pulumiConfig.requireSecret("tailscaleOauthClientSecret"), 23 | }, 24 | }, 25 | runtime: "nodejs20.x", 26 | callback: async (ev: any, ctx) => { 27 | return handler.lambdaHandler(ev); 28 | }, 29 | }), 30 | }, 31 | ], 32 | }); 33 | 34 | export const url = api.url; 35 | -------------------------------------------------------------------------------- /pulumi/aws/aws-lambda-device-lookup-table/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tailscale-lambda-device-approval-handler", 3 | "main": "index.ts", 4 | "devDependencies": { 5 | "@types/node": "^18", 6 | "typescript": "^5.0.0" 7 | }, 8 | "dependencies": { 9 | "@pulumi/aws": "^6.56.1", 10 | "@pulumi/aws-apigateway": "^2.6.1", 11 | "@pulumi/pulumi": "^3.137.0", 12 | "@types/aws-lambda": "^8.10.140", 13 | "aws-lambda": "^1.0.7" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /pulumi/aws/aws-lambda-device-lookup-table/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "outDir": "bin", 5 | "target": "es2020", 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "sourceMap": true, 9 | "experimentalDecorators": true, 10 | "pretty": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "noImplicitReturns": true, 13 | "forceConsistentCasingInFileNames": true 14 | }, 15 | "files": [ 16 | "index.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /terraform/Makefile: -------------------------------------------------------------------------------- 1 | default: help 2 | 3 | .PHONY: fix-terraform-examples 4 | fix-terraform-examples: ## Fix examples-for-customers/terraform 5 | ./repo-scripts/fix-terraform-fmt.sh 6 | ./repo-scripts/fix-variables-tailscale-install-scripts.sh 7 | 8 | .PHONY: help 9 | help: ## Display this information. Default target. 10 | @echo "Valid targets:" 11 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-25s\033[0m %s\n", $$1, $$2}' 12 | -------------------------------------------------------------------------------- /terraform/README.md: -------------------------------------------------------------------------------- 1 | # terraform 2 | 3 | ## Overview 4 | 5 | This directory contains [Terraform](https://www.terraform.io) 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 Terraform - its concepts, operations, and configuration. 10 | 11 | ## To use 12 | 13 | Each provider-specific subdirectory contains a readme explaining its use. 14 | 15 | > :warning: Do not link directly to these examples and modules as a remote Terraform module. This examples change frequently and backwards-compatible changes are not guaranteed. Please fork or copy these to your own private repository. 16 | -------------------------------------------------------------------------------- /terraform/aws/README.md: -------------------------------------------------------------------------------- 1 | # aws 2 | 3 | ## Overview 4 | 5 | This directory contains [Terraform](https://www.terraform.io) examples for common Tailscale deployments on Amazon Web Services. 6 | 7 | ## Prerequisites 8 | 9 | The examples assume prior experience with Terraform and Amazon Web Services - their concepts, operations, and configuration. 10 | 11 | ## To use 12 | 13 | Each subdirectory contains a readme explaining its use. The `shared-modules` directory contains Terraform modules used by the examples. 14 | -------------------------------------------------------------------------------- /terraform/aws/aws-ec2-autoscaling-dual-subnet/README.md: -------------------------------------------------------------------------------- 1 | # aws-ec2-autoscaling-dual-subnet 2 | 3 | > :information_source: This example is intended for users that have a AWS NAT Gateway that they specifically 4 | want to route Internet-bound traffic through. A use case for this is if you have _static IP addresses_ (typically Elastic IPs) 5 | that you want or need to use for all Internet-bound traffic such as restricting access to GitHub or Snowflake 6 | to a custom allow-list of IP addrresses. 7 | 8 | ![diagram for aws-ec2-autoscaling-dual-subnet](./assets/diagram-aws-ec2-autoscaling-dual-subnet.png) 9 | 10 | This module creates the following: 11 | 12 | - a VPC and related resources including a NAT Gateway 13 | - two AWS Elastic Network Interfaces (ENI) - one for a public subnet and one for a private subnet 14 | - an EC2 Launch Template using both ENIs and a userdata script to install and configure Tailscale 15 | - the userdata script also configures custom routing on the instance to allow direct connections to the instance 16 | through the public subnet and outgoing connectivity through the VPC's NAT Gateway 17 | - an EC2 Autoscaling Group using the Launch Template with `min_size`, `max_size`, and `desired_capacity` set to `1` 18 | - a Tailnet device key to authenticate instances launched by the ASG to your Tailnet 19 | 20 | ## Considerations 21 | 22 | - The Auto Scaling Group does not define an `instance_refresh` policy as the ASG cannot do a rolling restart with externally manaaged network interfaces (ENIs) as required by this configuration. To update instances to the latest launch template, terminate instances in the ASG in the AWS Console or programmatically. This will release the ENI so the replacement instance can use it. 23 | - Any advertised routes and exit nodes must still be approved in the Tailscale Admin Console. The code can be updated to use [Auto Approvers for routes](https://tailscale.com/kb/1018/acls/#auto-approvers-for-routes-and-exit-nodes) if this is configured in your ACLs. 24 | 25 | ## To use 26 | 27 | Follow the documentation to configure the Terraform providers: 28 | 29 | - [Tailscale](https://registry.terraform.io/providers/tailscale/tailscale/latest/docs) 30 | - [AWS](https://registry.terraform.io/providers/hashicorp/aws/latest/docs) 31 | 32 | ### Deploy 33 | 34 | ```shell 35 | terraform init 36 | terraform apply 37 | ``` 38 | 39 | ## To destroy 40 | 41 | ```shell 42 | terraform destroy 43 | ``` 44 | -------------------------------------------------------------------------------- /terraform/aws/aws-ec2-autoscaling-dual-subnet/assets/diagram-aws-ec2-autoscaling-dual-subnet.excalidraw: -------------------------------------------------------------------------------- 1 | { 2 | "type": "excalidraw", 3 | "version": 2, 4 | "source": "https://excalidraw.com", 5 | "elements": [ 6 | { 7 | "type": "text", 8 | "version": 105, 9 | "versionNonce": 1132079261, 10 | "index": "aW", 11 | "isDeleted": false, 12 | "id": "UCY96GdJOlDjK6M3yUl7z", 13 | "fillStyle": "hachure", 14 | "strokeWidth": 1, 15 | "strokeStyle": "solid", 16 | "roughness": 1, 17 | "opacity": 100, 18 | "angle": 0, 19 | "x": -291.89547453599516, 20 | "y": 295.46816674362424, 21 | "strokeColor": "#1e1e1e", 22 | "backgroundColor": "transparent", 23 | "width": 36.439971923828125, 24 | "height": 25, 25 | "seed": 105486525, 26 | "groupIds": [], 27 | "frameId": null, 28 | "roundness": null, 29 | "boundElements": [], 30 | "updated": 1719438436791, 31 | "link": null, 32 | "locked": false, 33 | "fontSize": 20, 34 | "fontFamily": 1, 35 | "text": "VPC", 36 | "textAlign": "left", 37 | "verticalAlign": "top", 38 | "containerId": null, 39 | "originalText": "VPC", 40 | "autoResize": true, 41 | "lineHeight": 1.25 42 | }, 43 | { 44 | "type": "rectangle", 45 | "version": 148, 46 | "versionNonce": 1729100723, 47 | "index": "aX", 48 | "isDeleted": false, 49 | "id": "OlwdZVBIFA_vJ9lE92Yu2", 50 | "fillStyle": "hachure", 51 | "strokeWidth": 1, 52 | "strokeStyle": "solid", 53 | "roughness": 1, 54 | "opacity": 100, 55 | "angle": 0, 56 | "x": -244.63766203599516, 57 | "y": 388.21426049362424, 58 | "strokeColor": "#1e1e1e", 59 | "backgroundColor": "#b2f2bb", 60 | "width": 399, 61 | "height": 159, 62 | "seed": 1114732829, 63 | "groupIds": [], 64 | "frameId": null, 65 | "roundness": { 66 | "type": 3 67 | }, 68 | "boundElements": [], 69 | "updated": 1719438436791, 70 | "link": null, 71 | "locked": false 72 | }, 73 | { 74 | "type": "rectangle", 75 | "version": 241, 76 | "versionNonce": 1321536765, 77 | "index": "aY", 78 | "isDeleted": false, 79 | "id": "wY3CWINKYOXEEd0slhaC2", 80 | "fillStyle": "hachure", 81 | "strokeWidth": 1, 82 | "strokeStyle": "solid", 83 | "roughness": 1, 84 | "opacity": 100, 85 | "angle": 0, 86 | "x": -242.87203703599516, 87 | "y": 563.2142604936242, 88 | "strokeColor": "#1e1e1e", 89 | "backgroundColor": "#ffec99", 90 | "width": 399, 91 | "height": 159, 92 | "seed": 58609021, 93 | "groupIds": [], 94 | "frameId": null, 95 | "roundness": { 96 | "type": 3 97 | }, 98 | "boundElements": [], 99 | "updated": 1719438436791, 100 | "link": null, 101 | "locked": false 102 | }, 103 | { 104 | "type": "text", 105 | "version": 283, 106 | "versionNonce": 899419475, 107 | "index": "aZ", 108 | "isDeleted": false, 109 | "id": "i8ux7JldglWRqYgaQM1QW", 110 | "fillStyle": "hachure", 111 | "strokeWidth": 1, 112 | "strokeStyle": "solid", 113 | "roughness": 1, 114 | "opacity": 100, 115 | "angle": 4.71238898038469, 116 | "x": -332.6375933714444, 117 | "y": 454.214329158175, 118 | "strokeColor": "#1e1e1e", 119 | "backgroundColor": "transparent", 120 | "width": 129.67987060546875, 121 | "height": 25, 122 | "seed": 1219597789, 123 | "groupIds": [], 124 | "frameId": null, 125 | "roundness": null, 126 | "boundElements": [], 127 | "updated": 1719438436791, 128 | "link": null, 129 | "locked": false, 130 | "fontSize": 20, 131 | "fontFamily": 1, 132 | "text": "Public Subnet", 133 | "textAlign": "left", 134 | "verticalAlign": "top", 135 | "containerId": null, 136 | "originalText": "Public Subnet", 137 | "autoResize": true, 138 | "lineHeight": 1.25 139 | }, 140 | { 141 | "type": "text", 142 | "version": 383, 143 | "versionNonce": 200566109, 144 | "index": "aa", 145 | "isDeleted": false, 146 | "id": "4ymLWeOsU7n7ZZaLqb82Z", 147 | "fillStyle": "hachure", 148 | "strokeWidth": 1, 149 | "strokeStyle": "solid", 150 | "roughness": 1, 151 | "opacity": 100, 152 | "angle": 4.71238898038469, 153 | "x": -337.5475894041592, 154 | "y": 623.3043331254602, 155 | "strokeColor": "#1e1e1e", 156 | "backgroundColor": "transparent", 157 | "width": 147.49986267089844, 158 | "height": 25, 159 | "seed": 756863549, 160 | "groupIds": [], 161 | "frameId": null, 162 | "roundness": null, 163 | "boundElements": [], 164 | "updated": 1719438436791, 165 | "link": null, 166 | "locked": false, 167 | "fontSize": 20, 168 | "fontFamily": 1, 169 | "text": "Private Subnet", 170 | "textAlign": "left", 171 | "verticalAlign": "top", 172 | "containerId": null, 173 | "originalText": "Private Subnet", 174 | "autoResize": true, 175 | "lineHeight": 1.25 176 | }, 177 | { 178 | "type": "rectangle", 179 | "version": 210, 180 | "versionNonce": 670362355, 181 | "index": "ab", 182 | "isDeleted": false, 183 | "id": "FDZ39cZRm9-Ct19Nr-WaF", 184 | "fillStyle": "solid", 185 | "strokeWidth": 1, 186 | "strokeStyle": "solid", 187 | "roughness": 1, 188 | "opacity": 100, 189 | "angle": 0, 190 | "x": -214.2852399546299, 191 | "y": 501.7473059240673, 192 | "strokeColor": "#1e1e1e", 193 | "backgroundColor": "#ffffff", 194 | "width": 183, 195 | "height": 112, 196 | "seed": 1839301277, 197 | "groupIds": [], 198 | "frameId": null, 199 | "roundness": { 200 | "type": 3 201 | }, 202 | "boundElements": [ 203 | { 204 | "type": "text", 205 | "id": "s1YQtc3gZLYfMduD0JROA" 206 | }, 207 | { 208 | "id": "4lciJNhcvcqiU-FwOwXgm", 209 | "type": "arrow" 210 | } 211 | ], 212 | "updated": 1719438436791, 213 | "link": null, 214 | "locked": false 215 | }, 216 | { 217 | "type": "text", 218 | "version": 213, 219 | "versionNonce": 188678013, 220 | "index": "ac", 221 | "isDeleted": false, 222 | "id": "s1YQtc3gZLYfMduD0JROA", 223 | "fillStyle": "solid", 224 | "strokeWidth": 1, 225 | "strokeStyle": "solid", 226 | "roughness": 1, 227 | "opacity": 100, 228 | "angle": 0, 229 | "x": -191.50518014017678, 230 | "y": 532.7473059240673, 231 | "strokeColor": "#1e1e1e", 232 | "backgroundColor": "#ffffff", 233 | "width": 137.43988037109375, 234 | "height": 50, 235 | "seed": 787556093, 236 | "groupIds": [], 237 | "frameId": null, 238 | "roundness": null, 239 | "boundElements": [], 240 | "updated": 1719438449615, 241 | "link": null, 242 | "locked": false, 243 | "fontSize": 20, 244 | "fontFamily": 1, 245 | "text": "Tailscale\nApp Connector", 246 | "textAlign": "center", 247 | "verticalAlign": "middle", 248 | "containerId": "FDZ39cZRm9-Ct19Nr-WaF", 249 | "originalText": "Tailscale\nApp Connector", 250 | "autoResize": true, 251 | "lineHeight": 1.25 252 | }, 253 | { 254 | "type": "diamond", 255 | "version": 146, 256 | "versionNonce": 1117852819, 257 | "index": "ad", 258 | "isDeleted": false, 259 | "id": "TbYffIAaUHLROZQLip0O1", 260 | "fillStyle": "solid", 261 | "strokeWidth": 1, 262 | "strokeStyle": "solid", 263 | "roughness": 1, 264 | "opacity": 100, 265 | "angle": 0, 266 | "x": -3.637662035995163, 267 | "y": 322.21426049362424, 268 | "strokeColor": "#1e1e1e", 269 | "backgroundColor": "#ffffff", 270 | "width": 128, 271 | "height": 128, 272 | "seed": 59588445, 273 | "groupIds": [], 274 | "frameId": null, 275 | "roundness": { 276 | "type": 2 277 | }, 278 | "boundElements": [ 279 | { 280 | "type": "text", 281 | "id": "LEV3HXZYCiV2uZ_j8FSK_" 282 | }, 283 | { 284 | "id": "dqJloQuPSkLjlq4MOLNka", 285 | "type": "arrow" 286 | } 287 | ], 288 | "updated": 1719438436791, 289 | "link": null, 290 | "locked": false 291 | }, 292 | { 293 | "type": "text", 294 | "version": 79, 295 | "versionNonce": 51802653, 296 | "index": "ae", 297 | "isDeleted": false, 298 | "id": "LEV3HXZYCiV2uZ_j8FSK_", 299 | "fillStyle": "solid", 300 | "strokeWidth": 1, 301 | "strokeStyle": "solid", 302 | "roughness": 1, 303 | "opacity": 100, 304 | "angle": 0, 305 | "x": 39.322352307266556, 306 | "y": 373.71426049362424, 307 | "strokeColor": "#1e1e1e", 308 | "backgroundColor": "#ffffff", 309 | "width": 42.07997131347656, 310 | "height": 25, 311 | "seed": 835144637, 312 | "groupIds": [], 313 | "frameId": null, 314 | "roundness": null, 315 | "boundElements": [], 316 | "updated": 1719438436791, 317 | "link": null, 318 | "locked": false, 319 | "fontSize": 20, 320 | "fontFamily": 1, 321 | "text": "NAT", 322 | "textAlign": "center", 323 | "verticalAlign": "middle", 324 | "containerId": "TbYffIAaUHLROZQLip0O1", 325 | "originalText": "NAT", 326 | "autoResize": true, 327 | "lineHeight": 1.25 328 | }, 329 | { 330 | "type": "ellipse", 331 | "version": 734, 332 | "versionNonce": 894896691, 333 | "index": "af", 334 | "isDeleted": false, 335 | "id": "cLmj-y98IBXhXmbOl8QMR", 336 | "fillStyle": "solid", 337 | "strokeWidth": 1, 338 | "strokeStyle": "solid", 339 | "roughness": 1, 340 | "opacity": 100, 341 | "angle": 0, 342 | "x": -164.2852399546298, 343 | "y": 464.024812527756, 344 | "strokeColor": "#1e1e1e", 345 | "backgroundColor": "#ffffff", 346 | "width": 81, 347 | "height": 49, 348 | "seed": 968936477, 349 | "groupIds": [], 350 | "frameId": null, 351 | "roundness": { 352 | "type": 2 353 | }, 354 | "boundElements": [ 355 | { 356 | "type": "text", 357 | "id": "2aII4lGjupmh7Te3bAQF9" 358 | }, 359 | { 360 | "id": "fIinrfejF9MDIuwmsaF6S", 361 | "type": "arrow" 362 | } 363 | ], 364 | "updated": 1719438436791, 365 | "link": null, 366 | "locked": false 367 | }, 368 | { 369 | "type": "text", 370 | "version": 914, 371 | "versionNonce": 627991741, 372 | "index": "ag", 373 | "isDeleted": false, 374 | "id": "2aII4lGjupmh7Te3bAQF9", 375 | "fillStyle": "solid", 376 | "strokeWidth": 1, 377 | "strokeStyle": "solid", 378 | "roughness": 1, 379 | "opacity": 100, 380 | "angle": 0, 381 | "x": -145.8930429252045, 382 | "y": 476.20069638868557, 383 | "strokeColor": "#1e1e1e", 384 | "backgroundColor": "#ffffff", 385 | "width": 43.93995666503906, 386 | "height": 25, 387 | "seed": 460013693, 388 | "groupIds": [], 389 | "frameId": null, 390 | "roundness": null, 391 | "boundElements": [], 392 | "updated": 1719438436794, 393 | "link": null, 394 | "locked": false, 395 | "fontSize": 20, 396 | "fontFamily": 1, 397 | "text": "ens6", 398 | "textAlign": "center", 399 | "verticalAlign": "middle", 400 | "containerId": "cLmj-y98IBXhXmbOl8QMR", 401 | "originalText": "ens6", 402 | "autoResize": true, 403 | "lineHeight": 1.25 404 | }, 405 | { 406 | "type": "ellipse", 407 | "version": 853, 408 | "versionNonce": 1962757075, 409 | "index": "ah", 410 | "isDeleted": false, 411 | "id": "X6iHMvRD9iSSK46ySaRx2", 412 | "fillStyle": "solid", 413 | "strokeWidth": 1, 414 | "strokeStyle": "solid", 415 | "roughness": 1, 416 | "opacity": 100, 417 | "angle": 0, 418 | "x": -75.65962981292932, 419 | "y": 583.9367538899355, 420 | "strokeColor": "#1e1e1e", 421 | "backgroundColor": "#ffffff", 422 | "width": 81, 423 | "height": 49, 424 | "seed": 1552427229, 425 | "groupIds": [], 426 | "frameId": null, 427 | "roundness": { 428 | "type": 2 429 | }, 430 | "boundElements": [ 431 | { 432 | "type": "text", 433 | "id": "EV0tFI3dhCVsIV9-avy6H" 434 | }, 435 | { 436 | "id": "4lciJNhcvcqiU-FwOwXgm", 437 | "type": "arrow" 438 | } 439 | ], 440 | "updated": 1719438436791, 441 | "link": null, 442 | "locked": false 443 | }, 444 | { 445 | "type": "text", 446 | "version": 1040, 447 | "versionNonce": 991741213, 448 | "index": "ai", 449 | "isDeleted": false, 450 | "id": "EV0tFI3dhCVsIV9-avy6H", 451 | "fillStyle": "solid", 452 | "strokeWidth": 1, 453 | "strokeStyle": "solid", 454 | "roughness": 1, 455 | "opacity": 100, 456 | "angle": 0, 457 | "x": -57.04743156280091, 458 | "y": 596.1126377508651, 459 | "strokeColor": "#1e1e1e", 460 | "backgroundColor": "#ffffff", 461 | "width": 43.49995422363281, 462 | "height": 25, 463 | "seed": 1319699773, 464 | "groupIds": [], 465 | "frameId": null, 466 | "roundness": null, 467 | "boundElements": [], 468 | "updated": 1719438436794, 469 | "link": null, 470 | "locked": false, 471 | "fontSize": 20, 472 | "fontFamily": 1, 473 | "text": "ens5", 474 | "textAlign": "center", 475 | "verticalAlign": "middle", 476 | "containerId": "X6iHMvRD9iSSK46ySaRx2", 477 | "originalText": "ens5", 478 | "autoResize": true, 479 | "lineHeight": 1.25 480 | }, 481 | { 482 | "type": "text", 483 | "version": 920, 484 | "versionNonce": 363485555, 485 | "index": "aj", 486 | "isDeleted": false, 487 | "id": "sMLmiNSxAfYCE_VGSI79m", 488 | "fillStyle": "solid", 489 | "strokeWidth": 1, 490 | "strokeStyle": "solid", 491 | "roughness": 2, 492 | "opacity": 100, 493 | "angle": 0, 494 | "x": 19.136172192520462, 495 | "y": 179.46294823776486, 496 | "strokeColor": "#1e1e1e", 497 | "backgroundColor": "#ffffff", 498 | "width": 82.65992736816406, 499 | "height": 25, 500 | "seed": 355423645, 501 | "groupIds": [], 502 | "frameId": null, 503 | "roundness": null, 504 | "boundElements": [ 505 | { 506 | "id": "dqJloQuPSkLjlq4MOLNka", 507 | "type": "arrow" 508 | } 509 | ], 510 | "updated": 1719438436791, 511 | "link": null, 512 | "locked": false, 513 | "fontSize": 20, 514 | "fontFamily": 1, 515 | "text": "Internet", 516 | "textAlign": "left", 517 | "verticalAlign": "top", 518 | "containerId": null, 519 | "originalText": "Internet", 520 | "autoResize": true, 521 | "lineHeight": 1.25 522 | }, 523 | { 524 | "type": "text", 525 | "version": 500, 526 | "versionNonce": 869821245, 527 | "index": "ak", 528 | "isDeleted": false, 529 | "id": "Ro6gyiFbVtEsDtQhVHBfY", 530 | "fillStyle": "solid", 531 | "strokeWidth": 1, 532 | "strokeStyle": "solid", 533 | "roughness": 2, 534 | "opacity": 100, 535 | "angle": 0, 536 | "x": -154.53179655747954, 537 | "y": 184.72076073776486, 538 | "strokeColor": "#1e1e1e", 539 | "backgroundColor": "#ffffff", 540 | "width": 70.63993835449219, 541 | "height": 25, 542 | "seed": 1800182269, 543 | "groupIds": [], 544 | "frameId": null, 545 | "roundness": null, 546 | "boundElements": [ 547 | { 548 | "id": "fIinrfejF9MDIuwmsaF6S", 549 | "type": "arrow" 550 | } 551 | ], 552 | "updated": 1719438436791, 553 | "link": null, 554 | "locked": false, 555 | "fontSize": 20, 556 | "fontFamily": 1, 557 | "text": "Tailnet", 558 | "textAlign": "left", 559 | "verticalAlign": "top", 560 | "containerId": null, 561 | "originalText": "Tailnet", 562 | "autoResize": true, 563 | "lineHeight": 1.25 564 | }, 565 | { 566 | "type": "arrow", 567 | "version": 2058, 568 | "versionNonce": 1688515347, 569 | "index": "al", 570 | "isDeleted": false, 571 | "id": "dqJloQuPSkLjlq4MOLNka", 572 | "fillStyle": "solid", 573 | "strokeWidth": 1, 574 | "strokeStyle": "solid", 575 | "roughness": 1, 576 | "opacity": 100, 577 | "angle": 0, 578 | "x": 61.04127415127277, 579 | "y": 298.912576868519, 580 | "strokeColor": "#1e1e1e", 581 | "backgroundColor": "#ffffff", 582 | "width": 1.443560467840598, 583 | "height": 92.44962863075415, 584 | "seed": 704795229, 585 | "groupIds": [], 586 | "frameId": null, 587 | "roundness": { 588 | "type": 2 589 | }, 590 | "boundElements": [], 591 | "updated": 1719438436791, 592 | "link": null, 593 | "locked": false, 594 | "startBinding": { 595 | "elementId": "cHPkS5EsVoh5BPgFFsUT8", 596 | "focus": 0.015406794151970491, 597 | "gap": 5.254328458744446 598 | }, 599 | "endBinding": { 600 | "elementId": "sMLmiNSxAfYCE_VGSI79m", 601 | "focus": 0.02636555092012574, 602 | "gap": 2 603 | }, 604 | "lastCommittedPoint": null, 605 | "startArrowhead": null, 606 | "endArrowhead": "arrow", 607 | "points": [ 608 | [ 609 | 0, 610 | 0 611 | ], 612 | [ 613 | -1.443560467840598, 614 | -92.44962863075415 615 | ] 616 | ] 617 | }, 618 | { 619 | "type": "arrow", 620 | "version": 814, 621 | "versionNonce": 729938845, 622 | "index": "am", 623 | "isDeleted": false, 624 | "id": "fIinrfejF9MDIuwmsaF6S", 625 | "fillStyle": "solid", 626 | "strokeWidth": 1, 627 | "strokeStyle": "solid", 628 | "roughness": 1, 629 | "opacity": 100, 630 | "angle": 0, 631 | "x": -118.77152731429601, 632 | "y": 212.07318281913012, 633 | "strokeColor": "#1e1e1e", 634 | "backgroundColor": "#ffffff", 635 | "width": 2.722756349132055, 636 | "height": 249.9336680229984, 637 | "seed": 237108925, 638 | "groupIds": [], 639 | "frameId": null, 640 | "roundness": { 641 | "type": 2 642 | }, 643 | "boundElements": [], 644 | "updated": 1719438436791, 645 | "link": null, 646 | "locked": false, 647 | "startBinding": { 648 | "elementId": "Ro6gyiFbVtEsDtQhVHBfY", 649 | "focus": -0.016982465899330994, 650 | "gap": 2.352422081365262 651 | }, 652 | "endBinding": { 653 | "elementId": "cLmj-y98IBXhXmbOl8QMR", 654 | "focus": 0.049432799365621484, 655 | "gap": 2.056470287828315 656 | }, 657 | "lastCommittedPoint": null, 658 | "startArrowhead": null, 659 | "endArrowhead": "arrow", 660 | "points": [ 661 | [ 662 | 0, 663 | 0 664 | ], 665 | [ 666 | -2.722756349132055, 667 | 249.9336680229984 668 | ] 669 | ] 670 | }, 671 | { 672 | "type": "arrow", 673 | "version": 255, 674 | "versionNonce": 1113774259, 675 | "index": "an", 676 | "isDeleted": false, 677 | "id": "4lciJNhcvcqiU-FwOwXgm", 678 | "fillStyle": "solid", 679 | "strokeWidth": 1, 680 | "strokeStyle": "solid", 681 | "roughness": 1, 682 | "opacity": 100, 683 | "angle": 0, 684 | "x": -17.915402689395705, 685 | "y": 585.2136593892355, 686 | "strokeColor": "#1e1e1e", 687 | "backgroundColor": "#ffffff", 688 | "width": 74.38321447508974, 689 | "height": 142.6794907440331, 690 | "seed": 1045241629, 691 | "groupIds": [], 692 | "frameId": null, 693 | "roundness": { 694 | "type": 2 695 | }, 696 | "boundElements": [], 697 | "updated": 1719438436791, 698 | "link": null, 699 | "locked": false, 700 | "startBinding": { 701 | "elementId": "X6iHMvRD9iSSK46ySaRx2", 702 | "focus": 0.12097377146303424, 703 | "gap": 1.015305220082741 704 | }, 705 | "endBinding": null, 706 | "lastCommittedPoint": null, 707 | "startArrowhead": null, 708 | "endArrowhead": "arrow", 709 | "points": [ 710 | [ 711 | 0, 712 | 0 713 | ], 714 | [ 715 | 74.38321447508974, 716 | -142.6794907440331 717 | ] 718 | ] 719 | }, 720 | { 721 | "type": "rectangle", 722 | "version": 359, 723 | "versionNonce": 744128509, 724 | "index": "ao", 725 | "isDeleted": false, 726 | "id": "VNTbtogTpQa9SfqhRygVh", 727 | "fillStyle": "hachure", 728 | "strokeWidth": 1, 729 | "strokeStyle": "solid", 730 | "roughness": 1, 731 | "opacity": 100, 732 | "angle": 0, 733 | "x": -315.60250578599516, 734 | "y": 269.44797936569455, 735 | "strokeColor": "#1e1e1e", 736 | "backgroundColor": "transparent", 737 | "width": 627, 738 | "height": 484.00000000000006, 739 | "seed": 284504957, 740 | "groupIds": [], 741 | "frameId": null, 742 | "roundness": { 743 | "type": 3 744 | }, 745 | "boundElements": [], 746 | "updated": 1719438436791, 747 | "link": null, 748 | "locked": false 749 | }, 750 | { 751 | "type": "ellipse", 752 | "version": 765, 753 | "versionNonce": 1015761491, 754 | "index": "ap", 755 | "isDeleted": false, 756 | "id": "cHPkS5EsVoh5BPgFFsUT8", 757 | "fillStyle": "solid", 758 | "strokeWidth": 1, 759 | "strokeStyle": "solid", 760 | "roughness": 1, 761 | "opacity": 100, 762 | "angle": 0, 763 | "x": 20.381869214004837, 764 | "y": 304.16672936569455, 765 | "strokeColor": "#1e1e1e", 766 | "backgroundColor": "#ffffff", 767 | "width": 81, 768 | "height": 49, 769 | "seed": 308001757, 770 | "groupIds": [], 771 | "frameId": null, 772 | "roundness": { 773 | "type": 2 774 | }, 775 | "boundElements": [ 776 | { 777 | "type": "text", 778 | "id": "DsgLC0n6qXuUnKCooQXSJ" 779 | }, 780 | { 781 | "id": "dqJloQuPSkLjlq4MOLNka", 782 | "type": "arrow" 783 | } 784 | ], 785 | "updated": 1719438436791, 786 | "link": null, 787 | "locked": false 788 | }, 789 | { 790 | "type": "text", 791 | "version": 948, 792 | "versionNonce": 1625299325, 793 | "index": "aq", 794 | "isDeleted": false, 795 | "id": "DsgLC0n6qXuUnKCooQXSJ", 796 | "fillStyle": "solid", 797 | "strokeWidth": 1, 798 | "strokeStyle": "solid", 799 | "roughness": 1, 800 | "opacity": 100, 801 | "angle": 0, 802 | "x": 41.94405678298091, 803 | "y": 316.3426132266241, 804 | "strokeColor": "#1e1e1e", 805 | "backgroundColor": "#ffffff", 806 | "width": 37.5999755859375, 807 | "height": 25, 808 | "seed": 1251096637, 809 | "groupIds": [], 810 | "frameId": null, 811 | "roundness": null, 812 | "boundElements": [], 813 | "updated": 1719438436794, 814 | "link": null, 815 | "locked": false, 816 | "fontSize": 20, 817 | "fontFamily": 1, 818 | "text": "EIP", 819 | "textAlign": "center", 820 | "verticalAlign": "middle", 821 | "containerId": "cHPkS5EsVoh5BPgFFsUT8", 822 | "originalText": "EIP", 823 | "autoResize": true, 824 | "lineHeight": 1.25 825 | } 826 | ], 827 | "appState": { 828 | "gridSize": null, 829 | "viewBackgroundColor": "#ffffff" 830 | }, 831 | "files": {} 832 | } -------------------------------------------------------------------------------- /terraform/aws/aws-ec2-autoscaling-dual-subnet/assets/diagram-aws-ec2-autoscaling-dual-subnet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale-dev/examples-infrastructure-as-code/361a28c325e6c7b0fb976a281ced51c2539d86b3/terraform/aws/aws-ec2-autoscaling-dual-subnet/assets/diagram-aws-ec2-autoscaling-dual-subnet.png -------------------------------------------------------------------------------- /terraform/aws/aws-ec2-autoscaling-dual-subnet/main.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | name = "example-${basename(path.cwd)}" 3 | 4 | aws_tags = { 5 | Name = local.name 6 | } 7 | 8 | tailscale_acl_tags = [ 9 | "tag:example-infra", 10 | "tag:example-exitnode", 11 | "tag:example-subnetrouter", 12 | "tag:example-appconnector", 13 | ] 14 | tailscale_set_preferences = [ 15 | "--auto-update", 16 | "--ssh", 17 | "--advertise-connector", 18 | "--advertise-exit-node", 19 | "--advertise-routes=${join(",", [ 20 | local.vpc_cidr_block, 21 | ])}", 22 | ] 23 | 24 | // Modify these to use your own VPC 25 | vpc_cidr_block = module.vpc.vpc_cidr_block 26 | vpc_id = module.vpc.vpc_id 27 | subnet_id = module.vpc.public_subnets[0] 28 | private_subnet_id = module.vpc.private_subnets[0] 29 | security_group_ids = [aws_security_group.tailscale.id] 30 | instance_type = "c7g.medium" 31 | } 32 | 33 | // Remove this to use your own VPC. 34 | module "vpc" { 35 | source = "../internal-modules/aws-vpc" 36 | 37 | name = local.name 38 | tags = local.aws_tags 39 | 40 | cidr = "10.0.80.0/22" 41 | 42 | public_subnets = ["10.0.80.0/24"] 43 | private_subnets = ["10.0.81.0/24"] 44 | } 45 | 46 | resource "tailscale_tailnet_key" "main" { 47 | ephemeral = true 48 | preauthorized = true 49 | reusable = true 50 | recreate_if_invalid = "always" 51 | tags = local.tailscale_acl_tags 52 | } 53 | 54 | resource "aws_network_interface" "primary" { 55 | subnet_id = local.subnet_id 56 | security_groups = local.security_group_ids 57 | tags = merge(local.aws_tags, { Name = "${local.name}-primary" }) 58 | } 59 | resource "aws_eip" "primary" { 60 | tags = local.aws_tags 61 | } 62 | resource "aws_eip_association" "primary" { 63 | network_interface_id = aws_network_interface.primary.id 64 | allocation_id = aws_eip.primary.id 65 | } 66 | 67 | resource "aws_network_interface" "secondary" { 68 | subnet_id = local.private_subnet_id 69 | security_groups = local.security_group_ids 70 | tags = merge(local.aws_tags, { Name = "${local.name}-secondary" }) 71 | } 72 | 73 | module "tailscale_aws_ec2_autoscaling" { 74 | source = "../internal-modules/aws-ec2-autoscaling/" 75 | 76 | autoscaling_group_name = local.name 77 | instance_type = local.instance_type 78 | instance_tags = local.aws_tags 79 | 80 | network_interfaces = [ 81 | aws_network_interface.primary.id, # first NIC must be in PUBLIC subnet 82 | aws_network_interface.secondary.id, 83 | ] 84 | 85 | # Variables for Tailscale resources 86 | tailscale_hostname = local.name 87 | tailscale_auth_key = tailscale_tailnet_key.main.key 88 | tailscale_set_preferences = local.tailscale_set_preferences 89 | 90 | depends_on = [ 91 | module.vpc.nat_ids, # remove if using your own VPC otherwise ensure provisioned NAT gateway is available 92 | ] 93 | } 94 | 95 | resource "aws_security_group" "tailscale" { 96 | vpc_id = local.vpc_id 97 | name = local.name 98 | } 99 | 100 | resource "aws_security_group_rule" "tailscale_ingress" { 101 | security_group_id = aws_security_group.tailscale.id 102 | type = "ingress" 103 | from_port = 41641 104 | to_port = 41641 105 | protocol = "udp" 106 | cidr_blocks = ["0.0.0.0/0"] 107 | ipv6_cidr_blocks = ["::/0"] 108 | } 109 | 110 | resource "aws_security_group_rule" "egress" { 111 | security_group_id = aws_security_group.tailscale.id 112 | type = "egress" 113 | from_port = 0 114 | to_port = 0 115 | protocol = "-1" 116 | cidr_blocks = ["0.0.0.0/0"] 117 | ipv6_cidr_blocks = ["::/0"] 118 | } 119 | 120 | resource "aws_security_group_rule" "internal_vpc_ingress_ipv4" { 121 | security_group_id = aws_security_group.tailscale.id 122 | type = "ingress" 123 | from_port = 0 124 | to_port = 0 125 | protocol = "-1" 126 | cidr_blocks = [local.vpc_cidr_block] 127 | } 128 | -------------------------------------------------------------------------------- /terraform/aws/aws-ec2-autoscaling-dual-subnet/outputs.tf: -------------------------------------------------------------------------------- 1 | output "vpc_id" { 2 | value = module.vpc.vpc_id 3 | } 4 | 5 | output "nat_public_ips" { 6 | value = module.vpc.nat_public_ips 7 | } 8 | 9 | output "autoscaling_group_name" { 10 | value = module.tailscale_aws_ec2_autoscaling.autoscaling_group_name 11 | } 12 | 13 | output "user_data_md5" { 14 | description = "MD5 hash of the VM user_data script - for detecting changes" 15 | value = module.tailscale_aws_ec2_autoscaling.user_data_md5 16 | sensitive = true 17 | } 18 | -------------------------------------------------------------------------------- /terraform/aws/aws-ec2-autoscaling-dual-subnet/versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | tailscale = { 4 | source = "tailscale/tailscale" 5 | version = ">= 0.13.13" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /terraform/aws/aws-ec2-autoscaling-session-recorder/README.md: -------------------------------------------------------------------------------- 1 | # aws-ec2-autoscaling-session-recorder 2 | 3 | This example creates the following: 4 | 5 | - a VPC and related resources including a NAT Gateway 6 | - an EC2 Launch Template and a userdata script to install and configure Tailscale and a [Tailscale SSH session recording container](https://tailscale.com/kb/1246/tailscale-ssh-session-recording) 7 | - an EC2 Autoscaling Group (ASG) using the Launch Template with `min_size`, `max_size`, and `desired_capacity` set to `1` 8 | - a Tailnet device key to authenticate instances launched by the ASG to your Tailnet 9 | 10 | ## To use 11 | 12 | Follow the documentation to configure the Terraform providers: 13 | 14 | - [Tailscale](https://registry.terraform.io/providers/tailscale/tailscale/latest/docs) 15 | - [AWS](https://registry.terraform.io/providers/hashicorp/aws/latest/docs) 16 | 17 | ### Deploy 18 | 19 | ```shell 20 | terraform init 21 | terraform apply 22 | ``` 23 | 24 | ## To destroy 25 | 26 | ```shell 27 | terraform destroy 28 | ``` 29 | -------------------------------------------------------------------------------- /terraform/aws/aws-ec2-autoscaling-session-recorder/main.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | name = "example-${basename(path.cwd)}" 3 | 4 | aws_tags = { 5 | Name = local.name 6 | } 7 | 8 | tailscale_acl_tags = [ 9 | "tag:example-infra", 10 | ] 11 | tailscale_set_preferences = [ 12 | "--auto-update", 13 | "--ssh", 14 | ] 15 | 16 | // Modify these to use your own VPC 17 | vpc_cidr_block = module.vpc.vpc_cidr_block 18 | vpc_id = module.vpc.vpc_id 19 | subnet_id = module.vpc.public_subnets[0] 20 | security_group_ids = [aws_security_group.tailscale.id] 21 | instance_type = "c7g.medium" 22 | vpc_endpoint_route_table_ids = flatten([ 23 | module.vpc.public_route_table_ids, 24 | module.vpc.private_route_table_ids, 25 | ]) 26 | } 27 | 28 | // Remove this to use your own VPC. 29 | module "vpc" { 30 | source = "../internal-modules/aws-vpc" 31 | 32 | name = local.name 33 | tags = local.aws_tags 34 | 35 | cidr = "10.0.80.0/22" 36 | 37 | public_subnets = ["10.0.80.0/24"] 38 | private_subnets = ["10.0.81.0/24"] 39 | } 40 | 41 | resource "aws_vpc_endpoint" "recorder" { 42 | vpc_id = local.vpc_id 43 | service_name = "com.amazonaws.${aws_s3_bucket.recorder.region}.s3" 44 | route_table_ids = local.vpc_endpoint_route_table_ids 45 | tags = local.aws_tags 46 | } 47 | 48 | resource "aws_s3_bucket" "recorder" { 49 | bucket_prefix = substr(local.name, 0, 37) 50 | tags = local.aws_tags 51 | 52 | force_destroy = true 53 | } 54 | 55 | resource "aws_s3_bucket_ownership_controls" "recorder" { 56 | bucket = aws_s3_bucket.recorder.id 57 | 58 | rule { 59 | object_ownership = "BucketOwnerEnforced" 60 | } 61 | } 62 | 63 | resource "aws_s3_bucket_policy" "recorder" { 64 | bucket = aws_s3_bucket.recorder.id 65 | policy = <<-EOT 66 | { 67 | "Version": "2012-10-17", 68 | "Statement": [ 69 | { 70 | "Sid": "Allow-access-from-specific-VPCE", 71 | "Effect": "Deny", 72 | "Principal": "*", 73 | "Action": [ 74 | "s3:PutObject", 75 | "s3:GetObject" 76 | ], 77 | "Resource": [ 78 | "${aws_s3_bucket.recorder.arn}", 79 | "${aws_s3_bucket.recorder.arn}/*" 80 | ], 81 | "Condition": { 82 | "StringNotEquals": { 83 | "aws:sourceVpce": "${aws_vpc_endpoint.recorder.id}" 84 | } 85 | } 86 | } 87 | ] 88 | } 89 | EOT 90 | } 91 | 92 | resource "aws_iam_policy" "recorder" { 93 | tags = local.aws_tags 94 | policy = <<-EOT 95 | { 96 | "Version": "2012-10-17", 97 | "Statement": [ 98 | { 99 | "Effect": "Allow", 100 | "Action": [ 101 | "s3:PutObject", 102 | "s3:GetBucketLocation", 103 | "s3:GetObject", 104 | "s3:ListBucket" 105 | ], 106 | "Resource": [ 107 | "${aws_s3_bucket.recorder.arn}", 108 | "${aws_s3_bucket.recorder.arn}/*" 109 | ] 110 | } 111 | ] 112 | } 113 | EOT 114 | } 115 | 116 | resource "aws_iam_user" "recorder" { 117 | name = local.name 118 | tags = local.aws_tags 119 | } 120 | 121 | resource "aws_iam_policy_attachment" "recorder" { 122 | name = local.name 123 | policy_arn = aws_iam_policy.recorder.arn 124 | users = [aws_iam_user.recorder.name] 125 | } 126 | 127 | resource "aws_iam_access_key" "recorder" { 128 | user = aws_iam_user.recorder.name 129 | } 130 | 131 | resource "tailscale_tailnet_key" "recorder" { 132 | ephemeral = true 133 | preauthorized = true 134 | reusable = true 135 | recreate_if_invalid = "always" 136 | tags = [ 137 | "tag:example-sessionrecorder", 138 | ] 139 | } 140 | 141 | resource "tailscale_tailnet_key" "main" { 142 | ephemeral = true 143 | preauthorized = true 144 | reusable = true 145 | recreate_if_invalid = "always" 146 | tags = local.tailscale_acl_tags 147 | } 148 | 149 | resource "aws_network_interface" "primary" { 150 | subnet_id = local.subnet_id 151 | security_groups = local.security_group_ids 152 | tags = local.aws_tags 153 | } 154 | resource "aws_eip" "primary" { 155 | tags = local.aws_tags 156 | } 157 | resource "aws_eip_association" "primary" { 158 | network_interface_id = aws_network_interface.primary.id 159 | allocation_id = aws_eip.primary.id 160 | } 161 | 162 | module "tailscale_aws_ec2_autoscaling" { 163 | source = "../internal-modules/aws-ec2-autoscaling/" 164 | 165 | autoscaling_group_name = local.name 166 | instance_type = local.instance_type 167 | instance_tags = local.aws_tags 168 | 169 | network_interfaces = [aws_network_interface.primary.id] 170 | 171 | # Variables for Tailscale resources 172 | tailscale_hostname = local.name 173 | tailscale_auth_key = tailscale_tailnet_key.main.key 174 | tailscale_set_preferences = local.tailscale_set_preferences 175 | 176 | # 177 | # Set up Tailscale Session Recorder (tsrecorder) 178 | # 179 | additional_after_scripts = [ 180 | templatefile( 181 | "${path.module}/scripts/tsrecorder_docker.tftpl", 182 | { 183 | tailscale_recorder_auth_key = tailscale_tailnet_key.recorder.key, 184 | aws_access_key = aws_iam_access_key.recorder.id, 185 | aws_secret_access_key = aws_iam_access_key.recorder.secret, 186 | bucket_name = aws_s3_bucket.recorder.bucket, 187 | bucket_region = aws_s3_bucket.recorder.region, 188 | } 189 | ) 190 | ] 191 | 192 | depends_on = [ 193 | module.vpc.nat_ids, # remove if using your own VPC otherwise ensure provisioned NAT gateway is available 194 | ] 195 | } 196 | 197 | resource "aws_security_group" "tailscale" { 198 | vpc_id = local.vpc_id 199 | name = local.name 200 | } 201 | 202 | resource "aws_security_group_rule" "tailscale_ingress" { 203 | security_group_id = aws_security_group.tailscale.id 204 | type = "ingress" 205 | from_port = 41641 206 | to_port = 41641 207 | protocol = "udp" 208 | cidr_blocks = ["0.0.0.0/0"] 209 | ipv6_cidr_blocks = ["::/0"] 210 | } 211 | 212 | resource "aws_security_group_rule" "egress" { 213 | security_group_id = aws_security_group.tailscale.id 214 | type = "egress" 215 | from_port = 0 216 | to_port = 0 217 | protocol = "-1" 218 | cidr_blocks = ["0.0.0.0/0"] 219 | ipv6_cidr_blocks = ["::/0"] 220 | } 221 | 222 | resource "aws_security_group_rule" "internal_vpc_ingress_ipv4" { 223 | security_group_id = aws_security_group.tailscale.id 224 | type = "ingress" 225 | from_port = 0 226 | to_port = 0 227 | protocol = "-1" 228 | cidr_blocks = [local.vpc_cidr_block] 229 | } 230 | -------------------------------------------------------------------------------- /terraform/aws/aws-ec2-autoscaling-session-recorder/outputs.tf: -------------------------------------------------------------------------------- 1 | output "vpc_id" { 2 | value = module.vpc.vpc_id 3 | } 4 | 5 | output "nat_public_ips" { 6 | value = module.vpc.nat_public_ips 7 | } 8 | 9 | output "autoscaling_group_name" { 10 | value = module.tailscale_aws_ec2_autoscaling.autoscaling_group_name 11 | } 12 | 13 | output "user_data_md5" { 14 | description = "MD5 hash of the VM user_data script - for detecting changes" 15 | value = module.tailscale_aws_ec2_autoscaling.user_data_md5 16 | sensitive = true 17 | } 18 | -------------------------------------------------------------------------------- /terraform/aws/aws-ec2-autoscaling-session-recorder/scripts/tsrecorder_docker.tftpl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Installs Docker and run the Session Recorder (tsrecorder) container 4 | # 5 | 6 | echo -e '\n#\n# Tailscale Session Recorder (tsrecorder) installation...\n#\n' 7 | 8 | apt-get -yqq install ca-certificates curl gnupg 9 | 10 | install -m 0755 -d /etc/apt/keyrings 11 | curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg 12 | chmod a+r /etc/apt/keyrings/docker.gpg 13 | 14 | echo \ 15 | "deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ 16 | "$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | \ 17 | tee /etc/apt/sources.list.d/docker.list > /dev/null 18 | 19 | apt-get -qq update 20 | apt-get -yqq install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin 21 | 22 | docker run \ 23 | --rm -d \ 24 | --name tsrecorder \ 25 | -e TS_AUTHKEY=${tailscale_recorder_auth_key} \ 26 | -v $HOME/tsrecorder:/data \ 27 | tailscale/tsrecorder:unstable \ 28 | /tsrecorder \ 29 | --dst='s3://s3.${bucket_region}.amazonaws.com' \ 30 | --bucket='${bucket_name}' \ 31 | --access-key=${aws_access_key} \ 32 | --secret-key=${aws_secret_access_key} \ 33 | --statedir=/data/state \ 34 | --ui 35 | 36 | echo -e '\n#\n# Complete.\n#\n' 37 | -------------------------------------------------------------------------------- /terraform/aws/aws-ec2-autoscaling-session-recorder/versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | tailscale = { 4 | source = "tailscale/tailscale" 5 | version = ">= 0.13.13" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /terraform/aws/aws-ec2-autoscaling/README.md: -------------------------------------------------------------------------------- 1 | # aws-ec2-autoscaling 2 | 3 | This example creates the following: 4 | 5 | - a VPC and related resources including a NAT Gateway 6 | - an EC2 Launch Template and a userdata script to install and configure Tailscale 7 | - an EC2 Autoscaling Group (ASG) using the Launch Template with `min_size`, `max_size`, and `desired_capacity` set to `1` 8 | - a Tailnet device key to authenticate instances launched by the ASG to your Tailnet 9 | 10 | ## Considerations 11 | 12 | - The Auto Scaling Group does not define an `instance_refresh` policy as the ASG cannot do a rolling restart with externally manaaged network interfaces (ENIs) as required by this configuration. To update instances to the latest launch template, terminate instances in the ASG in the AWS Console or programmatically. This will release the ENI so the replacement instance can use it. 13 | - Any advertised routes and exit nodes must still be approved in the Tailscale Admin Console. The code can be updated to use [Auto Approvers for routes](https://tailscale.com/kb/1018/acls/#auto-approvers-for-routes-and-exit-nodes) if this is configured in your ACLs. 14 | 15 | ## To use 16 | 17 | Follow the documentation to configure the Terraform providers: 18 | 19 | - [Tailscale](https://registry.terraform.io/providers/tailscale/tailscale/latest/docs) 20 | - [AWS](https://registry.terraform.io/providers/hashicorp/aws/latest/docs) 21 | 22 | ### Deploy 23 | 24 | ```shell 25 | terraform init 26 | terraform apply 27 | ``` 28 | 29 | ## To destroy 30 | 31 | ```shell 32 | terraform destroy 33 | ``` 34 | -------------------------------------------------------------------------------- /terraform/aws/aws-ec2-autoscaling/main.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | name = "example-${basename(path.cwd)}" 3 | 4 | aws_tags = { 5 | Name = local.name 6 | } 7 | 8 | tailscale_acl_tags = [ 9 | "tag:example-infra", 10 | "tag:example-exitnode", 11 | "tag:example-subnetrouter", 12 | "tag:example-appconnector", 13 | ] 14 | tailscale_set_preferences = [ 15 | "--auto-update", 16 | "--ssh", 17 | "--advertise-connector", 18 | "--advertise-exit-node", 19 | "--advertise-routes=${join(",", [ 20 | local.vpc_cidr_block, 21 | ])}", 22 | ] 23 | 24 | // Modify these to use your own VPC 25 | vpc_cidr_block = module.vpc.vpc_cidr_block 26 | vpc_id = module.vpc.vpc_id 27 | subnet_id = module.vpc.public_subnets[0] 28 | security_group_ids = [aws_security_group.tailscale.id] 29 | instance_type = "c7g.medium" 30 | } 31 | 32 | // Remove this to use your own VPC. 33 | module "vpc" { 34 | source = "../internal-modules/aws-vpc" 35 | 36 | name = local.name 37 | tags = local.aws_tags 38 | 39 | cidr = "10.0.80.0/22" 40 | 41 | public_subnets = ["10.0.80.0/24"] 42 | private_subnets = ["10.0.81.0/24"] 43 | } 44 | 45 | resource "tailscale_tailnet_key" "main" { 46 | ephemeral = true 47 | preauthorized = true 48 | reusable = true 49 | recreate_if_invalid = "always" 50 | tags = local.tailscale_acl_tags 51 | } 52 | 53 | resource "aws_network_interface" "primary" { 54 | subnet_id = local.subnet_id 55 | security_groups = local.security_group_ids 56 | tags = local.aws_tags 57 | } 58 | resource "aws_eip" "primary" { 59 | tags = local.aws_tags 60 | } 61 | resource "aws_eip_association" "primary" { 62 | network_interface_id = aws_network_interface.primary.id 63 | allocation_id = aws_eip.primary.id 64 | } 65 | 66 | module "tailscale_aws_ec2_autoscaling" { 67 | source = "../internal-modules/aws-ec2-autoscaling/" 68 | 69 | autoscaling_group_name = local.name 70 | instance_type = local.instance_type 71 | instance_tags = local.aws_tags 72 | 73 | network_interfaces = [aws_network_interface.primary.id] 74 | 75 | # Variables for Tailscale resources 76 | tailscale_auth_key = tailscale_tailnet_key.main.key 77 | tailscale_hostname = local.name 78 | tailscale_set_preferences = local.tailscale_set_preferences 79 | 80 | depends_on = [ 81 | module.vpc.nat_ids, # remove if using your own VPC otherwise ensure provisioned NAT gateway is available 82 | ] 83 | } 84 | 85 | resource "aws_security_group" "tailscale" { 86 | vpc_id = local.vpc_id 87 | name = local.name 88 | } 89 | 90 | resource "aws_security_group_rule" "tailscale_ingress" { 91 | security_group_id = aws_security_group.tailscale.id 92 | type = "ingress" 93 | from_port = 41641 94 | to_port = 41641 95 | protocol = "udp" 96 | cidr_blocks = ["0.0.0.0/0"] 97 | ipv6_cidr_blocks = ["::/0"] 98 | } 99 | 100 | resource "aws_security_group_rule" "egress" { 101 | security_group_id = aws_security_group.tailscale.id 102 | type = "egress" 103 | from_port = 0 104 | to_port = 0 105 | protocol = "-1" 106 | cidr_blocks = ["0.0.0.0/0"] 107 | ipv6_cidr_blocks = ["::/0"] 108 | } 109 | 110 | resource "aws_security_group_rule" "internal_vpc_ingress_ipv4" { 111 | security_group_id = aws_security_group.tailscale.id 112 | type = "ingress" 113 | from_port = 0 114 | to_port = 0 115 | protocol = "-1" 116 | cidr_blocks = [local.vpc_cidr_block] 117 | } 118 | -------------------------------------------------------------------------------- /terraform/aws/aws-ec2-autoscaling/outputs.tf: -------------------------------------------------------------------------------- 1 | output "vpc_id" { 2 | value = module.vpc.vpc_id 3 | } 4 | 5 | output "nat_public_ips" { 6 | value = module.vpc.nat_public_ips 7 | } 8 | 9 | output "autoscaling_group_name" { 10 | value = module.tailscale_aws_ec2_autoscaling.autoscaling_group_name 11 | } 12 | 13 | output "user_data_md5" { 14 | description = "MD5 hash of the VM user_data script - for detecting changes" 15 | value = module.tailscale_aws_ec2_autoscaling.user_data_md5 16 | sensitive = true 17 | } 18 | -------------------------------------------------------------------------------- /terraform/aws/aws-ec2-autoscaling/versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | tailscale = { 4 | source = "tailscale/tailscale" 5 | version = ">= 0.13.13" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /terraform/aws/aws-ec2-instance-dual-stack-ipv4-ipv6/README.md: -------------------------------------------------------------------------------- 1 | # aws-ec2-instance-dual-stack-ipv4-ipv6 2 | 3 | This example creates the following: 4 | 5 | - a **dual-stack IPv4/IPv6** VPC with related resources including a NAT Gateway 6 | - an EC2 instance running Tailscale in a public subnet 7 | - a Tailnet device key to authenticate the Tailscale device 8 | 9 | ## Considerations 10 | 11 | - Any advertised routes and exit nodes must still be approved in the Tailscale Admin Console. The code can be updated to use [Auto Approvers for routes](https://tailscale.com/kb/1018/acls/#auto-approvers-for-routes-and-exit-nodes) if this is configured in your ACLs. 12 | 13 | ## To use 14 | 15 | Follow the documentation to configure the Terraform providers: 16 | 17 | - [Tailscale](https://registry.terraform.io/providers/tailscale/tailscale/latest/docs) 18 | - [AWS](https://registry.terraform.io/providers/hashicorp/aws/latest/docs) 19 | 20 | ### Deploy 21 | 22 | ```shell 23 | terraform init 24 | terraform apply 25 | ``` 26 | 27 | ## To destroy 28 | 29 | ```shell 30 | terraform destroy 31 | ``` 32 | -------------------------------------------------------------------------------- /terraform/aws/aws-ec2-instance-dual-stack-ipv4-ipv6/main.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | name = "example-${basename(path.cwd)}" 3 | 4 | aws_tags = { 5 | Name = local.name 6 | } 7 | 8 | tailscale_acl_tags = [ 9 | "tag:example-infra", 10 | "tag:example-exitnode", 11 | "tag:example-subnetrouter", 12 | "tag:example-appconnector", 13 | ] 14 | tailscale_set_preferences = [ 15 | "--auto-update", 16 | "--ssh", 17 | "--advertise-connector", 18 | "--advertise-exit-node", 19 | "--advertise-routes=${join(",", [ 20 | local.vpc_cidr_block, 21 | ])}", 22 | ] 23 | 24 | // Modify these to use your own VPC 25 | vpc_cidr_block = module.vpc.vpc_cidr_block 26 | vpc_id = module.vpc.vpc_id 27 | subnet_id = module.vpc.public_subnets[0] 28 | security_group_ids = [aws_security_group.tailscale.id] 29 | instance_type = "c7g.medium" 30 | } 31 | 32 | // Remove this to use your own VPC. 33 | module "vpc" { 34 | source = "../internal-modules/aws-vpc" 35 | 36 | name = local.name 37 | tags = local.aws_tags 38 | 39 | cidr = "10.0.80.0/22" 40 | 41 | public_subnets = ["10.0.80.0/24"] 42 | private_subnets = ["10.0.81.0/24"] 43 | 44 | enable_ipv6 = true 45 | } 46 | 47 | resource "tailscale_tailnet_key" "main" { 48 | ephemeral = true 49 | preauthorized = true 50 | reusable = true 51 | recreate_if_invalid = "always" 52 | tags = local.tailscale_acl_tags 53 | } 54 | 55 | module "tailscale_aws_ec2" { 56 | source = "../internal-modules/aws-ec2-instance" 57 | 58 | instance_type = local.instance_type 59 | instance_tags = local.aws_tags 60 | 61 | subnet_id = local.subnet_id 62 | vpc_security_group_ids = local.security_group_ids 63 | ipv6_address_count = 1 64 | 65 | # Variables for Tailscale resources 66 | tailscale_hostname = local.name 67 | tailscale_auth_key = tailscale_tailnet_key.main.key 68 | tailscale_set_preferences = local.tailscale_set_preferences 69 | 70 | depends_on = [ 71 | module.vpc.nat_ids, # remove if using your own VPC otherwise ensure provisioned NAT gateway is available 72 | ] 73 | } 74 | 75 | resource "aws_security_group" "tailscale" { 76 | vpc_id = local.vpc_id 77 | name = local.name 78 | } 79 | 80 | resource "aws_security_group_rule" "tailscale_ingress" { 81 | security_group_id = aws_security_group.tailscale.id 82 | type = "ingress" 83 | from_port = 41641 84 | to_port = 41641 85 | protocol = "udp" 86 | cidr_blocks = ["0.0.0.0/0"] 87 | ipv6_cidr_blocks = ["::/0"] 88 | } 89 | 90 | resource "aws_security_group_rule" "egress" { 91 | security_group_id = aws_security_group.tailscale.id 92 | type = "egress" 93 | from_port = 0 94 | to_port = 0 95 | protocol = "-1" 96 | cidr_blocks = ["0.0.0.0/0"] 97 | ipv6_cidr_blocks = ["::/0"] 98 | } 99 | 100 | resource "aws_security_group_rule" "internal_vpc_ingress_ipv4" { 101 | security_group_id = aws_security_group.tailscale.id 102 | type = "ingress" 103 | from_port = 0 104 | to_port = 0 105 | protocol = "-1" 106 | cidr_blocks = [local.vpc_cidr_block] 107 | } 108 | -------------------------------------------------------------------------------- /terraform/aws/aws-ec2-instance-dual-stack-ipv4-ipv6/outputs.tf: -------------------------------------------------------------------------------- 1 | output "vpc_id" { 2 | value = module.vpc.vpc_id 3 | } 4 | 5 | output "nat_public_ips" { 6 | value = module.vpc.nat_public_ips 7 | } 8 | 9 | output "instance_ids" { 10 | value = module.tailscale_aws_ec2.*.instance_id 11 | } 12 | 13 | output "user_data_md5" { 14 | description = "MD5 hash of the VM user_data script - for detecting changes" 15 | value = module.tailscale_aws_ec2.user_data_md5 16 | sensitive = true 17 | } 18 | -------------------------------------------------------------------------------- /terraform/aws/aws-ec2-instance-dual-stack-ipv4-ipv6/versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | tailscale = { 4 | source = "tailscale/tailscale" 5 | version = ">= 0.13.13" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /terraform/aws/aws-ec2-instance/README.md: -------------------------------------------------------------------------------- 1 | # aws-ec2-instance 2 | 3 | This example creates the following: 4 | 5 | - a VPC and related resources including a NAT Gateway 6 | - an EC2 instance running Tailscale in a public subnet 7 | - a Tailnet device key to authenticate the Tailscale device 8 | 9 | ## Considerations 10 | 11 | - Any advertised routes and exit nodes must still be approved in the Tailscale Admin Console. The code can be updated to use [Auto Approvers for routes](https://tailscale.com/kb/1018/acls/#auto-approvers-for-routes-and-exit-nodes) if this is configured in your ACLs. 12 | 13 | ## To use 14 | 15 | Follow the documentation to configure the Terraform providers: 16 | 17 | - [Tailscale](https://registry.terraform.io/providers/tailscale/tailscale/latest/docs) 18 | - [AWS](https://registry.terraform.io/providers/hashicorp/aws/latest/docs) 19 | 20 | ### Deploy 21 | 22 | ```shell 23 | terraform init 24 | terraform apply 25 | ``` 26 | 27 | ## To destroy 28 | 29 | ```shell 30 | terraform destroy 31 | ``` 32 | -------------------------------------------------------------------------------- /terraform/aws/aws-ec2-instance/main.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | name = "example-${basename(path.cwd)}" 3 | 4 | aws_tags = { 5 | Name = local.name 6 | } 7 | 8 | tailscale_acl_tags = [ 9 | "tag:example-infra", 10 | "tag:example-exitnode", 11 | "tag:example-subnetrouter", 12 | "tag:example-appconnector", 13 | ] 14 | tailscale_set_preferences = [ 15 | "--auto-update", 16 | "--ssh", 17 | "--advertise-connector", 18 | "--advertise-exit-node", 19 | "--advertise-routes=${join(",", [ 20 | local.vpc_cidr_block, 21 | ])}", 22 | ] 23 | 24 | // Modify these to use your own VPC 25 | vpc_cidr_block = module.vpc.vpc_cidr_block 26 | vpc_id = module.vpc.vpc_id 27 | subnet_id = module.vpc.public_subnets[0] 28 | security_group_ids = [aws_security_group.tailscale.id] 29 | instance_type = "c7g.medium" 30 | } 31 | 32 | // Remove this to use your own VPC. 33 | module "vpc" { 34 | source = "../internal-modules/aws-vpc" 35 | 36 | name = local.name 37 | tags = local.aws_tags 38 | 39 | cidr = "10.0.80.0/22" 40 | 41 | public_subnets = ["10.0.80.0/24"] 42 | private_subnets = ["10.0.81.0/24"] 43 | } 44 | 45 | resource "tailscale_tailnet_key" "main" { 46 | ephemeral = true 47 | preauthorized = true 48 | reusable = true 49 | recreate_if_invalid = "always" 50 | tags = local.tailscale_acl_tags 51 | } 52 | 53 | module "tailscale_aws_ec2" { 54 | source = "../internal-modules/aws-ec2-instance" 55 | 56 | instance_type = local.instance_type 57 | instance_tags = local.aws_tags 58 | 59 | subnet_id = local.subnet_id 60 | vpc_security_group_ids = local.security_group_ids 61 | 62 | # Variables for Tailscale resources 63 | tailscale_hostname = local.name 64 | tailscale_auth_key = tailscale_tailnet_key.main.key 65 | tailscale_set_preferences = local.tailscale_set_preferences 66 | 67 | depends_on = [ 68 | module.vpc.nat_ids, # remove if using your own VPC otherwise ensure provisioned NAT gateway is available 69 | ] 70 | } 71 | 72 | resource "aws_security_group" "tailscale" { 73 | vpc_id = local.vpc_id 74 | name = local.name 75 | } 76 | 77 | resource "aws_security_group_rule" "tailscale_ingress" { 78 | security_group_id = aws_security_group.tailscale.id 79 | type = "ingress" 80 | from_port = 41641 81 | to_port = 41641 82 | protocol = "udp" 83 | cidr_blocks = ["0.0.0.0/0"] 84 | ipv6_cidr_blocks = ["::/0"] 85 | } 86 | 87 | resource "aws_security_group_rule" "egress" { 88 | security_group_id = aws_security_group.tailscale.id 89 | type = "egress" 90 | from_port = 0 91 | to_port = 0 92 | protocol = "-1" 93 | cidr_blocks = ["0.0.0.0/0"] 94 | ipv6_cidr_blocks = ["::/0"] 95 | } 96 | 97 | resource "aws_security_group_rule" "internal_vpc_ingress_ipv4" { 98 | security_group_id = aws_security_group.tailscale.id 99 | type = "ingress" 100 | from_port = 0 101 | to_port = 0 102 | protocol = "-1" 103 | cidr_blocks = [local.vpc_cidr_block] 104 | } 105 | -------------------------------------------------------------------------------- /terraform/aws/aws-ec2-instance/outputs.tf: -------------------------------------------------------------------------------- 1 | output "vpc_id" { 2 | value = module.vpc.vpc_id 3 | } 4 | 5 | output "nat_public_ips" { 6 | value = module.vpc.nat_public_ips 7 | } 8 | 9 | output "instance_ids" { 10 | value = module.tailscale_aws_ec2.*.instance_id 11 | } 12 | 13 | output "user_data_md5" { 14 | description = "MD5 hash of the VM user_data script - for detecting changes" 15 | value = module.tailscale_aws_ec2.user_data_md5 16 | sensitive = true 17 | } 18 | -------------------------------------------------------------------------------- /terraform/aws/aws-ec2-instance/versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | tailscale = { 4 | source = "tailscale/tailscale" 5 | version = ">= 0.13.13" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /terraform/aws/internal-modules/README.md: -------------------------------------------------------------------------------- 1 | # internal-modules 2 | 3 | ## Overview 4 | 5 | This directory contains contains Terraform modules used by the examples for this provider. 6 | -------------------------------------------------------------------------------- /terraform/aws/internal-modules/aws-ec2-autoscaling/README.md: -------------------------------------------------------------------------------- 1 | # aws-ec2-autoscaling 2 | 3 | This module creates the following: 4 | 5 | - an EC2 Launch Template and a userdata script to install and configure Tailscale 6 | - an EC2 Autoscaling Group using the Launch Template with `min_size`, `max_size`, and `desired_capacity` set to `1` 7 | - a Tailnet device key to authenticate the instance to your Tailnet 8 | 9 | ## Considerations 10 | 11 | - Any advertised routes and exit nodes must still be approved in the Tailscale Admin Console. The code can be updated to use [Auto Approvers for routes](https://tailscale.com/kb/1018/acls/#auto-approvers-for-routes-and-exit-nodes) if this is configured in your ACLs. 12 | 13 | ## Example Usage 14 | 15 | See the `examples` folder for complete examples. 16 | -------------------------------------------------------------------------------- /terraform/aws/internal-modules/aws-ec2-autoscaling/main.tf: -------------------------------------------------------------------------------- 1 | module "tailscale_install_scripts" { 2 | source = "../../../internal-modules/tailscale-install-scripts" 3 | 4 | tailscale_auth_key = var.tailscale_auth_key 5 | tailscale_hostname = var.tailscale_hostname 6 | tailscale_set_preferences = var.tailscale_set_preferences 7 | 8 | additional_before_scripts = var.additional_before_scripts 9 | additional_after_scripts = var.additional_after_scripts 10 | 11 | primary_subnet_cidr = data.aws_subnet.selected[0].cidr_block 12 | secondary_subnet_cidr = try(data.aws_subnet.selected[1].cidr_block, null) # only available if using dual subnets 13 | } 14 | 15 | data "aws_network_interface" "selected" { 16 | count = length(var.network_interfaces) 17 | id = var.network_interfaces[count.index] 18 | } 19 | data "aws_subnet" "selected" { 20 | count = length(var.network_interfaces) 21 | id = data.aws_network_interface.selected[count.index].subnet_id 22 | } 23 | 24 | data "aws_ami" "ubuntu" { 25 | owners = ["099720109477"] # Canonical 26 | most_recent = true 27 | 28 | filter { 29 | name = "name" 30 | values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-*-server-*"] 31 | } 32 | 33 | filter { 34 | name = "virtualization-type" 35 | values = ["hvm"] 36 | } 37 | 38 | filter { 39 | name = "architecture" 40 | values = ["arm64"] 41 | } 42 | } 43 | 44 | resource "aws_launch_template" "tailscale" { 45 | name_prefix = var.autoscaling_group_name 46 | image_id = data.aws_ami.ubuntu.id 47 | instance_type = var.instance_type 48 | key_name = var.instance_key_name 49 | 50 | dynamic "iam_instance_profile" { 51 | for_each = var.instance_profile_name != "" ? [1] : [] 52 | content { 53 | name = var.instance_profile_name 54 | } 55 | } 56 | 57 | metadata_options { 58 | http_endpoint = var.instance_metadata_options["http_endpoint"] 59 | http_tokens = var.instance_metadata_options["http_tokens"] 60 | } 61 | 62 | dynamic "network_interfaces" { 63 | # https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/scenarios-enis.html#creating-dual-homed-instances-with-workloads-roles-on-distinct-subnets 64 | for_each = var.network_interfaces 65 | content { 66 | delete_on_termination = false 67 | device_index = network_interfaces.key 68 | network_interface_id = network_interfaces.value 69 | } 70 | } 71 | 72 | tag_specifications { 73 | resource_type = "instance" 74 | tags = var.instance_tags 75 | } 76 | 77 | user_data = module.tailscale_install_scripts.ubuntu_install_script_base64_encoded 78 | 79 | lifecycle { 80 | ignore_changes = [ 81 | image_id, 82 | ] 83 | } 84 | } 85 | 86 | resource "aws_autoscaling_group" "tailscale" { 87 | name = var.autoscaling_group_name 88 | 89 | launch_template { 90 | id = aws_launch_template.tailscale.id 91 | version = aws_launch_template.tailscale.latest_version 92 | } 93 | 94 | availability_zones = [data.aws_network_interface.selected[0].availability_zone] 95 | 96 | desired_capacity = 1 97 | min_size = 0 98 | max_size = 1 99 | 100 | /** 101 | * Uncomment to allow ASG to replace the instance. It will take several minutes as the ASG 102 | * will try to launch a replacement instance before ENIs have been released. 103 | 104 | instance_refresh { 105 | strategy = "Rolling" 106 | preferences { 107 | min_healthy_percentage = 0 108 | } 109 | } 110 | */ 111 | 112 | health_check_grace_period = 300 113 | health_check_type = "EC2" 114 | 115 | timeouts { 116 | delete = "15m" 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /terraform/aws/internal-modules/aws-ec2-autoscaling/outputs.tf: -------------------------------------------------------------------------------- 1 | output "autoscaling_group_name" { 2 | value = aws_autoscaling_group.tailscale.name 3 | } 4 | 5 | output "user_data_md5" { 6 | description = "MD5 hash of the VM user_data script - for detecting changes" 7 | value = module.tailscale_install_scripts.ubuntu_install_script_md5 8 | } 9 | -------------------------------------------------------------------------------- /terraform/aws/internal-modules/aws-ec2-autoscaling/variables-tailscale-install-scripts.tf: -------------------------------------------------------------------------------- 1 | # 2 | # Variables for Tailscale resources 3 | # 4 | variable "tailscale_auth_key" { 5 | description = "Tailscale auth key to authenticate the device" 6 | type = string 7 | } 8 | variable "tailscale_hostname" { 9 | description = "Hostname to assign to the device" 10 | type = string 11 | } 12 | variable "tailscale_set_preferences" { 13 | description = "Preferences to run via `tailscale set ...`. Do not include `tailscale set`." 14 | type = set(string) 15 | default = [] 16 | } 17 | 18 | # 19 | # Variables for userdata 20 | # 21 | variable "additional_before_scripts" { 22 | description = "Additional scripts to run BEFORE Tailscale scripts" 23 | type = list(string) 24 | default = [] 25 | } 26 | variable "additional_after_scripts" { 27 | description = "Additional scripts to run AFTER Tailscale scripts" 28 | type = list(string) 29 | default = [] 30 | } 31 | -------------------------------------------------------------------------------- /terraform/aws/internal-modules/aws-ec2-autoscaling/variables.tf: -------------------------------------------------------------------------------- 1 | # 2 | # Variables for autoscaling resources 3 | # 4 | variable "network_interfaces" { 5 | description = "List of network interfaces to attach to instances - if attaching multiple for dual subnet routing, the first NIC must be the primary in the PUBLIC subnet" 6 | type = list(string) 7 | } 8 | variable "autoscaling_group_name" { 9 | type = string 10 | } 11 | variable "instance_type" { 12 | type = string 13 | } 14 | variable "instance_tags" { 15 | type = map(string) 16 | } 17 | variable "instance_key_name" { 18 | type = string 19 | default = "" 20 | } 21 | variable "instance_profile_name" { 22 | type = string 23 | default = "" 24 | } 25 | variable "instance_metadata_options" { 26 | type = map(string) 27 | # IMDSv2 - not required, but recommended 28 | default = { 29 | http_endpoint = "enabled" 30 | http_tokens = "required" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /terraform/aws/internal-modules/aws-ec2-autoscaling/versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | aws = { 4 | source = "hashicorp/aws" 5 | version = ">= 5.0, < 6.0" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /terraform/aws/internal-modules/aws-ec2-instance/README.md: -------------------------------------------------------------------------------- 1 | # aws-ec2-instance 2 | 3 | This module creates the following: 4 | 5 | - an Ubuntu EC2 instance with Tailscale installed via a userdata script 6 | - a Tailnet device key to authenticate the instance to your Tailnet 7 | -------------------------------------------------------------------------------- /terraform/aws/internal-modules/aws-ec2-instance/main.tf: -------------------------------------------------------------------------------- 1 | module "tailscale_install_scripts" { 2 | source = "../../../internal-modules/tailscale-install-scripts" 3 | 4 | tailscale_auth_key = var.tailscale_auth_key 5 | tailscale_hostname = var.tailscale_hostname 6 | tailscale_set_preferences = var.tailscale_set_preferences 7 | 8 | additional_before_scripts = var.additional_before_scripts 9 | additional_after_scripts = var.additional_after_scripts 10 | } 11 | 12 | data "aws_ami" "ubuntu" { 13 | owners = ["099720109477"] # Canonical 14 | most_recent = true 15 | 16 | filter { 17 | name = "name" 18 | values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-*-server-*"] 19 | } 20 | 21 | filter { 22 | name = "virtualization-type" 23 | values = ["hvm"] 24 | } 25 | 26 | filter { 27 | name = "architecture" 28 | values = ["arm64"] 29 | } 30 | } 31 | 32 | resource "aws_instance" "tailscale_instance" { 33 | ami = data.aws_ami.ubuntu.id 34 | instance_type = var.instance_type 35 | key_name = var.instance_key_name 36 | 37 | subnet_id = var.subnet_id 38 | vpc_security_group_ids = var.vpc_security_group_ids 39 | ipv6_address_count = var.ipv6_address_count 40 | 41 | iam_instance_profile = var.instance_profile_name 42 | 43 | metadata_options { 44 | http_endpoint = var.instance_metadata_options["http_endpoint"] 45 | http_tokens = var.instance_metadata_options["http_tokens"] 46 | } 47 | 48 | tags = var.instance_tags 49 | 50 | user_data_replace_on_change = var.instance_user_data_replace_on_change 51 | user_data = module.tailscale_install_scripts.ubuntu_install_script 52 | 53 | lifecycle { 54 | ignore_changes = [ 55 | ami, 56 | ] 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /terraform/aws/internal-modules/aws-ec2-instance/outputs.tf: -------------------------------------------------------------------------------- 1 | output "instance_id" { 2 | value = aws_instance.tailscale_instance.id 3 | } 4 | 5 | output "instance_private_ip" { 6 | value = aws_instance.tailscale_instance.private_ip 7 | } 8 | 9 | output "user_data_md5" { 10 | description = "MD5 hash of the VM user_data script - for detecting changes" 11 | value = module.tailscale_install_scripts.ubuntu_install_script_md5 12 | sensitive = true 13 | } 14 | -------------------------------------------------------------------------------- /terraform/aws/internal-modules/aws-ec2-instance/variables-tailscale-install-scripts.tf: -------------------------------------------------------------------------------- 1 | # 2 | # Variables for Tailscale resources 3 | # 4 | variable "tailscale_auth_key" { 5 | description = "Tailscale auth key to authenticate the device" 6 | type = string 7 | } 8 | variable "tailscale_hostname" { 9 | description = "Hostname to assign to the device" 10 | type = string 11 | } 12 | variable "tailscale_set_preferences" { 13 | description = "Preferences to run via `tailscale set ...`. Do not include `tailscale set`." 14 | type = set(string) 15 | default = [] 16 | } 17 | 18 | # 19 | # Variables for userdata 20 | # 21 | variable "additional_before_scripts" { 22 | description = "Additional scripts to run BEFORE Tailscale scripts" 23 | type = list(string) 24 | default = [] 25 | } 26 | variable "additional_after_scripts" { 27 | description = "Additional scripts to run AFTER Tailscale scripts" 28 | type = list(string) 29 | default = [] 30 | } 31 | -------------------------------------------------------------------------------- /terraform/aws/internal-modules/aws-ec2-instance/variables.tf: -------------------------------------------------------------------------------- 1 | # 2 | # Variables for instance resources 3 | # 4 | variable "subnet_id" { 5 | type = string 6 | } 7 | variable "ipv6_address_count" { 8 | type = number 9 | default = null 10 | } 11 | variable "vpc_security_group_ids" { 12 | type = set(string) 13 | } 14 | variable "instance_type" { 15 | type = string 16 | } 17 | variable "instance_tags" { 18 | type = map(string) 19 | } 20 | variable "instance_user_data_replace_on_change" { 21 | type = bool 22 | default = true 23 | } 24 | variable "instance_key_name" { 25 | type = string 26 | default = "" 27 | } 28 | variable "instance_profile_name" { 29 | type = string 30 | default = null 31 | } 32 | variable "instance_metadata_options" { 33 | type = map(string) 34 | # IMDSv2 - not required, but recommended 35 | default = { 36 | http_endpoint = "enabled" 37 | http_tokens = "required" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /terraform/aws/internal-modules/aws-ec2-instance/versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | aws = { 4 | source = "hashicorp/aws" 5 | version = ">= 5.0, < 6.0" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /terraform/aws/internal-modules/aws-vpc/README.md: -------------------------------------------------------------------------------- 1 | # aws-vpc 2 | 3 | ## Overview 4 | 5 | This module creates a AWS VPC (and related resources) using these community modules: 6 | 7 | - [terraform-aws-modules/vpc](https://registry.terraform.io/modules/terraform-aws-modules/vpc/aws/latest) 8 | -------------------------------------------------------------------------------- /terraform/aws/internal-modules/aws-vpc/main.tf: -------------------------------------------------------------------------------- 1 | data "aws_availability_zones" "available" { 2 | state = "available" 3 | } 4 | 5 | module "vpc" { 6 | # https://github.com/terraform-aws-modules/terraform-aws-vpc 7 | source = "terraform-aws-modules/vpc/aws" 8 | version = ">= 5.0, < 6.0" 9 | 10 | name = var.name 11 | tags = var.tags 12 | 13 | public_subnet_tags = merge(var.tags, { Name = "${var.name}-public" }) 14 | private_subnet_tags = merge(var.tags, { Name = "${var.name}-private" }) 15 | 16 | cidr = var.cidr 17 | 18 | azs = var.azs != null ? var.azs : data.aws_availability_zones.available.zone_ids 19 | public_subnets = var.public_subnets 20 | private_subnets = var.private_subnets 21 | 22 | map_public_ip_on_launch = true 23 | enable_nat_gateway = true 24 | single_nat_gateway = true 25 | one_nat_gateway_per_az = false 26 | 27 | # ipv6 28 | enable_ipv6 = var.enable_ipv6 29 | public_subnet_assign_ipv6_address_on_creation = var.enable_ipv6 30 | public_subnet_ipv6_prefixes = range(0, length(var.public_subnets)) 31 | private_subnet_ipv6_prefixes = range(10, 10 + length(var.private_subnets)) 32 | } 33 | -------------------------------------------------------------------------------- /terraform/aws/internal-modules/aws-vpc/outputs.tf: -------------------------------------------------------------------------------- 1 | output "vpc_id" { 2 | value = module.vpc.vpc_id 3 | } 4 | 5 | output "vpc_cidr_block" { 6 | value = module.vpc.vpc_cidr_block 7 | } 8 | output "vpc_ipv6_cidr_block" { 9 | value = module.vpc.vpc_ipv6_cidr_block 10 | } 11 | 12 | output "public_subnets" { 13 | value = module.vpc.public_subnets 14 | } 15 | 16 | output "private_subnets" { 17 | value = module.vpc.private_subnets 18 | } 19 | 20 | output "azs" { 21 | value = module.vpc.azs 22 | } 23 | 24 | output "nat_public_ips" { 25 | value = module.vpc.nat_public_ips 26 | } 27 | 28 | output "nat_ids" { 29 | description = "Useful for using within `depends_on` for other resources" 30 | value = module.vpc.nat_ids 31 | } 32 | 33 | output "public_route_table_ids" { 34 | value = module.vpc.public_route_table_ids 35 | } 36 | 37 | output "private_route_table_ids" { 38 | value = module.vpc.private_route_table_ids 39 | } 40 | -------------------------------------------------------------------------------- /terraform/aws/internal-modules/aws-vpc/variables.tf: -------------------------------------------------------------------------------- 1 | # 2 | # Variables for all resources 3 | # 4 | variable "name" { 5 | description = "Name for all resources" 6 | type = string 7 | } 8 | variable "tags" { 9 | description = "Map of tags to add to all resources" 10 | type = map(string) 11 | } 12 | 13 | # 14 | # Variables for network resources 15 | # 16 | variable "azs" { 17 | description = "A list of availability zones names or ids in the region" 18 | type = list(string) 19 | default = null 20 | } 21 | variable "cidr" { 22 | description = "IPv4 CIDR block for the VPC" 23 | type = string 24 | } 25 | variable "public_subnets" { 26 | description = "List of public subnet CIDR blocks" 27 | type = list(string) 28 | } 29 | variable "private_subnets" { 30 | description = "List of private subnet CIDR blocks" 31 | type = list(string) 32 | } 33 | variable "enable_ipv6" { 34 | description = "Conditional to provision IPV6 VPC resources too" 35 | type = bool 36 | default = false 37 | } 38 | -------------------------------------------------------------------------------- /terraform/aws/internal-modules/aws-vpc/versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | aws = { 4 | source = "hashicorp/aws" 5 | version = ">= 5.0, < 6.0" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /terraform/azure/README.md: -------------------------------------------------------------------------------- 1 | # azure 2 | 3 | ## Overview 4 | 5 | This directory contains [Terraform](https://www.terraform.io) examples for common Tailscale deployments on Microsoft Azure. 6 | 7 | ## Prerequisites 8 | 9 | The examples assume prior experience with Terraform and Microsoft Azure - their concepts, operations, and configuration. 10 | 11 | ## To use 12 | 13 | Each subdirectory contains a readme explaining its use. The `shared-modules` directory contains Terraform modules used by the examples. 14 | -------------------------------------------------------------------------------- /terraform/azure/azure-linux-vm/README.md: -------------------------------------------------------------------------------- 1 | # azure-linux-vm 2 | 3 | This example creates the following: 4 | 5 | - a Virtual Network with `public`, `private`, and `dns-inbound` subnets using the [Azure RM Module for Network](https://registry.terraform.io/modules/Azure/network/azurerm/latest) 6 | from the Terraform Registry 7 | - a Azure NAT Gateway associated with the `private` subnet 8 | - a Azure DNS Private Resolver in the `dns-inbound` subnet 9 | - an Azure Linux virtual machine running Tailscale in a public subnet 10 | - a Tailnet device key to authenticate the Tailscale device 11 | 12 | ## To use 13 | 14 | Follow the documentation to configure the Terraform providers: 15 | 16 | - [Tailscale](https://registry.terraform.io/providers/tailscale/tailscale/latest/docs) 17 | - [Azure](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs) 18 | 19 | ### Deploy 20 | 21 | ```shell 22 | terraform init 23 | terraform apply 24 | ``` 25 | 26 | ## To destroy 27 | 28 | ```shell 29 | terraform destroy 30 | ``` 31 | -------------------------------------------------------------------------------- /terraform/azure/azure-linux-vm/main.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | name = "example-${basename(path.cwd)}" 3 | 4 | azure_tags = { 5 | Name = local.name 6 | } 7 | 8 | tailscale_acl_tags = [ 9 | "tag:example-infra", 10 | "tag:example-exitnode", 11 | "tag:example-subnetrouter", 12 | "tag:example-appconnector", 13 | ] 14 | tailscale_set_preferences = [ 15 | "--auto-update", 16 | "--ssh", 17 | "--advertise-connector", 18 | "--advertise-exit-node", 19 | "--advertise-routes=${join(",", coalescelist( 20 | local.vpc_cidr_block, 21 | ))}", 22 | ] 23 | 24 | // Modify these to use your own VPC 25 | resource_group_name = azurerm_resource_group.main.name 26 | location = azurerm_resource_group.main.location 27 | 28 | vpc_cidr_block = module.vpc.vnet_address_space 29 | vpc_id = module.vpc.vnet_id 30 | subnet_id = module.vpc.public_subnet_id 31 | network_security_group_id = azurerm_network_security_group.tailscale_ingress.id 32 | instance_type = "Standard_DS1_v2" 33 | admin_public_key_path = var.admin_public_key_path 34 | } 35 | 36 | resource "azurerm_resource_group" "main" { 37 | location = "centralus" 38 | name = local.name 39 | } 40 | 41 | module "vpc" { 42 | source = "../internal-modules/azure-network" 43 | 44 | name = local.name 45 | tags = local.azure_tags 46 | 47 | location = local.location 48 | resource_group_name = local.resource_group_name 49 | 50 | cidrs = ["10.0.0.0/22"] 51 | subnet_cidrs = [ 52 | "10.0.0.0/24", 53 | "10.0.1.0/24", 54 | "10.0.2.0/24", 55 | ] 56 | subnet_name_public = "public" 57 | subnet_name_private = "private" 58 | subnet_name_private_dns_resolver = "dns-inbound" 59 | } 60 | 61 | # 62 | # Tailscale instance resources 63 | # 64 | resource "tailscale_tailnet_key" "main" { 65 | ephemeral = true 66 | preauthorized = true 67 | reusable = true 68 | recreate_if_invalid = "always" 69 | tags = local.tailscale_acl_tags 70 | } 71 | 72 | module "tailscale_azure_linux_virtual_machine" { 73 | source = "../internal-modules/azure-linux-vm" 74 | 75 | location = local.location 76 | resource_group_name = local.resource_group_name 77 | 78 | # public subnet 79 | primary_subnet_id = local.subnet_id 80 | network_security_group_id = local.network_security_group_id 81 | 82 | machine_name = local.name 83 | machine_size = local.instance_type 84 | admin_public_key_path = local.admin_public_key_path 85 | resource_tags = local.azure_tags 86 | 87 | # Variables for Tailscale resources 88 | tailscale_hostname = local.name 89 | tailscale_auth_key = tailscale_tailnet_key.main.key 90 | tailscale_set_preferences = local.tailscale_set_preferences 91 | 92 | depends_on = [ 93 | module.vpc.nat_ids, # remove if using your own VPC otherwise ensure provisioned NAT gateway is available 94 | ] 95 | } 96 | 97 | resource "azurerm_network_security_group" "tailscale_ingress" { 98 | location = local.location 99 | resource_group_name = local.resource_group_name 100 | 101 | name = "nsg-tailscale-ingress" 102 | 103 | security_rule { 104 | name = "AllowTailscaleInbound" 105 | access = "Allow" 106 | direction = "Inbound" 107 | priority = 100 108 | protocol = "Udp" 109 | source_address_prefix = "Internet" 110 | source_port_range = "*" 111 | destination_address_prefix = "*" 112 | destination_port_range = "41641" 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /terraform/azure/azure-linux-vm/outputs.tf: -------------------------------------------------------------------------------- 1 | output "vpc_id" { 2 | value = module.vpc.vnet_id 3 | } 4 | 5 | output "nat_public_ips" { 6 | value = module.vpc.nat_public_ips 7 | } 8 | 9 | output "public_subnet_id" { 10 | value = module.vpc.public_subnet_id 11 | } 12 | output "private_subnet_id" { 13 | value = module.vpc.private_subnet_id 14 | } 15 | 16 | output "private_dns_resolver_inbound_endpoint_ip" { 17 | value = module.vpc.private_dns_resolver_inbound_endpoint_ip 18 | } 19 | output "internal_domain_name_suffix" { 20 | value = module.tailscale_azure_linux_virtual_machine.internal_domain_name_suffix 21 | } 22 | 23 | output "instance_id" { 24 | value = module.tailscale_azure_linux_virtual_machine.instance_id 25 | } 26 | 27 | output "user_data_md5" { 28 | description = "MD5 hash of the VM user_data script - for detecting changes" 29 | value = module.tailscale_azure_linux_virtual_machine.user_data_md5 30 | sensitive = true 31 | } 32 | -------------------------------------------------------------------------------- /terraform/azure/azure-linux-vm/providers.tf: -------------------------------------------------------------------------------- 1 | provider "azurerm" { 2 | skip_provider_registration = true 3 | features { 4 | resource_group { 5 | prevent_deletion_if_contains_resources = false 6 | } 7 | virtual_machine { 8 | delete_os_disk_on_deletion = true 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /terraform/azure/azure-linux-vm/variables.tf: -------------------------------------------------------------------------------- 1 | # 2 | # Variables for Azure resources 3 | # 4 | variable "admin_public_key_path" { 5 | type = string 6 | } 7 | -------------------------------------------------------------------------------- /terraform/azure/azure-linux-vm/versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | tailscale = { 4 | source = "tailscale/tailscale" 5 | version = ">= 0.13.13" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /terraform/azure/internal-modules/README.md: -------------------------------------------------------------------------------- 1 | # internal-modules 2 | 3 | ## Overview 4 | 5 | This directory contains contains Terraform modules used by the examples for this provider. 6 | -------------------------------------------------------------------------------- /terraform/azure/internal-modules/azure-linux-vm/README.md: -------------------------------------------------------------------------------- 1 | # azure-linux-virtual-machine 2 | 3 | This module creates the following: 4 | 5 | - an Ubuntu Linux Virtual Machine with Tailscale installed via a userdata script 6 | - a Tailnet device key to authenticate the instance to your Tailnet 7 | -------------------------------------------------------------------------------- /terraform/azure/internal-modules/azure-linux-vm/main.tf: -------------------------------------------------------------------------------- 1 | module "tailscale_install_scripts" { 2 | source = "../../../internal-modules/tailscale-install-scripts" 3 | 4 | tailscale_auth_key = var.tailscale_auth_key 5 | tailscale_hostname = var.tailscale_hostname 6 | tailscale_set_preferences = var.tailscale_set_preferences 7 | 8 | additional_before_scripts = var.additional_before_scripts 9 | additional_after_scripts = var.additional_after_scripts 10 | } 11 | 12 | resource "azurerm_network_interface" "primary" { 13 | location = var.location 14 | resource_group_name = var.resource_group_name 15 | 16 | name = "${var.machine_name}-primary" 17 | tags = var.resource_tags 18 | 19 | internal_dns_name_label = "${var.machine_name}-primary" 20 | ip_configuration { 21 | subnet_id = var.primary_subnet_id 22 | name = "internal" 23 | private_ip_address_allocation = "Dynamic" 24 | public_ip_address_id = var.public_ip_address_id 25 | } 26 | } 27 | 28 | resource "azurerm_network_interface_security_group_association" "tailscale" { 29 | network_interface_id = azurerm_network_interface.primary.id 30 | network_security_group_id = var.network_security_group_id 31 | } 32 | 33 | resource "azurerm_linux_virtual_machine" "tailscale_instance" { 34 | location = var.location 35 | resource_group_name = var.resource_group_name 36 | 37 | name = var.machine_name 38 | tags = var.resource_tags 39 | size = var.machine_size 40 | 41 | network_interface_ids = [azurerm_network_interface.primary.id] 42 | 43 | admin_username = var.admin_username 44 | admin_ssh_key { 45 | username = var.admin_username 46 | public_key = file(var.admin_public_key_path) 47 | } 48 | 49 | os_disk { 50 | caching = "ReadWrite" 51 | storage_account_type = "Standard_LRS" 52 | } 53 | 54 | source_image_reference { 55 | publisher = "Canonical" 56 | offer = "ubuntu-24_04-lts" 57 | sku = "server" 58 | version = "latest" 59 | } 60 | 61 | user_data = module.tailscale_install_scripts.ubuntu_install_script_base64_encoded 62 | 63 | lifecycle { 64 | ignore_changes = [ 65 | source_image_reference, 66 | ] 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /terraform/azure/internal-modules/azure-linux-vm/outputs.tf: -------------------------------------------------------------------------------- 1 | output "instance_id" { 2 | value = azurerm_linux_virtual_machine.tailscale_instance.id 3 | } 4 | 5 | output "internal_domain_name_suffix" { 6 | value = azurerm_network_interface.primary.internal_domain_name_suffix 7 | } 8 | 9 | output "user_data_md5" { 10 | description = "MD5 hash of the VM user_data script - for detecting changes" 11 | value = module.tailscale_install_scripts.ubuntu_install_script_md5 12 | sensitive = true 13 | } 14 | -------------------------------------------------------------------------------- /terraform/azure/internal-modules/azure-linux-vm/variables-tailscale-install-scripts.tf: -------------------------------------------------------------------------------- 1 | # 2 | # Variables for Tailscale resources 3 | # 4 | variable "tailscale_auth_key" { 5 | description = "Tailscale auth key to authenticate the device" 6 | type = string 7 | } 8 | variable "tailscale_hostname" { 9 | description = "Hostname to assign to the device" 10 | type = string 11 | } 12 | variable "tailscale_set_preferences" { 13 | description = "Preferences to run via `tailscale set ...`. Do not include `tailscale set`." 14 | type = set(string) 15 | default = [] 16 | } 17 | 18 | # 19 | # Variables for userdata 20 | # 21 | variable "additional_before_scripts" { 22 | description = "Additional scripts to run BEFORE Tailscale scripts" 23 | type = list(string) 24 | default = [] 25 | } 26 | variable "additional_after_scripts" { 27 | description = "Additional scripts to run AFTER Tailscale scripts" 28 | type = list(string) 29 | default = [] 30 | } 31 | -------------------------------------------------------------------------------- /terraform/azure/internal-modules/azure-linux-vm/variables.tf: -------------------------------------------------------------------------------- 1 | # 2 | # Variables for all resources 3 | # 4 | variable "resource_group_name" { 5 | description = "The resource group to use" 6 | type = string 7 | } 8 | variable "location" { 9 | description = "The location to use" 10 | type = string 11 | } 12 | variable "resource_tags" { 13 | description = "Tags to assign to all resources created by this module" 14 | type = map(string) 15 | } 16 | 17 | # 18 | # Variables for virtual machine resources 19 | # 20 | variable "machine_name" { 21 | description = "The name to assign to the virtual machine" 22 | type = string 23 | } 24 | variable "primary_subnet_id" { 25 | description = "The primary subnet (typically PUBLIC) to assign to the virtual machine" 26 | type = string 27 | } 28 | variable "network_security_group_id" { 29 | description = "The network security group to assign to the virtual machine" 30 | type = string 31 | } 32 | variable "machine_size" { 33 | description = "The machine size to assign the virtual machine" 34 | type = string 35 | } 36 | variable "admin_username" { 37 | description = "The admin username to assign the virtual machine" 38 | type = string 39 | default = "ubuntu" 40 | } 41 | variable "admin_public_key_path" { 42 | description = "The filepath of the SSH public key to assign to the virtual machine" 43 | type = string 44 | } 45 | variable "public_ip_address_id" { 46 | description = "ID of the public address IP to the virtual machine" 47 | type = string 48 | default = null 49 | } 50 | -------------------------------------------------------------------------------- /terraform/azure/internal-modules/azure-linux-vm/versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | azurerm = { 4 | source = "hashicorp/azurerm" 5 | version = ">= 3.0, < 4.0" 6 | } 7 | tailscale = { 8 | source = "tailscale/tailscale" 9 | version = ">= 0.13.13" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /terraform/azure/internal-modules/azure-network/README.md: -------------------------------------------------------------------------------- 1 | # azure-network 2 | 3 | ## Overview 4 | 5 | This module creates a Azure Virtual Network (and related resources) using these community modules: 6 | 7 | - [Azure/network](https://registry.terraform.io/modules/Azure/network/azurerm/latest) 8 | -------------------------------------------------------------------------------- /terraform/azure/internal-modules/azure-network/main.tf: -------------------------------------------------------------------------------- 1 | module "vpc" { 2 | # https://registry.terraform.io/modules/Azure/network/azurerm/latest 3 | source = "Azure/network/azurerm" 4 | version = ">= 5.0, < 6.0" 5 | 6 | resource_group_location = var.location 7 | resource_group_name = var.resource_group_name 8 | 9 | vnet_name = var.name 10 | tags = var.tags 11 | 12 | address_spaces = var.cidrs 13 | subnet_prefixes = var.subnet_cidrs 14 | subnet_names = [ 15 | var.subnet_name_public, 16 | var.subnet_name_private, 17 | var.subnet_name_private_dns_resolver, 18 | ] 19 | 20 | subnet_delegation = { 21 | "${var.subnet_name_private_dns_resolver}" = [ 22 | { 23 | name = "Microsoft.Network/dnsResolvers" 24 | service_delegation = { 25 | name = "Microsoft.Network/dnsResolvers" 26 | actions = [ 27 | "Microsoft.Network/virtualNetworks/subnets/join/action", 28 | ] 29 | } 30 | } 31 | ] 32 | } 33 | 34 | use_for_each = true # https://github.com/Azure/terraform-azurerm-network#notice-to-contributor 35 | } 36 | 37 | data "azurerm_subnet" "public" { 38 | resource_group_name = var.resource_group_name 39 | 40 | virtual_network_name = module.vpc.vnet_name 41 | name = var.subnet_name_public 42 | 43 | depends_on = [module.vpc.vnet_subnets] 44 | } 45 | 46 | data "azurerm_subnet" "private" { 47 | resource_group_name = var.resource_group_name 48 | 49 | virtual_network_name = module.vpc.vnet_name 50 | name = var.subnet_name_private 51 | 52 | depends_on = [module.vpc.vnet_subnets] 53 | } 54 | 55 | data "azurerm_subnet" "dns-inbound" { 56 | resource_group_name = var.resource_group_name 57 | 58 | virtual_network_name = module.vpc.vnet_name 59 | name = var.subnet_name_private_dns_resolver 60 | 61 | depends_on = [module.vpc.vnet_subnets] 62 | } 63 | # 64 | # Private DNS resolver resources 65 | # 66 | resource "azurerm_private_dns_resolver" "main" { 67 | location = var.location 68 | resource_group_name = var.resource_group_name 69 | 70 | name = var.name 71 | tags = var.tags 72 | 73 | virtual_network_id = module.vpc.vnet_id 74 | } 75 | 76 | resource "azurerm_private_dns_resolver_inbound_endpoint" "main" { 77 | location = var.location 78 | 79 | name = var.name 80 | tags = var.tags 81 | 82 | private_dns_resolver_id = azurerm_private_dns_resolver.main.id 83 | 84 | ip_configurations { 85 | private_ip_allocation_method = "Dynamic" 86 | subnet_id = data.azurerm_subnet.dns-inbound.id 87 | } 88 | } 89 | 90 | # 91 | # NAT resources 92 | # 93 | resource "azurerm_nat_gateway" "nat" { 94 | location = var.location 95 | resource_group_name = var.resource_group_name 96 | 97 | name = var.name 98 | sku_name = "Standard" 99 | idle_timeout_in_minutes = 10 100 | } 101 | 102 | resource "azurerm_subnet_nat_gateway_association" "nat" { 103 | nat_gateway_id = azurerm_nat_gateway.nat.id 104 | subnet_id = data.azurerm_subnet.private.id 105 | } 106 | 107 | resource "azurerm_public_ip" "nat" { 108 | location = var.location 109 | resource_group_name = var.resource_group_name 110 | 111 | name = "${var.name}-nat" 112 | sku = "Standard" 113 | allocation_method = "Static" 114 | } 115 | 116 | resource "azurerm_nat_gateway_public_ip_association" "nat" { 117 | nat_gateway_id = azurerm_nat_gateway.nat.id 118 | public_ip_address_id = azurerm_public_ip.nat.id 119 | } 120 | -------------------------------------------------------------------------------- /terraform/azure/internal-modules/azure-network/outputs.tf: -------------------------------------------------------------------------------- 1 | output "vnet_id" { 2 | value = module.vpc.vnet_id 3 | } 4 | output "vnet_name" { 5 | value = module.vpc.vnet_name 6 | } 7 | output "vnet_address_space" { 8 | value = module.vpc.vnet_address_space 9 | } 10 | output "vnet_subnets" { 11 | value = module.vpc.vnet_subnets 12 | } 13 | 14 | output "public_subnet_id" { 15 | value = data.azurerm_subnet.public.id 16 | } 17 | output "public_subnet_name" { 18 | value = data.azurerm_subnet.public.name 19 | } 20 | 21 | output "private_subnet_id" { 22 | value = data.azurerm_subnet.private.id 23 | } 24 | output "private_subnet_name" { 25 | value = data.azurerm_subnet.private 26 | } 27 | 28 | output "dns_inbound_subnet_id" { 29 | value = data.azurerm_subnet.dns-inbound.id 30 | } 31 | output "dns_inbound_subnet_name" { 32 | value = data.azurerm_subnet.dns-inbound.name 33 | } 34 | 35 | output "private_dns_resolver_inbound_endpoint_ip" { 36 | value = azurerm_private_dns_resolver_inbound_endpoint.main.ip_configurations[0].private_ip_address 37 | } 38 | 39 | output "nat_public_ips" { 40 | value = azurerm_public_ip.nat.*.ip_address 41 | } 42 | 43 | output "nat_ids" { 44 | description = "Useful for using within `depends_on` for other resources" 45 | value = azurerm_nat_gateway.nat.*.id 46 | } 47 | -------------------------------------------------------------------------------- /terraform/azure/internal-modules/azure-network/variables.tf: -------------------------------------------------------------------------------- 1 | # 2 | # Variables for all resources 3 | # 4 | variable "resource_group_name" { 5 | description = "Name of Resource Group for all resources" 6 | type = string 7 | } 8 | variable "location" { 9 | description = "Location for all resources" 10 | type = string 11 | } 12 | variable "name" { 13 | description = "Name for all resources" 14 | type = string 15 | } 16 | variable "tags" { 17 | description = "Map of tags to add to all resources" 18 | type = map(string) 19 | } 20 | 21 | # 22 | # Variables for network resources 23 | # 24 | variable "cidrs" { 25 | description = "IPv4 CIDR block for the VPC" 26 | type = list(string) 27 | } 28 | variable "subnet_cidrs" { 29 | description = "List of CIDR blocks" 30 | type = list(string) 31 | } 32 | variable "subnet_name_public" { 33 | description = "Name of the `public` subnet" 34 | type = string 35 | } 36 | variable "subnet_name_private" { 37 | description = "Name of the `private` subnet" 38 | type = string 39 | } 40 | variable "subnet_name_private_dns_resolver" { 41 | description = "Name of the `dns resolver` subnet" 42 | type = string 43 | } 44 | -------------------------------------------------------------------------------- /terraform/azure/internal-modules/azure-network/versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | azurerm = { 4 | source = "hashicorp/azurerm" 5 | version = ">= 3.0, < 4.0" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /terraform/google/README.md: -------------------------------------------------------------------------------- 1 | # google 2 | 3 | ## Overview 4 | 5 | This directory contains [Terraform](https://www.terraform.io) examples for common Tailscale deployments on Google Cloud Platform. 6 | 7 | ## Prerequisites 8 | 9 | The examples assume prior experience with Terraform and Google Cloud Platform - their concepts, operations, and configuration. 10 | 11 | ## To use 12 | 13 | Each subdirectory contains a readme explaining its use. The `shared-modules` directory contains Terraform modules used by the examples. 14 | -------------------------------------------------------------------------------- /terraform/google/google-compute-instance/README.md: -------------------------------------------------------------------------------- 1 | # google-compute-instance 2 | 3 | This example creates the following: 4 | 5 | - a Google Cloud VPC network using the [Google VPC module](https://registry.terraform.io/modules/terraform-google-modules/network/google/latest) 6 | from the Terraform Registry 7 | - a Google Cloud Router and NAT using the [Google Cloud Router module](https://registry.terraform.io/modules/terraform-google-modules/cloud-router/google/latest) 8 | - a Google virtual machine running Tailscale in a public subnet 9 | - a Tailnet device key to authenticate the Tailscale device 10 | 11 | ## To use 12 | 13 | Follow the documentation to configure the Terraform providers: 14 | 15 | - [Tailscale](https://registry.terraform.io/providers/tailscale/tailscale/latest/docs) 16 | - [Google](https://registry.terraform.io/providers/hashicorp/google/latest/docs) 17 | 18 | ### Deploy 19 | 20 | ```shell 21 | terraform init 22 | terraform apply 23 | ``` 24 | 25 | ## To destroy 26 | 27 | ```shell 28 | terraform destroy 29 | ``` 30 | -------------------------------------------------------------------------------- /terraform/google/google-compute-instance/main.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | name = "example-${basename(path.cwd)}" 3 | 4 | google_metadata = { 5 | Name = local.name 6 | } 7 | 8 | tailscale_acl_tags = [ 9 | "tag:example-infra", 10 | "tag:example-exitnode", 11 | "tag:example-subnetrouter", 12 | "tag:example-appconnector", 13 | ] 14 | tailscale_set_preferences = [ 15 | "--auto-update", 16 | "--ssh", 17 | "--advertise-connector", 18 | "--advertise-exit-node", 19 | "--advertise-routes=${join(",", coalescelist( 20 | local.vpc_cidr_block, 21 | ))}", 22 | ] 23 | 24 | // Modify these to use your own VPC 25 | project_id = var.project_id 26 | region = var.region 27 | zone = var.zone 28 | vpc_cidr_block = module.vpc.subnets_ips 29 | subnet_id = module.vpc.subnets_ids[0] 30 | instance_type = "e2-medium" 31 | instance_tags = ["tailscale-instance"] 32 | } 33 | 34 | module "vpc" { 35 | source = "../internal-modules/google-vpc" 36 | 37 | project_id = local.project_id 38 | region = local.region 39 | 40 | name = local.name 41 | 42 | subnets = [ 43 | { 44 | subnet_name = "subnet-${local.region}-10-0-121" 45 | subnet_ip = "10.0.121.0/24" 46 | subnet_region = local.region 47 | }, 48 | { 49 | subnet_name = "subnet-${local.region}-10-0-122" 50 | subnet_ip = "10.0.122.0/24" 51 | subnet_region = local.region 52 | } 53 | ] 54 | } 55 | 56 | resource "tailscale_tailnet_key" "main" { 57 | ephemeral = true 58 | preauthorized = true 59 | reusable = true 60 | recreate_if_invalid = "always" 61 | tags = local.tailscale_acl_tags 62 | } 63 | 64 | module "tailscale_instance" { 65 | source = "../internal-modules/google-compute-instance" 66 | 67 | zone = local.zone 68 | machine_name = local.name 69 | machine_type = local.instance_type 70 | subnet = local.subnet_id 71 | 72 | instance_metadata = local.google_metadata 73 | instance_tags = local.instance_tags 74 | 75 | # Variables for Tailscale resources 76 | tailscale_hostname = local.name 77 | tailscale_auth_key = tailscale_tailnet_key.main.key 78 | tailscale_set_preferences = local.tailscale_set_preferences 79 | 80 | depends_on = [ 81 | module.vpc.nat_ids, # remove if using your own VPC otherwise ensure provisioned NAT gateway is available 82 | ] 83 | } 84 | 85 | resource "google_compute_firewall" "tailscale_ingress_ipv4" { 86 | name = "${local.name}-tailscale-ingress-ipv4" 87 | network = module.vpc.vpc_id 88 | 89 | allow { 90 | protocol = "udp" 91 | ports = ["41641"] 92 | } 93 | 94 | source_ranges = [ 95 | "0.0.0.0/0", 96 | ] 97 | target_tags = local.instance_tags 98 | } 99 | 100 | resource "google_compute_firewall" "tailscale_ingress_ipv6" { 101 | name = "${local.name}-tailscale-ingress-ipv6" 102 | network = module.vpc.vpc_id 103 | 104 | allow { 105 | protocol = "udp" 106 | ports = ["41641"] 107 | } 108 | 109 | source_ranges = [ 110 | "::/0", 111 | ] 112 | target_tags = local.instance_tags 113 | } 114 | -------------------------------------------------------------------------------- /terraform/google/google-compute-instance/outputs.tf: -------------------------------------------------------------------------------- 1 | output "instance_id" { 2 | value = module.tailscale_instance.instance_id 3 | } 4 | 5 | output "user_data_md5" { 6 | description = "MD5 hash of the VM user_data script - for detecting changes" 7 | value = module.tailscale_instance.user_data_md5 8 | sensitive = true 9 | } 10 | -------------------------------------------------------------------------------- /terraform/google/google-compute-instance/variables.tf: -------------------------------------------------------------------------------- 1 | # 2 | # Variables for Google resources 3 | # 4 | variable "project_id" { 5 | description = "The Google Cloud project ID to deploy to" 6 | type = string 7 | } 8 | variable "region" { 9 | description = "The Google Cloud region to deploy to" 10 | type = string 11 | default = "us-central1" 12 | } 13 | variable "zone" { 14 | description = "The Google Cloud zone to deploy to" 15 | type = string 16 | default = "us-central1-a" 17 | } 18 | -------------------------------------------------------------------------------- /terraform/google/google-compute-instance/versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | tailscale = { 4 | source = "tailscale/tailscale" 5 | version = ">= 0.13.13" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /terraform/google/internal-modules/README.md: -------------------------------------------------------------------------------- 1 | # internal-modules 2 | 3 | ## Overview 4 | 5 | This directory contains contains Terraform modules used by the examples for this provider. 6 | -------------------------------------------------------------------------------- /terraform/google/internal-modules/google-compute-instance/README.md: -------------------------------------------------------------------------------- 1 | # google-compute-instance 2 | 3 | This module creates the following: 4 | 5 | - an Ubuntu Google Compute instance with Tailscale installed via a userdata script 6 | - a Tailnet device key to authenticate the instance to your Tailnet 7 | -------------------------------------------------------------------------------- /terraform/google/internal-modules/google-compute-instance/main.tf: -------------------------------------------------------------------------------- 1 | module "tailscale_install_scripts" { 2 | source = "../../../internal-modules/tailscale-install-scripts" 3 | 4 | tailscale_auth_key = var.tailscale_auth_key 5 | tailscale_hostname = var.tailscale_hostname 6 | tailscale_set_preferences = var.tailscale_set_preferences 7 | 8 | additional_before_scripts = var.additional_before_scripts 9 | additional_after_scripts = var.additional_after_scripts 10 | } 11 | 12 | data "google_compute_subnetwork" "selected" { 13 | self_link = "https://www.googleapis.com/compute/v1/${var.subnet}" # requires full URL - https://github.com/hashicorp/terraform-provider-google/issues/9919 14 | } 15 | 16 | data "google_compute_image" "ubuntu" { 17 | project = "ubuntu-os-cloud" 18 | family = "ubuntu-2404-lts-amd64" 19 | } 20 | 21 | resource "google_compute_instance" "tailscale_instance" { 22 | zone = var.zone 23 | name = var.machine_name 24 | machine_type = var.machine_type 25 | 26 | boot_disk { 27 | initialize_params { 28 | image = data.google_compute_image.ubuntu.self_link 29 | } 30 | } 31 | 32 | network_interface { 33 | subnetwork = var.subnet 34 | access_config { 35 | // Ephemeral public IP 36 | } 37 | } 38 | 39 | metadata = var.instance_metadata 40 | tags = var.instance_tags 41 | 42 | metadata_startup_script = module.tailscale_install_scripts.ubuntu_install_script 43 | } 44 | -------------------------------------------------------------------------------- /terraform/google/internal-modules/google-compute-instance/outputs.tf: -------------------------------------------------------------------------------- 1 | output "instance_id" { 2 | value = google_compute_instance.tailscale_instance.id 3 | } 4 | 5 | output "user_data_md5" { 6 | description = "MD5 hash of the VM user_data script - for detecting changes" 7 | value = module.tailscale_install_scripts.ubuntu_install_script_md5 8 | sensitive = true 9 | } 10 | -------------------------------------------------------------------------------- /terraform/google/internal-modules/google-compute-instance/variables-tailscale-install-scripts.tf: -------------------------------------------------------------------------------- 1 | # 2 | # Variables for Tailscale resources 3 | # 4 | variable "tailscale_auth_key" { 5 | description = "Tailscale auth key to authenticate the device" 6 | type = string 7 | } 8 | variable "tailscale_hostname" { 9 | description = "Hostname to assign to the device" 10 | type = string 11 | } 12 | variable "tailscale_set_preferences" { 13 | description = "Preferences to run via `tailscale set ...`. Do not include `tailscale set`." 14 | type = set(string) 15 | default = [] 16 | } 17 | 18 | # 19 | # Variables for userdata 20 | # 21 | variable "additional_before_scripts" { 22 | description = "Additional scripts to run BEFORE Tailscale scripts" 23 | type = list(string) 24 | default = [] 25 | } 26 | variable "additional_after_scripts" { 27 | description = "Additional scripts to run AFTER Tailscale scripts" 28 | type = list(string) 29 | default = [] 30 | } 31 | -------------------------------------------------------------------------------- /terraform/google/internal-modules/google-compute-instance/variables.tf: -------------------------------------------------------------------------------- 1 | # 2 | # Variables for instance resources 3 | # 4 | variable "zone" { 5 | type = string 6 | } 7 | variable "subnet" { 8 | type = string 9 | } 10 | variable "machine_name" { 11 | type = string 12 | } 13 | variable "machine_type" { 14 | type = string 15 | } 16 | variable "instance_metadata" { 17 | type = map(string) 18 | } 19 | variable "instance_tags" { 20 | type = set(string) 21 | } 22 | -------------------------------------------------------------------------------- /terraform/google/internal-modules/google-compute-instance/versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | google = { 4 | source = "hashicorp/google" 5 | version = ">= 4.0, < 5.0" 6 | } 7 | tailscale = { 8 | source = "tailscale/tailscale" 9 | version = ">= 0.13.13" 10 | } 11 | } 12 | } 13 | 14 | -------------------------------------------------------------------------------- /terraform/google/internal-modules/google-vpc/README.md: -------------------------------------------------------------------------------- 1 | # google-vpc 2 | 3 | ## Overview 4 | 5 | This module creates a Google Cloud VPC network and Cloud Router (and related resources) using these community modules: 6 | 7 | - [terraform-google-modules/network](https://registry.terraform.io/modules/terraform-google-modules/network/google/latest) 8 | - [terraform-google-modules/cloud-router](https://registry.terraform.io/modules/terraform-google-modules/cloud-router/google/latest) 9 | -------------------------------------------------------------------------------- /terraform/google/internal-modules/google-vpc/main.tf: -------------------------------------------------------------------------------- 1 | module "vpc" { 2 | # https://registry.terraform.io/modules/terraform-google-modules/network/google/latest 3 | source = "terraform-google-modules/network/google" 4 | version = ">= 7.0, < 8.0" 5 | 6 | project_id = var.project_id 7 | network_name = var.name 8 | 9 | subnets = var.subnets 10 | } 11 | 12 | module "cloud_router" { 13 | # https://registry.terraform.io/modules/terraform-google-modules/cloud-router/google/latest 14 | source = "terraform-google-modules/cloud-router/google" 15 | version = ">= 6.0, < 7.0" 16 | 17 | project = var.project_id 18 | region = var.region 19 | 20 | name = var.name 21 | network = module.vpc.network_name 22 | 23 | nats = [{ 24 | name = var.name 25 | source_subnetwork_ip_ranges_to_nat = "LIST_OF_SUBNETWORKS" 26 | subnetworks = [ 27 | { 28 | name = module.vpc.subnets_names[0] 29 | source_ip_ranges_to_nat = ["PRIMARY_IP_RANGE"] 30 | } 31 | ] 32 | }] 33 | } 34 | -------------------------------------------------------------------------------- /terraform/google/internal-modules/google-vpc/outputs.tf: -------------------------------------------------------------------------------- 1 | output "vpc_id" { 2 | value = module.vpc.network_id 3 | } 4 | 5 | output "subnets_ids" { 6 | value = module.vpc.subnets_ids 7 | } 8 | 9 | output "subnets_ips" { 10 | value = module.vpc.subnets_ips 11 | } 12 | 13 | output "nat_ids" { 14 | description = "Useful for using within `depends_on` for other resources" 15 | value = [for nat in module.cloud_router.nat : nat.id] 16 | } 17 | -------------------------------------------------------------------------------- /terraform/google/internal-modules/google-vpc/variables.tf: -------------------------------------------------------------------------------- 1 | # 2 | # Variables for all resources 3 | # 4 | variable "project_id" { 5 | description = "The Google Cloud project ID to deploy to" 6 | type = string 7 | } 8 | variable "region" { 9 | description = "The Google Cloud region to deploy to" 10 | type = string 11 | } 12 | variable "name" { 13 | description = "Name for all resources" 14 | type = string 15 | } 16 | 17 | # 18 | # Variables for network resources 19 | # 20 | variable "subnets" { 21 | description = "List of subnet CIDR blocks" 22 | type = list(object( 23 | { subnet_name = string, 24 | subnet_ip = string, 25 | subnet_region = string 26 | } 27 | )) 28 | } 29 | -------------------------------------------------------------------------------- /terraform/google/internal-modules/google-vpc/versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | google = { 4 | source = "hashicorp/google" 5 | version = ">= 4.0, < 5.0" 6 | } 7 | } 8 | } 9 | 10 | -------------------------------------------------------------------------------- /terraform/internal-modules/README.md: -------------------------------------------------------------------------------- 1 | # internal-modules 2 | 3 | ## Overview 4 | 5 | This directory contains contains Terraform modules used by the examples. 6 | -------------------------------------------------------------------------------- /terraform/internal-modules/tailscale-advertise-routes/README.md: -------------------------------------------------------------------------------- 1 | # saas-route-lists 2 | 3 | Scripts to download, parse, and save various SaaS IP and domain lists to advertise via a Tailscale App Connector or Subnet Router. 4 | 5 | ## Usage 6 | 7 | ```hcl 8 | module "tailscale-advertise-routes" { 9 | source = "../../internal-modules/tailscale-advertise-routes" 10 | 11 | tailscale_advertise_aws_service_names = ["GLOBALACCELERATOR"] 12 | tailscale_advertise_routes = [module.vpc.vpc_cidr_block] # ensure initial routes list is re-added 13 | } 14 | 15 | module "tailscale_aws_ec2_autoscaling" { 16 | source = "../internal-modules/aws-ec2-autoscaling/" 17 | 18 | // other inputs omitted 19 | 20 | additional_after_scripts = [ 21 | module.tailscale-advertise-routes.routes_script, 22 | ] 23 | } 24 | ``` 25 | -------------------------------------------------------------------------------- /terraform/internal-modules/tailscale-advertise-routes/aws.tf: -------------------------------------------------------------------------------- 1 | variable "tailscale_advertise_aws_service_names" { 2 | description = "List of AWS Services to retrieve IP prefixes for - e.g. ['GLOBALACCELERATOR','AMAZON']" 3 | type = set(string) 4 | default = [] 5 | } 6 | 7 | locals { 8 | aws_routes_script = length(var.tailscale_advertise_aws_service_names) == 0 ? null : templatefile( 9 | "${path.module}/scripts/get-routes-aws.tftpl", 10 | { 11 | tailscale_advertise_aws_service_names = var.tailscale_advertise_aws_service_names, 12 | routes_file_to_append = var.tailscale_advertise_routes_from_file_on_host, 13 | } 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /terraform/internal-modules/tailscale-advertise-routes/main.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | routes_to_advertise = ( 3 | # boolean - do we have any routes to advertise? 4 | length(var.tailscale_advertise_routes) 5 | + length(var.tailscale_advertise_aws_service_names) 6 | ) == 0 7 | 8 | saas_routes_to_advertise = ( 9 | # boolean - do we have any **SaaS** routes to advertise? 10 | length(var.tailscale_advertise_aws_service_names) 11 | ) == 0 12 | 13 | advertise_routes_script = local.routes_to_advertise ? "" : templatefile( 14 | "${path.module}/scripts/advertise-routes.tftpl", 15 | { 16 | tailscale_advertise_routes = join(",", var.tailscale_advertise_routes), 17 | tailscale_advertise_routes_from_file_on_host = local.saas_routes_to_advertise ? "" : var.tailscale_advertise_routes_from_file_on_host 18 | } 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /terraform/internal-modules/tailscale-advertise-routes/outputs.tf: -------------------------------------------------------------------------------- 1 | /** 2 | * See other files for vendor-specific variables/outputs - `aws.tf`, etc. 3 | */ 4 | 5 | output "routes_script" { 6 | description = "Sript to fetch, parse, and save routes to `var.routes_file_to_append`" 7 | value = join("\n", compact([ 8 | local.aws_routes_script, 9 | local.advertise_routes_script, 10 | ])) 11 | } 12 | output "routes_file_to_append" { 13 | description = "File on the host with (sorted and distinct) routes" 14 | value = var.tailscale_advertise_routes_from_file_on_host 15 | } 16 | -------------------------------------------------------------------------------- /terraform/internal-modules/tailscale-advertise-routes/scripts/advertise-routes.tftpl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Advertise routes with `tailscale set --advertise-routes...` so we can merge routes provided 4 | # directly to the user data script (likely VPC or Subnet CIDRs) and routes provided by a file on 5 | # the host (likely a long list of routes for a SaaS provider). 6 | # 7 | 8 | echo -e '\n#\n# Beginning `tailscale set --advertise-routes...` configuration...\n#\n' 9 | 10 | %{ if tailscale_advertise_routes_from_file_on_host != "" ~} 11 | ROUTES_FROM_FILE=$(cat ${tailscale_advertise_routes_from_file_on_host} | tr '\n' ',' | sed 's/[,]$//') 12 | %{ else ~} 13 | # No `tailscale_advertise_routes_from_file_on_host` provided during Terraform run. 14 | %{ endif ~} 15 | 16 | %{ if tailscale_advertise_routes != "" ~} 17 | ROUTES=${tailscale_advertise_routes} 18 | %{ else ~} 19 | # No `tailscale_advertise_routes` provided during Terraform run. 20 | %{ endif ~} 21 | 22 | if [ "$ROUTES" != "" ] && [ "$ROUTES_FROM_FILE" != "" ]; then 23 | ROUTES="$ROUTES,$ROUTES_FROM_FILE" 24 | elif [ "$ROUTES_FROM_FILE" != "" ]; then 25 | ROUTES="$ROUTES_FROM_FILE" 26 | elif [ "$ROUTES" != "" ]; then 27 | ROUTES="$ROUTES" 28 | fi 29 | 30 | tailscale set --advertise-routes=$ROUTES 31 | 32 | echo -e '\n#\n# Complete.\n#\n' 33 | -------------------------------------------------------------------------------- /terraform/internal-modules/tailscale-advertise-routes/scripts/get-routes-aws.tftpl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Download and parse a json file from the vendor to create a list of routes to advertise 4 | # by Tailscale. The list is saved appended to a file that may have other routes already added. 5 | # 6 | # https://docs.aws.amazon.com/vpc/latest/userguide/aws-ip-ranges.html 7 | # 8 | 9 | echo -e '\n#\n# Beginning AWS routes fetching, parsing, and saving to [${routes_file_to_append}]...\n#\n' 10 | 11 | which jq > /dev/null # TODO: move to shared script? 12 | if [ $? -ne 0 ]; then 13 | apt-get -qq update 14 | apt-get -yqq install jq 15 | echo -e '\n#\n# jq installation complete.\n#\n' 16 | fi 17 | 18 | OUTPUT_FILE_TMP=/tmp/routes-output-aws.txt 19 | OUTPUT_FILE=${routes_file_to_append} 20 | 21 | JSON_FILE=routes-input-aws.json 22 | curl -s 'https://ip-ranges.amazonaws.com/ip-ranges.json' > $JSON_FILE 23 | 24 | %{ for s in tailscale_advertise_aws_service_names ~} 25 | jq -r '.prefixes[] | select(.service == "${s}").ip_prefix' $JSON_FILE >> $OUTPUT_FILE_TMP 26 | jq -r '.ipv6_prefixes[] | select(.service == "${s}").ipv6_prefix' $JSON_FILE >> $OUTPUT_FILE_TMP 27 | %{ endfor ~} 28 | 29 | cat $OUTPUT_FILE_TMP | sort | uniq >> $OUTPUT_FILE # append to file to not overwrite routes from other sources 30 | 31 | echo -e '\n#\n# Complete.\n#\n' 32 | -------------------------------------------------------------------------------- /terraform/internal-modules/tailscale-advertise-routes/variables.tf: -------------------------------------------------------------------------------- 1 | /** 2 | * See other files for vendor-specific variables/outputs - `aws.tf`, etc. 3 | */ 4 | 5 | variable "tailscale_advertise_routes_from_file_on_host" { 6 | description = "File on the host to append (sorted and distinct) routes to" 7 | type = string 8 | default = "/root/tailscale-routes-to-advertise.txt" 9 | } 10 | variable "tailscale_advertise_routes" { 11 | description = "List of subnets to advertise" 12 | type = set(string) 13 | default = [] 14 | } 15 | -------------------------------------------------------------------------------- /terraform/internal-modules/tailscale-install-scripts/README.md: -------------------------------------------------------------------------------- 1 | # tailscale-install-scripts 2 | 3 | Tailscale installation and configuration scripts used by the various other examples in this repo. 4 | -------------------------------------------------------------------------------- /terraform/internal-modules/tailscale-install-scripts/main.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | ubuntu_install_script = templatefile( 3 | "${path.module}/scripts/tailscale-ubuntu.tftpl", 4 | { 5 | tailscale_auth_key = var.tailscale_auth_key, 6 | tailscale_arguments = local.tailscale_arguments, 7 | tailscale_set_preferences = var.tailscale_set_preferences, 8 | 9 | before_scripts = flatten([ # scripts to run BEFORE tailscale install 10 | var.additional_before_scripts, 11 | local.ip_forwarding_script, 12 | local.netplan_dual_subnet_script, 13 | local.ethtool_udp_optimization_script, # run after netplan script 14 | ]), 15 | 16 | after_scripts = flatten([ # scripts to run AFTER tailscale install 17 | var.additional_after_scripts, 18 | ]), 19 | } 20 | ) 21 | 22 | netplan_dual_subnet_script = var.secondary_subnet_cidr == null ? "" : templatefile( 23 | "${path.module}/scripts/additional-scripts/netplan-dual-subnet.tftpl", 24 | { 25 | primary_subnet_cidr = var.primary_subnet_cidr, 26 | secondary_subnet_cidr = var.secondary_subnet_cidr, 27 | } 28 | ) 29 | 30 | tailscale_arguments = [ 31 | "--authkey=${var.tailscale_auth_key}", 32 | "--hostname=${var.tailscale_hostname}", 33 | ] 34 | 35 | ip_forwarding_required = length([for x in var.tailscale_set_preferences : x if strcontains(x, "advertise")]) > 0 36 | ip_forwarding_script = local.ip_forwarding_required == false ? "" : templatefile("${path.module}/scripts/additional-scripts/ip-forwarding.tftpl", {}) 37 | 38 | ethtool_udp_optimization_script = templatefile("${path.module}/scripts/additional-scripts/ethtool-udp.tftpl", {}) 39 | } 40 | -------------------------------------------------------------------------------- /terraform/internal-modules/tailscale-install-scripts/outputs.tf: -------------------------------------------------------------------------------- 1 | output "ubuntu_install_script" { 2 | value = local.ubuntu_install_script 3 | } 4 | 5 | output "ubuntu_install_script_base64_encoded" { 6 | value = base64encode(local.ubuntu_install_script) 7 | } 8 | 9 | output "ubuntu_install_script_md5" { 10 | description = "MD5 hash of the VM user_data script - for detecting changes" 11 | value = md5(local.ubuntu_install_script) 12 | } 13 | -------------------------------------------------------------------------------- /terraform/internal-modules/tailscale-install-scripts/scripts/additional-scripts/ethtool-udp.tftpl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Tailscale version 1.54 or later used with a Linux 6.2 or later kernel enables UDP throughput improvements via transport layer offloads. 4 | # https://tailscale.com/s/ethtool-config-udp-gro 5 | # 6 | 7 | echo -e '\n#\n# Beginning ethtool udp optimization configuration...\n#\n' 8 | 9 | NETDEV=$(ip -o route get 8.8.8.8 | cut -f 5 -d " ") 10 | sudo ethtool -K $NETDEV rx-udp-gro-forwarding on rx-gro-list off 11 | 12 | printf '#!/bin/sh\n\nethtool -K %s rx-udp-gro-forwarding on rx-gro-list off \n' "$(ip -o route get 8.8.8.8 | cut -f 5 -d " ")" | sudo tee /etc/networkd-dispatcher/routable.d/50-tailscale 13 | sudo chmod 755 /etc/networkd-dispatcher/routable.d/50-tailscale 14 | 15 | sudo /etc/networkd-dispatcher/routable.d/50-tailscale 16 | test $? -eq 0 || echo 'An error occurred.' 17 | 18 | echo -e '\n#\n# Complete.\n#\n' 19 | -------------------------------------------------------------------------------- /terraform/internal-modules/tailscale-install-scripts/scripts/additional-scripts/ip-forwarding.tftpl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Enables IP forwarding 4 | # 5 | 6 | echo -e '\n#\n# Beginning IP forwarding configuration...\n#\n' 7 | 8 | echo 'net.ipv4.ip_forward = 1' | sudo tee -a /etc/sysctl.conf 9 | echo 'net.ipv6.conf.all.forwarding = 1' | sudo tee -a /etc/sysctl.conf 10 | sysctl -p /etc/sysctl.conf 11 | 12 | echo -e '\n#\n# Complete.\n#\n' 13 | -------------------------------------------------------------------------------- /terraform/internal-modules/tailscale-install-scripts/scripts/additional-scripts/netplan-dual-subnet.tftpl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Configures routing via netplan to accept inbound requests on the public interface 4 | # and routes traffic for the public internet on the private interface (behind a NAT) 5 | # 6 | 7 | echo -e '\n#\n# Beginning dual-subnet netplan configuration...\n#\n' 8 | 9 | TAILSCALE_NETPLAN_FILE=/etc/netplan/51-tailscale-custom-routes.yaml 10 | 11 | PRIMARY_NETDEV=$(ip route show ${primary_subnet_cidr} | cut -f3 -d' ') 12 | SECONDARY_NETDEV=$(ip route show ${secondary_subnet_cidr} | cut -f3 -d' ') 13 | 14 | cat < $TAILSCALE_NETPLAN_FILE 15 | network: 16 | ethernets: 17 | $PRIMARY_NETDEV: # public interface 18 | dhcp4: true 19 | dhcp4-overrides: 20 | use-routes: false # prevent default route via dhcp on 2nd interface from being installed into default routing table 21 | dhcp6: false 22 | match: 23 | macaddress: $(cat /sys/class/net/$PRIMARY_NETDEV/address) 24 | set-name: $PRIMARY_NETDEV 25 | routes: 26 | - table: 51 # higher priority route table than Tailscale 27 | to: 0.0.0.0/0 # public internet 28 | via: ${cidrhost(primary_subnet_cidr, "1")} # default gateway for PUBLIC subnet 29 | routing-policy: 30 | - table: 51 # higher priority route table than Tailscale 31 | priority: 5100 # install the policy "above" other tailscaled policies (see ip rule ls) that start at priority 5210 32 | mark: 524288 # SO_MARK value used by the control and WireGuard traffic in tailscaled 33 | from: 0.0.0.0/0 34 | $SECONDARY_NETDEV: # private interface 35 | dhcp4: true 36 | dhcp4-overrides: 37 | route-metric: 100 38 | dhcp6: false 39 | match: 40 | macaddress: $(cat /sys/class/net/$SECONDARY_NETDEV/address) 41 | set-name: $SECONDARY_NETDEV 42 | version: 2 43 | EOT 44 | 45 | chmod 600 $TAILSCALE_NETPLAN_FILE 46 | 47 | mv /etc/netplan/50-cloud-init.yaml /etc/netplan/50-cloud-init.yaml.old 48 | 49 | netplan apply 50 | 51 | systemctl list-unit-files tailscaled.service > /dev/null 52 | if [ $? -eq 0 ]; then 53 | systemctl restart tailscaled 54 | echo -e '\n#\n# Tailscale restart complete.\n#\n' 55 | fi 56 | 57 | # 58 | # pause briefly to let route changes "settle" 59 | # without this, immediate network connections (e.g. curl google.com) fail with 'unknown host' 60 | # 61 | sleep 1 62 | 63 | echo -e '\n#\n# Complete.\n#\n' 64 | -------------------------------------------------------------------------------- /terraform/internal-modules/tailscale-install-scripts/scripts/tailscale-ubuntu.tftpl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Installs tailscale, runs `tailscale up`, and runs additional scripts if provided 4 | # 5 | 6 | exec > >(tee /var/log/tailscale-user-data.log|logger -t tailscale-user-data -s 2>/dev/console) 2>&1 7 | 8 | %{ for s in before_scripts } 9 | ${s} 10 | %{ endfor } 11 | 12 | echo -e '\n#\n# Beginning Tailscale installation...\n#\n' 13 | 14 | # https://tailscale.com/kb/1187/install-ubuntu-2204/ 15 | curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.noarmor.gpg | sudo tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null 16 | curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.tailscale-keyring.list | sudo tee /etc/apt/sources.list.d/tailscale.list 17 | 18 | apt-get -qq update 19 | apt-get install -yqq tailscale 20 | 21 | tailscale up ${join(" ", tailscale_arguments)} 22 | 23 | %{ for s in tailscale_set_preferences } 24 | tailscale set ${s} 25 | %{ endfor } 26 | 27 | echo -e '\n#\n# Complete.\n#\n' 28 | 29 | %{ for s in after_scripts } 30 | ${s} 31 | %{ endfor } 32 | 33 | tailscale status --peers=false 2>&1 1> /dev/null && echo -e '\n#\n# Tailscale status: connected\n#\n' || echo -e '\n#\n# Tailscale status: NOT connected\n#\n' 34 | -------------------------------------------------------------------------------- /terraform/internal-modules/tailscale-install-scripts/variables-for-modules.tf: -------------------------------------------------------------------------------- 1 | # 2 | # Variables for dual subnet routing resources 3 | # Typically only used by other modules in this repo - not passed in by the examples themselves. 4 | # 5 | variable "primary_subnet_cidr" { 6 | description = "For Dual Subnet only - the CIDR Block of the primary (PUBLIC) subnet. Used to derive the gateway IP." 7 | type = string 8 | default = null 9 | } 10 | variable "secondary_subnet_cidr" { 11 | description = "For Dual Subnet only - the CIDR Block of the secondary (PRIVATE) subnet. Used to derive the gateway IP." 12 | type = string 13 | default = null 14 | } 15 | -------------------------------------------------------------------------------- /terraform/internal-modules/tailscale-install-scripts/variables.tf: -------------------------------------------------------------------------------- 1 | # 2 | # Variables for Tailscale resources 3 | # 4 | variable "tailscale_auth_key" { 5 | description = "Tailscale auth key to authenticate the device" 6 | type = string 7 | } 8 | variable "tailscale_hostname" { 9 | description = "Hostname to assign to the device" 10 | type = string 11 | } 12 | variable "tailscale_set_preferences" { 13 | description = "Preferences to run via `tailscale set ...`. Do not include `tailscale set`." 14 | type = set(string) 15 | default = [] 16 | } 17 | 18 | # 19 | # Variables for userdata 20 | # 21 | variable "additional_before_scripts" { 22 | description = "Additional scripts to run BEFORE Tailscale scripts" 23 | type = list(string) 24 | default = [] 25 | } 26 | variable "additional_after_scripts" { 27 | description = "Additional scripts to run AFTER Tailscale scripts" 28 | type = list(string) 29 | default = [] 30 | } 31 | -------------------------------------------------------------------------------- /terraform/repo-scripts/README.md: -------------------------------------------------------------------------------- 1 | # scripts 2 | 3 | Scripts for maintaining the Terraform examples within this repository. These scripts are not used by the examples themselves. 4 | -------------------------------------------------------------------------------- /terraform/repo-scripts/check-terraform-fmt.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cmd="terraform fmt -check -recursive $@" 4 | # echo "running [$cmd]" 5 | $cmd 6 | -------------------------------------------------------------------------------- /terraform/repo-scripts/check-variables-tailscale-install-scripts.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # This scripts compares the variables file in $MODULE_VARIABLES_PATH to all 4 | # instances of $EXAMPLE_VARIABLES_FILENAME to ensure they are identical. 5 | # 6 | 7 | function get_md5() { 8 | MD5_COMMAND='md5sum --tag' 9 | if [ $(uname) == "Darwin" ]; then 10 | MD5_COMMAND='md5' 11 | fi 12 | 13 | $MD5_COMMAND $1 | cut -d' ' -f4 14 | } 15 | 16 | STARTING_DIRECTORY=$1 17 | if [ "$STARTING_DIRECTORY" == "" ]; then 18 | echo "Must pass the directory to start in as the first argument." 19 | exit 1 20 | fi 21 | 22 | MODULE_VARIABLES_PATH="$STARTING_DIRECTORY/internal-modules/tailscale-install-scripts/variables.tf" 23 | MODULE_VARIABLES_MD5=$(get_md5 $MODULE_VARIABLES_PATH) 24 | echo "md5 of [$MODULE_VARIABLES_PATH] is [$MODULE_VARIABLES_MD5]" 25 | 26 | EXAMPLE_VARIABLES_FILENAME=variables-tailscale-install-scripts.tf 27 | 28 | ERRORS_FOUND=0 29 | 30 | for file in $(find . -type f -name $EXAMPLE_VARIABLES_FILENAME) 31 | do 32 | FILE_MD5=$(get_md5 $file) 33 | 34 | if [ "$FILE_MD5" != "$MODULE_VARIABLES_MD5" ]; then 35 | echo "File [$file] does not match [$MODULE_VARIABLES_PATH]" 36 | ERRORS_FOUND=$((ERRORS_FOUND+1)) 37 | fi 38 | done 39 | 40 | if [ $ERRORS_FOUND -ne 0 ]; then 41 | printf "\n#\n# [$ERRORS_FOUND] ERRORS FOUND\n#\n" 42 | echo "Found downstream files that do not match [$MODULE_VARIABLES_PATH]." 43 | echo "Copy [$MODULE_VARIABLES_PATH] to each location of [$EXAMPLE_VARIABLES_FILENAME] to fix." 44 | exit 1 45 | fi 46 | -------------------------------------------------------------------------------- /terraform/repo-scripts/fix-terraform-fmt.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cmd="terraform fmt -recursive $@" 4 | # echo "running [$cmd]" 5 | $cmd 6 | -------------------------------------------------------------------------------- /terraform/repo-scripts/fix-variables-tailscale-install-scripts.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | MODULE_PATH=internal-modules/tailscale-install-scripts 4 | DESTINATION_FILENAME=variables-tailscale-install-scripts.tf 5 | 6 | for file in $(find . -name $DESTINATION_FILENAME | grep -v $MODULE_PATH); do 7 | cmd="cp $MODULE_PATH/variables.tf $(dirname $file)/$DESTINATION_FILENAME" 8 | # echo "running [$cmd]" 9 | $cmd 10 | done 11 | 12 | --------------------------------------------------------------------------------