diff --git a/envknob/envknob.go b/envknob/envknob.go index 499c86d28d5fc..bbac2003ae6a3 100644 --- a/envknob/envknob.go +++ b/envknob/envknob.go @@ -329,6 +329,13 @@ func NoLogsNoSupport() bool { return Bool("TS_NO_LOGS_NO_SUPPORT") } +var allowRemoteUpdate = RegisterBool("TS_ALLOW_ADMIN_CONSOLE_REMOTE_UPDATE") + +// AllowsRemoteUpdate reports whether this node has opted-in to letting the +// Tailscale control plane initiate a Tailscale update (e.g. on behalf of an +// admin on the admin console). +func AllowsRemoteUpdate() bool { return allowRemoteUpdate() } + // SetNoLogsNoSupport enables no-logs-no-support mode. func SetNoLogsNoSupport() { Setenv("TS_NO_LOGS_NO_SUPPORT", "true") diff --git a/hostinfo/hostinfo.go b/hostinfo/hostinfo.go index 4a16876dc411b..5ce9142edc460 100644 --- a/hostinfo/hostinfo.go +++ b/hostinfo/hostinfo.go @@ -53,6 +53,7 @@ func New() *tailcfg.Hostinfo { DeviceModel: deviceModel(), Cloud: string(cloudenv.Get()), NoLogsNoSupport: envknob.NoLogsNoSupport(), + AllowsUpdate: envknob.AllowsRemoteUpdate(), } } diff --git a/ipn/ipnlocal/c2n.go b/ipn/ipnlocal/c2n.go index 508f99c2f36cb..63a41c3e57aa6 100644 --- a/ipn/ipnlocal/c2n.go +++ b/ipn/ipnlocal/c2n.go @@ -6,14 +6,23 @@ package ipnlocal import ( "encoding/json" + "errors" + "fmt" "io" "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" "strconv" "time" + "tailscale.com/envknob" "tailscale.com/tailcfg" "tailscale.com/util/clientmetric" "tailscale.com/util/goroutines" + "tailscale.com/version" + "tailscale.com/version/distro" ) func (b *LocalBackend) handleC2N(w http.ResponseWriter, r *http.Request) { @@ -26,6 +35,8 @@ func (b *LocalBackend) handleC2N(w http.ResponseWriter, r *http.Request) { // Test handler. body, _ := io.ReadAll(r.Body) w.Write(body) + case "/update": + b.handleC2NUpdate(w, r) case "/logtail/flush": if r.Method != "POST" { http.Error(w, "bad method", http.StatusMethodNotAllowed) @@ -77,3 +88,108 @@ func (b *LocalBackend) handleC2N(w http.ResponseWriter, r *http.Request) { http.Error(w, "unknown c2n path", http.StatusBadRequest) } } + +func (b *LocalBackend) handleC2NUpdate(w http.ResponseWriter, r *http.Request) { + // TODO(bradfitz): add some sort of semaphore that prevents two concurrent + // updates, or if one happened in the past 5 minutes, or something. + + // TODO(bradfitz): move this type to some leaf package + type updateResponse struct { + Err string // error message, if any + Enabled bool // user has opted-in to remote updates + Supported bool // Tailscale supports updating this OS/platform + Started bool + } + var res updateResponse + res.Enabled = envknob.AllowsRemoteUpdate() + res.Supported = runtime.GOOS == "windows" || (runtime.GOOS == "linux" && distro.Get() == distro.Debian) + + switch r.Method { + case "GET", "POST": + default: + http.Error(w, "bad method", http.StatusMethodNotAllowed) + return + } + + defer func() { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(res) + }() + + if r.Method == "GET" { + return + } + + if !res.Enabled { + res.Err = "not enabled" + return + } + + if !res.Supported { + res.Err = "not supported" + return + } + cmdTS, err := findCmdTailscale() + if err != nil { + res.Err = fmt.Sprintf("failed to find cmd/tailscale binary: %v", err) + return + } + var ver struct { + Long string `json:"long"` + } + out, err := exec.Command(cmdTS, "version", "--json").Output() + if err != nil { + res.Err = fmt.Sprintf("failed to find cmd/tailscale binary: %v", err) + return + } + if err := json.Unmarshal(out, &ver); err != nil { + res.Err = "invalid JSON from cmd/tailscale version --json" + return + } + if ver.Long != version.Long { + res.Err = "cmd/tailscale version mismatch" + return + } + cmd := exec.Command(cmdTS, "update", "--yes") + if err := cmd.Start(); err != nil { + res.Err = fmt.Sprintf("failed to start cmd/tailscale update: %v", err) + return + } + res.Started = true + + // TODO(bradfitz,andrew): There might be a race condition here on Windows: + // * We start the update process + // * tailscale.exe copies itself and kicks off the update process msiexec stops + // * this process during the update before the selfCopy exits(?) This doesn't + // return because the process is dead. + // + // This seems fairly unlikely, but worth checking. + defer cmd.Wait() + return +} + +// findCmdTailscale looks for the cmd/tailscale that corresponds to the +// currently running cmd/tailcsaled. It's up to the caller to verify that the +// two match, but this function does its best to find the right one. Notably, it +// doesn't use $PATH for security reasons. +func findCmdTailscale() (string, error) { + self, err := os.Executable() + if err != nil { + return "", err + } + switch runtime.GOOS { + case "linux": + if self == "/usr/sbin/tailscaled" { + return "/usr/bin/tailscale", nil + } + return "", errors.New("tailscale not found in expected place") + case "windows": + dir := filepath.Dir(self) + ts := filepath.Join(dir, "tailscale.exe") + if fi, err := os.Stat(ts); err == nil && fi.Mode().IsRegular() { + return ts, nil + } + return "", errors.New("tailscale.exe not found in expected place") + } + return "", fmt.Errorf("unsupported OS %v", runtime.GOOS) +} diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index 17617fdc6765a..cc3bc485407c3 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -528,6 +528,7 @@ type Hostinfo struct { ShareeNode bool `json:",omitempty"` // indicates this node exists in netmap because it's owned by a shared-to user NoLogsNoSupport bool `json:",omitempty"` // indicates that the user has opted out of sending logs and support WireIngress bool `json:",omitempty"` // indicates that the node wants the option to receive ingress connections + AllowsUpdate bool `json:",omitempty"` // indicates that the node has opted-in to admin-console-drive remote updates Machine string `json:",omitempty"` // the current host's machine type (uname -m) GoArch string `json:",omitempty"` // GOARCH value (of the built binary) GoArchVar string `json:",omitempty"` // GOARM, GOAMD64, etc (of the built binary) diff --git a/tailcfg/tailcfg_clone.go b/tailcfg/tailcfg_clone.go index 8241e184d7339..0160f5f26a4eb 100644 --- a/tailcfg/tailcfg_clone.go +++ b/tailcfg/tailcfg_clone.go @@ -137,6 +137,7 @@ var _HostinfoCloneNeedsRegeneration = Hostinfo(struct { ShareeNode bool NoLogsNoSupport bool WireIngress bool + AllowsUpdate bool Machine string GoArch string GoArchVar string diff --git a/tailcfg/tailcfg_test.go b/tailcfg/tailcfg_test.go index d9a364859f061..c207ed6e825e8 100644 --- a/tailcfg/tailcfg_test.go +++ b/tailcfg/tailcfg_test.go @@ -50,6 +50,7 @@ func TestHostinfoEqual(t *testing.T) { "ShareeNode", "NoLogsNoSupport", "WireIngress", + "AllowsUpdate", "Machine", "GoArch", "GoArchVar", diff --git a/tailcfg/tailcfg_view.go b/tailcfg/tailcfg_view.go index 012a617b8895d..1ebc9c5cef1b9 100644 --- a/tailcfg/tailcfg_view.go +++ b/tailcfg/tailcfg_view.go @@ -276,6 +276,7 @@ func (v HostinfoView) ShieldsUp() bool { return v.ж.ShieldsUp } func (v HostinfoView) ShareeNode() bool { return v.ж.ShareeNode } func (v HostinfoView) NoLogsNoSupport() bool { return v.ж.NoLogsNoSupport } func (v HostinfoView) WireIngress() bool { return v.ж.WireIngress } +func (v HostinfoView) AllowsUpdate() bool { return v.ж.AllowsUpdate } func (v HostinfoView) Machine() string { return v.ж.Machine } func (v HostinfoView) GoArch() string { return v.ж.GoArch } func (v HostinfoView) GoArchVar() string { return v.ж.GoArchVar } @@ -312,6 +313,7 @@ var _HostinfoViewNeedsRegeneration = Hostinfo(struct { ShareeNode bool NoLogsNoSupport bool WireIngress bool + AllowsUpdate bool Machine string GoArch string GoArchVar string