├── .github ├── CODE_OF_CONDUCT.md ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── documentation-change.md │ ├── feature_request.md │ └── question.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .goreleaser.yml ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── docs ├── data-sources │ ├── 4via6.md │ ├── acl.md │ ├── device.md │ ├── devices.md │ ├── user.md │ └── users.md ├── index.md └── resources │ ├── acl.md │ ├── aws_external_id.md │ ├── contacts.md │ ├── device_authorization.md │ ├── device_key.md │ ├── device_subnet_routes.md │ ├── device_tags.md │ ├── dns_nameservers.md │ ├── dns_preferences.md │ ├── dns_search_paths.md │ ├── dns_split_nameservers.md │ ├── logstream_configuration.md │ ├── oauth_client.md │ ├── posture_integration.md │ ├── tailnet_key.md │ ├── tailnet_settings.md │ └── webhook.md ├── examples ├── data-sources │ ├── tailscale_4via6 │ │ └── data-source.tf │ ├── tailscale_device │ │ └── data-source.tf │ ├── tailscale_devices │ │ └── data-source.tf │ ├── tailscale_user │ │ └── data-source.tf │ └── tailscale_users │ │ └── data-source.tf ├── provider │ └── provider.tf └── resources │ ├── tailscale_acl │ ├── import.sh │ └── resource.tf │ ├── tailscale_aws_external_id │ └── resource.tf │ ├── tailscale_contacts │ ├── import.sh │ └── resource.tf │ ├── tailscale_device_authorization │ ├── import.sh │ └── resource.tf │ ├── tailscale_device_key │ ├── import.sh │ └── resource.tf │ ├── tailscale_device_subnet_routes │ ├── import.sh │ └── resource.tf │ ├── tailscale_device_tags │ ├── import.sh │ └── resource.tf │ ├── tailscale_dns_nameservers │ ├── import.sh │ └── resource.tf │ ├── tailscale_dns_preferences │ ├── import.sh │ └── resource.tf │ ├── tailscale_dns_search_paths │ ├── import.sh │ └── resource.tf │ ├── tailscale_dns_split_nameservers │ ├── import.sh │ └── resource.tf │ ├── tailscale_logstream_configuration │ ├── import.sh │ └── resource.tf │ ├── tailscale_oauth_client │ └── resource.tf │ ├── tailscale_posture_integration │ ├── import.sh │ └── resource.tf │ ├── tailscale_tailnet_key │ └── resource.tf │ ├── tailscale_tailnet_settings │ ├── import.sh │ └── resource.tf │ └── tailscale_webhook │ ├── import.sh │ └── resource.tf ├── go.mod ├── go.sum ├── main.go ├── scripts └── check_license_headers.sh ├── tailscale ├── data_source_4via6.go ├── data_source_4via6_test.go ├── data_source_acl.go ├── data_source_acl_test.go ├── data_source_device.go ├── data_source_devices.go ├── data_source_user.go ├── data_source_users.go ├── data_source_users_test.go ├── datasource_devices_test.go ├── provider.go ├── provider_test.go ├── resource_acl.go ├── resource_acl_test.go ├── resource_aws_external_id.go ├── resource_aws_external_id_test.go ├── resource_contacts.go ├── resource_contacts_test.go ├── resource_device_authorization.go ├── resource_device_authorization_test.go ├── resource_device_key.go ├── resource_device_key_test.go ├── resource_device_subnet_routes.go ├── resource_device_subnet_routes_test.go ├── resource_device_tags.go ├── resource_device_tags_test.go ├── resource_dns_nameservers.go ├── resource_dns_nameservers_test.go ├── resource_dns_preferences.go ├── resource_dns_preferences_test.go ├── resource_dns_search_paths.go ├── resource_dns_search_paths_test.go ├── resource_dns_split_nameservers.go ├── resource_dns_split_nameservers_test.go ├── resource_logstream_configuration.go ├── resource_logstream_configuration_test.go ├── resource_oauth_client.go ├── resource_oauth_client_test.go ├── resource_posture_integration.go ├── resource_posture_integration_test.go ├── resource_tailnet_key.go ├── resource_tailnet_key_test.go ├── resource_tailnet_settings.go ├── resource_tailnet_settings_test.go ├── resource_webhook.go ├── resource_webhook_test.go └── tailscale_test.go ├── templates └── resources │ └── tailnet_key.md.tmpl └── tools.go /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation 6 | in our community a harassment-free experience for everyone, regardless 7 | of age, body size, visible or invisible disability, ethnicity, sex 8 | characteristics, gender identity and expression, level of experience, 9 | education, socio-economic status, nationality, personal appearance, 10 | race, religion, or sexual identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, 13 | welcoming, diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for 18 | our community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our 24 | mistakes, and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or 33 | political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email 36 | address, without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in 38 | a professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our 43 | standards of acceptable behavior and will take appropriate and fair 44 | corrective action in response to any behavior that they deem 45 | inappropriate, threatening, offensive, or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, 48 | or reject comments, commits, code, wiki edits, issues, and other 49 | contributions that are not aligned to this Code of Conduct, and will 50 | communicate reasons for moderation decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also 55 | applies when an individual is officially representing the community in 56 | public spaces. Examples of representing our community include using an 57 | official e-mail address, posting via an official social media account, 58 | or acting as an appointed representative at an online or offline 59 | event. 60 | 61 | ## Enforcement 62 | 63 | Instances of abusive, harassing, or otherwise unacceptable behavior 64 | may be reported to the community leaders responsible for enforcement 65 | at [info@tailscale.com](mailto:info@tailscale.com). All complaints 66 | will be reviewed and investigated promptly and fairly. 67 | 68 | All community leaders are obligated to respect the privacy and 69 | security of the reporter of any incident. 70 | 71 | ## Enforcement Guidelines 72 | 73 | Community leaders will follow these Community Impact Guidelines in 74 | determining the consequences for any action they deem in violation of 75 | this Code of Conduct: 76 | 77 | ### 1. Correction 78 | 79 | **Community Impact**: Use of inappropriate language or other behavior 80 | deemed unprofessional or unwelcome in the community. 81 | 82 | **Consequence**: A private, written warning from community leaders, 83 | providing clarity around the nature of the violation and an 84 | explanation of why the behavior was inappropriate. A public apology 85 | may be requested. 86 | 87 | ### 2. Warning 88 | 89 | **Community Impact**: A violation through a single incident or series 90 | of actions. 91 | 92 | **Consequence**: A warning with consequences for continued 93 | behavior. No interaction with the people involved, including 94 | unsolicited interaction with those enforcing the Code of Conduct, for 95 | a specified period of time. This includes avoiding interactions in 96 | community spaces as well as external channels like social 97 | media. Violating these terms may lead to a temporary or permanent ban. 98 | 99 | ### 3. Temporary Ban 100 | 101 | **Community Impact**: A serious violation of community standards, 102 | including sustained inappropriate behavior. 103 | 104 | **Consequence**: A temporary ban from any sort of interaction or 105 | public communication with the community for a specified period of 106 | time. No public or private interaction with the people involved, 107 | including unsolicited interaction with those enforcing the Code of 108 | Conduct, is allowed during this period. Violating these terms may lead 109 | to a permanent ban. 110 | 111 | ### 4. Permanent Ban 112 | 113 | **Community Impact**: Demonstrating a pattern of violation of 114 | community standards, including sustained inappropriate behavior, 115 | harassment of an individual, or aggression toward or disparagement of 116 | classes of individuals. 117 | 118 | **Consequence**: A permanent ban from any sort of public interaction 119 | within the community. 120 | 121 | ## Attribution 122 | 123 | This Code of Conduct is adapted from the [Contributor 124 | Covenant][homepage], version 2.0, available at 125 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 126 | 127 | Community Impact Guidelines were inspired by [Mozilla's code of 128 | conduct enforcement ladder](https://github.com/mozilla/diversity). 129 | 130 | [homepage]: https://www.contributor-covenant.org 131 | 132 | For answers to common questions about this code of conduct, see the 133 | FAQ at https://www.contributor-covenant.org/faq. Translations are 134 | available at https://www.contributor-covenant.org/translations. 135 | 136 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behaviour: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behaviour** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Desktop (please complete the following information):** 24 | - OS: [e.g. Windows] 25 | - Terraform Version [e.g. 1.0.0] 26 | - Provider Version [e.g. 1.0.0] 27 | 28 | **Additional context** 29 | Add any other context about the problem here. 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation-change.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation Change 3 | about: Suggest a change to the project's documentation 4 | title: '' 5 | labels: documentation 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Ask a question about the project 4 | title: '' 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | **What this PR does / why we need it**: 2 | 3 | **Which issue this PR fixes** *(use `fixes #(, fixes #, ...)` format, will close that issue when PR gets merged)*: 4 | 5 | Fixes # 6 | 7 | **Special notes for your reviewer**: 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: / 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: github-actions 8 | directory: / 9 | schedule: 10 | interval: daily 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: {} 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 14 | 15 | - name: Install Go 16 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 17 | with: 18 | go-version-file: go.mod 19 | 20 | - name: Get cache paths 21 | id: cache 22 | run: | 23 | echo "build=$(go env GOCACHE)" | tee -a $GITHUB_OUTPUT 24 | echo "module=$(go env GOMODCACHE)" | tee -a $GITHUB_OUTPUT 25 | 26 | - name: Set up cache 27 | uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 28 | with: 29 | path: | 30 | ${{ steps.cache.outputs.build }} 31 | ${{ steps.cache.outputs.module }} 32 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 33 | restore-keys: | 34 | ${{ runner.os }}-go- 35 | 36 | - name: Run tests 37 | run: go test -race ./... 38 | 39 | generate: 40 | runs-on: ubuntu-latest 41 | steps: 42 | - name: Checkout 43 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 44 | 45 | - name: Install Go 46 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 47 | with: 48 | go-version-file: go.mod 49 | 50 | - name: Get cache paths 51 | id: cache 52 | run: | 53 | echo "build=$(go env GOCACHE)" | tee -a $GITHUB_OUTPUT 54 | echo "module=$(go env GOMODCACHE)" | tee -a $GITHUB_OUTPUT 55 | 56 | - name: Set up cache 57 | uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 58 | with: 59 | path: | 60 | ${{ steps.cache.outputs.build }} 61 | ${{ steps.cache.outputs.module }} 62 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 63 | restore-keys: | 64 | ${{ runner.os }}-go- 65 | 66 | - name: Install tfplugindocs 67 | run: go install github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs 68 | 69 | - name: Generate documentation 70 | run: go generate ./... 71 | 72 | - name: Check for uncommitted changes 73 | run: | 74 | git add . 75 | git diff --staged --exit-code 76 | 77 | format: 78 | runs-on: ubuntu-latest 79 | steps: 80 | - name: Checkout 81 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 82 | 83 | - name: Install Go 84 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 85 | with: 86 | go-version-file: go.mod 87 | 88 | - name: Get cache paths 89 | id: cache 90 | run: | 91 | echo "build=$(go env GOCACHE)" | tee -a $GITHUB_OUTPUT 92 | echo "module=$(go env GOMODCACHE)" | tee -a $GITHUB_OUTPUT 93 | 94 | - name: Set up cache 95 | uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 96 | with: 97 | path: | 98 | ${{ steps.cache.outputs.build }} 99 | ${{ steps.cache.outputs.module }} 100 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 101 | restore-keys: | 102 | ${{ runner.os }}-go- 103 | 104 | - name: Install goimports 105 | run: go install golang.org/x/tools/cmd/goimports 106 | 107 | - name: Format code 108 | run: | 109 | go fmt ./... 110 | goimports -w -local github.com/tailscale . 111 | 112 | - name: Check for uncommitted changes 113 | run: | 114 | git add . 115 | git diff --staged --exit-code 116 | 117 | licenses: 118 | runs-on: ubuntu-latest 119 | steps: 120 | - name: checkout 121 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 122 | - name: check licenses 123 | run: ./scripts/check_license_headers.sh . 124 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: 5 | - "v*" 6 | 7 | permissions: 8 | contents: write 9 | 10 | jobs: 11 | goreleaser: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 16 | with: 17 | fetch-depth: 0 18 | 19 | - name: Install Go 20 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 21 | with: 22 | go-version-file: go.mod 23 | 24 | - name: Get cache paths 25 | id: cache 26 | run: | 27 | echo "build=$(go env GOCACHE)" | tee -a $GITHUB_OUTPUT 28 | echo "module=$(go env GOMODCACHE)" | tee -a $GITHUB_OUTPUT 29 | 30 | - name: Set up cache 31 | uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 32 | with: 33 | path: | 34 | ${{ steps.cache.outputs.build }} 35 | ${{ steps.cache.outputs.module }} 36 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 37 | restore-keys: | 38 | ${{ runner.os }}-go- 39 | 40 | - name: Import GPG key 41 | id: import_gpg 42 | uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec # v6.3.0 43 | with: 44 | gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} 45 | passphrase: ${{ secrets.PASSPHRASE }} 46 | 47 | - name: Run GoReleaser 48 | uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 # v6.3.0 49 | with: 50 | version: latest 51 | args: release --clean 52 | env: 53 | GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }} 54 | GITHUB_TOKEN: ${{ github.token }} 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | terraform-provider-tailscale 3 | .idea/** 4 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | builds: 2 | - env: 3 | - CGO_ENABLED=0 4 | mod_timestamp: "{{ .CommitTimestamp }}" 5 | flags: 6 | - -trimpath 7 | ldflags: 8 | - "-s -w -X github.com/tailscale/terraform-provider-tailscale/tailscale.providerVersion={{.Version}} -X main.commit={{.Commit}}" 9 | goos: 10 | - freebsd 11 | - windows 12 | - linux 13 | - darwin 14 | goarch: 15 | - amd64 16 | - "386" 17 | - arm 18 | - arm64 19 | ignore: 20 | - goos: darwin 21 | goarch: "386" 22 | binary: "{{ .ProjectName }}_v{{ .Version }}" 23 | 24 | archives: 25 | - format: zip 26 | name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" 27 | 28 | checksum: 29 | name_template: "{{ .ProjectName }}_{{ .Version }}_SHA256SUMS" 30 | algorithm: sha256 31 | 32 | signs: 33 | - artifacts: checksum 34 | args: 35 | - "--batch" 36 | - "--local-user" 37 | - "{{ .Env.GPG_FINGERPRINT }}" 38 | - "--output" 39 | - "${signature}" 40 | - "--detach-sign" 41 | - "${artifact}" 42 | 43 | release: 44 | prerelease: auto 45 | 46 | changelog: 47 | use: github 48 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | This document contains tips for contributing to this repository. These are not hard and fast rules, but suggestions and 4 | advice. 5 | 6 | ## Raising Issues 7 | 8 | Please use the GitHub [issues](https://github.com/tailscale/terraform-provider-tailscale/issues/new/choose) tab to create a new issue, 9 | choosing an appropriate category from the list available. 10 | 11 | This terraform provider is limited by the functionality available in the [Tailscale API](https://github.com/tailscale/tailscale/blob/main/api.md), 12 | it may be the case that a feature you want implemented may not be available. If this is the case, please raise an issue 13 | on the [Tailscale repository](https://github.com/tailscale/tailscale) describing what you would like to do via the API. 14 | 15 | ## Opening Pull Requests 16 | 17 | Pull requests are welcome for this repository, please try to link the pull request to an issue or create an issue first before opening 18 | the pull request it relates to. 19 | 20 | ## Making Changes 21 | 22 | To work in this repository, you will need go 1.17. You can use the standard go toolchain for building and testing your 23 | changes. 24 | 25 | If you want to enable acceptance tests, you *must* set the `TF_ACC` environment variable. 26 | 27 | Be careful with acceptance 28 | tests as they will run against the Tailscale API and use your local environment. You may end up borking your own Tailscale 29 | devices/ACLs if you don't use a test account. 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 David Bond 4 | Copyright (c) 2024 Tailscale Inc & Contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TEST?=$$(go list ./... | grep -v 'vendor') 2 | HOSTNAME=github.com 3 | NAMESPACE=tailscale 4 | NAME=tailscale 5 | BINARY=terraform-provider-${NAME} 6 | VERSION=0.1 7 | OS_ARCH=linux_amd64 8 | 9 | default: install 10 | 11 | build: 12 | go build -o ${BINARY} 13 | 14 | release: 15 | GOOS=darwin GOARCH=amd64 go build -o ./bin/${BINARY}_${VERSION}_darwin_amd64 16 | GOOS=darwin GOARCH=arm64 go build -o ./bin/${BINARY}_${VERSION}_darwin_arm64 17 | GOOS=freebsd GOARCH=386 go build -o ./bin/${BINARY}_${VERSION}_freebsd_386 18 | GOOS=freebsd GOARCH=amd64 go build -o ./bin/${BINARY}_${VERSION}_freebsd_amd64 19 | GOOS=freebsd GOARCH=arm go build -o ./bin/${BINARY}_${VERSION}_freebsd_arm 20 | GOOS=linux GOARCH=386 go build -o ./bin/${BINARY}_${VERSION}_linux_386 21 | GOOS=linux GOARCH=amd64 go build -o ./bin/${BINARY}_${VERSION}_linux_amd64 22 | GOOS=linux GOARCH=arm go build -o ./bin/${BINARY}_${VERSION}_linux_arm 23 | GOOS=openbsd GOARCH=386 go build -o ./bin/${BINARY}_${VERSION}_openbsd_386 24 | GOOS=openbsd GOARCH=amd64 go build -o ./bin/${BINARY}_${VERSION}_openbsd_amd64 25 | GOOS=solaris GOARCH=amd64 go build -o ./bin/${BINARY}_${VERSION}_solaris_amd64 26 | GOOS=windows GOARCH=386 go build -o ./bin/${BINARY}_${VERSION}_windows_386 27 | GOOS=windows GOARCH=amd64 go build -o ./bin/${BINARY}_${VERSION}_windows_amd64 28 | 29 | install: build 30 | mkdir -p ~/.terraform.d/plugins/${HOSTNAME}/${NAMESPACE}/${NAME}/${VERSION}/${OS_ARCH} 31 | mv ${BINARY} ~/.terraform.d/plugins/${HOSTNAME}/${NAMESPACE}/${NAME}/${VERSION}/${OS_ARCH} 32 | 33 | test: 34 | go test -race ./... 35 | 36 | testacc: 37 | TF_ACC=1 go test $(TEST) -v $(TESTARGS) -timeout 120m 38 | 39 | testacc_local: 40 | TF_ACC=1 TAILSCALE_BASE_URL="http://localhost:31544" TAILSCALE_API_KEY=$$(jq -r .apiKey /tmp/terraform-api-key.json) TAILSCALE_TEST_DEVICE_NAME=$$(jq -r .name /tmp/terraform-device-info.json) go test $(TEST) -v $(TESTARGS) -timeout 120m 41 | 42 | format: 43 | go fmt ./... 44 | goimports -w -local github.com/tailscale . 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # terraform-provider-tailscale 2 | 3 | [![Go Reference](https://pkg.go.dev/badge/github.com/tailscale/terraform-provider-tailscale.svg)](https://pkg.go.dev/github.com/tailscale/terraform-provider-tailscale) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/tailscale/terraform-provider-tailscale)](https://goreportcard.com/report/github.com/tailscale/terraform-provider-tailscale) 5 | ![Github Actions](https://github.com/tailscale/terraform-provider-tailscale/actions/workflows/ci.yml/badge.svg?branch=main) 6 | 7 | This repository contains the source code for the [Tailscale Terraform provider](https://registry.terraform.io/providers/tailscale/tailscale). 8 | This Terraform provider lets you interact with the [Tailscale](https://tailscale.com) API. 9 | 10 | See the [documentation](https://registry.terraform.io/providers/tailscale/tailscale/latest/docs) in the Terraform registry 11 | for the most up-to-date information and latest release. 12 | 13 | This provider is maintained by Tailscale. Thanks to everyone who contributed to the development of the Tailscale Terraform provider, and special thanks to [davidsbond](https://github.com/davidsbond). 14 | 15 | ## Getting Started 16 | 17 | To install this provider, copy and paste this code into your Terraform configuration. Then, run `terraform init`: 18 | 19 | ```terraform 20 | terraform { 21 | required_providers { 22 | tailscale = { 23 | source = "tailscale/tailscale" 24 | version = "~> 0.16" // Latest 0.16.x 25 | } 26 | } 27 | } 28 | 29 | provider "tailscale" { 30 | api_key = "tskey-api-..." 31 | } 32 | ``` 33 | 34 | In the `provider` block, set your API key in the `api_key` field. Alternatively, use the `TAILSCALE_API_KEY` environment variable. 35 | 36 | ### Using OAuth client 37 | 38 | Instead of using a personal API key, you can configure the provider to use an [OAuth client](https://tailscale.com/kb/1215/oauth-clients/), e.g.: 39 | 40 | ```terraform 41 | provider "tailscale" { 42 | oauth_client_id = "..." 43 | oauth_client_secret = "tskey-client-..." 44 | } 45 | ``` 46 | 47 | ### API endpoint 48 | 49 | The default api endpoint is `https://api.tailscale.com`. If your coordination/control server API is at another endpoint, you can pass in `base_url` in the provider block. 50 | 51 | ```terraform 52 | provider "tailscale" { 53 | api_key = "tskey-api-..." 54 | base_url = "https://api.us.tailscale.com" 55 | } 56 | ``` 57 | 58 | ## Updating an existing installation 59 | To update an existing terraform deployment currently using the original `davidsbond/tailscale` provider, use: 60 | ``` 61 | terraform state replace-provider registry.terraform.io/davidsbond/tailscale registry.terraform.io/tailscale/tailscale 62 | ``` 63 | 64 | ## Contributing 65 | 66 | Please review the [contributing guidelines](./CONTRIBUTING.md) and [code of conduct](.github/CODE_OF_CONDUCT.md) before 67 | contributing to this codebase. Please create a [new issue](https://github.com/tailscale/terraform-provider-tailscale/issues/new/choose) 68 | for bugs and feature requests and fill in as much detail as you can. 69 | 70 | ### Local Provider Development 71 | 72 | The [Terraform plugin documentation on debugging](https://developer.hashicorp.com/terraform/plugin/debugging) 73 | provides helpful strategies for debugging while developing plugins. 74 | 75 | Namely, adding a [development override](https://developer.hashicorp.com/terraform/cli/config/config-file#development-overrides-for-provider-developers) 76 | for the `tailscale/tailscale` provider allows for using your local copy of the provider instead of a published version. 77 | 78 | Your `terraformrc` should look something like the following: 79 | 80 | ```hcl 81 | provider_installation { 82 | # This disables the version and checksum verifications for this 83 | # provider and forces Terraform to look for the tailscale/tailscale 84 | # provider plugin in the given directory. 85 | dev_overrides { 86 | "tailscale/tailscale" = "/path/to/this/repo/on/disk" 87 | } 88 | # For all other providers, install them directly from their origin provider 89 | # registries as normal. If you omit this, Terraform will _only_ use 90 | # the dev_overrides block, and so no other providers will be available. 91 | direct {} 92 | } 93 | ``` 94 | 95 | Remember to run `make build` to build the provider and pick up your local changes. 96 | 97 | #### Acceptance Tests 98 | 99 | Tests in this repo that are prefixed with `TestAcc` are acceptance tests which run against a real instance of the tailscale control plane. 100 | These tests are skipped unless the `TF_ACC` environment variable is set. 101 | Running `make testacc` sets the `TF_ACC` variable and runs the tests. 102 | 103 | The `TF_ACC` environment variable is handled by [Terraform core code](https://developer.hashicorp.com/terraform/plugin/sdkv2/testing/acceptance-tests#requirements-and-recommendations) 104 | and is not directly referenced in provider code. 105 | 106 | The following tailscale specific environment variables must also be set: 107 | - `TAILSCALE_BASE_URL` 108 | - URL of the control plane 109 | - `TAILSCALE_API_KEY` 110 | - Tests will be performed against the tailnet which the key belongs to 111 | - `TAILSCALE_TEST_DEVICE_NAME` 112 | - The FQDN of a device owned by the owner of the API key in use 113 | 114 | If you run a local control server with the `terraform-acceptance-testing` test scenario, then you can use the make rule `testacc_local` 115 | which will correctly populate the necessary environment variables for you. 116 | 117 | ``` 118 | ./tool/go run ./cmd/tailcontrol --dev --generate-test-devices=terraform-acceptance-testing & 119 | make testacc_local 120 | ``` 121 | 122 | ## Releasing 123 | 124 | Pushing a tag of the format `vX.Y.Z` will trigger the [release workflow](./.github/workflows/release.yml) which uses [goreleaser](https://github.com/goreleaser/goreleaser) to build and sign artifacts and generate a [GitHub release](https://github.com/tailscale/terraform-provider-tailscale/releases). 125 | 126 | GitHub releases are pulled in and served by the [HashiCorp Terrafrom](https://registry.terraform.io/providers/tailscale/tailscale/latest) and [OpenTofu](https://github.com/opentofu/registry/blob/main/providers/t/tailscale/tailscale.json) registries for usage of the provider via Terraform or OpenTofu. 127 | -------------------------------------------------------------------------------- /docs/data-sources/4via6.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "tailscale_4via6 Data Source - terraform-provider-tailscale" 4 | subcategory: "" 5 | description: |- 6 | The 4via6 data source is calculates an IPv6 prefix for a given site ID and IPv4 CIDR. See Tailscale documentation for 4via6 subnets https://tailscale.com/kb/1201/4via6-subnets/ for more details. 7 | --- 8 | 9 | # tailscale_4via6 (Data Source) 10 | 11 | The 4via6 data source is calculates an IPv6 prefix for a given site ID and IPv4 CIDR. See Tailscale documentation for [4via6 subnets](https://tailscale.com/kb/1201/4via6-subnets/) for more details. 12 | 13 | ## Example Usage 14 | 15 | ```terraform 16 | data "tailscale_4via6" "example" { 17 | site = 7 18 | cidr = "10.1.1.0/24" 19 | } 20 | ``` 21 | 22 | 23 | ## Schema 24 | 25 | ### Required 26 | 27 | - `cidr` (String) The IPv4 CIDR to map 28 | - `site` (Number) Site ID (between 0 and 65535) 29 | 30 | ### Read-Only 31 | 32 | - `id` (String) The ID of this resource. 33 | - `ipv6` (String) The 4via6 mapped address 34 | -------------------------------------------------------------------------------- /docs/data-sources/acl.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "tailscale_acl Data Source - terraform-provider-tailscale" 4 | subcategory: "" 5 | description: |- 6 | The acl data source gets the Tailscale ACL for a tailnet 7 | --- 8 | 9 | # tailscale_acl (Data Source) 10 | 11 | The acl data source gets the Tailscale ACL for a tailnet 12 | 13 | 14 | 15 | 16 | ## Schema 17 | 18 | ### Read-Only 19 | 20 | - `hujson` (String) The contents of Tailscale ACL as a HuJSON string 21 | - `id` (String) The ID of this resource. 22 | - `json` (String) The contents of Tailscale ACL as a JSON string 23 | -------------------------------------------------------------------------------- /docs/data-sources/device.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "tailscale_device Data Source - terraform-provider-tailscale" 4 | subcategory: "" 5 | description: |- 6 | The device data source describes a single device in a tailnet 7 | --- 8 | 9 | # tailscale_device (Data Source) 10 | 11 | The device data source describes a single device in a tailnet 12 | 13 | ## Example Usage 14 | 15 | ```terraform 16 | data "tailscale_device" "sample_device" { 17 | name = "device1.example.ts.net" 18 | wait_for = "60s" 19 | } 20 | 21 | data "tailscale_device" "sample_device2" { 22 | hostname = "device2" 23 | wait_for = "60s" 24 | } 25 | ``` 26 | 27 | 28 | ## Schema 29 | 30 | ### Optional 31 | 32 | - `hostname` (String) The short hostname of the device 33 | - `name` (String) The full name of the device (e.g. `hostname.domain.ts.net`) 34 | - `wait_for` (String) If specified, the provider will make multiple attempts to obtain the data source until the wait_for duration is reached. Retries are made every second so this value should be greater than 1s 35 | 36 | ### Read-Only 37 | 38 | - `addresses` (List of String) The list of device's IPs 39 | - `id` (String) The ID of this resource. 40 | - `node_id` (String) The preferred indentifier for a device. 41 | - `tags` (Set of String) The tags applied to the device 42 | - `user` (String) The user associated with the device 43 | -------------------------------------------------------------------------------- /docs/data-sources/devices.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "tailscale_devices Data Source - terraform-provider-tailscale" 4 | subcategory: "" 5 | description: |- 6 | The devices data source describes a list of devices in a tailnet 7 | --- 8 | 9 | # tailscale_devices (Data Source) 10 | 11 | The devices data source describes a list of devices in a tailnet 12 | 13 | ## Example Usage 14 | 15 | ```terraform 16 | data "tailscale_devices" "sample_devices" { 17 | name_prefix = "example-" 18 | } 19 | ``` 20 | 21 | 22 | ## Schema 23 | 24 | ### Optional 25 | 26 | - `name_prefix` (String) Filters the device list to elements whose name has the provided prefix 27 | 28 | ### Read-Only 29 | 30 | - `devices` (List of Object) The list of devices in the tailnet (see [below for nested schema](#nestedatt--devices)) 31 | - `id` (String) The ID of this resource. 32 | 33 | 34 | ### Nested Schema for `devices` 35 | 36 | Read-Only: 37 | 38 | - `addresses` (List of String) 39 | - `hostname` (String) 40 | - `id` (String) 41 | - `name` (String) 42 | - `node_id` (String) 43 | - `tags` (Set of String) 44 | - `user` (String) 45 | -------------------------------------------------------------------------------- /docs/data-sources/user.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "tailscale_user Data Source - terraform-provider-tailscale" 4 | subcategory: "" 5 | description: |- 6 | The user data source describes a single user in a tailnet 7 | --- 8 | 9 | # tailscale_user (Data Source) 10 | 11 | The user data source describes a single user in a tailnet 12 | 13 | ## Example Usage 14 | 15 | ```terraform 16 | data "tailscale_user" "32571345" { 17 | id = 32571345 18 | } 19 | ``` 20 | 21 | 22 | ## Schema 23 | 24 | ### Optional 25 | 26 | - `id` (String) The unique identifier for the user. 27 | - `login_name` (String) The emailish login name of the user. 28 | 29 | ### Read-Only 30 | 31 | - `created` (String) The time the user joined their tailnet. 32 | - `currently_connected` (Boolean) true when the user has a node currently connected to the control server. 33 | - `device_count` (Number) Number of devices the user owns. 34 | - `display_name` (String) The name of the user. 35 | - `last_seen` (String) The later of either: a) The last time any of the user's nodes were connected to the network or b) The last time the user authenticated to any tailscale service, including the admin panel. 36 | - `profile_pic_url` (String) The profile pic URL for the user. 37 | - `role` (String) The role of the user. 38 | - `status` (String) The status of the user. 39 | - `tailnet_id` (String) The tailnet that owns the user. 40 | - `type` (String) The type of relation this user has to the tailnet associated with the request. 41 | -------------------------------------------------------------------------------- /docs/data-sources/users.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "tailscale_users Data Source - terraform-provider-tailscale" 4 | subcategory: "" 5 | description: |- 6 | The users data source describes a list of users in a tailnet 7 | --- 8 | 9 | # tailscale_users (Data Source) 10 | 11 | The users data source describes a list of users in a tailnet 12 | 13 | ## Example Usage 14 | 15 | ```terraform 16 | data "tailscale_users" "all-users" {} 17 | ``` 18 | 19 | 20 | ## Schema 21 | 22 | ### Optional 23 | 24 | - `role` (String) Filters the users list to elements whose role is the provided value. 25 | - `type` (String) Filters the users list to elements whose type is the provided value. 26 | 27 | ### Read-Only 28 | 29 | - `id` (String) The ID of this resource. 30 | - `users` (List of Object) The list of users in the tailnet (see [below for nested schema](#nestedatt--users)) 31 | 32 | 33 | ### Nested Schema for `users` 34 | 35 | Read-Only: 36 | 37 | - `created` (String) 38 | - `currently_connected` (Boolean) 39 | - `device_count` (Number) 40 | - `display_name` (String) 41 | - `id` (String) 42 | - `last_seen` (String) 43 | - `login_name` (String) 44 | - `profile_pic_url` (String) 45 | - `role` (String) 46 | - `status` (String) 47 | - `tailnet_id` (String) 48 | - `type` (String) 49 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "tailscale Provider" 4 | subcategory: "" 5 | description: |- 6 | 7 | --- 8 | 9 | # tailscale Provider 10 | 11 | 12 | 13 | ## Example Usage 14 | 15 | ```terraform 16 | terraform { 17 | required_providers { 18 | tailscale = { 19 | source = "tailscale/tailscale" 20 | version = "" 21 | } 22 | } 23 | } 24 | 25 | provider "tailscale" { 26 | api_key = "my_api_key" 27 | tailnet = "example.com" 28 | } 29 | ``` 30 | 31 | 32 | ## Schema 33 | 34 | ### Optional 35 | 36 | - `api_key` (String, Sensitive) The API key to use for authenticating requests to the API. Can be set via the TAILSCALE_API_KEY environment variable. Conflicts with 'oauth_client_id' and 'oauth_client_secret'. 37 | - `base_url` (String) The base URL of the Tailscale API. Defaults to https://api.tailscale.com. Can be set via the TAILSCALE_BASE_URL environment variable. 38 | - `oauth_client_id` (String) The OAuth application's ID when using OAuth client credentials. Can be set via the TAILSCALE_OAUTH_CLIENT_ID environment variable. Both 'oauth_client_id' and 'oauth_client_secret' must be set. Conflicts with 'api_key'. 39 | - `oauth_client_secret` (String, Sensitive) The OAuth application's secret when using OAuth client credentials. Can be set via the TAILSCALE_OAUTH_CLIENT_SECRET environment variable. Both 'oauth_client_id' and 'oauth_client_secret' must be set. Conflicts with 'api_key'. 40 | - `scopes` (List of String) The OAuth 2.0 scopes to request when for the access token generated using the supplied OAuth client credentials. See https://tailscale.com/kb/1215/oauth-clients/#scopes for available scopes. Only valid when both 'oauth_client_id' and 'oauth_client_secret' are set. 41 | - `tailnet` (String) The organization name of the Tailnet in which to perform actions. Can be set via the TAILSCALE_TAILNET environment variable. Default is the tailnet that owns API credentials passed to the provider. 42 | - `user_agent` (String) User-Agent header for API requests. 43 | -------------------------------------------------------------------------------- /docs/resources/acl.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "tailscale_acl Resource - terraform-provider-tailscale" 4 | subcategory: "" 5 | description: |- 6 | The acl resource allows you to configure a Tailscale ACL. See https://tailscale.com/kb/1018/acls for more information. Note that this resource will completely overwrite existing ACL contents for a given tailnet. 7 | If tests are defined in the ACL (the top-level "tests" section), ACL validation will occur before creation and update operations are applied. 8 | --- 9 | 10 | # tailscale_acl (Resource) 11 | 12 | The acl resource allows you to configure a Tailscale ACL. See https://tailscale.com/kb/1018/acls for more information. Note that this resource will completely overwrite existing ACL contents for a given tailnet. 13 | 14 | If tests are defined in the ACL (the top-level "tests" section), ACL validation will occur before creation and update operations are applied. 15 | 16 | ## Example Usage 17 | 18 | ```terraform 19 | resource "tailscale_acl" "as_json" { 20 | acl = jsonencode({ 21 | acls : [ 22 | { 23 | // Allow all users access to all ports. 24 | action = "accept", 25 | users = ["*"], 26 | ports = ["*:*"], 27 | }, 28 | ], 29 | }) 30 | } 31 | 32 | resource "tailscale_acl" "as_hujson" { 33 | acl = < 50 | ## Schema 51 | 52 | ### Required 53 | 54 | - `acl` (String) The policy that defines which devices and users are allowed to connect in your network. Can be either a JSON or a HuJSON string. 55 | 56 | ### Optional 57 | 58 | - `overwrite_existing_content` (Boolean) If true, will skip requirement to import acl before allowing changes. Be careful, can cause ACL to be overwritten 59 | - `reset_acl_on_destroy` (Boolean) If true, will reset the ACL for the Tailnet to the default when this resource is destroyed 60 | 61 | ### Read-Only 62 | 63 | - `id` (String) The ID of this resource. 64 | 65 | ## Import 66 | 67 | Import is supported using the following syntax: 68 | 69 | ```shell 70 | # ID doesn't matter. 71 | terraform import tailscale_acl.sample_acl acl 72 | ``` 73 | -------------------------------------------------------------------------------- /docs/resources/aws_external_id.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "tailscale_aws_external_id Resource - terraform-provider-tailscale" 4 | subcategory: "" 5 | description: |- 6 | The aws_external_id resource allows you to mint an AWS External ID that Tailscale can use to assume an AWS IAM role that you create for the purposes of allowing Tailscale to stream logs to your S3 bucket. See the logstream_configuration resource for more details. 7 | --- 8 | 9 | # tailscale_aws_external_id (Resource) 10 | 11 | The aws_external_id resource allows you to mint an AWS External ID that Tailscale can use to assume an AWS IAM role that you create for the purposes of allowing Tailscale to stream logs to your S3 bucket. See the logstream_configuration resource for more details. 12 | 13 | ## Example Usage 14 | 15 | ```terraform 16 | resource "tailscale_aws_external_id" "prod" {} 17 | 18 | resource "tailscale_logstream_configuration" "configuration_logs" { 19 | log_type = "configuration" 20 | destination_type = "s3" 21 | s3_bucket = aws_s3_bucket.tailscale_logs.id 22 | s3_region = "us-west-2" 23 | s3_authentication_type = "rolearn" 24 | s3_role_arn = aws_iam_role.logs_writer.arn 25 | s3_external_id = tailscale_aws_external_id.prod.external_id 26 | } 27 | 28 | resource "aws_iam_role" "logs_writer" { 29 | name = "logs-writer" 30 | assume_role_policy = data.aws_iam_policy_document.tailscale_assume_role.json 31 | } 32 | 33 | resource "aws_iam_role_policy" "logs_writer" { 34 | role = aws_iam_role.logs_writer.id 35 | policy = data.aws_iam_policy_document.logs_writer.json 36 | } 37 | 38 | data "aws_iam_policy_document" "tailscale_assume_role" { 39 | statement { 40 | actions = ["sts:AssumeRole"] 41 | principals { 42 | type = "AWS" 43 | identifiers = [tailscale_aws_external_id.prod.tailscale_aws_account_id] 44 | } 45 | condition { 46 | test = "StringEquals" 47 | variable = "sts:ExternalId" 48 | values = [tailscale_aws_external_id.prod.external_id] 49 | } 50 | } 51 | } 52 | 53 | data "aws_iam_policy_document" "logs_writer" { 54 | statement { 55 | effect = "Allow" 56 | actions = ["s3:*"] 57 | resources = [ 58 | "arn:aws:s3:::example-bucket", 59 | "arn:aws:s3:::example-bucket/*" 60 | ] 61 | } 62 | } 63 | ``` 64 | 65 | 66 | ## Schema 67 | 68 | ### Read-Only 69 | 70 | - `external_id` (String) The External ID that Tailscale will supply when assuming your role. You must reference this in your IAM role's trust policy. See https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_common-scenarios_third-party.html for more information on external IDs. 71 | - `id` (String) The ID of this resource. 72 | - `tailscale_aws_account_id` (String) The AWS account from which Tailscale will assume your role. You must reference this in your IAM role's trust policy. See https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_common-scenarios_third-party.html for more information on external IDs. 73 | -------------------------------------------------------------------------------- /docs/resources/contacts.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "tailscale_contacts Resource - terraform-provider-tailscale" 4 | subcategory: "" 5 | description: |- 6 | The contacts resource allows you to configure contact details for your Tailscale network. See https://tailscale.com/kb/1224/contact-preferences for more information. 7 | Destroying this resource does not unset or modify values in the tailscale control plane, and simply removes the resource from Terraform state. 8 | --- 9 | 10 | # tailscale_contacts (Resource) 11 | 12 | The contacts resource allows you to configure contact details for your Tailscale network. See https://tailscale.com/kb/1224/contact-preferences for more information. 13 | 14 | Destroying this resource does not unset or modify values in the tailscale control plane, and simply removes the resource from Terraform state. 15 | 16 | ## Example Usage 17 | 18 | ```terraform 19 | resource "tailscale_contacts" "sample_contacts" { 20 | account { 21 | email = "account@example.com" 22 | } 23 | 24 | support { 25 | email = "support@example.com" 26 | } 27 | 28 | security { 29 | email = "security@example.com" 30 | } 31 | } 32 | ``` 33 | 34 | 35 | ## Schema 36 | 37 | ### Required 38 | 39 | - `account` (Block Set, Min: 1, Max: 1) Configuration for communications about important changes to your tailnet (see [below for nested schema](#nestedblock--account)) 40 | - `security` (Block Set, Min: 1, Max: 1) Configuration for communications about security issues affecting your tailnet (see [below for nested schema](#nestedblock--security)) 41 | - `support` (Block Set, Min: 1, Max: 1) Configuration for communications about misconfigurations in your tailnet (see [below for nested schema](#nestedblock--support)) 42 | 43 | ### Read-Only 44 | 45 | - `id` (String) The ID of this resource. 46 | 47 | 48 | ### Nested Schema for `account` 49 | 50 | Required: 51 | 52 | - `email` (String) Email address to send communications to 53 | 54 | 55 | 56 | ### Nested Schema for `security` 57 | 58 | Required: 59 | 60 | - `email` (String) Email address to send communications to 61 | 62 | 63 | 64 | ### Nested Schema for `support` 65 | 66 | Required: 67 | 68 | - `email` (String) Email address to send communications to 69 | 70 | ## Import 71 | 72 | Import is supported using the following syntax: 73 | 74 | ```shell 75 | # ID doesn't matter. 76 | terraform import tailscale_contacts.sample_contacts contacts 77 | ``` 78 | -------------------------------------------------------------------------------- /docs/resources/device_authorization.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "tailscale_device_authorization Resource - terraform-provider-tailscale" 4 | subcategory: "" 5 | description: |- 6 | The device_authorization resource is used to approve new devices before they can join the tailnet. See https://tailscale.com/kb/1099/device-authorization/ for more details. 7 | --- 8 | 9 | # tailscale_device_authorization (Resource) 10 | 11 | The device_authorization resource is used to approve new devices before they can join the tailnet. See https://tailscale.com/kb/1099/device-authorization/ for more details. 12 | 13 | ## Example Usage 14 | 15 | ```terraform 16 | data "tailscale_device" "sample_device" { 17 | name = "device.example.com" 18 | } 19 | 20 | resource "tailscale_device_authorization" "sample_authorization" { 21 | # Prefer the new, stable `node_id` attribute; the legacy `.id` field still works. 22 | device_id = data.tailscale_device.sample_device.node_id 23 | authorized = true 24 | } 25 | ``` 26 | 27 | 28 | ## Schema 29 | 30 | ### Required 31 | 32 | - `authorized` (Boolean) Whether or not the device is authorized 33 | - `device_id` (String) The device to set as authorized 34 | 35 | ### Read-Only 36 | 37 | - `id` (String) The ID of this resource. 38 | 39 | ## Import 40 | 41 | Import is supported using the following syntax: 42 | 43 | ```shell 44 | # Device authorization can be imported using the node ID (preferred), e.g., 45 | terraform import tailscale_device_authorization.sample_authorization nodeidCNTRL 46 | # Device authorization can be imported using the legacy ID, e.g., 47 | terraform import tailscale_device_authorization.sample_authorization 123456789 48 | ``` 49 | -------------------------------------------------------------------------------- /docs/resources/device_key.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "tailscale_device_key Resource - terraform-provider-tailscale" 4 | subcategory: "" 5 | description: |- 6 | The device_key resource allows you to update the properties of a device's key 7 | --- 8 | 9 | # tailscale_device_key (Resource) 10 | 11 | The device_key resource allows you to update the properties of a device's key 12 | 13 | ## Example Usage 14 | 15 | ```terraform 16 | data "tailscale_device" "example_device" { 17 | name = "device.example.com" 18 | } 19 | 20 | resource "tailscale_device_key" "example_key" { 21 | # Prefer the new, stable `node_id` attribute; the legacy `.id` field still works. 22 | device_id = data.tailscale_device.example_device.node_id 23 | key_expiry_disabled = true 24 | } 25 | ``` 26 | 27 | 28 | ## Schema 29 | 30 | ### Required 31 | 32 | - `device_id` (String) The device to update the key properties of 33 | 34 | ### Optional 35 | 36 | - `key_expiry_disabled` (Boolean) Determines whether or not the device's key will expire. Defaults to `false`. 37 | 38 | ### Read-Only 39 | 40 | - `id` (String) The ID of this resource. 41 | 42 | ## Import 43 | 44 | Import is supported using the following syntax: 45 | 46 | ```shell 47 | # Device key can be imported using the node ID (preferred), e.g., 48 | terraform import tailscale_device_key.sample nodeidCNTRL 49 | # Device key can be imported using the legacy ID, e.g., 50 | terraform import tailscale_device_key.sample 123456789 51 | ``` 52 | -------------------------------------------------------------------------------- /docs/resources/device_subnet_routes.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "tailscale_device_subnet_routes Resource - terraform-provider-tailscale" 4 | subcategory: "" 5 | description: |- 6 | The device_subnet_routes resource allows you to configure enabled subnet routes for your Tailscale devices. See https://tailscale.com/kb/1019/subnets for more information. 7 | Routes must be both advertised and enabled for a device to act as a subnet router or exit node. Routes must be advertised directly from the device: advertised routes cannot be managed through Terraform. If a device is advertising routes, they are not exposed to traffic until they are enabled. Conversely, if routes are enabled before they are advertised, they are not available for routing until the device in question is advertising them. 8 | Note: all routes enabled for the device through the admin console or autoApprovers in the ACL must be explicitly added to the routes attribute of this resource to avoid configuration drift. 9 | --- 10 | 11 | # tailscale_device_subnet_routes (Resource) 12 | 13 | The device_subnet_routes resource allows you to configure enabled subnet routes for your Tailscale devices. See https://tailscale.com/kb/1019/subnets for more information. 14 | 15 | Routes must be both advertised and enabled for a device to act as a subnet router or exit node. Routes must be advertised directly from the device: advertised routes cannot be managed through Terraform. If a device is advertising routes, they are not exposed to traffic until they are enabled. Conversely, if routes are enabled before they are advertised, they are not available for routing until the device in question is advertising them. 16 | 17 | Note: all routes enabled for the device through the admin console or autoApprovers in the ACL must be explicitly added to the routes attribute of this resource to avoid configuration drift. 18 | 19 | ## Example Usage 20 | 21 | ```terraform 22 | data "tailscale_device" "sample_device" { 23 | name = "device.example.com" 24 | } 25 | 26 | resource "tailscale_device_subnet_routes" "sample_routes" { 27 | # Prefer the new, stable `node_id` attribute; the legacy `.id` field still works. 28 | device_id = data.tailscale_device.sample_device.node_id 29 | routes = [ 30 | "10.0.1.0/24", 31 | "1.2.0.0/16", 32 | "2.0.0.0/24" 33 | ] 34 | } 35 | 36 | resource "tailscale_device_subnet_routes" "sample_exit_node" { 37 | # Prefer the new, stable `node_id` attribute; the legacy `.id` field still works. 38 | device_id = data.tailscale_device.sample_device.node_id 39 | routes = [ 40 | # Configure as an exit node 41 | "0.0.0.0/0", 42 | "::/0" 43 | ] 44 | } 45 | ``` 46 | 47 | 48 | ## Schema 49 | 50 | ### Required 51 | 52 | - `device_id` (String) The device to set subnet routes for 53 | - `routes` (Set of String) The subnet routes that are enabled to be routed by a device 54 | 55 | ### Read-Only 56 | 57 | - `id` (String) The ID of this resource. 58 | 59 | ## Import 60 | 61 | Import is supported using the following syntax: 62 | 63 | ```shell 64 | # Device subnet rules can be imported using the node ID (preferred), e.g., 65 | terraform import tailscale_device_subnet_routes.sample nodeidCNTRL 66 | # Device subnet rules can be imported using the legacy ID, e.g., 67 | terraform import tailscale_device_subnet_routes.sample 123456789 68 | ``` 69 | -------------------------------------------------------------------------------- /docs/resources/device_tags.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "tailscale_device_tags Resource - terraform-provider-tailscale" 4 | subcategory: "" 5 | description: |- 6 | The device_tags resource is used to apply tags to Tailscale devices. See https://tailscale.com/kb/1068/acl-tags/ for more details. 7 | --- 8 | 9 | # tailscale_device_tags (Resource) 10 | 11 | The device_tags resource is used to apply tags to Tailscale devices. See https://tailscale.com/kb/1068/acl-tags/ for more details. 12 | 13 | ## Example Usage 14 | 15 | ```terraform 16 | data "tailscale_device" "sample_device" { 17 | name = "device.example.com" 18 | } 19 | 20 | resource "tailscale_device_tags" "sample_tags" { 21 | # Prefer the new, stable `node_id` attribute; the legacy `.id` field still works. 22 | device_id = data.tailscale_device.sample_device.node_id 23 | tags = ["room:bedroom"] 24 | } 25 | ``` 26 | 27 | 28 | ## Schema 29 | 30 | ### Required 31 | 32 | - `device_id` (String) The device to set tags for 33 | - `tags` (Set of String) The tags to apply to the device 34 | 35 | ### Read-Only 36 | 37 | - `id` (String) The ID of this resource. 38 | 39 | ## Import 40 | 41 | Import is supported using the following syntax: 42 | 43 | ```shell 44 | # Device tags can be imported using the node ID (preferred), e.g., 45 | terraform import tailscale_device_tags.sample nodeidCNTRL 46 | # Device tags can be imported using the legacy ID, e.g., 47 | terraform import tailscale_device_tags.sample 123456789 48 | ``` 49 | -------------------------------------------------------------------------------- /docs/resources/dns_nameservers.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "tailscale_dns_nameservers Resource - terraform-provider-tailscale" 4 | subcategory: "" 5 | description: |- 6 | The dns_nameservers resource allows you to configure DNS nameservers for your Tailscale network. See https://tailscale.com/kb/1054/dns for more information. 7 | --- 8 | 9 | # tailscale_dns_nameservers (Resource) 10 | 11 | The dns_nameservers resource allows you to configure DNS nameservers for your Tailscale network. See https://tailscale.com/kb/1054/dns for more information. 12 | 13 | ## Example Usage 14 | 15 | ```terraform 16 | resource "tailscale_dns_nameservers" "sample_nameservers" { 17 | nameservers = [ 18 | "8.8.8.8", 19 | "8.8.4.4" 20 | ] 21 | } 22 | ``` 23 | 24 | 25 | ## Schema 26 | 27 | ### Required 28 | 29 | - `nameservers` (List of String) Devices on your network will use these nameservers to resolve DNS names. IPv4 or IPv6 addresses are accepted. 30 | 31 | ### Read-Only 32 | 33 | - `id` (String) The ID of this resource. 34 | 35 | ## Import 36 | 37 | Import is supported using the following syntax: 38 | 39 | ```shell 40 | # ID doesn't matter. 41 | terraform import tailscale_dns_nameservers.sample dns_nameservers 42 | ``` 43 | -------------------------------------------------------------------------------- /docs/resources/dns_preferences.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "tailscale_dns_preferences Resource - terraform-provider-tailscale" 4 | subcategory: "" 5 | description: |- 6 | The dns_preferences resource allows you to configure DNS preferences for your Tailscale network. See https://tailscale.com/kb/1054/dns for more information. 7 | --- 8 | 9 | # tailscale_dns_preferences (Resource) 10 | 11 | The dns_preferences resource allows you to configure DNS preferences for your Tailscale network. See https://tailscale.com/kb/1054/dns for more information. 12 | 13 | ## Example Usage 14 | 15 | ```terraform 16 | resource "tailscale_dns_preferences" "sample_preferences" { 17 | magic_dns = true 18 | } 19 | ``` 20 | 21 | 22 | ## Schema 23 | 24 | ### Required 25 | 26 | - `magic_dns` (Boolean) Whether or not to enable magic DNS 27 | 28 | ### Read-Only 29 | 30 | - `id` (String) The ID of this resource. 31 | 32 | ## Import 33 | 34 | Import is supported using the following syntax: 35 | 36 | ```shell 37 | # ID doesn't matter. 38 | terraform import tailscale_dns_preferences.sample_preferences dns_preferences 39 | ``` 40 | -------------------------------------------------------------------------------- /docs/resources/dns_search_paths.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "tailscale_dns_search_paths Resource - terraform-provider-tailscale" 4 | subcategory: "" 5 | description: |- 6 | The dns_nameservers resource allows you to configure DNS nameservers for your Tailscale network. See https://tailscale.com/kb/1054/dns for more information. 7 | --- 8 | 9 | # tailscale_dns_search_paths (Resource) 10 | 11 | The dns_nameservers resource allows you to configure DNS nameservers for your Tailscale network. See https://tailscale.com/kb/1054/dns for more information. 12 | 13 | ## Example Usage 14 | 15 | ```terraform 16 | resource "tailscale_dns_search_paths" "sample_search_paths" { 17 | search_paths = [ 18 | "example.com" 19 | ] 20 | } 21 | ``` 22 | 23 | 24 | ## Schema 25 | 26 | ### Required 27 | 28 | - `search_paths` (List of String) Devices on your network will use these domain suffixes to resolve DNS names. 29 | 30 | ### Read-Only 31 | 32 | - `id` (String) The ID of this resource. 33 | 34 | ## Import 35 | 36 | Import is supported using the following syntax: 37 | 38 | ```shell 39 | # ID doesn't matter. 40 | terraform import tailscale_dns_search_paths.sample dns_search_paths 41 | ``` 42 | -------------------------------------------------------------------------------- /docs/resources/dns_split_nameservers.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "tailscale_dns_split_nameservers Resource - terraform-provider-tailscale" 4 | subcategory: "" 5 | description: |- 6 | The dns_split_nameservers resource allows you to configure split DNS nameservers for your Tailscale network. See https://tailscale.com/kb/1054/dns for more information. 7 | --- 8 | 9 | # tailscale_dns_split_nameservers (Resource) 10 | 11 | The dns_split_nameservers resource allows you to configure split DNS nameservers for your Tailscale network. See https://tailscale.com/kb/1054/dns for more information. 12 | 13 | ## Example Usage 14 | 15 | ```terraform 16 | resource "tailscale_dns_split_nameservers" "sample_split_nameservers" { 17 | domain = "foo.example.com" 18 | 19 | nameservers = ["1.1.1.1"] 20 | } 21 | ``` 22 | 23 | 24 | ## Schema 25 | 26 | ### Required 27 | 28 | - `domain` (String) Domain to configure split DNS for. Requests for this domain will be resolved using the provided nameservers. Changing this will force the resource to be recreated. 29 | - `nameservers` (Set of String) Devices on your network will use these nameservers to resolve DNS names. IPv4 or IPv6 addresses are accepted. 30 | 31 | ### Read-Only 32 | 33 | - `id` (String) The ID of this resource. 34 | 35 | ## Import 36 | 37 | Import is supported using the following syntax: 38 | 39 | ```shell 40 | # Split DNS nameservers can be imported using the domain name, e.g. 41 | terraform import tailscale_dns_split_nameservers.sample_split_nameservers example.com 42 | ``` 43 | -------------------------------------------------------------------------------- /docs/resources/logstream_configuration.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "tailscale_logstream_configuration Resource - terraform-provider-tailscale" 4 | subcategory: "" 5 | description: |- 6 | The logstream_configuration resource allows you to configure streaming configuration or network flow logs to a supported security information and event management (SIEM) system. See https://tailscale.com/kb/1255/log-streaming for more information. 7 | --- 8 | 9 | # tailscale_logstream_configuration (Resource) 10 | 11 | The logstream_configuration resource allows you to configure streaming configuration or network flow logs to a supported security information and event management (SIEM) system. See https://tailscale.com/kb/1255/log-streaming for more information. 12 | 13 | ## Example Usage 14 | 15 | ```terraform 16 | # Example configuration for a non-S3 logstreaming endpoint 17 | 18 | resource "tailscale_logstream_configuration" "sample_logstream_configuration" { 19 | log_type = "configuration" 20 | destination_type = "panther" 21 | url = "https://example.com" 22 | token = "some-token" 23 | } 24 | 25 | # Example configuration for an AWS S3 logstreaming endpoint 26 | 27 | resource "tailscale_logstream_configuration" "sample_logstream_configuration_s3" { 28 | log_type = "configuration" 29 | destination_type = "s3" 30 | s3_bucket = aws_s3_bucket.tailscale_logs.id 31 | s3_region = "us-west-2" 32 | s3_authentication_type = "rolearn" 33 | s3_role_arn = aws_iam_role.tailscale_logs_writer.arn 34 | s3_external_id = tailscale_aws_external_id.prod.external_id 35 | } 36 | 37 | # Example configuration for an S3-compatible logstreaming endpoint 38 | 39 | resource "tailscale_logstream_configuration" "sample_logstream_configuration_s3_compatible" { 40 | log_type = "configuration" 41 | destination_type = "s3" 42 | url = "https://s3.example.com" 43 | s3_bucket = "example-bucket" 44 | s3_region = "us-west-2" 45 | s3_authentication_type = "accesskey" 46 | s3_access_key_id = "some-access-key" 47 | s3_secret_access_key = "some-secret-key" 48 | } 49 | ``` 50 | 51 | 52 | ## Schema 53 | 54 | ### Required 55 | 56 | - `destination_type` (String) The type of system to which logs are being streamed. 57 | - `log_type` (String) The type of log that is streamed to this endpoint. Either `configuration` for configuration audit logs, or `network` for network flow logs. 58 | 59 | ### Optional 60 | 61 | - `compression_format` (String) The compression algorithm with which to compress logs. One of `none`, `zstd` or `gzip`. Defaults to `none`. 62 | - `s3_access_key_id` (String) The S3 access key ID. Required if destination_type is s3 and s3_authentication_type is 'accesskey'. 63 | - `s3_authentication_type` (String) What type of authentication to use for S3. Required if destination_type is 's3'. Tailscale recommends using 'rolearn'. 64 | - `s3_bucket` (String) The S3 bucket name. Required if destination_type is 's3'. 65 | - `s3_external_id` (String) The AWS External ID that Tailscale supplies when authenticating using role-based authentication. Required if destination_type is 's3' and s3_authentication_type is 'rolearn'. This can be obtained via the tailscale_aws_external_id resource. 66 | - `s3_key_prefix` (String) An optional S3 key prefix to prepend to the auto-generated S3 key name. 67 | - `s3_region` (String) The region in which the S3 bucket is located. Required if destination_type is 's3'. 68 | - `s3_role_arn` (String) ARN of the AWS IAM role that Tailscale should assume when using role-based authentication. Required if destination_type is 's3' and s3_authentication_type is 'rolearn'. 69 | - `s3_secret_access_key` (String, Sensitive) The S3 secret access key. Required if destination_type is 's3' and s3_authentication_type is 'accesskey'. 70 | - `token` (String, Sensitive) The token/password with which log streams to this endpoint should be authenticated, required unless destination_type is 's3'. 71 | - `upload_period_minutes` (Number) An optional number of minutes to wait in between uploading new logs. If the quantity of logs does not fit within a single upload, multiple uploads will be made. 72 | - `url` (String) The URL to which log streams are being posted. If destination_type is 's3' and you want to use the official Amazon S3 endpoint, leave this empty. 73 | - `user` (String) The username with which log streams to this endpoint are authenticated. Only required if destination_type is 'elastic', defaults to 'user' if not set. 74 | 75 | ### Read-Only 76 | 77 | - `id` (String) The ID of this resource. 78 | 79 | ## Import 80 | 81 | Import is supported using the following syntax: 82 | 83 | ```shell 84 | # Logstream configuration can be imported using the logstream configuration id, e.g., 85 | terraform import tailscale_logstream_configuration.sample_logstream_configuration 123456789 86 | ``` 87 | -------------------------------------------------------------------------------- /docs/resources/oauth_client.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "tailscale_oauth_client Resource - terraform-provider-tailscale" 4 | subcategory: "" 5 | description: |- 6 | The oauth_client resource allows you to create OAuth clients to programmatically interact with the Tailscale API. 7 | --- 8 | 9 | # tailscale_oauth_client (Resource) 10 | 11 | The oauth_client resource allows you to create OAuth clients to programmatically interact with the Tailscale API. 12 | 13 | ## Example Usage 14 | 15 | ```terraform 16 | resource "tailscale_oauth_client" "sample_client" { 17 | description = "sample client" 18 | scopes = ["all:read"] 19 | tags = ["tag:test"] 20 | } 21 | ``` 22 | 23 | 24 | ## Schema 25 | 26 | ### Required 27 | 28 | - `scopes` (Set of String) Scopes to grant to the client. See https://tailscale.com/kb/1215/ for a list of available scopes. 29 | 30 | ### Optional 31 | 32 | - `description` (String) A description of the key consisting of alphanumeric characters. Defaults to `""`. 33 | - `tags` (Set of String) A list of tags that access tokens generated for the OAuth client will be able to assign to devices. Mandatory if the scopes include "devices:core" or "auth_keys". 34 | 35 | ### Read-Only 36 | 37 | - `created_at` (String) The creation timestamp of the key in RFC3339 format 38 | - `id` (String) The client ID, also known as the key id. Used with the client secret to generate access tokens. 39 | - `key` (String, Sensitive) The client secret, also known as the key. Used with the client ID to generate access tokens. 40 | - `user_id` (String) ID of the user who created this key, empty for OAuth clients created by other OAuth clients. 41 | -------------------------------------------------------------------------------- /docs/resources/posture_integration.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "tailscale_posture_integration Resource - terraform-provider-tailscale" 4 | subcategory: "" 5 | description: |- 6 | The posture_integration resource allows you to manage integrations with device posture data providers. See https://tailscale.com/kb/1288/device-posture for more information. 7 | --- 8 | 9 | # tailscale_posture_integration (Resource) 10 | 11 | The posture_integration resource allows you to manage integrations with device posture data providers. See https://tailscale.com/kb/1288/device-posture for more information. 12 | 13 | ## Example Usage 14 | 15 | ```terraform 16 | resource "tailscale_posture_integration" "sample_posture_integration" { 17 | posture_provider = "falcon" 18 | cloud_id = "us-1" 19 | client_id = "clientid1" 20 | client_secret = "test-secret1" 21 | } 22 | ``` 23 | 24 | 25 | ## Schema 26 | 27 | ### Required 28 | 29 | - `client_secret` (String) The secret (auth key, token, etc.) used to authenticate with the provider. 30 | - `posture_provider` (String) The type of posture integration data provider. 31 | 32 | ### Optional 33 | 34 | - `client_id` (String) Unique identifier for your client. 35 | - `cloud_id` (String) Identifies which of the provider's clouds to integrate with. 36 | - `tenant_id` (String) The Microsoft Intune directory (tenant) ID. For other providers, this is left blank. 37 | 38 | ### Read-Only 39 | 40 | - `id` (String) The ID of this resource. 41 | 42 | ## Import 43 | 44 | Import is supported using the following syntax: 45 | 46 | ```shell 47 | # Posture integration can be imported using the posture integration id, e.g., 48 | terraform import tailscale_posture_integration.sample_posture_integration 123456789 49 | ``` 50 | -------------------------------------------------------------------------------- /docs/resources/tailnet_key.md: -------------------------------------------------------------------------------- 1 | --- 2 | page_title: "tailscale_tailnet_key Resource - terraform-provider-tailscale" 3 | subcategory: "" 4 | description: |- 5 | The tailnet_key resource allows you to create pre-authentication keys that can register new nodes without needing to sign in via a web browser. See https://tailscale.com/kb/1085/auth-keys for more information 6 | --- 7 | 8 | # tailscale_tailnet_key (Resource) 9 | 10 | The tailnet_key resource allows you to create pre-authentication keys that can register new nodes without needing to sign in via a web browser. See https://tailscale.com/kb/1085/auth-keys for more information 11 | 12 | ## Example Usage 13 | 14 | ```terraform 15 | resource "tailscale_tailnet_key" "sample_key" { 16 | reusable = true 17 | ephemeral = false 18 | preauthorized = true 19 | expiry = 3600 20 | description = "Sample key" 21 | } 22 | ``` 23 | 24 | 25 | ## Schema 26 | 27 | ### Optional 28 | 29 | - `description` (String) A description of the key consisting of alphanumeric characters. Defaults to `""`. 30 | - `ephemeral` (Boolean) Indicates if the key is ephemeral. Defaults to `false`. 31 | - `expiry` (Number) The expiry of the key in seconds. Defaults to `7776000` (90 days). 32 | - `preauthorized` (Boolean) Determines whether or not the machines authenticated by the key will be authorized for the tailnet by default. Defaults to `false`. 33 | - `recreate_if_invalid` (String) Determines whether the key should be created again if it becomes invalid. By default, reusable keys will be recreated, but single-use keys will not. Possible values: 'always', 'never'. 34 | - `reusable` (Boolean) Indicates if the key is reusable or single-use. Defaults to `false`. 35 | - `tags` (Set of String) List of tags to apply to the machines authenticated by the key. 36 | - `user_id` (String) ID of the user who created this key, empty for keys created by OAuth clients. 37 | 38 | ### Read-Only 39 | 40 | - `created_at` (String) The creation timestamp of the key in RFC3339 format 41 | - `expires_at` (String) The expiry timestamp of the key in RFC3339 format 42 | - `id` (String) The ID of this resource. 43 | - `invalid` (Boolean) Indicates whether the key is invalid (e.g. expired, revoked or has been deleted). 44 | - `key` (String, Sensitive) The authentication key 45 | 46 | ## Import 47 | 48 | Import is supported using the following syntax: 49 | 50 | ```shell 51 | # Tailnet key can be imported using the key id, e.g., 52 | terraform import tailscale_tailnet_key.sample_key 123456789 53 | ``` 54 | 55 | -> ** Note ** the `key` attribute will not be populated on import as this attribute is only populated 56 | on resource creation. 57 | 58 | -------------------------------------------------------------------------------- /docs/resources/tailnet_settings.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "tailscale_tailnet_settings Resource - terraform-provider-tailscale" 4 | subcategory: "" 5 | description: |- 6 | The tailnet_settings resource allows you to configure settings for your tailnet. See https://tailscale.com/api#tag/tailnetsettings for more information. 7 | --- 8 | 9 | # tailscale_tailnet_settings (Resource) 10 | 11 | The tailnet_settings resource allows you to configure settings for your tailnet. See https://tailscale.com/api#tag/tailnetsettings for more information. 12 | 13 | ## Example Usage 14 | 15 | ```terraform 16 | resource "tailscale_tailnet_settings" "sample_tailnet_settings" { 17 | acls_externally_managed_on = true 18 | acls_external_link = "https://github.com/octocat/Hello-World" 19 | devices_approval_on = true 20 | devices_auto_updates_on = true 21 | devices_key_duration_days = 5 22 | users_approval_on = true 23 | users_role_allowed_to_join_external_tailnet = "member" 24 | posture_identity_collection_on = true 25 | } 26 | ``` 27 | 28 | 29 | ## Schema 30 | 31 | ### Optional 32 | 33 | - `acls_external_link` (String) Link to your external ACL definition or management system. Must be a valid URL. 34 | - `acls_externally_managed_on` (Boolean) Prevent users from editing policies in the admin console to avoid conflicts with external management workflows like GitOps or Terraform. 35 | - `devices_approval_on` (Boolean) Whether device approval is enabled for the tailnet 36 | - `devices_auto_updates_on` (Boolean) Whether auto updates are enabled for devices that belong to this tailnet 37 | - `devices_key_duration_days` (Number) The key expiry duration for devices on this tailnet 38 | - `network_flow_logging_on` (Boolean) Whether network flog logs are enabled for the tailnet 39 | - `posture_identity_collection_on` (Boolean) Whether identity collection is enabled for device posture integrations for the tailnet 40 | - `regional_routing_on` (Boolean) Whether regional routing is enabled for the tailnet 41 | - `users_approval_on` (Boolean) Whether user approval is enabled for this tailnet 42 | - `users_role_allowed_to_join_external_tailnet` (String) Which user roles are allowed to join external tailnets 43 | 44 | ### Read-Only 45 | 46 | - `id` (String) The ID of this resource. 47 | 48 | ## Import 49 | 50 | Import is supported using the following syntax: 51 | 52 | ```shell 53 | # ID doesn't matter. 54 | terraform import tailscale_tailnet_settings.sample_preferences tailnet_settings 55 | ``` 56 | -------------------------------------------------------------------------------- /docs/resources/webhook.md: -------------------------------------------------------------------------------- 1 | --- 2 | # generated by https://github.com/hashicorp/terraform-plugin-docs 3 | page_title: "tailscale_webhook Resource - terraform-provider-tailscale" 4 | subcategory: "" 5 | description: |- 6 | The webhook resource allows you to configure webhook endpoints for your Tailscale network. See https://tailscale.com/kb/1213/webhooks for more information. 7 | --- 8 | 9 | # tailscale_webhook (Resource) 10 | 11 | The webhook resource allows you to configure webhook endpoints for your Tailscale network. See https://tailscale.com/kb/1213/webhooks for more information. 12 | 13 | ## Example Usage 14 | 15 | ```terraform 16 | resource "tailscale_webhook" "sample_webhook" { 17 | endpoint_url = "https://example.com/webhook/endpoint" 18 | provider_type = "slack" 19 | subscriptions = ["nodeCreated", "userDeleted"] 20 | } 21 | ``` 22 | 23 | 24 | ## Schema 25 | 26 | ### Required 27 | 28 | - `endpoint_url` (String) The endpoint to send webhook events to. 29 | - `subscriptions` (Set of String) The Tailscale events to subscribe this webhook to. See https://tailscale.com/kb/1213/webhooks#events for the list of valid events. 30 | 31 | ### Optional 32 | 33 | - `provider_type` (String) The provider type of the endpoint URL. Also referred to as the 'destination' for the webhook in the admin panel. Webhook event payloads are formatted according to the provider type if it is set to a known value. Must be one of `slack`, `mattermost`, `googlechat`, or `discord` if set. 34 | 35 | ### Read-Only 36 | 37 | - `id` (String) The ID of this resource. 38 | - `secret` (String, Sensitive) The secret used for signing webhook payloads. Only set on resource creation. See https://tailscale.com/kb/1213/webhooks#webhook-secret for more information. 39 | 40 | ## Import 41 | 42 | Import is supported using the following syntax: 43 | 44 | ```shell 45 | # Webhooks can be imported using the endpoint id, e.g., 46 | terraform import tailscale_webhook.sample_webhook 123456789 47 | ``` 48 | -------------------------------------------------------------------------------- /examples/data-sources/tailscale_4via6/data-source.tf: -------------------------------------------------------------------------------- 1 | data "tailscale_4via6" "example" { 2 | site = 7 3 | cidr = "10.1.1.0/24" 4 | } 5 | -------------------------------------------------------------------------------- /examples/data-sources/tailscale_device/data-source.tf: -------------------------------------------------------------------------------- 1 | data "tailscale_device" "sample_device" { 2 | name = "device1.example.ts.net" 3 | wait_for = "60s" 4 | } 5 | 6 | data "tailscale_device" "sample_device2" { 7 | hostname = "device2" 8 | wait_for = "60s" 9 | } 10 | -------------------------------------------------------------------------------- /examples/data-sources/tailscale_devices/data-source.tf: -------------------------------------------------------------------------------- 1 | data "tailscale_devices" "sample_devices" { 2 | name_prefix = "example-" 3 | } 4 | -------------------------------------------------------------------------------- /examples/data-sources/tailscale_user/data-source.tf: -------------------------------------------------------------------------------- 1 | data "tailscale_user" "32571345" { 2 | id = 32571345 3 | } 4 | -------------------------------------------------------------------------------- /examples/data-sources/tailscale_users/data-source.tf: -------------------------------------------------------------------------------- 1 | data "tailscale_users" "all-users" {} 2 | -------------------------------------------------------------------------------- /examples/provider/provider.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | tailscale = { 4 | source = "tailscale/tailscale" 5 | version = "" 6 | } 7 | } 8 | } 9 | 10 | provider "tailscale" { 11 | api_key = "my_api_key" 12 | tailnet = "example.com" 13 | } 14 | -------------------------------------------------------------------------------- /examples/resources/tailscale_acl/import.sh: -------------------------------------------------------------------------------- 1 | # ID doesn't matter. 2 | terraform import tailscale_acl.sample_acl acl 3 | -------------------------------------------------------------------------------- /examples/resources/tailscale_acl/resource.tf: -------------------------------------------------------------------------------- 1 | resource "tailscale_acl" "as_json" { 2 | acl = jsonencode({ 3 | acls : [ 4 | { 5 | // Allow all users access to all ports. 6 | action = "accept", 7 | users = ["*"], 8 | ports = ["*:*"], 9 | }, 10 | ], 11 | }) 12 | } 13 | 14 | resource "tailscale_acl" "as_hujson" { 15 | acl = <&2 25 | exit 1 26 | fi 27 | 28 | fail=0 29 | for file in $(find $1 \( -name '*.go' -or -name '*.tsx' -or -name '*.ts' -not -name '*.config.ts' \) -not -path '*/.git/*' -not -path '*/node_modules/*'); do 30 | case $file in 31 | $1/tempfork/*) 32 | # Skip, tempfork of third-party code 33 | ;; 34 | $1/wgengine/router/ifconfig_windows.go) 35 | # WireGuard copyright. 36 | ;; 37 | $1/cmd/tailscale/cli/authenticode_windows.go) 38 | # WireGuard copyright. 39 | ;; 40 | *_string.go) 41 | # Generated file from go:generate stringer 42 | ;; 43 | $1/control/controlbase/noiseexplorer_test.go) 44 | # Noiseexplorer.com copyright. 45 | ;; 46 | */zsyscall_windows.go) 47 | # Generated syscall wrappers 48 | ;; 49 | $1/util/winutil/subprocess_windows_test.go) 50 | # Subprocess test harness code 51 | ;; 52 | $1/util/winutil/testdata/testrestartableprocesses/main.go) 53 | # Subprocess test harness code 54 | ;; 55 | *$1/k8s-operator/apis/v1alpha1/zz_generated.deepcopy.go) 56 | # Generated kube deepcopy funcs file starts with a Go build tag + an empty line 57 | header="$(head -5 $file | tail -n+3 )" 58 | ;; 59 | $1/derp/xdp/bpf_bpfe*.go) 60 | # Generated eBPF management code 61 | ;; 62 | *) 63 | header="$(head -2 $file)" 64 | ;; 65 | esac 66 | if [ ! -z "$header" ]; then 67 | if ! check_file "$header"; then 68 | fail=1 69 | echo "${file#$1/} doesn't have the right copyright header:" 70 | echo "$header" | sed -e 's/^/ /g' 71 | fi 72 | fi 73 | done 74 | 75 | if [ $fail -ne 0 ]; then 76 | exit 1 77 | fi -------------------------------------------------------------------------------- /tailscale/data_source_4via6.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale 5 | 6 | import ( 7 | "context" 8 | "net/netip" 9 | 10 | "github.com/hashicorp/terraform-plugin-sdk/v2/diag" 11 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 12 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" 13 | "tailscale.com/net/tsaddr" 14 | ) 15 | 16 | func dataSource4Via6() *schema.Resource { 17 | return &schema.Resource{ 18 | Description: "The 4via6 data source is calculates an IPv6 prefix for a given site ID and IPv4 CIDR. See Tailscale documentation for [4via6 subnets](https://tailscale.com/kb/1201/4via6-subnets/) for more details.", 19 | ReadContext: dataSource4Via6Read, 20 | Schema: map[string]*schema.Schema{ 21 | "site": { 22 | Type: schema.TypeInt, 23 | Required: true, 24 | Description: "Site ID (between 0 and 65535)", 25 | ValidateFunc: validation.IntBetween(0, 65535), 26 | }, 27 | "cidr": { 28 | Type: schema.TypeString, 29 | Description: "The IPv4 CIDR to map", 30 | Required: true, 31 | ValidateFunc: validation.IsCIDR, 32 | }, 33 | "ipv6": { 34 | Type: schema.TypeString, 35 | Description: "The 4via6 mapped address", 36 | Computed: true, 37 | }, 38 | }, 39 | } 40 | } 41 | 42 | func dataSource4Via6Read(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 43 | site := uint32(d.Get("site").(int)) 44 | cidr, err := netip.ParsePrefix(d.Get("cidr").(string)) 45 | if err != nil { 46 | return diagnosticsError(err, "Provided CIDR is invalid") 47 | } 48 | 49 | via, err := tsaddr.MapVia(site, cidr) 50 | if err != nil { 51 | return diagnosticsError(err, "Failed to map 4via6 address") 52 | } 53 | 54 | mapped := via.String() 55 | 56 | d.SetId(mapped) 57 | 58 | if err = d.Set("ipv6", mapped); err != nil { 59 | return diagnosticsError(err, "Failed to set ipv6") 60 | } 61 | 62 | return nil 63 | } 64 | -------------------------------------------------------------------------------- /tailscale/data_source_4via6_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale 5 | 6 | import ( 7 | "fmt" 8 | "net/netip" 9 | "regexp" 10 | "strconv" 11 | "testing" 12 | 13 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" 14 | "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" 15 | "tailscale.com/net/tsaddr" 16 | ) 17 | 18 | const testDataSource4Via6 = ` 19 | data "tailscale_4via6" "example" { 20 | site = 7 21 | cidr = "10.1.1.0/24" 22 | } 23 | ` 24 | 25 | const testDataSource4Via6InvalidSite = ` 26 | data "tailscale_4via6" "invalid" { 27 | site = 70000 28 | cidr = "10.1.1.0/24" 29 | } 30 | ` 31 | 32 | func TestProvider_DataSourceTailscale4Via6(t *testing.T) { 33 | resource.ParallelTest(t, resource.TestCase{ 34 | IsUnitTest: true, 35 | ProviderFactories: testProviderFactories(t), 36 | Steps: []resource.TestStep{ 37 | { 38 | Config: testDataSource4Via6, 39 | Check: check4Via6Result("data.tailscale_4via6.example"), 40 | }, 41 | }, 42 | }) 43 | } 44 | 45 | func TestProvider_DataSourceTailscale4Via6_InvalidSite(t *testing.T) { 46 | resource.ParallelTest(t, resource.TestCase{ 47 | IsUnitTest: true, 48 | ProviderFactories: testProviderFactories(t), 49 | Steps: []resource.TestStep{ 50 | { 51 | Config: testDataSource4Via6InvalidSite, 52 | ExpectError: regexp.MustCompile(`expected site to be in the range \(0 - 65535\), got 70000`), 53 | }, 54 | }, 55 | }) 56 | } 57 | 58 | func check4Via6Result(n string) resource.TestCheckFunc { 59 | return func(s *terraform.State) error { 60 | rs, ok := s.RootModule().Resources[n] 61 | if !ok { 62 | return fmt.Errorf("can't find 4via6 resource: %s", n) 63 | } 64 | 65 | if rs.Primary.ID == "" { 66 | return fmt.Errorf("4via6 data source ID not set.") 67 | } 68 | 69 | siteAttr := rs.Primary.Attributes["site"] 70 | if siteAttr == "" { 71 | return fmt.Errorf("attribute site expected to not be nil") 72 | } 73 | 74 | site, err := strconv.ParseUint(siteAttr, 10, 32) 75 | if err != nil { 76 | return fmt.Errorf("invalid site ID %q: %s", siteAttr, err) 77 | } 78 | 79 | if site > 65535 { 80 | return fmt.Errorf("site ID %d is higher than the maximum allowed value of 65535", site) 81 | } 82 | 83 | cidrAttr := rs.Primary.Attributes["cidr"] 84 | if cidrAttr == "" { 85 | return fmt.Errorf("attribute cidr expected to not be nil") 86 | } 87 | 88 | cidr, err := netip.ParsePrefix(cidrAttr) 89 | if err != nil { 90 | return fmt.Errorf("invalid CIDR %q: %s", cidrAttr, err) 91 | } 92 | 93 | via, err := tsaddr.MapVia(uint32(site), cidr) 94 | if err != nil { 95 | return fmt.Errorf("failed to map 4via6: %s", err) 96 | } 97 | 98 | expected := via.String() 99 | if got := rs.Primary.Attributes["ipv6"]; expected != got { 100 | return fmt.Errorf("expected ipv6 to be %q but got %q", expected, got) 101 | } 102 | 103 | if expected != "fd7a:115c:a1e0:b1a:0:7:a01:100/120" { 104 | return fmt.Errorf("calculated %q, which is different than the value in Tailscale docs", expected) 105 | } 106 | 107 | return nil 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /tailscale/data_source_acl.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/hashicorp/terraform-plugin-sdk/v2/diag" 10 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 11 | 12 | "tailscale.com/client/tailscale/v2" 13 | 14 | "github.com/tailscale/hujson" 15 | ) 16 | 17 | func dataSourceACL() *schema.Resource { 18 | return &schema.Resource{ 19 | Description: "The acl data source gets the Tailscale ACL for a tailnet", 20 | ReadContext: dataSourceACLRead, 21 | Schema: map[string]*schema.Schema{ 22 | "json": { 23 | Computed: true, 24 | Type: schema.TypeString, 25 | Description: "The contents of Tailscale ACL as a JSON string", 26 | }, 27 | "hujson": { 28 | Computed: true, 29 | Type: schema.TypeString, 30 | Description: "The contents of Tailscale ACL as a HuJSON string", 31 | }, 32 | }, 33 | } 34 | } 35 | 36 | func dataSourceACLRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 37 | client := m.(*tailscale.Client) 38 | 39 | acl, err := client.PolicyFile().Raw(ctx) 40 | if err != nil { 41 | return diagnosticsError(err, "Failed to fetch ACL") 42 | } 43 | huj, err := hujson.Parse([]byte(acl.HuJSON)) 44 | if err != nil { 45 | return diagnosticsError(err, "Failed to parse ACL as HuJSON") 46 | } 47 | if err := d.Set("hujson", huj.String()); err != nil { 48 | return diagnosticsError(err, "Failed to set 'hujson'") 49 | } 50 | 51 | huj.Minimize() 52 | if err := d.Set("json", huj.String()); err != nil { 53 | return diagnosticsError(err, "Failed to set 'json'") 54 | } 55 | 56 | d.SetId(createUUID()) 57 | return nil 58 | } 59 | -------------------------------------------------------------------------------- /tailscale/data_source_acl_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "testing" 10 | 11 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" 12 | "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" 13 | 14 | "tailscale.com/client/tailscale/v2" 15 | 16 | "github.com/tailscale/hujson" 17 | ) 18 | 19 | func TestAccTailscaleACL(t *testing.T) { 20 | resourceName := "data.tailscale_acl.acl" 21 | 22 | resource.Test(t, resource.TestCase{ 23 | PreCheck: func() { testAccPreCheck(t) }, 24 | ProviderFactories: testAccProviderFactories(t), 25 | Steps: []resource.TestStep{ 26 | { 27 | Config: `data "tailscale_acl" "acl" {}`, 28 | Check: func(s *terraform.State) error { 29 | client := testAccProvider.Meta().(*tailscale.Client) 30 | acl, err := client.PolicyFile().Raw(context.Background()) 31 | if err != nil { 32 | return fmt.Errorf("unable to get ACL: %s", err) 33 | } 34 | 35 | huj, err := hujson.Parse([]byte(acl.HuJSON)) 36 | if err != nil { 37 | return fmt.Errorf("Failed to parse ACL as HuJSON: %s", err) 38 | } 39 | expected := huj.String() 40 | 41 | rs := s.RootModule().Resources[resourceName].Primary 42 | actual := rs.Attributes["hujson"] 43 | if err := assertEqual(expected, actual, "wrong ACL"); err != nil { 44 | return err 45 | } 46 | 47 | return nil 48 | }, 49 | }, 50 | }, 51 | }) 52 | } 53 | -------------------------------------------------------------------------------- /tailscale/data_source_device.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "time" 10 | 11 | "github.com/hashicorp/go-cty/cty" 12 | "github.com/hashicorp/terraform-plugin-sdk/v2/diag" 13 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 14 | 15 | "tailscale.com/client/tailscale/v2" 16 | ) 17 | 18 | func dataSourceDevice() *schema.Resource { 19 | return &schema.Resource{ 20 | Description: "The device data source describes a single device in a tailnet", 21 | ReadContext: readWithWaitFor(dataSourceDeviceRead), 22 | Schema: map[string]*schema.Schema{ 23 | "name": { 24 | Type: schema.TypeString, 25 | Description: "The full name of the device (e.g. `hostname.domain.ts.net`)", 26 | Optional: true, 27 | ExactlyOneOf: []string{"name", "hostname"}, 28 | }, 29 | "hostname": { 30 | Type: schema.TypeString, 31 | Description: "The short hostname of the device", 32 | Optional: true, 33 | ExactlyOneOf: []string{"name", "hostname"}, 34 | }, 35 | "user": { 36 | Type: schema.TypeString, 37 | Description: "The user associated with the device", 38 | Computed: true, 39 | }, 40 | "node_id": { 41 | Type: schema.TypeString, 42 | Description: "The preferred indentifier for a device.", 43 | Computed: true, 44 | }, 45 | "addresses": { 46 | Type: schema.TypeList, 47 | Description: "The list of device's IPs", 48 | Computed: true, 49 | Elem: &schema.Schema{ 50 | Type: schema.TypeString, 51 | }, 52 | }, 53 | "tags": { 54 | Type: schema.TypeSet, 55 | Description: "The tags applied to the device", 56 | Computed: true, 57 | Elem: &schema.Schema{ 58 | Type: schema.TypeString, 59 | }, 60 | }, 61 | "wait_for": { 62 | Type: schema.TypeString, 63 | Description: "If specified, the provider will make multiple attempts to obtain the data source until the wait_for duration is reached. Retries are made every second so this value should be greater than 1s", 64 | Optional: true, 65 | ValidateDiagFunc: func(i interface{}, path cty.Path) diag.Diagnostics { 66 | waitFor, err := time.ParseDuration(i.(string)) 67 | switch { 68 | case err != nil: 69 | return diagnosticsErrorWithPath(err, "failed to parse wait_for", path) 70 | case waitFor <= time.Second: 71 | return diagnosticsErrorWithPath(nil, "wait_for must be greater than 1 second", path) 72 | default: 73 | return nil 74 | } 75 | }, 76 | }, 77 | }, 78 | } 79 | } 80 | 81 | func dataSourceDeviceRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 82 | client := m.(*tailscale.Client) 83 | 84 | var filter func(d tailscale.Device) bool 85 | var filterDesc string 86 | 87 | if name, ok := d.GetOk("name"); ok { 88 | filter = func(d tailscale.Device) bool { 89 | return d.Name == name.(string) 90 | } 91 | filterDesc = fmt.Sprintf("name=%q", name.(string)) 92 | } 93 | 94 | if hostname, ok := d.GetOk("hostname"); ok { 95 | filter = func(d tailscale.Device) bool { 96 | return d.Hostname == hostname.(string) 97 | } 98 | filterDesc = fmt.Sprintf("hostname=%q", hostname.(string)) 99 | } 100 | 101 | devices, err := client.Devices().List(ctx) 102 | if err != nil { 103 | return diagnosticsError(err, "Failed to fetch devices") 104 | } 105 | 106 | var selected *tailscale.Device 107 | for _, device := range devices { 108 | if filter(device) { 109 | selected = &device 110 | break 111 | } 112 | } 113 | 114 | if selected == nil { 115 | return diag.Errorf("Could not find device with %s", filterDesc) 116 | } 117 | 118 | d.SetId(selected.ID) 119 | return setProperties(d, deviceToMap(selected)) 120 | } 121 | 122 | // deviceToMap converts the given device into a map representing the device as a 123 | // resource in Terraform. This omits the "id" which is expected to be set 124 | // using [schema.ResourceData.SetId]. 125 | func deviceToMap(device *tailscale.Device) map[string]any { 126 | return map[string]any{ 127 | "name": device.Name, 128 | "hostname": device.Hostname, 129 | "user": device.User, 130 | "node_id": device.NodeID, 131 | "addresses": device.Addresses, 132 | "tags": device.Tags, 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /tailscale/data_source_devices.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale 5 | 6 | import ( 7 | "context" 8 | "strings" 9 | 10 | "github.com/hashicorp/terraform-plugin-sdk/v2/diag" 11 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 12 | 13 | "tailscale.com/client/tailscale/v2" 14 | ) 15 | 16 | func dataSourceDevices() *schema.Resource { 17 | return &schema.Resource{ 18 | Description: "The devices data source describes a list of devices in a tailnet", 19 | ReadContext: dataSourceDevicesRead, 20 | Schema: map[string]*schema.Schema{ 21 | "name_prefix": { 22 | Optional: true, 23 | Type: schema.TypeString, 24 | Description: "Filters the device list to elements whose name has the provided prefix", 25 | }, 26 | "devices": { 27 | Computed: true, 28 | Type: schema.TypeList, 29 | Description: "The list of devices in the tailnet", 30 | Elem: &schema.Resource{ 31 | Schema: map[string]*schema.Schema{ 32 | "name": { 33 | Type: schema.TypeString, 34 | Description: "The full name of the device (e.g. `hostname.domain.ts.net`)", 35 | Computed: true, 36 | }, 37 | "hostname": { 38 | Type: schema.TypeString, 39 | Description: "The short hostname of the device", 40 | Computed: true, 41 | }, 42 | "user": { 43 | Type: schema.TypeString, 44 | Description: "The user associated with the device", 45 | Computed: true, 46 | }, 47 | "id": { 48 | Type: schema.TypeString, 49 | Description: "The legacy identifier of the device. Use node_id instead for new resources.", 50 | Computed: true, 51 | }, 52 | "node_id": { 53 | Type: schema.TypeString, 54 | Description: "The preferred indentifier for a device.", 55 | Computed: true, 56 | }, 57 | "addresses": { 58 | Computed: true, 59 | Type: schema.TypeList, 60 | Description: "The list of device's IPs", 61 | Elem: &schema.Schema{ 62 | Type: schema.TypeString, 63 | }, 64 | }, 65 | "tags": { 66 | Type: schema.TypeSet, 67 | Description: "The tags applied to the device", 68 | Computed: true, 69 | Elem: &schema.Schema{ 70 | Type: schema.TypeString, 71 | }, 72 | }, 73 | }, 74 | }, 75 | }, 76 | }, 77 | } 78 | } 79 | 80 | func dataSourceDevicesRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 81 | client := m.(*tailscale.Client) 82 | 83 | devices, err := client.Devices().List(ctx) 84 | if err != nil { 85 | return diagnosticsError(err, "Failed to fetch devices") 86 | } 87 | 88 | namePrefix, _ := d.Get("name_prefix").(string) 89 | deviceMaps := make([]map[string]interface{}, 0) 90 | for _, device := range devices { 91 | if namePrefix != "" && !strings.HasPrefix(device.Name, namePrefix) { 92 | continue 93 | } 94 | 95 | m := deviceToMap(&device) 96 | m["id"] = device.ID 97 | deviceMaps = append(deviceMaps, m) 98 | } 99 | 100 | if err = d.Set("devices", deviceMaps); err != nil { 101 | return diag.FromErr(err) 102 | } 103 | 104 | d.SetId(createUUID()) 105 | return nil 106 | } 107 | -------------------------------------------------------------------------------- /tailscale/data_source_user.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale 5 | 6 | import ( 7 | "context" 8 | "time" 9 | 10 | "github.com/hashicorp/terraform-plugin-sdk/v2/diag" 11 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 12 | 13 | "tailscale.com/client/tailscale/v2" 14 | ) 15 | 16 | var commonUserSchema = map[string]*schema.Schema{ 17 | "display_name": { 18 | Type: schema.TypeString, 19 | Description: "The name of the user.", 20 | Computed: true, 21 | }, 22 | "profile_pic_url": { 23 | Type: schema.TypeString, 24 | Description: "The profile pic URL for the user.", 25 | Computed: true, 26 | }, 27 | "tailnet_id": { 28 | Type: schema.TypeString, 29 | Description: "The tailnet that owns the user.", 30 | Computed: true, 31 | }, 32 | "created": { 33 | Type: schema.TypeString, 34 | Description: "The time the user joined their tailnet.", 35 | Computed: true, 36 | }, 37 | "type": { 38 | Type: schema.TypeString, 39 | Description: "The type of relation this user has to the tailnet associated with the request.", 40 | Computed: true, 41 | }, 42 | "role": { 43 | Type: schema.TypeString, 44 | Description: "The role of the user.", 45 | Computed: true, 46 | }, 47 | "status": { 48 | Type: schema.TypeString, 49 | Description: "The status of the user.", 50 | Computed: true, 51 | }, 52 | "device_count": { 53 | Type: schema.TypeInt, 54 | Description: "Number of devices the user owns.", 55 | Computed: true, 56 | }, 57 | "last_seen": { 58 | Type: schema.TypeString, 59 | Description: "The later of either: a) The last time any of the user's nodes were connected to the network or b) The last time the user authenticated to any tailscale service, including the admin panel.", 60 | Computed: true, 61 | }, 62 | "currently_connected": { 63 | Type: schema.TypeBool, 64 | Description: "true when the user has a node currently connected to the control server.", 65 | Computed: true, 66 | }, 67 | } 68 | 69 | func dataSourceUser() *schema.Resource { 70 | return &schema.Resource{ 71 | Description: "The user data source describes a single user in a tailnet", 72 | ReadContext: dataSourceUserRead, 73 | Schema: combinedSchemas(commonUserSchema, map[string]*schema.Schema{ 74 | "id": { 75 | Type: schema.TypeString, 76 | Description: "The unique identifier for the user.", 77 | Optional: true, 78 | ExactlyOneOf: []string{"id", "login_name"}, 79 | }, 80 | "login_name": { 81 | Type: schema.TypeString, 82 | Description: "The emailish login name of the user.", 83 | Optional: true, 84 | ExactlyOneOf: []string{"id", "login_name"}, 85 | }, 86 | }), 87 | } 88 | } 89 | 90 | func dataSourceUserRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 91 | client := m.(*tailscale.Client) 92 | 93 | if id := d.Id(); id != "" { 94 | user, err := client.Users().Get(ctx, id) 95 | if err != nil { 96 | return diagnosticsError(err, "Failed to fetch user with id %s", id) 97 | } 98 | return setProperties(d, userToMap(user)) 99 | } 100 | 101 | loginName, ok := d.GetOk("login_name") 102 | if !ok { 103 | return diag.Errorf("please specify an id or login_name for the user") 104 | } 105 | 106 | users, err := client.Users().List(ctx, nil, nil) 107 | if err != nil { 108 | return diagnosticsError(err, "Failed to fetch users") 109 | } 110 | 111 | var selected *tailscale.User 112 | for _, user := range users { 113 | if user.LoginName == loginName.(string) { 114 | selected = &user 115 | break 116 | } 117 | } 118 | 119 | if selected == nil { 120 | return diag.Errorf("Could not find user with login name %s", loginName) 121 | } 122 | 123 | d.SetId(selected.ID) 124 | return setProperties(d, userToMap(selected)) 125 | } 126 | 127 | // userToMap converts the given user into a map representing the user as a 128 | // resource in Terraform. This omits the "id" which is expected to be set 129 | // using [schema.ResourceData.SetId]. 130 | func userToMap(user *tailscale.User) map[string]any { 131 | return map[string]any{ 132 | "id": user.ID, 133 | "display_name": user.DisplayName, 134 | "login_name": user.LoginName, 135 | "profile_pic_url": user.ProfilePicURL, 136 | "tailnet_id": user.TailnetID, 137 | "created": user.Created.Format(time.RFC3339), 138 | "type": user.Type, 139 | "role": user.Role, 140 | "status": user.Status, 141 | "device_count": user.DeviceCount, 142 | "last_seen": user.LastSeen.Format(time.RFC3339), 143 | "currently_connected": user.CurrentlyConnected, 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /tailscale/data_source_users.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/hashicorp/terraform-plugin-sdk/v2/diag" 10 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 11 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" 12 | 13 | "tailscale.com/client/tailscale/v2" 14 | ) 15 | 16 | func dataSourceUsers() *schema.Resource { 17 | return &schema.Resource{ 18 | Description: "The users data source describes a list of users in a tailnet", 19 | ReadContext: dataSourceUsersRead, 20 | Schema: map[string]*schema.Schema{ 21 | "type": { 22 | Optional: true, 23 | Type: schema.TypeString, 24 | Description: "Filters the users list to elements whose type is the provided value.", 25 | ValidateFunc: validation.StringInSlice( 26 | []string{ 27 | string(tailscale.UserTypeMember), 28 | string(tailscale.UserTypeShared), 29 | }, 30 | false, 31 | ), 32 | }, 33 | "role": { 34 | Optional: true, 35 | Type: schema.TypeString, 36 | Description: "Filters the users list to elements whose role is the provided value.", 37 | ValidateFunc: validation.StringInSlice( 38 | []string{ 39 | string(tailscale.UserRoleOwner), 40 | string(tailscale.UserRoleMember), 41 | string(tailscale.UserRoleAdmin), 42 | string(tailscale.UserRoleITAdmin), 43 | string(tailscale.UserRoleNetworkAdmin), 44 | string(tailscale.UserRoleBillingAdmin), 45 | string(tailscale.UserRoleAuditor), 46 | }, 47 | false, 48 | ), 49 | }, 50 | "users": { 51 | Computed: true, 52 | Type: schema.TypeList, 53 | Description: "The list of users in the tailnet", 54 | Elem: &schema.Resource{ 55 | Schema: combinedSchemas(commonUserSchema, map[string]*schema.Schema{ 56 | "id": { 57 | Type: schema.TypeString, 58 | Description: "The unique identifier for the user.", 59 | Computed: true, 60 | }, 61 | "login_name": { 62 | Type: schema.TypeString, 63 | Description: "The emailish login name of the user.", 64 | Computed: true, 65 | }, 66 | }), 67 | }, 68 | }, 69 | }, 70 | } 71 | } 72 | 73 | func dataSourceUsersRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 74 | client := m.(*tailscale.Client) 75 | 76 | var userType *tailscale.UserType 77 | if _userType, ok := d.Get("type").(string); ok { 78 | userType = tailscale.PointerTo(tailscale.UserType(_userType)) 79 | } 80 | 81 | var userRole *tailscale.UserRole 82 | if _userRole, ok := d.Get("role").(string); ok { 83 | userRole = tailscale.PointerTo(tailscale.UserRole(_userRole)) 84 | } 85 | 86 | users, err := client.Users().List(ctx, userType, userRole) 87 | if err != nil { 88 | return diagnosticsError(err, "Failed to fetch users") 89 | } 90 | 91 | userMaps := make([]map[string]interface{}, 0, len(users)) 92 | for _, user := range users { 93 | m := userToMap(&user) 94 | userMaps = append(userMaps, m) 95 | } 96 | 97 | if err = d.Set("users", userMaps); err != nil { 98 | return diag.FromErr(err) 99 | } 100 | 101 | d.SetId(createUUID()) 102 | return nil 103 | } 104 | -------------------------------------------------------------------------------- /tailscale/data_source_users_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" 13 | "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" 14 | 15 | "tailscale.com/client/tailscale/v2" 16 | ) 17 | 18 | func TestAccTailscaleUsers(t *testing.T) { 19 | resourceName := "data.tailscale_users.all_users" 20 | 21 | // This is a string containing tailscale_user datasource configurations 22 | userDataSources := &strings.Builder{} 23 | 24 | // First test the tailscale_users datasource, which will give us a list of 25 | // all user IDs. 26 | resource.Test(t, resource.TestCase{ 27 | PreCheck: func() { testAccPreCheck(t) }, 28 | ProviderFactories: testAccProviderFactories(t), 29 | Steps: []resource.TestStep{ 30 | { 31 | Config: `data "tailscale_users" "all_users" {}`, 32 | Check: func(s *terraform.State) error { 33 | client := testAccProvider.Meta().(*tailscale.Client) 34 | users, err := client.Users().List(context.Background(), nil, nil) 35 | if err != nil { 36 | return fmt.Errorf("unable to list users: %s", err) 37 | } 38 | 39 | usersByLoginName := make(map[string]map[string]any) 40 | for _, user := range users { 41 | m := userToMap(&user) 42 | usersByLoginName[user.LoginName] = m 43 | } 44 | 45 | rs := s.RootModule().Resources[resourceName].Primary 46 | 47 | // first find indexes for users 48 | userIndexes := make(map[string]string) 49 | for k, v := range rs.Attributes { 50 | if strings.HasSuffix(k, ".login_name") { 51 | idx := strings.Split(k, ".")[1] 52 | userIndexes[idx] = v 53 | } 54 | } 55 | 56 | // make sure we got the right number of users 57 | if len(userIndexes) != len(usersByLoginName) { 58 | return fmt.Errorf("wrong number of users in datasource, want %d, got %d", len(usersByLoginName), len(userIndexes)) 59 | } 60 | 61 | // now compare datasource attributes to expected values 62 | for k, v := range rs.Attributes { 63 | if strings.HasPrefix(k, "users.") { 64 | parts := strings.Split(k, ".") 65 | if len(parts) != 3 { 66 | continue 67 | } 68 | prop := parts[2] 69 | if prop == "%" { 70 | continue 71 | } 72 | idx := parts[1] 73 | loginName := userIndexes[idx] 74 | expected := fmt.Sprint(usersByLoginName[loginName][prop]) 75 | if v != expected { 76 | return fmt.Errorf("wrong value of %s for user %s, want %q, got %q", prop, loginName, expected, v) 77 | } 78 | } 79 | } 80 | 81 | // Now set up user datasources for each user. This is used in the following test 82 | // of the tailscale_user datasource. 83 | for loginName, user := range usersByLoginName { 84 | userDataSources.WriteString(fmt.Sprintf("\ndata \"tailscale_user\" \"%s\" {\n login_name = \"%s\"\n}\n", user["id"], loginName)) 85 | } 86 | 87 | return nil 88 | }, 89 | }, 90 | }, 91 | }) 92 | 93 | // Now test the individual tailscale_user data sources for each user, 94 | // making sure that it pulls in the relevant details for each user. 95 | resource.Test(t, resource.TestCase{ 96 | PreCheck: func() { testAccPreCheck(t) }, 97 | ProviderFactories: testAccProviderFactories(t), 98 | Steps: []resource.TestStep{ 99 | { 100 | Config: userDataSources.String(), 101 | Check: func(s *terraform.State) error { 102 | client := testAccProvider.Meta().(*tailscale.Client) 103 | users, err := client.Users().List(context.Background(), nil, nil) 104 | if err != nil { 105 | return fmt.Errorf("unable to list users: %s", err) 106 | } 107 | 108 | for _, user := range users { 109 | expected := userToMap(&user) 110 | expected["id"] = user.ID 111 | resourceName := fmt.Sprintf("data.tailscale_user.%s", user.ID) 112 | if err := checkPropertiesMatch(resourceName, s, expected); err != nil { 113 | return err 114 | } 115 | } 116 | 117 | return nil 118 | }, 119 | }, 120 | }, 121 | }) 122 | } 123 | -------------------------------------------------------------------------------- /tailscale/datasource_devices_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" 13 | "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" 14 | 15 | "tailscale.com/client/tailscale/v2" 16 | ) 17 | 18 | func TestAccTailscaleDevices(t *testing.T) { 19 | resourceName := "data.tailscale_devices.all_devices" 20 | 21 | // This is a string containing tailscale_device datasource configurations 22 | devicesDataSources := &strings.Builder{} 23 | 24 | toResourceComponent := func(str string) string { 25 | return strings.ReplaceAll(str, " ", "_") 26 | } 27 | 28 | // First test the tailscale_devices datasource, which will give us a list of 29 | // all device IDs. 30 | resource.Test(t, resource.TestCase{ 31 | PreCheck: func() { testAccPreCheck(t) }, 32 | ProviderFactories: testAccProviderFactories(t), 33 | Steps: []resource.TestStep{ 34 | { 35 | Config: `data "tailscale_devices" "all_devices" {}`, 36 | Check: func(s *terraform.State) error { 37 | client := testAccProvider.Meta().(*tailscale.Client) 38 | devices, err := client.Devices().List(context.Background()) 39 | if err != nil { 40 | return fmt.Errorf("unable to list devices: %s", err) 41 | } 42 | 43 | devicesByID := make(map[string]map[string]any) 44 | for _, device := range devices { 45 | m := deviceToMap(&device) 46 | m["id"] = device.ID 47 | devicesByID[device.ID] = m 48 | } 49 | 50 | rs := s.RootModule().Resources[resourceName].Primary 51 | 52 | // first find indexes for devices 53 | deviceIndexes := make(map[string]string) 54 | for k, v := range rs.Attributes { 55 | if strings.HasSuffix(k, ".id") { 56 | idx := strings.Split(k, ".")[1] 57 | deviceIndexes[idx] = v 58 | } 59 | } 60 | 61 | // make sure we got the right number of devices 62 | if len(deviceIndexes) != len(devicesByID) { 63 | return fmt.Errorf("wrong number of devices in datasource, want %d, got %d", len(devicesByID), len(deviceIndexes)) 64 | } 65 | 66 | // now compare datasource attributes to expected values 67 | for k, v := range rs.Attributes { 68 | if strings.HasPrefix(k, "devices.") { 69 | parts := strings.Split(k, ".") 70 | if len(parts) != 3 { 71 | continue 72 | } 73 | prop := parts[2] 74 | if prop == "%" { 75 | continue 76 | } 77 | idx := parts[1] 78 | id := deviceIndexes[idx] 79 | expected := fmt.Sprint(devicesByID[id][prop]) 80 | if v != expected { 81 | return fmt.Errorf("wrong value of %s for device %s, want %q, got %q", prop, id, expected, v) 82 | } 83 | } 84 | } 85 | 86 | // Now set up device datasources for each device. This is used in the following test 87 | // of the tailscale_device datasource. 88 | for _, device := range devices { 89 | if device.Hostname != "" { 90 | devicesDataSources.WriteString(fmt.Sprintf("\ndata \"tailscale_device\" \"%s\" {\n hostname = \"%s\"\n}\n", toResourceComponent(device.Hostname), device.Hostname)) 91 | } else { 92 | devicesDataSources.WriteString(fmt.Sprintf("\ndata \"tailscale_device\" \"%s\" {\n name = \"%s\"\n}\n", toResourceComponent(device.Name), device.Name)) 93 | } 94 | } 95 | 96 | return nil 97 | }, 98 | }, 99 | }, 100 | }) 101 | 102 | // Now test the individual tailscale_device data sources for each device, 103 | // making sure that it pulls in the relevant details for each device. 104 | resource.Test(t, resource.TestCase{ 105 | PreCheck: func() { testAccPreCheck(t) }, 106 | ProviderFactories: testAccProviderFactories(t), 107 | Steps: []resource.TestStep{ 108 | { 109 | Config: devicesDataSources.String(), 110 | Check: func(s *terraform.State) error { 111 | client := testAccProvider.Meta().(*tailscale.Client) 112 | devices, err := client.Devices().List(context.Background()) 113 | if err != nil { 114 | return fmt.Errorf("unable to list devices: %s", err) 115 | } 116 | 117 | for _, device := range devices { 118 | expected := deviceToMap(&device) 119 | expected["id"] = device.ID 120 | var nameComponent string 121 | if device.Hostname != "" { 122 | nameComponent = device.Hostname 123 | } else { 124 | nameComponent = device.Name 125 | } 126 | resourceName := fmt.Sprintf("data.tailscale_device.%s", toResourceComponent(nameComponent)) 127 | if err := checkPropertiesMatch(resourceName, s, expected); err != nil { 128 | return err 129 | } 130 | } 131 | 132 | return nil 133 | }, 134 | }, 135 | }, 136 | }) 137 | } 138 | -------------------------------------------------------------------------------- /tailscale/resource_aws_external_id.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/hashicorp/terraform-plugin-sdk/v2/diag" 10 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 11 | 12 | "tailscale.com/client/tailscale/v2" 13 | ) 14 | 15 | func resourceAWSExternalID() *schema.Resource { 16 | return &schema.Resource{ 17 | Description: "The aws_external_id resource allows you to mint an AWS External ID that Tailscale can use to assume an AWS IAM role that you create for the purposes of allowing Tailscale to stream logs to your S3 bucket. See the logstream_configuration resource for more details.", 18 | CreateContext: resourceAWSExternalIDCreate, 19 | 20 | // No GET or DELETE endpoints in the API. This is a create-only resource. 21 | ReadContext: schema.NoopContext, 22 | DeleteContext: schema.NoopContext, 23 | 24 | Schema: map[string]*schema.Schema{ 25 | "external_id": { 26 | Type: schema.TypeString, 27 | Computed: true, 28 | Description: "The External ID that Tailscale will supply when assuming your role. You must reference this in your IAM role's trust policy. See https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_common-scenarios_third-party.html for more information on external IDs.", 29 | }, 30 | "tailscale_aws_account_id": { 31 | Type: schema.TypeString, 32 | Computed: true, 33 | Description: "The AWS account from which Tailscale will assume your role. You must reference this in your IAM role's trust policy. See https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_common-scenarios_third-party.html for more information on external IDs.", 34 | }, 35 | }, 36 | } 37 | } 38 | 39 | func resourceAWSExternalIDCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 40 | client := m.(*tailscale.Client) 41 | 42 | // We pass "reusable: false" on purpose. Otherwise, two tailscale_aws_external_id resources 43 | // could end up with the same resource ID (because we use the actual external ID). 44 | // 45 | // Also, "reusable: true" is an optimization intended for the admin console UI's usage 46 | // pattern, and it's not really necessary for Terraform use cases. 47 | aid, err := client.Logging().CreateOrGetAwsExternalId(ctx, false) 48 | if err != nil { 49 | return diagnosticsError(err, "Failed to create AWS External ID") 50 | } 51 | 52 | d.SetId(aid.ExternalID) 53 | if err = d.Set("external_id", aid.ExternalID); err != nil { 54 | return diagnosticsError(err, "Failed to set externalId") 55 | } 56 | if err = d.Set("tailscale_aws_account_id", aid.TailscaleAWSAccountID); err != nil { 57 | return diagnosticsError(err, "Failed to set AWSAccountID") 58 | } 59 | 60 | return nil 61 | } 62 | -------------------------------------------------------------------------------- /tailscale/resource_aws_external_id_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" 10 | ) 11 | 12 | const testAWSExternalID = ` 13 | resource "tailscale_aws_external_id" "test" {} 14 | ` 15 | 16 | func TestAccTailscaleAWSExternalID(t *testing.T) { 17 | const resourceName = "tailscale_aws_external_id.test" 18 | 19 | resource.Test(t, resource.TestCase{ 20 | PreCheck: func() { testAccPreCheck(t) }, 21 | ProviderFactories: testAccProviderFactories(t), 22 | Steps: []resource.TestStep{ 23 | { 24 | Config: testAWSExternalID, 25 | Check: resource.ComposeTestCheckFunc( 26 | resource.TestCheckResourceAttrSet(resourceName, "external_id"), 27 | resource.TestCheckResourceAttrSet(resourceName, "tailscale_aws_account_id"), 28 | ), 29 | }, 30 | }, 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /tailscale/resource_contacts_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "testing" 10 | 11 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" 12 | "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" 13 | 14 | "tailscale.com/client/tailscale/v2" 15 | ) 16 | 17 | func TestAccTailscaleContacts(t *testing.T) { 18 | const resourceName = "tailscale_contacts.test_contacts" 19 | 20 | const testContactsBasic = ` 21 | resource "tailscale_contacts" "test_contacts" { 22 | account { 23 | email = "account@example.com" 24 | } 25 | 26 | support { 27 | email = "support@example.com" 28 | } 29 | 30 | security { 31 | email = "security@example.com" 32 | } 33 | }` 34 | 35 | const testContactsUpdated = ` 36 | resource "tailscale_contacts" "test_contacts" { 37 | account { 38 | email = "otheraccount@example.com" 39 | } 40 | 41 | support { 42 | email = "support@example.com" 43 | } 44 | 45 | security { 46 | email = "security2@example.com" 47 | } 48 | }` 49 | 50 | expectedContactsBasic := &tailscale.Contacts{ 51 | Account: tailscale.Contact{ 52 | Email: "account@example.com", 53 | }, 54 | Support: tailscale.Contact{ 55 | Email: "support@example.com", 56 | }, 57 | Security: tailscale.Contact{ 58 | Email: "security@example.com", 59 | }, 60 | } 61 | 62 | expectedContactsUpdated := &tailscale.Contacts{ 63 | Account: tailscale.Contact{ 64 | Email: "otheraccount@example.com", 65 | }, 66 | Support: tailscale.Contact{ 67 | Email: "support@example.com", 68 | }, 69 | Security: tailscale.Contact{ 70 | Email: "security2@example.com", 71 | }, 72 | } 73 | 74 | checkProperties := func(expectedContacts *tailscale.Contacts) func(client *tailscale.Client, rs *terraform.ResourceState) error { 75 | return func(client *tailscale.Client, rs *terraform.ResourceState) error { 76 | contacts, err := client.Contacts().Get(context.Background()) 77 | if err != nil { 78 | return err 79 | } 80 | 81 | if contacts.Account.Email != expectedContacts.Account.Email { 82 | return fmt.Errorf("bad account email, expected %q, got %q", expectedContacts.Account.Email, contacts.Account.Email) 83 | } 84 | 85 | if contacts.Support.Email != expectedContacts.Support.Email { 86 | return fmt.Errorf("bad support email, expected %q, got %q", expectedContacts.Support.Email, contacts.Support.Email) 87 | } 88 | 89 | if contacts.Security.Email != expectedContacts.Security.Email { 90 | return fmt.Errorf("bad security email, expected %q, got %q", expectedContacts.Security.Email, contacts.Security.Email) 91 | } 92 | 93 | return nil 94 | } 95 | } 96 | 97 | resource.Test(t, resource.TestCase{ 98 | PreCheck: func() { testAccPreCheck(t) }, 99 | ProviderFactories: testAccProviderFactories(t), 100 | // Contacts are not destroyed in the control plane upon resource deletion since 101 | // contacts cannot be empty, so make sure that contacts are still the updated contacts. 102 | CheckDestroy: checkResourceDestroyed(resourceName, checkProperties(expectedContactsUpdated)), 103 | Steps: []resource.TestStep{ 104 | { 105 | Config: testContactsBasic, 106 | Check: resource.ComposeTestCheckFunc( 107 | checkResourceRemoteProperties(resourceName, checkProperties(expectedContactsBasic)), 108 | resource.TestCheckResourceAttr(resourceName, "account.0.email", "account@example.com"), 109 | resource.TestCheckResourceAttr(resourceName, "support.0.email", "support@example.com"), 110 | resource.TestCheckResourceAttr(resourceName, "security.0.email", "security@example.com"), 111 | ), 112 | }, 113 | { 114 | Config: testContactsUpdated, 115 | Check: resource.ComposeTestCheckFunc( 116 | checkResourceRemoteProperties(resourceName, checkProperties(expectedContactsUpdated)), 117 | resource.TestCheckResourceAttr(resourceName, "account.0.email", "otheraccount@example.com"), 118 | resource.TestCheckResourceAttr(resourceName, "support.0.email", "support@example.com"), 119 | resource.TestCheckResourceAttr(resourceName, "security.0.email", "security2@example.com"), 120 | ), 121 | }, 122 | { 123 | ResourceName: resourceName, 124 | ImportState: true, 125 | ImportStateVerify: true, 126 | }, 127 | }, 128 | }) 129 | } 130 | -------------------------------------------------------------------------------- /tailscale/resource_device_authorization.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/hashicorp/terraform-plugin-sdk/v2/diag" 10 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 11 | 12 | "tailscale.com/client/tailscale/v2" 13 | ) 14 | 15 | func resourceDeviceAuthorization() *schema.Resource { 16 | return &schema.Resource{ 17 | Description: "The device_authorization resource is used to approve new devices before they can join the tailnet. See https://tailscale.com/kb/1099/device-authorization/ for more details.", 18 | ReadContext: resourceDeviceAuthorizationRead, 19 | CreateContext: resourceDeviceAuthorizationCreate, 20 | UpdateContext: resourceDeviceAuthorizationUpdate, 21 | DeleteContext: resourceDeviceAuthorizationDelete, 22 | Importer: &schema.ResourceImporter{ 23 | StateContext: schema.ImportStatePassthroughContext, 24 | }, 25 | Schema: map[string]*schema.Schema{ 26 | "device_id": { 27 | Type: schema.TypeString, 28 | Required: true, 29 | Description: "The device to set as authorized", 30 | }, 31 | "authorized": { 32 | Type: schema.TypeBool, 33 | Required: true, 34 | Description: "Whether or not the device is authorized", 35 | }, 36 | }, 37 | } 38 | } 39 | 40 | func resourceDeviceAuthorizationRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 41 | client := m.(*tailscale.Client) 42 | deviceID := d.Id() 43 | 44 | device, err := client.Devices().Get(ctx, deviceID) 45 | if err != nil { 46 | return diagnosticsError(err, "Failed to fetch device") 47 | } 48 | 49 | // If the device lookup succeeds and the state ID is not the same as the legacy ID, we can assume the ID is the node ID. 50 | canonicalDeviceID := device.ID 51 | if device.ID != deviceID { 52 | canonicalDeviceID = device.NodeID 53 | } 54 | d.SetId(canonicalDeviceID) 55 | if err = d.Set("device_id", canonicalDeviceID); err != nil { 56 | return diagnosticsError(err, "failed to set device_id") 57 | } 58 | 59 | d.Set("authorized", device.Authorized) 60 | return nil 61 | } 62 | 63 | func resourceDeviceAuthorizationCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 64 | client := m.(*tailscale.Client) 65 | deviceID := d.Get("device_id").(string) 66 | authorized := d.Get("authorized").(bool) 67 | 68 | if authorized { 69 | if err := client.Devices().SetAuthorized(ctx, deviceID, true); err != nil { 70 | return diagnosticsError(err, "Failed to authorize device") 71 | } 72 | } 73 | 74 | d.SetId(deviceID) 75 | return resourceDeviceAuthorizationRead(ctx, d, m) 76 | } 77 | 78 | func resourceDeviceAuthorizationUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 79 | client := m.(*tailscale.Client) 80 | deviceID := d.Get("device_id").(string) 81 | 82 | device, err := client.Devices().Get(ctx, deviceID) 83 | if err != nil { 84 | return diagnosticsError(err, "Failed to fetch device") 85 | } 86 | 87 | // Currently, the Tailscale API only supports authorizing a device, but not un-authorizing one. So if the device 88 | // data from the API states it is authorized then we can't do anything else here. 89 | if device.Authorized { 90 | d.Set("authorized", true) 91 | return nil 92 | } 93 | 94 | if err = client.Devices().SetAuthorized(ctx, deviceID, true); err != nil { 95 | return diagnosticsError(err, "Failed to authorize device") 96 | } 97 | 98 | d.Set("authorized", true) 99 | return resourceDeviceAuthorizationRead(ctx, d, m) 100 | } 101 | 102 | func resourceDeviceAuthorizationDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 103 | // Since authorization cannot be removed at this point, deleting the resource will do nothing. 104 | return nil 105 | } 106 | -------------------------------------------------------------------------------- /tailscale/resource_device_authorization_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "os" 10 | "testing" 11 | 12 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" 13 | "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" 14 | 15 | "tailscale.com/client/tailscale/v2" 16 | ) 17 | 18 | func TestAccTailscaleDeviceAuthorization(t *testing.T) { 19 | const resourceName = "tailscale_device_authorization.test_authorization" 20 | 21 | const testDeviceAuthorization = ` 22 | data "tailscale_device" "test_device" { 23 | name = "%s" 24 | } 25 | 26 | resource "tailscale_device_authorization" "test_authorization" { 27 | device_id = data.tailscale_device.test_device.id 28 | authorized = true 29 | }` 30 | 31 | checkAuthorized := func(client *tailscale.Client, rs *terraform.ResourceState) error { 32 | // Check that the device both exists and is still authorized. 33 | device, err := client.Devices().Get(context.Background(), rs.Primary.ID) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | if device.Authorized != true { 39 | return fmt.Errorf("device with id %q is not authorized", rs.Primary.ID) 40 | } 41 | 42 | return nil 43 | } 44 | 45 | checkLegacyID := func(client *tailscale.Client, rs *terraform.ResourceState) error { 46 | // Check that the device ID and State ID Match 47 | device, err := client.Devices().Get(context.Background(), rs.Primary.ID) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | if device.ID != rs.Primary.ID { 53 | return fmt.Errorf("state id %q does not match legacy id %q", rs.Primary.ID, device.ID) 54 | } 55 | 56 | return nil 57 | } 58 | 59 | resource.Test(t, resource.TestCase{ 60 | PreCheck: func() { testAccPreCheck(t) }, 61 | ProviderFactories: testAccProviderFactories(t), 62 | // Devices are not currently deauthorized when this resource is deleted, 63 | // expect that the device both exists and is still authorized. 64 | CheckDestroy: checkResourceDestroyed(resourceName, checkAuthorized), 65 | Steps: []resource.TestStep{ 66 | { 67 | Config: fmt.Sprintf(testDeviceAuthorization, os.Getenv("TAILSCALE_TEST_DEVICE_NAME")), 68 | Check: resource.ComposeTestCheckFunc( 69 | checkResourceRemoteProperties(resourceName, checkLegacyID), 70 | checkResourceRemoteProperties(resourceName, checkAuthorized), 71 | resource.TestCheckResourceAttr(resourceName, "authorized", "true"), 72 | ), 73 | }, 74 | { 75 | ResourceName: resourceName, 76 | ImportState: true, 77 | ImportStateVerify: true, 78 | }, 79 | }, 80 | }) 81 | } 82 | 83 | func TestAccTailscaleDeviceAuthorization_UsesNodeID(t *testing.T) { 84 | const resourceName = "tailscale_device_authorization.test_authorization" 85 | 86 | const testDeviceAuthorization = ` 87 | data "tailscale_device" "test_device" { 88 | name = "%s" 89 | } 90 | 91 | resource "tailscale_device_authorization" "test_authorization" { 92 | device_id = data.tailscale_device.test_device.node_id 93 | authorized = true 94 | }` 95 | 96 | checkAuthorized := func(client *tailscale.Client, rs *terraform.ResourceState) error { 97 | // Check that the device both exists and is still authorized. 98 | device, err := client.Devices().Get(context.Background(), rs.Primary.ID) 99 | if err != nil { 100 | return err 101 | } 102 | 103 | if device.Authorized != true { 104 | return fmt.Errorf("device with id %q is not authorized", rs.Primary.ID) 105 | } 106 | 107 | return nil 108 | } 109 | 110 | checkNodeID := func(client *tailscale.Client, rs *terraform.ResourceState) error { 111 | // Check that the device ID and State ID Match 112 | device, err := client.Devices().Get(context.Background(), rs.Primary.ID) 113 | if err != nil { 114 | return err 115 | } 116 | 117 | if device.NodeID != rs.Primary.ID { 118 | return fmt.Errorf("state id %q does not match node id %q", rs.Primary.ID, device.NodeID) 119 | } 120 | 121 | return nil 122 | } 123 | 124 | resource.Test(t, resource.TestCase{ 125 | PreCheck: func() { testAccPreCheck(t) }, 126 | ProviderFactories: testAccProviderFactories(t), 127 | // Devices are not currently deauthorized when this resource is deleted, 128 | // expect that the device both exists and is still authorized. 129 | CheckDestroy: checkResourceDestroyed(resourceName, checkAuthorized), 130 | Steps: []resource.TestStep{ 131 | { 132 | Config: fmt.Sprintf(testDeviceAuthorization, os.Getenv("TAILSCALE_TEST_DEVICE_NAME")), 133 | Check: resource.ComposeTestCheckFunc( 134 | checkResourceRemoteProperties(resourceName, checkAuthorized), 135 | checkResourceRemoteProperties(resourceName, checkNodeID), 136 | resource.TestCheckResourceAttr(resourceName, "authorized", "true"), 137 | ), 138 | }, 139 | { 140 | ResourceName: resourceName, 141 | ImportState: true, 142 | ImportStateVerify: true, 143 | }, 144 | }, 145 | }) 146 | } 147 | -------------------------------------------------------------------------------- /tailscale/resource_device_key.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/hashicorp/terraform-plugin-sdk/v2/diag" 10 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 11 | 12 | "tailscale.com/client/tailscale/v2" 13 | ) 14 | 15 | func resourceDeviceKey() *schema.Resource { 16 | return &schema.Resource{ 17 | Description: "The device_key resource allows you to update the properties of a device's key", 18 | ReadContext: resourceDeviceKeyRead, 19 | CreateContext: resourceDeviceKeyCreate, 20 | DeleteContext: resourceDeviceKeyDelete, 21 | UpdateContext: resourceDeviceKeyUpdate, 22 | Importer: &schema.ResourceImporter{ 23 | StateContext: schema.ImportStatePassthroughContext, 24 | }, 25 | Schema: map[string]*schema.Schema{ 26 | "device_id": { 27 | Type: schema.TypeString, 28 | Required: true, 29 | Description: "The device to update the key properties of", 30 | }, 31 | "key_expiry_disabled": { 32 | Type: schema.TypeBool, 33 | Optional: true, 34 | Description: "Determines whether or not the device's key will expire. Defaults to `false`.", 35 | }, 36 | }, 37 | } 38 | } 39 | 40 | func resourceDeviceKeyCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 41 | client := m.(*tailscale.Client) 42 | 43 | deviceID := d.Get("device_id").(string) 44 | keyExpiryDisabled := d.Get("key_expiry_disabled").(bool) 45 | 46 | key := tailscale.DeviceKey{ 47 | KeyExpiryDisabled: keyExpiryDisabled, 48 | } 49 | 50 | if err := client.Devices().SetKey(ctx, deviceID, key); err != nil { 51 | return diagnosticsError(err, "failed to update device key") 52 | } 53 | 54 | d.SetId(deviceID) 55 | return resourceDeviceKeyRead(ctx, d, m) 56 | } 57 | 58 | func resourceDeviceKeyDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 59 | client := m.(*tailscale.Client) 60 | 61 | deviceID := d.Get("device_id").(string) 62 | key := tailscale.DeviceKey{} 63 | 64 | if err := client.Devices().SetKey(ctx, deviceID, key); err != nil { 65 | return diagnosticsError(err, "failed to update device key") 66 | } 67 | 68 | return nil 69 | } 70 | 71 | func resourceDeviceKeyRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 72 | client := m.(*tailscale.Client) 73 | deviceID := d.Id() 74 | 75 | device, err := client.Devices().Get(ctx, deviceID) 76 | if err != nil { 77 | return diagnosticsError(err, "Failed to fetch devices") 78 | } 79 | 80 | // If the device lookup succeeds and the state ID is not the same as the legacy ID, we can assume the ID is the node ID. 81 | canonicalDeviceID := device.ID 82 | if device.ID != deviceID { 83 | canonicalDeviceID = device.NodeID 84 | } 85 | 86 | if err = d.Set("device_id", canonicalDeviceID); err != nil { 87 | return diagnosticsError(err, "failed to set device_id") 88 | } 89 | if err = d.Set("key_expiry_disabled", device.KeyExpiryDisabled); err != nil { 90 | return diagnosticsError(err, "failed to set key_expiry_disabled field") 91 | } 92 | 93 | return nil 94 | } 95 | 96 | func resourceDeviceKeyUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 97 | client := m.(*tailscale.Client) 98 | 99 | deviceID := d.Get("device_id").(string) 100 | keyExpiryDisabled := d.Get("key_expiry_disabled").(bool) 101 | 102 | key := tailscale.DeviceKey{ 103 | KeyExpiryDisabled: keyExpiryDisabled, 104 | } 105 | 106 | if err := client.Devices().SetKey(ctx, deviceID, key); err != nil { 107 | return diagnosticsError(err, "failed to update device key") 108 | } 109 | 110 | return resourceDeviceKeyRead(ctx, d, m) 111 | } 112 | -------------------------------------------------------------------------------- /tailscale/resource_device_subnet_routes.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/hashicorp/terraform-plugin-sdk/v2/diag" 10 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 11 | 12 | "tailscale.com/client/tailscale/v2" 13 | ) 14 | 15 | const resourceDeviceSubnetRoutesDescription = `The device_subnet_routes resource allows you to configure enabled subnet routes for your Tailscale devices. See https://tailscale.com/kb/1019/subnets for more information. 16 | 17 | Routes must be both advertised and enabled for a device to act as a subnet router or exit node. Routes must be advertised directly from the device: advertised routes cannot be managed through Terraform. If a device is advertising routes, they are not exposed to traffic until they are enabled. Conversely, if routes are enabled before they are advertised, they are not available for routing until the device in question is advertising them. 18 | 19 | Note: all routes enabled for the device through the admin console or autoApprovers in the ACL must be explicitly added to the routes attribute of this resource to avoid configuration drift. 20 | ` 21 | 22 | func resourceDeviceSubnetRoutes() *schema.Resource { 23 | return &schema.Resource{ 24 | Description: resourceDeviceSubnetRoutesDescription, 25 | ReadContext: resourceDeviceSubnetRoutesRead, 26 | CreateContext: resourceDeviceSubnetRoutesCreate, 27 | UpdateContext: resourceDeviceSubnetRoutesUpdate, 28 | DeleteContext: resourceDeviceSubnetRoutesDelete, 29 | Importer: &schema.ResourceImporter{ 30 | StateContext: func(ctx context.Context, d *schema.ResourceData, meta any) ([]*schema.ResourceData, error) { 31 | // We can't do a simple passthrough here as the ID used for this resource is a 32 | // randomly generated UUID and we need to instead fetch based on the device_id. 33 | // 34 | // TODO(mpminardi): investigate changing the ID in state to be the device_id instead 35 | // in an eventual major version bump. 36 | d.Set("device_id", d.Id()) 37 | d.SetId(createUUID()) 38 | 39 | return []*schema.ResourceData{d}, nil 40 | }, 41 | }, 42 | Schema: map[string]*schema.Schema{ 43 | "device_id": { 44 | Type: schema.TypeString, 45 | Required: true, 46 | Description: "The device to set subnet routes for", 47 | }, 48 | "routes": { 49 | Type: schema.TypeSet, 50 | Elem: &schema.Schema{ 51 | Type: schema.TypeString, 52 | }, 53 | Required: true, 54 | Description: "The subnet routes that are enabled to be routed by a device", 55 | }, 56 | }, 57 | } 58 | } 59 | 60 | func resourceDeviceSubnetRoutesRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 61 | client := m.(*tailscale.Client) 62 | deviceID := d.Get("device_id").(string) 63 | 64 | routes, err := client.Devices().SubnetRoutes(ctx, deviceID) 65 | if err != nil { 66 | return diagnosticsError(err, "Failed to fetch device subnet routes") 67 | } 68 | 69 | if err = d.Set("routes", routes.Enabled); err != nil { 70 | return diag.FromErr(err) 71 | } 72 | 73 | return nil 74 | } 75 | 76 | func resourceDeviceSubnetRoutesCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 77 | client := m.(*tailscale.Client) 78 | deviceID := d.Get("device_id").(string) 79 | routes := d.Get("routes").(*schema.Set).List() 80 | 81 | subnetRoutes := make([]string, len(routes)) 82 | for i, route := range routes { 83 | subnetRoutes[i] = route.(string) 84 | } 85 | 86 | if err := client.Devices().SetSubnetRoutes(ctx, deviceID, subnetRoutes); err != nil { 87 | return diagnosticsError(err, "Failed to set device subnet routes") 88 | } 89 | 90 | d.SetId(createUUID()) 91 | return resourceDeviceSubnetRoutesRead(ctx, d, m) 92 | } 93 | 94 | func resourceDeviceSubnetRoutesUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 95 | client := m.(*tailscale.Client) 96 | deviceID := d.Get("device_id").(string) 97 | routes := d.Get("routes").(*schema.Set).List() 98 | 99 | subnetRoutes := make([]string, len(routes)) 100 | for i, route := range routes { 101 | subnetRoutes[i] = route.(string) 102 | } 103 | 104 | if err := client.Devices().SetSubnetRoutes(ctx, deviceID, subnetRoutes); err != nil { 105 | return diagnosticsError(err, "Failed to set device subnet routes") 106 | } 107 | 108 | return resourceDeviceSubnetRoutesRead(ctx, d, m) 109 | } 110 | 111 | func resourceDeviceSubnetRoutesDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 112 | client := m.(*tailscale.Client) 113 | deviceID := d.Get("device_id").(string) 114 | 115 | if err := client.Devices().SetSubnetRoutes(ctx, deviceID, []string{}); err != nil { 116 | return diagnosticsError(err, "Failed to set device subnet routes") 117 | } 118 | 119 | return nil 120 | } 121 | -------------------------------------------------------------------------------- /tailscale/resource_device_tags.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/hashicorp/terraform-plugin-sdk/v2/diag" 10 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 11 | 12 | "tailscale.com/client/tailscale/v2" 13 | ) 14 | 15 | func resourceDeviceTags() *schema.Resource { 16 | var deleteContext = resourceDeviceTagsDelete 17 | if isAcceptanceTesting() { 18 | // Tags cannot be removed without reauthorizing the device as a user. 19 | // We have no way of doing this during testing. 20 | // Because of https://github.com/hashicorp/terraform-plugin-sdk/issues/609, 21 | // we also have no way of telling the Terraform acceptance test to not test 22 | // resource deletion. 23 | // So, as a workaround, we don't actually delete during acceptance tests. 24 | deleteContext = schema.NoopContext 25 | } 26 | 27 | return &schema.Resource{ 28 | Description: "The device_tags resource is used to apply tags to Tailscale devices. See https://tailscale.com/kb/1068/acl-tags/ for more details.", 29 | ReadContext: resourceDeviceTagsRead, 30 | CreateContext: resourceDeviceTagsSet, 31 | UpdateContext: resourceDeviceTagsSet, 32 | DeleteContext: deleteContext, 33 | Importer: &schema.ResourceImporter{ 34 | StateContext: schema.ImportStatePassthroughContext, 35 | }, 36 | Schema: map[string]*schema.Schema{ 37 | "device_id": { 38 | Type: schema.TypeString, 39 | Required: true, 40 | Description: "The device to set tags for", 41 | }, 42 | "tags": { 43 | Type: schema.TypeSet, 44 | Elem: &schema.Schema{ 45 | Type: schema.TypeString, 46 | }, 47 | Required: true, 48 | Description: "The tags to apply to the device", 49 | }, 50 | }, 51 | } 52 | } 53 | 54 | func resourceDeviceTagsRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 55 | client := m.(*tailscale.Client) 56 | deviceID := d.Id() 57 | 58 | device, err := client.Devices().Get(ctx, deviceID) 59 | if err != nil { 60 | return diagnosticsError(err, "Failed to fetch device") 61 | } 62 | 63 | // If the device lookup succeeds and the state ID is not the same as the legacy ID, we can assume the ID is the node ID. 64 | canonicalDeviceID := device.ID 65 | if device.ID != deviceID { 66 | canonicalDeviceID = device.NodeID 67 | } 68 | 69 | if err = d.Set("device_id", canonicalDeviceID); err != nil { 70 | return diagnosticsError(err, "failed to set device_id") 71 | } 72 | 73 | d.Set("tags", device.Tags) 74 | return nil 75 | } 76 | 77 | func resourceDeviceTagsSet(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 78 | client := m.(*tailscale.Client) 79 | deviceID := d.Get("device_id").(string) 80 | set := d.Get("tags").(*schema.Set) 81 | 82 | tags := make([]string, set.Len()) 83 | for i, item := range set.List() { 84 | tags[i] = item.(string) 85 | } 86 | 87 | if err := client.Devices().SetTags(ctx, deviceID, tags); err != nil { 88 | return diagnosticsError(err, "Failed to set device tags") 89 | } 90 | 91 | d.SetId(deviceID) 92 | return resourceDeviceTagsRead(ctx, d, m) 93 | } 94 | 95 | func resourceDeviceTagsDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 96 | client := m.(*tailscale.Client) 97 | deviceID := d.Get("device_id").(string) 98 | 99 | if err := client.Devices().SetTags(ctx, deviceID, []string{}); err != nil { 100 | return diagnosticsError(err, "Failed to set device tags") 101 | } 102 | 103 | d.SetId(deviceID) 104 | return nil 105 | } 106 | -------------------------------------------------------------------------------- /tailscale/resource_dns_nameservers.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/hashicorp/terraform-plugin-sdk/v2/diag" 10 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 11 | 12 | "tailscale.com/client/tailscale/v2" 13 | ) 14 | 15 | func resourceDNSNameservers() *schema.Resource { 16 | return &schema.Resource{ 17 | Description: "The dns_nameservers resource allows you to configure DNS nameservers for your Tailscale network. See https://tailscale.com/kb/1054/dns for more information.", 18 | ReadContext: resourceDNSNameserversRead, 19 | CreateContext: resourceDNSNameserversCreate, 20 | UpdateContext: resourceDNSNameserversUpdate, 21 | DeleteContext: resourceDNSNameserversDelete, 22 | Importer: &schema.ResourceImporter{ 23 | StateContext: schema.ImportStatePassthroughContext, 24 | }, 25 | Schema: map[string]*schema.Schema{ 26 | "nameservers": { 27 | Type: schema.TypeList, 28 | Elem: &schema.Schema{ 29 | Type: schema.TypeString, 30 | }, 31 | Description: "Devices on your network will use these nameservers to resolve DNS names. IPv4 or IPv6 addresses are accepted.", 32 | Required: true, 33 | MinItems: 1, 34 | }, 35 | }, 36 | } 37 | } 38 | 39 | func resourceDNSNameserversRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 40 | client := m.(*tailscale.Client) 41 | servers, err := client.DNS().Nameservers(ctx) 42 | if err != nil { 43 | return diagnosticsError(err, "Failed to fetch dns nameservers") 44 | } 45 | 46 | if err = d.Set("nameservers", servers); err != nil { 47 | return diag.FromErr(err) 48 | } 49 | 50 | return nil 51 | } 52 | 53 | func resourceDNSNameserversCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 54 | client := m.(*tailscale.Client) 55 | nameservers := d.Get("nameservers").([]interface{}) 56 | 57 | servers := make([]string, len(nameservers)) 58 | for i, server := range nameservers { 59 | servers[i] = server.(string) 60 | } 61 | 62 | if err := client.DNS().SetNameservers(ctx, servers); err != nil { 63 | return diagnosticsError(err, "Failed to create dns nameservers") 64 | } 65 | 66 | d.SetId(createUUID()) 67 | return resourceDNSNameserversRead(ctx, d, m) 68 | } 69 | 70 | func resourceDNSNameserversUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 71 | if !d.HasChange("nameservers") { 72 | return resourceDNSNameserversRead(ctx, d, m) 73 | } 74 | 75 | client := m.(*tailscale.Client) 76 | nameservers := d.Get("nameservers").([]interface{}) 77 | 78 | servers := make([]string, len(nameservers)) 79 | for i, server := range nameservers { 80 | servers[i] = server.(string) 81 | } 82 | 83 | if err := client.DNS().SetNameservers(ctx, servers); err != nil { 84 | return diagnosticsError(err, "Failed to update dns nameservers") 85 | } 86 | 87 | return resourceDNSNameserversRead(ctx, d, m) 88 | } 89 | 90 | func resourceDNSNameserversDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 91 | client := m.(*tailscale.Client) 92 | 93 | if err := client.DNS().SetNameservers(ctx, []string{}); err != nil { 94 | return diagnosticsError(err, "Failed to set dns nameservers") 95 | } 96 | 97 | return nil 98 | } 99 | -------------------------------------------------------------------------------- /tailscale/resource_dns_nameservers_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale 5 | 6 | import ( 7 | "context" 8 | "net/http" 9 | "testing" 10 | 11 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" 12 | "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" 13 | 14 | "tailscale.com/client/tailscale/v2" 15 | ) 16 | 17 | const testNameserversCreate = ` 18 | resource "tailscale_dns_nameservers" "test_nameservers" { 19 | nameservers = [ 20 | "8.8.8.8", 21 | "8.8.4.4", 22 | ] 23 | }` 24 | 25 | const testNameserversUpdate = ` 26 | resource "tailscale_dns_nameservers" "test_nameservers" { 27 | nameservers = [ 28 | "1.1.1.1", 29 | ] 30 | }` 31 | 32 | func TestProvider_TailscaleDNSNameservers(t *testing.T) { 33 | resource.Test(t, resource.TestCase{ 34 | IsUnitTest: true, 35 | PreCheck: func() { 36 | testServer.ResponseCode = http.StatusOK 37 | testServer.ResponseBody = nil 38 | }, 39 | ProviderFactories: testProviderFactories(t), 40 | Steps: []resource.TestStep{ 41 | testResourceCreated("tailscale_dns_nameservers.test_nameservers", testNameserversCreate), 42 | testResourceDestroyed("tailscale_dns_nameservers.test_nameservers", testNameserversCreate), 43 | }, 44 | }) 45 | } 46 | 47 | func TestAccTailscaleDNSNameservers(t *testing.T) { 48 | const resourceName = "tailscale_dns_nameservers.test_nameservers" 49 | 50 | checkProperties := func(expected []string) func(client *tailscale.Client, rs *terraform.ResourceState) error { 51 | return func(client *tailscale.Client, rs *terraform.ResourceState) error { 52 | actual, err := client.DNS().Nameservers(context.Background()) 53 | if err != nil { 54 | return err 55 | } 56 | 57 | if err := assertEqual(expected, actual, "wrong nameservers"); err != nil { 58 | return err 59 | } 60 | 61 | return nil 62 | } 63 | } 64 | 65 | resource.Test(t, resource.TestCase{ 66 | PreCheck: func() { testAccPreCheck(t) }, 67 | ProviderFactories: testAccProviderFactories(t), 68 | CheckDestroy: checkResourceDestroyed(resourceName, checkProperties([]string{})), 69 | Steps: []resource.TestStep{ 70 | { 71 | Config: testNameserversCreate, 72 | Check: resource.ComposeTestCheckFunc( 73 | checkResourceRemoteProperties(resourceName, 74 | checkProperties([]string{"8.8.8.8", "8.8.4.4"}), 75 | ), 76 | resource.TestCheckTypeSetElemAttr(resourceName, "nameservers.*", "8.8.8.8"), 77 | resource.TestCheckTypeSetElemAttr(resourceName, "nameservers.*", "8.8.4.4"), 78 | ), 79 | }, 80 | { 81 | Config: testNameserversUpdate, 82 | Check: resource.ComposeTestCheckFunc( 83 | checkResourceRemoteProperties(resourceName, 84 | checkProperties([]string{"1.1.1.1"}), 85 | ), 86 | resource.TestCheckTypeSetElemAttr(resourceName, "nameservers.*", "1.1.1.1"), 87 | ), 88 | }, 89 | { 90 | ResourceName: resourceName, 91 | ImportState: true, 92 | ImportStateVerify: true, 93 | }, 94 | }, 95 | }) 96 | } 97 | -------------------------------------------------------------------------------- /tailscale/resource_dns_preferences.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/hashicorp/terraform-plugin-sdk/v2/diag" 10 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 11 | 12 | "tailscale.com/client/tailscale/v2" 13 | ) 14 | 15 | func resourceDNSPreferences() *schema.Resource { 16 | return &schema.Resource{ 17 | Description: "The dns_preferences resource allows you to configure DNS preferences for your Tailscale network. See https://tailscale.com/kb/1054/dns for more information.", 18 | ReadContext: resourceDNSPreferencesRead, 19 | CreateContext: resourceDNSPreferencesCreate, 20 | UpdateContext: resourceDNSPreferencesUpdate, 21 | DeleteContext: resourceDNSPreferencesDelete, 22 | Importer: &schema.ResourceImporter{ 23 | StateContext: schema.ImportStatePassthroughContext, 24 | }, 25 | Schema: map[string]*schema.Schema{ 26 | "magic_dns": { 27 | Type: schema.TypeBool, 28 | Description: "Whether or not to enable magic DNS", 29 | Required: true, 30 | }, 31 | }, 32 | } 33 | } 34 | 35 | func resourceDNSPreferencesRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 36 | client := m.(*tailscale.Client) 37 | 38 | preferences, err := client.DNS().Preferences(ctx) 39 | if err != nil { 40 | return diagnosticsError(err, "Failed to fetch dns preferences") 41 | } 42 | 43 | if err = d.Set("magic_dns", preferences.MagicDNS); err != nil { 44 | return diag.FromErr(err) 45 | } 46 | 47 | return nil 48 | } 49 | 50 | func resourceDNSPreferencesCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 51 | client := m.(*tailscale.Client) 52 | magicDNS := d.Get("magic_dns").(bool) 53 | preferences := tailscale.DNSPreferences{ 54 | MagicDNS: magicDNS, 55 | } 56 | 57 | if err := client.DNS().SetPreferences(ctx, preferences); err != nil { 58 | return diagnosticsError(err, "Failed to set dns preferences") 59 | } 60 | 61 | d.SetId(createUUID()) 62 | return resourceDNSPreferencesRead(ctx, d, m) 63 | } 64 | 65 | func resourceDNSPreferencesUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 66 | if !d.HasChange("magic_dns") { 67 | return resourceDNSPreferencesRead(ctx, d, m) 68 | } 69 | 70 | client := m.(*tailscale.Client) 71 | magicDNS := d.Get("magic_dns").(bool) 72 | 73 | preferences := tailscale.DNSPreferences{ 74 | MagicDNS: magicDNS, 75 | } 76 | 77 | if err := client.DNS().SetPreferences(ctx, preferences); err != nil { 78 | return diagnosticsError(err, "Failed to set dns preferences") 79 | } 80 | 81 | return resourceDNSPreferencesRead(ctx, d, m) 82 | } 83 | 84 | func resourceDNSPreferencesDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 85 | client := m.(*tailscale.Client) 86 | 87 | if err := client.DNS().SetPreferences(ctx, tailscale.DNSPreferences{}); err != nil { 88 | return diagnosticsError(err, "Failed to set dns preferences") 89 | } 90 | 91 | return nil 92 | } 93 | -------------------------------------------------------------------------------- /tailscale/resource_dns_preferences_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale 5 | 6 | import ( 7 | "context" 8 | "net/http" 9 | "testing" 10 | 11 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" 12 | "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" 13 | 14 | "tailscale.com/client/tailscale/v2" 15 | ) 16 | 17 | const testDNSPreferencesCreate = ` 18 | resource "tailscale_dns_preferences" "test_preferences" { 19 | magic_dns = true 20 | }` 21 | 22 | const testDNSPreferencesUpdate = ` 23 | resource "tailscale_dns_preferences" "test_preferences" { 24 | magic_dns = false 25 | }` 26 | 27 | func TestProvider_TailscaleDNSPreferences(t *testing.T) { 28 | resource.Test(t, resource.TestCase{ 29 | IsUnitTest: true, 30 | PreCheck: func() { 31 | testServer.ResponseCode = http.StatusOK 32 | testServer.ResponseBody = nil 33 | }, 34 | ProviderFactories: testProviderFactories(t), 35 | Steps: []resource.TestStep{ 36 | testResourceCreated("tailscale_dns_preferences.test_preferences", testDNSPreferencesCreate), 37 | testResourceDestroyed("tailscale_dns_preferences.test_preferences", testDNSPreferencesCreate), 38 | }, 39 | }) 40 | } 41 | 42 | func TestAccTailscaleDNSPreferences(t *testing.T) { 43 | const resourceName = "tailscale_dns_preferences.test_preferences" 44 | 45 | checkProperties := func(expected *tailscale.DNSPreferences) func(client *tailscale.Client, rs *terraform.ResourceState) error { 46 | return func(client *tailscale.Client, rs *terraform.ResourceState) error { 47 | actual, err := client.DNS().Preferences(context.Background()) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | if err := assertEqual(expected, actual, "wrong DNS preferences"); err != nil { 53 | return err 54 | } 55 | 56 | return nil 57 | } 58 | } 59 | 60 | resource.Test(t, resource.TestCase{ 61 | PreCheck: func() { testAccPreCheck(t) }, 62 | ProviderFactories: testAccProviderFactories(t), 63 | CheckDestroy: checkResourceDestroyed(resourceName, checkProperties(&tailscale.DNSPreferences{})), 64 | Steps: []resource.TestStep{ 65 | { 66 | Config: testDNSPreferencesCreate, 67 | Check: resource.ComposeTestCheckFunc( 68 | checkResourceRemoteProperties(resourceName, 69 | checkProperties(&tailscale.DNSPreferences{MagicDNS: true}), 70 | ), 71 | resource.TestCheckResourceAttr(resourceName, "magic_dns", "true"), 72 | ), 73 | }, 74 | { 75 | Config: testDNSPreferencesUpdate, 76 | Check: resource.ComposeTestCheckFunc( 77 | checkResourceRemoteProperties(resourceName, 78 | checkProperties(&tailscale.DNSPreferences{MagicDNS: false}), 79 | ), 80 | resource.TestCheckResourceAttr(resourceName, "magic_dns", "false"), 81 | ), 82 | }, 83 | { 84 | ResourceName: resourceName, 85 | ImportState: true, 86 | ImportStateVerify: true, 87 | }, 88 | }, 89 | }) 90 | } 91 | -------------------------------------------------------------------------------- /tailscale/resource_dns_search_paths.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/hashicorp/terraform-plugin-sdk/v2/diag" 10 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 11 | 12 | "tailscale.com/client/tailscale/v2" 13 | ) 14 | 15 | func resourceDNSSearchPaths() *schema.Resource { 16 | return &schema.Resource{ 17 | Description: "The dns_nameservers resource allows you to configure DNS nameservers for your Tailscale network. See https://tailscale.com/kb/1054/dns for more information.", 18 | ReadContext: resourceDNSSearchPathsRead, 19 | UpdateContext: resourceDNSSearchPathsUpdate, 20 | DeleteContext: resourceDNSSearchPathsDelete, 21 | CreateContext: resourceDNSSearchPathsCreate, 22 | Importer: &schema.ResourceImporter{ 23 | StateContext: schema.ImportStatePassthroughContext, 24 | }, 25 | Schema: map[string]*schema.Schema{ 26 | "search_paths": { 27 | Type: schema.TypeList, 28 | Elem: &schema.Schema{ 29 | Type: schema.TypeString, 30 | }, 31 | Required: true, 32 | Description: "Devices on your network will use these domain suffixes to resolve DNS names.", 33 | }, 34 | }, 35 | } 36 | } 37 | 38 | func resourceDNSSearchPathsRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 39 | client := m.(*tailscale.Client) 40 | paths, err := client.DNS().SearchPaths(ctx) 41 | if err != nil { 42 | return diagnosticsError(err, "Failed to fetch dns search paths") 43 | } 44 | 45 | if err = d.Set("search_paths", paths); err != nil { 46 | return diag.FromErr(err) 47 | } 48 | 49 | return nil 50 | } 51 | 52 | func resourceDNSSearchPathsCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 53 | client := m.(*tailscale.Client) 54 | paths := d.Get("search_paths").([]interface{}) 55 | 56 | searchPaths := make([]string, len(paths)) 57 | for i, path := range paths { 58 | searchPaths[i] = path.(string) 59 | } 60 | 61 | if err := client.DNS().SetSearchPaths(ctx, searchPaths); err != nil { 62 | return diagnosticsError(err, "Failed to fetch set search paths") 63 | } 64 | 65 | d.SetId(createUUID()) 66 | return resourceDNSSearchPathsRead(ctx, d, m) 67 | } 68 | 69 | func resourceDNSSearchPathsUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 70 | if !d.HasChange("search_paths") { 71 | return resourceDNSSearchPathsRead(ctx, d, m) 72 | } 73 | 74 | client := m.(*tailscale.Client) 75 | paths := d.Get("search_paths").([]interface{}) 76 | 77 | searchPaths := make([]string, len(paths)) 78 | for i, path := range paths { 79 | searchPaths[i] = path.(string) 80 | } 81 | 82 | if err := client.DNS().SetSearchPaths(ctx, searchPaths); err != nil { 83 | return diagnosticsError(err, "Failed to fetch set search paths") 84 | } 85 | 86 | return resourceDNSSearchPathsRead(ctx, d, m) 87 | } 88 | 89 | func resourceDNSSearchPathsDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 90 | client := m.(*tailscale.Client) 91 | 92 | if err := client.DNS().SetSearchPaths(ctx, []string{}); err != nil { 93 | return diagnosticsError(err, "Failed to fetch set search paths") 94 | } 95 | 96 | return nil 97 | } 98 | -------------------------------------------------------------------------------- /tailscale/resource_dns_search_paths_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale 5 | 6 | import ( 7 | "context" 8 | "net/http" 9 | "testing" 10 | 11 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" 12 | "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" 13 | 14 | "tailscale.com/client/tailscale/v2" 15 | ) 16 | 17 | const testSearchPathsCreate = ` 18 | resource "tailscale_dns_search_paths" "test_search_paths" { 19 | search_paths = [ 20 | "sub1.example.com", 21 | "sub2.example.com", 22 | ] 23 | }` 24 | 25 | const testSearchPathsUpdate = ` 26 | resource "tailscale_dns_search_paths" "test_search_paths" { 27 | search_paths = [ 28 | "example.com", 29 | ] 30 | }` 31 | 32 | func TestProvider_TailscaleDNSSearchPaths(t *testing.T) { 33 | resource.Test(t, resource.TestCase{ 34 | IsUnitTest: true, 35 | PreCheck: func() { 36 | testServer.ResponseCode = http.StatusOK 37 | testServer.ResponseBody = nil 38 | }, 39 | ProviderFactories: testProviderFactories(t), 40 | Steps: []resource.TestStep{ 41 | testResourceCreated("tailscale_dns_search_paths.test_search_paths", testSearchPathsCreate), 42 | testResourceDestroyed("tailscale_dns_search_paths.test_search_paths", testSearchPathsCreate), 43 | }, 44 | }) 45 | } 46 | 47 | func TestAccTailscaleDNSSearchPaths(t *testing.T) { 48 | const resourceName = "tailscale_dns_search_paths.test_search_paths" 49 | 50 | checkProperties := func(expected []string) func(client *tailscale.Client, rs *terraform.ResourceState) error { 51 | return func(client *tailscale.Client, rs *terraform.ResourceState) error { 52 | actual, err := client.DNS().SearchPaths(context.Background()) 53 | if err != nil { 54 | return err 55 | } 56 | 57 | if err := assertEqual(expected, actual, "wrong DNS search paths"); err != nil { 58 | return err 59 | } 60 | 61 | return nil 62 | } 63 | } 64 | 65 | resource.Test(t, resource.TestCase{ 66 | PreCheck: func() { testAccPreCheck(t) }, 67 | ProviderFactories: testAccProviderFactories(t), 68 | CheckDestroy: checkResourceDestroyed(resourceName, checkProperties([]string{})), 69 | Steps: []resource.TestStep{ 70 | { 71 | Config: testSearchPathsCreate, 72 | Check: resource.ComposeTestCheckFunc( 73 | checkResourceRemoteProperties(resourceName, 74 | checkProperties([]string{"sub1.example.com", "sub2.example.com"}), 75 | ), 76 | resource.TestCheckTypeSetElemAttr(resourceName, "search_paths.*", "sub1.example.com"), 77 | resource.TestCheckTypeSetElemAttr(resourceName, "search_paths.*", "sub2.example.com"), 78 | ), 79 | }, 80 | { 81 | Config: testSearchPathsUpdate, 82 | Check: resource.ComposeTestCheckFunc( 83 | checkResourceRemoteProperties(resourceName, 84 | checkProperties([]string{"example.com"}), 85 | ), 86 | resource.TestCheckTypeSetElemAttr(resourceName, "search_paths.*", "example.com"), 87 | ), 88 | }, 89 | { 90 | ResourceName: resourceName, 91 | ImportState: true, 92 | ImportStateVerify: true, 93 | }, 94 | }, 95 | }) 96 | } 97 | -------------------------------------------------------------------------------- /tailscale/resource_dns_split_nameservers.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/hashicorp/terraform-plugin-sdk/v2/diag" 10 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 11 | 12 | "tailscale.com/client/tailscale/v2" 13 | ) 14 | 15 | func resourceDNSSplitNameservers() *schema.Resource { 16 | return &schema.Resource{ 17 | Description: "The dns_split_nameservers resource allows you to configure split DNS nameservers for your Tailscale network. See https://tailscale.com/kb/1054/dns for more information.", 18 | ReadContext: resourceSplitDNSNameserversRead, 19 | CreateContext: resourceSplitDNSNameserversCreateOrUpdate, 20 | UpdateContext: resourceSplitDNSNameserversCreateOrUpdate, 21 | DeleteContext: resourceSplitDNSNameserversDelete, 22 | Importer: &schema.ResourceImporter{ 23 | StateContext: schema.ImportStatePassthroughContext, 24 | }, 25 | Schema: map[string]*schema.Schema{ 26 | "domain": { 27 | Type: schema.TypeString, 28 | Description: "Domain to configure split DNS for. Requests for this domain will be resolved using the provided nameservers. Changing this will force the resource to be recreated.", 29 | Required: true, 30 | ForceNew: true, 31 | }, 32 | "nameservers": { 33 | Type: schema.TypeSet, 34 | Description: "Devices on your network will use these nameservers to resolve DNS names. IPv4 or IPv6 addresses are accepted.", 35 | Required: true, 36 | Elem: &schema.Schema{ 37 | Type: schema.TypeString, 38 | }, 39 | }, 40 | }, 41 | } 42 | } 43 | 44 | func resourceSplitDNSNameserversRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 45 | client := m.(*tailscale.Client) 46 | splitDNS, err := client.DNS().SplitDNS(ctx) 47 | if err != nil { 48 | return diagnosticsError(err, "Failed to fetch split DNS configs") 49 | } 50 | 51 | domain := d.Id() 52 | 53 | if err = d.Set("domain", domain); err != nil { 54 | return diag.FromErr(err) 55 | } 56 | 57 | nameservers := splitDNS[d.Id()] 58 | 59 | if err = d.Set("nameservers", nameservers); err != nil { 60 | return diag.FromErr(err) 61 | } 62 | 63 | return nil 64 | } 65 | 66 | func resourceSplitDNSNameserversCreateOrUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 67 | client := m.(*tailscale.Client) 68 | nameserversSet := d.Get("nameservers").(*schema.Set) 69 | domain := d.Get("domain").(string) 70 | 71 | nameserversList := nameserversSet.List() 72 | 73 | req := make(tailscale.SplitDNSRequest) 74 | var nameservers []string 75 | for _, nameserver := range nameserversList { 76 | nameservers = append(nameservers, nameserver.(string)) 77 | } 78 | req[domain] = nameservers 79 | 80 | // Return value is not useful to us here, ignore. 81 | if _, err := client.DNS().UpdateSplitDNS(ctx, req); err != nil { 82 | return diagnosticsError(err, "Failed to set dns split nameservers") 83 | } 84 | 85 | d.SetId(domain) 86 | return resourceSplitDNSNameserversRead(ctx, d, m) 87 | } 88 | 89 | func resourceSplitDNSNameserversDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 90 | client := m.(*tailscale.Client) 91 | domain := d.Get("domain").(string) 92 | 93 | req := make(tailscale.SplitDNSRequest) 94 | req[domain] = []string{} 95 | 96 | // Return value is not useful to us here, ignore. 97 | if _, err := client.DNS().UpdateSplitDNS(ctx, req); err != nil { 98 | return diagnosticsError(err, "Failed to delete dns split nameservers") 99 | } 100 | 101 | return nil 102 | } 103 | -------------------------------------------------------------------------------- /tailscale/resource_dns_split_nameservers_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "net/http" 10 | "testing" 11 | 12 | "github.com/google/go-cmp/cmp" 13 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" 14 | "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" 15 | 16 | "tailscale.com/client/tailscale/v2" 17 | ) 18 | 19 | const testSplitNameservers = ` 20 | resource "tailscale_dns_split_nameservers" "test_nameservers" { 21 | domain = "example.com" 22 | nameservers = ["1.2.3.4", "4.5.6.7"] 23 | }` 24 | 25 | func TestProvider_TailscaleSplitDNSNameservers(t *testing.T) { 26 | resource.Test(t, resource.TestCase{ 27 | IsUnitTest: true, 28 | PreCheck: func() { 29 | testServer.ResponseCode = http.StatusOK 30 | testServer.ResponseBody = nil 31 | }, 32 | ProviderFactories: testProviderFactories(t), 33 | Steps: []resource.TestStep{ 34 | testResourceCreated("tailscale_dns_split_nameservers.test_nameservers", testSplitNameservers), 35 | testResourceDestroyed("tailscale_dns_split_nameservers.test_nameservers", testSplitNameservers), 36 | }, 37 | }) 38 | } 39 | 40 | func TestAccTailscaleDNSSplitNameservers(t *testing.T) { 41 | const resourceName = "tailscale_dns_split_nameservers.test_nameservers" 42 | 43 | const testSplitNameserversCreate = ` 44 | resource "tailscale_dns_split_nameservers" "test_nameservers" { 45 | domain = "example.com" 46 | nameservers = ["1.2.3.4", "4.5.6.7"] 47 | }` 48 | 49 | const testSplitNameserversUpdate = ` 50 | resource "tailscale_dns_split_nameservers" "test_nameservers" { 51 | domain = "sub.example.com" 52 | nameservers = ["8.8.9.9"] 53 | }` 54 | 55 | const testSplitNameserversUpdateSameDomain = ` 56 | resource "tailscale_dns_split_nameservers" "test_nameservers" { 57 | domain = "sub.example.com" 58 | nameservers = ["8.8.7.7", "8.8.9.9"] 59 | }` 60 | 61 | checkProperties := func(expected tailscale.SplitDNSResponse) func(client *tailscale.Client, rs *terraform.ResourceState) error { 62 | return func(client *tailscale.Client, rs *terraform.ResourceState) error { 63 | actual, err := client.DNS().SplitDNS(context.Background()) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | if diff := cmp.Diff(actual, expected); diff != "" { 69 | return fmt.Errorf("wrong split dns: (-got+want) \n%s", diff) 70 | } 71 | 72 | return nil 73 | } 74 | } 75 | 76 | resource.Test(t, resource.TestCase{ 77 | PreCheck: func() { testAccPreCheck(t) }, 78 | ProviderFactories: testAccProviderFactories(t), 79 | CheckDestroy: checkResourceDestroyed(resourceName, checkProperties(tailscale.SplitDNSResponse{})), 80 | Steps: []resource.TestStep{ 81 | { 82 | Config: testSplitNameserversCreate, 83 | Check: resource.ComposeTestCheckFunc( 84 | checkResourceRemoteProperties(resourceName, 85 | checkProperties(tailscale.SplitDNSResponse{ 86 | "example.com": []string{"1.2.3.4", "4.5.6.7"}, 87 | }), 88 | ), 89 | resource.TestCheckResourceAttr(resourceName, "domain", "example.com"), 90 | resource.TestCheckTypeSetElemAttr(resourceName, "nameservers.*", "1.2.3.4"), 91 | resource.TestCheckTypeSetElemAttr(resourceName, "nameservers.*", "4.5.6.7"), 92 | ), 93 | }, 94 | { 95 | Config: testSplitNameserversUpdate, 96 | Check: resource.ComposeTestCheckFunc( 97 | checkResourceRemoteProperties(resourceName, 98 | checkProperties(tailscale.SplitDNSResponse{ 99 | "sub.example.com": []string{"8.8.9.9"}, 100 | }), 101 | ), 102 | resource.TestCheckResourceAttr(resourceName, "domain", "sub.example.com"), 103 | resource.TestCheckTypeSetElemAttr(resourceName, "nameservers.*", "8.8.9.9"), 104 | ), 105 | }, 106 | { 107 | Config: testSplitNameserversUpdateSameDomain, 108 | Check: resource.ComposeTestCheckFunc( 109 | checkResourceRemoteProperties(resourceName, 110 | checkProperties(tailscale.SplitDNSResponse{ 111 | "sub.example.com": []string{"8.8.7.7", "8.8.9.9"}, 112 | }), 113 | ), 114 | resource.TestCheckResourceAttr(resourceName, "domain", "sub.example.com"), 115 | resource.TestCheckTypeSetElemAttr(resourceName, "nameservers.*", "8.8.7.7"), 116 | resource.TestCheckTypeSetElemAttr(resourceName, "nameservers.*", "8.8.9.9"), 117 | ), 118 | }, 119 | { 120 | ResourceName: resourceName, 121 | ImportState: true, 122 | ImportStateVerify: true, 123 | }, 124 | }, 125 | }) 126 | } 127 | -------------------------------------------------------------------------------- /tailscale/resource_oauth_client.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale 5 | 6 | import ( 7 | "context" 8 | "time" 9 | 10 | "github.com/hashicorp/go-cty/cty" 11 | "github.com/hashicorp/terraform-plugin-sdk/v2/diag" 12 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 13 | 14 | "tailscale.com/client/tailscale/v2" 15 | ) 16 | 17 | func resourceOAuthClient() *schema.Resource { 18 | return &schema.Resource{ 19 | Description: "The oauth_client resource allows you to create OAuth clients to programmatically interact with the Tailscale API.", 20 | ReadContext: resourceOAuthClientRead, 21 | CreateContext: resourceOAuthClientCreate, 22 | DeleteContext: resourceOAuthClientDelete, 23 | UpdateContext: nil, 24 | // Importer: &schema.ResourceImporter{StateContext: schema.ImportStatePassthroughContext}, no import support - the key is not returned by the API so it'd serve no purpose 25 | Schema: map[string]*schema.Schema{ 26 | "description": { 27 | Type: schema.TypeString, 28 | Optional: true, 29 | Description: "A description of the key consisting of alphanumeric characters. Defaults to `\"\"`.", 30 | ForceNew: true, 31 | ValidateDiagFunc: func(i interface{}, p cty.Path) diag.Diagnostics { 32 | if len(i.(string)) > 50 { 33 | return diagnosticsError(nil, "description must be 50 characters or less") 34 | } 35 | return nil 36 | }, 37 | }, 38 | "scopes": { 39 | Type: schema.TypeSet, 40 | Elem: &schema.Schema{ 41 | Type: schema.TypeString, 42 | }, 43 | Required: true, 44 | Description: "Scopes to grant to the client. See https://tailscale.com/kb/1215/ for a list of available scopes.", 45 | ForceNew: true, 46 | }, 47 | "tags": { 48 | Type: schema.TypeSet, 49 | Elem: &schema.Schema{ 50 | Type: schema.TypeString, 51 | }, 52 | Optional: true, 53 | Description: "A list of tags that access tokens generated for the OAuth client will be able to assign to devices. Mandatory if the scopes include \"devices:core\" or \"auth_keys\".", 54 | ForceNew: true, 55 | }, 56 | "id": { 57 | Type: schema.TypeString, 58 | Description: "The client ID, also known as the key id. Used with the client secret to generate access tokens.", 59 | Computed: true, 60 | }, 61 | "key": { 62 | Type: schema.TypeString, 63 | Description: "The client secret, also known as the key. Used with the client ID to generate access tokens.", 64 | Computed: true, 65 | Sensitive: true, 66 | }, 67 | "created_at": { 68 | Type: schema.TypeString, 69 | Description: "The creation timestamp of the key in RFC3339 format", 70 | Computed: true, 71 | }, 72 | "user_id": { 73 | Type: schema.TypeString, 74 | Description: "ID of the user who created this key, empty for OAuth clients created by other OAuth clients.", 75 | Computed: true, 76 | }, 77 | }, 78 | } 79 | } 80 | 81 | func resourceOAuthClientRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 82 | client := m.(*tailscale.Client) 83 | key, err := client.Keys().Get(ctx, d.Id()) 84 | if err != nil { 85 | return diagnosticsError(err, "Failed to fetch key") 86 | } 87 | 88 | d.SetId(key.ID) 89 | if err = d.Set("description", key.Description); err != nil { 90 | return diagnosticsError(err, "Failed to set description") 91 | } 92 | 93 | if err = d.Set("created_at", key.Created.Format(time.RFC3339)); err != nil { 94 | return diagnosticsError(err, "Failed to set created_at") 95 | } 96 | 97 | if err = d.Set("user_id", key.UserID); err != nil { 98 | return diagnosticsError(err, "Failed to set 'user_id'") 99 | } 100 | 101 | return nil 102 | } 103 | 104 | func resourceOAuthClientCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 105 | client := m.(*tailscale.Client) 106 | 107 | description, ok := d.GetOk("description") 108 | if !ok { 109 | description = "" 110 | } 111 | var scopes []string 112 | for _, scope := range d.Get("scopes").(*schema.Set).List() { 113 | scopes = append(scopes, scope.(string)) 114 | } 115 | var tags []string 116 | for _, tag := range d.Get("tags").(*schema.Set).List() { 117 | tags = append(tags, tag.(string)) 118 | } 119 | 120 | key, err := client.Keys().CreateOAuthClient(ctx, tailscale.CreateOAuthClientRequest{ 121 | Description: description.(string), 122 | Scopes: scopes, 123 | Tags: tags, 124 | }) 125 | if err != nil { 126 | return diagnosticsError(err, "Failed to create oauth client") 127 | } 128 | 129 | d.SetId(key.ID) 130 | if err = d.Set("key", key.Key); err != nil { 131 | return diagnosticsError(err, "Failed to set key") 132 | } 133 | if err = d.Set("created_at", key.Created.Format(time.RFC3339)); err != nil { 134 | return diagnosticsError(err, "Failed to set created_at") 135 | } 136 | if err = d.Set("user_id", key.UserID); err != nil { 137 | return diagnosticsError(err, "Failed to set user_id") 138 | } 139 | 140 | return resourceOAuthClientRead(ctx, d, m) 141 | } 142 | 143 | func resourceOAuthClientDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 144 | client := m.(*tailscale.Client) 145 | 146 | err := client.Keys().Delete(ctx, d.Id()) 147 | if err != nil && !tailscale.IsNotFound(err) { 148 | return diagnosticsError(err, "Failed to delete oauth client") 149 | } 150 | 151 | return nil 152 | } 153 | -------------------------------------------------------------------------------- /tailscale/resource_oauth_client_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | "fmt" 10 | "net/http" 11 | "testing" 12 | "time" 13 | 14 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" 15 | "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" 16 | "tailscale.com/client/tailscale/v2" 17 | ) 18 | 19 | func TestProvider_TailscaleOAuthClient(t *testing.T) { 20 | const testOAuthClient = ` 21 | resource "tailscale_oauth_client" "example_oauth_client" { 22 | description = "Example OAuth client" 23 | scopes = ["auth_keys", "devices:core"] 24 | tags = ["tag:test"] 25 | }` 26 | 27 | resource.Test(t, resource.TestCase{ 28 | IsUnitTest: true, 29 | PreCheck: func() { 30 | testServer.ResponseCode = http.StatusOK 31 | testServer.ResponseBody = tailscale.Key{ 32 | ID: "test", 33 | Key: "thisisatestclient", 34 | } 35 | }, 36 | ProviderFactories: testProviderFactories(t), 37 | Steps: []resource.TestStep{ 38 | testResourceCreated("tailscale_oauth_client.example_oauth_client", testOAuthClient), 39 | testResourceDestroyed("tailscale_oauth_client.example_oauth_client", testOAuthClient), 40 | }, 41 | }) 42 | } 43 | 44 | func TestAccTailscaleOAuthClient(t *testing.T) { 45 | const resourceName = "tailscale_oauth_client.test_client" 46 | 47 | const testOAuthClientCreate = ` 48 | resource "tailscale_oauth_client" "test_client" { 49 | description = "Test client" 50 | scopes = ["auth_keys", "devices:core"] 51 | tags = ["tag:test"] 52 | }` 53 | 54 | const testOAuthClientUpdate = ` 55 | resource "tailscale_oauth_client" "test_client" { 56 | description = "Updated description" 57 | scopes = ["auth_keys:read"] 58 | }` 59 | 60 | var expectedOAuthClientCreated tailscale.Key 61 | expectedOAuthClientCreated.Description = "Test client" 62 | expectedOAuthClientCreated.KeyType = "client" 63 | 64 | var expectedOAuthClientUpdated tailscale.Key 65 | expectedOAuthClientUpdated.Description = "Updated description" 66 | expectedOAuthClientUpdated.KeyType = "client" 67 | 68 | checkProperties := func(expected *tailscale.Key) func(client *tailscale.Client, rs *terraform.ResourceState) error { 69 | return func(client *tailscale.Client, rs *terraform.ResourceState) error { 70 | actual, err := client.Keys().Get(context.Background(), rs.Primary.ID) 71 | if err != nil { 72 | return err 73 | } 74 | 75 | if actual.Created.IsZero() { 76 | return errors.New("created should be set") 77 | } 78 | 79 | // don't compare server-side generated fields 80 | actual.Created = time.Time{} 81 | actual.ID = "" 82 | actual.UserID = "" 83 | 84 | if err := assertEqual(expected, actual, "wrong key"); err != nil { 85 | return err 86 | } 87 | 88 | return nil 89 | } 90 | } 91 | 92 | checkOAuthClientDeleted := func(client *tailscale.Client, rs *terraform.ResourceState) error { 93 | key, err := client.Keys().Get(context.Background(), rs.Primary.ID) 94 | if err != nil { 95 | return fmt.Errorf("unexpected error while checking if oauth client was deleted: %w", err) 96 | } 97 | 98 | if !key.Invalid { 99 | return fmt.Errorf("oauth client is still valid on server") 100 | } 101 | if key.Revoked.IsZero() { 102 | return fmt.Errorf("oauth client was not revoked on server") 103 | } 104 | 105 | return nil 106 | } 107 | 108 | resource.Test(t, resource.TestCase{ 109 | PreCheck: func() { testAccPreCheck(t) }, 110 | ProviderFactories: testAccProviderFactories(t), 111 | CheckDestroy: checkResourceDestroyed(resourceName, checkOAuthClientDeleted), 112 | Steps: []resource.TestStep{ 113 | { 114 | PreConfig: func() { 115 | // Set up ACLs to allow the required tags 116 | client := testAccProvider.Meta().(*tailscale.Client) 117 | err := client.PolicyFile().Set(context.Background(), ` 118 | { 119 | "tagOwners": { 120 | "tag:test": ["autogroup:member"], 121 | }, 122 | }`, "") 123 | if err != nil { 124 | panic(err) 125 | } 126 | }, 127 | Config: testOAuthClientCreate, 128 | Check: resource.ComposeTestCheckFunc( 129 | checkResourceRemoteProperties(resourceName, 130 | checkProperties(&expectedOAuthClientCreated), 131 | ), 132 | resource.TestCheckResourceAttr(resourceName, "description", "Test client"), 133 | resource.TestCheckResourceAttr(resourceName, "scopes.0", "auth_keys"), 134 | resource.TestCheckResourceAttr(resourceName, "scopes.1", "devices:core"), 135 | resource.TestCheckResourceAttr(resourceName, "tags.0", "tag:test"), 136 | resource.TestCheckResourceAttrSet(resourceName, "id"), 137 | resource.TestCheckResourceAttrSet(resourceName, "key"), 138 | resource.TestCheckResourceAttrSet(resourceName, "created_at"), 139 | resource.TestCheckResourceAttrSet(resourceName, "user_id"), 140 | ), 141 | }, 142 | { 143 | Config: testOAuthClientUpdate, 144 | Check: resource.ComposeTestCheckFunc( 145 | checkResourceRemoteProperties(resourceName, 146 | checkProperties(&expectedOAuthClientUpdated), 147 | ), 148 | resource.TestCheckResourceAttr(resourceName, "description", "Updated description"), 149 | resource.TestCheckResourceAttr(resourceName, "scopes.0", "auth_keys:read"), 150 | resource.TestCheckResourceAttrSet(resourceName, "id"), 151 | resource.TestCheckResourceAttrSet(resourceName, "key"), 152 | resource.TestCheckResourceAttrSet(resourceName, "created_at"), 153 | resource.TestCheckResourceAttrSet(resourceName, "user_id"), 154 | ), 155 | }, 156 | }, 157 | }) 158 | } 159 | -------------------------------------------------------------------------------- /tailscale/resource_posture_integration.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/hashicorp/terraform-plugin-sdk/v2/diag" 10 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 11 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" 12 | 13 | "tailscale.com/client/tailscale/v2" 14 | ) 15 | 16 | func resourcePostureIntegration() *schema.Resource { 17 | return &schema.Resource{ 18 | Description: "The posture_integration resource allows you to manage integrations with device posture data providers. See https://tailscale.com/kb/1288/device-posture for more information.", 19 | ReadContext: resourcePostureIntegrationRead, 20 | CreateContext: resourcePostureIntegrationCreate, 21 | UpdateContext: resourcePostureIntegrationUpdate, 22 | DeleteContext: resourcePostureIntegrationDelete, 23 | Importer: &schema.ResourceImporter{ 24 | StateContext: schema.ImportStatePassthroughContext, 25 | }, 26 | Schema: map[string]*schema.Schema{ 27 | "posture_provider": { 28 | Type: schema.TypeString, 29 | Description: "The type of posture integration data provider.", 30 | Required: true, 31 | ForceNew: true, 32 | ValidateFunc: validation.StringInSlice( 33 | []string{ 34 | string(tailscale.PostureIntegrationProviderFalcon), 35 | string(tailscale.PostureIntegrationProviderIntune), 36 | string(tailscale.PostureIntegrationProviderJamfPro), 37 | string(tailscale.PostureIntegrationProviderKandji), 38 | string(tailscale.PostureIntegrationProviderKolide), 39 | string(tailscale.PostureIntegrationProviderSentinelOne), 40 | }, 41 | false, 42 | ), 43 | }, 44 | "cloud_id": { 45 | Type: schema.TypeString, 46 | Description: "Identifies which of the provider's clouds to integrate with.", 47 | Optional: true, 48 | }, 49 | "client_id": { 50 | Type: schema.TypeString, 51 | Description: "Unique identifier for your client.", 52 | Optional: true, 53 | }, 54 | "tenant_id": { 55 | Type: schema.TypeString, 56 | Description: "The Microsoft Intune directory (tenant) ID. For other providers, this is left blank.", 57 | Optional: true, 58 | }, 59 | "client_secret": { 60 | Type: schema.TypeString, 61 | Description: "The secret (auth key, token, etc.) used to authenticate with the provider.", 62 | Required: true, 63 | }, 64 | }, 65 | } 66 | } 67 | 68 | func resourcePostureIntegrationRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 69 | client := m.(*tailscale.Client) 70 | 71 | integration, err := client.DevicePosture().GetIntegration(ctx, d.Id()) 72 | if err != nil { 73 | return diagnosticsError(err, "Failed to find posture integration with id %q", d.Id()) 74 | } 75 | return resourcePostureIntegrationUpdateFromRemote(d, integration) 76 | } 77 | 78 | func resourcePostureIntegrationUpdateFromRemote(d *schema.ResourceData, integration *tailscale.PostureIntegration) diag.Diagnostics { 79 | if err := d.Set("posture_provider", string(integration.Provider)); err != nil { 80 | return diagnosticsError(err, "Failed to set posture_provider field") 81 | } 82 | if err := d.Set("cloud_id", string(integration.CloudID)); err != nil { 83 | return diagnosticsError(err, "Failed to set cloud_id field") 84 | } 85 | if err := d.Set("client_id", string(integration.ClientID)); err != nil { 86 | return diagnosticsError(err, "Failed to set client_id field") 87 | } 88 | if err := d.Set("tenant_id", string(integration.TenantID)); err != nil { 89 | return diagnosticsError(err, "Failed to set tenant_id field") 90 | } 91 | 92 | return nil 93 | } 94 | 95 | func resourcePostureIntegrationCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 96 | client := m.(*tailscale.Client) 97 | 98 | integration, err := client.DevicePosture().CreateIntegration( 99 | ctx, 100 | tailscale.CreatePostureIntegrationRequest{ 101 | Provider: tailscale.PostureIntegrationProvider(d.Get("posture_provider").(string)), 102 | CloudID: d.Get("cloud_id").(string), 103 | ClientID: d.Get("client_id").(string), 104 | TenantID: d.Get("tenant_id").(string), 105 | ClientSecret: d.Get("client_secret").(string), 106 | }, 107 | ) 108 | if err != nil { 109 | return diagnosticsError(err, "Failed to create posture integration") 110 | } 111 | 112 | d.SetId(integration.ID) 113 | return resourcePostureIntegrationUpdateFromRemote(d, integration) 114 | } 115 | 116 | func resourcePostureIntegrationUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 117 | client := m.(*tailscale.Client) 118 | 119 | integration, err := client.DevicePosture().UpdateIntegration( 120 | ctx, 121 | d.Id(), 122 | tailscale.UpdatePostureIntegrationRequest{ 123 | CloudID: d.Get("cloud_id").(string), 124 | ClientID: d.Get("client_id").(string), 125 | TenantID: d.Get("tenant_id").(string), 126 | ClientSecret: tailscale.PointerTo(d.Get("client_secret").(string)), 127 | }) 128 | if err != nil { 129 | return diagnosticsError(err, "Failed to update posture integration with id %q", d.Id()) 130 | } 131 | 132 | return resourcePostureIntegrationUpdateFromRemote(d, integration) 133 | } 134 | 135 | func resourcePostureIntegrationDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { 136 | client := m.(*tailscale.Client) 137 | 138 | err := client.DevicePosture().DeleteIntegration(ctx, d.Id()) 139 | if err != nil { 140 | return diagnosticsError(err, "Failed to delete posture integration with id %q", d.Id()) 141 | } 142 | 143 | return nil 144 | } 145 | -------------------------------------------------------------------------------- /tailscale/resource_posture_integration_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "testing" 10 | 11 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" 12 | "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" 13 | 14 | "tailscale.com/client/tailscale/v2" 15 | ) 16 | 17 | const testPostureIntegrationCreate = ` 18 | resource "tailscale_posture_integration" "test_posture_integration" { 19 | posture_provider = "falcon" 20 | cloud_id = "us-1" 21 | client_id = "clientid1" 22 | client_secret = "test-secret1" 23 | }` 24 | 25 | const testPostureIntegrationUpdateSameProvider = ` 26 | resource "tailscale_posture_integration" "test_posture_integration" { 27 | posture_provider = "falcon" 28 | cloud_id = "us-2" 29 | client_id = "clientid2" 30 | client_secret = "test-secret2" 31 | }` 32 | 33 | const testPostureIntegrationUpdateDifferentProvider = ` 34 | resource "tailscale_posture_integration" "test_posture_integration" { 35 | posture_provider = "intune" 36 | cloud_id = "global" 37 | client_id = "fddf23ae-0e3a-4e0c-908d-6f44e80f9400" 38 | tenant_id = "fddf23ae-0e3a-4e0c-908d-6f44e80f9401" 39 | client_secret = "test-secret3" 40 | }` 41 | 42 | func TestAccTailscalePostureIntegration(t *testing.T) { 43 | const resourceName = "tailscale_posture_integration.test_posture_integration" 44 | 45 | checkProperties := func(expected tailscale.PostureIntegration) func(client *tailscale.Client, rs *terraform.ResourceState) error { 46 | return func(client *tailscale.Client, rs *terraform.ResourceState) error { 47 | integration, err := client.DevicePosture().GetIntegration(context.Background(), rs.Primary.ID) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | if integration.Provider != expected.Provider { 53 | return fmt.Errorf("wrong provider, want %q got %q", expected.Provider, integration.Provider) 54 | } 55 | if integration.CloudID != expected.CloudID { 56 | return fmt.Errorf("wrong cloud_id, want %q got %q", expected.CloudID, integration.CloudID) 57 | } 58 | if integration.ClientID != expected.ClientID { 59 | return fmt.Errorf("wrong client_id, want %q got %q", expected.ClientID, integration.ClientID) 60 | } 61 | if integration.TenantID != expected.TenantID { 62 | return fmt.Errorf("wrong tenant_id, want %q got %q", expected.TenantID, integration.TenantID) 63 | } 64 | 65 | return nil 66 | } 67 | } 68 | 69 | resource.Test(t, resource.TestCase{ 70 | PreCheck: func() { testAccPreCheck(t) }, 71 | ProviderFactories: testAccProviderFactories(t), 72 | CheckDestroy: checkResourceDestroyed(resourceName, func(client *tailscale.Client, rs *terraform.ResourceState) error { 73 | _, err := client.DevicePosture().GetIntegration(context.Background(), rs.Primary.ID) 74 | if err == nil { 75 | return fmt.Errorf("posture integration %q still exists on server", resourceName) 76 | } 77 | 78 | return nil 79 | }), 80 | Steps: []resource.TestStep{ 81 | { 82 | Config: testPostureIntegrationCreate, 83 | Check: resource.ComposeTestCheckFunc( 84 | checkResourceRemoteProperties(resourceName, 85 | checkProperties(tailscale.PostureIntegration{ 86 | Provider: tailscale.PostureIntegrationProviderFalcon, 87 | CloudID: "us-1", 88 | ClientID: "clientid1", 89 | }), 90 | ), 91 | resource.TestCheckResourceAttr(resourceName, "posture_provider", "falcon"), 92 | resource.TestCheckResourceAttr(resourceName, "cloud_id", "us-1"), 93 | resource.TestCheckResourceAttr(resourceName, "client_id", "clientid1"), 94 | resource.TestCheckResourceAttr(resourceName, "client_secret", "test-secret1"), 95 | ), 96 | }, 97 | { 98 | Config: testPostureIntegrationUpdateSameProvider, 99 | Check: resource.ComposeTestCheckFunc( 100 | checkResourceRemoteProperties(resourceName, 101 | checkProperties(tailscale.PostureIntegration{ 102 | Provider: tailscale.PostureIntegrationProviderFalcon, 103 | CloudID: "us-2", 104 | ClientID: "clientid2", 105 | }), 106 | ), 107 | resource.TestCheckResourceAttr(resourceName, "posture_provider", "falcon"), 108 | resource.TestCheckResourceAttr(resourceName, "cloud_id", "us-2"), 109 | resource.TestCheckResourceAttr(resourceName, "client_id", "clientid2"), 110 | resource.TestCheckResourceAttr(resourceName, "client_secret", "test-secret2"), 111 | ), 112 | }, 113 | { 114 | Config: testPostureIntegrationUpdateDifferentProvider, 115 | Check: resource.ComposeTestCheckFunc( 116 | checkResourceRemoteProperties(resourceName, 117 | checkProperties(tailscale.PostureIntegration{ 118 | Provider: tailscale.PostureIntegrationProviderIntune, 119 | CloudID: "global", 120 | ClientID: "fddf23ae-0e3a-4e0c-908d-6f44e80f9400", 121 | TenantID: "fddf23ae-0e3a-4e0c-908d-6f44e80f9401", 122 | }), 123 | ), 124 | resource.TestCheckResourceAttr(resourceName, "posture_provider", "intune"), 125 | resource.TestCheckResourceAttr(resourceName, "cloud_id", "global"), 126 | resource.TestCheckResourceAttr(resourceName, "client_id", "fddf23ae-0e3a-4e0c-908d-6f44e80f9400"), 127 | resource.TestCheckResourceAttr(resourceName, "tenant_id", "fddf23ae-0e3a-4e0c-908d-6f44e80f9401"), 128 | resource.TestCheckResourceAttr(resourceName, "client_secret", "test-secret3"), 129 | ), 130 | }, 131 | { 132 | ResourceName: resourceName, 133 | ImportState: true, 134 | ImportStateVerify: true, 135 | ImportStateVerifyIgnore: []string{"client_secret"}, 136 | }, 137 | }, 138 | }) 139 | } 140 | -------------------------------------------------------------------------------- /tailscale/resource_webhook_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "net/http" 10 | "reflect" 11 | "slices" 12 | "testing" 13 | 14 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" 15 | "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" 16 | 17 | "tailscale.com/client/tailscale/v2" 18 | ) 19 | 20 | const testWebhook = ` 21 | resource "tailscale_webhook" "test_webhook" { 22 | endpoint_url = "https://example.com/endpoint" 23 | provider_type = "slack" 24 | subscriptions = ["userNeedsApproval", "nodeCreated"] 25 | }` 26 | 27 | const testWebhookUpdate = ` 28 | resource "tailscale_webhook" "test_webhook" { 29 | endpoint_url = "https://example.com/endpoint" 30 | provider_type = "slack" 31 | subscriptions = ["nodeCreated", "userSuspended", "userRoleUpdated"] 32 | }` 33 | 34 | func TestProvider_TailscaleWebhook(t *testing.T) { 35 | resource.Test(t, resource.TestCase{ 36 | IsUnitTest: true, 37 | PreCheck: func() { 38 | testServer.ResponseCode = http.StatusOK 39 | testServer.ResponseBody = tailscale.Webhook{ 40 | EndpointID: "12345", 41 | } 42 | }, 43 | ProviderFactories: testProviderFactories(t), 44 | Steps: []resource.TestStep{ 45 | testResourceCreated("tailscale_webhook.test_webhook", testWebhook), 46 | testResourceDestroyed("tailscale_webhook.test_webhook", testWebhook), 47 | }, 48 | }) 49 | } 50 | 51 | func TestAccTailscaleWebhook(t *testing.T) { 52 | const resourceName = "tailscale_webhook.test_webhook" 53 | 54 | checkProperties := func(expectedSubscriptions []tailscale.WebhookSubscriptionType) func(client *tailscale.Client, rs *terraform.ResourceState) error { 55 | return func(client *tailscale.Client, rs *terraform.ResourceState) error { 56 | webhook, err := client.Webhooks().Get(context.Background(), rs.Primary.ID) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | if webhook.EndpointURL != "https://example.com/endpoint" { 62 | return fmt.Errorf("bad webhook.endpoint_url: %s", webhook.EndpointURL) 63 | } 64 | if webhook.ProviderType != "slack" { 65 | return fmt.Errorf("bad webhook.provider_type: %s", webhook.ProviderType) 66 | } 67 | 68 | slices.Sort(expectedSubscriptions) 69 | slices.Sort(webhook.Subscriptions) 70 | 71 | if !reflect.DeepEqual(webhook.Subscriptions, expectedSubscriptions) { 72 | return fmt.Errorf("bad webhook.subscriptions: %#v", webhook.Subscriptions) 73 | } 74 | return nil 75 | } 76 | } 77 | 78 | resource.Test(t, resource.TestCase{ 79 | PreCheck: func() { testAccPreCheck(t) }, 80 | ProviderFactories: testAccProviderFactories(t), 81 | CheckDestroy: checkResourceDestroyed(resourceName, func(client *tailscale.Client, rs *terraform.ResourceState) error { 82 | _, err := client.Webhooks().Get(context.Background(), rs.Primary.ID) 83 | if err == nil { 84 | return fmt.Errorf("webhook %q still exists on server", resourceName) 85 | } 86 | return nil 87 | }), 88 | Steps: []resource.TestStep{ 89 | { 90 | Config: testWebhook, 91 | Check: resource.ComposeTestCheckFunc( 92 | checkResourceRemoteProperties( 93 | resourceName, 94 | checkProperties([]tailscale.WebhookSubscriptionType{ 95 | tailscale.WebhookNodeCreated, 96 | tailscale.WebhookUserNeedsApproval, 97 | }), 98 | ), 99 | resource.TestCheckResourceAttr(resourceName, "endpoint_url", "https://example.com/endpoint"), 100 | resource.TestCheckResourceAttr(resourceName, "provider_type", "slack"), 101 | resource.TestCheckTypeSetElemAttr(resourceName, "subscriptions.*", "userNeedsApproval"), 102 | resource.TestCheckTypeSetElemAttr(resourceName, "subscriptions.*", "nodeCreated"), 103 | resource.TestCheckResourceAttrSet(resourceName, "secret"), 104 | ), 105 | }, 106 | { 107 | Config: testWebhookUpdate, 108 | Check: resource.ComposeTestCheckFunc( 109 | checkResourceRemoteProperties( 110 | resourceName, 111 | checkProperties([]tailscale.WebhookSubscriptionType{ 112 | tailscale.WebhookNodeCreated, 113 | tailscale.WebhookUserRoleUpdated, 114 | tailscale.WebhookUserSuspended, 115 | }), 116 | ), 117 | resource.TestCheckResourceAttr(resourceName, "endpoint_url", "https://example.com/endpoint"), 118 | resource.TestCheckResourceAttr(resourceName, "provider_type", "slack"), 119 | resource.TestCheckTypeSetElemAttr(resourceName, "subscriptions.*", "nodeCreated"), 120 | resource.TestCheckTypeSetElemAttr(resourceName, "subscriptions.*", "userSuspended"), 121 | resource.TestCheckTypeSetElemAttr(resourceName, "subscriptions.*", "userRoleUpdated"), 122 | resource.TestCheckResourceAttrSet(resourceName, "secret"), 123 | ), 124 | }, 125 | { 126 | ResourceName: resourceName, 127 | ImportState: true, 128 | ImportStateVerify: true, 129 | ImportStateVerifyIgnore: []string{"secret"}, 130 | }, 131 | }, 132 | }) 133 | } 134 | -------------------------------------------------------------------------------- /tailscale/tailscale_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | package tailscale 5 | 6 | import ( 7 | "bytes" 8 | "encoding/json" 9 | "fmt" 10 | "io" 11 | "net" 12 | "net/http" 13 | "net/url" 14 | "testing" 15 | 16 | "github.com/stretchr/testify/assert" 17 | "github.com/stretchr/testify/require" 18 | 19 | "tailscale.com/client/tailscale/v2" 20 | ) 21 | 22 | type TestServer struct { 23 | t *testing.T 24 | 25 | Method string 26 | Path string 27 | Body *bytes.Buffer 28 | 29 | ResponseCode int 30 | ResponseBody interface{} 31 | } 32 | 33 | func NewTestHarness(t *testing.T) (*tailscale.Client, *TestServer) { 34 | t.Helper() 35 | 36 | testServer := &TestServer{ 37 | t: t, 38 | } 39 | 40 | mux := http.NewServeMux() 41 | mux.Handle("/", testServer) 42 | svr := &http.Server{ 43 | Handler: mux, 44 | } 45 | 46 | // Start a listener on a random port 47 | listener, err := net.Listen("tcp", ":0") 48 | assert.NoError(t, err) 49 | 50 | go func() { 51 | _ = svr.Serve(listener) 52 | }() 53 | 54 | // When the test is over, close the server 55 | t.Cleanup(func() { 56 | assert.NoError(t, svr.Close()) 57 | }) 58 | 59 | baseURL := fmt.Sprintf("http://localhost:%v", listener.Addr().(*net.TCPAddr).Port) 60 | parsedBaseURL, err := url.Parse(baseURL) 61 | require.NoError(t, err) 62 | client := &tailscale.Client{ 63 | BaseURL: parsedBaseURL, 64 | APIKey: "not-a-real-key", 65 | Tailnet: "example.com", 66 | } 67 | 68 | return client, testServer 69 | } 70 | 71 | func (t *TestServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { 72 | t.Method = r.Method 73 | t.Path = r.URL.Path 74 | 75 | t.Body = bytes.NewBuffer([]byte{}) 76 | _, err := io.Copy(t.Body, r.Body) 77 | assert.NoError(t.t, err) 78 | 79 | w.WriteHeader(t.ResponseCode) 80 | switch body := t.ResponseBody.(type) { 81 | case []byte: 82 | _, err := w.Write(body) 83 | assert.NoError(t.t, err) 84 | default: 85 | assert.NoError(t.t, json.NewEncoder(w).Encode(body)) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /templates/resources/tailnet_key.md.tmpl: -------------------------------------------------------------------------------- 1 | --- 2 | page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" 3 | subcategory: "" 4 | description: |- 5 | {{ .Description | plainmarkdown | trimspace | prefixlines " " }} 6 | --- 7 | 8 | # {{.Name}} ({{.Type}}) 9 | 10 | {{ .Description | trimspace }} 11 | 12 | ## Example Usage 13 | 14 | {{ tffile (printf "examples/resources/%s/resource.tf" .Name)}} 15 | 16 | {{ .SchemaMarkdown | trimspace }} 17 | 18 | ## Import 19 | 20 | Import is supported using the following syntax: 21 | 22 | ```shell 23 | # Tailnet key can be imported using the key id, e.g., 24 | terraform import tailscale_tailnet_key.sample_key 123456789 25 | ``` 26 | 27 | -> ** Note ** the `key` attribute will not be populated on import as this attribute is only populated 28 | on resource creation. 29 | 30 | -------------------------------------------------------------------------------- /tools.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) David Bond, Tailscale Inc, & Contributors 2 | // SPDX-License-Identifier: MIT 3 | 4 | //go:build tools 5 | 6 | package tools 7 | 8 | import ( 9 | _ "github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs" 10 | _ "golang.org/x/tools/cmd/goimports" 11 | ) 12 | --------------------------------------------------------------------------------