Skip to content

Commit

Permalink
ipn/ipnlocal: add logging and locking to c2n /update (tailscale#9290)
Browse files Browse the repository at this point in the history
Log some progress info to make updates more debuggable. Also, track
whether an active update is already started and return an error if
a concurrent update is attempted.

Some planned future PRs:
* add JSON output to `tailscale update`
* use JSON output from `tailscale update` to provide a more detailed
  status of in-progress update (stage, download progress, etc)

Updates tailscale#6907

Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
Signed-off-by: Alex Paguis <alex@windscribe.com>
  • Loading branch information
awly authored and alexelisenko committed Feb 15, 2024
1 parent b092905 commit 682bebd
Show file tree
Hide file tree
Showing 4 changed files with 95 additions and 35 deletions.
4 changes: 2 additions & 2 deletions clientupdate/clientupdate.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,8 @@ func NewUpdater(args Arguments) (*Updater, error) {
return nil, err
}
}
if args.PkgsAddr == "" {
args.PkgsAddr = "https://pkgs.tailscale.com"
if up.Arguments.PkgsAddr == "" {
up.Arguments.PkgsAddr = "https://pkgs.tailscale.com"
}
return &up, nil
}
Expand Down
118 changes: 86 additions & 32 deletions ipn/ipnlocal/c2n.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package ipnlocal

