master
go 278 lines 8.32 KB
Raw
1 package commands
2
3 // This file implements fetching Kubo release binaries from GitHub Releases.
4 //
5 // We use GitHub Releases instead of dist.ipfs.tech because GitHub is harder
6 // to censor. Many networks and regions block or interfere with IPFS-specific
7 // infrastructure, but GitHub is widely accessible and its TLS-protected API
8 // is difficult to selectively block without breaking many other services.
9
10 import (
11 "bytes"
12 "context"
13 "crypto/sha512"
14 "encoding/hex"
15 "encoding/json"
16 "fmt"
17 "io"
18 "net/http"
19 "os"
20 "runtime"
21 "strings"
22
23 version "github.com/ipfs/kubo"
24 )
25
26 const (
27 githubOwner = "ipfs"
28 githubRepo = "kubo"
29
30 githubAPIBase = "https://api.github.com"
31
32 // maxDownloadSize is the maximum allowed binary archive size (200 MB).
33 maxDownloadSize = 200 << 20
34 )
35
36 // githubReleaseFmt is the default GitHub Releases API URL prefix.
37 // It is a var (not const) so unit tests can point API calls at a mock server.
38 var githubReleaseFmt = githubAPIBase + "/repos/" + githubOwner + "/" + githubRepo + "/releases"
39
40 // githubReleaseBaseURL returns the Releases API base URL. It normally
41 // returns githubReleaseFmt.
42 //
43 // If TEST_KUBO_UPDATE_GITHUB_URL is set, that value is used instead.
44 // This is a test-only escape hatch -- the TEST_ prefix is the gate,
45 // signaling that production users should never set it. The integration
46 // tests in test/cli/update_test.go use it to redirect API calls to a
47 // local httptest mock server so the install pipeline can be exercised
48 // without hitting real GitHub.
49 func githubReleaseBaseURL() string {
50 if u := os.Getenv("TEST_KUBO_UPDATE_GITHUB_URL"); u != "" {
51 return u
52 }
53 return githubReleaseFmt
54 }
55
56 // ghRelease represents a GitHub release.
57 type ghRelease struct {
58 TagName string `json:"tag_name"`
59 Prerelease bool `json:"prerelease"`
60 Assets []ghAsset `json:"assets"`
61 }
62
63 // ghAsset represents a release asset on GitHub.
64 type ghAsset struct {
65 Name string `json:"name"`
66 Size int64 `json:"size"`
67 BrowserDownloadURL string `json:"browser_download_url"`
68 }
69
70 // githubGet performs an authenticated GET request to the GitHub API.
71 // It honors GITHUB_TOKEN or GH_TOKEN env vars to avoid the 60 req/hr
72 // unauthenticated rate limit.
73 func githubGet(ctx context.Context, url string) (*http.Response, error) {
74 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
75 if err != nil {
76 return nil, err
77 }
78
79 req.Header.Set("Accept", "application/vnd.github+json")
80 req.Header.Set("User-Agent", "kubo/"+version.CurrentVersionNumber)
81
82 if token := githubToken(); token != "" {
83 req.Header.Set("Authorization", "Bearer "+token)
84 }
85
86 resp, err := http.DefaultClient.Do(req)
87 if err != nil {
88 return nil, err
89 }
90
91 if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
92 resp.Body.Close()
93 hint := ""
94 if githubToken() == "" {
95 hint = " (hint: set GITHUB_TOKEN or GH_TOKEN to avoid rate limits)"
96 }
97 return nil, fmt.Errorf("GitHub API rate limit exceeded%s", hint)
98 }
99
100 if resp.StatusCode != http.StatusOK {
101 resp.Body.Close()
102 return nil, fmt.Errorf("GitHub API returned HTTP %d for %s", resp.StatusCode, url)
103 }
104
105 return resp, nil
106 }
107
108 func githubToken() string {
109 if t := os.Getenv("GITHUB_TOKEN"); t != "" {
110 return t
111 }
112 return os.Getenv("GH_TOKEN")
113 }
114
115 // githubLatestRelease returns the newest release that has a platform asset
116 // for the current GOOS/GOARCH. This avoids false positives when a release
117 // tag exists but artifacts haven't been uploaded yet.
118 func githubLatestRelease(ctx context.Context, includePre bool) (*ghRelease, error) {
119 releases, err := githubListReleases(ctx, 10, includePre)
120 if err != nil {
121 return nil, err
122 }
123
124 for i := range releases {
125 want := assetNameForPlatformTag(releases[i].TagName)
126 for _, a := range releases[i].Assets {
127 if a.Name == want {
128 return &releases[i], nil
129 }
130 }
131 }
132 return nil, fmt.Errorf("no release found with a binary for %s/%s", runtime.GOOS, runtime.GOARCH)
133 }
134
135 // githubListReleases fetches up to count releases, optionally including prereleases.
136 func githubListReleases(ctx context.Context, count int, includePre bool) ([]ghRelease, error) {
137 // Fetch more than needed so we can filter prereleases and still return count results.
138 perPage := count
139 if !includePre {
140 perPage = count * 3
141 }
142 if perPage > 100 {
143 perPage = 100
144 }
145
146 url := fmt.Sprintf("%s?per_page=%d", githubReleaseBaseURL(), perPage)
147 resp, err := githubGet(ctx, url)
148 if err != nil {
149 return nil, err
150 }
151 defer resp.Body.Close()
152
153 var all []ghRelease
154 if err := json.NewDecoder(resp.Body).Decode(&all); err != nil {
155 return nil, fmt.Errorf("decoding GitHub releases: %w", err)
156 }
157
158 var filtered []ghRelease
159 for _, r := range all {
160 if !includePre && r.Prerelease {
161 continue
162 }
163 filtered = append(filtered, r)
164 if len(filtered) >= count {
165 break
166 }
167 }
168 return filtered, nil
169 }
170
171 // githubReleaseByTag fetches a single release by its git tag.
172 func githubReleaseByTag(ctx context.Context, tag string) (*ghRelease, error) {
173 url := fmt.Sprintf("%s/tags/%s", githubReleaseBaseURL(), tag)
174 resp, err := githubGet(ctx, url)
175 if err != nil {
176 return nil, err
177 }
178 defer resp.Body.Close()
179
180 var rel ghRelease
181 if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
182 return nil, fmt.Errorf("decoding GitHub release: %w", err)
183 }
184 return &rel, nil
185 }
186
187 // findReleaseAsset locates the platform-appropriate asset in a release.
188 // It fails immediately with a clear message if:
189 // - the release tag does not exist on GitHub (typo, unreleased version)
190 // - the release exists but has no binary for this OS/arch (CI still building)
191 func findReleaseAsset(ctx context.Context, tag string) (*ghRelease, *ghAsset, error) {
192 rel, err := githubReleaseByTag(ctx, tag)
193 if err != nil {
194 return nil, nil, fmt.Errorf("release %s not found on GitHub: %w", tag, err)
195 }
196
197 want := assetNameForPlatformTag(tag)
198 for i := range rel.Assets {
199 if rel.Assets[i].Name == want {
200 return rel, &rel.Assets[i], nil
201 }
202 }
203
204 return nil, nil, fmt.Errorf(
205 "release %s exists but has no binary for %s/%s yet; build artifacts may still be uploading, try again in a few hours",
206 tag, runtime.GOOS, runtime.GOARCH)
207 }
208
209 // downloadAsset downloads a release asset by its browser_download_url.
210 // This hits GitHub's CDN directly, not the API, so no auth headers are needed.
211 func downloadAsset(ctx context.Context, url string) ([]byte, error) {
212 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
213 if err != nil {
214 return nil, err
215 }
216 req.Header.Set("User-Agent", "kubo/"+version.CurrentVersionNumber)
217
218 resp, err := http.DefaultClient.Do(req)
219 if err != nil {
220 return nil, fmt.Errorf("downloading asset: %w", err)
221 }
222 defer resp.Body.Close()
223
224 if resp.StatusCode != http.StatusOK {
225 return nil, fmt.Errorf("download returned HTTP %d", resp.StatusCode)
226 }
227
228 data, err := io.ReadAll(io.LimitReader(resp.Body, maxDownloadSize+1))
229 if err != nil {
230 return nil, fmt.Errorf("reading download: %w", err)
231 }
232 if int64(len(data)) > maxDownloadSize {
233 return nil, fmt.Errorf("download exceeds maximum size of %d bytes", maxDownloadSize)
234 }
235 return data, nil
236 }
237
238 // downloadAndVerifySHA512 downloads the .sha512 sidecar file for the given
239 // archive URL and verifies the archive data against it.
240 func downloadAndVerifySHA512(ctx context.Context, data []byte, archiveURL string) error {
241 sha512URL := archiveURL + ".sha512"
242 checksumData, err := downloadAsset(ctx, sha512URL)
243 if err != nil {
244 return fmt.Errorf("downloading checksum file: %w", err)
245 }
246
247 // Parse "<hex> <filename>\n" format (standard sha512sum output).
248 fields := strings.Fields(string(checksumData))
249 if len(fields) < 1 {
250 return fmt.Errorf("empty or malformed .sha512 file")
251 }
252 wantHex := fields[0]
253
254 return verifySHA512(data, wantHex)
255 }
256
257 // verifySHA512 checks that data matches the given hex-encoded SHA-512 hash.
258 func verifySHA512(data []byte, wantHex string) error {
259 want, err := hex.DecodeString(wantHex)
260 if err != nil {
261 return fmt.Errorf("invalid hex in SHA-512 checksum: %w", err)
262 }
263 got := sha512.Sum512(data)
264 if !bytes.Equal(got[:], want) {
265 return fmt.Errorf("SHA-512 mismatch: expected %s, got %x", wantHex, got[:])
266 }
267 return nil
268 }
269
270 // assetNameForPlatformTag returns the expected archive filename for a given
271 // release tag and the current GOOS/GOARCH.
272 func assetNameForPlatformTag(tag string) string {
273 ext := "tar.gz"
274 if runtime.GOOS == "windows" {
275 ext = "zip"
276 }
277 return fmt.Sprintf("kubo_%s_%s-%s.%s", tag, runtime.GOOS, runtime.GOARCH, ext)
278 }