diff --git a/clientupdate/clientupdate.go b/clientupdate/clientupdate.go index 70bd7dc14..35152c26f 100644 --- a/clientupdate/clientupdate.go +++ b/clientupdate/clientupdate.go @@ -7,13 +7,16 @@ package clientupdate import ( + "archive/tar" "bufio" "bytes" + "compress/gzip" "context" "encoding/json" "errors" "fmt" "io" + "maps" "net/http" "os" "os/exec" @@ -839,7 +842,162 @@ func (up *Updater) updateFreeBSD() (err error) { } func (up *Updater) updateLinuxBinary() error { - return errors.New("Linux binary updates without a package manager are not supported yet") + ver, err := requestedTailscaleVersion(up.Version, up.track) + if err != nil { + return err + } + if !up.confirm(ver) { + return nil + } + // Root is needed to overwrite binaries and restart systemd unit. + if err := requireRoot(); err != nil { + return err + } + + dlPath, err := up.downloadLinuxTarball(ver) + if err != nil { + return err + } + up.Logf("Extracting %q", dlPath) + if err := up.unpackLinuxTarball(dlPath); err != nil { + return err + } + if err := os.Remove(dlPath); err != nil { + up.Logf("failed to clean up %q: %w", dlPath, err) + } + if err := restartSystemdUnit(context.Background()); err != nil { + if errors.Is(err, errors.ErrUnsupported) { + up.Logf("Tailscale binaries updated successfully.\nPlease restart tailscaled to finish the update.") + } else { + up.Logf("Tailscale binaries updated successfully, but failed to restart tailscaled: %s.\nPlease restart tailscaled to finish the update.", err) + } + } else { + up.Logf("Success") + } + + return nil +} + +func (up *Updater) downloadLinuxTarball(ver string) (string, error) { + dlDir, err := os.UserCacheDir() + if err != nil { + return "", err + } + dlDir = filepath.Join(dlDir, "tailscale-update") + if err := os.MkdirAll(dlDir, 0700); err != nil { + return "", err + } + pkgsPath := fmt.Sprintf("%s/tailscale_%s_%s.tgz", up.track, ver, runtime.GOARCH) + dlPath := filepath.Join(dlDir, path.Base(pkgsPath)) + if err := up.downloadURLToFile(pkgsPath, dlPath); err != nil { + return "", err + } + return dlPath, nil +} + +func (up *Updater) unpackLinuxTarball(path string) error { + tailscale, tailscaled, err := binaryPaths() + if err != nil { + return err + } + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + gr, err := gzip.NewReader(f) + if err != nil { + return err + } + defer gr.Close() + tr := tar.NewReader(gr) + files := make(map[string]int) + wantFiles := map[string]int{ + "tailscale": 1, + "tailscaled": 1, + } + for { + th, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("failed extracting %q: %w", path, err) + } + // TODO(awly): try to also extract tailscaled.service. The tricky part + // is fixing up binary paths in that file if they differ from where + // local tailscale/tailscaled are installed. Also, this may not be a + // systemd distro. + switch filepath.Base(th.Name) { + case "tailscale": + files["tailscale"]++ + if err := writeFile(tr, tailscale+".new", 0755); err != nil { + return fmt.Errorf("failed extracting the new tailscale binary from %q: %w", path, err) + } + case "tailscaled": + files["tailscaled"]++ + if err := writeFile(tr, tailscaled+".new", 0755); err != nil { + return fmt.Errorf("failed extracting the new tailscaled binary from %q: %w", path, err) + } + } + } + if !maps.Equal(files, wantFiles) { + return fmt.Errorf("%q has missing or duplicate files: got %v, want %v", path, files, wantFiles) + } + + // Only place the files in final locations after everything extracted correctly. + if err := os.Rename(tailscale+".new", tailscale); err != nil { + return err + } + up.Logf("Updated %s", tailscale) + if err := os.Rename(tailscaled+".new", tailscaled); err != nil { + return err + } + up.Logf("Updated %s", tailscaled) + return nil +} + +func writeFile(r io.Reader, path string, perm os.FileMode) error { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove existing file at %q: %w", path, err) + } + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, perm) + if err != nil { + return err + } + defer f.Close() + if _, err := io.Copy(f, r); err != nil { + return err + } + return f.Close() +} + +// Var allows overriding this in tests. +var binaryPaths = func() (tailscale, tailscaled string, err error) { + // This can be either tailscale or tailscaled. + this, err := os.Executable() + if err != nil { + return "", "", err + } + otherName := "tailscaled" + if filepath.Base(this) == "tailscaled" { + otherName = "tailscale" + } + // Try to find the other binary in the same directory. + other := filepath.Join(filepath.Dir(this), otherName) + _, err = os.Stat(other) + if os.IsNotExist(err) { + // If it's not in the same directory, try to find it in $PATH. + other, err = exec.LookPath(otherName) + } + if err != nil { + return "", "", fmt.Errorf("cannot find %q in neither %q nor $PATH: %w", otherName, filepath.Dir(this), err) + } + if otherName == "tailscaled" { + return this, other, nil + } else { + return other, this, nil + } } func haveExecutable(name string) bool { diff --git a/clientupdate/clientupdate_test.go b/clientupdate/clientupdate_test.go index c6fa18234..334292c33 100644 --- a/clientupdate/clientupdate_test.go +++ b/clientupdate/clientupdate_test.go @@ -4,9 +4,14 @@ package clientupdate import ( + "archive/tar" + "compress/gzip" "fmt" + "io/fs" + "maps" "os" "path/filepath" + "strings" "testing" ) @@ -502,3 +507,257 @@ unique="synology_88f6281_213air" }) } } + +func TestUnpackLinuxTarball(t *testing.T) { + oldBinaryPaths := binaryPaths + t.Cleanup(func() { binaryPaths = oldBinaryPaths }) + + tests := []struct { + desc string + tarball map[string]string + before map[string]string + after map[string]string + wantErr bool + }{ + { + desc: "success", + before: map[string]string{ + "tailscale": "v1", + "tailscaled": "v1", + }, + tarball: map[string]string{ + "/usr/bin/tailscale": "v2", + "/usr/bin/tailscaled": "v2", + }, + after: map[string]string{ + "tailscale": "v2", + "tailscaled": "v2", + }, + }, + { + desc: "don't touch unrelated files", + before: map[string]string{ + "tailscale": "v1", + "tailscaled": "v1", + "foo": "bar", + }, + tarball: map[string]string{ + "/usr/bin/tailscale": "v2", + "/usr/bin/tailscaled": "v2", + }, + after: map[string]string{ + "tailscale": "v2", + "tailscaled": "v2", + "foo": "bar", + }, + }, + { + desc: "unmodified", + before: map[string]string{ + "tailscale": "v1", + "tailscaled": "v1", + }, + tarball: map[string]string{ + "/usr/bin/tailscale": "v1", + "/usr/bin/tailscaled": "v1", + }, + after: map[string]string{ + "tailscale": "v1", + "tailscaled": "v1", + }, + }, + { + desc: "ignore extra tarball files", + before: map[string]string{ + "tailscale": "v1", + "tailscaled": "v1", + }, + tarball: map[string]string{ + "/usr/bin/tailscale": "v2", + "/usr/bin/tailscaled": "v2", + "/systemd/tailscaled.service": "v2", + }, + after: map[string]string{ + "tailscale": "v2", + "tailscaled": "v2", + }, + }, + { + desc: "tarball missing tailscaled", + before: map[string]string{ + "tailscale": "v1", + "tailscaled": "v1", + }, + tarball: map[string]string{ + "/usr/bin/tailscale": "v2", + }, + after: map[string]string{ + "tailscale": "v1", + "tailscale.new": "v2", + "tailscaled": "v1", + }, + wantErr: true, + }, + { + desc: "duplicate tailscale binary", + before: map[string]string{ + "tailscale": "v1", + "tailscaled": "v1", + }, + tarball: map[string]string{ + "/usr/bin/tailscale": "v2", + "/usr/sbin/tailscale": "v2", + "/usr/bin/tailscaled": "v2", + }, + after: map[string]string{ + "tailscale": "v1", + "tailscale.new": "v2", + "tailscaled": "v1", + "tailscaled.new": "v2", + }, + wantErr: true, + }, + { + desc: "empty archive", + before: map[string]string{ + "tailscale": "v1", + "tailscaled": "v1", + }, + tarball: map[string]string{}, + after: map[string]string{ + "tailscale": "v1", + "tailscaled": "v1", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + // Swap out binaryPaths function to point at dummy file paths. + tmp := t.TempDir() + tailscalePath := filepath.Join(tmp, "tailscale") + tailscaledPath := filepath.Join(tmp, "tailscaled") + binaryPaths = func() (string, string, error) { + return tailscalePath, tailscaledPath, nil + } + for name, content := range tt.before { + if err := os.WriteFile(filepath.Join(tmp, name), []byte(content), 0755); err != nil { + t.Fatal(err) + } + } + tarPath := filepath.Join(tmp, "tailscale.tgz") + genTarball(t, tarPath, tt.tarball) + + up := &Updater{Arguments: Arguments{Logf: t.Logf}} + err := up.unpackLinuxTarball(tarPath) + if err != nil { + if !tt.wantErr { + t.Fatalf("unexpected error: %v", err) + } + } else if tt.wantErr { + t.Fatalf("unpack succeeded, expected an error") + } + + gotAfter := make(map[string]string) + err = filepath.WalkDir(tmp, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.Type().IsDir() { + return nil + } + if path == tarPath { + return nil + } + content, err := os.ReadFile(path) + if err != nil { + return err + } + path = filepath.ToSlash(path) + base := filepath.ToSlash(tmp) + gotAfter[strings.TrimPrefix(path, base+"/")] = string(content) + return nil + }) + if err != nil { + t.Fatal(err) + } + + if !maps.Equal(gotAfter, tt.after) { + t.Errorf("files after unpack: %+v, want %+v", gotAfter, tt.after) + } + }) + } +} + +func genTarball(t *testing.T, path string, files map[string]string) { + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + gw := gzip.NewWriter(f) + defer gw.Close() + tw := tar.NewWriter(gw) + defer tw.Close() + for file, content := range files { + if err := tw.WriteHeader(&tar.Header{ + Name: file, + Size: int64(len(content)), + Mode: 0755, + }); err != nil { + t.Fatal(err) + } + if _, err := tw.Write([]byte(content)); err != nil { + t.Fatal(err) + } + } +} + +func TestWriteFileOverwrite(t *testing.T) { + path := filepath.Join(t.TempDir(), "test") + for i := 0; i < 2; i++ { + content := fmt.Sprintf("content %d", i) + if err := writeFile(strings.NewReader(content), path, 0600); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != content { + t.Errorf("got content: %q, want: %q", got, content) + } + } +} + +func TestWriteFileSymlink(t *testing.T) { + // Test for a malicious symlink at the destination path. + // f2 points to f1 and writeFile(f2) should not end up overwriting f1. + tmp := t.TempDir() + f1 := filepath.Join(tmp, "f1") + if err := os.WriteFile(f1, []byte("old"), 0600); err != nil { + t.Fatal(err) + } + f2 := filepath.Join(tmp, "f2") + if err := os.Symlink(f1, f2); err != nil { + t.Fatal(err) + } + + if err := writeFile(strings.NewReader("new"), f2, 0600); err != nil { + t.Errorf("writeFile(%q) failed: %v", f2, err) + } + want := map[string]string{ + f1: "old", + f2: "new", + } + for f, content := range want { + got, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + if string(got) != content { + t.Errorf("%q: got content %q, want %q", f, got, content) + } + } +} diff --git a/clientupdate/systemd_linux.go b/clientupdate/systemd_linux.go new file mode 100644 index 000000000..810f7dd55 --- /dev/null +++ b/clientupdate/systemd_linux.go @@ -0,0 +1,37 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package clientupdate + +import ( + "context" + "errors" + "fmt" + + "github.com/coreos/go-systemd/v22/dbus" +) + +func restartSystemdUnit(ctx context.Context) error { + c, err := dbus.NewWithContext(ctx) + if err != nil { + // Likely not a systemd-managed distro. + return errors.ErrUnsupported + } + defer c.Close() + if err := c.ReloadContext(ctx); err != nil { + return fmt.Errorf("failed to reload tailsacled.service: %w", err) + } + ch := make(chan string, 1) + if _, err := c.RestartUnitContext(ctx, "tailscaled.service", "replace", ch); err != nil { + return fmt.Errorf("failed to restart tailsacled.service: %w", err) + } + select { + case res := <-ch: + if res != "done" { + return fmt.Errorf("systemd service restart failed with result %q", res) + } + case <-ctx.Done(): + return ctx.Err() + } + return nil +} diff --git a/clientupdate/systemd_other.go b/clientupdate/systemd_other.go new file mode 100644 index 000000000..b2412b6e4 --- /dev/null +++ b/clientupdate/systemd_other.go @@ -0,0 +1,15 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !linux + +package clientupdate + +import ( + "context" + "errors" +) + +func restartSystemdUnit(ctx context.Context) error { + return errors.ErrUnsupported +} diff --git a/cmd/tailscale/depaware.txt b/cmd/tailscale/depaware.txt index e827b81a9..16e3121ce 100644 --- a/cmd/tailscale/depaware.txt +++ b/cmd/tailscale/depaware.txt @@ -11,9 +11,11 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep W github.com/alexbrainman/sspi/internal/common from github.com/alexbrainman/sspi/negotiate W 💣 github.com/alexbrainman/sspi/negotiate from tailscale.com/net/tshttpproxy L github.com/coreos/go-iptables/iptables from tailscale.com/util/linuxfw + L github.com/coreos/go-systemd/v22/dbus from tailscale.com/clientupdate W 💣 github.com/dblohm7/wingoes from tailscale.com/util/winutil/authenticode+ W 💣 github.com/dblohm7/wingoes/pe from tailscale.com/util/winutil/authenticode github.com/fxamacker/cbor/v2 from tailscale.com/tka + L 💣 github.com/godbus/dbus/v5 from github.com/coreos/go-systemd/v22/dbus github.com/golang/groupcache/lru from tailscale.com/net/dnscache L github.com/google/nftables from tailscale.com/util/linuxfw L 💣 github.com/google/nftables/alignedbuff from github.com/google/nftables/xt @@ -200,11 +202,12 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep golang.org/x/text/unicode/bidi from golang.org/x/net/idna+ golang.org/x/text/unicode/norm from golang.org/x/net/idna golang.org/x/time/rate from tailscale.com/cmd/tailscale/cli+ + archive/tar from tailscale.com/clientupdate bufio from compress/flate+ bytes from bufio+ cmp from slices compress/flate from compress/gzip+ - compress/gzip from net/http + compress/gzip from net/http+ compress/zlib from image/png+ container/list from crypto/tls+ context from crypto/tls+ diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index d71d50008..4ecac3d83 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -76,6 +76,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de L github.com/aws/smithy-go/transport/http/internal/io from github.com/aws/smithy-go/transport/http L github.com/aws/smithy-go/waiter from github.com/aws/aws-sdk-go-v2/service/ssm L github.com/coreos/go-iptables/iptables from tailscale.com/util/linuxfw + L github.com/coreos/go-systemd/v22/dbus from tailscale.com/clientupdate LD 💣 github.com/creack/pty from tailscale.com/ssh/tailssh W 💣 github.com/dblohm7/wingoes from github.com/dblohm7/wingoes/com+ W 💣 github.com/dblohm7/wingoes/com from tailscale.com/cmd/tailscaled+ @@ -413,6 +414,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de golang.org/x/text/unicode/bidi from golang.org/x/net/idna+ golang.org/x/text/unicode/norm from golang.org/x/net/idna golang.org/x/time/rate from gvisor.dev/gvisor/pkg/tcpip/stack+ + archive/tar from tailscale.com/clientupdate bufio from compress/flate+ bytes from bufio+ cmp from slices diff --git a/go.mod b/go.mod index d0e175b46..86e202ec8 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssm v1.36.3 github.com/coreos/go-iptables v0.6.0 github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf + github.com/coreos/go-systemd/v22 v22.4.0 github.com/creack/pty v1.1.18 github.com/dave/jennifer v1.6.1 github.com/dblohm7/wingoes v0.0.0-20230821191801-fc76608aecf0 @@ -26,7 +27,7 @@ require ( github.com/go-json-experiment/json v0.0.0-20230321051131-ccbac49a6929 github.com/go-logr/zapr v1.2.4 github.com/go-ole/go-ole v1.2.6 - github.com/godbus/dbus/v5 v5.1.0 + github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da github.com/golangci/golangci-lint v1.52.2 github.com/google/go-cmp v0.5.9 diff --git a/go.sum b/go.sum index f9e62235f..3d6a4720b 100644 --- a/go.sum +++ b/go.sum @@ -208,6 +208,8 @@ github.com/coreos/go-iptables v0.6.0 h1:is9qnZMPYjLd8LYqmm/qlE+wwEgJIkTYdhV3rfZo github.com/coreos/go-iptables v0.6.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pqaw19uhpSCzhj44qo35pNgKFGqzDKkU= github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd/v22 v22.4.0 h1:y9YHcjnjynCd/DVbg5j9L/33jQM3MxJlbj/zWskzfGU= +github.com/coreos/go-systemd/v22 v22.4.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -356,8 +358,9 @@ github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8= github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo= github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= -github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= -github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg= +github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU= github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= @@ -1178,6 +1181,7 @@ golang.org/x/sys v0.0.0-20220702020025-31831981b65f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=