diff --git a/docs/resources/contacts.md b/docs/resources/contacts.md
new file mode 100644
index 00000000..ea2b8245
--- /dev/null
+++ b/docs/resources/contacts.md
@@ -0,0 +1,77 @@
+---
+# generated by /~https://github.com/hashicorp/terraform-plugin-docs
+page_title: "tailscale_contacts Resource - terraform-provider-tailscale"
+subcategory: ""
+description: |-
+ The contacts resource allows you to configure contact details for your Tailscale network. See https://tailscale.com/kb/1224/contact-preferences for more information.
+ Destroying this resource does not unset or modify values in the tailscale control plane, and simply removes the resource from Terraform state.
+---
+
+# tailscale_contacts (Resource)
+
+The contacts resource allows you to configure contact details for your Tailscale network. See https://tailscale.com/kb/1224/contact-preferences for more information.
+
+Destroying this resource does not unset or modify values in the tailscale control plane, and simply removes the resource from Terraform state.
+
+## Example Usage
+
+```terraform
+resource "tailscale_contacts" "sample_contacts" {
+ account {
+ email = "account@example.com"
+ }
+
+ support {
+ email = "support@example.com"
+ }
+
+ security {
+ email = "security@example.com"
+ }
+}
+```
+
+
+## Schema
+
+### Required
+
+- `account` (Block Set, Min: 1, Max: 1) Configuration for communications about important changes to your tailnet (see [below for nested schema](#nestedblock--account))
+- `security` (Block Set, Min: 1, Max: 1) Configuration for communications about security issues affecting your tailnet (see [below for nested schema](#nestedblock--security))
+- `support` (Block Set, Min: 1, Max: 1) Configuration for communications about misconfigurations in your tailnet (see [below for nested schema](#nestedblock--support))
+
+### Read-Only
+
+- `id` (String) The ID of this resource.
+
+
+### Nested Schema for `account`
+
+Required:
+
+- `email` (String) Email address to send communications to
+
+
+
+### Nested Schema for `security`
+
+Required:
+
+- `email` (String) Email address to send communications to
+
+
+
+### Nested Schema for `support`
+
+Required:
+
+- `email` (String) Email address to send communications to
+
+## Import
+
+Import is supported using the following syntax:
+
+```shell
+# ID doesn't matter.
+terraform import tailscale_contacts.sample_contacts contacts
+```
diff --git a/examples/resources/tailscale_contacts/import.sh b/examples/resources/tailscale_contacts/import.sh
new file mode 100644
index 00000000..7b65252e
--- /dev/null
+++ b/examples/resources/tailscale_contacts/import.sh
@@ -0,0 +1,2 @@
+# ID doesn't matter.
+terraform import tailscale_contacts.sample_contacts contacts
diff --git a/examples/resources/tailscale_contacts/resource.tf b/examples/resources/tailscale_contacts/resource.tf
new file mode 100644
index 00000000..e6913c37
--- /dev/null
+++ b/examples/resources/tailscale_contacts/resource.tf
@@ -0,0 +1,13 @@
+resource "tailscale_contacts" "sample_contacts" {
+ account {
+ email = "account@example.com"
+ }
+
+ support {
+ email = "support@example.com"
+ }
+
+ security {
+ email = "security@example.com"
+ }
+}
diff --git a/go.mod b/go.mod
index 8ba8f3e1..8486fbb8 100644
--- a/go.mod
+++ b/go.mod
@@ -9,7 +9,7 @@ require (
github.com/hashicorp/terraform-plugin-sdk/v2 v2.34.0
github.com/stretchr/testify v1.9.0
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a
- github.com/tailscale/tailscale-client-go v1.17.1-0.20240724064152-5d834701bd85
+ github.com/tailscale/tailscale-client-go v1.17.1-0.20240729175651-90a1e935cc19
golang.org/x/tools v0.23.0
tailscale.com v1.70.0
)
diff --git a/go.sum b/go.sum
index 53f02daf..0a2b6ace 100644
--- a/go.sum
+++ b/go.sum
@@ -188,10 +188,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw=
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8=
-github.com/tailscale/tailscale-client-go v1.17.1-0.20240718200212-ff9b01c0d472 h1:BLqo/AEtgxdsqPC/WU0lNDwMLjQwvkXappShYmZrZ34=
-github.com/tailscale/tailscale-client-go v1.17.1-0.20240718200212-ff9b01c0d472/go.mod h1:jbwJyHniK3nyLttwcDTXnfdDQEnADvc4VMOP8hZWnR0=
-github.com/tailscale/tailscale-client-go v1.17.1-0.20240724064152-5d834701bd85 h1:F6nQg/GLWZDx27RPiAkJwCR3LYKYxBquUuiPcnuLesA=
-github.com/tailscale/tailscale-client-go v1.17.1-0.20240724064152-5d834701bd85/go.mod h1:jbwJyHniK3nyLttwcDTXnfdDQEnADvc4VMOP8hZWnR0=
+github.com/tailscale/tailscale-client-go v1.17.1-0.20240729175651-90a1e935cc19 h1:fRLv1yZH1ueL1cnpLhOnOymoBfMCIviCn0e0VkAjkK4=
+github.com/tailscale/tailscale-client-go v1.17.1-0.20240729175651-90a1e935cc19/go.mod h1:jbwJyHniK3nyLttwcDTXnfdDQEnADvc4VMOP8hZWnR0=
github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI=
github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
diff --git a/tailscale/provider.go b/tailscale/provider.go
index 9cab53a9..8b3f5c35 100644
--- a/tailscale/provider.go
+++ b/tailscale/provider.go
@@ -84,6 +84,7 @@ func Provider(options ...ProviderOption) *schema.Provider {
"tailscale_device_tags": resourceDeviceTags(),
"tailscale_device_key": resourceDeviceKey(),
"tailscale_webhook": resourceWebhook(),
+ "tailscale_contacts": resourceContacts(),
},
DataSourcesMap: map[string]*schema.Resource{
"tailscale_device": dataSourceDevice(),
diff --git a/tailscale/resource_contacts.go b/tailscale/resource_contacts.go
new file mode 100644
index 00000000..1c6bbefd
--- /dev/null
+++ b/tailscale/resource_contacts.go
@@ -0,0 +1,181 @@
+package tailscale
+
+import (
+ "context"
+
+ "github.com/hashicorp/terraform-plugin-sdk/v2/diag"
+ "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+
+ "github.com/tailscale/tailscale-client-go/tailscale"
+)
+
+const resourceContactsDescription = `The contacts resource allows you to configure contact details for your Tailscale network. See https://tailscale.com/kb/1224/contact-preferences for more information.
+
+Destroying this resource does not unset or modify values in the tailscale control plane, and simply removes the resource from Terraform state.
+`
+
+func resourceContacts() *schema.Resource {
+ return &schema.Resource{
+ Description: resourceContactsDescription,
+ ReadContext: resourceContactsRead,
+ CreateContext: resourceContactsCreate,
+ UpdateContext: resourceContactsUpdate,
+ DeleteContext: resourceContactsDelete,
+ Importer: &schema.ResourceImporter{
+ StateContext: schema.ImportStatePassthroughContext,
+ },
+ Schema: map[string]*schema.Schema{
+ "account": {
+ Type: schema.TypeSet,
+ Description: "Configuration for communications about important changes to your tailnet",
+ Required: true,
+ MaxItems: 1,
+ Elem: &schema.Resource{
+ Schema: map[string]*schema.Schema{
+ "email": {
+ Type: schema.TypeString,
+ Description: "Email address to send communications to",
+ Required: true,
+ },
+ },
+ },
+ },
+ "support": {
+ Type: schema.TypeSet,
+ Description: "Configuration for communications about misconfigurations in your tailnet",
+ Required: true,
+ MaxItems: 1,
+ Elem: &schema.Resource{
+ Schema: map[string]*schema.Schema{
+ "email": {
+ Type: schema.TypeString,
+ Description: "Email address to send communications to",
+ Required: true,
+ },
+ },
+ },
+ },
+ "security": {
+ Type: schema.TypeSet,
+ Description: "Configuration for communications about security issues affecting your tailnet",
+ Required: true,
+ MaxItems: 1,
+ Elem: &schema.Resource{
+ Schema: map[string]*schema.Schema{
+ "email": {
+ Type: schema.TypeString,
+ Description: "Email address to send communications to",
+ Required: true,
+ },
+ },
+ },
+ },
+ },
+ }
+}
+
+func resourceContactsCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
+ client := m.(*tailscale.Client)
+
+ if diagErr := updateContact(ctx, client, d, tailscale.ContactAccount); diagErr != nil {
+ return diagErr
+ }
+
+ if diagErr := updateContact(ctx, client, d, tailscale.ContactSupport); diagErr != nil {
+ return diagErr
+ }
+
+ if diagErr := updateContact(ctx, client, d, tailscale.ContactSecurity); diagErr != nil {
+ return diagErr
+ }
+
+ d.SetId(createUUID())
+ return resourceContactsRead(ctx, d, m)
+}
+
+func resourceContactsRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
+ client := m.(*tailscale.Client)
+
+ contacts, err := client.Contacts(ctx)
+ if err != nil {
+ return diagnosticsError(err, "Failed to fetch contacts")
+ }
+
+ if err = d.Set("account", buildContactMap(contacts.Account)); err != nil {
+ return diagnosticsError(err, "Failed to set account field")
+ }
+
+ if err = d.Set("support", buildContactMap(contacts.Support)); err != nil {
+ return diagnosticsError(err, "Failed to set support field")
+ }
+
+ if err = d.Set("security", buildContactMap(contacts.Security)); err != nil {
+ return diagnosticsError(err, "Failed to set security field")
+ }
+
+ return nil
+}
+
+func resourceContactsUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
+ client := m.(*tailscale.Client)
+
+ if d.HasChange("account") {
+ if diagErr := updateContact(ctx, client, d, tailscale.ContactAccount); diagErr != nil {
+ return diagErr
+ }
+ }
+
+ if d.HasChange("support") {
+ if diagErr := updateContact(ctx, client, d, tailscale.ContactSupport); diagErr != nil {
+ return diagErr
+ }
+ }
+
+ if d.HasChange("security") {
+ if diagErr := updateContact(ctx, client, d, tailscale.ContactSecurity); diagErr != nil {
+ return diagErr
+ }
+ }
+
+ return resourceContactsRead(ctx, d, m)
+}
+
+func resourceContactsDelete(_ context.Context, _ *schema.ResourceData, _ interface{}) diag.Diagnostics {
+ // Deleting is a no-op since we cannot have unset contact information.
+ // Deletion in this context is simply removing from terraform state.
+ const diagDetail = `This resource has been successfully destroyed, but values in tailscale will remain set.
+See https://tailscale.com/kb/1224/contact-preferences to learn more.`
+
+ return diag.Diagnostics{
+ diag.Diagnostic{
+ Severity: diag.Warning,
+ Summary: "Destroying tailscale_contacts does not unset contact values on tailscale",
+ Detail: diagDetail,
+ },
+ }
+}
+
+// buildContactMap transforms a tailscale.Contact into an equivalnet single element
+// slice of map[string]interface{} so that it can be set on a schema.TypeSet property
+// in the resource.
+func buildContactMap(contact tailscale.Contact) []map[string]interface{} {
+ return []map[string]interface{}{
+ {
+ "email": contact.Email,
+ },
+ }
+}
+
+// updateContact updates the contact specified by the tailscale.ContactType by
+// reading the resource property with the correct name and using it to build a
+// request to the underlying client.
+func updateContact(ctx context.Context, client *tailscale.Client, d *schema.ResourceData, contactType tailscale.ContactType) diag.Diagnostics {
+ contact := d.Get(string(contactType)).(*schema.Set).List()
+ contactEmail := contact[0].(map[string]interface{})["email"].(string)
+
+ if err := client.UpdateContact(ctx, contactType, tailscale.UpdateContactRequest{Email: &contactEmail}); err != nil {
+ return diagnosticsError(err, "Failed to create contacts")
+ }
+
+ return nil
+}
diff --git a/tailscale/resource_contacts_test.go b/tailscale/resource_contacts_test.go
new file mode 100644
index 00000000..4ed40cb7
--- /dev/null
+++ b/tailscale/resource_contacts_test.go
@@ -0,0 +1,220 @@
+package tailscale_test
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
+ "github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
+
+ "github.com/tailscale/tailscale-client-go/tailscale"
+)
+
+const testContactsBasic = `
+ resource "tailscale_contacts" "test_contacts" {
+ account {
+ email = "account@example.com"
+ }
+
+ support {
+ email = "support@example.com"
+ }
+
+ security {
+ email = "security@example.com"
+ }
+ }`
+
+const testContactsUpdated = `
+ resource "tailscale_contacts" "test_contacts" {
+ account {
+ email = "otheraccount@example.com"
+ }
+
+ support {
+ email = "support@example.com"
+ }
+
+ security {
+ email = "security2@example.com"
+ }
+ }`
+
+var expectedContactsBasic = &tailscale.Contacts{
+ Account: tailscale.Contact{
+ Email: "account@example.com",
+ },
+ Support: tailscale.Contact{
+ Email: "support@example.com",
+ },
+ Security: tailscale.Contact{
+ Email: "security@example.com",
+ },
+}
+
+var expectedContactsUpdated = &tailscale.Contacts{
+ Account: tailscale.Contact{
+ Email: "otheraccount@example.com",
+ },
+ Support: tailscale.Contact{
+ Email: "support@example.com",
+ },
+ Security: tailscale.Contact{
+ Email: "security2@example.com",
+ },
+}
+
+func TestAccTailscaleContacts_Basic(t *testing.T) {
+ contacts := &tailscale.Contacts{}
+
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { testAccPreCheck(t) },
+ ProviderFactories: testAccProviderFactories(t),
+ CheckDestroy: testAccCheckContactsDestroyBasic,
+ Steps: []resource.TestStep{
+ {
+ Config: testContactsBasic,
+ Check: resource.ComposeTestCheckFunc(
+ testAccCheckContactsExists("tailscale_contacts.test_contacts", contacts),
+ testAccCheckContactsPropertiesBasic(contacts),
+ resource.TestCheckResourceAttr("tailscale_contacts.test_contacts", "account.0.email", "account@example.com"),
+ resource.TestCheckResourceAttr("tailscale_contacts.test_contacts", "support.0.email", "support@example.com"),
+ resource.TestCheckResourceAttr("tailscale_contacts.test_contacts", "security.0.email", "security@example.com"),
+ ),
+ },
+ {
+ ResourceName: "tailscale_contacts.test_contacts",
+ ImportState: true,
+ ImportStateVerify: true,
+ },
+ },
+ })
+}
+
+func TestAccTailscaleContacts_Update(t *testing.T) {
+ contacts := &tailscale.Contacts{}
+
+ resource.Test(t, resource.TestCase{
+ PreCheck: func() { testAccPreCheck(t) },
+ ProviderFactories: testAccProviderFactories(t),
+ CheckDestroy: testAccCheckContactsDestroyUpdated,
+ Steps: []resource.TestStep{
+ {
+ Config: testContactsBasic,
+ Check: resource.ComposeTestCheckFunc(
+ testAccCheckContactsExists("tailscale_contacts.test_contacts", contacts),
+ testAccCheckContactsPropertiesBasic(contacts),
+ resource.TestCheckResourceAttr("tailscale_contacts.test_contacts", "account.0.email", "account@example.com"),
+ resource.TestCheckResourceAttr("tailscale_contacts.test_contacts", "support.0.email", "support@example.com"),
+ resource.TestCheckResourceAttr("tailscale_contacts.test_contacts", "security.0.email", "security@example.com"),
+ ),
+ },
+ {
+ Config: testContactsUpdated,
+ Check: resource.ComposeTestCheckFunc(
+ testAccCheckContactsExists("tailscale_contacts.test_contacts", contacts),
+ testAccCheckContactsPropertiesUpdated(contacts),
+ resource.TestCheckResourceAttr("tailscale_contacts.test_contacts", "account.0.email", "otheraccount@example.com"),
+ resource.TestCheckResourceAttr("tailscale_contacts.test_contacts", "support.0.email", "support@example.com"),
+ resource.TestCheckResourceAttr("tailscale_contacts.test_contacts", "security.0.email", "security2@example.com"),
+ ),
+ },
+ {
+ ResourceName: "tailscale_contacts.test_contacts",
+ ImportState: true,
+ ImportStateVerify: true,
+ },
+ },
+ })
+}
+
+func testAccCheckContactsExists(resourceName string, contacts *tailscale.Contacts) resource.TestCheckFunc {
+ return func(s *terraform.State) error {
+ rs, ok := s.RootModule().Resources[resourceName]
+ if !ok {
+ return fmt.Errorf("resource not found: %s", resourceName)
+ }
+
+ if rs.Primary.ID == "" {
+ return fmt.Errorf("resource has no ID set")
+ }
+
+ client := testAccProvider.Meta().(*tailscale.Client)
+ out, err := client.Contacts(context.Background())
+ if err != nil {
+ return err
+ }
+
+ *contacts = *out
+ return nil
+ }
+}
+
+func testAccCheckContactsPropertiesBasic(contacts *tailscale.Contacts) resource.TestCheckFunc {
+ return func(s *terraform.State) error {
+ if err := checkContacts(contacts, expectedContactsBasic); err != nil {
+ return err
+ }
+
+ return nil
+ }
+}
+
+func testAccCheckContactsPropertiesUpdated(contacts *tailscale.Contacts) resource.TestCheckFunc {
+ return func(s *terraform.State) error {
+ if err := checkContacts(contacts, expectedContactsUpdated); err != nil {
+ return err
+ }
+
+ return nil
+ }
+}
+
+func testAccCheckContactsDestroyBasic(s *terraform.State) error {
+ return testAccCheckContactsDestroy(s, expectedContactsBasic)
+}
+
+func testAccCheckContactsDestroyUpdated(s *terraform.State) error {
+ return testAccCheckContactsDestroy(s, expectedContactsUpdated)
+}
+
+func testAccCheckContactsDestroy(s *terraform.State, expectedContacts *tailscale.Contacts) error {
+ client := testAccProvider.Meta().(*tailscale.Client)
+
+ for _, rs := range s.RootModule().Resources {
+ if rs.Type != "tailscale_contacts" {
+ continue
+ }
+
+ if rs.Primary.ID == "" {
+ return fmt.Errorf("resource has no ID set")
+ }
+
+ // Contacts are not destroyed in the control plane upon resource deletion since
+ // contacts cannot be empty.
+ contacts, err := client.Contacts(context.Background())
+ if err != nil {
+ return fmt.Errorf("expected contacts to still exist")
+ }
+
+ return checkContacts(contacts, expectedContacts)
+ }
+ return nil
+}
+
+func checkContacts(contacts *tailscale.Contacts, expectedContacts *tailscale.Contacts) error {
+ if contacts.Account.Email != expectedContacts.Account.Email {
+ return fmt.Errorf("bad account email, expected %q, got %q", expectedContacts.Account.Email, contacts.Account.Email)
+ }
+
+ if contacts.Support.Email != expectedContacts.Support.Email {
+ return fmt.Errorf("bad support email, expected %q, got %q", expectedContacts.Support.Email, contacts.Support.Email)
+ }
+
+ if contacts.Security.Email != expectedContacts.Security.Email {
+ return fmt.Errorf("bad security email, expected %q, got %q", expectedContacts.Security.Email, contacts.Security.Email)
+ }
+
+ return nil
+}