├── .coderabbit.yaml ├── .editorconfig ├── .github ├── CODEOWNERS ├── PULL_REQUEST_TEMPLATE.md ├── renovate.json5 └── workflows │ ├── lint.yaml │ ├── release-please.yaml │ ├── test.yaml │ └── trunk-upgrade.yaml ├── .gitignore ├── .terraform-docs.yaml ├── .trunk ├── .gitignore ├── configs │ ├── .checkov.yaml │ ├── .markdownlint.yaml │ ├── .shellcheckrc │ └── .yamllint.yaml └── trunk.yaml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── aqua.yaml ├── context.tf ├── examples └── complete │ ├── context.tf │ ├── fixtures.us-east-2.tfvars │ ├── main.tf │ ├── outputs.tf │ ├── variables.tf │ └── versions.tf ├── main.tf ├── outputs.tf ├── userdata.sh.tmpl ├── variables.tf └── versions.tf /.coderabbit.yaml: -------------------------------------------------------------------------------- 1 | # Docs: https://docs.coderabbit.ai/configure-coderabbit 2 | # Schema: https://coderabbit.ai/integrations/schema.v2.json 3 | # Support: https://discord.gg/GsXnASn26c 4 | 5 | language: en 6 | 7 | tone_instructions: | 8 | Provide feedback in a professional, friendly, constructive, and concise tone. 9 | Offer clear, specific suggestions and best practices to help enhance the code quality and promote learning. 10 | Be concise and only comment on significant issues. 11 | 12 | early_access: true 13 | 14 | knowledge_base: 15 | # The scope of learnings to use for the knowledge base. 16 | # `local` uses the repository's learnings, 17 | # `global` uses the organization's learnings, 18 | # `auto` uses repository's learnings for public repositories and organization's learnings for private repositories. 19 | # Default value: `auto` 20 | learnings: 21 | scope: global 22 | issues: 23 | scope: global 24 | pull_requests: 25 | scope: global 26 | 27 | reviews: 28 | profile: chill 29 | auto_review: 30 | # Disable incremental code review on each push 31 | auto_incremental_review: false 32 | # The keywords are case-insensitive 33 | ignore_title_keywords: 34 | - wip 35 | - draft 36 | - test 37 | commit_status: false 38 | path_instructions: 39 | - path: "**/*.tf" 40 | instructions: | 41 | You're a Terraform expert who has thoroughly studied all the documentation from Hashicorp https://developer.hashicorp.com/terraform/docs and OpenTofu https://opentofu.org/docs/. 42 | You have a strong grasp of Terraform syntax and prioritize providing accurate and insightful code suggestions. 43 | As a fan of the Cloud Posse / SweetOps ecosystem, you incorporate many of their best practices https://docs.cloudposse.com/best-practices/terraform/ while balancing them with general Terraform guidelines. 44 | changed_files_summary: false 45 | poem: false 46 | # Don't post review details on each review. 47 | review_status: false 48 | sequence_diagrams: false 49 | tools: 50 | # By default, all tools are enabled. 51 | # Masterpoint uses Trunk (https://trunk.io) so we do not need a lot of this feedback due to overlap. 52 | shellcheck: 53 | enabled: false 54 | ruff: 55 | enabled: false 56 | markdownlint: 57 | enabled: false 58 | github-checks: 59 | enabled: false 60 | languagetool: 61 | enabled: false 62 | biome: 63 | enabled: false 64 | hadolint: 65 | enabled: false 66 | swiftlint: 67 | enabled: false 68 | phpstan: 69 | enabled: false 70 | golangci-lint: 71 | enabled: false 72 | yamllint: 73 | enabled: false 74 | gitleaks: 75 | enabled: false 76 | checkov: 77 | enabled: false 78 | detekt: 79 | enabled: false 80 | eslint: 81 | enabled: false 82 | rubocop: 83 | enabled: false 84 | buf: 85 | enabled: false 86 | regal: 87 | enabled: false 88 | actionlint: 89 | enabled: false 90 | pmd: 91 | enabled: false 92 | cppcheck: 93 | enabled: false 94 | circleci: 95 | enabled: false 96 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Unix-style newlines with a newline ending every file 2 | [*] 3 | charset = utf-8 4 | end_of_line = lf 5 | indent_size = 2 6 | indent_style = space 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | 10 | [*.md] 11 | max_line_length = 0 12 | 13 | [COMMIT_EDITMSG] 14 | max_line_length = 0 15 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Use this file to define individuals or teams that are responsible for code in a repository. 2 | # Read more: 3 | # 4 | # Order is important: the last matching pattern takes the most precedence 5 | 6 | # These owners will be the default owners for everything 7 | * @masterpointio/masterpoint-open-source -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## what 2 | 3 | - Describe high-level what changed as a result of these commits (i.e. in plain-english, what do these changes mean?) 4 | - Use bullet points to be concise and to the point. 5 | 6 | ## why 7 | 8 | - Provide the justifications for the changes (e.g. business case). 9 | - Describe why these changes were made (e.g. why do these commits fix the problem?) 10 | - Use bullet points to be concise and to the point. 11 | 12 | ## references 13 | 14 | - Link to any supporting GitHub issues or helpful documentation to add some context (e.g. Stackoverflow). 15 | - Use `closes #123`, if this PR closes a GitHub issue `#123` 16 | -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:best-practices", 4 | "github>aquaproj/aqua-renovate-config#2.7.5" 5 | ], 6 | "enabledManagers": [ 7 | "terraform", 8 | "github-actions" 9 | ], 10 | "terraform": { 11 | "ignorePaths": [ 12 | "**/context.tf" // Mixin file https://github.com/cloudposse/terraform-null-label/blob/main/exports/context.tf 13 | ], 14 | "fileMatch": [ 15 | "\\.tf$", 16 | "\\.tofu$" 17 | ] 18 | }, 19 | "schedule": [ 20 | "after 9am on the first day of the month" 21 | ], 22 | "assigneesFromCodeOwners": true, 23 | "dependencyDashboardAutoclose": true, 24 | "addLabels": ["{{manager}}"], 25 | "packageRules": [ 26 | { 27 | "matchManagers": ["github-actions"], 28 | "matchUpdateTypes": ["minor", "patch", "pin", "digest"], 29 | "automerge": true, 30 | "automergeType": "branch", 31 | "groupName": "github-actions-auto-upgrade", 32 | "addLabels": ["auto-upgrade"] 33 | }, 34 | { 35 | "matchManagers": ["github-actions"], 36 | "matchUpdateTypes": ["major"], 37 | "groupName": "github-actions-needs-review", 38 | "addLabels": ["needs-review"] 39 | }, 40 | { 41 | "matchManagers": ["terraform"], 42 | "groupName": "tf", 43 | "addLabels": ["needs-review"] 44 | }, 45 | { 46 | "matchFileNames": ["**/*.tofu", "**/*.tf"], 47 | "matchDatasources": ["terraform-provider", "terraform-module"], 48 | "registryUrls": ["https://registry.opentofu.org"], 49 | "groupName": "tf" 50 | }, 51 | { 52 | "matchFileNames": ["**/*.tofu"], 53 | "matchDepTypes": ["required_version"], 54 | "registryUrls": ["https://registry.opentofu.org"], 55 | "groupName": "tf" 56 | }, 57 | { 58 | "matchFileNames": ["**/*.tf"], 59 | "matchDepTypes": ["required_version"], 60 | "registryUrls": ["https://registry.terraform.io"], 61 | "groupName": "tf" 62 | } 63 | ] 64 | } 65 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | concurrency: 4 | group: lint-${{ github.head_ref || github.run_id }} 5 | cancel-in-progress: true 6 | 7 | on: pull_request 8 | 9 | permissions: 10 | actions: read 11 | checks: write 12 | contents: read 13 | pull-requests: read 14 | 15 | jobs: 16 | lint: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Check out Git repository 20 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 21 | - name: Trunk Check 22 | uses: trunk-io/trunk-action@4d5ecc89b2691705fd08c747c78652d2fc806a94 # v1.1.19 23 | 24 | conventional-title: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yaml: -------------------------------------------------------------------------------- 1 | name: Release Please 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | issues: write 12 | 13 | jobs: 14 | release-please: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: googleapis/release-please-action@7987652d64b4581673a76e33ad5e98e3dd56832f #v4.1.3 18 | with: 19 | release-type: terraform-module 20 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: TF Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | permissions: 10 | actions: read 11 | checks: write 12 | contents: read 13 | id-token: write 14 | pull-requests: read 15 | 16 | jobs: 17 | tf-test: 18 | name: 🧪 ${{ matrix.tf }} test 19 | runs-on: ubuntu-latest 20 | strategy: 21 | matrix: 22 | tf: [tofu, terraform] 23 | steps: 24 | - uses: masterpointio/github-action-tf-test@c3b619f3bca9e4f482b9e0fb3166ab3f02d9d54c # v1.0.0 25 | with: 26 | tf_type: ${{ matrix.tf }} 27 | aws_role_arn: ${{ vars.TF_TEST_AWS_ROLE_ARN }} 28 | github_token: ${{ secrets.GITHUB_TOKEN }} 29 | -------------------------------------------------------------------------------- /.github/workflows/trunk-upgrade.yaml: -------------------------------------------------------------------------------- 1 | name: Trunk Upgrade 2 | 3 | on: 4 | schedule: 5 | # On the first day of every month @ 8am 6 | - cron: 0 8 1 * * 7 | workflow_dispatch: {} 8 | 9 | permissions: read-all 10 | 11 | jobs: 12 | trunk-upgrade: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | # For trunk to create PRs 16 | contents: write 17 | pull-requests: write 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 21 | 22 | - name: Create Token for MasterpointBot App 23 | uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a #v2.1.0 24 | id: generate-token 25 | with: 26 | app_id: ${{ secrets.MP_BOT_APP_ID }} 27 | private_key: ${{ secrets.MP_BOT_APP_PRIVATE_KEY }} 28 | 29 | - name: Upgrade 30 | id: trunk-upgrade 31 | uses: trunk-io/trunk-action/upgrade@4d5ecc89b2691705fd08c747c78652d2fc806a94 # v1.1.19 32 | with: 33 | github-token: ${{ steps.generate-token.outputs.token }} 34 | reviewers: "@masterpointio/masterpoint-internal" 35 | prefix: "chore: " 36 | 37 | - name: Merge PR automatically 38 | if: steps.trunk-upgrade.outputs.pull-request-number != '' 39 | env: 40 | GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} 41 | PR_NUMBER: ${{ steps.trunk-upgrade.outputs.pull-request-number }} 42 | run: | 43 | gh pr merge "$PR_NUMBER" --squash --auto --delete-branch 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore override files as they are usually used to override resources locally 2 | *override.tf 3 | *override.tf.json 4 | 5 | # .tfstate files 6 | *.tfstate 7 | *.tfstate.* 8 | 9 | # Local .terraform directories 10 | **/.terraform/* 11 | 12 | # Ignore the root .terraform.lock.hcl file (Child modules don't want this) 13 | .terraform.lock.hcl 14 | !examples/**/.terraform.lock.hcl 15 | 16 | # IDE/Editor settings 17 | **/.idea 18 | **/*.iml 19 | .vscode/ 20 | *.orig 21 | *.draft 22 | *~ 23 | 24 | # Build Harness https://github.com/cloudposse/build-harness 25 | **/.build-harness 26 | **/build-harness 27 | 28 | # Log files 29 | *.log 30 | 31 | # Output from other tools that might be used alongside Terraform/OpenTofu 32 | *.tfvars.json 33 | backend.tf.json 34 | 35 | # Taskit files 36 | .taskit/ 37 | .task/ 38 | .env.taskit-secrets 39 | 40 | # Other 41 | **/*.backup 42 | ***/*.tmp 43 | **/*.temp 44 | **/*.bak 45 | **/*.*swp 46 | **/.DS_Store 47 | -------------------------------------------------------------------------------- /.terraform-docs.yaml: -------------------------------------------------------------------------------- 1 | version: 0.20.0 2 | formatter: markdown table 3 | 4 | recursive: 5 | enabled: false 6 | 7 | settings: 8 | lockfile: false 9 | 10 | output: 11 | file: README.md 12 | mode: inject 13 | template: |- 14 | 15 | {{ .Content }} 16 | 17 | -------------------------------------------------------------------------------- /.trunk/.gitignore: -------------------------------------------------------------------------------- 1 | *out 2 | *logs 3 | *actions 4 | *notifications 5 | *tools 6 | plugins 7 | user_trunk.yaml 8 | user.yaml 9 | tmp 10 | -------------------------------------------------------------------------------- /.trunk/configs/.checkov.yaml: -------------------------------------------------------------------------------- 1 | skip-check: 2 | - CKV_TF_1 # Ensure module references are pinned to a commit SHA. 3 | -------------------------------------------------------------------------------- /.trunk/configs/.markdownlint.yaml: -------------------------------------------------------------------------------- 1 | # Autoformatter friendly markdownlint config (all formatting rules disabled) 2 | default: true 3 | blank_lines: false 4 | bullet: false 5 | html: false 6 | indentation: false 7 | line_length: false 8 | spaces: false 9 | url: false 10 | whitespace: false 11 | 12 | # Ignore MD041/first-line-heading/first-line-h1 13 | # Error: First line in a file should be a top-level heading 14 | MD041: false 15 | 16 | # Ignore MD013/line-length 17 | MD013: 18 | strict: false 19 | line_length: 350 20 | -------------------------------------------------------------------------------- /.trunk/configs/.shellcheckrc: -------------------------------------------------------------------------------- 1 | enable=all 2 | source-path=SCRIPTDIR 3 | disable=SC2154 4 | 5 | # If you're having issues with shellcheck following source, disable the errors via: 6 | # disable=SC1090 7 | # disable=SC1091 8 | -------------------------------------------------------------------------------- /.trunk/configs/.yamllint.yaml: -------------------------------------------------------------------------------- 1 | rules: 2 | quoted-strings: 3 | required: only-when-needed 4 | extra-allowed: ["{|}"] 5 | key-duplicates: {} 6 | octal-values: 7 | forbid-implicit-octal: true 8 | -------------------------------------------------------------------------------- /.trunk/trunk.yaml: -------------------------------------------------------------------------------- 1 | # This file controls the behavior of Trunk: https://docs.trunk.io/cli 2 | # To learn more about the format of this file, see https://docs.trunk.io/reference/trunk-yaml 3 | version: 0.1 4 | cli: 5 | version: 1.24.0 6 | # Trunk provides extensibility via plugins. (https://docs.trunk.io/plugins) 7 | plugins: 8 | sources: 9 | - id: trunk 10 | ref: v1.7.0 11 | uri: https://github.com/trunk-io/plugins 12 | # Many linters and tools depend on runtimes - configure them here. (https://docs.trunk.io/runtimes) 13 | runtimes: 14 | enabled: 15 | - node@22.16.0 16 | - python@3.10.8 17 | # This is the section where you manage your linters. (https://docs.trunk.io/check/configuration) 18 | lint: 19 | disabled: 20 | # Incompatible with some Terraform features: https://github.com/tenable/terrascan/issues/1331 21 | - terrascan 22 | enabled: 23 | - renovate@40.36.2 24 | - tofu@1.9.1 25 | - terraform@1.1.0 26 | - actionlint@1.7.7 27 | - checkov@3.2.435 28 | - git-diff-check 29 | - markdownlint@0.45.0 30 | - prettier@3.5.3 31 | - tflint@0.58.0 32 | - trivy@0.63.0 33 | - trufflehog@3.88.35 34 | - yamllint@1.37.1 35 | ignore: 36 | - linters: [tofu] 37 | paths: 38 | - "**/backend.tf.json" 39 | # Ignore CHANGELOG.md as release-please manages this file 40 | - linters: [ALL] 41 | paths: 42 | - "**/CHANGELOG.md" 43 | actions: 44 | enabled: 45 | - terraform-docs 46 | - trunk-announce 47 | - trunk-check-pre-push 48 | - trunk-fmt-pre-commit 49 | - trunk-upgrade-available 50 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.8.0](https://github.com/masterpointio/terraform-aws-tailscale/compare/v1.7.0...v1.8.0) (2025-05-22) 4 | 5 | 6 | ### Features 7 | 8 | * Expose the "architecture" variable ([#58](https://github.com/masterpointio/terraform-aws-tailscale/issues/58)) ([8662e72](https://github.com/masterpointio/terraform-aws-tailscale/commit/8662e722bccd056a64fa720ba2fecbec7f5bb51d)) 9 | 10 | ## [1.7.0](https://github.com/masterpointio/terraform-aws-tailscale/compare/v1.6.0...v1.7.0) (2025-05-16) 11 | 12 | 13 | ### Features 14 | 15 | * allow configuring additional security group rules ([#56](https://github.com/masterpointio/terraform-aws-tailscale/issues/56)) ([e854ea0](https://github.com/masterpointio/terraform-aws-tailscale/commit/e854ea03f2fe100ed9a4e6de7ece462dccc9c485)) 16 | 17 | ## [1.6.0](https://github.com/masterpointio/terraform-aws-tailscale/compare/v1.5.1...v1.6.0) (2025-01-06) 18 | 19 | 20 | ### Features 21 | 22 | * enable log rotation + install CW Agent ([#48](https://github.com/masterpointio/terraform-aws-tailscale/issues/48)) ([560774b](https://github.com/masterpointio/terraform-aws-tailscale/commit/560774b0c2a4e5a0a4bcdc06d4c060dd16db4678)) 23 | 24 | ## [1.5.1](https://github.com/masterpointio/terraform-aws-tailscale/compare/v1.5.0...v1.5.1) (2024-11-25) 25 | 26 | 27 | ### Bug Fixes 28 | 29 | * avoid RPM lock issue ([#44](https://github.com/masterpointio/terraform-aws-tailscale/issues/44)) ([30b0aca](https://github.com/masterpointio/terraform-aws-tailscale/commit/30b0acaba65aa95bee257cb46b76ddc7d8071a1b)) 30 | 31 | ## [1.5.0](https://github.com/masterpointio/terraform-aws-tailscale/compare/v1.4.0...v1.5.0) (2024-11-21) 32 | 33 | 34 | ### Features 35 | 36 | * support AWS SSM tailscaled state ([#41](https://github.com/masterpointio/terraform-aws-tailscale/issues/41)) ([4e9ef78](https://github.com/masterpointio/terraform-aws-tailscale/commit/4e9ef782a5e2f6460c9150e78972bb7e8560dd52)) 37 | 38 | ## [1.4.0](https://github.com/masterpointio/terraform-aws-tailscale/compare/v1.3.0...v1.4.0) (2024-08-20) 39 | 40 | 41 | ### Features 42 | 43 | * support extra arguments ([#28](https://github.com/masterpointio/terraform-aws-tailscale/issues/28)) ([6ff5059](https://github.com/masterpointio/terraform-aws-tailscale/commit/6ff5059a5c4a1efa0b3c81b6f92a42ee5f165e3d)) 44 | 45 | ## [1.3.0](https://github.com/masterpointio/terraform-aws-tailscale/compare/1.2.0...v1.3.0) (2024-08-13) 46 | 47 | 48 | ### Features 49 | 50 | * adds conventional-title lint check ([73abd18](https://github.com/masterpointio/terraform-aws-tailscale/commit/73abd184189ce062cba882d79ab10b183a1f117c)) 51 | * adds release-please for automated releases ([c08d7bb](https://github.com/masterpointio/terraform-aws-tailscale/commit/c08d7bbdffba9038e4e111e984dfbe2e78e1512c)) 52 | * allow configuring router as an exit node ([#24](https://github.com/masterpointio/terraform-aws-tailscale/issues/24)) ([3c30878](https://github.com/masterpointio/terraform-aws-tailscale/commit/3c30878166fc694c27cd77ace3879e2a19168556)) 53 | 54 | 55 | ### Bug Fixes 56 | 57 | * update to use kebab-case name ([f8006ff](https://github.com/masterpointio/terraform-aws-tailscale/commit/f8006ff056060edab3c2b311b014b548156d6204)) 58 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2025 Masterpoint 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Banner][banner-image]](https://masterpoint.io/) 2 | 3 | # terraform-aws-tailscale 4 | 5 | [![Release][release-badge]][latest-release] 6 | 7 | 💡 Learn more about Masterpoint [below](#who-we-are-𐦂𖨆𐀪𖠋). 8 | 9 | ## Purpose and Functionality 10 | 11 | This is a Terraform Module to create a simple, autoscaled [Tailscale Subnet Router](https://tailscale.com/kb/1019/subnets/) on EC2 instance along with generated auth key, and its corresponding IAM resources. The instance should cycle itself on a schedule. 12 | 13 | ## Usage 14 | 15 | Here's how to invoke this example module in your projects 16 | 17 | ```hcl 18 | module "vpc" { 19 | source = "cloudposse/vpc/aws" 20 | version = "2.1.1" 21 | 22 | namespace = "eg" 23 | stage = "test" 24 | name = "tailscale" 25 | 26 | ipv4_primary_cidr_block = "172.16.0.0/16" 27 | } 28 | 29 | module "subnets" { 30 | source = "cloudposse/dynamic-subnets/aws" 31 | version = "2.4.1" 32 | 33 | namespace = "eg" 34 | stage = "test" 35 | name = "tailscale" 36 | 37 | availability_zones = ["us-east-1a", "us-east-1b"] 38 | 39 | vpc_id = module.vpc.vpc_id 40 | igw_id = [module.vpc.igw_id] 41 | ipv4_cidr_block = [module.vpc.vpc_cidr_block] 42 | } 43 | 44 | module "tailscale" { 45 | source = "masterpointio/tailscale/aws" 46 | version = "X.X.X" 47 | 48 | namespace = "eg" 49 | stage = "test" 50 | name = "tailscale" 51 | 52 | vpc_id = module.vpc.vpc_id 53 | subnet_ids = module.subnets.private_subnet_ids 54 | advertise_routes = [module.vpc.vpc_cidr_block] 55 | 56 | ephemeral = true 57 | } 58 | ``` 59 | 60 | ## Examples 61 | 62 | Here is an example of using this module: 63 | 64 | - [`examples/complete`](https://github.com/masterpointio/terraform-aws-tailscale/) - complete example of using this module 65 | 66 | ## System Logging and Monitoring Setup 67 | 68 | On Linux and other Unix-like systems, Tailscale typically runs as a systemd service, which by default does not rotate logs - potentially allowing system logs to grow until the disk fills. 69 | 70 | To address this, our user data script configures both a maximum journal size and a retention period to ensure logs are periodically purged. We also install the [CloudWatch Agent](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/Install-CloudWatch-Agent.html) with its default configuration so that filesystem usage metrics are reported to AWS. 71 | 72 | 👀 To view these metrics, navigate in the AWS Console to “CWAgent” → “AutoScalingGroupName, ImageId, InstanceId, InstanceType, device, fstype, path” → “disk_used_percent” for the root path “/”. 73 | 74 | ## Direct and Relayed Connections 75 | 76 | Tailscale supports two primary types of [connection types](https://tailscale.com/kb/1257/connection-types) for subnet routers: 77 | 78 | - **Direct (peer-to-peer)**: Nodes communicate directly with each other when possible, offering better performance and reliability. 79 | - **Relayed**: Traffic is routed through Tailscale's DERP (Designated Encrypted Relay for Packets) servers when direct connectivity isn't possible (e.g. when the subnet router is in a private VPC subnet). 80 | 81 | ### Addressing Connection Stability Issues 82 | 83 | We've been using relayed connections for our subnet routers, but we've observed that relayed connections can sometimes cause intermittent connectivity issues, particularly when working with database connections through the Tailscale proxy (see [this issue](https://github.com/cyrilgdn/terraform-provider-postgresql/issues/495) for an example). 84 | 85 | These issues appear as connection timeouts or SOCKS server errors: 86 | 87 | ```sh 88 | │ Error: Error connecting to PostgreSQL server dev.example.com (scheme: postgres): socks connect tcp localhost:1055->dev.example.com:5432: unknown error general SOCKS server failure 89 | │ 90 | │ with data.postgresql_schemas.schemas["example"], 91 | │ on main.tf line 65, in data "postgresql_schemas" "schemas": 92 | │ 65: data "postgresql_schemas" "schemas" { 93 | │ 94 | ╵ 95 | netstack: decrementing connsInFlightByClient[100.0.108.92] because the packet was not handled; new value is 0 96 | [RATELIMIT] format("netstack: decrementing connsInFlightByClient[%v] because the packet was not handled; new value is %d") 97 | ``` 98 | 99 | ### Configuring Direct Connections 100 | 101 | To optimize for direct connections in your Tailscale subnet router, follow this example: 102 | 103 | ```hcl 104 | locals { 105 | public_subnets = ["subnet-1234567890", "subnet-0987654321"] 106 | vpc_id = "vpc-1234567890" 107 | direct_port = "41641" 108 | } 109 | 110 | module "tailscale" { 111 | source = "masterpointio/tailscale/aws" 112 | version = "1.6.0" # Or later 113 | ... 114 | # Direct connection configuration 115 | subnet_ids = local.public_subnets # Ensure subnet router is in a public subnet 116 | 117 | additional_security_group_ids = [module.direct_sg.id] # Attach the security group to the subnet router 118 | tailscaled_extra_flags = ["--port=${local.direct_port}"] # Ensure `tailscaled` listens on the same port as the security group is configured 119 | 120 | context = module.this.context 121 | } 122 | 123 | module "direct_sg" { 124 | source = "cloudposse/security-group/aws" 125 | version = "2.2.0" 126 | enabled = true 127 | 128 | vpc_id = local.vpc_id 129 | attributes = ["tailscale", "direct"] 130 | 131 | rules = [{ 132 | key = "direct_ingress" 133 | type = "ingress" 134 | from_port = local.direct_port 135 | to_port = local.direct_port 136 | protocol = "udp" 137 | cidr_blocks = ["0.0.0.0/0"] 138 | description = "Allow a direct Tailscale connection from any peer." 139 | }] 140 | 141 | context = module.this.context 142 | } 143 | ``` 144 | 145 | The above configuration ensures that the subnet router can establish direct connections with other Tailscale nodes: 146 | 147 | 1. It is in a public subnet and gets a public IP address. 148 | 2. The security group is attached and configured to listen on a fixed port. 149 | 3. The `tailscaled` daemon is configured to listen on the same port as the security group is configured to listen on. 150 | 4. The outgoing UDP and TCP packets on port `443` are permitted. In our example, [`cloudposse/security-group/aws`](https://github.com/cloudposse/terraform-aws-security-group) module allows all egress. 151 | 152 | 153 | 154 | 155 | ## Requirements 156 | 157 | | Name | Version | 158 | |------|---------| 159 | | [terraform](#requirement\_terraform) | >= 1.0 | 160 | | [aws](#requirement\_aws) | >= 4.0 | 161 | | [tailscale](#requirement\_tailscale) | >= 0.13.7 | 162 | 163 | ## Providers 164 | 165 | | Name | Version | 166 | |------|---------| 167 | | [aws](#provider\_aws) | >= 4.0 | 168 | | [tailscale](#provider\_tailscale) | >= 0.13.7 | 169 | 170 | ## Modules 171 | 172 | | Name | Source | Version | 173 | | -------------------------------------------------------------------------------------------------------- | ---------------------------------- | ------- | 174 | | [ssm_policy](#module_ssm_policy) | cloudposse/iam-policy/aws | 2.0.1 | 175 | | [ssm_state](#module_ssm_state) | cloudposse/ssm-parameter-store/aws | 0.13.0 | 176 | | [tailscale_subnet_router](#module_tailscale_subnet_router) | masterpointio/ssm-agent/aws | 1.4.0 | 177 | | [this](#module_this) | cloudposse/label/null | 0.25.0 | 178 | 179 | ## Resources 180 | 181 | | Name | Type | 182 | |------|------| 183 | | [aws_iam_role_policy_attachment.cw_agent](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | 184 | | [aws_iam_role_policy_attachment.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | 185 | | [tailscale_tailnet_key.default](https://registry.terraform.io/providers/tailscale/tailscale/latest/docs/resources/tailnet_key) | resource | 186 | 187 | ## Inputs 188 | 189 | | Name | Description | Type | Default | Required | 190 | | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------: | 191 | | [additional_security_group_ids](#input_additional_security_group_ids) | Additional Security Group IDs to associate with the Tailscale Subnet Router EC2 instance. | `list(string)` | `[]` | no | 192 | | [additional_security_group_rules](#input_additional_security_group_rules) | Additional security group rules that will be attached to the primary security group |
map(object({
type = string
from_port = number
to_port = number
protocol = string

description = optional(string)
cidr_blocks = optional(list(string))
ipv6_cidr_blocks = optional(list(string))
prefix_list_ids = optional(list(string))
self = optional(bool)
}))
| `{}` | no | 193 | | [additional_tag_map](#input_additional_tag_map) | Additional key-value pairs to add to each map in `tags_as_list_of_maps`. Not added to `tags` or `id`.
This is for some rare cases where resources want additional configuration of tags
and therefore take a list of maps with tag key, value, and additional configuration. | `map(string)` | `{}` | no | 194 | | [additional_tags](#input_additional_tags) | Additional Tailscale tags to apply to the Tailscale Subnet Router machine in addition to `primary_tag`. These should not include the `tag:` prefix. | `list(string)` | `[]` | no | 195 | | [advertise_routes](#input_advertise_routes) | The routes (expressed as CIDRs) to advertise as part of the Tailscale Subnet Router.
Example: ["10.0.2.0/24", "0.0.1.0/24"] | `list(string)` | `[]` | no | 196 | | [ami](#input_ami) | The AMI to use for the Tailscale Subnet Router EC2 instance.
If not provided, the latest Amazon Linux 2 AMI will be used.
Note: This will update periodically as AWS releases updates to their AL2 AMI.
Pin to a specific AMI if you would like to avoid these updates. | `string` | `""` | no | 197 | | [associate_public_ip_address](#input_associate_public_ip_address) | Associate public IP address with subnet router | `bool` | `null` | no | 198 | | [attributes](#input_attributes) | ID element. Additional attributes (e.g. `workers` or `cluster`) to add to `id`,
in the order they appear in the list. New attributes are appended to the
end of the list. The elements of the list are joined by the `delimiter`
and treated as a single ID element. | `list(string)` | `[]` | no | 199 | | [context](#input_context) | Single object for setting entire context at once.
See description of individual variables for details.
Leave string and numeric variables as `null` to use default value.
Individual variable settings (non-null) override settings in context object,
except for attributes, tags, and additional_tag_map, which are merged. | `any` |
{
"additional_tag_map": {},
"attributes": [],
"delimiter": null,
"descriptor_formats": {},
"enabled": true,
"environment": null,
"id_length_limit": null,
"label_key_case": null,
"label_order": [],
"label_value_case": null,
"labels_as_tags": [
"unset"
],
"name": null,
"namespace": null,
"regex_replace_chars": null,
"stage": null,
"tags": {},
"tenant": null
}
| no | 200 | | [create_run_shell_document](#input_create_run_shell_document) | Whether or not to create the SSM-SessionManagerRunShell SSM Document. | `bool` | `true` | no | 201 | | [delimiter](#input_delimiter) | Delimiter to be used between ID elements.
Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. | `string` | `null` | no | 202 | | [descriptor_formats](#input_descriptor_formats) | Describe additional descriptors to be output in the `descriptors` output map.
Map of maps. Keys are names of descriptors. Values are maps of the form
`{
format = string
labels = list(string)
}`
(Type is `any` so the map values can later be enhanced to provide additional options.)
`format` is a Terraform format string to be passed to the `format()` function.
`labels` is a list of labels, in order, to pass to `format()` function.
Label values will be normalized before being passed to `format()` so they will be
identical to how they appear in `id`.
Default is `{}` (`descriptors` output will be empty). | `any` | `{}` | no | 203 | | [desired_capacity](#input_desired_capacity) | Desired number of instances in the Auto Scaling Group | `number` | `1` | no | 204 | | [enabled](#input_enabled) | Set to false to prevent the module from creating any resources | `bool` | `null` | no | 205 | | [environment](#input_environment) | ID element. Usually used for region e.g. 'uw2', 'us-west-2', OR role 'prod', 'staging', 'dev', 'UAT' | `string` | `null` | no | 206 | | [ephemeral](#input_ephemeral) | Indicates if the key is ephemeral. | `bool` | `false` | no | 207 | | [exit_node_enabled](#input_exit_node_enabled) | Advertise Tailscale Subnet Router EC2 instance as exit node. Defaults to false. | `bool` | `false` | no | 208 | | [expiry](#input_expiry) | The expiry of the auth key in seconds. | `number` | `7776000` | no | 209 | | [id_length_limit](#input_id_length_limit) | Limit `id` to this many characters (minimum 6).
Set to `0` for unlimited length.
Set to `null` for keep the existing setting, which defaults to `0`.
Does not affect `id_full`. | `number` | `null` | no | 210 | | [instance_type](#input_instance_type) | The instance type to use for the Tailscale Subnet Router EC2 instance. | `string` | `"t4g.nano"` | no | 211 | | [journald_max_retention_sec](#input_journald_max_retention_sec) | The maximum time to store journal entries. | `string` | `"7d"` | no | 212 | | [journald_system_max_use](#input_journald_system_max_use) | Disk space the journald may use up at most | `string` | `"200M"` | no | 213 | | [key_pair_name](#input_key_pair_name) | The name of the key-pair to associate with the Tailscale Subnet Router EC2 instance. | `string` | `null` | no | 214 | | [label_key_case](#input_label_key_case) | Controls the letter case of the `tags` keys (label names) for tags generated by this module.
Does not affect keys of tags passed in via the `tags` input.
Possible values: `lower`, `title`, `upper`.
Default value: `title`. | `string` | `null` | no | 215 | | [label_order](#input_label_order) | The order in which the labels (ID elements) appear in the `id`.
Defaults to ["namespace", "environment", "stage", "name", "attributes"].
You can omit any of the 6 labels ("tenant" is the 6th), but at least one must be present. | `list(string)` | `null` | no | 216 | | [label_value_case](#input_label_value_case) | Controls the letter case of ID elements (labels) as included in `id`,
set as tag values, and output by this module individually.
Does not affect values of tags passed in via the `tags` input.
Possible values: `lower`, `title`, `upper` and `none` (no transformation).
Set this to `title` and set `delimiter` to `""` to yield Pascal Case IDs.
Default value: `lower`. | `string` | `null` | no | 217 | | [labels_as_tags](#input_labels_as_tags) | Set of labels (ID elements) to include as tags in the `tags` output.
Default is to include all labels.
Tags with empty values will not be included in the `tags` output.
Set to `[]` to suppress all generated tags.
**Notes:**
The value of the `name` tag, if included, will be the `id`, not the `name`.
Unlike other `null-label` inputs, the initial setting of `labels_as_tags` cannot be
changed in later chained modules. Attempts to change it will be silently ignored. | `set(string)` |
[
"default"
]
| no | 218 | | [max_size](#input_max_size) | Maximum number of instances in the Auto Scaling Group. Must be >= desired_capacity. | `number` | `2` | no | 219 | | [min_size](#input_min_size) | Minimum number of instances in the Auto Scaling Group | `number` | `1` | no | 220 | | [monitoring_enabled](#input_monitoring_enabled) | Enable detailed monitoring of instances | `bool` | `true` | no | 221 | | [name](#input_name) | ID element. Usually the component or solution name, e.g. 'app' or 'jenkins'.
This is the only ID element not also included as a `tag`.
The "name" tag is set to the full `id` string. There is no tag with the value of the `name` input. | `string` | `null` | no | 222 | | [namespace](#input_namespace) | ID element. Usually an abbreviation of your organization name, e.g. 'eg' or 'cp', to help ensure generated IDs are globally unique | `string` | `null` | no | 223 | | [preauthorized](#input_preauthorized) | Determines whether or not the machines authenticated by the key will be authorized for the tailnet by default. | `bool` | `true` | no | 224 | | [primary_tag](#input_primary_tag) | The primary tag to apply to the Tailscale Subnet Router machine. Do not include the `tag:` prefix. This must match the OAuth client's tag. If not provided, the module will use the module's ID as the primary tag, which is configured in context.tf | `string` | `null` | no | 225 | | [regex_replace_chars](#input_regex_replace_chars) | Terraform regular expression (regex) string.
Characters matching the regex will be removed from the ID elements.
If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. | `string` | `null` | no | 226 | | [reusable](#input_reusable) | Indicates if the key is reusable or single-use. | `bool` | `true` | no | 227 | | [session_logging_enabled](#input_session_logging_enabled) | To enable CloudWatch and S3 session logging or not.
Note this does not apply to SSH sessions as AWS cannot log those sessions. | `bool` | `true` | no | 228 | | [session_logging_kms_key_alias](#input_session_logging_kms_key_alias) | Alias name for `session_logging` KMS Key.
This is only applied if 2 conditions are met: (1) `session_logging_kms_key_arn` is unset,
(2) `session_logging_encryption_enabled` = true. | `string` | `"alias/session_logging"` | no | 229 | | [session_logging_ssm_document_name](#input_session_logging_ssm_document_name) | Name for `session_logging` SSM document.
This is only applied if 2 conditions are met: (1) `session_logging_enabled` = true,
(2) `create_run_shell_document` = true. | `string` | `"SSM-SessionManagerRunShell-Tailscale"` | no | 230 | | [ssh_enabled](#input_ssh_enabled) | Enable SSH access to the Tailscale Subnet Router EC2 instance. Defaults to true. | `bool` | `true` | no | 231 | | [ssm_state_enabled](#input_ssm_state_enabled) | Control if tailscaled state is stored in AWS SSM (including preferences and keys).
This tells the Tailscale daemon to write + read state from SSM,
which unlocks important features like retaining the existing tailscale machine name.
See more in the [docs](https://tailscale.com/kb/1278/tailscaled#flags-to-tailscaled). | `bool` | `false` | no | 232 | | [stage](#input_stage) | ID element. Usually used to indicate role, e.g. 'prod', 'staging', 'source', 'build', 'test', 'deploy', 'release' | `string` | `null` | no | 233 | | [subnet_ids](#input_subnet_ids) | The Subnet IDs which the Tailscale Subnet Router EC2 instance will run in. These _should_ be private subnets. | `list(string)` | n/a | yes | 234 | | [tags](#input_tags) | Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`).
Neither the tag keys nor the tag values will be modified by this module. | `map(string)` | `{}` | no | 235 | | [tailscale_up_extra_flags](#input_tailscale_up_extra_flags) | Extra flags to pass to `tailscale up` for advanced configuration.
See more in the [docs](https://tailscale.com/kb/1241/tailscale-up). | `list(string)` | `[]` | no | 236 | | [tailscaled_extra_flags](#input_tailscaled_extra_flags) | Extra flags to pass to Tailscale daemon for advanced configuration. Example: ["--state=mem:"]
See more in the [docs](https://tailscale.com/kb/1278/tailscaled#flags-to-tailscaled). | `list(string)` | `[]` | no | 237 | | [tenant](#input_tenant) | ID element \_(Rarely used, not included by default)\_. A customer identifier, indicating who this instance of a resource is for | `string` | `null` | no | 238 | | [user_data](#input_user_data) | The user_data to use for the Tailscale Subnet Router EC2 instance.
You can use this to automate installation of all the required command line tools. | `string` | `""` | no | 239 | | [vpc_id](#input_vpc_id) | The ID of the VPC which the Tailscale Subnet Router EC2 instance will run in. | `string` | n/a | yes | 240 | 241 | ## Outputs 242 | 243 | | Name | Description | 244 | |------|-------------| 245 | | [autoscaling\_group\_id](#output\_autoscaling\_group\_id) | The ID of the Tailscale Subnet Router EC2 instance Autoscaling Group. | 246 | | [instance\_name](#output\_instance\_name) | The name tag value of the Tailscale Subnet Router EC2 instance. | 247 | | [launch\_template\_id](#output\_launch\_template\_id) | The ID of the Tailscale Subnet Router EC2 instance Launch Template. | 248 | | [security\_group\_id](#output\_security\_group\_id) | The ID of the Tailscale Subnet Router EC2 instance Security Group. | 249 | 250 | 251 | 252 | 253 | ## Built By 254 | 255 | Powered by the [Masterpoint team](https://masterpoint.io/who-we-are/) and driven forward by contributions from the community ❤️ 256 | 257 | [![Contributors][contributors-image]][contributors-url] 258 | 259 | ## Contribution Guidelines 260 | 261 | Contributions are welcome and appreciated! 262 | 263 | Found an issue or want to request a feature? [Open an issue][issues-url] 264 | 265 | Want to fix a bug you found or add some functionality? Fork, clone, commit, push, and PR — we'll check it out. 266 | 267 | ## Who We Are 𐦂𖨆𐀪𖠋 268 | 269 | Established in 2016, Masterpoint is a team of experienced software and platform engineers specializing in Infrastructure as Code (IaC). We provide expert guidance to organizations of all sizes, helping them leverage the latest IaC practices to accelerate their engineering teams. 270 | 271 | ### Our Mission 272 | 273 | Our mission is to simplify cloud infrastructure so developers can innovate faster, safer, and with greater confidence. By open-sourcing tools and modules that we use internally, we aim to contribute back to the community, promoting consistency, quality, and security. 274 | 275 | ### Our Commitments 276 | 277 | - 🌟 **Open Source**: We live and breathe open source, contributing to and maintaining hundreds of projects across multiple organizations. 278 | - 🌎 **1% for the Planet**: Demonstrating our commitment to environmental sustainability, we are proud members of [1% for the Planet](https://www.onepercentfortheplanet.org), pledging to donate 1% of our annual sales to environmental nonprofits. 279 | - 🇺🇦 **1% Towards Ukraine**: With team members and friends affected by the ongoing [Russo-Ukrainian war](https://en.wikipedia.org/wiki/Russo-Ukrainian_War), we donate 1% of our annual revenue to invasion relief efforts, supporting organizations providing aid to those in need. [Here's how you can help Ukraine with just a few clicks](https://masterpoint.io/updates/supporting-ukraine/). 280 | 281 | ## Connect With Us 282 | 283 | We're active members of the community and are always publishing content, giving talks, and sharing our hard earned expertise. Here are a few ways you can see what we're up to: 284 | 285 | [![LinkedIn][linkedin-badge]][linkedin-url] [![Newsletter][newsletter-badge]][newsletter-url] [![Blog][blog-badge]][blog-url] [![YouTube][youtube-badge]][youtube-url] 286 | 287 | ... and be sure to connect with our founder, [Matt Gowie](https://www.linkedin.com/in/gowiem/). 288 | 289 | ## License 290 | 291 | [Apache License, Version 2.0][license-url]. 292 | 293 | [![Open Source Initiative][osi-image]][license-url] 294 | 295 | Copyright © 2016-2025 [Masterpoint Consulting LLC](https://masterpoint.io/) 296 | 297 | 298 | 299 | [banner-image]: https://masterpoint-public.s3.us-west-2.amazonaws.com/v2/standard-long-fullcolor.png 300 | [license-url]: https://opensource.org/license/apache-2-0 301 | [osi-image]: https://i0.wp.com/opensource.org/wp-content/uploads/2023/03/cropped-OSI-horizontal-large.png?fit=250%2C229&ssl=1 302 | [linkedin-badge]: https://img.shields.io/badge/LinkedIn-Follow-0A66C2?style=for-the-badge&logoColor=white 303 | [linkedin-url]: https://www.linkedin.com/company/masterpoint-consulting 304 | [blog-badge]: https://img.shields.io/badge/Blog-IaC_Insights-55C1B4?style=for-the-badge&logoColor=white 305 | [blog-url]: https://masterpoint.io/updates/ 306 | [newsletter-badge]: https://img.shields.io/badge/Newsletter-Subscribe-ECE295?style=for-the-badge&logoColor=222222 307 | [newsletter-url]: https://newsletter.masterpoint.io/ 308 | [youtube-badge]: https://img.shields.io/badge/YouTube-Subscribe-D191BF?style=for-the-badge&logo=youtube&logoColor=white 309 | [youtube-url]: https://www.youtube.com/channel/UCeeDaO2NREVlPy9Plqx-9JQ 310 | [release-badge]: https://img.shields.io/github/v/release/masterpointio/terraform-aws-tailscale?color=0E383A&label=Release&style=for-the-badge&logo=github&logoColor=white 311 | [latest-release]: https://github.com/masterpointio/terraform-aws-tailscale/releases/latest 312 | [contributors-image]: https://contrib.rocks/image?repo=masterpointio/terraform-aws-tailscale 313 | [contributors-url]: https://github.com/masterpointio/terraform-aws-tailscale/graphs/contributors 314 | [issues-url]: https://github.com/masterpointio/terraform-aws-tailscale/issues 315 | -------------------------------------------------------------------------------- /aqua.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # aqua - Declarative CLI Version Manager 3 | # https://aquaproj.github.io/ 4 | # checksum: 5 | # enabled: true 6 | # require_checksum: true 7 | # supported_envs: 8 | # - all 9 | registries: 10 | - type: standard 11 | ref: v4.355.0 # renovate: depName=aquaproj/aqua-registry 12 | packages: 13 | - name: terraform-docs/terraform-docs@v0.20.0 14 | - name: hashicorp/terraform@v1.11.4 15 | tags: [terraform] 16 | - name: opentofu/opentofu@v1.9.1 17 | tags: [tofu] 18 | -------------------------------------------------------------------------------- /context.tf: -------------------------------------------------------------------------------- 1 | # 2 | # ONLY EDIT THIS FILE IN github.com/cloudposse/terraform-null-label 3 | # All other instances of this file should be a copy of that one 4 | # 5 | # 6 | # Copy this file from https://github.com/cloudposse/terraform-null-label/blob/master/exports/context.tf 7 | # and then place it in your Terraform module to automatically get 8 | # Cloud Posse's standard configuration inputs suitable for passing 9 | # to Cloud Posse modules. 10 | # 11 | # curl -sL https://raw.githubusercontent.com/cloudposse/terraform-null-label/master/exports/context.tf -o context.tf 12 | # 13 | # Modules should access the whole context as `module.this.context` 14 | # to get the input variables with nulls for defaults, 15 | # for example `context = module.this.context`, 16 | # and access individual variables as `module.this.`, 17 | # with final values filled in. 18 | # 19 | # For example, when using defaults, `module.this.context.delimiter` 20 | # will be null, and `module.this.delimiter` will be `-` (hyphen). 21 | # 22 | 23 | module "this" { 24 | source = "cloudposse/label/null" 25 | version = "0.25.0" # requires Terraform >= 0.13.0 26 | 27 | enabled = var.enabled 28 | namespace = var.namespace 29 | tenant = var.tenant 30 | environment = var.environment 31 | stage = var.stage 32 | name = var.name 33 | delimiter = var.delimiter 34 | attributes = var.attributes 35 | tags = var.tags 36 | additional_tag_map = var.additional_tag_map 37 | label_order = var.label_order 38 | regex_replace_chars = var.regex_replace_chars 39 | id_length_limit = var.id_length_limit 40 | label_key_case = var.label_key_case 41 | label_value_case = var.label_value_case 42 | descriptor_formats = var.descriptor_formats 43 | labels_as_tags = var.labels_as_tags 44 | 45 | context = var.context 46 | } 47 | 48 | # Copy contents of cloudposse/terraform-null-label/variables.tf here 49 | 50 | variable "context" { 51 | type = any 52 | default = { 53 | enabled = true 54 | namespace = null 55 | tenant = null 56 | environment = null 57 | stage = null 58 | name = null 59 | delimiter = null 60 | attributes = [] 61 | tags = {} 62 | additional_tag_map = {} 63 | regex_replace_chars = null 64 | label_order = [] 65 | id_length_limit = null 66 | label_key_case = null 67 | label_value_case = null 68 | descriptor_formats = {} 69 | # Note: we have to use [] instead of null for unset lists due to 70 | # https://github.com/hashicorp/terraform/issues/28137 71 | # which was not fixed until Terraform 1.0.0, 72 | # but we want the default to be all the labels in `label_order` 73 | # and we want users to be able to prevent all tag generation 74 | # by setting `labels_as_tags` to `[]`, so we need 75 | # a different sentinel to indicate "default" 76 | labels_as_tags = ["unset"] 77 | } 78 | description = <<-EOT 79 | Single object for setting entire context at once. 80 | See description of individual variables for details. 81 | Leave string and numeric variables as `null` to use default value. 82 | Individual variable settings (non-null) override settings in context object, 83 | except for attributes, tags, and additional_tag_map, which are merged. 84 | EOT 85 | 86 | validation { 87 | condition = lookup(var.context, "label_key_case", null) == null ? true : contains(["lower", "title", "upper"], var.context["label_key_case"]) 88 | error_message = "Allowed values: `lower`, `title`, `upper`." 89 | } 90 | 91 | validation { 92 | condition = lookup(var.context, "label_value_case", null) == null ? true : contains(["lower", "title", "upper", "none"], var.context["label_value_case"]) 93 | error_message = "Allowed values: `lower`, `title`, `upper`, `none`." 94 | } 95 | } 96 | 97 | variable "enabled" { 98 | type = bool 99 | default = null 100 | description = "Set to false to prevent the module from creating any resources" 101 | } 102 | 103 | variable "namespace" { 104 | type = string 105 | default = null 106 | description = "ID element. Usually an abbreviation of your organization name, e.g. 'eg' or 'cp', to help ensure generated IDs are globally unique" 107 | } 108 | 109 | variable "tenant" { 110 | type = string 111 | default = null 112 | description = "ID element _(Rarely used, not included by default)_. A customer identifier, indicating who this instance of a resource is for" 113 | } 114 | 115 | variable "environment" { 116 | type = string 117 | default = null 118 | description = "ID element. Usually used for region e.g. 'uw2', 'us-west-2', OR role 'prod', 'staging', 'dev', 'UAT'" 119 | } 120 | 121 | variable "stage" { 122 | type = string 123 | default = null 124 | description = "ID element. Usually used to indicate role, e.g. 'prod', 'staging', 'source', 'build', 'test', 'deploy', 'release'" 125 | } 126 | 127 | variable "name" { 128 | type = string 129 | default = null 130 | description = <<-EOT 131 | ID element. Usually the component or solution name, e.g. 'app' or 'jenkins'. 132 | This is the only ID element not also included as a `tag`. 133 | The "name" tag is set to the full `id` string. There is no tag with the value of the `name` input. 134 | EOT 135 | } 136 | 137 | variable "delimiter" { 138 | type = string 139 | default = null 140 | description = <<-EOT 141 | Delimiter to be used between ID elements. 142 | Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. 143 | EOT 144 | } 145 | 146 | variable "attributes" { 147 | type = list(string) 148 | default = [] 149 | description = <<-EOT 150 | ID element. Additional attributes (e.g. `workers` or `cluster`) to add to `id`, 151 | in the order they appear in the list. New attributes are appended to the 152 | end of the list. The elements of the list are joined by the `delimiter` 153 | and treated as a single ID element. 154 | EOT 155 | } 156 | 157 | variable "labels_as_tags" { 158 | type = set(string) 159 | default = ["default"] 160 | description = <<-EOT 161 | Set of labels (ID elements) to include as tags in the `tags` output. 162 | Default is to include all labels. 163 | Tags with empty values will not be included in the `tags` output. 164 | Set to `[]` to suppress all generated tags. 165 | **Notes:** 166 | The value of the `name` tag, if included, will be the `id`, not the `name`. 167 | Unlike other `null-label` inputs, the initial setting of `labels_as_tags` cannot be 168 | changed in later chained modules. Attempts to change it will be silently ignored. 169 | EOT 170 | } 171 | 172 | variable "tags" { 173 | type = map(string) 174 | default = {} 175 | description = <<-EOT 176 | Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`). 177 | Neither the tag keys nor the tag values will be modified by this module. 178 | EOT 179 | } 180 | 181 | variable "additional_tag_map" { 182 | type = map(string) 183 | default = {} 184 | description = <<-EOT 185 | Additional key-value pairs to add to each map in `tags_as_list_of_maps`. Not added to `tags` or `id`. 186 | This is for some rare cases where resources want additional configuration of tags 187 | and therefore take a list of maps with tag key, value, and additional configuration. 188 | EOT 189 | } 190 | 191 | variable "label_order" { 192 | type = list(string) 193 | default = null 194 | description = <<-EOT 195 | The order in which the labels (ID elements) appear in the `id`. 196 | Defaults to ["namespace", "environment", "stage", "name", "attributes"]. 197 | You can omit any of the 6 labels ("tenant" is the 6th), but at least one must be present. 198 | EOT 199 | } 200 | 201 | variable "regex_replace_chars" { 202 | type = string 203 | default = null 204 | description = <<-EOT 205 | Terraform regular expression (regex) string. 206 | Characters matching the regex will be removed from the ID elements. 207 | If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. 208 | EOT 209 | } 210 | 211 | variable "id_length_limit" { 212 | type = number 213 | default = null 214 | description = <<-EOT 215 | Limit `id` to this many characters (minimum 6). 216 | Set to `0` for unlimited length. 217 | Set to `null` for keep the existing setting, which defaults to `0`. 218 | Does not affect `id_full`. 219 | EOT 220 | validation { 221 | condition = var.id_length_limit == null ? true : var.id_length_limit >= 6 || var.id_length_limit == 0 222 | error_message = "The id_length_limit must be >= 6 if supplied (not null), or 0 for unlimited length." 223 | } 224 | } 225 | 226 | variable "label_key_case" { 227 | type = string 228 | default = null 229 | description = <<-EOT 230 | Controls the letter case of the `tags` keys (label names) for tags generated by this module. 231 | Does not affect keys of tags passed in via the `tags` input. 232 | Possible values: `lower`, `title`, `upper`. 233 | Default value: `title`. 234 | EOT 235 | 236 | validation { 237 | condition = var.label_key_case == null ? true : contains(["lower", "title", "upper"], var.label_key_case) 238 | error_message = "Allowed values: `lower`, `title`, `upper`." 239 | } 240 | } 241 | 242 | variable "label_value_case" { 243 | type = string 244 | default = null 245 | description = <<-EOT 246 | Controls the letter case of ID elements (labels) as included in `id`, 247 | set as tag values, and output by this module individually. 248 | Does not affect values of tags passed in via the `tags` input. 249 | Possible values: `lower`, `title`, `upper` and `none` (no transformation). 250 | Set this to `title` and set `delimiter` to `""` to yield Pascal Case IDs. 251 | Default value: `lower`. 252 | EOT 253 | 254 | validation { 255 | condition = var.label_value_case == null ? true : contains(["lower", "title", "upper", "none"], var.label_value_case) 256 | error_message = "Allowed values: `lower`, `title`, `upper`, `none`." 257 | } 258 | } 259 | 260 | variable "descriptor_formats" { 261 | type = any 262 | default = {} 263 | description = <<-EOT 264 | Describe additional descriptors to be output in the `descriptors` output map. 265 | Map of maps. Keys are names of descriptors. Values are maps of the form 266 | `{ 267 | format = string 268 | labels = list(string) 269 | }` 270 | (Type is `any` so the map values can later be enhanced to provide additional options.) 271 | `format` is a Terraform format string to be passed to the `format()` function. 272 | `labels` is a list of labels, in order, to pass to `format()` function. 273 | Label values will be normalized before being passed to `format()` so they will be 274 | identical to how they appear in `id`. 275 | Default is `{}` (`descriptors` output will be empty). 276 | EOT 277 | } 278 | 279 | #### End of copy of cloudposse/terraform-null-label/variables.tf 280 | -------------------------------------------------------------------------------- /examples/complete/context.tf: -------------------------------------------------------------------------------- 1 | # 2 | # ONLY EDIT THIS FILE IN github.com/cloudposse/terraform-null-label 3 | # All other instances of this file should be a copy of that one 4 | # 5 | # 6 | # Copy this file from https://github.com/cloudposse/terraform-null-label/blob/master/exports/context.tf 7 | # and then place it in your Terraform module to automatically get 8 | # Cloud Posse's standard configuration inputs suitable for passing 9 | # to Cloud Posse modules. 10 | # 11 | # curl -sL https://raw.githubusercontent.com/cloudposse/terraform-null-label/master/exports/context.tf -o context.tf 12 | # 13 | # Modules should access the whole context as `module.this.context` 14 | # to get the input variables with nulls for defaults, 15 | # for example `context = module.this.context`, 16 | # and access individual variables as `module.this.`, 17 | # with final values filled in. 18 | # 19 | # For example, when using defaults, `module.this.context.delimiter` 20 | # will be null, and `module.this.delimiter` will be `-` (hyphen). 21 | # 22 | 23 | module "this" { 24 | source = "cloudposse/label/null" 25 | version = "0.25.0" # requires Terraform >= 0.13.0 26 | 27 | enabled = var.enabled 28 | namespace = var.namespace 29 | tenant = var.tenant 30 | environment = var.environment 31 | stage = var.stage 32 | name = var.name 33 | delimiter = var.delimiter 34 | attributes = var.attributes 35 | tags = var.tags 36 | additional_tag_map = var.additional_tag_map 37 | label_order = var.label_order 38 | regex_replace_chars = var.regex_replace_chars 39 | id_length_limit = var.id_length_limit 40 | label_key_case = var.label_key_case 41 | label_value_case = var.label_value_case 42 | descriptor_formats = var.descriptor_formats 43 | labels_as_tags = var.labels_as_tags 44 | 45 | context = var.context 46 | } 47 | 48 | # Copy contents of cloudposse/terraform-null-label/variables.tf here 49 | 50 | variable "context" { 51 | type = any 52 | default = { 53 | enabled = true 54 | namespace = null 55 | tenant = null 56 | environment = null 57 | stage = null 58 | name = null 59 | delimiter = null 60 | attributes = [] 61 | tags = {} 62 | additional_tag_map = {} 63 | regex_replace_chars = null 64 | label_order = [] 65 | id_length_limit = null 66 | label_key_case = null 67 | label_value_case = null 68 | descriptor_formats = {} 69 | # Note: we have to use [] instead of null for unset lists due to 70 | # https://github.com/hashicorp/terraform/issues/28137 71 | # which was not fixed until Terraform 1.0.0, 72 | # but we want the default to be all the labels in `label_order` 73 | # and we want users to be able to prevent all tag generation 74 | # by setting `labels_as_tags` to `[]`, so we need 75 | # a different sentinel to indicate "default" 76 | labels_as_tags = ["unset"] 77 | } 78 | description = <<-EOT 79 | Single object for setting entire context at once. 80 | See description of individual variables for details. 81 | Leave string and numeric variables as `null` to use default value. 82 | Individual variable settings (non-null) override settings in context object, 83 | except for attributes, tags, and additional_tag_map, which are merged. 84 | EOT 85 | 86 | validation { 87 | condition = lookup(var.context, "label_key_case", null) == null ? true : contains(["lower", "title", "upper"], var.context["label_key_case"]) 88 | error_message = "Allowed values: `lower`, `title`, `upper`." 89 | } 90 | 91 | validation { 92 | condition = lookup(var.context, "label_value_case", null) == null ? true : contains(["lower", "title", "upper", "none"], var.context["label_value_case"]) 93 | error_message = "Allowed values: `lower`, `title`, `upper`, `none`." 94 | } 95 | } 96 | 97 | variable "enabled" { 98 | type = bool 99 | default = null 100 | description = "Set to false to prevent the module from creating any resources" 101 | } 102 | 103 | variable "namespace" { 104 | type = string 105 | default = null 106 | description = "ID element. Usually an abbreviation of your organization name, e.g. 'eg' or 'cp', to help ensure generated IDs are globally unique" 107 | } 108 | 109 | variable "tenant" { 110 | type = string 111 | default = null 112 | description = "ID element _(Rarely used, not included by default)_. A customer identifier, indicating who this instance of a resource is for" 113 | } 114 | 115 | variable "environment" { 116 | type = string 117 | default = null 118 | description = "ID element. Usually used for region e.g. 'uw2', 'us-west-2', OR role 'prod', 'staging', 'dev', 'UAT'" 119 | } 120 | 121 | variable "stage" { 122 | type = string 123 | default = null 124 | description = "ID element. Usually used to indicate role, e.g. 'prod', 'staging', 'source', 'build', 'test', 'deploy', 'release'" 125 | } 126 | 127 | variable "name" { 128 | type = string 129 | default = null 130 | description = <<-EOT 131 | ID element. Usually the component or solution name, e.g. 'app' or 'jenkins'. 132 | This is the only ID element not also included as a `tag`. 133 | The "name" tag is set to the full `id` string. There is no tag with the value of the `name` input. 134 | EOT 135 | } 136 | 137 | variable "delimiter" { 138 | type = string 139 | default = null 140 | description = <<-EOT 141 | Delimiter to be used between ID elements. 142 | Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. 143 | EOT 144 | } 145 | 146 | variable "attributes" { 147 | type = list(string) 148 | default = [] 149 | description = <<-EOT 150 | ID element. Additional attributes (e.g. `workers` or `cluster`) to add to `id`, 151 | in the order they appear in the list. New attributes are appended to the 152 | end of the list. The elements of the list are joined by the `delimiter` 153 | and treated as a single ID element. 154 | EOT 155 | } 156 | 157 | variable "labels_as_tags" { 158 | type = set(string) 159 | default = ["default"] 160 | description = <<-EOT 161 | Set of labels (ID elements) to include as tags in the `tags` output. 162 | Default is to include all labels. 163 | Tags with empty values will not be included in the `tags` output. 164 | Set to `[]` to suppress all generated tags. 165 | **Notes:** 166 | The value of the `name` tag, if included, will be the `id`, not the `name`. 167 | Unlike other `null-label` inputs, the initial setting of `labels_as_tags` cannot be 168 | changed in later chained modules. Attempts to change it will be silently ignored. 169 | EOT 170 | } 171 | 172 | variable "tags" { 173 | type = map(string) 174 | default = {} 175 | description = <<-EOT 176 | Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`). 177 | Neither the tag keys nor the tag values will be modified by this module. 178 | EOT 179 | } 180 | 181 | variable "additional_tag_map" { 182 | type = map(string) 183 | default = {} 184 | description = <<-EOT 185 | Additional key-value pairs to add to each map in `tags_as_list_of_maps`. Not added to `tags` or `id`. 186 | This is for some rare cases where resources want additional configuration of tags 187 | and therefore take a list of maps with tag key, value, and additional configuration. 188 | EOT 189 | } 190 | 191 | variable "label_order" { 192 | type = list(string) 193 | default = null 194 | description = <<-EOT 195 | The order in which the labels (ID elements) appear in the `id`. 196 | Defaults to ["namespace", "environment", "stage", "name", "attributes"]. 197 | You can omit any of the 6 labels ("tenant" is the 6th), but at least one must be present. 198 | EOT 199 | } 200 | 201 | variable "regex_replace_chars" { 202 | type = string 203 | default = null 204 | description = <<-EOT 205 | Terraform regular expression (regex) string. 206 | Characters matching the regex will be removed from the ID elements. 207 | If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. 208 | EOT 209 | } 210 | 211 | variable "id_length_limit" { 212 | type = number 213 | default = null 214 | description = <<-EOT 215 | Limit `id` to this many characters (minimum 6). 216 | Set to `0` for unlimited length. 217 | Set to `null` for keep the existing setting, which defaults to `0`. 218 | Does not affect `id_full`. 219 | EOT 220 | validation { 221 | condition = var.id_length_limit == null ? true : var.id_length_limit >= 6 || var.id_length_limit == 0 222 | error_message = "The id_length_limit must be >= 6 if supplied (not null), or 0 for unlimited length." 223 | } 224 | } 225 | 226 | variable "label_key_case" { 227 | type = string 228 | default = null 229 | description = <<-EOT 230 | Controls the letter case of the `tags` keys (label names) for tags generated by this module. 231 | Does not affect keys of tags passed in via the `tags` input. 232 | Possible values: `lower`, `title`, `upper`. 233 | Default value: `title`. 234 | EOT 235 | 236 | validation { 237 | condition = var.label_key_case == null ? true : contains(["lower", "title", "upper"], var.label_key_case) 238 | error_message = "Allowed values: `lower`, `title`, `upper`." 239 | } 240 | } 241 | 242 | variable "label_value_case" { 243 | type = string 244 | default = null 245 | description = <<-EOT 246 | Controls the letter case of ID elements (labels) as included in `id`, 247 | set as tag values, and output by this module individually. 248 | Does not affect values of tags passed in via the `tags` input. 249 | Possible values: `lower`, `title`, `upper` and `none` (no transformation). 250 | Set this to `title` and set `delimiter` to `""` to yield Pascal Case IDs. 251 | Default value: `lower`. 252 | EOT 253 | 254 | validation { 255 | condition = var.label_value_case == null ? true : contains(["lower", "title", "upper", "none"], var.label_value_case) 256 | error_message = "Allowed values: `lower`, `title`, `upper`, `none`." 257 | } 258 | } 259 | 260 | variable "descriptor_formats" { 261 | type = any 262 | default = {} 263 | description = <<-EOT 264 | Describe additional descriptors to be output in the `descriptors` output map. 265 | Map of maps. Keys are names of descriptors. Values are maps of the form 266 | `{ 267 | format = string 268 | labels = list(string) 269 | }` 270 | (Type is `any` so the map values can later be enhanced to provide additional options.) 271 | `format` is a Terraform format string to be passed to the `format()` function. 272 | `labels` is a list of labels, in order, to pass to `format()` function. 273 | Label values will be normalized before being passed to `format()` so they will be 274 | identical to how they appear in `id`. 275 | Default is `{}` (`descriptors` output will be empty). 276 | EOT 277 | } 278 | 279 | #### End of copy of cloudposse/terraform-null-label/variables.tf 280 | -------------------------------------------------------------------------------- /examples/complete/fixtures.us-east-2.tfvars: -------------------------------------------------------------------------------- 1 | enabled = true 2 | 3 | namespace = "eg" 4 | stage = "test" 5 | name = "tailscale" 6 | 7 | region = "us-east-1" 8 | availability_zones = ["us-east-1a", "us-east-1b"] 9 | ipv4_primary_cidr_block = "172.16.0.0/16" 10 | 11 | ssm_state_enabled = true 12 | 13 | # Replace these values with your own 14 | tailnet = "orgname.org.github" 15 | oauth_client_id = "OAUTH_CLIENT_ID" 16 | oauth_client_secret = "OAUTH_CLIENT_SECRET" 17 | -------------------------------------------------------------------------------- /examples/complete/main.tf: -------------------------------------------------------------------------------- 1 | # trunk-ignore-all(trivy/AVD-AWS-0178): We don't need have VPC Flow logs. 2 | provider "aws" { 3 | region = var.region 4 | } 5 | 6 | provider "tailscale" { 7 | tailnet = var.tailnet 8 | oauth_client_id = var.oauth_client_id 9 | oauth_client_secret = var.oauth_client_secret 10 | } 11 | 12 | module "vpc" { 13 | source = "cloudposse/vpc/aws" 14 | version = "2.1.1" 15 | 16 | ipv4_primary_cidr_block = "172.16.0.0/16" 17 | 18 | context = module.this.context 19 | } 20 | 21 | module "subnets" { 22 | source = "cloudposse/dynamic-subnets/aws" 23 | version = "2.4.1" 24 | 25 | availability_zones = var.availability_zones 26 | 27 | vpc_id = module.vpc.vpc_id 28 | igw_id = [module.vpc.igw_id] 29 | ipv4_cidr_block = [module.vpc.vpc_cidr_block] 30 | 31 | context = module.this.context 32 | } 33 | 34 | module "tailscale" { 35 | source = "../.." 36 | 37 | vpc_id = module.vpc.vpc_id 38 | subnet_ids = module.subnets.private_subnet_ids 39 | advertise_routes = [module.vpc.vpc_cidr_block] 40 | 41 | context = module.this.context 42 | } 43 | -------------------------------------------------------------------------------- /examples/complete/outputs.tf: -------------------------------------------------------------------------------- 1 | output "instance_name" { 2 | value = module.tailscale.instance_name 3 | description = "The name tag value of the Bastion instance." 4 | } 5 | 6 | output "security_group_id" { 7 | value = module.tailscale.security_group_id 8 | description = "The ID of the SSM Agent Security Group." 9 | } 10 | 11 | output "launch_template_id" { 12 | value = module.tailscale.launch_template_id 13 | description = "The ID of the SSM Agent Launch Template." 14 | } 15 | 16 | output "autoscaling_group_id" { 17 | value = module.tailscale.autoscaling_group_id 18 | description = "The ID of the SSM Agent Autoscaling Group." 19 | } 20 | -------------------------------------------------------------------------------- /examples/complete/variables.tf: -------------------------------------------------------------------------------- 1 | variable "availability_zones" { 2 | type = list(string) 3 | description = "List of Availability Zones where subnets will be created" 4 | } 5 | 6 | variable "oauth_client_id" { 7 | type = string 8 | description = <<-EOF 9 | The OAuth application's ID when using OAuth client credentials. 10 | Can be set via the TAILSCALE_OAUTH_CLIENT_ID environment variable. 11 | Both 'oauth_client_id' and 'oauth_client_secret' must be set. 12 | Conflicts with 'api_key'. 13 | EOF 14 | } 15 | 16 | variable "oauth_client_secret" { 17 | type = string 18 | description = <<-EOF 19 | (Sensitive) The OAuth application's secret when using OAuth client credentials. 20 | Can be set via the TAILSCALE_OAUTH_CLIENT_SECRET environment variable. 21 | Both 'oauth_client_id' and 'oauth_client_secret' must be set. 22 | Conflicts with 'api_key'. 23 | EOF 24 | } 25 | 26 | variable "region" { 27 | type = string 28 | description = "The AWS Region to deploy these resources to." 29 | } 30 | 31 | variable "tailnet" { 32 | type = string 33 | description = <<-EOF 34 | The organization name of the Tailnet in which to perform actions. 35 | Can be set via the TAILSCALE_TAILNET environment variable. 36 | Default is the tailnet that owns API credentials passed to the provider. 37 | EOF 38 | } 39 | -------------------------------------------------------------------------------- /examples/complete/versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = ">= 1.5" 3 | 4 | required_providers { 5 | aws = { 6 | source = "hashicorp/aws" 7 | version = "~> 5.0" 8 | } 9 | tailscale = { 10 | source = "tailscale/tailscale" 11 | version = "~> 0.13" 12 | } 13 | local = { 14 | source = "hashicorp/local" 15 | version = "~> 2.4" 16 | } 17 | null = { 18 | source = "hashicorp/null" 19 | version = "~> 3.0" 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /main.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | 3 | primary_tag = coalesce(var.primary_tag, module.this.id) 4 | prefixed_primary_tag = "tag:${local.primary_tag}" 5 | prefixed_additional_tags = [for tag in var.additional_tags : "tag:${tag}"] 6 | 7 | ssm_state_param_name = var.ssm_state_enabled ? "/tailscale/${module.this.id}/state" : null 8 | ssm_state_flag = var.ssm_state_enabled ? "--state=${module.ssm_state[0].arn_map[local.ssm_state_param_name]}" : "" 9 | 10 | tailscale_tags = concat([local.prefixed_primary_tag], local.prefixed_additional_tags) 11 | 12 | tailscaled_extra_flags = join(" ", compact(concat(var.tailscaled_extra_flags, [local.ssm_state_flag]))) 13 | tailscaled_extra_flags_enabled = length(local.tailscaled_extra_flags) > 0 14 | 15 | tailscale_up_extra_flags_enabled = length(var.tailscale_up_extra_flags) > 0 16 | 17 | userdata = templatefile("${path.module}/userdata.sh.tmpl", { 18 | authkey = tailscale_tailnet_key.default.key 19 | exit_node_enabled = var.exit_node_enabled 20 | hostname = module.this.id 21 | routes = join(",", var.advertise_routes) 22 | ssh_enabled = var.ssh_enabled 23 | tags = join(",", local.tailscale_tags) 24 | 25 | tailscaled_extra_flags_enabled = local.tailscaled_extra_flags_enabled 26 | tailscaled_extra_flags = local.tailscaled_extra_flags 27 | tailscale_up_extra_flags_enabled = local.tailscale_up_extra_flags_enabled 28 | tailscale_up_extra_flags = join(" ", var.tailscale_up_extra_flags) 29 | 30 | journald_system_max_use = var.journald_system_max_use 31 | journald_max_retention_sec = var.journald_max_retention_sec 32 | }) 33 | } 34 | 35 | # Note: `trunk` ignores that this rule is already listed in `.trivyignore` file. 36 | # Bucket does not have versioning enabled 37 | # trivy:ignore:AVD-AWS-0090 38 | module "tailscale_subnet_router" { 39 | source = "masterpointio/ssm-agent/aws" 40 | version = "1.4.0" 41 | 42 | context = module.this.context 43 | tags = module.this.tags 44 | 45 | vpc_id = var.vpc_id 46 | subnet_ids = var.subnet_ids 47 | key_pair_name = var.key_pair_name 48 | create_run_shell_document = var.create_run_shell_document 49 | 50 | additional_security_group_ids = var.additional_security_group_ids 51 | additional_security_group_rules = var.additional_security_group_rules 52 | 53 | session_logging_kms_key_alias = var.session_logging_kms_key_alias 54 | session_logging_enabled = var.session_logging_enabled 55 | session_logging_ssm_document_name = var.session_logging_ssm_document_name 56 | 57 | ami = var.ami 58 | architecture = var.architecture 59 | instance_type = var.instance_type 60 | max_size = var.max_size 61 | min_size = var.min_size 62 | desired_capacity = var.desired_capacity 63 | 64 | monitoring_enabled = var.monitoring_enabled 65 | associate_public_ip_address = var.associate_public_ip_address 66 | 67 | user_data = base64encode(length(var.user_data) > 0 ? var.user_data : local.userdata) 68 | } 69 | 70 | resource "tailscale_tailnet_key" "default" { 71 | reusable = var.reusable 72 | ephemeral = var.ephemeral 73 | preauthorized = var.preauthorized 74 | expiry = var.expiry 75 | 76 | # A device is automatically tagged when it is authenticated with this key. 77 | tags = local.tailscale_tags 78 | } 79 | 80 | module "ssm_state" { 81 | count = var.ssm_state_enabled ? 1 : 0 82 | source = "cloudposse/ssm-parameter-store/aws" 83 | version = "0.13.0" 84 | ignore_value_changes = true 85 | 86 | parameter_write = [ 87 | { 88 | name = local.ssm_state_param_name 89 | type = "SecureString" 90 | overwrite = "true" 91 | value = "{}" 92 | description = "Tailscaled state of ${module.this.id} subnet router." 93 | } 94 | ] 95 | context = module.this.context 96 | tags = module.this.tags 97 | } 98 | 99 | module "ssm_policy" { 100 | count = var.ssm_state_enabled ? 1 : 0 101 | source = "cloudposse/iam-policy/aws" 102 | version = "2.0.1" 103 | 104 | name = "ssm" 105 | description = "Additional SSM access for SSM Agent" 106 | 107 | iam_policy_enabled = true 108 | iam_policy = [{ 109 | statements = [ 110 | { 111 | sid = "SSMAgentPutParameter" 112 | effect = "Allow" 113 | actions = ["ssm:PutParameter"] 114 | resources = [ 115 | module.ssm_state[0].arn_map[local.ssm_state_param_name], 116 | ] 117 | }, 118 | ] 119 | }] 120 | context = module.this.context 121 | tags = module.this.tags 122 | } 123 | 124 | resource "aws_iam_role_policy_attachment" "default" { 125 | count = var.ssm_state_enabled ? 1 : 0 126 | role = module.tailscale_subnet_router.role_id 127 | policy_arn = module.ssm_policy[0].policy_arn 128 | } 129 | 130 | resource "aws_iam_role_policy_attachment" "cw_agent" { 131 | role = module.tailscale_subnet_router.role_id 132 | policy_arn = "arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy" 133 | } 134 | -------------------------------------------------------------------------------- /outputs.tf: -------------------------------------------------------------------------------- 1 | output "instance_name" { 2 | value = module.this.id 3 | description = "The name tag value of the Tailscale Subnet Router EC2 instance." 4 | } 5 | 6 | output "security_group_id" { 7 | value = module.tailscale_subnet_router.security_group_id 8 | description = "The ID of the Tailscale Subnet Router EC2 instance Security Group." 9 | } 10 | 11 | output "launch_template_id" { 12 | value = module.tailscale_subnet_router.launch_template_id 13 | description = "The ID of the Tailscale Subnet Router EC2 instance Launch Template." 14 | } 15 | 16 | output "autoscaling_group_id" { 17 | value = module.tailscale_subnet_router.autoscaling_group_id 18 | description = "The ID of the Tailscale Subnet Router EC2 instance Autoscaling Group." 19 | } 20 | -------------------------------------------------------------------------------- /userdata.sh.tmpl: -------------------------------------------------------------------------------- 1 | #!/bin/bash -ex 2 | exec > >(tee /var/log/user-data.log | logger -t user-data -s 2>/dev/console) 2>&1 3 | 4 | echo "Starting user-data script..." 5 | 6 | echo "Enabling IP forwarding..." 7 | echo 'net.ipv4.ip_forward = 1' >> /etc/sysctl.conf 8 | echo 'net.ipv6.conf.all.forwarding = 1' >> /etc/sysctl.conf 9 | sysctl -p /etc/sysctl.conf 10 | 11 | # In systemd, Administrator drop-ins should reside in /etc/systemd/, ensuring they 12 | # are preserved across updates and have higher precedence than vendor defaults. 13 | # 14 | # We name our file 99-custom.conf so it loads last among any .conf files. 15 | # That way, it overrides any settings that come earlier. 16 | 17 | # Create the journald configs directory if it doesn't already exist 18 | mkdir -p /etc/systemd/journald.conf.d 19 | 20 | cat < /etc/systemd/journald.conf.d/99-custom.conf 21 | [Journal] 22 | SystemMaxUse=${journald_system_max_use} 23 | MaxRetentionSec=${journald_max_retention_sec} 24 | EOF 25 | 26 | # Restart journald so it picks up the new configuration 27 | systemctl restart systemd-journald 28 | 29 | # Function to retry a command up to a maximum number of attempts 30 | retry_command() { 31 | local cmd="$1" 32 | local max_attempts="$2" 33 | local attempt=1 34 | local exit_code=0 35 | 36 | while [ $attempt -le $max_attempts ]; do 37 | echo "Attempt $attempt of $max_attempts: $cmd" 38 | eval "$cmd" 39 | exit_code=$? 40 | if [ $exit_code -eq 0 ]; then 41 | echo "Command succeeded: $cmd" 42 | return 0 43 | else 44 | echo "Command failed with exit code $exit_code: $cmd" 45 | attempt=$((attempt + 1)) 46 | if [ $attempt -le $max_attempts ]; then 47 | echo "Retrying in 2 seconds..." 48 | sleep 2 49 | fi 50 | fi 51 | done 52 | 53 | echo "Command failed after $max_attempts attempts: $cmd" 54 | return $exit_code 55 | } 56 | 57 | # Install CloudWatch Agent 58 | echo "Installing CloudWatch Agent..." 59 | retry_command "dnf install -y amazon-cloudwatch-agent" 5 60 | amazon-cloudwatch-agent-ctl -a start -m ec2 61 | 62 | # Install Tailscale 63 | echo "Installing Tailscale..." 64 | retry_command "dnf install -y dnf-utils" 5 65 | retry_command "dnf config-manager --add-repo https://pkgs.tailscale.com/stable/amazon-linux/2/tailscale.repo" 5 66 | retry_command "dnf install -y tailscale" 5 67 | 68 | %{ if tailscaled_extra_flags_enabled == true } 69 | echo "Exporting FLAGS to /etc/default/tailscaled..." 70 | sed -i "s|^FLAGS=.*|FLAGS=\"${tailscaled_extra_flags}\"|" /etc/default/tailscaled 71 | %{ endif } 72 | 73 | # Setup Tailscale 74 | echo "Enabling and starting tailscaled service..." 75 | systemctl enable --now tailscaled 76 | 77 | echo "Waiting for tailscaled to initialize..." 78 | sleep 5 79 | 80 | # Start tailscale 81 | # We pass --advertise-tags below even though the authkey being created with those tags should result 82 | # in the same effect. This is to be more explicit because tailscale tags are a complicated topic. 83 | tailscale up \ 84 | %{ if ssh_enabled == true }--ssh%{ endif } \ 85 | %{ if exit_node_enabled == true }--advertise-exit-node%{ endif } \ 86 | %{ if tailscale_up_extra_flags_enabled == true }${tailscale_up_extra_flags}%{ endif } \ 87 | --advertise-routes=${routes} \ 88 | --advertise-tags=${tags} \ 89 | --hostname=${hostname} \ 90 | --authkey=${authkey} 91 | 92 | echo "Tailscale setup completed." 93 | -------------------------------------------------------------------------------- /variables.tf: -------------------------------------------------------------------------------- 1 | ################################# 2 | ## Subnet Router EC2 Instance ## 3 | ############################### 4 | 5 | variable "vpc_id" { 6 | type = string 7 | description = "The ID of the VPC which the Tailscale Subnet Router EC2 instance will run in." 8 | } 9 | 10 | variable "subnet_ids" { 11 | type = list(string) 12 | description = "The Subnet IDs which the Tailscale Subnet Router EC2 instance will run in. These *should* be private subnets." 13 | } 14 | 15 | variable "additional_security_group_ids" { 16 | default = [] 17 | type = list(string) 18 | description = "Additional Security Group IDs to associate with the Tailscale Subnet Router EC2 instance." 19 | } 20 | 21 | variable "additional_security_group_rules" { 22 | description = "Additional security group rules that will be attached to the primary security group" 23 | type = map(object({ 24 | type = string 25 | from_port = number 26 | to_port = number 27 | protocol = string 28 | 29 | description = optional(string) 30 | cidr_blocks = optional(list(string)) 31 | ipv6_cidr_blocks = optional(list(string)) 32 | prefix_list_ids = optional(list(string)) 33 | self = optional(bool) 34 | })) 35 | default = {} 36 | } 37 | 38 | variable "create_run_shell_document" { 39 | default = true 40 | type = bool 41 | description = "Whether or not to create the SSM-SessionManagerRunShell SSM Document." 42 | } 43 | 44 | variable "session_logging_enabled" { 45 | default = true 46 | type = bool 47 | description = <