From ed30b8ab436be835d002fb5fff8756d906c7890a Mon Sep 17 00:00:00 2001 From: mkelvers Date: Mon, 27 Apr 2026 18:18:03 +0200 Subject: [PATCH] playback: use utls to bypass cloudflare --- api/playback/allanime_client.go | 318 ++++++++++++++++++++++++++++++-- go.mod | 11 +- go.sum | 16 ++ 3 files changed, 327 insertions(+), 18 deletions(-) diff --git a/api/playback/allanime_client.go b/api/playback/allanime_client.go index 2049297..eebdd5b 100644 --- a/api/playback/allanime_client.go +++ b/api/playback/allanime_client.go @@ -1,6 +1,7 @@ package playback import ( + "bufio" "bytes" "context" "crypto/aes" @@ -10,17 +11,23 @@ import ( "encoding/json" "fmt" "io" + "net" "net/http" + "net/url" "os" "regexp" "strconv" "strings" "time" + + utls "github.com/refraction-networking/utls" + "golang.org/x/net/http2" ) const ( allAnimeBaseURL = "https://api.allanime.day" - allAnimeReferer = "https://allmanga.to" + allAnimeReferer = "https://allmanga.to/" + allAnimeOrigin = "https://youtu-chan.com" defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0" allAnimeAESKey = "ALLANIME_AES_KEY" aniCliRawSourceURL = "https://raw.githubusercontent.com/pystardust/ani-cli/master/ani-cli" @@ -42,6 +49,58 @@ var ( } ) +// utlsRoundTripper uses uTLS + HTTP/2 to mimic Firefox and bypass Cloudflare JA3 detection +type utlsRoundTripper struct{} + +func (rt *utlsRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + fmt.Printf("[uTLS] RoundTrip: %s %s\n", req.Method, req.URL.Host) + dialer := &net.Dialer{Timeout: 10 * time.Second} + host := req.URL.Hostname() + port := req.URL.Port() + if port == "" { + port = "443" + } + addr := host + ":" + port + + rawConn, err := dialer.DialContext(req.Context(), "tcp", addr) + if err != nil { + return nil, fmt.Errorf("tcp dial: %w", err) + } + + uconn := utls.UClient(rawConn, &utls.Config{ + ServerName: host, + NextProtos: []string{"h2", "http/1.1"}, + }, utls.HelloFirefox_120) + + if err := uconn.HandshakeContext(req.Context()); err != nil { + uconn.Close() + return nil, fmt.Errorf("utls handshake: %w", err) + } + + alpn := uconn.ConnectionState().NegotiatedProtocol + if alpn == "h2" { + t := &http2.Transport{} + cc, err := t.NewClientConn(uconn) + if err != nil { + uconn.Close() + return nil, fmt.Errorf("http2 client conn: %w", err) + } + return cc.RoundTrip(req) + } + + // Fallback to HTTP/1.1 + if err := req.Write(uconn); err != nil { + uconn.Close() + return nil, fmt.Errorf("http1 write: %w", err) + } + return http.ReadResponse(bufio.NewReader(uconn), req) +} + +var allAnimeUTLSClient = &http.Client{ + Transport: &utlsRoundTripper{}, + Timeout: 15 * time.Second, +} + type searchResult struct { ID string MalID string @@ -55,12 +114,19 @@ type allAnimeClient struct { func newAllAnimeClient() *allAnimeClient { return &allAnimeClient{ - httpClient: &http.Client{Timeout: 12 * time.Second}, - extractor: newProviderExtractor(), + httpClient: &http.Client{ + Timeout: 12 * time.Second, + }, + extractor: newProviderExtractor(), } } func (c *allAnimeClient) graphqlRequest(ctx context.Context, query string, variables map[string]any) (map[string]any, error) { + // Ensure mode is lowercase if present in variables + if mode, ok := variables["translationType"].(string); ok { + variables["translationType"] = strings.ToLower(mode) + } + payload := map[string]any{ "query": query, "variables": variables, @@ -107,6 +173,215 @@ func (c *allAnimeClient) graphqlRequest(ctx context.Context, query string, varia return parsed, nil } +// query hash for episode embedding (pre-registered query) +const episodeQueryHash = "d405d0edd690624b66baba3068e0edc3ac90f1597d898a1ec8db4e5c43c00fec" + +func (c *allAnimeClient) graphqlRequestWithHash(ctx context.Context, showID, episode, mode string) (map[string]any, error) { + fmt.Printf("[ALLANIME] graphqlRequestWithHash called: showID=%s mode=%s ep=%s\n", showID, mode, episode) + // Ensure mode is lowercase + mode = strings.ToLower(mode) + + // Build JSON strings manually to match ani-cli's exact order and formatting + // ani-cli order: showId, translationType, episodeString + varsJSON := fmt.Sprintf(`{"showId":"%s","translationType":"%s","episodeString":"%s"}`, showID, mode, episode) + extJSON := fmt.Sprintf(`{"persistedQuery":{"version":1,"sha256Hash":"%s"}}`, episodeQueryHash) + + // URL-encode the JSON strings (match ani-cli's sed patterns exactly) + // Build GET URL with query parameters using url.QueryEscape + apiURL := fmt.Sprintf("%s/api?variables=%s&extensions=%s", + allAnimeBaseURL, + url.QueryEscape(varsJSON), + url.QueryEscape(extJSON)) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return nil, fmt.Errorf("create GET request: %w", err) + } + + // Match Firefox headers exactly + req.Header.Set("User-Agent", defaultUserAgent) + req.Header.Set("Accept", "*/*") + req.Header.Set("Accept-Language", "en-US,en;q=0.5") + req.Header.Set("Accept-Encoding", "identity") + req.Header.Set("Referer", allAnimeReferer) + req.Header.Set("Origin", allAnimeOrigin) + req.Header.Set("Sec-Fetch-Dest", "empty") + req.Header.Set("Sec-Fetch-Mode", "cors") + req.Header.Set("Sec-Fetch-Site", "cross-site") + + // Use uTLS + HTTP/2 client to bypass Cloudflare fingerprinting + resp, err := allAnimeUTLSClient.Do(req) + if err != nil { + return nil, fmt.Errorf("execute GET request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1022)) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + bodyPreview := string(respBody) + if len(bodyPreview) > 300 { + bodyPreview = bodyPreview[:300] + } + fmt.Printf("[uTLS] Response status=%d body=%s\n", resp.StatusCode, bodyPreview) + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GET status %d: %s", resp.StatusCode, string(respBody)) + } + + var parsed map[string]any + if err := json.Unmarshal(respBody, &parsed); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + + // Check for errors + if errs, ok := parsed["errors"].([]any); ok && len(errs) > 0 { + return nil, fmt.Errorf("graphql error: %v", errs[0]) + } + + // Check if we got tobeparsed (indicates success) + data, ok := parsed["data"].(map[string]any) + if !ok { + return nil, fmt.Errorf("no data in response") + } + + // tobeparsed can be either at data.tobeparsed or data.episode.tobeparsed + var toBeParsed string + if s, ok := data["tobeparsed"].(string); ok && s != "" { + toBeParsed = s + } else if episodeData, ok := data["episode"].(map[string]any); ok { + if s, ok := episodeData["tobeparsed"].(string); ok { + toBeParsed = s + } + } + + if toBeParsed != "" { + fmt.Printf("[uTLS] Got tobeparsed (len=%d), attempting decrypt...\n", len(toBeParsed)) + decrypted, err := decryptTobeparsed(toBeParsed) + if err != nil { + fmt.Printf("[uTLS] Decrypt error: %v\n", err) + return nil, fmt.Errorf("decrypt tobeparsed: %w", err) + } + fmt.Printf("[uTLS] Decrypted OK (len=%d), preview: %s\n", len(decrypted), string(decrypted[:min(len(decrypted), 200)])) + + var ep map[string]any + if jerr := json.Unmarshal(decrypted, &ep); jerr != nil { + return nil, fmt.Errorf("unmarshal decrypted: %w", jerr) + } + + // Decrypted JSON might have sourceUrls directly or under episode + var sourceURLs []any + if srcs, ok := ep["sourceUrls"].([]any); ok { + sourceURLs = srcs + } else if epInner, ok := ep["episode"].(map[string]any); ok { + if srcs, ok := epInner["sourceUrls"].([]any); ok { + sourceURLs = srcs + } + } + + fmt.Printf("[uTLS] Found sourceUrls: len=%d\n", len(sourceURLs)) + if len(sourceURLs) > 0 { + return map[string]any{ + "episode": map[string]any{ + "sourceUrls": sourceURLs, + }, + }, nil + } + } + + // Maybe sourceUrls came back unencrypted + if episodeData, ok := data["episode"].(map[string]any); ok { + if srcs, ok := episodeData["sourceUrls"].([]any); ok && len(srcs) > 0 { + return parsed, nil + } + } + + return nil, fmt.Errorf("no usable data in response") +} + +func getMapKeys(m map[string]any) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func (c *allAnimeClient) extractSourceURLsFromData(data map[string]any) []StreamSource { + episodeData, ok := data["episode"].(map[string]any) + if !ok { + return nil + } + + sourceURLs, ok := episodeData["sourceUrls"].([]any) + if !ok || len(sourceURLs) == 0 { + return nil + } + + references := buildSourceReferences(sourceURLs) + if len(references) == 0 { + return nil + } + + out := make([]StreamSource, 0, len(references)) + for _, ref := range references { + target := strings.TrimSpace(ref.URL) + if target == "" { + continue + } + + if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") { + sourceType := detectStreamType(target) + if sourceType == "unknown" { + sourceType = detectEmbedType(target) + } + + out = append(out, buildStreamSource(target, sourceType, ref.Name)) + continue + } + + decoded := decodeSourceURL(target) + if decoded == "" { + continue + } + + if strings.HasPrefix(decoded, "http://") || strings.HasPrefix(decoded, "https://") { + sourceType := detectStreamType(decoded) + if sourceType == "unknown" { + sourceType = detectEmbedType(decoded) + } + + out = append(out, buildStreamSource(decoded, sourceType, ref.Name)) + continue + } + + if !strings.HasPrefix(decoded, "/") { + decoded = "/" + decoded + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + extracted, err := c.extractor.ExtractVideoLinks(ctx, decoded) + if err != nil { + continue + } + + out = append(out, extracted...) + } + + return out +} + func (c *allAnimeClient) Search(ctx context.Context, query string, mode string) ([]searchResult, error) { graphqlQuery := `query($search: SearchInput, $limit: Int, $page: Int, $translationType: VaildTranslationTypeEnumType, $countryOrigin: VaildCountryOriginEnumType) { shows(search: $search, limit: $limit, page: $page, translationType: $translationType, countryOrigin: $countryOrigin) { @@ -235,19 +510,32 @@ func buildStreamSource(url, sourceType, provider string) StreamSource { } func (c *allAnimeClient) GetEpisodeSources(ctx context.Context, showID string, episode string, mode string) ([]StreamSource, error) { - graphqlQuery := `query($showId: String!, $translationType: VaildTranslationTypeEnumType!, $episodeString: String!) { + episodeQuery := `query($showId: String!, $translationType: VaildTranslationTypeEnumType!, $episodeString: String!) { episode(showId: $showId, translationType: $translationType, episodeString: $episodeString) { sourceUrls } }` - variables := map[string]any{ - "showId": showID, - "translationType": mode, - "episodeString": episode, + // First try persistent query approach (GET with query hash) + result, err := c.graphqlRequestWithHash(ctx, showID, episode, mode) + if err != nil { + fmt.Printf("[ALLANIME] graphqlRequestWithHash err: %v\n", err) + } else { + fmt.Printf("[ALLANIME] graphqlRequestWithHash got result, top keys: %v\n", getMapKeys(result)) + // Result is already in shape {"episode": {"sourceUrls": [...]}} + sources := c.extractSourceURLsFromData(result) + fmt.Printf("[ALLANIME] extractSourceURLsFromData returned %d sources\n", len(sources)) + if len(sources) > 0 { + return sources, nil + } } - result, err := c.graphqlRequest(ctx, graphqlQuery, variables) + // Fall back to standard POST + result, err = c.graphqlRequest(ctx, episodeQuery, map[string]any{ + "showId": showID, + "translationType": mode, + "episodeString": episode, + }) if err != nil { return nil, err } @@ -257,17 +545,17 @@ func (c *allAnimeClient) GetEpisodeSources(ctx context.Context, showID string, e return nil, fmt.Errorf("invalid source response") } - episodeData, err := extractEpisodeData(data) - if err != nil { - return nil, err + rawSourceURLs, ok := data["episode"].(map[string]any) + if !ok { + return nil, fmt.Errorf("invalid episode response") } - rawSourceURLs, ok := episodeData["sourceUrls"].([]any) - if !ok || len(rawSourceURLs) == 0 { + sourceURLs, ok := rawSourceURLs["sourceUrls"].([]any) + if !ok || len(sourceURLs) == 0 { return nil, fmt.Errorf("no source urls") } - references := buildSourceReferences(rawSourceURLs) + references := buildSourceReferences(sourceURLs) if len(references) == 0 { return nil, fmt.Errorf("no source references") } diff --git a/go.mod b/go.mod index 9e5b0f4..025d794 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module mal -go 1.24.0 +go 1.25.0 require ( github.com/PuerkitoBio/goquery v1.11.0 @@ -8,10 +8,15 @@ require ( github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 github.com/mattn/go-sqlite3 v1.14.40 - golang.org/x/crypto v0.45.0 + golang.org/x/crypto v0.50.0 ) require ( + github.com/andybalholm/brotli v1.1.0 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect - golang.org/x/net v0.47.0 // indirect + github.com/klauspost/compress v1.17.4 // indirect + github.com/refraction-networking/utls v1.8.2 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect ) diff --git a/go.sum b/go.sum index e1ababb..eb710b2 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43 github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ= github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY= github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= @@ -10,8 +12,12 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= +github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/mattn/go-sqlite3 v1.14.40 h1:f7+saIsbq4EF86mUqe0uiecQOJYMOdfi5uATADmUG94= github.com/mattn/go-sqlite3 v1.14.40/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= +github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= @@ -21,6 +27,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -37,6 +45,8 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -55,6 +65,10 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -73,6 +87,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=