diff --git a/core/install.sh b/core/install.sh index 8e4b8c6f7..12797066c 100755 --- a/core/install.sh +++ b/core/install.sh @@ -35,11 +35,21 @@ aarch64) ;; esac -# Get the latest release version -LATEST_VERSION=$(curl -sL https://api.github.com/repos/duxlabs-dev/dux-shell/releases/latest | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') +REPO_URL="https://github.com/duxlabs-dev/dux-shell" + +# The web redirect has no per-ip quota; api.github.com allows 60 unauthenticated requests per hour per ip +LATEST_VERSION=$(curl -sI "$REPO_URL/releases/latest" | tr -d '\r' | awk 'tolower($1) == "location:" { sub(".*/releases/tag/", "", $2); print $2 }') + +if [ -z "$LATEST_VERSION" ]; then + API_RESPONSE=$(curl -sL https://api.github.com/repos/duxlabs-dev/dux-shell/releases/latest) + LATEST_VERSION=$(printf "%s" "$API_RESPONSE" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') +fi if [ -z "$LATEST_VERSION" ]; then printf "%bError: Could not fetch latest version%b\n" "$RED" "$NC" + if printf "%s" "$API_RESPONSE" | grep -q "rate limit"; then + printf "GitHub API rate limit reached for your IP address (60 requests per hour). Wait an hour or install from a different network.\n" + fi exit 1 fi @@ -52,7 +62,7 @@ cd "$TEMP_DIR" || exit 1 # Download the gzipped binary and its checksum printf "%bDownloading installer...%b\n" "$GREEN" "$NC" -RELEASE_URL="https://github.com/duxlabs-dev/dux-shell/releases/download/$LATEST_VERSION" +RELEASE_URL="$REPO_URL/releases/download/$LATEST_VERSION" # install.duxlabs.dev serves this file from master; releases before 2.0.1 only ship dankinstall-*.gz ASSET="duxinstall-$ARCH.gz" if ! curl -fsLI "$RELEASE_URL/$ASSET" >/dev/null; then diff --git a/core/internal/netfetch/release.go b/core/internal/netfetch/release.go index f36e4c415..6693e821c 100644 --- a/core/internal/netfetch/release.go +++ b/core/internal/netfetch/release.go @@ -3,14 +3,72 @@ package netfetch import ( "context" "encoding/json" + "errors" "fmt" + "net/http" + "path" + "strings" "time" ) -const latestReleaseURL = "https://api.github.com/repos/duxlabs-dev/dux-shell/releases/latest" +const ( + latestReleaseRedirectURL = "https://github.com/duxlabs-dev/dux-shell/releases/latest" + latestReleaseAPIURL = "https://api.github.com/repos/duxlabs-dev/dux-shell/releases/latest" + releaseLookupTimeout = 10 * time.Second +) + +var ErrGitHubRateLimited = errors.New("github api rate limit exceeded (60 unauthenticated requests per hour per ip)") func LatestReleaseTag(ctx context.Context) (string, error) { - body, err := Bytes(ctx, latestReleaseURL, Options{Timeout: 10 * time.Second}) + tag, redirectErr := latestTagFromRedirect(ctx, latestReleaseRedirectURL) + if redirectErr == nil { + return tag, nil + } + + tag, apiErr := latestTagFromAPI(ctx, latestReleaseAPIURL) + if apiErr == nil { + return tag, nil + } + return "", fmt.Errorf("release redirect: %w; release api: %w", redirectErr, apiErr) +} + +func latestTagFromRedirect(ctx context.Context, url string) (string, error) { + opts := Options{ + Timeout: releaseLookupTimeout, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } + req, err := opts.request(ctx, url) + if err != nil { + return "", err + } + req.Method = http.MethodHead + + resp, err := opts.client().Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode < 300 || resp.StatusCode >= 400 { + return "", &StatusError{Code: resp.StatusCode} + } + + location := resp.Header.Get("Location") + tag := path.Base(location) + if tag == "" || tag == "." || tag == "/" || !strings.Contains(location, "/releases/tag/") { + return "", fmt.Errorf("unexpected release redirect: %q", location) + } + return tag, nil +} + +func latestTagFromAPI(ctx context.Context, url string) (string, error) { + body, err := Bytes(ctx, url, Options{Timeout: releaseLookupTimeout}) + var status *StatusError + if errors.As(err, &status) && (status.Code == http.StatusForbidden || status.Code == http.StatusTooManyRequests) { + return "", ErrGitHubRateLimited + } if err != nil { return "", err }