Skip to content

Commit

Permalink
feat: Configure connections using DNS domain names (#843)
Browse files Browse the repository at this point in the history
The dialer may be configured to use a DNS name to look up the instance
name instead of configuring the connector with the instance name directly.

Add a DNS TXT record for the Cloud SQL instance to a private DNS server
or a private Google Cloud DNS Zone used by your application. For example:

- Record type: TXT
- Name: `prod-db.mycompany.example.com` – This is the domain name used by the application
- Value: `my-project:region:my-instance` – This is the instance connection name

Configure the dialer with the cloudsqlconn.WithDNSResolver() option.

Open a database connection using the DNS name:

```
db, err := sql.Open(
    "cloudsql-mysql",
    "myuser:mypass@cloudsql-mysql(prod-db.mycompany.example.com)/mydb",
)
```

Part of #842
  • Loading branch information
hessjcg authored Aug 8, 2024
1 parent 511fae4 commit ec6b3a0
Show file tree
Hide file tree
Showing 7 changed files with 390 additions and 4 deletions.
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,64 @@ func connect() {
// ... etc
}
```
### Using DNS to identify an instance

The connector can be configured to use DNS to look up an instance. This would
allow you to configure your application to connect to a database instance, and
centrally configure which instance in your DNS zone.

#### Configure your DNS Records

Add a DNS TXT record for the Cloud SQL instance to a **private** DNS server
or a private Google Cloud DNS Zone used by your application.

**Note:** You are strongly discouraged from adding DNS records for your
Cloud SQL instances to a public DNS server. This would allow anyone on the
internet to discover the Cloud SQL instance name.

For example: suppose you wanted to use the domain name
`prod-db.mycompany.example.com` to connect to your database instance
`my-project:region:my-instance`. You would create the following DNS record:

- Record type: `TXT`
- Name: `prod-db.mycompany.example.com` – This is the domain name used by the application
- Value: `my-project:region:my-instance` – This is the instance name

#### Configure the connector

Configure the connector as described above, replacing the conenctor ID with
the DNS name.

Adapting the MySQL + database/sql example above:

```go
package main

import (
"database/sql"

"cloud.google.com/go/cloudsqlconn"
"cloud.google.com/go/cloudsqlconn/mysql/mysql"
)

func connect() {
cleanup, err := mysql.RegisterDriver("cloudsql-mysql",
cloudsqlconn.WithDNSResolver(),
cloudsqlconn.WithCredentialsFile("key.json"))
if err != nil {
// ... handle error
}
// call cleanup when you're done with the database connection
defer cleanup()

db, err := sql.Open(
"cloudsql-mysql",
"myuser:mypass@cloudsql-mysql(prod-db.mycompany.example.com)/mydb",
)
// ... etc
}
```


### Using Options

Expand Down
15 changes: 12 additions & 3 deletions dialer.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@ type Dialer struct {

// iamTokenSource supplies the OAuth2 token used for IAM DB Authn.
iamTokenSource oauth2.TokenSource

// resolver converts instance names into DNS names.
resolver instance.ConnectionNameResolver
}

var (
Expand Down Expand Up @@ -253,6 +256,11 @@ func NewDialer(ctx context.Context, opts ...Option) (*Dialer, error) {
if err != nil {
return nil, err
}
var r instance.ConnectionNameResolver = cloudsql.DefaultResolver
if cfg.resolver != nil {
r = cfg.resolver
}

d := &Dialer{
closed: make(chan struct{}),
cache: make(map[instance.ConnName]monitoredCache),
Expand All @@ -265,6 +273,7 @@ func NewDialer(ctx context.Context, opts ...Option) (*Dialer, error) {
dialerID: uuid.New().String(),
iamTokenSource: cfg.iamLoginTokenSource,
dialFunc: cfg.dialFunc,
resolver: r,
}
return d, nil
}
Expand All @@ -288,7 +297,7 @@ func (d *Dialer) Dial(ctx context.Context, icn string, opts ...DialOption) (conn
go trace.RecordDialError(context.Background(), icn, d.dialerID, err)
endDial(err)
}()
cn, err := instance.ParseConnName(icn)
cn, err := d.resolver.Resolve(ctx, icn)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -429,7 +438,7 @@ func validClientCert(
// the instance:
// https://cloud.google.com/sql/docs/mysql/admin-api/rest/v1beta4/SqlDatabaseVersion
func (d *Dialer) EngineVersion(ctx context.Context, icn string) (string, error) {
cn, err := instance.ParseConnName(icn)
cn, err := d.resolver.Resolve(ctx, icn)
if err != nil {
return "", err
}
Expand All @@ -449,7 +458,7 @@ func (d *Dialer) EngineVersion(ctx context.Context, icn string) (string, error)
// Use Warmup to start the refresh process early if you don't know when you'll
// need to call "Dial".
func (d *Dialer) Warmup(ctx context.Context, icn string, opts ...DialOption) error {
cn, err := instance.ParseConnName(icn)
cn, err := d.resolver.Resolve(ctx, icn)
if err != nil {
return err
}
Expand Down
69 changes: 68 additions & 1 deletion dialer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ import (
// and verifies the connection works end to end.
func testSuccessfulDial(
ctx context.Context, t *testing.T, d *Dialer, icn string, opts ...DialOption,
) {
testSucessfulDialWithInstanceName(ctx, t, d, icn, "my-instance", opts...)
}

// testSuccessfulDial uses the provided dialer to dial the specified instance
// and verifies the connection works end to end.
func testSucessfulDialWithInstanceName(
ctx context.Context, t *testing.T, d *Dialer, icn string, instanceName string, opts ...DialOption,
) {
conn, err := d.Dial(ctx, icn, opts...)
if err != nil {
Expand All @@ -50,7 +58,7 @@ func testSuccessfulDial(
if err != nil {
t.Fatalf("expected ReadAll to succeed, got error %v", err)
}
if string(data) != "my-instance" {
if string(data) != instanceName {
t.Fatalf(
"expected known response from the server, but got %v",
string(data),
Expand Down Expand Up @@ -1018,3 +1026,62 @@ func TestDialerInitializesLazyCache(t *testing.T) {
t.Fatalf("dialer was initialized with non-lazy type: %T", tt)
}
}

type fakeResolver struct {
domainName string
instanceName instance.ConnName
}

func (r *fakeResolver) Resolve(_ context.Context, name string) (instance.ConnName, error) {
// For TestDialerSuccessfullyDialsDnsTxtRecord
if name == r.domainName {
return r.instanceName, nil
}
// TestDialerFailsDnsTxtRecordMissing
return instance.ConnName{}, fmt.Errorf("no resolution for %q", name)
}

func TestDialerSuccessfullyDialsDnsTxtRecord(t *testing.T) {
inst := mock.NewFakeCSQLInstance(
"my-project", "my-region", "my-instance",
)
wantName, _ := instance.ParseConnName("my-project:my-region:my-instance")
d := setupDialer(t, setupConfig{
testInstance: inst,
reqs: []*mock.Request{
mock.InstanceGetSuccess(inst, 1),
mock.CreateEphemeralSuccess(inst, 1),
},
dialerOptions: []Option{
WithTokenSource(mock.EmptyTokenSource{}),
WithResolver(&fakeResolver{
domainName: "db.example.com",
instanceName: wantName,
}),
},
})

testSuccessfulDial(
context.Background(), t, d,
"db.example.com",
)
}

func TestDialerFailsDnsTxtRecordMissing(t *testing.T) {
inst := mock.NewFakeCSQLInstance(
"my-project", "my-region", "my-instance",
)
d := setupDialer(t, setupConfig{
testInstance: inst,
reqs: []*mock.Request{},
dialerOptions: []Option{
WithTokenSource(mock.EmptyTokenSource{}),
WithResolver(&fakeResolver{}),
},
})
_, err := d.Dial(context.Background(), "doesnt-exist.example.com")
wantMsg := "no resolution for \"doesnt-exist.example.com\""
if !strings.Contains(err.Error(), wantMsg) {
t.Fatalf("want = %v, got = %v", wantMsg, err)
}
}
11 changes: 11 additions & 0 deletions instance/conn_name.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
package instance

import (
"context"
"fmt"
"regexp"

Expand Down Expand Up @@ -74,3 +75,13 @@ func ParseConnName(cn string) (ConnName, error) {
}
return c, nil
}

// ConnectionNameResolver resolves the connection name string into a valid
// instance name. This allows an application to replace the default
// resolver with a custom implementation.
type ConnectionNameResolver interface {
// Resolve accepts a name, and returns a ConnName with the instance
// connection string for the name. If the name cannot be resolved, returns
// an error.
Resolve(ctx context.Context, name string) (ConnName, error)
}
123 changes: 123 additions & 0 deletions internal/cloudsql/resolver.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Copyright 2024 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cloudsql

import (
"context"
"fmt"
"net"
"sort"

"cloud.google.com/go/cloudsqlconn/instance"
)

// DNSResolver uses the default net.Resolver to find
// TXT records containing an instance name for a DNS record.
var DNSResolver = &DNSInstanceConnectionNameResolver{
dnsResolver: net.DefaultResolver,
}

// DefaultResolver simply parses instance names.
var DefaultResolver = &ConnNameResolver{}

// ConnNameResolver simply parses instance names. Implements
// InstanceConnectionNameResolver
type ConnNameResolver struct {
}

// Resolve returns the instance name, possibly using DNS. This will return an
// instance.ConnName or an error if it was unable to resolve an instance name.
func (r *ConnNameResolver) Resolve(_ context.Context, icn string) (instanceName instance.ConnName, err error) {
return instance.ParseConnName(icn)
}

// netResolver groups the methods on net.Resolver that are used by the DNS
// resolver implementation. This allows an application to replace the default
// net.DefaultResolver with a custom implementation. For example: the
// application may need to connect to a specific DNS server using a specially
// configured instance of net.Resolver.
type netResolver interface {
LookupTXT(ctx context.Context, name string) ([]string, error)
}

// DNSInstanceConnectionNameResolver can resolve domain names into instance names using
// TXT records in DNS. Implements InstanceConnectionNameResolver
type DNSInstanceConnectionNameResolver struct {
dnsResolver netResolver
}

// Resolve returns the instance name, possibly using DNS. This will return an
// instance.ConnName or an error if it was unable to resolve an instance name.
func (r *DNSInstanceConnectionNameResolver) Resolve(ctx context.Context, icn string) (instanceName instance.ConnName, err error) {
cn, err := instance.ParseConnName(icn)
if err != nil {
// The connection name was not project:region:instance
// Attempt to query a TXT record and see if it works instead.
cn, err = r.queryDNS(ctx, icn)
if err != nil {
return instance.ConnName{}, err
}
}

return cn, nil
}

// queryDNS attempts to resolve a TXT record for the domain name.
// The DNS TXT record's target field is used as instance name.
//
// This handles several conditions where the DNS records may be missing or
// invalid:
// - The domain name resolves to 0 DNS records - return an error
// - Some DNS records to not contain a well-formed instance name - return the
// first well-formed instance name. If none found return an error.
// - The domain name resolves to 2 or more DNS record - return first valid
// record when sorted by priority: lowest value first, then by target:
// alphabetically.
func (r *DNSInstanceConnectionNameResolver) queryDNS(ctx context.Context, domainName string) (instance.ConnName, error) {
// Attempt to query the TXT records.
// This could return a partial error where both err != nil && len(records) > 0.
records, err := r.dnsResolver.LookupTXT(ctx, domainName)
// If resolve failed and no records were found, return the error.
if err != nil {
return instance.ConnName{}, fmt.Errorf("unable to resolve TXT record for %q: %v", domainName, err)
}

// Process the records returning the first valid TXT record.

// Sort the TXT record values alphabetically by instance name
sort.Slice(records, func(i, j int) bool {
return records[i] < records[j]
})

var perr error
// Attempt to parse records, returning the first valid record.
for _, record := range records {
// Parse the target as a CN
cn, parseErr := instance.ParseConnName(record)
if parseErr != nil {
perr = fmt.Errorf("unable to parse TXT for %q -> %q : %v", domainName, record, parseErr)
continue
}
return cn, nil
}

// If all the records failed to parse, return one of the parse errors
if perr != nil {
return instance.ConnName{}, perr
}

// No records were found, return an error.
return instance.ConnName{}, fmt.Errorf("no valid TXT records found for %q", domainName)
}
Loading

0 comments on commit ec6b3a0

Please sign in to comment.