mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-31 02:34:07 +09:00
handleRegister, the OIDC auth path and SetNodeExpiry refuse it. Expiries are stored in UTC, which SQLite can read back, and loaded in UTC, as PostgreSQL returns the server's zone. The v1 API answers 400. Fixes ANT-2026-QMCAFJ1Q
456 lines
14 KiB
Go
456 lines
14 KiB
Go
package hscontrol
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/juanfont/headscale/hscontrol/types"
|
|
"github.com/juanfont/headscale/hscontrol/types/testcapture"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"tailscale.com/tailcfg"
|
|
"tailscale.com/types/key"
|
|
)
|
|
|
|
// nodeSeed carries fixed key material so isolated apps in a mutation parity test
|
|
// register byte-identical nodes; otherwise random keys would defeat body
|
|
// comparison.
|
|
type nodeSeed struct {
|
|
user string
|
|
machineKey key.MachinePrivate
|
|
nodeKey key.NodePrivate
|
|
hostname string
|
|
tags []string
|
|
}
|
|
|
|
func newNodeSeed(user, hostname string, tags ...string) nodeSeed {
|
|
return nodeSeed{
|
|
user: user,
|
|
machineKey: key.NewMachine(),
|
|
nodeKey: key.NewNode(),
|
|
hostname: hostname,
|
|
tags: tags,
|
|
}
|
|
}
|
|
|
|
// register inserts the user, a matching pre-auth key, and the node itself using
|
|
// the seed's fixed keys, returning the created node ID.
|
|
func (s nodeSeed) register(t *testing.T, app *Headscale) types.NodeID {
|
|
t.Helper()
|
|
|
|
user := app.state.CreateUserForTest(s.user)
|
|
|
|
pak, err := app.state.CreatePreAuthKey(user.TypedID(), false, false, nil, s.tags)
|
|
require.NoError(t, err)
|
|
|
|
req := tailcfg.RegisterRequest{
|
|
Auth: &tailcfg.RegisterResponseAuth{AuthKey: pak.Key},
|
|
NodeKey: s.nodeKey.Public(),
|
|
Hostinfo: &tailcfg.Hostinfo{Hostname: s.hostname},
|
|
}
|
|
|
|
_, err = app.handleRegisterWithAuthKey(req, s.machineKey.Public())
|
|
require.NoError(t, err)
|
|
|
|
node, found := app.state.GetNodeByNodeKey(s.nodeKey.Public())
|
|
require.True(t, found)
|
|
|
|
return node.ID()
|
|
}
|
|
|
|
func seedNodes(seeds ...nodeSeed) func(t *testing.T, app *Headscale) {
|
|
return func(t *testing.T, app *Headscale) {
|
|
t.Helper()
|
|
|
|
for _, s := range seeds {
|
|
s.register(t, app)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAPIV1NodeGet(t *testing.T) {
|
|
t.Run("happy parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
seedNodes(newNodeSeed("alice", "node-a"))(t, h.app)
|
|
h.assertParity(t, http.MethodGet, "/api/v1/node/1", nil)
|
|
})
|
|
|
|
t.Run("tagged node parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
// No tagOwners means policy won't authorise a tag, so register via the
|
|
// pre-auth key path, which forces tags regardless of policy.
|
|
seedNodes(newNodeSeed("bob", "node-b", "tag:initial"))(t, h.app)
|
|
h.assertParity(t, http.MethodGet, "/api/v1/node/1", nil)
|
|
})
|
|
|
|
t.Run("not found parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
res := h.assertParity(t, http.MethodGet, "/api/v1/node/99999", nil)
|
|
assertStatus(t, res, http.StatusNotFound)
|
|
})
|
|
|
|
t.Run("invalid id parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
res := h.assertParity(t, http.MethodGet, "/api/v1/node/abc", nil)
|
|
assertStatus(t, res, http.StatusBadRequest)
|
|
})
|
|
|
|
t.Run("huma response shape", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
seedNodes(newNodeSeed("carol", "node-c"))(t, h.app)
|
|
|
|
res := h.callHuma(http.MethodGet, "/api/v1/node/1", nil)
|
|
require.Equal(t, http.StatusOK, res.status)
|
|
|
|
var got struct {
|
|
Node map[string]any `json:"node"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(res.body, &got))
|
|
|
|
assert.Equal(t, "1", got.Node["id"])
|
|
assert.Equal(t, "node-c", got.Node["name"])
|
|
assert.Equal(t, "REGISTER_METHOD_AUTH_KEY", got.Node["registerMethod"])
|
|
// EmitUnpopulated parity: slices present as [], expiry as null.
|
|
assert.Equal(t, []any{}, got.Node["ipAddresses"])
|
|
assert.Equal(t, []any{}, got.Node["tags"])
|
|
assert.Nil(t, got.Node["expiry"])
|
|
assert.Contains(t, got.Node, "user")
|
|
assert.Contains(t, got.Node, "preAuthKey")
|
|
assert.Contains(t, got.Node, "createdAt")
|
|
})
|
|
}
|
|
|
|
func TestAPIV1NodeList(t *testing.T) {
|
|
t.Run("empty returns empty array", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
|
|
res := h.callHuma(http.MethodGet, "/api/v1/node", nil)
|
|
require.Equal(t, http.StatusOK, res.status)
|
|
assert.JSONEq(t, `{"nodes":[]}`, string(res.body))
|
|
})
|
|
|
|
t.Run("empty parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
h.assertParity(t, http.MethodGet, "/api/v1/node", nil)
|
|
})
|
|
|
|
t.Run("all parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
seedNodes(
|
|
newNodeSeed("alice", "node-a"),
|
|
newNodeSeed("bob", "node-b"),
|
|
)(t, h.app)
|
|
h.assertParity(t, http.MethodGet, "/api/v1/node", nil)
|
|
})
|
|
|
|
t.Run("filter by user parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
seedNodes(
|
|
newNodeSeed("alice", "node-a"),
|
|
newNodeSeed("bob", "node-b"),
|
|
)(t, h.app)
|
|
h.assertParity(t, http.MethodGet, "/api/v1/node?user=alice", nil)
|
|
})
|
|
|
|
t.Run("unknown user parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
seedNodes(newNodeSeed("alice", "node-a"))(t, h.app)
|
|
res := h.assertParity(t, http.MethodGet, "/api/v1/node?user=nope", nil)
|
|
assertStatus(t, res, http.StatusNotFound)
|
|
})
|
|
}
|
|
|
|
func TestAPIV1NodeDelete(t *testing.T) {
|
|
t.Run("happy parity", func(t *testing.T) {
|
|
assertParityIsolated(t, seedNodes(newNodeSeed("alice", "node-a")),
|
|
http.MethodDelete, "/api/v1/node/1", nil)
|
|
})
|
|
|
|
t.Run("not found parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
res := h.assertParity(t, http.MethodDelete, "/api/v1/node/99999", nil)
|
|
assertStatus(t, res, http.StatusNotFound)
|
|
})
|
|
|
|
t.Run("invalid id parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
res := h.assertParity(t, http.MethodDelete, "/api/v1/node/abc", nil)
|
|
assertStatus(t, res, http.StatusBadRequest)
|
|
})
|
|
}
|
|
|
|
func TestAPIV1NodeExpire(t *testing.T) {
|
|
// The embedded pre-auth key's masked prefix is random per app, so isolated
|
|
// body comparison is impossible. GetNode/ListNodes prove full serialisation;
|
|
// here we just assert the mutation took effect.
|
|
t.Run("huma expires node", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
seedNodes(newNodeSeed("alice", "node-a"))(t, h.app)
|
|
|
|
res := h.callHuma(http.MethodPost, "/api/v1/node/1/expire", nil)
|
|
require.Equal(t, http.StatusOK, res.status)
|
|
|
|
var got struct {
|
|
Node map[string]any `json:"node"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(res.body, &got))
|
|
|
|
assert.Equal(t, "1", got.Node["id"])
|
|
// Expiry was nil before; expiring sets it to a concrete timestamp.
|
|
assert.NotNil(t, got.Node["expiry"])
|
|
})
|
|
|
|
t.Run("huma refuses an unencodable expiry", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
seedNodes(newNodeSeed("alice", "node-a"))(t, h.app)
|
|
|
|
res := h.callHuma(http.MethodPost, "/api/v1/node/1/expire",
|
|
[]byte(`{"expiry":"9999-12-31T23:59:59-23:59"}`))
|
|
assertStatus(t, res, http.StatusBadRequest)
|
|
})
|
|
|
|
t.Run("not found parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
res := h.assertParity(t, http.MethodPost, "/api/v1/node/99999/expire", nil)
|
|
assertStatus(t, res, http.StatusNotFound)
|
|
})
|
|
|
|
t.Run("invalid id parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
res := h.assertParity(t, http.MethodPost, "/api/v1/node/abc/expire", nil)
|
|
assertStatus(t, res, http.StatusBadRequest)
|
|
})
|
|
}
|
|
|
|
func TestAPIV1NodeRename(t *testing.T) {
|
|
t.Run("huma renames node", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
seedNodes(newNodeSeed("alice", "node-a"))(t, h.app)
|
|
|
|
res := h.callHuma(http.MethodPost, "/api/v1/node/1/rename/renamed-node", nil)
|
|
require.Equal(t, http.StatusOK, res.status)
|
|
|
|
var got struct {
|
|
Node map[string]any `json:"node"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(res.body, &got))
|
|
|
|
assert.Equal(t, "renamed-node", got.Node["givenName"])
|
|
})
|
|
|
|
t.Run("not found parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
res := h.assertParity(t, http.MethodPost, "/api/v1/node/99999/rename/whatever", nil)
|
|
assertStatus(t, res, http.StatusNotFound)
|
|
})
|
|
|
|
t.Run("invalid id parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
res := h.assertParity(t, http.MethodPost, "/api/v1/node/abc/rename/whatever", nil)
|
|
assertStatus(t, res, http.StatusBadRequest)
|
|
})
|
|
}
|
|
|
|
func TestAPIV1NodeSetTags(t *testing.T) {
|
|
t.Run("not found parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
res := h.assertParity(t, http.MethodPost, "/api/v1/node/99999/tags",
|
|
[]byte(`{"tags":["tag:foo"]}`))
|
|
assertStatus(t, res, http.StatusNotFound)
|
|
})
|
|
|
|
t.Run("empty tags parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
seedNodes(newNodeSeed("alice", "node-a"))(t, h.app)
|
|
res := h.assertParity(t, http.MethodPost, "/api/v1/node/1/tags", []byte(`{"tags":[]}`))
|
|
assertStatus(t, res, http.StatusBadRequest)
|
|
})
|
|
|
|
t.Run("unauthorized tag parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
seedNodes(newNodeSeed("alice", "node-a"))(t, h.app)
|
|
// No tagOwners in policy → SetNodeTags rejects with InvalidArgument (400).
|
|
res := h.assertParity(t, http.MethodPost, "/api/v1/node/1/tags",
|
|
[]byte(`{"tags":["tag:server"]}`))
|
|
assertStatus(t, res, http.StatusBadRequest)
|
|
})
|
|
|
|
t.Run("invalid id parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
res := h.assertParity(t, http.MethodPost, "/api/v1/node/abc/tags",
|
|
[]byte(`{"tags":["tag:foo"]}`))
|
|
assertStatus(t, res, http.StatusBadRequest)
|
|
})
|
|
}
|
|
|
|
func TestAPIV1NodeSetApprovedRoutes(t *testing.T) {
|
|
t.Run("huma sets approved routes", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
seedNodes(newNodeSeed("alice", "node-a"))(t, h.app)
|
|
|
|
res := h.callHuma(http.MethodPost, "/api/v1/node/1/approve_routes",
|
|
[]byte(`{"routes":[]}`))
|
|
require.Equal(t, http.StatusOK, res.status)
|
|
|
|
var got struct {
|
|
Node map[string]any `json:"node"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(res.body, &got))
|
|
|
|
assert.Equal(t, "1", got.Node["id"])
|
|
// SubnetRoutes is recomputed from primary routes; empty here but present.
|
|
assert.Equal(t, []any{}, got.Node["approvedRoutes"])
|
|
assert.Equal(t, []any{}, got.Node["subnetRoutes"])
|
|
})
|
|
|
|
t.Run("not found parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
res := h.assertParity(t, http.MethodPost, "/api/v1/node/99999/approve_routes",
|
|
[]byte(`{"routes":["10.0.0.0/24"]}`))
|
|
assertStatus(t, res, http.StatusNotFound)
|
|
})
|
|
|
|
t.Run("invalid id parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
res := h.assertParity(t, http.MethodPost, "/api/v1/node/abc/approve_routes",
|
|
[]byte(`{"routes":[]}`))
|
|
assertStatus(t, res, http.StatusBadRequest)
|
|
})
|
|
|
|
// The admin's v2 route calls on routers announcing the tailnet's own
|
|
// addresses, replayed against v1: the same routes accepted or refused.
|
|
// Test data source: policy/v2/testdata/routes_results/routes-t4-admin.hujson.
|
|
t.Run("saas compat", func(t *testing.T) {
|
|
c := readRoutesCapture(t, "routes-t4-admin")
|
|
h := newAPIV1Harness(t)
|
|
devices := seedCaptureRouters(t, h.app, c)
|
|
|
|
replayRouteCalls(t, c, "v1-", func(a testcapture.Action, alias string) routeCall {
|
|
target := "/api/v1/node/" + devices[alias]
|
|
if a.Method == http.MethodPost {
|
|
target += "/approve_routes"
|
|
}
|
|
|
|
res := h.callHuma(a.Method, target, a.Body)
|
|
|
|
var body struct {
|
|
Node struct {
|
|
Available []string `json:"availableRoutes"`
|
|
Approved []string `json:"approvedRoutes"`
|
|
} `json:"node"`
|
|
Detail string `json:"detail"`
|
|
}
|
|
require.NoErrorf(t, json.Unmarshal(res.body, &body), "body: %s", res.body)
|
|
|
|
return routeCall{res.status, body.Node.Available, body.Node.Approved, body.Detail}
|
|
})
|
|
})
|
|
}
|
|
|
|
func TestAPIV1NodeRegister(t *testing.T) {
|
|
t.Run("happy parity", func(t *testing.T) {
|
|
authID := types.MustAuthID()
|
|
mk := key.NewMachine()
|
|
nk := key.NewNode()
|
|
|
|
seed := func(t *testing.T, app *Headscale) {
|
|
t.Helper()
|
|
|
|
app.state.CreateUserForTest("alice")
|
|
|
|
regData := &types.RegistrationData{
|
|
NodeKey: nk.Public(),
|
|
MachineKey: mk.Public(),
|
|
Hostname: "registered-node",
|
|
}
|
|
app.state.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(regData))
|
|
}
|
|
|
|
assertParityIsolated(t, seed, http.MethodPost,
|
|
"/api/v1/node/register?user=alice&key="+authID.String(), nil)
|
|
})
|
|
|
|
t.Run("invalid key parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
seedNodes(newNodeSeed("alice", "node-a"))(t, h.app)
|
|
res := h.assertParity(t, http.MethodPost,
|
|
"/api/v1/node/register?user=alice&key=invalidkey", nil)
|
|
assertStatus(t, res, http.StatusBadRequest)
|
|
})
|
|
|
|
t.Run("unknown user parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
res := h.assertParity(t, http.MethodPost,
|
|
"/api/v1/node/register?user=nope&key="+types.MustAuthID().String(), nil)
|
|
assertStatus(t, res, http.StatusNotFound)
|
|
})
|
|
}
|
|
|
|
func TestAPIV1NodeBackfillIPs(t *testing.T) {
|
|
t.Run("confirmed empty parity", func(t *testing.T) {
|
|
assertParityIsolated(t, nil, http.MethodPost,
|
|
"/api/v1/node/backfillips?confirmed=true", nil)
|
|
})
|
|
|
|
t.Run("confirmed returns empty array", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
|
|
res := h.callHuma(http.MethodPost, "/api/v1/node/backfillips?confirmed=true", nil)
|
|
require.Equal(t, http.StatusOK, res.status)
|
|
assert.JSONEq(t, `{"changes":[]}`, string(res.body))
|
|
})
|
|
|
|
t.Run("unconfirmed parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
res := h.assertParity(t, http.MethodPost, "/api/v1/node/backfillips", nil)
|
|
assertStatus(t, res, http.StatusBadRequest)
|
|
})
|
|
}
|
|
|
|
func TestAPIV1NodeDebugCreate(t *testing.T) {
|
|
t.Run("unknown user parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
body := []byte(`{"user":"nope","key":"` + types.MustAuthID().String() +
|
|
`","name":"dbg","routes":[]}`)
|
|
res := h.assertParity(t, http.MethodPost, "/api/v1/debug/node", body)
|
|
assertStatus(t, res, http.StatusNotFound)
|
|
})
|
|
|
|
t.Run("invalid key parity", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
seedNodes(newNodeSeed("alice", "node-a"))(t, h.app)
|
|
|
|
body := []byte(`{"user":"alice","key":"badkey","name":"dbg","routes":[]}`)
|
|
res := h.assertParity(t, http.MethodPost, "/api/v1/debug/node", body)
|
|
assertStatus(t, res, http.StatusBadRequest)
|
|
})
|
|
|
|
// The handler mints fresh key material per call, so isolated apps can't
|
|
// produce byte-identical bodies; assert the shape directly instead.
|
|
t.Run("huma response shape", func(t *testing.T) {
|
|
h := newAPIV1Harness(t)
|
|
h.app.state.CreateUserForTest("alice")
|
|
|
|
body := []byte(`{"user":"alice","key":"` + types.MustAuthID().String() +
|
|
`","name":"dbgnode","routes":["10.0.0.0/24"]}`)
|
|
|
|
res := h.callHuma(http.MethodPost, "/api/v1/debug/node", body)
|
|
require.Equal(t, http.StatusOK, res.status)
|
|
|
|
var got struct {
|
|
Node map[string]any `json:"node"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(res.body, &got))
|
|
|
|
assert.Equal(t, "dbgnode", got.Node["name"])
|
|
assert.Equal(t, "REGISTER_METHOD_UNSPECIFIED", got.Node["registerMethod"])
|
|
assert.Equal(t, []any{"10.0.0.0/24"}, got.Node["availableRoutes"])
|
|
// The synthetic echo node has no pre-auth key: emitted as null.
|
|
assert.Nil(t, got.Node["preAuthKey"])
|
|
// Zero-time expiry/lastSeen are emitted as the zero instant, not null.
|
|
assert.Equal(t, "0001-01-01T00:00:00Z", got.Node["expiry"])
|
|
assert.Equal(t, "0001-01-01T00:00:00Z", got.Node["lastSeen"])
|
|
})
|
|
}
|