diff --git a/util/cloudenv/cloudenv.go b/util/cloudenv/cloudenv.go index 3c86339e4..4a07bdeeb 100644 --- a/util/cloudenv/cloudenv.go +++ b/util/cloudenv/cloudenv.go @@ -8,6 +8,7 @@ import ( "context" "encoding/json" "log" + "math/rand" "net" "net/http" "os" @@ -16,6 +17,7 @@ import ( "time" "tailscale.com/syncs" + "tailscale.com/types/lazy" ) // CommonNonRoutableMetadataIP is the IP address of the metadata server @@ -40,9 +42,10 @@ const AzureResolverIP = "168.63.129.16" type Cloud string const ( - AWS = Cloud("aws") // Amazon Web Services (EC2 in particular) - Azure = Cloud("azure") // Microsoft Azure - GCP = Cloud("gcp") // Google Cloud + AWS = Cloud("aws") // Amazon Web Services (EC2 in particular) + Azure = Cloud("azure") // Microsoft Azure + GCP = Cloud("gcp") // Google Cloud + DigitalOcean = Cloud("digitalocean") // DigitalOcean ) // ResolverIP returns the cloud host's recursive DNS server or the @@ -55,10 +58,27 @@ func (c Cloud) ResolverIP() string { return AWSResolverIP case Azure: return AzureResolverIP + case DigitalOcean: + return getDigitalOceanResolver() } return "" } +var ( + // https://docs.digitalocean.com/support/check-your-droplets-network-configuration/ + digitalOceanResolvers = []string{"67.207.67.2", "67.207.67.3"} + digitalOceanResolver lazy.SyncValue[string] +) + +func getDigitalOceanResolver() string { + // Randomly select one of the available resolvers so we don't overload + // one of them by sending all traffic there. + return digitalOceanResolver.Get(func() string { + rn := rand.New(rand.NewSource(time.Now().UnixNano())) + return digitalOceanResolvers[rn.Intn(len(digitalOceanResolvers))] + }) +} + // HasInternalTLD reports whether c is a cloud environment // whose ResolverIP serves *.internal records. func (c Cloud) HasInternalTLD() bool { @@ -98,6 +118,12 @@ func getCloud() Cloud { return AWS } + sysVendor := readFileTrimmed("/sys/class/dmi/id/sys_vendor") + if sysVendor == "DigitalOcean" { + return DigitalOcean + } + // TODO(andrew): "Vultr" is also valid if we need it + prod := readFileTrimmed("/sys/class/dmi/id/product_name") if prod == "Google Compute Engine" { return GCP @@ -109,6 +135,7 @@ func getCloud() Cloud { // Azure, or maybe all Hyper-V? hitMetadata = true } + default: // TODO(bradfitz): use Win32_SystemEnclosure from WMI or something on // Windows to see if it's a physical machine and skip the cloud check diff --git a/util/cloudenv/cloudenv_test.go b/util/cloudenv/cloudenv_test.go new file mode 100644 index 000000000..c4486b284 --- /dev/null +++ b/util/cloudenv/cloudenv_test.go @@ -0,0 +1,29 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package cloudenv + +import ( + "flag" + "net/netip" + "testing" +) + +var extNetwork = flag.Bool("use-external-network", false, "use the external network in tests") + +// Informational only since we can run tests in a variety of places. +func TestGetCloud(t *testing.T) { + if !*extNetwork { + t.Skip("skipping test without --use-external-network") + } + + cloud := getCloud() + t.Logf("Cloud: %q", cloud) + t.Logf("Cloud.HasInternalTLD: %v", cloud.HasInternalTLD()) + t.Logf("Cloud.ResolverIP: %q", cloud.ResolverIP()) +} + +func TestGetDigitalOceanResolver(t *testing.T) { + addr := netip.MustParseAddr(getDigitalOceanResolver()) + t.Logf("got: %v", addr) +}