Browse Source

tool/gocross: a tool for building Tailscale binaries

Signed-off-by: David Anderson <[email protected]>
David Anderson 3 years ago
parent
commit
860734aed9

+ 2 - 0
.gitignore

@@ -34,3 +34,5 @@ cmd/tailscaled/tailscaled
 
 # Ignore direnv nix-shell environment cache
 .direnv/
+
+/gocross

+ 1 - 78
tool/go

@@ -4,81 +4,4 @@
 # currently-desired version from https://github.com/tailscale/go,
 # downloading it first if necessary.
 
-set -eu
-
-log() {
-    echo "$@" >&2
-}
-
-DEFAULT_TOOLCHAIN_DIR="${HOME}/.cache/tailscale-go"
-TOOLCHAIN="${TOOLCHAIN-${DEFAULT_TOOLCHAIN_DIR}}"
-TOOLCHAIN_GO="${TOOLCHAIN}/bin/go"
-read -r REV < "$(dirname "$0")/../go.toolchain.rev"
-
-# Fast, quiet path, when Tailscale is already current.
-if [ -e "${TOOLCHAIN_GO}" ]; then
-    short_hash=$("${TOOLCHAIN_GO}" version | sed 's/.*-ts//; s/ .*//')
-    case $REV in
-    "$short_hash"*)
-        unset GOROOT
-        exec "${TOOLCHAIN_GO}" "$@"
-    esac
-fi
-
-# This works for linux and darwin, which is sufficient
-# (we do not build tailscale-go for other targets).
-host_os=$(uname -s | tr A-Z a-z)
-host_arch="$(uname -m)"
-if [ "$host_arch" = "aarch64" ]; then
-    # Go uses the name "arm64".
-    host_arch="arm64"
-elif [ "$host_arch" = "x86_64" ]; then
-    # Go uses the name "amd64".
-    host_arch="amd64"
-fi
-
-get_cached() {
-    if [ ! -d "$TOOLCHAIN" ]; then
-        mkdir -p "$TOOLCHAIN"
-    fi
-
-    archive="$TOOLCHAIN-$REV.tar.gz"
-    mark="$TOOLCHAIN.extracted"
-    extracted=
-
-    # Ignore the error from read, which may error if the mark file does not contain a line end.
-    read -r extracted < "$mark" || true
-
-    if [ "$extracted" = "$REV" ] && [ -e "${TOOLCHAIN_GO}" ]; then
-        # already ok
-        log "Go toolchain '$REV' already extracted."
-        return 0
-    fi
-
-    rm -f "$archive.new" "$TOOLCHAIN.extracted"
-    if [ ! -e "$archive" ]; then
-            log "Need to download go '$REV'."
-            curl -f -L -o "$archive.new" "https://github.com/tailscale/go/releases/download/build-${REV}/${host_os}-${host_arch}.tar.gz"
-            rm -f "$archive"
-            mv "$archive.new" "$archive"
-    fi
-
-    log "Extracting tailscale/go rev '$REV'" >&2
-    log "  into '$TOOLCHAIN'." >&2
-    rm -rf "$TOOLCHAIN"
-    mkdir -p "$TOOLCHAIN"
-    (cd "$TOOLCHAIN" && tar --strip-components=1 -xf "$archive")
-    echo "$REV" >$mark
-}
-
-if [ "${REV}" = "SKIP" ] ||
-   [ "${host_os}" != "darwin" -a "${host_os}" != "linux" ] ||
-   [ "${host_arch}" != "amd64" -a "${host_arch}" != "arm64" ]; then
-    # Use whichever go is available
-    exec go "$@"
-else
-    get_cached
-fi
-
-unset GOROOT
-exec "${TOOLCHAIN_GO}" "$@"
+exec "$(dirname "$0")/../tool/gocross/gocross-wrapper.sh" "$@"

+ 183 - 0
tool/gocross/autoflags.go

