From 4e72992900b37b36c47252d08113c5404faacbb5 Mon Sep 17 00:00:00 2001 From: Andrew Lytvynov Date: Wed, 30 Aug 2023 17:25:06 -0600 Subject: [PATCH] clientupdate: add linux tarball updates (#9144) As a fallback to package managers, allow updating tailscale that was self-installed in some way. There are some tricky bits around updating the systemd unit (should we stick to local binary paths or to the ones in tailscaled.service?), so leaving that out for now. Updates #6995 Signed-off-by: Andrew Lytvynov --- clientupdate/clientupdate.go | 160 +++++++++++++++++- clientupdate/clientupdate_test.go | 259 ++++++++++++++++++++++++++++++ clientupdate/systemd_linux.go | 37 +++++ clientupdate/systemd_other.go | 15 ++ cmd/tailscale/depaware.txt | 5 +- cmd/tailscaled/depaware.txt | 2 + go.mod | 3 +- go.sum | 8 +- 8 files changed, 484 insertions(+), 5 deletions(-) create mode 100644 clientupdate/systemd_linux.go create mode 100644 clientupdate/systemd_other.go 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=