import (
"bytes"
"encoding/json"
"errors"
"fmt"
Expand All @@ -14,6 +15,7 @@ import (
"path/filepath"
"runtime"
"strconv"
"strings"
"time"

"tailscale.com/clientupdate"
Expand All @@ -38,7 +40,15 @@ func (b *LocalBackend) handleC2N(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
w.Write(body)
case "/update":
b.handleC2NUpdate(w, r)
switch r.Method {
case http.MethodGet:
b.handleC2NUpdateGet(w, r)
case http.MethodPost:
b.handleC2NUpdatePost(w, r)
default:
http.Error(w, "bad method", http.StatusMethodNotAllowed)
return
}
case "/logtail/flush":
if r.Method != "POST" {
http.Error(w, "bad method", http.StatusMethodNotAllowed)
Expand Down Expand Up @@ -111,37 +121,27 @@ func (b *LocalBackend) handleC2N(w http.ResponseWriter, r *http.Request) {
}
}

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.
func (b *LocalBackend) handleC2NUpdateGet(w http.ResponseWriter, r *http.Request) {
b.logf("c2n: GET /update received")

// GET returns the current status, and POST actually begins an update.
if r.Method != "GET" && r.Method != "POST" {
http.Error(w, "bad method", http.StatusMethodNotAllowed)
return
}
res := b.newC2NUpdateResponse()
res.Started = b.c2nUpdateStarted()

// If NewUpdater does not return an error, we can update the installation.
// Exception: When version.IsMacSysExt returns true, we don't support that
// yet. TODO(cpalmer, #6995): Implement it.
//
// Note that we create the Updater solely to check for errors; we do not
// invoke it here. For this purpose, it is ok to pass it a zero Arguments.
prefs := b.Prefs().AutoUpdate()
_, err := clientupdate.NewUpdater(clientupdate.Arguments{})
res := tailcfg.C2NUpdateResponse{
Enabled: envknob.AllowsRemoteUpdate() || prefs.Apply,
Supported: err == nil && !version.IsMacSysExt(),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(res)
}

func (b *LocalBackend) handleC2NUpdatePost(w http.ResponseWriter, r *http.Request) {
b.logf("c2n: POST /update received")
res := b.newC2NUpdateResponse()
defer func() {
if res.Err != "" {
b.logf("c2n: POST /update failed: %s", res.Err)
}
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
Expand All @@ -151,6 +151,18 @@ func (b *LocalBackend) handleC2NUpdate(w http.ResponseWriter, r *http.Request) {
return
}

// Check if update was already started, and mark as started.
if !b.trySetC2NUpdateStarted() {
res.Err = "update already started"
return
}
defer func() {
// Clear the started flag if something failed.
if res.Err != "" {
b.setC2NUpdateStarted(false)
}
}()

cmdTS, err := findCmdTailscale()
if err != nil {
res.Err = fmt.Sprintf("failed to find cmd/tailscale binary: %v", err)
Expand All @@ -172,22 +184,64 @@ func (b *LocalBackend) handleC2NUpdate(w http.ResponseWriter, r *http.Request) {
res.Err = "cmd/tailscale version mismatch"
return
}

cmd := exec.Command(cmdTS, "update", "--yes")
buf := new(bytes.Buffer)
cmd.Stdout = buf
cmd.Stderr = buf
b.logf("c2n: running %q", strings.Join(cmd.Args, " "))
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.
// Run update asynchronously and respond that it started.
go func() {
if err := cmd.Wait(); err != nil {
b.logf("c2n: update command failed: %v, output: %s", err, buf)
} else {
b.logf("c2n: update complete")
}
b.setC2NUpdateStarted(false)
}()
}

func (b *LocalBackend) newC2NUpdateResponse() tailcfg.C2NUpdateResponse {
// If NewUpdater does not return an error, we can update the installation.
// Exception: When version.IsMacSysExt returns true, we don't support that
// yet. TODO(cpalmer, #6995): Implement it.
//
// This seems fairly unlikely, but worth checking.
defer cmd.Wait()
return
// Note that we create the Updater solely to check for errors; we do not
// invoke it here. For this purpose, it is ok to pass it a zero Arguments.
prefs := b.Prefs().AutoUpdate()
_, err := clientupdate.NewUpdater(clientupdate.Arguments{})
return tailcfg.C2NUpdateResponse{
Enabled: envknob.AllowsRemoteUpdate() || prefs.Apply,
Supported: err == nil && !version.IsMacSysExt(),
}
}

func (b *LocalBackend) c2nUpdateStarted() bool {
b.mu.Lock()
defer b.mu.Unlock()
return b.c2nUpdateStatus.started
}

func (b *LocalBackend) setC2NUpdateStarted(v bool) {
b.mu.Lock()
defer b.mu.Unlock()
b.c2nUpdateStatus.started = v
}

func (b *LocalBackend) trySetC2NUpdateStarted() bool {
b.mu.Lock()
defer b.mu.Unlock()
if b.c2nUpdateStatus.started {
return false
}
b.c2nUpdateStatus.started = true
return true
}

// findCmdTailscale looks for the cmd/tailscale that corresponds to the
Expand Down
6 changes: 6 additions & 0 deletions ipn/ipnlocal/local.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,8 @@ type LocalBackend struct {
directFileRoot string
directFileDoFinalRename bool // false on macOS, true on several NAS platforms
componentLogUntil map[string]componentLogState
// c2nUpdateStatus is the status of c2n-triggered client update.
c2nUpdateStatus updateStatus

// ServeConfig fields. (also guarded by mu)
lastServeConfJSON mem.RO // last JSON that was parsed into serveConfig
Expand Down Expand Up @@ -268,6 +270,10 @@ type LocalBackend struct {
clock tstime.Clock
}

type updateStatus struct {
started bool
}

// clientGen is a func that creates a control plane client.
// It's the type used by LocalBackend.SetControlClientGetterForTesting.
type clientGen func(controlclient.Options) (controlclient.Client, error)
Expand Down
2 changes: 1 addition & 1 deletion tailcfg/c2ntypes.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ type C2NSSHUsernamesResponse struct {
// its Tailscale installation.
type C2NUpdateResponse struct {
// Err is the error message, if any.
Err string
Err string `json:",omitempty"`

// Enabled indicates whether the user has opted in to updates triggered from
// control.
Expand Down

0 comments on commit 682bebd

Please sign in to comment.