@@ -0,0 +1,183 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package main
+
+import (
+	"fmt"
+	"runtime"
+	"strings"
+
+	"tailscale.com/version/mkversion"
+)
+
+// Autoflags adjusts the commandline argv into a new commandline
+// newArgv and envvar alterations in env.
+func Autoflags(argv []string, goroot string) (newArgv []string, env *Environment, err error) {
+	return autoflagsForTest(argv, NewEnvironment(), goroot, runtime.GOOS, runtime.GOARCH, mkversion.Info)
+}
+
+func autoflagsForTest(argv []string, env *Environment, goroot, nativeGOOS, nativeGOARCH string, getVersion func() mkversion.VersionInfo) (newArgv []string, newEnv *Environment, err error) {
+	// This is where all our "automatic flag injection" decisions get
+	// made. Modifying this code will modify the environment variables
+	// and commandline flags that the final `go` tool invocation will
+	// receive.
+	//
+	// When choosing between making this code concise or readable,
+	// please err on the side of being readable. Our build
+	// environments are relatively complicated by Go standards, and we
+	// want to keep it intelligible and malleable for our future
+	// selves.
+	var (
+		subcommand = ""
+
+		targetOS    = env.Get("GOOS", nativeGOOS)
+		targetArch  = env.Get("GOARCH", nativeGOARCH)
+		buildFlags  = []string{"-trimpath"}
+		cgoCflags   = []string{"-O3", "-std=gnu11"}
+		cgoLdflags  []string
+		ldflags     []string
+		tags        = []string{"tailscale_go"}
+		cgo         = false
+		failReflect = false
+	)
+	if len(argv) > 1 {
+		subcommand = argv[1]
+	}
+
+	switch subcommand {
+	case "build", "env", "install", "run", "test", "list":
+	default:
+		return argv, env, nil
+	}
+
+	vi := getVersion()
+	ldflags = []string{
+		"-X", "tailscale.com/version.longStamp=" + vi.Long,
+		"-X", "tailscale.com/version.shortStamp=" + vi.Short,
+		"-X", "tailscale.com/version.gitCommitStamp=" + vi.GitHash,
+		"-X", "tailscale.com/version.extraGitCommitStamp=" + vi.OtherHash,
+	}
+
+	switch targetOS {
+	case "linux":
+		// Getting Go to build a static binary with cgo enabled is a
+		// minor ordeal. The incantations you apparently need are
+		// documented at: https://github.com/golang/go/issues/26492
+		tags = append(tags, "osusergo", "netgo")
+		cgo = targetOS == nativeGOOS && targetArch == nativeGOARCH
+		// When in a Nix environment, the gcc package is built with only dynamic
+		// versions of glibc. You can get a static version of glibc via
+		// pkgs.glibc.static, but then you are reliant on Nix's gcc wrapper
+		// magic to inject that as a -L path to linker invocations.
+		//
+		// We can't rely on that magic linker flag injection, because that
+		// injection breaks redo's go machinery for dynamic go+cgo linking due
+		// to flag ordering issues that we can't easily fix (since the nix
+		// machinery controls the flag ordering, not us).
+		//
+		// So, instead, we unset NIX_LDFLAGS in our nix shell, which disables
+		// the magic linker flag passing; and we have shell.nix drop the path to
+		// the static glibc files in GOCROSS_GLIBC_DIR. Finally, we reinject it
+		// into the build process here, so that the linker can find static glibc
+		// and complete a static-with-cgo linkage.
+		extldflags := []string{"-static"}
+		if glibcDir := env.Get("GOCROSS_GLIBC_DIR", ""); glibcDir != "" {
+			extldflags = append(extldflags, "-L", glibcDir)
+		}
+		// -extldflags, when it contains multiple external linker flags, must be
+		// quoted in its entirety as a member of -ldflags. Source:
+		// https://github.com/golang/go/issues/6234
+		ldflags = append(ldflags, fmt.Sprintf("'-extldflags=%s'", strings.Join(extldflags, " ")))
+	case "windowsgui":
+		// Fake GOOS that translates to "windows, but building GUI .exes not console .exes"
+		targetOS = "windows"
+		ldflags = append(ldflags, "-H", "windowsgui", "-s")
+	case "windows":
+		ldflags = append(ldflags, "-H", "windows", "-s")
+	case "ios":
+		failReflect = true
+		fallthrough
+	case "darwin":
+		cgo = nativeGOOS == "darwin"
+		tags = append(tags, "omitidna", "omitpemdecrypt")
+		if env.IsSet("XCODE_VERSION_ACTUAL") {
+			var xcodeFlags []string
+			// Minimum OS version being targeted, results in
+			// e.g. -mmacosx-version-min=11.3
+			minOSKey := env.Get("DEPLOYMENT_TARGET_CLANG_FLAG_NAME", "")
+			minOSVal := env.Get(env.Get("DEPLOYMENT_TARGET_CLANG_ENV_NAME", ""), "")
+			xcodeFlags = append(xcodeFlags, fmt.Sprintf("-%s=%s", minOSKey, minOSVal))
+
+			// Target-specific SDK directory. Must be passed as two
+			// words ("-isysroot PATH", not "-isysroot=PATH").
+			xcodeFlags = append(xcodeFlags, "-isysroot", env.Get("SDKROOT", ""))
+
+			// What does clang call the target GOARCH?
+			var clangArch string
+			switch targetArch {
+			case "amd64":
+				clangArch = "x86_64"
+			case "arm64":
+				clangArch = "arm64"
+			default:
+				return nil, nil, fmt.Errorf("unsupported GOARCH=%q when building from Xcode", targetArch)
+			}
+			xcodeFlags = append(xcodeFlags, "-arch", clangArch)
+			cgoCflags = append(cgoCflags, xcodeFlags...)
+			cgoLdflags = append(cgoLdflags, xcodeFlags...)
+			ldflags = append(ldflags, "-w")
+		}
+	}
+
+	// Finished computing the settings we want. Generate the modified
+	// commandline and environment modifications.
+	newArgv = append(newArgv, argv[:2]...) // Program name and `go` tool subcommand
+	newArgv = append(newArgv, buildFlags...)
+	if len(tags) > 0 {
+		newArgv = append(newArgv, fmt.Sprintf("-tags=%s", strings.Join(tags, ",")))
+	}
+	if len(ldflags) > 0 {
+		newArgv = append(newArgv, "-ldflags", strings.Join(ldflags, " "))
+	}
+	newArgv = append(newArgv, argv[2:]...)
+
+	env.Set("GOOS", targetOS)
+	env.Set("GOARCH", targetArch)
+	env.Set("GOARM", "5") // TODO: fix, see go/internal-bug/3092
+	env.Set("GOMIPS", "softfloat")
+	env.Set("CGO_ENABLED", boolStr(cgo))
+	env.Set("CGO_CFLAGS", strings.Join(cgoCflags, " "))
+	env.Set("CGO_LDFLAGS", strings.Join(cgoLdflags, " "))
+	env.Set("CC", "cc")
+	env.Set("TS_LINK_FAIL_REFLECT", boolStr(failReflect))
+	env.Set("GOROOT", goroot)
+
+	if subcommand == "env" {
+		return argv, env, nil
+	}
+
+	return newArgv, env, nil
+}
+
+// boolStr formats v as a string 0 or 1.
+// Used because CGO_ENABLED doesn't strconv.ParseBool, so
+// strconv.FormatBool breaks.
+func boolStr(v bool) string {
+	if v {
+		return "1"
+	}
+	return "0"
+}
+
+// formatArgv formats a []string similarly to %v, but quotes each
+// string so that the reader can clearly see each array element.
+func formatArgv(v []string) string {
+	var ret strings.Builder
+	ret.WriteByte('[')
+	for _, s := range v {
+		fmt.Fprintf(&ret, "%q ", s)
+	}
+	ret.WriteByte(']')
+	return ret.String()
+}

+ 409 - 0
tool/gocross/autoflags_test.go

