Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ run: dev
test:
go test -v ./internal

.PHONY: test_acc
test_acc:
.PHONY: testacc
testacc:
go test -v ./internal -args -acc

.PHONY: testacc_local
testacc_local:
TEST_CONTROL_URL="http://localhost:31544" TEST_APIKEY=$$(jq -r .apiKey /tmp/terraform-api-key.json) go test -v ./internal -acc

6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,10 +241,14 @@ sudo ln -s $DOCKER_SOCKET_FILE_LOCATION /var/run/docker.sock
```
Alternatively, if you don't want this to apply globally, set the DOCKER_HOST environment variable to that custom location.

To run the unit & acceptance tests
To run the unit and acceptance tests
```
make test_acc
```
The acceptance tests will run against a testcontrol server unless TEST_CONTROL_URL and TEST_APIKEY environment variables are set.
```
TEST_CONTROL_URL="http://localhost:31544" TEST_APIKEY=$SOME_KEY go test -v ./internal -acc
```

To run only the unit tests
```
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -217,4 +217,5 @@ require (
gopkg.in/yaml.v3 v3.0.1 // indirect
gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 // indirect
honnef.co/go/tools v0.5.1 // indirect
tailscale.com/client/tailscale/v2 v2.3.0 // indirect
)
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -628,3 +628,5 @@ software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
tailscale.com v1.90.0 h1:nHYG58QAfNoxp6ahSe5liyJreVIaS4Zl0Lf/rJiOuQc=
tailscale.com v1.90.0/go.mod h1:AgJNXfVABAj4tpBVyAY0Hjf6M3GRRCMYEnzLHV20zqk=
tailscale.com/client/tailscale/v2 v2.3.0 h1:MBURNDWzWEB+GMBSnQFhZ2xiTjJg5dqOcFEEqZQ9cyA=
tailscale.com/client/tailscale/v2 v2.3.0/go.mod h1:RkAl+CyJiu437uUelFWW/2wL+EgZ6Vd15S1f+IitGr4=
2 changes: 0 additions & 2 deletions internal/db_capability.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ import (
"strconv"
)

const tsDBCap = "tailscale.com/cap/databases"

// dbCapability represents the access grants for a specific database instance
type dbCapability struct {
Engine string `json:"engine,omitzero"`
Expand Down
3 changes: 2 additions & 1 deletion internal/relay_base.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"time"

"github.com/openbao/openbao/sdk/v2/database/dbplugin/v5"
"github.com/tailscale/ts-db-connector/pkg"
"tailscale.com/client/local"
"tailscale.com/metrics"
"tailscale.com/tailcfg"
Expand Down Expand Up @@ -221,7 +222,7 @@ func (r *relay) getClientIdentity(ctx context.Context, conn net.Conn) (string, s
return "", "", nil, fmt.Errorf("couldn't identify source user and machine (user %q, machine %q)", user, machine)
}

return user, machine, whois.CapMap[tailcfg.PeerCapability(tsDBCap)], nil
return user, machine, whois.CapMap[tailcfg.PeerCapability(pkg.TSDBCap)], nil
}

// hasAccess checks if the given Tailscale identity is authorized to access the specified database
Expand Down
129 changes: 64 additions & 65 deletions internal/relay_pgwire_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package internal

