├── .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 | [](https://tailscale.com/kb/1167/release-stages/#experimental)
4 |
5 |
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 | 
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 |
--------------------------------------------------------------------------------