diff --git a/Makefile b/Makefile index 83755d7..756abf6 100644 --- a/Makefile +++ b/Makefile @@ -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 + diff --git a/README.md b/README.md index de4d2fe..61b0ae4 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/go.mod b/go.mod index 125a81e..5e5a426 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 156f244..95aecea 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/db_capability.go b/internal/db_capability.go index 5caf5a2..5db5cae 100644 --- a/internal/db_capability.go +++ b/internal/db_capability.go @@ -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"` diff --git a/internal/relay_base.go b/internal/relay_base.go index 9a1fb9d..800b5aa 100644 --- a/internal/relay_base.go +++ b/internal/relay_base.go @@ -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" @@ -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 diff --git a/internal/relay_pgwire_test.go b/internal/relay_pgwire_test.go index e173cf6..05568ce 100644 --- a/internal/relay_pgwire_test.go +++ b/internal/relay_pgwire_test.go @@ -2,7 +2,6 @@ package internal import ( "context" - "encoding/json" "fmt" "net/netip" "os" @@ -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" @@ -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 @@ -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" @@ -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 @@ -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() @@ -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), - }, - }, - }}, - }, } } diff --git a/pkg/db_capability.go b/pkg/db_capability.go new file mode 100644 index 0000000..c3a2949 --- /dev/null +++ b/pkg/db_capability.go @@ -0,0 +1,3 @@ +package pkg + +const TSDBCap = "tailscale.com/cap/databases" diff --git a/testutil/tailscale/control.go b/testutil/tailscale/control.go index 558dc5b..b8c6cd9 100644 --- a/testutil/tailscale/control.go +++ b/testutil/tailscale/control.go @@ -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" @@ -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. @@ -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) +} diff --git a/testutil/tailscale/server.go b/testutil/tailscale/server.go index 2917121..aa70f8e 100644 --- a/testutil/tailscale/server.go +++ b/testutil/tailscale/server.go @@ -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) {