import (
"context"
"encoding/json"
"fmt"
"net/netip"
"os"
Expand All @@ -13,23 +12,28 @@ import (
"github.com/tailscale/ts-db-connector/testutil/cockroachdb"
"github.com/tailscale/ts-db-connector/testutil/postgres"
"github.com/tailscale/ts-db-connector/testutil/tailscale"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
)

func TestPostgresRelay(t *testing.T) {
testutil.SkipUnlessAcc(t)

ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
const ACCTEST_TIMEOUT = 120 * time.Second

func TestMain(m *testing.M) {
// Disable ryuk to avoid the overhead of running another container on every test run.
// It isn't required, as we're already running the container cleanup with defer on exit.
os.Setenv("TESTCONTAINERS_RYUK_DISABLED", "true")
os.Exit(m.Run())
}

func TestPostgresRelay(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), ACCTEST_TIMEOUT)
defer cancel()

// =====
// GIVEN
// =====

control := setupControl(t)

dbInstance := "my-postgres-1"
dbName := "testdb"
adminUser := "admin"
Expand All @@ -40,16 +44,13 @@ func TestPostgresRelay(t *testing.T) {
host, port, cleanup := postgres.StartPostgres(t, ctx, certDir, dbName, adminUser, adminPassword, clientRole)
defer cleanup()
configJSON := formatConfigJSON(t, "postgres", dbInstance, host, port, CAFile, adminUser, adminPassword)
appCap := formatAppCap(t, "postgres", dbInstance, dbName, port, clientRole)
appCap := formatAppCap(t, "postgres", dbInstance, dbName, clientRole)

controlURL, control := tailscale.StartControl(t)
connectorTsnet, connectorIP, connectorNodeKey := tailscale.StartTsnetServer(t, ctx, controlURL, "test-db-connector")
connectorTsnet, connectorIP, connectorNodeKey := tailscale.StartTsnetServer(t, ctx, control.URL, "test-db-connector")
defer connectorTsnet.Close()
clientTsnet, clientIP, clientNodeKey := tailscale.StartTsnetServer(t, ctx, controlURL, "test-db-client")
clientTsnet, clientIP, clientNodeKey := tailscale.StartTsnetServer(t, ctx, control.URL, "test-db-client")
defer clientTsnet.Close()
filterRules := formatFilterRules(clientIP, connectorIP, appCap)
tailscale.MustInjectFilterRules(t, control, connectorNodeKey, clientNodeKey, filterRules...)
tailscale.MustInjectFilterRules(t, control, clientNodeKey, connectorNodeKey)
control.grantAppCap(appCap, clientIP, clientNodeKey, connectorIP, connectorNodeKey)

// ====
// WHEN
Expand Down Expand Up @@ -110,19 +111,15 @@ func TestPostgresRelay(t *testing.T) {
}

func TestCockroachDBRelay(t *testing.T) {
testutil.SkipUnlessAcc(t)

ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), ACCTEST_TIMEOUT)
defer cancel()

// Disable ryuk to avoid the overhead of running another container on every test run.
// It isn't required, as we're already running the container cleanup with defer on exit.
os.Setenv("TESTCONTAINERS_RYUK_DISABLED", "true")

// =====
// GIVEN
// =====

control := setupControl(t)

dbInstance := "my-cockroach-1"
dbName := "testdb"
adminUser := "cockroach_admin"
Expand All @@ -133,16 +130,13 @@ func TestCockroachDBRelay(t *testing.T) {
host, port, cleanup := cockroachdb.StartCockroachDB(t, ctx, certDir, dbName, adminUser, adminPassword, clientRole)
defer cleanup()
configJSON := formatConfigJSON(t, "cockroachdb", dbInstance, host, port, CAFile, adminUser, adminPassword)
appCap := formatAppCap(t, "cockroachdb", dbInstance, dbName, port, clientRole)
appCap := formatAppCap(t, "cockroachdb", dbInstance, dbName, clientRole)

controlURL, control := tailscale.StartControl(t)
connectorTsnet, connectorIP, connectorNodeKey := tailscale.StartTsnetServer(t, ctx, controlURL, "test-db-connector")
connectorTsnet, connectorIP, connectorNodeKey := tailscale.StartTsnetServer(t, ctx, control.URL, "test-db-connector")
defer connectorTsnet.Close()
clientTsnet, clientIP, clientNodeKey := tailscale.StartTsnetServer(t, ctx, controlURL, "test-db-client")
clientTsnet, clientIP, clientNodeKey := tailscale.StartTsnetServer(t, ctx, control.URL, "test-db-client")
defer clientTsnet.Close()
filterRules := formatFilterRules(clientIP, connectorIP, appCap)
tailscale.MustInjectFilterRules(t, control, connectorNodeKey, clientNodeKey, filterRules...)
tailscale.MustInjectFilterRules(t, control, clientNodeKey, connectorNodeKey)
control.grantAppCap(appCap, clientIP, clientNodeKey, connectorIP, connectorNodeKey)

// ====
// WHEN
Expand Down Expand Up @@ -202,6 +196,41 @@ func TestCockroachDBRelay(t *testing.T) {
t.Logf("Successfully inserted and queried database via connector")
}

type controlFixture struct {
URL string
grantAppCap func(appCap map[string]any, clientIP netip.Addr, clientNodeKey key.NodePublic, connectorIP netip.Addr, connectorNodeKey key.NodePublic)
}

func setupControl(t *testing.T) controlFixture {
testutil.SkipUnlessAcc(t)

controlURL := os.Getenv("TEST_CONTROL_URL")
apiKey := os.Getenv("TEST_APIKEY")

if controlURL == "" {
t.Log("TEST_CONTROL_URL environment variable is empty, running acceptance test against testcontrol server")
fakeControlUrl, control := tailscale.FakeControlStart(t)
return controlFixture{
URL: fakeControlUrl,
grantAppCap: func(appCap map[string]any, clientIP netip.Addr, clientNodeKey key.NodePublic, connectorIP netip.Addr, connectorNodeKey key.NodePublic) {
tailscale.FakeControlGrantAppCap(t, appCap, clientIP, clientNodeKey, connectorIP, connectorNodeKey, control)
},
}
}

if apiKey == "" {
t.Fatal("TEST_CONTROL_URL environment variable is set, but TEST_APIKEY is empty.")
}

t.Logf("Running acceptance test against TEST_CONTROL_URL=%s", controlURL)
return controlFixture{
URL: controlURL,
grantAppCap: func(appCap map[string]any, clientIP netip.Addr, clientNodeKey key.NodePublic, connectorIP netip.Addr, connectorNodeKey key.NodePublic) {
tailscale.ControlGrantAppCap(t, appCap, controlURL, apiKey)
},
}
}

func formatConfigJSON(t *testing.T, engine string, instance string, host string, port int, caFile string, adminUser string, adminPassword string) string {
t.Helper()

Expand All @@ -220,48 +249,18 @@ func formatConfigJSON(t *testing.T, engine string, instance string, host string,
return configJSON
}

func formatAppCap(t *testing.T, engine string, instance string, db string, port int, role string) string {
func formatAppCap(t *testing.T, engine string, instance string, db string, role string) map[string]any {
t.Helper()

capValue, err := json.Marshal(dbCapability{
Engine: engine,
Access: []accessSchema{
{
Databases: []string{db},
Roles: []string{role},
},
},
})
if err != nil {
t.Fatalf("failed to marshal capability: %v", err)
}

return fmt.Sprintf(`{%q: %s}`, instance, capValue)
}

func formatFilterRules(clientIP netip.Addr, connectorIP netip.Addr, connectorAppCap string) []tailcfg.FilterRule {
return []tailcfg.FilterRule{
{
SrcIPs: []string{clientIP.String()},
DstPorts: []tailcfg.NetPortRange{
return map[string]any{
instance: dbCapability{
Engine: engine,
Access: []accessSchema{
{
IP: fmt.Sprintf("%s/32", connectorIP), // TODO: there must be a better way!?
Ports: tailcfg.PortRange{First: 0, Last: 65535},
Databases: []string{db},
Roles: []string{role},
},
},
},
{
SrcIPs: []string{clientIP.String()},
CapGrant: []tailcfg.CapGrant{{
Dsts: []netip.Prefix{
netip.MustParsePrefix(fmt.Sprintf("%s/32", connectorIP)), // TODO: there must be a better way!?
},
CapMap: tailcfg.PeerCapMap{
tsDBCap: []tailcfg.RawMessage{
tailcfg.RawMessage(connectorAppCap),
},
},
}},
},
}
}
3 changes: 3 additions & 0 deletions pkg/db_capability.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package pkg

const TSDBCap = "tailscale.com/cap/databases"
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

82 changes: 81 additions & 1 deletion testutil/tailscale/control.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
package tailscale

import (
"context"
"encoding/json"
"fmt"
"net/http/httptest"
"net/netip"
"net/url"
"testing"

"github.com/tailscale/ts-db-connector/pkg"
"tailscale.com/client/tailscale/v2"
"tailscale.com/net/netns"
"tailscale.com/tailcfg"
"tailscale.com/tstest/integration"
Expand All @@ -12,7 +19,7 @@ import (
"tailscale.com/types/logger"
)

func StartControl(t *testing.T) (controlURL string, control *testcontrol.Server) {
func FakeControlStart(t *testing.T) (controlURL string, control *testcontrol.Server) {
t.Helper()

// Corp#4520: don't use netns for tests.
Expand Down Expand Up @@ -63,3 +70,76 @@ func MustInjectFilterRules(t *testing.T, control *testcontrol.Server, nodeKey ke
t.Fatalf("failed to inject raw MapResponse for node with key %s", nodeKey)
}
}

func FakeControlGrantAppCap(t *testing.T, appCaps map[string]any, clientIP netip.Addr, clientNodeKey key.NodePublic, connectorIP netip.Addr, connectorNodeKey key.NodePublic, control *testcontrol.Server) {
t.Helper()

rawAppCaps, err := json.Marshal(appCaps)
if err != nil {
t.Fatal(err)
}
filterRules := FormatFilterRules(t, clientIP, connectorIP, rawAppCaps)
MustInjectFilterRules(t, control, connectorNodeKey, clientNodeKey, filterRules...)
MustInjectFilterRules(t, control, clientNodeKey, connectorNodeKey)
}

func FormatFilterRules(t *testing.T, clientIP netip.Addr, connectorIP netip.Addr, connectorAppCap []byte) []tailcfg.FilterRule {
t.Helper()

return []tailcfg.FilterRule{
{
SrcIPs: []string{clientIP.String()},
DstPorts: []tailcfg.NetPortRange{
{
IP: fmt.Sprintf("%s/32", connectorIP),
Ports: tailcfg.PortRange{First: 0, Last: 65535},
},
},
},
{
SrcIPs: []string{clientIP.String()},
CapGrant: []tailcfg.CapGrant{{
Dsts: []netip.Prefix{
netip.MustParsePrefix(fmt.Sprintf("%s/32", connectorIP)),
},
CapMap: tailcfg.PeerCapMap{
pkg.TSDBCap: []tailcfg.RawMessage{
tailcfg.RawMessage(connectorAppCap),
},
},
}},
},
}
}

func ControlGrantAppCap(t *testing.T, appCaps map[string]any, controlURL string, apiKey string) {
t.Helper()

url, err := url.Parse(controlURL)
if err != nil {
t.Fatal(err)
}
client := &tailscale.Client{
BaseURL: url,
APIKey: apiKey,
}

acl := tailscale.ACL{
Grants: []tailscale.Grant{
{
Source: []string{"*"},
Destination: []string{"*"},
IP: []string{"tcp:*"},
App: map[string][]map[string]any{
pkg.TSDBCap: {appCaps},
},
},
},
}
t.Logf("Overwriting ACL with: %s", acl)
res, err := client.PolicyFile().SetAndGet(context.Background(), acl, "")
if err != nil {
t.Fatal(err)
}
t.Logf("API response: %s", res)
}
Copy link
Owner

@mcoulombe mcoulombe Nov 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd still be keen in a follow-up PR to use our official Go client and to add temporary grant rules instead of overriding the entire policy file. This is dangerous if someone from the community wants to implement a relay and uses its personal tailnet to run the tests.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've created https://github.com/tailscale/corp/issues/34682 to track looking into restoring the grants into their original state.

2 changes: 1 addition & 1 deletion testutil/tailscale/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ import (
"net/netip"
"os"
"path/filepath"
"testing"

"tailscale.com/ipn/store/mem"
"tailscale.com/tailcfg"
"tailscale.com/tsnet"
"tailscale.com/types/key"
"testing"
)

func StartTsnetServer(t *testing.T, ctx context.Context, controlURL, hostname string) (*tsnet.Server, netip.Addr, key.NodePublic) {
Expand Down