diff --git a/changelog/unreleased/issue-3738 b/changelog/unreleased/issue-3738 new file mode 100644 index 000000000..479c6c58a --- /dev/null +++ b/changelog/unreleased/issue-3738 @@ -0,0 +1,8 @@ +Enhancement: Allow Github personal access token to be specified for `self-update` + +`restic self-update` previously only used unauthenticated GitHub API requests when checking for the latest release. This caused some users sharing IP addresses to hit the GitHub rate limit, resulting in a 403 Forbidden error and preventing updates. + +Restic still uses unauthenticated requests by default, but it now optionally supports authenticated GitHub API requests during `self-update`. Users can set the `$GITHUB_ACCESS_TOKEN` environment variable to use a [personal access token](https://github.com/settings/tokens) for this effect, avoiding update failures due to rate limiting. + +https://github.com/restic/restic/issues/3738 +https://github.com/restic/restic/pull/5568 \ No newline at end of file diff --git a/doc/020_installation.rst b/doc/020_installation.rst index 34cf62f81..f3c0b40b0 100644 --- a/doc/020_installation.rst +++ b/doc/020_installation.rst @@ -25,7 +25,12 @@ you can download and run without having to do additional installation work. Please see the :ref:`official_binaries` section below for various downloads. Official binaries can be updated in place by using the ``restic self-update`` -command. +command. + +The environment variable ``$GITHUB_ACCESS_TOKEN`` can be set to use a personal +access token when updating. This increases the rate limit through authenticated GitHub API +requests, and prevents update failures when many +unauthenticated requests have already been made from the same IP. Alpine Linux ============ diff --git a/internal/selfupdate/github.go b/internal/selfupdate/github.go index cdee8c74d..2ac1ef07f 100644 --- a/internal/selfupdate/github.go +++ b/internal/selfupdate/github.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "os" "strings" "time" @@ -45,6 +46,23 @@ type githubError struct { Message string } +func newGitHubRequest(ctx context.Context, url, acceptHeader string) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + // Set the Accept header to pin the API version + req.Header.Set("Accept", acceptHeader) + + // Add Authorization header if token is available + if token := os.Getenv("GITHUB_ACCESS_TOKEN"); token != "" { + req.Header.Set("Authorization", "token "+token) + } + + return req, nil +} + // GitHubLatestRelease uses the GitHub API to get information about the latest // release of a repository. func GitHubLatestRelease(ctx context.Context, owner, repo string) (Release, error) { @@ -52,14 +70,11 @@ func GitHubLatestRelease(ctx context.Context, owner, repo string) (Release, erro defer cancel() url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + req, err := newGitHubRequest(ctx, url, "application/vnd.github.v3+json") if err != nil { return Release{}, err } - // pin API version 3 - req.Header.Set("Accept", "application/vnd.github.v3+json") - res, err := http.DefaultClient.Do(req) if err != nil { return Release{}, err @@ -111,14 +126,11 @@ func GitHubLatestRelease(ctx context.Context, owner, repo string) (Release, erro } func getGithubData(ctx context.Context, url string) ([]byte, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + req, err := newGitHubRequest(ctx, url, "application/octet-stream") if err != nil { return nil, err } - // request binary data - req.Header.Set("Accept", "application/octet-stream") - res, err := http.DefaultClient.Do(req) if err != nil { return nil, err diff --git a/internal/selfupdate/github_test.go b/internal/selfupdate/github_test.go new file mode 100644 index 000000000..39be1e9dc --- /dev/null +++ b/internal/selfupdate/github_test.go @@ -0,0 +1,37 @@ +package selfupdate + +import ( + "context" + "net/http" + "testing" + + rtest "github.com/restic/restic/internal/test" +) + +func TestNewGitHubRequest(t *testing.T) { + ctx := context.Background() + url := "https://api.github.com/repos/restic/restic/releases/latest" + acceptHeader := "application/vnd.github.v3+json" + + t.Run("With GITHUB_ACCESS_TOKEN", func(t *testing.T) { + expectedToken := "testtoken123" + t.Setenv("GITHUB_ACCESS_TOKEN", expectedToken) + + req, err := newGitHubRequest(ctx, url, acceptHeader) + rtest.OK(t, err) + + rtest.Assert(t, req.Method == http.MethodGet, "expected method %s, got %s", http.MethodGet, req.Method) + rtest.Assert(t, req.URL.String() == url, "expected URL %s, got %s", url, req.URL.String()) + rtest.Assert(t, req.Header.Get("Accept") == acceptHeader, "expected Accept header %s, got %s", acceptHeader, req.Header.Get("Accept")) + rtest.Assert(t, req.Header.Get("Authorization") == "token "+expectedToken, "expected Authorization header 'token %s', got %s", expectedToken, req.Header.Get("Authorization")) + }) + + t.Run("Without GITHUB_ACCESS_TOKEN", func(t *testing.T) { + t.Setenv("GITHUB_ACCESS_TOKEN", "") + + req, err := newGitHubRequest(ctx, url, acceptHeader) + rtest.OK(t, err) + + rtest.Assert(t, req.Header.Get("Authorization") == "", "expected no Authorization header, got %s", req.Header.Get("Authorization")) + }) +}