@@ -0,0 +1,409 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package main
+
+import (
+	"reflect"
+	"testing"
+
+	"tailscale.com/version/mkversion"
+)
+
+var fakeVersion = mkversion.VersionInfo{
+	Short:     "1.2.3",
+	Long:      "1.2.3-long",
+	GitHash:   "abcd",
+	OtherHash: "defg",
+	Xcode:     "100.2.3",
+	Winres:    "1,2,3,0",
+}
+
+func TestAutoflags(t *testing.T) {
+	tests := []struct {
+		// name convention: "<hostos>_<hostarch>_to_<targetos>_<targetarch>_<anything else?>"
+		name         string
+		env          map[string]string
+		argv         []string
+		goroot       string
+		nativeGOOS   string
+		nativeGOARCH string
+
+		wantEnv  map[string]string
+		envDiff  string
+		wantArgv []string
+	}{
+		{
+			name:         "linux_amd64_to_linux_amd64",
+			argv:         []string{"gocross", "build", "./cmd/tailcontrol"},
+			goroot:       "/goroot",
+			nativeGOOS:   "linux",
+			nativeGOARCH: "amd64",
+
+			envDiff: `CC=cc (was <nil>)
+CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
+CGO_ENABLED=1 (was <nil>)
+CGO_LDFLAGS= (was <nil>)
+GOARCH=amd64 (was <nil>)
+GOARM=5 (was <nil>)
+GOMIPS=softfloat (was <nil>)
+GOOS=linux (was <nil>)
+GOROOT=/goroot (was <nil>)
+TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
+			wantArgv: []string{
+				"gocross", "build",
+				"-trimpath",
+				"-tags=tailscale_go,osusergo,netgo",
+				"-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg '-extldflags=-static'",
+				"./cmd/tailcontrol",
+			},
+		},
+		{
+			name:         "install_linux_amd64_to_linux_amd64",
+			argv:         []string{"gocross", "install", "./cmd/tailcontrol"},
+			goroot:       "/goroot",
+			nativeGOOS:   "linux",
+			nativeGOARCH: "amd64",
+
+			envDiff: `CC=cc (was <nil>)
+CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
+CGO_ENABLED=1 (was <nil>)
+CGO_LDFLAGS= (was <nil>)
+GOARCH=amd64 (was <nil>)
+GOARM=5 (was <nil>)
+GOMIPS=softfloat (was <nil>)
+GOOS=linux (was <nil>)
+GOROOT=/goroot (was <nil>)
+TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
+			wantArgv: []string{
+				"gocross", "install",
+				"-trimpath",
+				"-tags=tailscale_go,osusergo,netgo",
+				"-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg '-extldflags=-static'",
+				"./cmd/tailcontrol",
+			},
+		},
+		{
+			name: "linux_amd64_to_linux_riscv64",
+			env: map[string]string{
+				"GOARCH": "riscv64",
+			},
+			argv:         []string{"gocross", "build", "./cmd/tailcontrol"},
+			goroot:       "/goroot",
+			nativeGOOS:   "linux",
+			nativeGOARCH: "amd64",
+
+			envDiff: `CC=cc (was <nil>)
+CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
+CGO_ENABLED=0 (was <nil>)
+CGO_LDFLAGS= (was <nil>)
+GOARCH=riscv64 (was riscv64)
+GOARM=5 (was <nil>)
+GOMIPS=softfloat (was <nil>)
+GOOS=linux (was <nil>)
+GOROOT=/goroot (was <nil>)
+TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
+			wantArgv: []string{
+				"gocross", "build",
+				"-trimpath",
+				"-tags=tailscale_go,osusergo,netgo",
+				"-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg '-extldflags=-static'",
+				"./cmd/tailcontrol",
+			},
+		},
+		{
+			name: "linux_amd64_to_freebsd_amd64",
+			env: map[string]string{
+				"GOOS": "freebsd",
+			},
+			argv:         []string{"gocross", "build", "./cmd/tailcontrol"},
+			goroot:       "/goroot",
+			nativeGOOS:   "linux",
+			nativeGOARCH: "amd64",
+
+			envDiff: `CC=cc (was <nil>)
+CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
+CGO_ENABLED=0 (was <nil>)
+CGO_LDFLAGS= (was <nil>)
+GOARCH=amd64 (was <nil>)
+GOARM=5 (was <nil>)
+GOMIPS=softfloat (was <nil>)
+GOOS=freebsd (was freebsd)
+GOROOT=/goroot (was <nil>)
+TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
+			wantArgv: []string{
+				"gocross", "build",
+				"-trimpath",
+				"-tags=tailscale_go",
+				"-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg",
+				"./cmd/tailcontrol",
+			},
+		},
+		{
+			name:         "linux_amd64_to_linux_amd64_race",
+			argv:         []string{"gocross", "test", "-race", "./cmd/tailcontrol"},
+			goroot:       "/goroot",
+			nativeGOOS:   "linux",
+			nativeGOARCH: "amd64",
+
+			envDiff: `CC=cc (was <nil>)
+CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
+CGO_ENABLED=1 (was <nil>)
+CGO_LDFLAGS= (was <nil>)
+GOARCH=amd64 (was <nil>)
+GOARM=5 (was <nil>)
+GOMIPS=softfloat (was <nil>)
+GOOS=linux (was <nil>)
+GOROOT=/goroot (was <nil>)
+TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
+			wantArgv: []string{
+				"gocross", "test",
+				"-trimpath",
+				"-tags=tailscale_go,osusergo,netgo",
+				"-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg '-extldflags=-static'",
+				"-race",
+				"./cmd/tailcontrol",
+			},
+		},
+		{
+			name: "linux_amd64_to_windows_amd64",
+			env: map[string]string{
+				"GOOS": "windows",
+			},
+			argv:         []string{"gocross", "build", "./cmd/tailcontrol"},
+			goroot:       "/goroot",
+			nativeGOOS:   "linux",
+			nativeGOARCH: "amd64",
+
+			envDiff: `CC=cc (was <nil>)
+CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
+CGO_ENABLED=0 (was <nil>)
+CGO_LDFLAGS= (was <nil>)
+GOARCH=amd64 (was <nil>)
+GOARM=5 (was <nil>)
+GOMIPS=softfloat (was <nil>)
+GOOS=windows (was windows)
+GOROOT=/goroot (was <nil>)
+TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
+			wantArgv: []string{
+				"gocross", "build",
+				"-trimpath",
+				"-tags=tailscale_go",
+				"-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg -H windows -s",
+				"./cmd/tailcontrol",
+			},
+		},
+		{
+			name:         "darwin_arm64_to_darwin_arm64",
+			argv:         []string{"gocross", "build", "./cmd/tailcontrol"},
+			goroot:       "/goroot",
+			nativeGOOS:   "darwin",
+			nativeGOARCH: "arm64",
+
+			envDiff: `CC=cc (was <nil>)
+CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
+CGO_ENABLED=1 (was <nil>)
+CGO_LDFLAGS= (was <nil>)
+GOARCH=arm64 (was <nil>)
+GOARM=5 (was <nil>)
+GOMIPS=softfloat (was <nil>)
+GOOS=darwin (was <nil>)
+GOROOT=/goroot (was <nil>)
+TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
+			wantArgv: []string{
+				"gocross", "build",
+				"-trimpath",
+				"-tags=tailscale_go,omitidna,omitpemdecrypt",
+				"-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg",
+				"./cmd/tailcontrol",
+			},
+		},
+		{
+			name: "darwin_arm64_to_darwin_amd64",
+			env: map[string]string{
+				"GOARCH": "amd64",
+			},
+			argv:         []string{"gocross", "build", "./cmd/tailcontrol"},
+			goroot:       "/goroot",
+			nativeGOOS:   "darwin",
+			nativeGOARCH: "arm64",
+
+			envDiff: `CC=cc (was <nil>)
+CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
+CGO_ENABLED=1 (was <nil>)
+CGO_LDFLAGS= (was <nil>)
+GOARCH=amd64 (was amd64)
+GOARM=5 (was <nil>)
+GOMIPS=softfloat (was <nil>)
+GOOS=darwin (was <nil>)
+GOROOT=/goroot (was <nil>)
+TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
+			wantArgv: []string{
+				"gocross", "build",
+				"-trimpath",
+				"-tags=tailscale_go,omitidna,omitpemdecrypt",
+				"-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg",
+				"./cmd/tailcontrol",
+			},
+		},
+		{
+			name: "darwin_arm64_to_ios_arm64",
+			env: map[string]string{
+				"GOOS": "ios",
+			},
+			argv:         []string{"gocross", "build", "./cmd/tailcontrol"},
+			goroot:       "/goroot",
+			nativeGOOS:   "darwin",
+			nativeGOARCH: "arm64",
+
+			envDiff: `CC=cc (was <nil>)
+CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
+CGO_ENABLED=1 (was <nil>)
+CGO_LDFLAGS= (was <nil>)
+GOARCH=arm64 (was <nil>)
+GOARM=5 (was <nil>)
+GOMIPS=softfloat (was <nil>)
+GOOS=ios (was ios)
+GOROOT=/goroot (was <nil>)
+TS_LINK_FAIL_REFLECT=1 (was <nil>)`,
+			wantArgv: []string{
+				"gocross", "build",
+				"-trimpath",
+				"-tags=tailscale_go,omitidna,omitpemdecrypt",
+				"-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg",
+				"./cmd/tailcontrol",
+			},
+		},
+		{
+			name: "darwin_arm64_to_darwin_amd64_xcode",
+			env: map[string]string{
+				"GOOS":                              "darwin",
+				"GOARCH":                            "amd64",
+				"XCODE_VERSION_ACTUAL":              "1300",
+				"DEPLOYMENT_TARGET_CLANG_FLAG_NAME": "mmacosx-version-min",
+				"MACOSX_DEPLOYMENT_TARGET":          "11.3",
+				"DEPLOYMENT_TARGET_CLANG_ENV_NAME":  "MACOSX_DEPLOYMENT_TARGET",
+				"SDKROOT":                           "/my/sdk/root",
+			},
+			argv:         []string{"gocross", "build", "./cmd/tailcontrol"},
+			goroot:       "/goroot",
+			nativeGOOS:   "darwin",
+			nativeGOARCH: "arm64",
+
+			envDiff: `CC=cc (was <nil>)
+CGO_CFLAGS=-O3 -std=gnu11 -mmacosx-version-min=11.3 -isysroot /my/sdk/root -arch x86_64 (was <nil>)
+CGO_ENABLED=1 (was <nil>)
+CGO_LDFLAGS=-mmacosx-version-min=11.3 -isysroot /my/sdk/root -arch x86_64 (was <nil>)
+GOARCH=amd64 (was amd64)
+GOARM=5 (was <nil>)
+GOMIPS=softfloat (was <nil>)
+GOOS=darwin (was darwin)
+GOROOT=/goroot (was <nil>)
+TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
+			wantArgv: []string{
+				"gocross", "build",
+				"-trimpath",
+				"-tags=tailscale_go,omitidna,omitpemdecrypt",
+				"-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg -w",
+				"./cmd/tailcontrol",
+			},
+		},
+		{
+			name:         "linux_amd64_to_linux_amd64_in_goroot",
+			argv:         []string{"go", "build", "./cmd/tailcontrol"},
+			goroot:       "/special/toolchain/path",
+			nativeGOOS:   "linux",
+			nativeGOARCH: "amd64",
+
+			envDiff: `CC=cc (was <nil>)
+CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
+CGO_ENABLED=1 (was <nil>)
+CGO_LDFLAGS= (was <nil>)
+GOARCH=amd64 (was <nil>)
+GOARM=5 (was <nil>)
+GOMIPS=softfloat (was <nil>)
+GOOS=linux (was <nil>)
+GOROOT=/special/toolchain/path (was <nil>)
+TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
+			wantArgv: []string{
+				"go", "build",
+				"-trimpath",
+				"-tags=tailscale_go,osusergo,netgo",
+				"-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg '-extldflags=-static'",
+				"./cmd/tailcontrol",
+			},
+		},
+		{
+			name:         "linux_list_amd64_to_linux_amd64",
+			argv:         []string{"gocross", "list", "./cmd/tailcontrol"},
+			goroot:       "/goroot",
+			nativeGOOS:   "linux",
+			nativeGOARCH: "amd64",
+
+			envDiff: `CC=cc (was <nil>)
+CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
+CGO_ENABLED=1 (was <nil>)
+CGO_LDFLAGS= (was <nil>)
+GOARCH=amd64 (was <nil>)
+GOARM=5 (was <nil>)
+GOMIPS=softfloat (was <nil>)
+GOOS=linux (was <nil>)
+GOROOT=/goroot (was <nil>)
+TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
+			wantArgv: []string{
+				"gocross", "list",
+				"-trimpath",
+				"-tags=tailscale_go,osusergo,netgo",
+				"-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg '-extldflags=-static'",
+				"./cmd/tailcontrol",
+			},
+		},
+		{
+			name: "linux_amd64_to_linux_amd64_with_extra_glibc_path",
+			env: map[string]string{
+				"GOCROSS_GLIBC_DIR": "/my/glibc/path",
+			},
+			argv:         []string{"gocross", "build", "./cmd/tailcontrol"},
+			goroot:       "/goroot",
+			nativeGOOS:   "linux",
+			nativeGOARCH: "amd64",
+
+			envDiff: `CC=cc (was <nil>)
+CGO_CFLAGS=-O3 -std=gnu11 (was <nil>)
+CGO_ENABLED=1 (was <nil>)
+CGO_LDFLAGS= (was <nil>)
+GOARCH=amd64 (was <nil>)
+GOARM=5 (was <nil>)
+GOMIPS=softfloat (was <nil>)
+GOOS=linux (was <nil>)
+GOROOT=/goroot (was <nil>)
+TS_LINK_FAIL_REFLECT=0 (was <nil>)`,
+			wantArgv: []string{
+				"gocross", "build",
+				"-trimpath",
+				"-tags=tailscale_go,osusergo,netgo",
+				"-ldflags", "-X tailscale.com/version.longStamp=1.2.3-long -X tailscale.com/version.shortStamp=1.2.3 -X tailscale.com/version.gitCommitStamp=abcd -X tailscale.com/version.extraGitCommitStamp=defg '-extldflags=-static -L /my/glibc/path'",
+				"./cmd/tailcontrol",
+			},
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			getver := func() mkversion.VersionInfo { return fakeVersion }
+			env := newEnvironmentForTest(test.env, nil, nil)
+
+			gotArgv, env, err := autoflagsForTest(test.argv, env, test.goroot, test.nativeGOOS, test.nativeGOARCH, getver)
+			if err != nil {
+				t.Fatalf("newAutoflagsForTest failed: %v", err)
+			}
+
+			if diff := env.Diff(); diff != test.envDiff {
+				t.Errorf("wrong environment diff, got:\n%s\n\nwant:\n%s", diff, test.envDiff)
+			}
+			if !reflect.DeepEqual(gotArgv, test.wantArgv) {
+				t.Errorf("wrong argv:\n  got : %s\n  want: %s", formatArgv(gotArgv), formatArgv(test.wantArgv))
+			}
+		})
+	}
+}

+ 131 - 0
tool/gocross/env.go

@@ -0,0 +1,131 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package main
+
+import (
+	"fmt"
+	"os"
+	"sort"
+	"strings"
+)
+
+// Environment starts from an initial set of environment variables, and tracks
+// mutations to the environment. It can then apply those mutations to the
+// environment, or produce debugging output that illustrates the changes it
+// would make.
+type Environment struct {
+	init  map[string]string
+	set   map[string]string
+	unset map[string]bool
+
+	setenv   func(string, string) error
+	unsetenv func(string) error
+}
+
+// NewEnvironment returns an Environment initialized from os.Environ.
+func NewEnvironment() *Environment {
+	init := map[string]string{}
+	for _, env := range os.Environ() {
+		fs := strings.SplitN(env, "=", 2)
+		if len(fs) != 2 {
+			panic("bad environ provided")
+		}
+		init[fs[0]] = fs[1]
+	}
+
+	return newEnvironmentForTest(init, os.Setenv, os.Unsetenv)
+}
+
+func newEnvironmentForTest(init map[string]string, setenv func(string, string) error, unsetenv func(string) error) *Environment {
+	return &Environment{
+		init:     init,
+		set:      map[string]string{},
+		unset:    map[string]bool{},
+		setenv:   setenv,
+		unsetenv: unsetenv,
+	}
+}
+
+// Set sets the environment variable k to v.
+func (e *Environment) Set(k, v string) {
+	e.set[k] = v
+	delete(e.unset, k)
+}
+
+// Unset removes the environment variable k.
+func (e *Environment) Unset(k string) {
+	delete(e.set, k)
+	e.unset[k] = true
+}
+
+// IsSet reports whether the environment variable k is set.
+func (e *Environment) IsSet(k string) bool {
+	if e.unset[k] {
+		return false
+	}
+	if _, ok := e.init[k]; ok {
+		return true
+	}
+	if _, ok := e.set[k]; ok {
+		return true
+	}
+	return false
+}
+
+// Get returns the value of the environment variable k, or defaultVal if it is
+// not set.
+func (e *Environment) Get(k, defaultVal string) string {
+	if e.unset[k] {
+		return defaultVal
+	}
+	if v, ok := e.set[k]; ok {
+		return v
+	}
+	if v, ok := e.init[k]; ok {
+		return v
+	}
+	return defaultVal
+}
+
+// Apply applies all pending mutations to the environment.
+func (e *Environment) Apply() error {
+	for k, v := range e.set {
+		if err := e.setenv(k, v); err != nil {
+			return fmt.Errorf("setting %q: %v", k, err)
+		}
+		e.init[k] = v
+		delete(e.set, k)
+	}
+	for k := range e.unset {
+		if err := e.unsetenv(k); err != nil {
+			return fmt.Errorf("unsetting %q: %v", k, err)
+		}
+		delete(e.init, k)
+		delete(e.unset, k)
+	}
+	return nil
+}
+
+// Diff returns a string describing the pending mutations to the environment.
+func (e *Environment) Diff() string {
+	lines := make([]string, 0, len(e.set)+len(e.unset))
+	for k, v := range e.set {
+		old, ok := e.init[k]
+		if ok {
+			lines = append(lines, fmt.Sprintf("%s=%s (was %s)", k, v, old))
+		} else {
+			lines = append(lines, fmt.Sprintf("%s=%s (was <nil>)", k, v))
+		}
+	}
+	for k := range e.unset {
+		old, ok := e.init[k]
+		if ok {
+			lines = append(lines, fmt.Sprintf("%s=<nil> (was %s)", k, old))
+		} else {
+			lines = append(lines, fmt.Sprintf("%s=<nil> (was <nil>)", k))
+		}
+	}
+	sort.Strings(lines)
+	return strings.Join(lines, "\n")
+}

+ 99 - 0
tool/gocross/env_test.go

@@ -0,0 +1,99 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package main
+
+import (
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+)
+
+func TestEnv(t *testing.T) {
+
+	var (
+		init = map[string]string{
+			"FOO": "bar",
+		}
+
+		wasSet   = map[string]string{}
+		wasUnset = map[string]bool{}
+
+		setenv = func(k, v string) error {
+			wasSet[k] = v
+			return nil
+		}
+		unsetenv = func(k string) error {
+			wasUnset[k] = true
+			return nil
+		}
+	)
+
+	env := newEnvironmentForTest(init, setenv, unsetenv)
+
+	if got, want := env.Get("FOO", ""), "bar"; got != want {
+		t.Errorf(`env.Get("FOO") = %q, want %q`, got, want)
+	}
+	if got, want := env.IsSet("FOO"), true; got != want {
+		t.Errorf(`env.IsSet("FOO") = %v, want %v`, got, want)
+	}
+
+	if got, want := env.Get("BAR", "defaultVal"), "defaultVal"; got != want {
+		t.Errorf(`env.Get("BAR") = %q, want %q`, got, want)
+	}
+	if got, want := env.IsSet("BAR"), false; got != want {
+		t.Errorf(`env.IsSet("BAR") = %v, want %v`, got, want)
+	}
+
+	env.Set("BAR", "quux")
+	if got, want := env.Get("BAR", ""), "quux"; got != want {
+		t.Errorf(`env.Get("BAR") = %q, want %q`, got, want)
+	}
+	if got, want := env.IsSet("BAR"), true; got != want {
+		t.Errorf(`env.IsSet("BAR") = %v, want %v`, got, want)
+	}
+	diff := "BAR=quux (was <nil>)"
+	if got := env.Diff(); got != diff {
+		t.Errorf("env.Diff() = %q, want %q", got, diff)
+	}
+
+	env.Set("FOO", "foo2")
+	if got, want := env.Get("FOO", ""), "foo2"; got != want {
+		t.Errorf(`env.Get("FOO") = %q, want %q`, got, want)
+	}
+	if got, want := env.IsSet("FOO"), true; got != want {
+		t.Errorf(`env.IsSet("FOO") = %v, want %v`, got, want)
+	}
+	diff = `BAR=quux (was <nil>)
+FOO=foo2 (was bar)`
+	if got := env.Diff(); got != diff {
+		t.Errorf("env.Diff() = %q, want %q", got, diff)
+	}
+
+	env.Unset("FOO")
+	if got, want := env.Get("FOO", "default"), "default"; got != want {
+		t.Errorf(`env.Get("FOO") = %q, want %q`, got, want)
+	}
+	if got, want := env.IsSet("FOO"), false; got != want {
+		t.Errorf(`env.IsSet("FOO") = %v, want %v`, got, want)
+	}
+	diff = `BAR=quux (was <nil>)
+FOO=<nil> (was bar)`
+	if got := env.Diff(); got != diff {
+		t.Errorf("env.Diff() = %q, want %q", got, diff)
+	}
+
+	if err := env.Apply(); err != nil {
+		t.Fatalf("env.Apply() failed: %v", err)
+	}
+
+	wantSet := map[string]string{"BAR": "quux"}
+	wantUnset := map[string]bool{"FOO": true}
+
+	if diff := cmp.Diff(wasSet, wantSet); diff != "" {
+		t.Errorf("env.Apply didn't set as expected (-got+want):\n%s", diff)
+	}
+	if diff := cmp.Diff(wasUnset, wantUnset); diff != "" {
+		t.Errorf("env.Apply didn't unset as expected (-got+want):\n%s", diff)
+	}
+}

+ 20 - 0
tool/gocross/exec_other.go

@@ -0,0 +1,20 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build !unix
+
+package main
+
+import (
+	"os"
+	"os/exec"
+)
+
+func doExec(cmd string, args []string, env []string) error {
+	c := exec.Command(cmd, args...)
+	c.Env = env
+	c.Stdin = os.Stdin
+	c.Stdout = os.Stdout
+	c.Stderr = os.Stderr
+	return c.Run()
+}

+ 12 - 0
tool/gocross/exec_unix.go

@@ -0,0 +1,12 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build unix
+
+package main
+
+import "golang.org/x/sys/unix"
+
+func doExec(cmd string, args []string, env []string) error {
+	return unix.Exec(cmd, args, env)
+}

+ 72 - 0
tool/gocross/gocross-wrapper.sh

@@ -0,0 +1,72 @@
+#!/usr/bin/env sh
+# Copyright (c) Tailscale Inc & AUTHORS
+# SPDX-License-Identifier: BSD-3-Clause
+#
+# gocross-wrapper.sh is a wrapper that can be aliased to 'go', which
+# transparently builds gocross using a "bootstrap" Go toolchain, and
+# then invokes gocross.
+
+set -eu
+
+if [ "${CI:-}" = "true" ]; then
+    set -x
+fi
+
+repo_root="$(dirname $0)/../.."
+
+toolchain="$HOME/.cache/tailscale-go"
+
+if [ ! -d "$toolchain" ]; then
+    mkdir -p "$HOME/.cache"
+
+    # We need any Go toolchain to build gocross, but the toolchain also has to
+    # be reasonably recent because we upgrade eagerly and gocross might not
+    # build with Go N-1. So, if we have no cached tailscale toolchain at all,
+    # fetch the initial one in shell. Once gocross is built, it'll manage
+    # updates.
+    read -r REV <$repo_root/go.toolchain.rev
+
+    # This works for linux and darwin, which is sufficient
+    # (we do not build tailscale-go for other targets).
+    HOST_OS=$(uname -s | tr A-Z a-z)
+    HOST_ARCH="$(uname -m)"
+    if [ "$HOST_ARCH" = "aarch64" ]; then
+        # Go uses the name "arm64".
+        HOST_ARCH="arm64"
+    elif [ "$HOST_ARCH" = "x86_64" ]; then
+        # Go uses the name "amd64".
+        HOST_ARCH="amd64"
+    fi
+
+    rm -rf "$toolchain" "$toolchain.extracted"
+    curl -f -L -o "$toolchain.tar.gz" "https://github.com/tailscale/go/releases/download/build-${REV}/${HOST_OS}-${HOST_ARCH}.tar.gz"
+    mkdir -p "$toolchain"
+    (cd "$toolchain" && tar --strip-components=1 -xf "$toolchain.tar.gz")
+    echo "$REV" >"$toolchain.extracted"
+fi
+
+# Binaries run with `gocross run` can reinvoke gocross, resulting in a
+# potentially fancy build that invokes external linkers, might be
+# cross-building for other targets, and so forth. In one hilarious
+# case, cmd/cloner invokes go with GO111MODULE=off at some stage.
+#
+# Anyway, build gocross in a stripped down universe.
+gocross_path="$repo_root/gocross"
+gocross_ok=0
+if [ -x "$gocross_path" ]; then
+	gotver="$($gocross_path gocross-version 2>/dev/null || echo '')"
+	wantver="$(git rev-parse HEAD)"
+	if [ "$gotver" = "$wantver" ]; then
+		gocross_ok=1
+	fi
+fi
+if [ "$gocross_ok" = "0" ]; then
+    (
+        unset GOOS
+        unset GOARCH
+        unset GO111MODULE
+        export CGO_ENABLED=0
+        "$toolchain/bin/go" build -o "$gocross_path" tailscale.com/tool/gocross
+    )
+fi
+exec "$gocross_path" "$@"

+ 132 - 0
tool/gocross/gocross.go

@@ -0,0 +1,132 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+// gocross is a wrapper around the `go` tool that invokes `go` from Tailscale's
+// custom toolchain, with the right build parameters injected based on the
+// native+target GOOS/GOARCH.
+//
+// In short, when aliased to `go`, using `go build`, `go test` behave like the
+// upstream Go tools, but produce correctly configured, correctly linked
+// binaries stamped with version information.
+
+package main
+
+import (
+	_ "embed"
+	"fmt"
+	"os"
+	"path/filepath"
+	runtimeDebug "runtime/debug"
+)
+
+func main() {
+	if len(os.Args) > 1 {
+		// These additional subcommands are various support commands to handle
+		// integration with Tailscale's existing build system. Unless otherwise
+		// specified, these are not stable APIs, and may change or go away at
+		// any time.
+		switch os.Args[1] {
+		case "gocross-version":
+			hash, err := embeddedCommit()
+			if err != nil {
+				fmt.Fprintf(os.Stderr, "getting commit hash: %v", err)
+				os.Exit(1)
+			}
+			fmt.Println(hash)
+			os.Exit(0)
+		case "is-gocross":
+			// This subcommand exits with an error code when called on a
+			// regular go binary, so it can be used to detect when `go` is
+			// actually gocross.
+			os.Exit(0)
+		case "make-goroot":
+			_, gorootDir, err := getToolchain()
+			if err != nil {
+				fmt.Fprintf(os.Stderr, "getting toolchain: %v\n", err)
+				os.Exit(1)
+			}
+
+			fmt.Println(gorootDir)
+			os.Exit(0)
+		case "gocross-get-toolchain-go":
+			toolchain, _, err := getToolchain()
+			if err != nil {
+				fmt.Fprintf(os.Stderr, "getting toolchain: %v\n", err)
+				os.Exit(1)
+			}
+			fmt.Println(filepath.Join(toolchain, "bin/go"))
+			os.Exit(0)
+		}
+	}
+
+	toolchain, goroot, err := getToolchain()
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "getting toolchain: %v\n", err)
+		os.Exit(1)
+	}
+
+	args := os.Args
+	if os.Getenv("GOCROSS_BYPASS") == "" {
+		newArgv, env, err := Autoflags(os.Args, goroot)
+		if err != nil {
+			fmt.Fprintf(os.Stderr, "computing flags: %v\n", err)
+			os.Exit(1)
+		}
+
+		// Make sure the right version of cmd/go is the first thing in the PATH
+		// for tests that execute `go build` or `go test`.
+		// TODO: if we really need to do this, do it inside Autoflags, not here.
+		path := filepath.Join(toolchain, "bin") + string(os.PathListSeparator) + os.Getenv("PATH")
+		env.Set("PATH", path)
+
+		debug("Input: %s\n", formatArgv(os.Args))
+		debug("Command: %s\n", formatArgv(newArgv))
+		debug("Set the following flags/envvars:\n%s\n", env.Diff())
+
+		args = newArgv
+		if err := env.Apply(); err != nil {
+			fmt.Fprintf(os.Stderr, "modifying environment: %v\n", err)
+			os.Exit(1)
+		}
+
+	}
+
+	doExec(filepath.Join(toolchain, "bin/go"), args, os.Environ())
+}
+
+func debug(format string, args ...interface{}) {
+	debug := os.Getenv("GOCROSS_DEBUG")
+	var (
+		out *os.File
+		err error
+	)
+	switch debug {
+	case "0", "":
+		return
+	case "1":
+		out = os.Stderr
+	default:
+		out, err = os.OpenFile(debug, os.O_APPEND|os.O_CREATE|os.O_RDWR, 0640)
+		if err != nil {
+			fmt.Fprintf(os.Stderr, "opening debug file %q: %v", debug, err)
+			out = os.Stderr
+		} else {
+			defer out.Close() // May lose some write errors, but we don't care.
+		}
+	}
+
+	fmt.Fprintf(out, format, args...)
+}
+
+func embeddedCommit() (string, error) {
+	bi, ok := runtimeDebug.ReadBuildInfo()
+	if !ok {
+		return "", fmt.Errorf("no build info")
+	}
+	for _, s := range bi.Settings {
+		if s.Key == "vcs.revision" {
+			return s.Value, nil
+		}
+	}
+	return "", fmt.Errorf("no git commit found")
+}

+ 90 - 0
tool/gocross/goroot.go

@@ -0,0 +1,90 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package main
+
+import (
+	"errors"
+	"fmt"
+	"io"
+	"io/fs"
+	"os"
+	"path/filepath"
+)
+
+// makeGoroot constructs a GOROOT-like file structure in outPath,
+// which consists of toolchainRoot except for the `go` binary, which
+// points to gocross.
+//
+// It's useful for integrating with tooling that expects to be handed
+// a GOROOT, like the Goland IDE or depaware.
+func makeGoroot(toolchainRoot, outPath string) error {
+	self, err := os.Executable()
+	if err != nil {
+		return fmt.Errorf("getting gocross's path: %v", err)
+	}
+
+	os.RemoveAll(outPath)
+	if err := os.MkdirAll(filepath.Join(outPath, "bin"), 0750); err != nil {
+		return fmt.Errorf("making %q: %v", outPath, err)
+	}
+	if err := os.Symlink(self, filepath.Join(outPath, "bin/go")); err != nil {
+		return fmt.Errorf("linking gocross into outpath: %v", err)
+	}
+
+	if err := linkFarm(toolchainRoot, outPath); err != nil {
+		return fmt.Errorf("creating GOROOT link farm: %v", err)
+	}
+	if err := linkFarm(filepath.Join(toolchainRoot, "bin"), filepath.Join(outPath, "bin")); err != nil {
+		return fmt.Errorf("creating GOROOT/bin link farm: %v", err)
+	}
+
+	return nil
+}
+
+func copyFile(src, dst string) error {
+	s, err := os.Open(src)
+	if err != nil {
+		return fmt.Errorf("opening %q: %v", src, err)
+	}
+	defer s.Close()
+
+	d, err := os.OpenFile(dst, os.O_RDWR|os.O_CREATE, 0755)
+	if err != nil {
+		return fmt.Errorf("opening %q: %v", dst, err)
+	}
+
+	if _, err := io.Copy(d, s); err != nil {
+		d.Close()
+		return fmt.Errorf("copying %q to %q: %v", src, dst, err)
+	}
+
+	if err := d.Close(); err != nil {
+		return fmt.Errorf("closing %q: %v", dst, err)
+	}
+
+	return nil
+}
+
+// linkFarm symlinks every entry in srcDir into outDir, unless that
+// directory entry already exists.
+func linkFarm(srcDir, outDir string) error {
+	ents, err := os.ReadDir(srcDir)
+	if err != nil {
+		return fmt.Errorf("reading %q: %v", srcDir, err)
+	}
+
+	for _, ent := range ents {
+		dst := filepath.Join(outDir, ent.Name())
+		_, err := os.Lstat(dst)
+		if errors.Is(err, fs.ErrNotExist) {
+			if err := os.Symlink(filepath.Join(srcDir, ent.Name()), dst); err != nil {
+				return fmt.Errorf("symlinking %q to %q: %v", ent.Name(), outDir, err)
+			}
+		} else if err != nil {
+			return fmt.Errorf("stat-ing %q: %v", dst, err)
+		}
+	}
+
+	return nil
+}

+ 173 - 0
tool/gocross/toolchain.go

@@ -0,0 +1,173 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package main
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"runtime"
+)
+
+func toolchainRev() (string, error) {
+	cwd, err := os.Getwd()
+	if err != nil {
+		return "", fmt.Errorf("getting CWD: %v", err)
+	}
+	d := cwd
+findTopLevel:
+	for {
+		if _, err := os.Lstat(filepath.Join(d, ".git")); err == nil {
+			break findTopLevel
+		} else if !os.IsNotExist(err) {
+			return "", fmt.Errorf("finding .git: %v", err)
+		}
+		d = filepath.Dir(d)
+		if d == "/" {
+			return "", fmt.Errorf("couldn't find .git starting from %q, cannot manage toolchain", cwd)
+		}
+	}
+
+	return readRevFile(filepath.Join(d, "go.toolchain.rev"))
+}
+
+func readRevFile(path string) (string, error) {
+	bs, err := os.ReadFile(path)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return "", nil
+		}
+		return "", err
+	}
+	return string(bytes.TrimSpace(bs)), nil
+}
+
+func getToolchain() (toolchainDir, gorootDir string, err error) {
+	cache := filepath.Join(os.Getenv("HOME"), ".cache")
+	toolchainDir = filepath.Join(cache, "tailscale-go")
+	gorootDir = filepath.Join(toolchainDir, "gocross-goroot")
+
+	// You might wonder why getting the toolchain also provisions and returns a
+	// path suitable for use as GOROOT. Wonder no longer!
+	//
+	// A bunch of our tests and build processes involve re-invoking 'go build'
+	// or other build-ish commands (install, run, ...). These typically use
+	// runtime.GOROOT + "bin/go" to get at the Go binary. Even more edge case-y,
+	// tailscale.com/cmd/tsconnect needs to fish a javascript glue file out of
+	// GOROOT in order to build the javascript bundle for serving.
+	//
+	// Gocross always does a -trimpath on builds for reproducibility, which
+	// wipes out the burned-in runtime.GOROOT value from the binary. This means
+	// that using gocross on these various test and build processes ends up
+	// breaking with mysterious path errors.
+	//
+	// We don't want to stop using -trimpath, or otherwise make GOROOT work in
+	// "normal" builds, because that is a footgun that lets people accidentally
+	// create assumptions that the build toolchain is still around at runtime.
+	// Instead, we want to make 'go test' and 'go run' have access to GOROOT,
+	// while still removing it from standalone binaries.
+	//
+	// So, construct and pass a GOROOT to the actual 'go' invocation, which lets
+	// tests and build processes locate and use GOROOT. For consistency, the
+	// GOROOT that's passed in is a symlink farm that mostly points to the
+	// toolchain's underlying GOROOT, but 'bin/go' points back to gocross. This
+	// means that if you invoke 'go test' via gocross, and that test tries to
+	// build code, that build will also end up using gocross.
+
+	if err := ensureToolchain(cache, toolchainDir); err != nil {
+		return "", "", err
+	}
+	if err := ensureGoroot(toolchainDir, gorootDir); err != nil {
+		return "", "", err
+	}
+
+	return toolchainDir, gorootDir, nil
+}
+
+func ensureToolchain(cacheDir, toolchainDir string) error {
+	stampFile := toolchainDir + ".extracted"
+
+	wantRev, err := toolchainRev()
+	if err != nil {
+		return err
+	}
+	gotRev, err := readRevFile(stampFile)
+	if err != nil {
+		return fmt.Errorf("reading stamp file %q: %v", stampFile, err)
+	}
+	if gotRev == wantRev {
+		// Toolchain already good.
+		return nil
+	}
+
+	if err := os.RemoveAll(toolchainDir); err != nil {
+		return err
+	}
+	if err := os.RemoveAll(stampFile); err != nil {
+		return err
+	}
+
+	if err := downloadCachedgo(toolchainDir, wantRev); err != nil {
+		return err
+	}
+	if err := os.WriteFile(stampFile, []byte(wantRev), 0644); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func ensureGoroot(toolchainDir, gorootDir string) error {
+	if _, err := os.Stat(gorootDir); err == nil {
+		return nil
+	} else if !os.IsNotExist(err) {
+		return err
+	}
+	return makeGoroot(toolchainDir, gorootDir)
+
+}
+
+func downloadCachedgo(toolchainDir, toolchainRev string) error {
+	url := fmt.Sprintf("https://github.com/tailscale/go/releases/download/build-%s/%s-%s.tar.gz", toolchainRev, runtime.GOOS, runtime.GOARCH)
+
+	archivePath := toolchainDir + ".tar.gz"
+	f, err := os.Create(archivePath)
+	if err != nil {
+		return err
+	}
+
+	resp, err := http.Get(url)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode != 200 {
+		return fmt.Errorf("failed to get %q: %v", url, resp.Status)
+	}
+	if _, err := io.Copy(f, resp.Body); err != nil {
+		return err
+	}
+	if err := f.Close(); err != nil {
+		return err
+	}
+
+	if err := os.MkdirAll(toolchainDir, 0755); err != nil {
+		return err
+	}
+	cmd := exec.Command("tar", "--strip-components=1", "-xf", archivePath)
+	cmd.Dir = toolchainDir
+	if err := cmd.Run(); err != nil {
+		return err
+	}
+
+	if err := os.RemoveAll(archivePath); err != nil {
+		return err
+	}
+
+	return nil
+}