Repository: supabase/cli
Pull request #436 (2022-09-03 14:20:39)

fix: use dns over https as fallback resolver
## What kind of change does this PR introduce?

Bug fix

## What is the current behavior?

netgo dns resolver is bugged on macOS

https://go.dev/src/net/net.go?s=16228:16259
https://github.com/golang/go/issues/12524

## What is the new behavior?

Adds cloudflare dns as a fallback. This avoids compiling with cgo enabled.

## Additional context

```bash
$ GODEBUG=netdns=go+2 go run main.go projects list
go package net: GODEBUG setting forcing use of Go's resolver
go package net: hostLookupOrder(api.supabase.io) = files,dns
```


coveralls: ## Pull Request Test Coverage Report for [Build 2984706178](https://coveralls.io/builds/52185822)

* **0** of **0**   changed or added relevant lines in **0** files are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage remained the same at **32.43%**

---



|  Totals | [![Coverage Status](https://coveralls.io/builds/52185822/badge)](https://coveralls.io/builds/52185822) |
| :-- | --: |
| Change from base [Build 2969713551](https://coveralls.io/builds/52121501): |  0.0% |
| Covered Lines: | 1628 |
| Relevant Lines: | 5020 |

---
##### 💛  - [Coveralls](https://coveralls.io)


Patch:
diff --git a/cmd/root.go b/cmd/root.go
index c022f50c..e847b65f 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -6,6 +6,7 @@ import (
 	"github.com/spf13/cobra"
 	"github.com/spf13/pflag"
 	"github.com/spf13/viper"
+	"github.com/supabase/cli/internal/utils"
 )
 
 // Passed from `-ldflags`: https://stackoverflow.com/q/11354518.
@@ -17,6 +18,11 @@ var rootCmd = &cobra.Command{
 	Version:       version,
 	SilenceErrors: true,
 	SilenceUsage:  true,
+	PersistentPreRun: func(cmd *cobra.Command, args []string) {
+		if viper.GetBool("DEBUG") {
+			cmd.SetContext(utils.WithTraceContext(cmd.Context()))
+		}
+	},
 }
 
 func Execute() {
diff --git a/internal/utils/api.go b/internal/utils/api.go
index 6020af45..d43e1ce5 100644
--- a/internal/utils/api.go
+++ b/internal/utils/api.go
@@ -1,7 +1,14 @@
 package utils
 
 import (
+	"context"
+	"crypto/tls"
+	"encoding/json"
 	"log"
+	"net"
+	"net/http"
+	"net/http/httptrace"
+	"net/textproto"
 	"sync"
 
 	"github.com/deepmap/oapi-codegen/pkg/securityprovider"
@@ -14,6 +21,110 @@ var (
 	apiClient  *supabase.ClientWithResponses
 )
 
+const (
+	// Ref: https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dns-parameters-4
+	dnsIPv4Type uint16 = 1
+	dnsIPv6Type uint16 = 28
+)
+
+type dnsAnswer struct {
+	Type uint16 `json:"type"`
+	Data string `json:"data"`
+}
+
+type dnsResponse struct {
+	Answer []dnsAnswer `json:",omitempty"`
+}
+
+// Performs DNS lookup via HTTPS, in case firewall blocks native netgo resolver.
+func fallbackLookupIP(ctx context.Context, address string) string {
+	host, port, err := net.SplitHostPort(address)
+	if err != nil {
+		return ""
+	}
+	// Ref: https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/make-api-requests/dns-json
+	req, err := http.NewRequestWithContext(ctx, "GET", "https://1.1.1.1/dns-query?name="+host, nil)
+	if err != nil {
+		return ""
+	}
+	req.Header.Add("accept", "application/dns-json")
+	// Sends request
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		return ""
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		return ""
+	}
+	// Parses response
+	var data dnsResponse
+	dec := json.NewDecoder(resp.Body)
+	if err := dec.Decode(&data); err != nil {
+		return ""
+	}
+	// Look for first valid IP
+	for _, answer := range data.Answer {
+		if answer.Type == dnsIPv4Type || answer.Type == dnsIPv6Type {
+			return net.JoinHostPort(answer.Data, port)
+		}
+	}
+	return ""
+}
+
+func WithTraceContext(ctx context.Context) context.Context {
+	trace := &httptrace.ClientTrace{
+		DNSStart: func(info httptrace.DNSStartInfo) {
+			log.Printf("DNS Start: %+v\n", info)
+		},
+		DNSDone: func(info httptrace.DNSDoneInfo) {
+			if info.Err != nil {
+				log.Println("DNS Error:", info.Err)
+			} else {
+				log.Printf("DNS Done: %+v\n", info)
+			}
+		},
+		ConnectStart: func(network, addr string) {
+			log.Println("Connect Start:", network, addr)
+		},
+		ConnectDone: func(network, addr string, err error) {
+			if err != nil {
+				log.Println("Connect Error:", network, addr, err)
+			} else {
+				log.Println("Connect Done:", network, addr)
+			}
+		},
+		TLSHandshakeStart: func() {
+			log.Println("TLS Start")
+		},
+		TLSHandshakeDone: func(cs tls.ConnectionState, err error) {
+			if err != nil {
+				log.Println("TLS Error:", err)
+			} else {
+				log.Printf("TLS Done: %+v\n", cs)
+			}
+		},
+		WroteHeaderField: func(key string, value []string) {
+			log.Println("Sent Header:", key, value)
+		},
+		WroteRequest: func(wr httptrace.WroteRequestInfo) {
+			if wr.Err != nil {
+				log.Println("Send Error:", wr.Err)
+			} else {
+				log.Println("Send Done")
+			}
+		},
+		Got1xxResponse: func(code int, header textproto.MIMEHeader) error {
+			log.Println("Recv 1xx:", code, header)
+			return nil
+		},
+		GotFirstResponseByte: func() {
+			log.Println("Recv First Byte")
+		},
+	}
+	return httptrace.WithClientTrace(ctx, trace)
+}
+
 func GetSupabase() *supabase.ClientWithResponses {
 	clientOnce.Do(func() {
 		token, err := LoadAccessToken()
@@ -24,6 +135,19 @@ func GetSupabase() *supabase.ClientWithResponses {
 		if err != nil {
 			log.Fatalln(err)
 		}
+		if t, ok := http.DefaultTransport.(*http.Transport); ok {
+			dialContext := t.DialContext
+			t.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
+				conn, err := dialContext(ctx, network, address)
+				// Workaround when pure Go DNS resolver fails https://github.com/golang/go/issues/12524
+				if err, ok := err.(*net.OpError); ok && err.Op == "dial" {
+					if ip := fallbackLookupIP(ctx, address); ip != "" {
+						return dialContext(ctx, network, ip)
+					}
+				}
+				return conn, err
+			}
+		}
 		apiClient, err = supabase.NewClientWithResponses(
 			GetSupabaseAPIHost(),
 			supabase.WithRequestEditorFn(provider.Intercept),


Test patch:
diff --git a/internal/utils/api_test.go b/internal/utils/api_test.go
new file mode 100644
index 00000000..ccf9fdd2
--- /dev/null
+++ b/internal/utils/api_test.go
@@ -0,0 +1,119 @@
+package utils
+
+import (
+	"context"
+	"errors"
+	"net/http"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/supabase/cli/internal/testing/apitest"
+	"gopkg.in/h2non/gock.v1"
+)
+
+func TestFallbackDNS(t *testing.T) {
+	const host = "api.supabase.io"
+
+	t.Run("resolves IPv4 with CloudFlare", func(t *testing.T) {
+		// Setup http mock
+		defer gock.OffAll()
+		gock.New("https://1.1.1.1").
+			Get("/dns-query").
+			MatchParam("name", host).
+			MatchHeader("accept", "application/dns-json").
+			Reply(http.StatusOK).
+			JSON(&dnsResponse{Answer: []dnsAnswer{
+				{Type: dnsIPv4Type, Data: "127.0.0.1"},
+			}})
+		// Run test
+		ip := fallbackLookupIP(context.Background(), host+":443")
+		// Validate output
+		assert.Equal(t, "127.0.0.1:443", ip)
+		assert.Empty(t, apitest.ListUnmatchedRequests())
+	})
+
+	t.Run("resolves IPv6 recursively", func(t *testing.T) {
+		// Setup http mock
+		defer gock.OffAll()
+		gock.New("https://1.1.1.1").
+			Get("/dns-query").
+			MatchParam("name", "api.supabase.com").
+			MatchHeader("accept", "application/dns-json").
+			Reply(http.StatusOK).
+			JSON(&dnsResponse{Answer: []dnsAnswer{
+				{Type: 5, Data: "supabase-api.fly.dev."},
+				{Type: dnsIPv6Type, Data: "2606:2800:220:1:248:1893:25c8:1946"},
+			}})
+		// Run test
+		ip := fallbackLookupIP(context.Background(), "api.supabase.com:443")
+		// Validate output
+		assert.Equal(t, "[2606:2800:220:1:248:1893:25c8:1946]:443", ip)
+		assert.Empty(t, apitest.ListUnmatchedRequests())
+	})
+
+	t.Run("empty on malformed address", func(t *testing.T) {
+		assert.Equal(t, "", fallbackLookupIP(context.Background(), "bad?url"))
+	})
+
+	t.Run("empty on network failure", func(t *testing.T) {
+		// Setup http mock
+		defer gock.OffAll()
+		gock.New("https://1.1.1.1").
+			Get("/dns-query").
+			MatchParam("name", host).
+			MatchHeader("accept", "application/dns-json").
+			ReplyError(errors.New("network error"))
+		// Run test
+		ip := fallbackLookupIP(context.Background(), host+":443")
+		// Validate output
+		assert.Equal(t, "", ip)
+		assert.Empty(t, apitest.ListUnmatchedRequests())
+	})
+
+	t.Run("empty on service unavailable", func(t *testing.T) {
+		// Setup http mock
+		defer gock.OffAll()
+		gock.New("https://1.1.1.1").
+			Get("/dns-query").
+			MatchParam("name", host).
+			MatchHeader("accept", "application/dns-json").
+			Reply(http.StatusServiceUnavailable)
+		// Run test
+		ip := fallbackLookupIP(context.Background(), host+":443")
+		// Validate output
+		assert.Equal(t, "", ip)
+		assert.Empty(t, apitest.ListUnmatchedRequests())
+	})
+
+	t.Run("empty on malformed json", func(t *testing.T) {
+		// Setup http mock
+		defer gock.OffAll()
+		gock.New("https://1.1.1.1").
+			Get("/dns-query").
+			MatchParam("name", host).
+			MatchHeader("accept", "application/dns-json").
+			Reply(http.StatusOK).
+			JSON("malformed")
+		// Run test
+		ip := fallbackLookupIP(context.Background(), host+":443")
+		// Validate output
+		assert.Equal(t, "", ip)
+		assert.Empty(t, apitest.ListUnmatchedRequests())
+	})
+
+	t.Run("empty on no answer", func(t *testing.T) {
+		// Setup http mock
+		defer gock.OffAll()
+		gock.New("https://1.1.1.1").
+			Get("/dns-query").
+			MatchParam("name", host).
+			MatchHeader("accept", "application/dns-json").
+			Reply(http.StatusOK).
+			JSON(&dnsResponse{})
+		// Run test
+		ip := fallbackLookupIP(context.Background(), host+":443")
+		// Validate output
+		assert.Equal(t, "", ip)
+		assert.Empty(t, apitest.ListUnmatchedRequests())
+	})
+}