mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-08 09:00:22 +09:00
servertest: roundtrip the v2 API through go-client, tscli, and opentofu
Exercise the full surface against the three real clients on a live server, cross-checking get-after-set, server state, and no Terraform drift.
This commit is contained in:
@@ -0,0 +1,457 @@
|
||||
package servertest_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/servertest"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
tsclient "tailscale.com/client/tailscale/v2"
|
||||
)
|
||||
|
||||
// baselinePolicy declares tag:ci so device SetTags is permitted. Every policy
|
||||
// the device/acl tests write keeps tag:ci, so subtest order does not matter.
|
||||
const baselinePolicy = `{"tagOwners":{"tag:ci":["apiv2@"]},"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`
|
||||
|
||||
// setBaselinePolicy installs baselinePolicy into both the policy manager and the
|
||||
// database so device tagging and the ACL reads work.
|
||||
func setBaselinePolicy(t *testing.T, srv *servertest.TestServer) {
|
||||
t.Helper()
|
||||
|
||||
st := srv.State()
|
||||
|
||||
_, err := st.SetPolicy([]byte(baselinePolicy))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = st.SetPolicyInDB(baselinePolicy)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = st.ReloadPolicy()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func goClient(t *testing.T, baseURL, apiKey string) *tsclient.Client {
|
||||
t.Helper()
|
||||
|
||||
base, err := url.Parse(baseURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
return &tsclient.Client{BaseURL: base, APIKey: apiKey, Tailnet: "-"}
|
||||
}
|
||||
|
||||
// srvNodeView reads the node straight from the server's NodeStore — the
|
||||
// authoritative state between client steps. Handlers mutate the NodeStore
|
||||
// synchronously before responding, so a read right after a 2xx is consistent.
|
||||
func srvNodeView(t *testing.T, srv *servertest.TestServer, id types.NodeID) types.NodeView {
|
||||
t.Helper()
|
||||
|
||||
v, ok := srv.State().GetNodeByID(id)
|
||||
require.Truef(t, ok, "node %d must exist server-side", id)
|
||||
require.True(t, v.Valid())
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
func approvedRoutesOf(nv types.NodeView) []string {
|
||||
return util.PrefixesToString(nv.ApprovedRoutes().AsSlice())
|
||||
}
|
||||
|
||||
func nodeListed(srv *servertest.TestServer, id types.NodeID) bool {
|
||||
for _, n := range srv.State().ListNodes().All() {
|
||||
if n.ID() == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// apiv2DevicesGoClient drives the device lifecycle through the official SDK,
|
||||
// validating each mutation three ways: the tool's own get-after-set, the
|
||||
// server-side NodeStore, and (where relevant) permutations. Tagging is done late
|
||||
// because it clears user ownership; delete is last.
|
||||
func apiv2DevicesGoClient(t *testing.T, srv *servertest.TestServer, baseURL, apiKey string, id types.NodeID) {
|
||||
t.Helper()
|
||||
|
||||
ctx := t.Context()
|
||||
dr := goClient(t, baseURL, apiKey).Devices()
|
||||
deviceID := strconv.FormatUint(uint64(id), 10)
|
||||
|
||||
// Get — tool and server agree on identity and addresses.
|
||||
dev, err := dr.Get(ctx, deviceID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, deviceID, dev.NodeID)
|
||||
assert.NotEmpty(t, dev.Addresses)
|
||||
assert.True(t, dev.Authorized)
|
||||
assert.Equal(t, srvNodeView(t, srv, id).IPsAsString(), dev.Addresses)
|
||||
assert.Equal(t, srvNodeView(t, srv, id).GivenName(), dev.Name)
|
||||
|
||||
// List — present in both the tool list and the server's node list.
|
||||
devs, err := dr.List(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, containsDevice(devs, deviceID), "created device present in list")
|
||||
assert.True(t, nodeListed(srv, id))
|
||||
|
||||
// SetName — get-after-set + server-side, then a second rename.
|
||||
require.NoError(t, dr.SetName(ctx, deviceID, "renamed-go"))
|
||||
dev, err = dr.Get(ctx, deviceID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "renamed-go", dev.Name)
|
||||
assert.Equal(t, "renamed-go", srvNodeView(t, srv, id).GivenName())
|
||||
|
||||
require.NoError(t, dr.SetName(ctx, deviceID, "renamed-go-2"))
|
||||
assert.Equal(t, "renamed-go-2", srvNodeView(t, srv, id).GivenName())
|
||||
|
||||
// SetSubnetRoutes — one, two, then exit (expands to both families).
|
||||
require.NoError(t, dr.SetSubnetRoutes(ctx, deviceID, []string{"10.0.0.0/24"}))
|
||||
routes, err := dr.SubnetRoutes(ctx, deviceID)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, routes.Enabled, "10.0.0.0/24")
|
||||
assert.Contains(t, approvedRoutesOf(srvNodeView(t, srv, id)), "10.0.0.0/24")
|
||||
assert.Empty(t, srvNodeView(t, srv, id).AnnouncedRoutes(), "route enabled without being announced")
|
||||
|
||||
require.NoError(t, dr.SetSubnetRoutes(ctx, deviceID, []string{"10.0.0.0/24", "192.168.0.0/24"}))
|
||||
approved := approvedRoutesOf(srvNodeView(t, srv, id))
|
||||
assert.Contains(t, approved, "10.0.0.0/24")
|
||||
assert.Contains(t, approved, "192.168.0.0/24")
|
||||
|
||||
// A single exit prefix expands to both families on the server.
|
||||
require.NoError(t, dr.SetSubnetRoutes(ctx, deviceID, []string{"0.0.0.0/0"}))
|
||||
approved = approvedRoutesOf(srvNodeView(t, srv, id))
|
||||
assert.Contains(t, approved, "0.0.0.0/0")
|
||||
assert.Contains(t, approved, "::/0")
|
||||
|
||||
// SetKey — seed a real expiry first so disabling it is a state transition.
|
||||
future := time.Now().Add(24 * time.Hour)
|
||||
_, _, err = srv.State().SetNodeExpiry(id, &future)
|
||||
require.NoError(t, err)
|
||||
require.True(t, srvNodeView(t, srv, id).Expiry().Valid())
|
||||
|
||||
require.NoError(t, dr.SetKey(ctx, deviceID, tsclient.DeviceKey{KeyExpiryDisabled: true}))
|
||||
dev, err = dr.Get(ctx, deviceID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, dev.KeyExpiryDisabled)
|
||||
assert.False(t, srvNodeView(t, srv, id).Expiry().Valid())
|
||||
|
||||
// Re-enable is a no-op; expiry stays cleared.
|
||||
require.NoError(t, dr.SetKey(ctx, deviceID, tsclient.DeviceKey{KeyExpiryDisabled: false}))
|
||||
assert.False(t, srvNodeView(t, srv, id).Expiry().Valid())
|
||||
|
||||
// SetTags — flips ownership to the tags; the user is dropped.
|
||||
require.NoError(t, dr.SetTags(ctx, deviceID, []string{"tag:ci"}))
|
||||
dev, err = dr.Get(ctx, deviceID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"tag:ci"}, dev.Tags)
|
||||
assert.Equal(t, types.TaggedDevices.Username(), dev.User)
|
||||
n := srvNodeView(t, srv, id)
|
||||
assert.True(t, n.IsTagged())
|
||||
assert.Equal(t, []string{"tag:ci"}, n.Tags().AsSlice())
|
||||
assert.False(t, n.User().Valid())
|
||||
|
||||
// Re-tagging with the same tag is idempotent.
|
||||
require.NoError(t, dr.SetTags(ctx, deviceID, []string{"tag:ci"}))
|
||||
assert.Equal(t, []string{"tag:ci"}, srvNodeView(t, srv, id).Tags().AsSlice())
|
||||
|
||||
// SetAuthorized(true) is a no-op success; de-auth is rejected and inert.
|
||||
require.NoError(t, dr.SetAuthorized(ctx, deviceID, true))
|
||||
dev, err = dr.Get(ctx, deviceID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, dev.Authorized)
|
||||
|
||||
require.Error(t, dr.SetAuthorized(ctx, deviceID, false), "de-authorization is unsupported")
|
||||
assert.True(t, srvNodeView(t, srv, id).Valid(), "rejected de-auth left the node present")
|
||||
|
||||
// Delete — gone from the tool and the server.
|
||||
require.NoError(t, dr.Delete(ctx, deviceID))
|
||||
_, err = dr.Get(ctx, deviceID)
|
||||
assert.Truef(t, tsclient.IsNotFound(err), "get after delete should be 404, got %v", err)
|
||||
|
||||
_, ok := srv.State().GetNodeByID(id)
|
||||
assert.False(t, ok, "deleted node is gone server-side")
|
||||
}
|
||||
|
||||
func containsDevice(devs []tsclient.Device, id string) bool {
|
||||
for _, d := range devs {
|
||||
if d.NodeID == id || d.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// apiv2ACLGoClient round-trips the policy file: read, raw-read, conditional set.
|
||||
func apiv2ACLGoClient(t *testing.T, baseURL, apiKey string) {
|
||||
t.Helper()
|
||||
|
||||
ctx := t.Context()
|
||||
pf := goClient(t, baseURL, apiKey).PolicyFile()
|
||||
|
||||
acl, err := pf.Get(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, acl.ETag, "GET /acl carries an ETag")
|
||||
|
||||
raw, err := pf.Raw(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, raw.HuJSON)
|
||||
|
||||
// Set with the current etag as If-Match; keep tag:ci.
|
||||
require.NoError(t, pf.Set(ctx, baselinePolicy, raw.ETag))
|
||||
|
||||
updated, err := pf.Raw(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, updated.HuJSON, "tag:ci")
|
||||
}
|
||||
|
||||
// apiv2SettingsGoClient reads the tailnet settings (write is unsupported).
|
||||
func apiv2SettingsGoClient(t *testing.T, baseURL, apiKey string) {
|
||||
t.Helper()
|
||||
|
||||
settings, err := goClient(t, baseURL, apiKey).TailnetSettings().Get(t.Context())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "none", string(settings.UsersRoleAllowedToJoinExternalTailnets))
|
||||
}
|
||||
|
||||
// tscliRun runs tscli and fails the test on a non-zero exit.
|
||||
type tscliRun func(args ...string) string
|
||||
|
||||
// tscliRunner returns runners for tscli against the local server: one that
|
||||
// requires success, and one that tolerates a non-zero exit (for the expected
|
||||
// 404 after a delete).
|
||||
func tscliRunner(t *testing.T, baseURL, apiKey string) (tscliRun, func(args ...string) error) {
|
||||
t.Helper()
|
||||
|
||||
bin, err := exec.LookPath("tscli")
|
||||
require.NoErrorf(t, err, "tscli is required for TestAPIv2 (provided by the nix dev shell)")
|
||||
|
||||
env := append(
|
||||
os.Environ(),
|
||||
"TSCLI_BASE_URL="+baseURL,
|
||||
"TAILSCALE_API_KEY="+apiKey,
|
||||
"TAILSCALE_TAILNET=-",
|
||||
)
|
||||
|
||||
cmd := func(args ...string) *exec.Cmd {
|
||||
c := exec.CommandContext(t.Context(), bin, args...)
|
||||
c.Env = env
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
run := func(args ...string) string {
|
||||
t.Helper()
|
||||
|
||||
out, err := cmd(args...).CombinedOutput()
|
||||
require.NoErrorf(t, err, "tscli %s\n%s", strings.Join(args, " "), out)
|
||||
|
||||
return string(out)
|
||||
}
|
||||
|
||||
runAllowErr := func(args ...string) error {
|
||||
t.Helper()
|
||||
|
||||
return cmd(args...).Run()
|
||||
}
|
||||
|
||||
return run, runAllowErr
|
||||
}
|
||||
|
||||
// apiv2DevicesTSCLI drives the device verbs through tscli, asserting each
|
||||
// mutation via get-after-set (tscli's own json output) and the server NodeStore.
|
||||
func apiv2DevicesTSCLI(t *testing.T, srv *servertest.TestServer, baseURL, apiKey string, id types.NodeID) {
|
||||
t.Helper()
|
||||
|
||||
run, runAllowErr := tscliRunner(t, baseURL, apiKey)
|
||||
deviceID := strconv.FormatUint(uint64(id), 10)
|
||||
|
||||
assert.Contains(t, run("get", "device", "--device", deviceID, "-o", "json"), deviceID)
|
||||
assert.True(t, nodeListed(srv, id))
|
||||
|
||||
// Name.
|
||||
run("set", "device", "name", "--device", deviceID, "--name", "renamed-tscli")
|
||||
assert.Contains(t, run("get", "device", "--device", deviceID, "-o", "json"), "renamed-tscli")
|
||||
assert.Equal(t, "renamed-tscli", srvNodeView(t, srv, id).GivenName())
|
||||
|
||||
// Routes — one, two, then exit (both families).
|
||||
run("set", "device", "routes", "--device", deviceID, "--route", "10.0.0.0/24")
|
||||
assert.Contains(t, run("list", "routes", "--device", deviceID, "-o", "json"), "10.0.0.0/24")
|
||||
assert.Contains(t, approvedRoutesOf(srvNodeView(t, srv, id)), "10.0.0.0/24")
|
||||
|
||||
run("set", "device", "routes", "--device", deviceID, "--route", "10.0.0.0/24", "--route", "192.168.0.0/24")
|
||||
|
||||
approved := approvedRoutesOf(srvNodeView(t, srv, id))
|
||||
assert.Contains(t, approved, "10.0.0.0/24")
|
||||
assert.Contains(t, approved, "192.168.0.0/24")
|
||||
|
||||
run("set", "device", "routes", "--device", deviceID, "--route", "0.0.0.0/0")
|
||||
|
||||
exitApproved := approvedRoutesOf(srvNodeView(t, srv, id))
|
||||
assert.Contains(t, exitApproved, "0.0.0.0/0")
|
||||
assert.Contains(t, exitApproved, "::/0")
|
||||
|
||||
// Key — seed a real expiry first so disabling it is a transition.
|
||||
future := time.Now().Add(24 * time.Hour)
|
||||
_, _, err := srv.State().SetNodeExpiry(id, &future)
|
||||
require.NoError(t, err)
|
||||
|
||||
run("set", "device", "key", "--device", deviceID, "--disable-expiry")
|
||||
assert.Contains(t, run("get", "device", "--device", deviceID, "-o", "json"), `"keyExpiryDisabled": true`)
|
||||
assert.False(t, srvNodeView(t, srv, id).Expiry().Valid())
|
||||
|
||||
// Tags — flips to tag ownership.
|
||||
run("set", "device", "tags", "--device", deviceID, "--tag", "tag:ci")
|
||||
assert.Contains(t, run("get", "device", "--device", deviceID, "-o", "json"), "tag:ci")
|
||||
n := srvNodeView(t, srv, id)
|
||||
assert.True(t, n.IsTagged())
|
||||
assert.Equal(t, []string{"tag:ci"}, n.Tags().AsSlice())
|
||||
assert.False(t, n.User().Valid())
|
||||
|
||||
// Authorization — approve is a no-op success.
|
||||
run("set", "device", "authorization", "--device", deviceID, "--approve")
|
||||
assert.Contains(t, run("get", "device", "--device", deviceID, "-o", "json"), `"authorized": true`)
|
||||
|
||||
// Delete — gone from tscli and the server.
|
||||
run("delete", "device", "--device", deviceID)
|
||||
require.Error(t, runAllowErr("get", "device", "--device", deviceID, "-o", "json"), "get after delete should fail")
|
||||
|
||||
_, ok := srv.State().GetNodeByID(id)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
// apiv2ACLTSCLI reads and writes the policy file through tscli.
|
||||
func apiv2ACLTSCLI(t *testing.T, baseURL, apiKey string) {
|
||||
t.Helper()
|
||||
|
||||
run, _ := tscliRunner(t, baseURL, apiKey)
|
||||
|
||||
assert.Contains(t, run("get", "policy", "--json"), "acls")
|
||||
|
||||
dir := t.TempDir()
|
||||
polFile := filepath.Join(dir, "policy.hujson")
|
||||
require.NoError(t, os.WriteFile(polFile, []byte(baselinePolicy), 0o600))
|
||||
run("set", "policy", "--file", polFile)
|
||||
}
|
||||
|
||||
// apiv2SettingsTSCLI reads the tailnet settings through tscli.
|
||||
func apiv2SettingsTSCLI(t *testing.T, baseURL, apiKey string) {
|
||||
t.Helper()
|
||||
|
||||
run, _ := tscliRunner(t, baseURL, apiKey)
|
||||
assert.Contains(t, run("get", "settings", "-o", "json"), "devicesKeyDurationDays")
|
||||
}
|
||||
|
||||
// devicesACLTFConfig exercises Terraform device + ACL data sources AND resources.
|
||||
// %s is the test node's hostname (the tailscale_device data source key).
|
||||
const devicesACLTFConfig = `
|
||||
terraform {
|
||||
required_providers {
|
||||
tailscale = {
|
||||
source = "tailscale/tailscale"
|
||||
version = "~> 0.21"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "tailscale" {}
|
||||
|
||||
resource "tailscale_acl" "policy" {
|
||||
acl = jsonencode({
|
||||
tagOwners = { "tag:ci" = ["apiv2@"] }
|
||||
acls = [{ action = "accept", src = ["*"], dst = ["*:*"] }]
|
||||
})
|
||||
overwrite_existing_content = true
|
||||
}
|
||||
|
||||
data "tailscale_device" "dut" {
|
||||
hostname = "%s"
|
||||
wait_for = "30s"
|
||||
}
|
||||
|
||||
data "tailscale_devices" "all" {}
|
||||
|
||||
data "tailscale_acl" "current" {
|
||||
depends_on = [tailscale_acl.policy]
|
||||
}
|
||||
|
||||
resource "tailscale_device_authorization" "dut" {
|
||||
device_id = data.tailscale_device.dut.node_id
|
||||
authorized = true
|
||||
}
|
||||
|
||||
resource "tailscale_device_tags" "dut" {
|
||||
device_id = data.tailscale_device.dut.node_id
|
||||
tags = ["tag:ci"]
|
||||
depends_on = [tailscale_acl.policy]
|
||||
}
|
||||
|
||||
resource "tailscale_device_key" "dut" {
|
||||
device_id = data.tailscale_device.dut.node_id
|
||||
key_expiry_disabled = true
|
||||
}
|
||||
|
||||
resource "tailscale_device_subnet_routes" "dut" {
|
||||
device_id = data.tailscale_device.dut.node_id
|
||||
routes = ["10.0.0.0/24"]
|
||||
}
|
||||
|
||||
output "dut_node_id" { value = data.tailscale_device.dut.node_id }
|
||||
output "dut_addresses" { value = data.tailscale_device.dut.addresses }
|
||||
output "dut_authorized" { value = data.tailscale_device.dut.authorized }
|
||||
output "device_count" { value = length(data.tailscale_devices.all.devices) }
|
||||
output "acl_hujson" { value = data.tailscale_acl.current.hujson }
|
||||
output "enabled_routes" { value = tailscale_device_subnet_routes.dut.routes }
|
||||
`
|
||||
|
||||
// apiv2DevicesACLTerraform runs a tofu init/apply/destroy over the device and
|
||||
// ACL data sources and resources, asserting no post-apply drift, the data-source
|
||||
// outputs (read path) against the server truth, and the resulting server state
|
||||
// (write path). parallelism=1 avoids racing concurrent mutations on the one
|
||||
// shared node.
|
||||
func apiv2DevicesACLTerraform(t *testing.T, srv *servertest.TestServer, baseURL, apiKey, hostname string, id types.NodeID) {
|
||||
t.Helper()
|
||||
|
||||
tf := newTofu(t, baseURL, apiKey, fmt.Sprintf(devicesACLTFConfig, hostname))
|
||||
|
||||
tf.run("init", "-no-color", "-input=false")
|
||||
tf.run("apply", "-auto-approve", "-no-color", "-input=false", "-parallelism=1")
|
||||
|
||||
// Data sources resolved to real values that match the server.
|
||||
outputs := tf.outputs()
|
||||
assert.Equal(t, strconv.FormatUint(uint64(id), 10), outputs.str(t, "dut_node_id"))
|
||||
assert.ElementsMatch(t, srvNodeView(t, srv, id).IPsAsString(), outputs.strSlice(t, "dut_addresses"))
|
||||
outputs.jsonEq(t, "dut_authorized", true)
|
||||
assert.Equal(t, srv.State().ListNodes().Len(), int(outputs.num(t, "device_count")))
|
||||
assert.Contains(t, outputs.str(t, "acl_hujson"), "tag:ci")
|
||||
assert.Contains(t, outputs.strSlice(t, "enabled_routes"), "10.0.0.0/24")
|
||||
|
||||
// Server-side: the resources actually applied (write path).
|
||||
n := srvNodeView(t, srv, id)
|
||||
assert.True(t, n.IsTagged())
|
||||
assert.Equal(t, []string{"tag:ci"}, n.Tags().AsSlice())
|
||||
assert.Contains(t, approvedRoutesOf(n), "10.0.0.0/24")
|
||||
assert.False(t, n.Expiry().Valid())
|
||||
|
||||
pol, err := srv.State().GetPolicy()
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, pol.Data, "tag:ci", "tailscale_acl wrote the policy")
|
||||
|
||||
// A converged config must produce an empty plan — drift is a read/write bug.
|
||||
tf.assertNoDrift()
|
||||
|
||||
// destroy resets the policy; the node is a data source, so it persists.
|
||||
// Tags/expiry teardown are no-ops on Headscale, so they are not reverted.
|
||||
tf.run("destroy", "-auto-approve", "-no-color", "-input=false", "-parallelism=1")
|
||||
|
||||
_, ok := srv.State().GetNodeByID(id)
|
||||
assert.True(t, ok, "data-source node persists across destroy")
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
package servertest_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/servertest"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
tsclient "tailscale.com/client/tailscale/v2"
|
||||
)
|
||||
|
||||
// TestAPIv2 proves the v2 API's Tailscale-compatible (ported) endpoints against the three real
|
||||
// clients it exists to support: the official Go SDK, tscli, and the Tailscale
|
||||
// Terraform provider (via OpenTofu). All three run against one Headscale bound
|
||||
// to a real loopback port, authenticating with a user-owned API key.
|
||||
//
|
||||
// Every mutation is validated three ways — the tool's own get-after-set, the
|
||||
// server-side NodeStore/state, and (for Terraform) a no-change plan proving no
|
||||
// drift — so a server/provider read-write mismatch fails loudly.
|
||||
//
|
||||
// tscli and tofu are required, not optional: they ship in the nix dev shell, so
|
||||
// a missing binary means a broken environment and the test fails rather than
|
||||
// silently skipping.
|
||||
func TestAPIv2(t *testing.T) {
|
||||
srv := servertest.NewServer(t, servertest.WithRealListener())
|
||||
owner := srv.CreateUser(t, "apiv2")
|
||||
apiKey := srv.CreateAPIKey(t, owner)
|
||||
|
||||
// tag:ci must exist in policy for device SetTags; every policy the tests
|
||||
// write keeps it, so subtest order is irrelevant. Terraform runs last so its
|
||||
// ACL teardown does not strand the others.
|
||||
setBaselinePolicy(t, srv)
|
||||
|
||||
t.Run("GoClient", func(t *testing.T) {
|
||||
apiv2GoClient(t, srv, srv.URL, apiKey, owner)
|
||||
|
||||
node := srv.CreateRegisteredNode(t, owner, "dut-go")
|
||||
apiv2DevicesGoClient(t, srv, srv.URL, apiKey, node.ID())
|
||||
apiv2ACLGoClient(t, srv.URL, apiKey)
|
||||
apiv2SettingsGoClient(t, srv.URL, apiKey)
|
||||
})
|
||||
|
||||
t.Run("TSCLI", func(t *testing.T) {
|
||||
apiv2TSCLI(t, srv, srv.URL, apiKey)
|
||||
|
||||
node := srv.CreateRegisteredNode(t, owner, "dut-tscli")
|
||||
apiv2DevicesTSCLI(t, srv, srv.URL, apiKey, node.ID())
|
||||
apiv2ACLTSCLI(t, srv.URL, apiKey)
|
||||
apiv2SettingsTSCLI(t, srv.URL, apiKey)
|
||||
})
|
||||
|
||||
t.Run("Terraform", func(t *testing.T) {
|
||||
apiv2Terraform(t, srv, srv.URL, apiKey, owner)
|
||||
|
||||
node := srv.CreateRegisteredNode(t, owner, "dut-tf")
|
||||
apiv2DevicesACLTerraform(t, srv, srv.URL, apiKey, node.Hostname(), node.ID())
|
||||
})
|
||||
}
|
||||
|
||||
// apiv2GoClient exercises the official SDK with untagged (user-owned) keys — the
|
||||
// default Terraform/tscli path — validating each operation against the server's
|
||||
// stored PreAuthKey, plus ephemeral and default-expiry permutations.
|
||||
func apiv2GoClient(t *testing.T, srv *servertest.TestServer, baseURL, apiKey string, owner *types.User) {
|
||||
t.Helper()
|
||||
|
||||
ctx := t.Context()
|
||||
keys := goClient(t, baseURL, apiKey).Keys()
|
||||
wantOwner := strconv.FormatUint(uint64(owner.ID), 10)
|
||||
|
||||
var req tsclient.CreateKeyRequest
|
||||
|
||||
req.Description = "go-client"
|
||||
req.ExpirySeconds = 3600
|
||||
req.Capabilities.Devices.Create.Reusable = true
|
||||
|
||||
created, err := keys.CreateAuthKey(ctx, req)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, created.ID)
|
||||
assert.NotEmpty(t, created.Key, "secret returned on create")
|
||||
assert.Equal(t, "go-client", created.Description)
|
||||
assert.Equal(t, wantOwner, created.UserID, "user-owned key reports its owner")
|
||||
|
||||
// Server-side: the stored key matches the request and is owned by the user.
|
||||
pak := srvPreAuthKey(t, srv, created.ID)
|
||||
assert.True(t, pak.Reusable)
|
||||
assert.False(t, pak.Ephemeral)
|
||||
assert.Empty(t, pak.Tags, "no tags -> user-owned")
|
||||
require.NotNil(t, pak.User)
|
||||
assert.Equal(t, owner.ID, pak.User.ID)
|
||||
assert.Equal(t, "go-client", pak.Description)
|
||||
require.NotNil(t, pak.CreatedAt)
|
||||
require.NotNil(t, pak.Expiration)
|
||||
assert.InDelta(t, 3600, pak.Expiration.Sub(*pak.CreatedAt).Seconds(), 5)
|
||||
|
||||
got, err := keys.Get(ctx, created.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, created.ID, got.ID)
|
||||
assert.Empty(t, got.Key, "secret omitted on get")
|
||||
assert.Equal(t, "go-client", got.Description)
|
||||
assert.False(t, got.Invalid)
|
||||
// The SDK decodes our integer expirySeconds into a Duration of nanoseconds,
|
||||
// so never assert it numerically; the lifetime rides on Expires-Created.
|
||||
assert.InDelta(t, 3600, got.Expires.Sub(got.Created).Seconds(), 5)
|
||||
|
||||
list, err := keys.List(ctx, true)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, containsKeyID(list, created.ID), "created key present in list")
|
||||
|
||||
// DELETE soft-revokes (Tailscale-faithful): the key stays retrievable, now
|
||||
// invalid, until the collector reaps it.
|
||||
require.NoError(t, keys.Delete(ctx, created.ID))
|
||||
revoked, err := keys.Get(ctx, created.ID)
|
||||
require.NoError(t, err, "revoked key stays retrievable")
|
||||
assert.True(t, revoked.Invalid, "revoked key reports invalid")
|
||||
require.NotNil(t, srvPreAuthKey(t, srv, created.ID).Revoked, "key soft-revoked server-side")
|
||||
|
||||
// Permutation — ephemeral key.
|
||||
var ephReq tsclient.CreateKeyRequest
|
||||
|
||||
ephReq.Capabilities.Devices.Create.Ephemeral = true
|
||||
eph, err := keys.CreateAuthKey(ctx, ephReq)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, srvPreAuthKey(t, srv, eph.ID).Ephemeral)
|
||||
require.NoError(t, keys.Delete(ctx, eph.ID))
|
||||
|
||||
// Permutation — default expiry (omit ExpirySeconds -> 90 days).
|
||||
def, err := keys.CreateAuthKey(ctx, tsclient.CreateKeyRequest{})
|
||||
require.NoError(t, err)
|
||||
defKey := srvPreAuthKey(t, srv, def.ID)
|
||||
require.NotNil(t, defKey.CreatedAt)
|
||||
require.NotNil(t, defKey.Expiration)
|
||||
assert.InDelta(t, 7776000, defKey.Expiration.Sub(*defKey.CreatedAt).Seconds(), 5)
|
||||
require.NoError(t, keys.Delete(ctx, def.ID))
|
||||
}
|
||||
|
||||
func containsKeyID(keys []tsclient.Key, id string) bool {
|
||||
for _, k := range keys {
|
||||
if k.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// srvPreAuthKey is the server-side ground truth for a key id; it fails the test
|
||||
// if the key is absent.
|
||||
func srvPreAuthKey(t *testing.T, srv *servertest.TestServer, id string) types.PreAuthKey {
|
||||
t.Helper()
|
||||
|
||||
pak := findPAKByID(t, srv, id)
|
||||
require.NotNilf(t, pak, "pre-auth key %s not found server-side", id)
|
||||
|
||||
return *pak
|
||||
}
|
||||
|
||||
// findPAKByID returns the stored key with the given stringified id, or nil.
|
||||
func findPAKByID(t *testing.T, srv *servertest.TestServer, id string) *types.PreAuthKey {
|
||||
t.Helper()
|
||||
|
||||
want, err := strconv.ParseUint(id, 10, 64)
|
||||
require.NoError(t, err)
|
||||
|
||||
keys, err := srv.State().ListPreAuthKeys()
|
||||
require.NoError(t, err)
|
||||
|
||||
for i := range keys {
|
||||
if keys[i].ID == want {
|
||||
return &keys[i]
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// apiv2TSCLI exercises tscli with a tagged key, validating server-side that the
|
||||
// stored key carries the requested tags and metadata.
|
||||
func apiv2TSCLI(t *testing.T, srv *servertest.TestServer, baseURL, apiKey string) {
|
||||
t.Helper()
|
||||
|
||||
run, _ := tscliRunner(t, baseURL, apiKey)
|
||||
|
||||
out := run(
|
||||
"create", "key",
|
||||
"--type", "authkey",
|
||||
"--description", "tscli",
|
||||
"--expiry", "1h",
|
||||
"--reusable",
|
||||
"--tags", "tag:ci",
|
||||
"-o", "json",
|
||||
)
|
||||
|
||||
var created struct {
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
require.NoErrorf(t, json.Unmarshal([]byte(out), &created), "tscli create output: %s", out)
|
||||
assert.NotEmpty(t, created.ID)
|
||||
assert.NotEmpty(t, created.Key, "secret returned on create")
|
||||
|
||||
// Server-side: tagged, reusable, described, no owner.
|
||||
pak := srvPreAuthKey(t, srv, created.ID)
|
||||
assert.Equal(t, []string{"tag:ci"}, pak.Tags)
|
||||
assert.True(t, pak.Reusable)
|
||||
assert.Equal(t, "tscli", pak.Description)
|
||||
assert.Nil(t, pak.User, "tagged key has no owning user")
|
||||
|
||||
getOut := run("get", "key", "--key", created.ID, "-o", "json")
|
||||
|
||||
var got struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(getOut), &got))
|
||||
assert.Empty(t, got.Key, "secret omitted on get")
|
||||
|
||||
assert.Contains(t, run("list", "keys", "--all", "-o", "json"), created.ID)
|
||||
|
||||
// DELETE soft-revokes: the key stays retrievable (invalid) server-side until
|
||||
// the collector reaps it.
|
||||
run("delete", "key", "--key", created.ID)
|
||||
assert.Contains(t, run("get", "key", "--key", created.ID, "-o", "json"), `"invalid": true`)
|
||||
require.NotNil(t, srvPreAuthKey(t, srv, created.ID).Revoked, "key soft-revoked server-side")
|
||||
}
|
||||
|
||||
// terraformConfig drives the tailscale_tailnet_key resource against the local
|
||||
// server. No tags, so the key is owned by the API key's user — the default
|
||||
// Terraform path. Outputs expose the provider's read-back for value + drift
|
||||
// checks.
|
||||
const terraformConfig = `
|
||||
terraform {
|
||||
required_providers {
|
||||
tailscale = {
|
||||
source = "tailscale/tailscale"
|
||||
version = "~> 0.21"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "tailscale" {}
|
||||
|
||||
resource "tailscale_tailnet_key" "test" {
|
||||
reusable = true
|
||||
ephemeral = false
|
||||
preauthorized = true
|
||||
expiry = 3600
|
||||
description = "tofu-roundtrip"
|
||||
}
|
||||
|
||||
output "key_id" { value = tailscale_tailnet_key.test.id }
|
||||
output "key_reusable" { value = tailscale_tailnet_key.test.reusable }
|
||||
output "key_ephemeral" { value = tailscale_tailnet_key.test.ephemeral }
|
||||
output "key_description" { value = tailscale_tailnet_key.test.description }
|
||||
`
|
||||
|
||||
// apiv2Terraform runs a tofu init→apply→(no-drift)→destroy roundtrip on a
|
||||
// tailnet key, cross-checking the provider outputs and the server's stored key.
|
||||
func apiv2Terraform(t *testing.T, srv *servertest.TestServer, baseURL, apiKey string, owner *types.User) {
|
||||
t.Helper()
|
||||
|
||||
tf := newTofu(t, baseURL, apiKey, terraformConfig)
|
||||
|
||||
tf.run("init", "-no-color", "-input=false")
|
||||
tf.run("apply", "-auto-approve", "-no-color", "-input=false", "-parallelism=1")
|
||||
|
||||
outputs := tf.outputs()
|
||||
keyID := outputs.str(t, "key_id")
|
||||
require.NotEmpty(t, keyID)
|
||||
outputs.jsonEq(t, "key_reusable", true)
|
||||
outputs.jsonEq(t, "key_ephemeral", false)
|
||||
outputs.jsonEq(t, "key_description", "tofu-roundtrip")
|
||||
|
||||
// Server-side: the key exists, user-owned, with the requested attributes.
|
||||
pak := srvPreAuthKey(t, srv, keyID)
|
||||
assert.Equal(t, "tofu-roundtrip", pak.Description)
|
||||
assert.True(t, pak.Reusable)
|
||||
assert.False(t, pak.Ephemeral)
|
||||
assert.Empty(t, pak.Tags, "no tags -> user-owned key")
|
||||
require.NotNil(t, pak.User)
|
||||
assert.Equal(t, owner.ID, pak.User.ID)
|
||||
require.NotNil(t, pak.CreatedAt)
|
||||
require.NotNil(t, pak.Expiration)
|
||||
assert.InDelta(t, 3600, pak.Expiration.Sub(*pak.CreatedAt).Seconds(), 5)
|
||||
|
||||
// A converged config must produce an empty plan — drift is a read/write bug.
|
||||
tf.assertNoDrift()
|
||||
|
||||
// destroy DELETEs the key, which soft-revokes it: the row is kept (revoked)
|
||||
// until the collector reaps it.
|
||||
tf.run("destroy", "-auto-approve", "-no-color", "-input=false", "-parallelism=1")
|
||||
require.NotNil(t, srvPreAuthKey(t, srv, keyID).Revoked, "key revoked after destroy")
|
||||
}
|
||||
|
||||
// tofu binds a tofu binary, working dir, and env for a single workspace. cmd is
|
||||
// a closure capturing the looked-up binary so subprocess construction stays in
|
||||
// one place.
|
||||
type tofu struct {
|
||||
t *testing.T
|
||||
cmd func(args ...string) *exec.Cmd
|
||||
}
|
||||
|
||||
func newTofu(t *testing.T, baseURL, apiKey, config string) *tofu {
|
||||
t.Helper()
|
||||
|
||||
bin, err := exec.LookPath("tofu")
|
||||
require.NoErrorf(t, err, "tofu is required for TestAPIv2 (provided by the nix dev shell)")
|
||||
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "main.tf"), []byte(config), 0o600))
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "plugin-cache"), 0o755))
|
||||
|
||||
env := append(
|
||||
os.Environ(),
|
||||
"TAILSCALE_BASE_URL="+baseURL,
|
||||
"TAILSCALE_API_KEY="+apiKey,
|
||||
"TAILSCALE_TAILNET=-",
|
||||
// Keep provider plugins inside the temp dir so the run is self-contained.
|
||||
"TF_PLUGIN_CACHE_DIR="+filepath.Join(dir, "plugin-cache"),
|
||||
)
|
||||
|
||||
cmd := func(args ...string) *exec.Cmd {
|
||||
c := exec.CommandContext(t.Context(), bin, args...)
|
||||
c.Dir = dir
|
||||
c.Env = env
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
return &tofu{t: t, cmd: cmd}
|
||||
}
|
||||
|
||||
func (tf *tofu) run(args ...string) string {
|
||||
tf.t.Helper()
|
||||
|
||||
out, err := tf.cmd(args...).CombinedOutput()
|
||||
require.NoErrorf(tf.t, err, "tofu %s\n%s", strings.Join(args, " "), out)
|
||||
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// assertNoDrift fails if a no-change plan reports changes. plan
|
||||
// -detailed-exitcode returns 0 = no changes, 1 = error, 2 = drift.
|
||||
func (tf *tofu) assertNoDrift() {
|
||||
tf.t.Helper()
|
||||
|
||||
out, err := tf.cmd("plan", "-detailed-exitcode", "-no-color", "-input=false", "-parallelism=1").CombinedOutput()
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var exit *exec.ExitError
|
||||
require.ErrorAsf(tf.t, err, &exit, "tofu plan\n%s", out)
|
||||
require.Equalf(tf.t, 0, exit.ExitCode(),
|
||||
"no-change plan after apply must be empty; drift means a provider read disagrees with desired state:\n%s", out)
|
||||
}
|
||||
|
||||
func (tf *tofu) outputs() tofuOutputs {
|
||||
tf.t.Helper()
|
||||
|
||||
out := tf.run("output", "-json", "-no-color")
|
||||
|
||||
var raw map[string]struct {
|
||||
Value json.RawMessage `json:"value"`
|
||||
}
|
||||
require.NoError(tf.t, json.Unmarshal([]byte(out), &raw))
|
||||
|
||||
o := make(tofuOutputs, len(raw))
|
||||
for k, v := range raw {
|
||||
o[k] = v.Value
|
||||
}
|
||||
|
||||
return o
|
||||
}
|
||||
|
||||
// tofuOutputs is the decoded `tofu output -json`, keyed by output name.
|
||||
type tofuOutputs map[string]json.RawMessage
|
||||
|
||||
func (o tofuOutputs) raw(t *testing.T, key string) json.RawMessage {
|
||||
t.Helper()
|
||||
|
||||
v, ok := o[key]
|
||||
require.Truef(t, ok, "output %q missing", key)
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
func (o tofuOutputs) str(t *testing.T, key string) string {
|
||||
t.Helper()
|
||||
|
||||
var s string
|
||||
require.NoError(t, json.Unmarshal(o.raw(t, key), &s))
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (o tofuOutputs) num(t *testing.T, key string) float64 {
|
||||
t.Helper()
|
||||
|
||||
var n float64
|
||||
require.NoError(t, json.Unmarshal(o.raw(t, key), &n))
|
||||
|
||||
return n
|
||||
}
|
||||
|
||||
func (o tofuOutputs) strSlice(t *testing.T, key string) []string {
|
||||
t.Helper()
|
||||
|
||||
var s []string
|
||||
require.NoError(t, json.Unmarshal(o.raw(t, key), &s))
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// jsonEq asserts the output decodes equal to want (handles bools/strings/numbers).
|
||||
func (o tofuOutputs) jsonEq(t *testing.T, key string, want any) {
|
||||
t.Helper()
|
||||
|
||||
wantJSON, err := json.Marshal(want)
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, string(wantJSON), string(o.raw(t, key)))
|
||||
}
|
||||
Reference in New Issue
Block a user