| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194 |
- // Copyright (c) Tailscale Inc & AUTHORS
- // SPDX-License-Identifier: BSD-3-Clause
- //go:build !(ios || android || js)
- // Package cloudinfo provides cloud metadata utilities.
- package cloudinfo
- import (
- "context"
- "errors"
- "fmt"
- "io"
- "net"
- "net/http"
- "net/netip"
- "slices"
- "strings"
- "time"
- "tailscale.com/feature/buildfeatures"
- "tailscale.com/types/logger"
- "tailscale.com/util/cloudenv"
- )
- const maxCloudInfoWait = 2 * time.Second
- // CloudInfo holds state used in querying instance metadata (IMDS) endpoints.
- 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
- }
- // New constructs a new [*CloudInfo] that will log to the provided logger instance.
- func New(logf logger.Logf) *CloudInfo {
- if !buildfeatures.HasCloud {
- return nil
- }
- 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.
- //
- // Currently supports only AWS.
- func (ci *CloudInfo) GetPublicIPs(ctx context.Context) ([]netip.Addr, error) {
- if !buildfeatures.HasCloud {
- return nil, nil
- }
- 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
- }
|