wgengine/magicsock: extract IMDS utilities into a standalone package (#18334)

Moves magicksock.cloudInfo into util/cloudinfo with minimal changes.

Updates #17796

Change-Id: I83f32473b9180074d5cdbf00fa31e5b3f579f189

Signed-off-by: Alex Valiushko <alexvaliushko@tailscale.com>
This commit is contained in:
Alex Valiushko
2026-01-06 21:58:52 -08:00
committed by GitHub
parent a662c541ab
commit 4c3cf8bb11
11 changed files with 50 additions and 35 deletions
+194
View File
@@ -0,0 +1,194 @@
// 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
}
+26
View File
@@ -0,0 +1,26 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build ios || android || js
package cloudinfo
import (
"context"
"net/netip"
"tailscale.com/types/logger"
)
// CloudInfo is not available in mobile and JS targets.
type CloudInfo struct{}
// New construct a no-op CloudInfo stub.
func New(_ logger.Logf) *CloudInfo {
return &CloudInfo{}
}
// GetPublicIPs always returns nil slice and error.
func (ci *CloudInfo) GetPublicIPs(_ context.Context) ([]netip.Addr, error) {
return nil, nil
}
+123
View File
@@ -0,0 +1,123 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package cloudinfo
import (
"context"
"net/http"
"net/http/httptest"
"net/netip"
"slices"
"testing"
"tailscale.com/util/cloudenv"
)
func TestCloudInfo_AWS(t *testing.T) {
const (
mac1 = "06:1d:00:00:00:00"
mac2 = "06:1d:00:00:00:01"
publicV4 = "1.2.3.4"
otherV4_1 = "5.6.7.8"
otherV4_2 = "11.12.13.14"
v6addr = "2001:db8::1"
macsPrefix = "/latest/meta-data/network/interfaces/macs/"
)
// Launch a fake AWS IMDS server
fake := &fakeIMDS{
tb: t,
paths: map[string]string{
macsPrefix: mac1 + "\n" + mac2,
// This is the "main" public IP address for the instance
macsPrefix + mac1 + "/public-ipv4s": publicV4,
// There's another interface with two public IPs
// attached to it and an IPv6 address, all of which we
// should discover.
macsPrefix + mac2 + "/public-ipv4s": otherV4_1 + "\n" + otherV4_2,
macsPrefix + mac2 + "/ipv6s": v6addr,
},
}
srv := httptest.NewServer(fake)
defer srv.Close()
ci := New(t.Logf)
ci.cloud = cloudenv.AWS
ci.endpoint = srv.URL
ips, err := ci.GetPublicIPs(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
wantIPs := []netip.Addr{
netip.MustParseAddr(publicV4),
netip.MustParseAddr(otherV4_1),
netip.MustParseAddr(otherV4_2),
netip.MustParseAddr(v6addr),
}
if !slices.Equal(ips, wantIPs) {
t.Fatalf("got %v, want %v", ips, wantIPs)
}
}
func TestCloudInfo_AWSNotPublic(t *testing.T) {
returns404 := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "PUT" && r.URL.Path == "/latest/api/token" {
w.Header().Set("Server", "EC2ws")
w.Write([]byte("fake-imds-token"))
return
}
http.NotFound(w, r)
})
srv := httptest.NewServer(returns404)
defer srv.Close()
ci := New(t.Logf)
ci.cloud = cloudenv.AWS
ci.endpoint = srv.URL
// If the IMDS server doesn't return any public IPs, it's not an error
// and we should just get an empty list.
ips, err := ci.GetPublicIPs(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ips) != 0 {
t.Fatalf("got %v, want none", ips)
}
}
type fakeIMDS struct {
tb testing.TB
paths map[string]string
}
func (f *fakeIMDS) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.tb.Logf("%s %s", r.Method, r.URL.Path)
path := r.URL.Path
// Handle the /latest/api/token case
const token = "fake-imds-token"
if r.Method == "PUT" && path == "/latest/api/token" {
w.Header().Set("Server", "EC2ws")
w.Write([]byte(token))
return
}
// Otherwise, require the IMDSv2 token to be set
if r.Header.Get("X-aws-ec2-metadata-token") != token {
f.tb.Errorf("missing or invalid IMDSv2 token")
http.Error(w, "missing or invalid IMDSv2 token", http.StatusForbidden)
return
}
if v, ok := f.paths[path]; ok {
w.Write([]byte(v))
return
}
http.NotFound(w, r)
}