|
|
@@ -0,0 +1,182 @@
|
|
|
+// Copyright (c) Tailscale Inc & AUTHORS
|
|
|
+// SPDX-License-Identifier: BSD-3-Clause
|
|
|
+
|
|
|
+//go:build !(ios || android || js)
|
|
|
+
|
|
|
+package magicsock
|
|
|
+
|
|
|
+import (
|
|
|
+ "context"
|
|
|
+ "errors"
|
|
|
+ "fmt"
|
|
|
+ "io"
|
|
|
+ "net"
|
|
|
+ "net/http"
|
|
|
+ "net/netip"
|
|
|
+ "slices"
|
|
|
+ "strings"
|
|
|
+ "time"
|
|
|
+
|
|
|
+ "tailscale.com/types/logger"
|
|
|
+ "tailscale.com/util/cloudenv"
|
|
|
+)
|
|
|
+
|
|
|
+const maxCloudInfoWait = 2 * time.Second
|
|
|
+
|
|
|
+type cloudInfo struct {
|
|
|
+ client http.Client
|
|
|
+ logf logger.Logf
|
|
|
+
|
|
|
+ // The following parameters are fixed for the lifetime of the cloudInfo
|
|
|
+ // object, but are used for testing.
|
|
|
+ cloud cloudenv.Cloud
|
|
|
+ endpoint string
|
|
|
+}
|
|
|
+
|
|
|
+func newCloudInfo(logf logger.Logf) *cloudInfo {
|
|
|
+ tr := &http.Transport{
|
|
|
+ DisableKeepAlives: true,
|
|
|
+ Dial: (&net.Dialer{
|
|
|
+ Timeout: maxCloudInfoWait,
|
|
|
+ }).Dial,
|
|
|
+ }
|
|
|
+
|
|
|
+ return &cloudInfo{
|
|
|
+ client: http.Client{Transport: tr},
|
|
|
+ logf: logf,
|
|
|
+ cloud: cloudenv.Get(),
|
|
|
+ endpoint: "http://" + cloudenv.CommonNonRoutableMetadataIP,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// GetPublicIPs returns any public IPs attached to the current cloud instance,
|
|
|
+// if the tailscaled process is running in a known cloud and there are any such
|
|
|
+// IPs present.
|
|
|
+func (ci *cloudInfo) GetPublicIPs(ctx context.Context) ([]netip.Addr, error) {
|
|
|
+ switch ci.cloud {
|
|
|
+ case cloudenv.AWS:
|
|
|
+ ret, err := ci.getAWS(ctx)
|
|
|
+ ci.logf("[v1] cloudinfo.GetPublicIPs: AWS: %v, %v", ret, err)
|
|
|
+ return ret, err
|
|
|
+ }
|
|
|
+
|
|
|
+ return nil, nil
|
|
|
+}
|
|
|
+
|
|
|
+// getAWSMetadata makes a request to the AWS metadata service at the given
|
|
|
+// path, authenticating with the provided IMDSv2 token. The returned metadata
|
|
|
+// is split by newline and returned as a slice.
|
|
|
+func (ci *cloudInfo) getAWSMetadata(ctx context.Context, token, path string) ([]string, error) {
|
|
|
+ req, err := http.NewRequestWithContext(ctx, "GET", ci.endpoint+path, nil)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("creating request to %q: %w", path, err)
|
|
|
+ }
|
|
|
+ req.Header.Set("X-aws-ec2-metadata-token", token)
|
|
|
+
|
|
|
+ resp, err := ci.client.Do(req)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("making request to metadata service %q: %w", path, err)
|
|
|
+ }
|
|
|
+ defer resp.Body.Close()
|
|
|
+
|
|
|
+ switch resp.StatusCode {
|
|
|
+ case http.StatusOK:
|
|
|
+ // Good
|
|
|
+ case http.StatusNotFound:
|
|
|
+ // Nothing found, but this isn't an error; just return
|
|
|
+ return nil, nil
|
|
|
+ default:
|
|
|
+ return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
|
|
+ }
|
|
|
+
|
|
|
+ body, err := io.ReadAll(resp.Body)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("reading response body for %q: %w", path, err)
|
|
|
+ }
|
|
|
+
|
|
|
+ return strings.Split(strings.TrimSpace(string(body)), "\n"), nil
|
|
|
+}
|
|
|
+
|
|
|
+// getAWS returns all public IPv4 and IPv6 addresses present in the AWS instance metadata.
|
|
|
+func (ci *cloudInfo) getAWS(ctx context.Context) ([]netip.Addr, error) {
|
|
|
+ ctx, cancel := context.WithTimeout(ctx, maxCloudInfoWait)
|
|
|
+ defer cancel()
|
|
|
+
|
|
|
+ // Get a token so we can query the metadata service.
|
|
|
+ req, err := http.NewRequestWithContext(ctx, "PUT", ci.endpoint+"/latest/api/token", nil)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("creating token request: %w", err)
|
|
|
+ }
|
|
|
+ req.Header.Set("X-Aws-Ec2-Metadata-Token-Ttl-Seconds", "10")
|
|
|
+
|
|
|
+ resp, err := ci.client.Do(req)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("making token request to metadata service: %w", err)
|
|
|
+ }
|
|
|
+ body, err := io.ReadAll(resp.Body)
|
|
|
+ resp.Body.Close()
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("reading token response body: %w", err)
|
|
|
+ }
|
|
|
+ token := string(body)
|
|
|
+
|
|
|
+ server := resp.Header.Get("Server")
|
|
|
+ if server != "EC2ws" {
|
|
|
+ return nil, fmt.Errorf("unexpected server header: %q", server)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Iterate over all interfaces and get their public IP addresses, both IPv4 and IPv6.
|
|
|
+ macAddrs, err := ci.getAWSMetadata(ctx, token, "/latest/meta-data/network/interfaces/macs/")
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("getting interface MAC addresses: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ var (
|
|
|
+ addrs []netip.Addr
|
|
|
+ errs []error
|
|
|
+ )
|
|
|
+
|
|
|
+ addAddr := func(addr string) {
|
|
|
+ ip, err := netip.ParseAddr(addr)
|
|
|
+ if err != nil {
|
|
|
+ errs = append(errs, fmt.Errorf("parsing IP address %q: %w", addr, err))
|
|
|
+ return
|
|
|
+ }
|
|
|
+ addrs = append(addrs, ip)
|
|
|
+ }
|
|
|
+ for _, mac := range macAddrs {
|
|
|
+ ips, err := ci.getAWSMetadata(ctx, token, "/latest/meta-data/network/interfaces/macs/"+mac+"/public-ipv4s")
|
|
|
+ if err != nil {
|
|
|
+ errs = append(errs, fmt.Errorf("getting IPv4 addresses for %q: %w", mac, err))
|
|
|
+ continue
|
|
|
+ }
|
|
|
+
|
|
|
+ for _, ip := range ips {
|
|
|
+ addAddr(ip)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Try querying for IPv6 addresses.
|
|
|
+ ips, err = ci.getAWSMetadata(ctx, token, "/latest/meta-data/network/interfaces/macs/"+mac+"/ipv6s")
|
|
|
+ if err != nil {
|
|
|
+ errs = append(errs, fmt.Errorf("getting IPv6 addresses for %q: %w", mac, err))
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ for _, ip := range ips {
|
|
|
+ addAddr(ip)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Sort the returned addresses for determinism.
|
|
|
+ slices.SortFunc(addrs, func(a, b netip.Addr) int {
|
|
|
+ return a.Compare(b)
|
|
|
+ })
|
|
|
+
|
|
|
+ // Preferentially return any addresses we found, even if there were errors.
|
|
|
+ if len(addrs) > 0 {
|
|
|
+ return addrs, nil
|
|
|
+ }
|
|
|
+ if len(errs) > 0 {
|
|
|
+ return nil, fmt.Errorf("getting IP addresses: %w", errors.Join(errs...))
|
|
|
+ }
|
|
|
+ return nil, nil
|
|
|
+}
|