diff --git a/README.md b/README.md index 82e4914..b11a1d3 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,8 @@ git push origin v0.2.0 The Release workflow checks that the tag is on `main`, runs `make check`, builds the archives with `make release`, and creates the GitHub release with both archives and `checksums.txt`. A tag with a pre-release suffix, such as `v0.2.0-rc.1`, becomes a pre-release, so installed CLIs do not update to it. +`install.sh` and `fly update` install only a release that has `checksums.txt`. Releases before v0.2.0 have none, so push the tag right after the merge into `main`: until the release is published, `install.sh` from `main` stops. + ### Dev pre-releases To test a branch on real servers before it merges, publish a dev pre-release of its current commit: diff --git a/cmd/version.go b/cmd/version.go index 9a28f90..a32680a 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -6,6 +6,8 @@ import ( "fmt" "io" "os" + "os/signal" + "syscall" "github.com/flywp/server-cli/internal/release" "github.com/flywp/server-cli/internal/service" @@ -37,7 +39,11 @@ command also restarts the agent, so that the agent runs the new binary.`, return errors.New("the update command must be run as root, please run 'sudo fly update'") } - update, err := release.CheckForUpdates(cmd.Context()) + // Ctrl-C stops the download, and the partial download is removed. + ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM) + defer stop() + + update, err := release.CheckForUpdates(ctx) if err != nil { return fmt.Errorf("checking for updates: %w", err) } @@ -48,7 +54,7 @@ command also restarts the agent, so that the agent runs the new binary.`, fmt.Printf("This is not a release build (version %s). Latest release: %s\n", version.Version, latest) case !update.Available: fmt.Println("You are already running the latest version.") - return restartStaleAgent(cmd.Context()) + return restartStaleAgent(ctx) default: fmt.Printf("New version available: %s\n", latest) } @@ -65,12 +71,12 @@ command also restarts the agent, so that the agent runs the new binary.`, } fmt.Println("Updating...") - if err := release.SelfUpdate(cmd.Context(), update.Release); err != nil { + if err := release.SelfUpdate(ctx, update.Release); err != nil { return fmt.Errorf("updating: %w", err) } fmt.Printf("Updated to %s.\n", latest) - return restartAgent(cmd.Context()) + return restartAgent(ctx) }, } diff --git a/install.sh b/install.sh index a587a84..9dccd3d 100644 --- a/install.sh +++ b/install.sh @@ -148,6 +148,25 @@ download_release() { info_msg "Download completed successfully." } +# Examine the download with the checksum file of the release. The file has +# one line for each archive: " ". +verify_download() { + info_msg "Verifying the download with checksums.txt..." + + CHECKSUMS_URL="https://github.com/flywp/server-cli/releases/download/${TAG_NAME}/checksums.txt" + if ! curl -fsSL -o "$TEMP_DIR/checksums.txt" "$CHECKSUMS_URL"; then + rm -rf "$TEMP_DIR" + error_exit "Failed to download checksums.txt of ${TAG_NAME}. The download cannot be checked, so it is not installed." + fi + + if ! (cd "$TEMP_DIR" && grep " fly-${OS}-${ARCH}.tar.gz\$" checksums.txt | sha256sum -c --status -); then + rm -rf "$TEMP_DIR" + error_exit "The checksum of fly-${OS}-${ARCH}.tar.gz does not agree with checksums.txt. The download is not installed." + fi + + info_msg "Checksum verified." +} + # Extract and install install_binary() { info_msg "Extracting $DOWNLOAD_FILE..." @@ -215,6 +234,9 @@ main() { # Download the release download_release + + # Examine the download + verify_download # Install the binary install_binary diff --git a/internal/release/download_test.go b/internal/release/download_test.go index eaac30d..4b05a2c 100644 --- a/internal/release/download_test.go +++ b/internal/release/download_test.go @@ -145,3 +145,83 @@ func TestRemoveTempKeepsYoungFiles(t *testing.T) { t.Errorf("directory holds %v, want the young download and the binary only", names) } } + +// testRelease serves a release with the archive and a checksum file, and +// returns its description. +func testRelease(t *testing.T, archive []byte, checksums string) *GithubRelease { + t.Helper() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/fly-linux-amd64.tar.gz": + _, _ = w.Write(archive) + case "/checksums.txt": + _, _ = w.Write([]byte(checksums)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + + rel := &GithubRelease{TagName: "v0.3.0"} + for _, name := range []string{"fly-linux-amd64.tar.gz", "checksums.txt"} { + if name == "checksums.txt" && checksums == "" { + continue + } + rel.Assets = append(rel.Assets, Asset{Name: name, BrowserDownloadURL: srv.URL + "/" + name}) + } + return rel +} + +func TestSelfUpdateChecksTheArchive(t *testing.T) { + data := archive(t, map[string]string{"fly-linux-amd64": "new"}).Bytes() + other := sum([]byte("other")) + + tests := []struct { + name, checksums, want string + }{ + {"checksum agrees", sum(data) + " fly-linux-arm64.tar.gz\n" + sum(data) + " fly-linux-amd64.tar.gz\n", ""}, + {"binary mode mark", sum(data) + " *fly-linux-amd64.tar.gz\n", ""}, + {"checksum does not agree", other + " fly-linux-amd64.tar.gz\n", "the sha256 of"}, + {"no line for the archive", other + " fly-linux-arm64.tar.gz\n", "has no line for fly-linux-amd64.tar.gz"}, + {"no checksum file", "", "has no checksums.txt"}, + {"CRLF line ends", sum(data) + " fly-linux-amd64.tar.gz\r\n", ""}, + {"upper case hex", strings.ToUpper(sum(data)) + " fly-linux-amd64.tar.gz\n", ""}, + {"the same line two times", sum(data) + " fly-linux-amd64.tar.gz\n" + sum(data) + " fly-linux-amd64.tar.gz\n", ""}, + {"two different sums", sum(data) + " fly-linux-amd64.tar.gz\n" + other + " fly-linux-amd64.tar.gz\n", "two different sums"}, + {"a similar name", sum(data) + " fly-linux-amd64.tar.gz.sig\n", "has no line for fly-linux-amd64.tar.gz"}, + {"empty checksum file", "\n", "has no line for fly-linux-amd64.tar.gz"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + exe := filepath.Join(dir, "fly") + if err := os.WriteFile(exe, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + + err := selfUpdate(context.Background(), testRelease(t, data, tt.checksums), exe, "linux", "amd64") + + got, _ := os.ReadFile(exe) + if tt.want == "" { + if err != nil || string(got) != "new" { + t.Fatalf("selfUpdate() = %v, binary %q; want the new binary", err, got) + } + if entries, _ := os.ReadDir(dir); len(entries) != 1 { + t.Errorf("the directory holds %d files after the update, want only the binary", len(entries)) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("selfUpdate() error = %v, want %q", err, tt.want) + } + if string(got) != "old" { + t.Errorf("binary = %q, want the old binary unchanged", got) + } + if entries, _ := os.ReadDir(dir); len(entries) != 1 { + t.Errorf("the directory holds %d files, want only the binary", len(entries)) + } + }) + } +} diff --git a/internal/release/release.go b/internal/release/release.go index e962ad9..62747ab 100644 --- a/internal/release/release.go +++ b/internal/release/release.go @@ -16,6 +16,7 @@ import ( "path/filepath" "regexp" "runtime" + "strings" "syscall" "time" @@ -32,12 +33,16 @@ var httpClient = &http.Client{Timeout: 60 * time.Second} // maxBinarySize limits the size of the binary in a release archive. const maxBinarySize = 200 << 20 +// GithubRelease is a release in the GitHub API. type GithubRelease struct { - TagName string `json:"tag_name"` - Assets []struct { - Name string `json:"name"` - BrowserDownloadURL string `json:"browser_download_url"` - } `json:"assets"` + TagName string `json:"tag_name"` + Assets []Asset `json:"assets"` +} + +// Asset is a file of a release. +type Asset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` } // Update compares the latest release with the running version. @@ -124,13 +129,17 @@ func get(ctx context.Context, url string) (*http.Response, error) { return resp, nil } -// SelfUpdate replaces the running binary with the binary from release. -func SelfUpdate(ctx context.Context, release *GithubRelease) error { - assetURL := assetURL(release, runtime.GOOS, runtime.GOARCH) - if assetURL == "" { - return fmt.Errorf("no suitable binary found for this system (OS: %s, ARCH: %s)", runtime.GOOS, runtime.GOARCH) - } +// ChecksumsAsset is the checksum file of each release: one line for each +// archive, " " (the output of sha256sum). +const ChecksumsAsset = "checksums.txt" +// selfUpdateTimeout limits the download of an update. +const selfUpdateTimeout = 10 * time.Minute + +// SelfUpdate replaces the running binary with the binary from release. It +// installs the archive only when its sha256 agrees with the checksum file of +// the release. +func SelfUpdate(ctx context.Context, release *GithubRelease) error { exe, err := os.Executable() if err != nil { return fmt.Errorf("finding the current executable: %w", err) @@ -140,13 +149,71 @@ func SelfUpdate(ctx context.Context, release *GithubRelease) error { return fmt.Errorf("resolving symlinks: %w", err) } - resp, err := get(ctx, assetURL) + return selfUpdate(ctx, release, exe, runtime.GOOS, runtime.GOARCH) +} + +func selfUpdate(ctx context.Context, release *GithubRelease, exe, goos, goarch string) error { + ctx, cancel := context.WithTimeout(ctx, selfUpdateTimeout) + defer cancel() + + archiveURL := assetURL(release, goos, goarch) + if archiveURL == "" { + return fmt.Errorf("no suitable binary found for this system (OS: %s, ARCH: %s)", goos, goarch) + } + + name := BinaryName(goos, goarch) + sum, err := checksum(ctx, release, name+".tar.gz") + if err != nil { + return err + } + + archive, err := Download(ctx, archiveURL, sum, filepath.Dir(exe)) + if err != nil { + return err + } + defer func() { _ = os.Remove(archive) }() + + return Install(archive, exe, name) +} + +// checksum returns the sha256 of the release file name from the checksum +// file of the release. +func checksum(ctx context.Context, release *GithubRelease, name string) (string, error) { + url := asset(release, ChecksumsAsset) + if url == "" { + return "", fmt.Errorf("release %s has no %s, so its download cannot be checked", release.TagName, ChecksumsAsset) + } + + resp, err := get(ctx, url) if err != nil { - return fmt.Errorf("downloading update: %w", err) + return "", fmt.Errorf("downloading %s: %w", ChecksumsAsset, err) } defer func() { _ = resp.Body.Close() }() - return replaceBinary(exe, resp.Body, BinaryName(runtime.GOOS, runtime.GOARCH)) + data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return "", fmt.Errorf("downloading %s: %w", ChecksumsAsset, err) + } + + // install.sh reads the file with the same rules. Two different sums for + // one file make the file not valid: it is not clear which one is correct. + var sum string + for line := range strings.Lines(string(data)) { + // sha256sum marks a file that it read in binary mode with "*". + fields := strings.Fields(line) + if len(fields) != 2 || strings.TrimPrefix(fields[1], "*") != name { + continue + } + if sum != "" && !strings.EqualFold(sum, fields[0]) { + return "", fmt.Errorf("%s of release %s has two different sums for %s", ChecksumsAsset, release.TagName, name) + } + sum = fields[0] + } + if sum == "" { + return "", fmt.Errorf("%s of release %s has no line for %s", ChecksumsAsset, release.TagName, name) + } + + return sum, nil } // BinaryName is the name of the binary in a release archive. Releases must @@ -162,10 +229,14 @@ func assetURL(release *GithubRelease, goos, goarch string) string { return "" } - expectedName := BinaryName(goos, goarch) + ".tar.gz" - for _, asset := range release.Assets { - if asset.Name == expectedName { - return asset.BrowserDownloadURL + return asset(release, BinaryName(goos, goarch)+".tar.gz") +} + +// asset returns the download URL of the release file name, or "". +func asset(release *GithubRelease, name string) string { + for _, a := range release.Assets { + if a.Name == name { + return a.BrowserDownloadURL } } diff --git a/internal/release/release_test.go b/internal/release/release_test.go index a967c2a..a4681bb 100644 --- a/internal/release/release_test.go +++ b/internal/release/release_test.go @@ -121,10 +121,7 @@ func TestLatestReleaseErrors(t *testing.T) { func TestAssetURL(t *testing.T) { release := &GithubRelease{TagName: "v0.2.0"} for _, name := range []string{"fly-linux-amd64.tar.gz", "fly-linux-arm64.tar.gz"} { - release.Assets = append(release.Assets, struct { - Name string `json:"name"` - BrowserDownloadURL string `json:"browser_download_url"` - }{Name: name, BrowserDownloadURL: "https://example.com/" + name}) + release.Assets = append(release.Assets, Asset{Name: name, BrowserDownloadURL: "https://example.com/" + name}) } tests := []struct{ goos, goarch, want string }{