Skip to content

Commit

Permalink
pkg/linksharing/sharing: add static dns client
Browse files Browse the repository at this point in the history
This allows to adjust DNSClient to use a local file for testing
purposes, avoiding the need to use public DNS servers.

Change-Id: If687eb5417a3763b62b32a277f619f80384fe76e
  • Loading branch information
egonelbre authored and Storj Robot committed Apr 26, 2024
1 parent 31cfe0d commit ebd773a
Show file tree
Hide file tree
Showing 5 changed files with 239 additions and 11 deletions.
40 changes: 40 additions & 0 deletions docs/linksharing.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ $ linksharing run
```

## Standard Linksharing with Uplink

Anything shared with `--url` will be readonly and available publicly (no secret key needed).

`uplink share --url sj://<path>`
Expand Down Expand Up @@ -123,6 +124,45 @@ or [S3 gateway](https://docs.storj.io/api-reference/s3-gateway). Download the [U

[Maxmind]: https://dev.maxmind.com/geoip/geoipupdate/

## Testing DNS related configuration locally

When testing different DNS configuration and options it can be annoying to
configure a public DNS and later debug what is misconfigured when it doesn't
work. The following approach also uses access grants instead of authservice,
which avoids the need to having a separate service running.

It's possible to specify a zone file to use instead of a DNS service:

```
linksharing run --dns-server file:local.zone --public-url http://linksharing.local:20020 --landing-redirect-target ""
```

Where the `local.zone` contains:

```
txt-alpha.linksharing.local. IN CNAME linksharing.local.
txt-alpha.linksharing.local. IN TXT storj-tls:false
txt-alpha.linksharing.local. IN TXT storj-access-1:<access grant>
txt-alpha.linksharing.local. IN TXT storj-access-2:<access grant continued>
txt-alpha.linksharing.local. IN TXT storj-root:<bucket>
txt-beta.linksharing.local. IN CNAME linksharing.local.
txt-beta.linksharing.local. IN TXT storj-tls:false
txt-beta.linksharing.local. IN TXT storj-access-1:<access grant>
txt-beta.linksharing.local. IN TXT storj-access-2:<access grant continued>
txt-beta.linksharing.local. IN TXT storj-root:<bucket>
```

The access grant has to be split across multiple TXT entries, because there's a
per entry limit due to packet sizes. Also, remember to adjust your `etc/hosts`
with:

```
127.0.0.1 linksharing.local
127.0.0.1 alpha.linksharing.local
127.0.0.1 beta.linksharing.local
```

## Custom response metadata

Linksharing will respond with certain headers if they are set on an object's metadata.
Expand Down
59 changes: 51 additions & 8 deletions pkg/linksharing/sharing/dns.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ package sharing
import (
"context"
"net/url"
"os"
"strings"
"time"

"github.com/miekg/dns"
Expand All @@ -21,30 +23,61 @@ var (
type DNSClient struct {
c *dns.Client
dnsServer string

static *StaticDNSClient
}

// NewDNSClient creates a DNS Client that uses the given
// dnsServerAddr. Currently requires that the DNS Server speaks TCP.
func NewDNSClient(dnsServerAddr string) (*DNSClient, error) {
if strings.HasPrefix(dnsServerAddr, "file:") {
path := strings.TrimPrefix(dnsServerAddr, "file:")

data, err := os.ReadFile(path)
if err != nil {
return nil, errDNS.New("unable to read %q: %w", path, err)
}

static, err := ParseStaticDNSClientFromZoneFile(data)
if err != nil {
return nil, errDNS.New("unable to parse %q: %w", path, err)
}

return &DNSClient{
static: static,
}, nil
}

return &DNSClient{
c: &dns.Client{Net: "tcp"},
dnsServer: dnsServerAddr,
}, nil
}

// Lookup is a helper method that never returns truncated DNS messages.
// The current implementation does this by doing all lookups over TCP.
func (cli *DNSClient) Lookup(ctx context.Context, host string, recordType uint16) (_ *dns.Msg, err error) {
// LookupTXTRecordSet fetches record set from the specified host.
func (cli *DNSClient) LookupTXTRecordSet(ctx context.Context, host string) (_ *TXTRecordSet, err error) {
defer mon.Task()(&ctx)(&err)
m := dns.Msg{}
m.SetQuestion(dns.Fqdn(host), recordType)
r, _, err := cli.c.ExchangeContext(ctx, &m, cli.dnsServer)
return r, errDNS.Wrap(err)

if cli.static != nil {
return cli.static.LookupTXTRecordSet(ctx, host)
}

r, err := cli.lookup(ctx, host, dns.TypeTXT)
if err != nil {
return nil, errDNS.Wrap(err)
}
return ResponseToTXTRecordSet(r), nil
}

// ValidateCNAME checks name has a CNAME record with a value of one of the public URL bases.
func (cli *DNSClient) ValidateCNAME(ctx context.Context, name string, bases []*url.URL) (err error) {
msg, err := cli.Lookup(ctx, name, dns.TypeCNAME)
defer mon.Task()(&ctx)(&err)

if cli.static != nil {
return cli.static.ValidateCNAME(ctx, name, bases)
}

msg, err := cli.lookup(ctx, name, dns.TypeCNAME)
if err != nil {
return err
}
Expand All @@ -67,6 +100,16 @@ func (cli *DNSClient) ValidateCNAME(ctx context.Context, name string, bases []*u
return errs.New("domain %q does not contain a CNAME with any public host", name)
}

// lookup is a helper method that never returns truncated DNS messages.
// The current implementation does this by doing all lookups over TCP.
func (cli *DNSClient) lookup(ctx context.Context, host string, recordType uint16) (_ *dns.Msg, err error) {
defer mon.Task()(&ctx)(&err)
m := dns.Msg{}
m.SetQuestion(dns.Fqdn(host), recordType)
r, _, err := cli.c.ExchangeContext(ctx, &m, cli.dnsServer)
return r, errDNS.Wrap(err)
}

// ResponseToTXTRecordSet returns a TXTRecordSet from a dns Lookup response.
func ResponseToTXTRecordSet(resp *dns.Msg) *TXTRecordSet {
set := NewTXTRecordSet()
Expand Down
100 changes: 100 additions & 0 deletions pkg/linksharing/sharing/dns_static.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Copyright (C) 2024 Storj Labs, Inc.
// See LICENSE for copying information.

package sharing

import (
"bytes"
"context"
"net/url"
"strings"
"time"

"github.com/miekg/dns"
"github.com/zeebo/errs"
)

// StaticDNSClient provides static responses to dns queries.
type StaticDNSClient struct {
txt map[string]*TXTRecordSet
cname map[string][]string
}

// ParseStaticDNSClientFromZoneFile parses zone file for txt records.
func ParseStaticDNSClientFromZoneFile(data []byte) (*StaticDNSClient, error) {
client := &StaticDNSClient{
txt: map[string]*TXTRecordSet{},
cname: map[string][]string{},
}

zp := dns.NewZoneParser(bytes.NewReader(data), "", "")
for {
rr, ok := zp.Next()
if !ok {
break
}

switch rec := rr.(type) {
case *dns.TXT:
domain := rec.Hdr.Name
ttl := time.Duration(rec.Hdr.Ttl) * time.Second

set, ok := client.txt[domain]
if !ok {
set = NewTXTRecordSet()
client.txt[domain] = set
}

for _, txt := range rec.Txt {
set.Add(txt, ttl)
}
case *dns.CNAME:
domain := rec.Hdr.Name
client.cname[domain] = append(client.cname[domain], rec.Target)
}
}
if err := zp.Err(); err != nil {
return nil, errDNS.Wrap(err)
}

for _, set := range client.txt {
set.Finalize()
}

return client, nil
}

// LookupTXTRecordSet fetches record set from the specified host.
func (cli *StaticDNSClient) LookupTXTRecordSet(ctx context.Context, host string) (_ *TXTRecordSet, err error) {
defer mon.Task()(&ctx)(&err)

if !strings.HasSuffix(host, ".") {
host += "."
}

set, ok := cli.txt[host]
if !ok {
return nil, errDNS.New("not found")
}

return set, nil
}

// ValidateCNAME checks host has a CNAME record with a value of one of the public URL bases.
func (cli *StaticDNSClient) ValidateCNAME(ctx context.Context, host string, bases []*url.URL) (err error) {
defer mon.Task()(&ctx)(&err)

if !strings.HasSuffix(host, ".") {
host += "."
}

for _, url := range bases {
for _, target := range cli.cname[host] {
if target == url.Host || target == url.Host+"." {
return nil
}
}
}

return errs.New("domain %q does not contain a CNAME with any public host", host)
}
47 changes: 47 additions & 0 deletions pkg/linksharing/sharing/dns_static_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright (C) 2024 Storj Labs, Inc.
// See LICENSE for copying information.

package sharing

import (
"net/url"
"strings"
"testing"

"github.com/stretchr/testify/require"

"storj.io/common/testcontext"
)

func TestStaticDNSClient(t *testing.T) {
ctx := testcontext.New(t)

client, err := ParseStaticDNSClientFromZoneFile([]byte(strings.Join(
[]string{
"downloads.example.com. IN CNAME link.storjshare.io.",
"txt-downloads.example.com. IN TXT storj-root:files",
"txt-downloads.example.com. IN TXT storj-access:ju5umq3nrhaf6xo6srpb4xvldglq",
},
"\n",
)))
require.NoError(t, err)

t.Run("ValidateCNAME", func(t *testing.T) {
base, err := parseURLBase("http://link.storjshare.io")
require.NoError(t, err)

err = client.ValidateCNAME(ctx, "downloads.example.com", []*url.URL{base})
require.NoError(t, err)

err = client.ValidateCNAME(ctx, "downloads.example.local", []*url.URL{base})
require.Error(t, err)
})

t.Run("LookupTXTRecordSet", func(t *testing.T) {
set, err := client.LookupTXTRecordSet(ctx, "txt-downloads.example.com")
require.NoError(t, err)

require.Equal(t, "files", set.Lookup("storj-root"))
require.Equal(t, "ju5umq3nrhaf6xo6srpb4xvldglq", set.Lookup("storj-access"))
})
}
4 changes: 1 addition & 3 deletions pkg/linksharing/sharing/txtrecords.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (
"sync"
"time"

"github.com/miekg/dns"
"github.com/zeebo/errs"

"storj.io/edge/pkg/authclient"
Expand Down Expand Up @@ -148,11 +147,10 @@ func (records *TXTRecords) updateCache(ctx context.Context, hostname string, all
func (records *TXTRecords) queryAccessFromDNS(ctx context.Context, hostname string, allowAccessGrant bool, clientIP string) (record *txtRecord, err error) {
defer mon.Task()(&ctx)(&err)

r, err := records.dns.Lookup(ctx, "txt-"+hostname, dns.TypeTXT)
set, err := records.dns.LookupTXTRecordSet(ctx, "txt-"+hostname)
if err != nil {
return nil, errs.New("failure with hostname %q: %w", hostname, err)
}
set := ResponseToTXTRecordSet(r)

serializedAccess := set.Lookup("storj-access")
if serializedAccess == "" {
Expand Down

0 comments on commit ebd773a

Please sign in to comment.