mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-25 15:56:49 +09:00
1 parent
f60f89647b
commit
d11be77f1f
7 files changed
+192
-261
No files matched your search
@@ -140,27 +140,6 @@ func cmpOptions() []cmp.Option {
|
|||||||
|
|
||||||
return a.Bits() < b.Bits()
|
return a.Bits() < b.Bits()
|
||||||
}),
|
}),
|
||||||
// Compare json.RawMessage semantically rather than by exact
|
|
||||||
// bytes to handle indentation differences between the policy
|
|
||||||
// source and the golden capture data.
|
|
||||||
cmp.Comparer(func(a, b json.RawMessage) bool {
|
|
||||||
var va, vb any
|
|
||||||
|
|
||||||
err := json.Unmarshal(a, &va)
|
|
||||||
if err != nil {
|
|
||||||
return string(a) == string(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = json.Unmarshal(b, &vb)
|
|
||||||
if err != nil {
|
|
||||||
return string(a) == string(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
ja, _ := json.Marshal(va)
|
|
||||||
jb, _ := json.Marshal(vb)
|
|
||||||
|
|
||||||
return string(ja) == string(jb)
|
|
||||||
}),
|
|
||||||
// Compare tailcfg.RawMessage semantically (it's a string type
|
// Compare tailcfg.RawMessage semantically (it's a string type
|
||||||
// containing JSON) to handle indentation differences.
|
// containing JSON) to handle indentation differences.
|
||||||
cmp.Comparer(func(a, b tailcfg.RawMessage) bool {
|
cmp.Comparer(func(a, b tailcfg.RawMessage) bool {
|
||||||
@@ -492,9 +471,6 @@ func testACLSuccess(
|
|||||||
|
|
||||||
for nodeName, capture := range tf.Captures {
|
for nodeName, capture := range tf.Captures {
|
||||||
t.Run(nodeName, func(t *testing.T) {
|
t.Run(nodeName, func(t *testing.T) {
|
||||||
captureIsNull := len(capture.PacketFilterRules) == 0 ||
|
|
||||||
string(capture.PacketFilterRules) == "null" //nolint:goconst
|
|
||||||
|
|
||||||
node := findNodeByGivenName(nodes, nodeName)
|
node := findNodeByGivenName(nodes, nodeName)
|
||||||
if node == nil {
|
if node == nil {
|
||||||
t.Skipf(
|
t.Skipf(
|
||||||
@@ -524,21 +500,7 @@ func testACLSuccess(
|
|||||||
compiledRules,
|
compiledRules,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Parse expected rules from JSON
|
wantRules := capture.PacketFilterRules
|
||||||
var wantRules []tailcfg.FilterRule
|
|
||||||
if !captureIsNull {
|
|
||||||
err = json.Unmarshal(
|
|
||||||
capture.PacketFilterRules,
|
|
||||||
&wantRules,
|
|
||||||
)
|
|
||||||
require.NoError(
|
|
||||||
t,
|
|
||||||
err,
|
|
||||||
"%s/%s: failed to unmarshal expected rules",
|
|
||||||
tf.TestID,
|
|
||||||
nodeName,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compare
|
// Compare
|
||||||
opts := append(
|
opts := append(
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
package v2
|
package v2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -273,9 +272,11 @@ func findGrantsNode(nodes types.Nodes, name string) *types.Node {
|
|||||||
// convertPolicyUserEmails used to map SaaS-side emails to @example.com.
|
// convertPolicyUserEmails used to map SaaS-side emails to @example.com.
|
||||||
// tscap now anonymizes the policy JSON at write time (kratail2tid -> odin,
|
// tscap now anonymizes the policy JSON at write time (kratail2tid -> odin,
|
||||||
// kristoffer -> thor, monitorpasskeykradalby -> freya), so the captured
|
// kristoffer -> thor, monitorpasskeykradalby -> freya), so the captured
|
||||||
// FullPolicy is already in its final form and this is a passthrough.
|
// FullPolicy is already in its final form and this is a passthrough that
|
||||||
func convertPolicyUserEmails(policyJSON []byte) []byte {
|
// just adapts the captured string value to the []byte that the policy
|
||||||
return policyJSON
|
// parser expects.
|
||||||
|
func convertPolicyUserEmails(policyJSON string) []byte {
|
||||||
|
return []byte(policyJSON)
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadGrantTestFile loads and parses a single grant capture HuJSON file.
|
// loadGrantTestFile loads and parses a single grant capture HuJSON file.
|
||||||
@@ -501,10 +502,7 @@ func testGrantSuccess(
|
|||||||
// kakuna (tag:prod) was frequently offline (132 of 188 success tests).
|
// kakuna (tag:prod) was frequently offline (132 of 188 success tests).
|
||||||
// When offline, packet_filter_rules is null and topology shows
|
// When offline, packet_filter_rules is null and topology shows
|
||||||
// hostname="unknown" with empty tags.
|
// hostname="unknown" with empty tags.
|
||||||
captureIsNull := len(capture.PacketFilterRules) == 0 ||
|
if len(capture.PacketFilterRules) == 0 {
|
||||||
string(capture.PacketFilterRules) == "null"
|
|
||||||
|
|
||||||
if captureIsNull {
|
|
||||||
topoNode, exists := tf.Topology.Nodes[nodeName]
|
topoNode, exists := tf.Topology.Nodes[nodeName]
|
||||||
if exists && (topoNode.Hostname == "unknown" || topoNode.Hostname == "") {
|
if exists && (topoNode.Hostname == "unknown" || topoNode.Hostname == "") {
|
||||||
t.Skipf(
|
t.Skipf(
|
||||||
@@ -515,7 +513,7 @@ func testGrantSuccess(
|
|||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Node was online but has null/empty rules — means Tailscale
|
// Node was online but has empty rules — means Tailscale
|
||||||
// produced no rules. headscale should also produce no rules.
|
// produced no rules. headscale should also produce no rules.
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,21 +543,7 @@ func testGrantSuccess(
|
|||||||
|
|
||||||
gotRules = policyutil.ReduceFilterRules(node.View(), gotRules)
|
gotRules = policyutil.ReduceFilterRules(node.View(), gotRules)
|
||||||
|
|
||||||
// Unmarshal Tailscale expected rules from JSON capture
|
wantRules := capture.PacketFilterRules
|
||||||
var wantRules []tailcfg.FilterRule
|
|
||||||
if !captureIsNull {
|
|
||||||
err = json.Unmarshal(
|
|
||||||
[]byte(capture.PacketFilterRules),
|
|
||||||
&wantRules,
|
|
||||||
)
|
|
||||||
require.NoError(
|
|
||||||
t,
|
|
||||||
err,
|
|
||||||
"%s/%s: failed to unmarshal expected rules from JSON",
|
|
||||||
tf.TestID,
|
|
||||||
nodeName,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compare headscale output against Tailscale expected output.
|
// Compare headscale output against Tailscale expected output.
|
||||||
// The diff labels show (-tailscale +headscale) to make clear
|
// The diff labels show (-tailscale +headscale) to make clear
|
||||||
|
|||||||
@@ -26,7 +26,6 @@
|
|||||||
package v2
|
package v2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -245,9 +244,6 @@ func TestRoutesCompat(t *testing.T) {
|
|||||||
|
|
||||||
for nodeName, capture := range tf.Captures {
|
for nodeName, capture := range tf.Captures {
|
||||||
t.Run(nodeName, func(t *testing.T) {
|
t.Run(nodeName, func(t *testing.T) {
|
||||||
captureIsNull := len(capture.PacketFilterRules) == 0 ||
|
|
||||||
string(capture.PacketFilterRules) == "null" //nolint:goconst
|
|
||||||
|
|
||||||
node := findNodeByGivenName(nodes, nodeName)
|
node := findNodeByGivenName(nodes, nodeName)
|
||||||
if node == nil {
|
if node == nil {
|
||||||
t.Skipf(
|
t.Skipf(
|
||||||
@@ -276,20 +272,7 @@ func TestRoutesCompat(t *testing.T) {
|
|||||||
compiledRules,
|
compiledRules,
|
||||||
)
|
)
|
||||||
|
|
||||||
var wantRules []tailcfg.FilterRule
|
wantRules := capture.PacketFilterRules
|
||||||
if !captureIsNull {
|
|
||||||
err = json.Unmarshal(
|
|
||||||
capture.PacketFilterRules,
|
|
||||||
&wantRules,
|
|
||||||
)
|
|
||||||
require.NoError(
|
|
||||||
t,
|
|
||||||
err,
|
|
||||||
"%s/%s: failed to unmarshal expected rules",
|
|
||||||
tf.TestID,
|
|
||||||
nodeName,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
opts := append(
|
opts := append(
|
||||||
cmpOptions(),
|
cmpOptions(),
|
||||||
@@ -331,19 +314,11 @@ func derivePeerPairsFromCaptures(
|
|||||||
pairs := make(map[[2]string]bool)
|
pairs := make(map[[2]string]bool)
|
||||||
|
|
||||||
for dstNodeName, capture := range tf.Captures {
|
for dstNodeName, capture := range tf.Captures {
|
||||||
captureIsNull := len(capture.PacketFilterRules) == 0 ||
|
if len(capture.PacketFilterRules) == 0 {
|
||||||
string(capture.PacketFilterRules) == "null"
|
|
||||||
if captureIsNull {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var rules []tailcfg.FilterRule
|
rules := capture.PacketFilterRules
|
||||||
|
|
||||||
err := json.Unmarshal(capture.PacketFilterRules, &rules)
|
|
||||||
require.NoError(t, err,
|
|
||||||
"%s/%s: failed to unmarshal capture rules",
|
|
||||||
tf.TestID, dstNodeName,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Build an IPSet of all SrcIPs from the capture's filter rules.
|
// Build an IPSet of all SrcIPs from the capture's filter rules.
|
||||||
var srcBuilder netipx.IPSetBuilder
|
var srcBuilder netipx.IPSetBuilder
|
||||||
@@ -460,19 +435,11 @@ func deriveAllPeerPairsFromCaptures(
|
|||||||
pairs := make(map[[2]string]bool)
|
pairs := make(map[[2]string]bool)
|
||||||
|
|
||||||
for dstNodeName, capture := range tf.Captures {
|
for dstNodeName, capture := range tf.Captures {
|
||||||
captureIsNull := len(capture.PacketFilterRules) == 0 ||
|
if len(capture.PacketFilterRules) == 0 {
|
||||||
string(capture.PacketFilterRules) == "null"
|
|
||||||
if captureIsNull {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var rules []tailcfg.FilterRule
|
rules := capture.PacketFilterRules
|
||||||
|
|
||||||
err := json.Unmarshal(capture.PacketFilterRules, &rules)
|
|
||||||
require.NoError(t, err,
|
|
||||||
"%s/%s: failed to unmarshal capture rules",
|
|
||||||
tf.TestID, dstNodeName,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Build an IPSet of all SrcIPs.
|
// Build an IPSet of all SrcIPs.
|
||||||
var srcBuilder netipx.IPSetBuilder
|
var srcBuilder netipx.IPSetBuilder
|
||||||
@@ -665,22 +632,11 @@ func TestRoutesCompatPeerVisibility(t *testing.T) {
|
|||||||
// called from a node whose subnet routes overlap the
|
// called from a node whose subnet routes overlap the
|
||||||
// source CIDRs.
|
// source CIDRs.
|
||||||
for dstNodeName, capture := range tf.Captures {
|
for dstNodeName, capture := range tf.Captures {
|
||||||
captureIsNull := len(
|
if len(capture.PacketFilterRules) == 0 {
|
||||||
capture.PacketFilterRules,
|
|
||||||
) == 0 ||
|
|
||||||
string(
|
|
||||||
capture.PacketFilterRules,
|
|
||||||
) == "null"
|
|
||||||
if captureIsNull {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var rules []tailcfg.FilterRule
|
rules := capture.PacketFilterRules
|
||||||
|
|
||||||
err := json.Unmarshal(
|
|
||||||
capture.PacketFilterRules, &rules,
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Extract destination prefixes from the rules.
|
// Extract destination prefixes from the rules.
|
||||||
var dstPrefixes []netip.Prefix
|
var dstPrefixes []netip.Prefix
|
||||||
@@ -920,25 +876,11 @@ func TestRoutesCompatReduceRoutes(t *testing.T) {
|
|||||||
// Then verify that viewer nodes with matching source
|
// Then verify that viewer nodes with matching source
|
||||||
// identity can access those routes via CanAccessRoute.
|
// identity can access those routes via CanAccessRoute.
|
||||||
for dstNodeName, capture := range tf.Captures {
|
for dstNodeName, capture := range tf.Captures {
|
||||||
captureIsNull := len(
|
if len(capture.PacketFilterRules) == 0 {
|
||||||
capture.PacketFilterRules,
|
|
||||||
) == 0 ||
|
|
||||||
string(
|
|
||||||
capture.PacketFilterRules,
|
|
||||||
) == "null"
|
|
||||||
if captureIsNull {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var rules []tailcfg.FilterRule
|
rules := capture.PacketFilterRules
|
||||||
|
|
||||||
err := json.Unmarshal(
|
|
||||||
capture.PacketFilterRules, &rules,
|
|
||||||
)
|
|
||||||
require.NoError(t, err,
|
|
||||||
"%s/%s: failed to unmarshal capture rules",
|
|
||||||
tf.TestID, dstNodeName,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Build the set of destination route prefixes.
|
// Build the set of destination route prefixes.
|
||||||
var dstPrefixes []netip.Prefix
|
var dstPrefixes []netip.Prefix
|
||||||
@@ -1265,24 +1207,11 @@ func TestRoutesCompatNoPeersBeyondCaptures(t *testing.T) {
|
|||||||
// appears in DstPorts of rules delivered to another
|
// appears in DstPorts of rules delivered to another
|
||||||
// node, they must be peers.
|
// node, they must be peers.
|
||||||
for dstNodeName, capture := range tf.Captures {
|
for dstNodeName, capture := range tf.Captures {
|
||||||
captureIsNull := len(
|
if len(capture.PacketFilterRules) == 0 {
|
||||||
capture.PacketFilterRules,
|
|
||||||
) == 0 ||
|
|
||||||
string(
|
|
||||||
capture.PacketFilterRules,
|
|
||||||
) == "null"
|
|
||||||
if captureIsNull {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var rules []tailcfg.FilterRule
|
rules := capture.PacketFilterRules
|
||||||
|
|
||||||
err := json.Unmarshal(
|
|
||||||
capture.PacketFilterRules, &rules,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var dstBuilder netipx.IPSetBuilder
|
var dstBuilder netipx.IPSetBuilder
|
||||||
|
|
||||||
@@ -1423,9 +1352,7 @@ func TestRoutesCompatNoFalsePositivePeers(t *testing.T) {
|
|||||||
routerNodes := make(map[string]bool)
|
routerNodes := make(map[string]bool)
|
||||||
|
|
||||||
for nodeName, capture := range tf.Captures {
|
for nodeName, capture := range tf.Captures {
|
||||||
captureIsNull := len(capture.PacketFilterRules) == 0 ||
|
if len(capture.PacketFilterRules) > 0 {
|
||||||
string(capture.PacketFilterRules) == "null"
|
|
||||||
if !captureIsNull {
|
|
||||||
routerNodes[nodeName] = true
|
routerNodes[nodeName] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
package v2
|
package v2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -185,7 +184,7 @@ func TestSSHDataCompat(t *testing.T) {
|
|||||||
// tscap already rewrites SaaS emails to @example.com.
|
// tscap already rewrites SaaS emails to @example.com.
|
||||||
policyJSON := tf.Input.FullPolicy
|
policyJSON := tf.Input.FullPolicy
|
||||||
|
|
||||||
pol, err := unmarshalPolicy(policyJSON)
|
pol, err := unmarshalPolicy([]byte(policyJSON))
|
||||||
require.NoError(
|
require.NoError(
|
||||||
t,
|
t,
|
||||||
err,
|
err,
|
||||||
@@ -217,24 +216,10 @@ func TestSSHDataCompat(t *testing.T) {
|
|||||||
nodeName,
|
nodeName,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Parse expected rules from JSON capture
|
// Build expected SSHPolicy from the typed rules.
|
||||||
var wantRules []*tailcfg.SSHRule
|
|
||||||
if len(capture.SSHRules) > 0 &&
|
|
||||||
string(capture.SSHRules) != "null" {
|
|
||||||
err = json.Unmarshal(capture.SSHRules, &wantRules)
|
|
||||||
require.NoError(
|
|
||||||
t,
|
|
||||||
err,
|
|
||||||
"%s/%s: failed to unmarshal expected rules",
|
|
||||||
tf.TestID,
|
|
||||||
nodeName,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build expected SSHPolicy from the rules
|
|
||||||
var wantSSH *tailcfg.SSHPolicy
|
var wantSSH *tailcfg.SSHPolicy
|
||||||
if len(wantRules) > 0 {
|
if len(capture.SSHRules) > 0 {
|
||||||
wantSSH = &tailcfg.SSHPolicy{Rules: wantRules}
|
wantSSH = &tailcfg.SSHPolicy{Rules: capture.SSHRules}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize: treat empty-rules SSHPolicy as nil
|
// Normalize: treat empty-rules SSHPolicy as nil
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ func captureStats(c *Capture) string {
|
|||||||
if n.PacketFilterRules != nil {
|
if n.PacketFilterRules != nil {
|
||||||
filterRulesSet = true
|
filterRulesSet = true
|
||||||
|
|
||||||
if !isJSONNullOrEmpty(n.PacketFilterRules) {
|
if len(n.PacketFilterRules) > 0 {
|
||||||
filterRules++
|
filterRules++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,7 +97,7 @@ func captureStats(c *Capture) string {
|
|||||||
if n.SSHRules != nil {
|
if n.SSHRules != nil {
|
||||||
sshRulesSet = true
|
sshRulesSet = true
|
||||||
|
|
||||||
if !isJSONNullOrEmpty(n.SSHRules) {
|
if len(n.SSHRules) > 0 {
|
||||||
sshRules++
|
sshRules++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,15 +112,3 @@ func captureStats(c *Capture) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// isJSONNullOrEmpty reports whether raw is the JSON value `null`,
|
|
||||||
// an empty array `[]`, or empty after whitespace trimming.
|
|
||||||
func isJSONNullOrEmpty(raw []byte) bool {
|
|
||||||
trimmed := strings.TrimSpace(string(raw))
|
|
||||||
switch trimmed {
|
|
||||||
case "", "null", "[]":
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,12 +2,15 @@
|
|||||||
// policy v2 compatibility tests for golden data captured from
|
// policy v2 compatibility tests for golden data captured from
|
||||||
// Tailscale SaaS by the tscap tool.
|
// Tailscale SaaS by the tscap tool.
|
||||||
//
|
//
|
||||||
// Files are HuJSON. The package intentionally does not import
|
// Files are HuJSON. Wire-format Tailscale data (filter rules, netmap,
|
||||||
// tailscale.com/tailcfg so it can live under hscontrol/types/
|
// whois, SSH rules) is stored as proper tailcfg/netmap/filtertype/
|
||||||
// without dragging extra dependencies. Wire-format Tailscale data
|
// apitype values rather than json.RawMessage so that schema drift
|
||||||
// (filter rules, netmap, whois, SSH rules) is stored as
|
// between tscap and headscale becomes a compile error rather than a
|
||||||
// json.RawMessage; consumers json.Unmarshal into the typed shape
|
// silent test failure, and so that consumers don't have to repeat
|
||||||
// they need.
|
// json.Unmarshal at every read site. Storing data as json.RawMessage
|
||||||
|
// previously hid a serious capture-pipeline bug (the IPN bus initial
|
||||||
|
// notification returns a stale Peers slice — see the comment on
|
||||||
|
// Node.Netmap below) for months.
|
||||||
//
|
//
|
||||||
// All four corpora (acl, routes, grant, ssh) use the same Capture
|
// All four corpora (acl, routes, grant, ssh) use the same Capture
|
||||||
// shape. SSH scenarios populate Captures[name].SSHRules; the others
|
// shape. SSH scenarios populate Captures[name].SSHRules; the others
|
||||||
@@ -17,6 +20,11 @@ package testcapture
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"tailscale.com/client/tailscale/apitype"
|
||||||
|
"tailscale.com/tailcfg"
|
||||||
|
"tailscale.com/types/netmap"
|
||||||
|
"tailscale.com/wgengine/filter/filtertype"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SchemaVersion identifies the on-disk format. Bumped on breaking changes.
|
// SchemaVersion identifies the on-disk format. Bumped on breaking changes.
|
||||||
@@ -84,10 +92,18 @@ type Capture struct {
|
|||||||
|
|
||||||
// Input describes everything that was sent to the tailnet to produce
|
// Input describes everything that was sent to the tailnet to produce
|
||||||
// the captured state.
|
// the captured state.
|
||||||
|
//
|
||||||
|
// Input has a custom UnmarshalJSON to accept both the new on-disk
|
||||||
|
// shape (where full_policy is a JSON-encoded string) and the legacy
|
||||||
|
// shape (where full_policy is a JSON object). The legacy shape is
|
||||||
|
// re-marshaled to a string at load time so consumers see the typed
|
||||||
|
// field uniformly.
|
||||||
type Input struct {
|
type Input struct {
|
||||||
// FullPolicy is the verbatim policy that was POSTed to the SaaS
|
// FullPolicy is the verbatim policy that was POSTed to the SaaS
|
||||||
// API. Stored as raw JSON so it round-trips losslessly.
|
// API. Stored as a string because it is opaque JSON that round-
|
||||||
FullPolicy json.RawMessage `json:"full_policy"`
|
// trips losslessly without parsing — headscale's policy parser
|
||||||
|
// reads it on demand.
|
||||||
|
FullPolicy string `json:"full_policy"`
|
||||||
|
|
||||||
// APIResponseCode is the HTTP status code of the policy POST.
|
// APIResponseCode is the HTTP status code of the policy POST.
|
||||||
APIResponseCode int `json:"api_response_code"`
|
APIResponseCode int `json:"api_response_code"`
|
||||||
@@ -109,6 +125,65 @@ type Input struct {
|
|||||||
ScenarioPath string `json:"scenario_path,omitempty"`
|
ScenarioPath string `json:"scenario_path,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON handles both the current on-disk shape (full_policy
|
||||||
|
// as a JSON-encoded string) and the legacy shape (full_policy as a
|
||||||
|
// JSON object). Legacy objects are re-marshaled into a string at
|
||||||
|
// load time so consumers see the typed field uniformly. New captures
|
||||||
|
// always write the string form via the default Marshaler.
|
||||||
|
func (i *Input) UnmarshalJSON(data []byte) error {
|
||||||
|
type alias Input
|
||||||
|
|
||||||
|
var raw struct {
|
||||||
|
alias
|
||||||
|
|
||||||
|
FullPolicy json.RawMessage `json:"full_policy"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := json.Unmarshal(data, &raw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
*i = Input(raw.alias)
|
||||||
|
// raw.FullPolicy might be a JSON-encoded string ("...") or a JSON
|
||||||
|
// object/array/null. Try string first; on failure use the raw bytes
|
||||||
|
// verbatim, normalised to compact form.
|
||||||
|
if len(raw.FullPolicy) == 0 || string(raw.FullPolicy) == "null" {
|
||||||
|
i.FullPolicy = ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw.FullPolicy[0] == '"' {
|
||||||
|
var s string
|
||||||
|
|
||||||
|
err := json.Unmarshal(raw.FullPolicy, &s)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
i.FullPolicy = s
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Legacy: full_policy is a raw JSON object. Compact and store as
|
||||||
|
// the equivalent string.
|
||||||
|
var v any
|
||||||
|
|
||||||
|
err = json.Unmarshal(raw.FullPolicy, &v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
compact, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
i.FullPolicy = string(compact)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// APIResponseBody is the (subset of) the SaaS API error response we keep.
|
// APIResponseBody is the (subset of) the SaaS API error response we keep.
|
||||||
type APIResponseBody struct {
|
type APIResponseBody struct {
|
||||||
Message string `json:"message,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
@@ -190,31 +265,41 @@ type TopologyNode struct {
|
|||||||
//
|
//
|
||||||
// Whichever fields are set in the file is what the consumer reads.
|
// Whichever fields are set in the file is what the consumer reads.
|
||||||
type Node struct {
|
type Node struct {
|
||||||
// PacketFilterRules is the wire-format []tailcfg.FilterRule as
|
// PacketFilterRules is the wire-format filter rules as returned
|
||||||
// returned by tailscaled localapi /debug-packet-filter-rules.
|
// by tailscaled localapi /debug-packet-filter-rules. The single
|
||||||
// The single most important field for ACL/routes/grant tests.
|
// most important field for ACL/routes/grant tests.
|
||||||
PacketFilterRules json.RawMessage `json:"packet_filter_rules,omitempty"`
|
PacketFilterRules []tailcfg.FilterRule `json:"packet_filter_rules,omitempty"`
|
||||||
|
|
||||||
// PacketFilterMatches is the compiled []filtertype.Match with
|
// PacketFilterMatches is the compiled filter matches (with
|
||||||
// CapMatch, returned by tailscaled localapi
|
// CapMatch) returned by tailscaled localapi
|
||||||
// /debug-packet-filter-matches. Captured alongside
|
// /debug-packet-filter-matches. Captured alongside
|
||||||
// PacketFilterRules; useful for grant tests that want the
|
// PacketFilterRules; useful for grant tests that want the
|
||||||
// compiled form.
|
// compiled form.
|
||||||
PacketFilterMatches json.RawMessage `json:"packet_filter_matches,omitempty"`
|
PacketFilterMatches []filtertype.Match `json:"packet_filter_matches,omitempty"`
|
||||||
|
|
||||||
// Netmap is the full netmap as returned by tailscaled localapi
|
// Netmap is the full netmap as observed by the local tailscaled.
|
||||||
// /watch-ipn-bus?mask=NotifyInitialNetMap. NEVER trimmed.
|
// NEVER trimmed. Consumers extract whatever fields they need.
|
||||||
// Consumers extract whatever fields they need.
|
//
|
||||||
Netmap json.RawMessage `json:"netmap,omitempty"`
|
// IMPORTANT: tscap captures this by waiting for the IPN bus to
|
||||||
|
// settle on a fresh delta-triggered notification, NOT by reading
|
||||||
|
// the WatchIPNBus(NotifyInitialNetMap) initial notification.
|
||||||
|
// The initial notification carries cn.NetMap() which returns
|
||||||
|
// nb.netMap as-is — the netmap.NetworkMap whose Peers slice was
|
||||||
|
// set at full-sync time and never re-synchronized from the
|
||||||
|
// authoritative nb.peers map. tscap previously used the initial
|
||||||
|
// notification and silently captured netmaps with mostly-empty
|
||||||
|
// Peers, which corrupted every via-grant compat test against the
|
||||||
|
// stale data. See tscap/tsdaemon/capture.go:NetMap for the
|
||||||
|
// stability-wait pattern, and tailscale.com/ipn/ipnlocal/c2n.go
|
||||||
|
// :handleC2NDebugNetMap which uses netMapWithPeers() for the
|
||||||
|
// same reason.
|
||||||
|
Netmap *netmap.NetworkMap `json:"netmap,omitempty"`
|
||||||
|
|
||||||
// Whois is per-peer whois lookups, keyed by peer IP. Each value
|
// Whois is per-peer whois lookups, keyed by peer IP. Captured
|
||||||
// is the verbatim WhoIsResponse JSON returned by
|
// only when scenario.options.capture_whois is true.
|
||||||
// /localapi/v0/whois. Captured only when
|
Whois map[string]*apitype.WhoIsResponse `json:"whois,omitempty"`
|
||||||
// scenario.options.capture_whois is true.
|
|
||||||
Whois map[string]json.RawMessage `json:"whois,omitempty"`
|
|
||||||
|
|
||||||
// SSHRules is the SSH rules for SSH corpus. Same shape as
|
// SSHRules is the SSH rules slice extracted from
|
||||||
// tailcfg.SSHPolicy.Rules ([]tailcfg.SSHRule), kept as raw JSON.
|
// netmap.SSHPolicy.Rules. Populated only for SSH scenarios.
|
||||||
// Populated only for SSH scenarios.
|
SSHRules []*tailcfg.SSHRule `json:"ssh_rules,omitempty"`
|
||||||
SSHRules json.RawMessage `json:"ssh_rules,omitempty"`
|
|
||||||
}
|
}
|
||||||
@@ -8,19 +8,23 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/go-cmp/cmp"
|
|
||||||
"github.com/juanfont/headscale/hscontrol/types/testcapture"
|
"github.com/juanfont/headscale/hscontrol/types/testcapture"
|
||||||
|
"tailscale.com/tailcfg"
|
||||||
|
"tailscale.com/types/netmap"
|
||||||
)
|
)
|
||||||
|
|
||||||
func sampleACLCapture() *testcapture.Capture {
|
func sampleACLCapture() *testcapture.Capture {
|
||||||
policy := json.RawMessage(`{"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`)
|
rules := []tailcfg.FilterRule{
|
||||||
rules := json.RawMessage(`[
|
{
|
||||||
{
|
SrcIPs: []string{"*"},
|
||||||
"SrcIPs": ["*"],
|
DstPorts: []tailcfg.NetPortRange{
|
||||||
"DstPorts": [{"IP": "*", "Ports": {"First": 0, "Last": 65535}}]
|
{IP: "*", Ports: tailcfg.PortRangeAny},
|
||||||
}
|
},
|
||||||
]`)
|
},
|
||||||
netmap := json.RawMessage(`{"SelfNode":{"Name":"user1.tail.example.com."}}`)
|
}
|
||||||
|
nm := &netmap.NetworkMap{
|
||||||
|
SelfNode: (&tailcfg.Node{Name: "user1.tail.example.com."}).View(),
|
||||||
|
}
|
||||||
|
|
||||||
return &testcapture.Capture{
|
return &testcapture.Capture{
|
||||||
SchemaVersion: testcapture.SchemaVersion,
|
SchemaVersion: testcapture.SchemaVersion,
|
||||||
@@ -31,7 +35,7 @@ func sampleACLCapture() *testcapture.Capture {
|
|||||||
ToolVersion: "tscap-test-0.0.0",
|
ToolVersion: "tscap-test-0.0.0",
|
||||||
Tailnet: "kratail2tid@passkey",
|
Tailnet: "kratail2tid@passkey",
|
||||||
Input: testcapture.Input{
|
Input: testcapture.Input{
|
||||||
FullPolicy: policy,
|
FullPolicy: `{"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`,
|
||||||
APIResponseCode: 200,
|
APIResponseCode: 200,
|
||||||
Tailnet: testcapture.TailnetInput{
|
Tailnet: testcapture.TailnetInput{
|
||||||
DNS: testcapture.DNSInput{
|
DNS: testcapture.DNSInput{
|
||||||
@@ -71,19 +75,24 @@ func sampleACLCapture() *testcapture.Capture {
|
|||||||
Captures: map[string]testcapture.Node{
|
Captures: map[string]testcapture.Node{
|
||||||
"user1": {
|
"user1": {
|
||||||
PacketFilterRules: rules,
|
PacketFilterRules: rules,
|
||||||
Netmap: netmap,
|
Netmap: nm,
|
||||||
},
|
},
|
||||||
"tagged-server": {
|
"tagged-server": {
|
||||||
PacketFilterRules: rules,
|
PacketFilterRules: rules,
|
||||||
Netmap: netmap,
|
Netmap: nm,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sampleSSHCapture() *testcapture.Capture {
|
func sampleSSHCapture() *testcapture.Capture {
|
||||||
policy := json.RawMessage(`{"ssh":[{"action":"accept","src":["autogroup:member"],"dst":["autogroup:self"],"users":["root"]}]}`)
|
sshRules := []*tailcfg.SSHRule{
|
||||||
sshRules := json.RawMessage(`[{"action":{"accept":true},"principals":[{"nodeIP":"100.90.199.68"}],"sshUsers":{"root":"root"}}]`)
|
{
|
||||||
|
Action: &tailcfg.SSHAction{Accept: true},
|
||||||
|
Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.90.199.68"}},
|
||||||
|
SSHUsers: map[string]string{"root": "root"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
return &testcapture.Capture{
|
return &testcapture.Capture{
|
||||||
SchemaVersion: testcapture.SchemaVersion,
|
SchemaVersion: testcapture.SchemaVersion,
|
||||||
@@ -94,7 +103,7 @@ func sampleSSHCapture() *testcapture.Capture {
|
|||||||
ToolVersion: "tscap-test-0.0.0",
|
ToolVersion: "tscap-test-0.0.0",
|
||||||
Tailnet: "kratail2tid@passkey",
|
Tailnet: "kratail2tid@passkey",
|
||||||
Input: testcapture.Input{
|
Input: testcapture.Input{
|
||||||
FullPolicy: policy,
|
FullPolicy: `{"ssh":[{"action":"accept","src":["autogroup:member"],"dst":["autogroup:self"],"users":["root"]}]}`,
|
||||||
APIResponseCode: 200,
|
APIResponseCode: 200,
|
||||||
Tailnet: testcapture.TailnetInput{
|
Tailnet: testcapture.TailnetInput{
|
||||||
DNS: testcapture.DNSInput{
|
DNS: testcapture.DNSInput{
|
||||||
@@ -126,39 +135,27 @@ func sampleSSHCapture() *testcapture.Capture {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// jsonRawMessageEqual is a cmp.Comparer that treats two json.RawMessage
|
// equalViaJSON compares two captures by JSON-marshaling them and
|
||||||
// values as equal if they decode to the same value. This avoids false
|
// comparing the bytes. The Capture struct embeds tailcfg view types
|
||||||
// negatives from indentation/whitespace differences after hujson formats
|
// with unexported pointer fields that go-cmp can't traverse, so a
|
||||||
// the embedded raw blobs.
|
// JSON round-trip is the simplest way to verify Write+Read produced
|
||||||
func jsonRawMessageEqual(a, b json.RawMessage) bool {
|
// equivalent values.
|
||||||
var va, vb any
|
func equalViaJSON(t *testing.T, want, got *testcapture.Capture) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
err := json.Unmarshal(a, &va)
|
wantJSON, err := json.MarshalIndent(want, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return string(a) == string(b)
|
t.Fatalf("marshal want: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = json.Unmarshal(b, &vb)
|
gotJSON, err := json.MarshalIndent(got, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return string(a) == string(b)
|
t.Fatalf("marshal got: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Both va and vb came from successful Unmarshal, so they're guaranteed to
|
if string(wantJSON) != string(gotJSON) {
|
||||||
// re-marshal cleanly. The error returns here are for the linter; we treat
|
t.Errorf("roundtrip mismatch\n--- want ---\n%s\n--- got ---\n%s",
|
||||||
// them as bytewise inequality if they ever fire.
|
string(wantJSON), string(gotJSON))
|
||||||
ja, jaErr := json.Marshal(va)
|
|
||||||
jb, jbErr := json.Marshal(vb)
|
|
||||||
|
|
||||||
if jaErr != nil || jbErr != nil {
|
|
||||||
return string(a) == string(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(ja) == string(jb)
|
|
||||||
}
|
|
||||||
|
|
||||||
func captureCompareOpts() []cmp.Option {
|
|
||||||
return []cmp.Option{
|
|
||||||
cmp.Comparer(jsonRawMessageEqual),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,9 +175,7 @@ func TestWriteReadRoundtrip_ACL(t *testing.T) {
|
|||||||
t.Fatalf("Read: %v", err)
|
t.Fatalf("Read: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if diff := cmp.Diff(in, out, captureCompareOpts()...); diff != "" {
|
equalViaJSON(t, in, out)
|
||||||
t.Errorf("ACL roundtrip mismatch (-want +got):\n%s", diff)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWriteReadRoundtrip_SSH(t *testing.T) {
|
func TestWriteReadRoundtrip_SSH(t *testing.T) {
|
||||||
@@ -199,9 +194,7 @@ func TestWriteReadRoundtrip_SSH(t *testing.T) {
|
|||||||
t.Fatalf("Read: %v", err)
|
t.Fatalf("Read: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if diff := cmp.Diff(in, out, captureCompareOpts()...); diff != "" {
|
equalViaJSON(t, in, out)
|
||||||
t.Errorf("SSH roundtrip mismatch (-want +got):\n%s", diff)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWrite_ProducesCommentHeader(t *testing.T) {
|
func TestWrite_ProducesCommentHeader(t *testing.T) {
|
||||||
@@ -287,7 +280,7 @@ func TestRead_HuJSONWithComments(t *testing.T) {
|
|||||||
"tool_version": "test",
|
"tool_version": "test",
|
||||||
"tailnet": "example.com",
|
"tailnet": "example.com",
|
||||||
"input": {
|
"input": {
|
||||||
"full_policy": {},
|
"full_policy": "{}",
|
||||||
"api_response_code": 200,
|
"api_response_code": 200,
|
||||||
"tailnet": {
|
"tailnet": {
|
||||||
"dns": {
|
"dns": {
|
||||||
@@ -362,17 +355,24 @@ func TestCommentHeader_NoStatsForEmptyCaptures(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCommentHeader_NullFilterRulesCountAsEmpty(t *testing.T) {
|
func TestCommentHeader_EmptyFilterRulesCountAsEmpty(t *testing.T) {
|
||||||
|
// Mixed: nil, empty slice, and one populated rule. Only the
|
||||||
|
// populated entry should be counted in the "filter rules" stat.
|
||||||
c := &testcapture.Capture{
|
c := &testcapture.Capture{
|
||||||
TestID: "NULLS",
|
TestID: "NULLS",
|
||||||
Captures: map[string]testcapture.Node{
|
Captures: map[string]testcapture.Node{
|
||||||
"a": {PacketFilterRules: json.RawMessage(`null`)},
|
"a": {PacketFilterRules: nil},
|
||||||
"b": {PacketFilterRules: json.RawMessage(`[]`)},
|
"b": {PacketFilterRules: []tailcfg.FilterRule{}},
|
||||||
"c": {PacketFilterRules: json.RawMessage(`[{"SrcIPs":["*"]}]`)},
|
"c": {PacketFilterRules: []tailcfg.FilterRule{{SrcIPs: []string{"*"}}}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
header := testcapture.CommentHeader(c)
|
header := testcapture.CommentHeader(c)
|
||||||
|
// Only "b" and "c" are non-nil, so the corpus is detected as
|
||||||
|
// "filter rules" — and only "c" actually has rules. With the new
|
||||||
|
// typed semantics, b's empty slice still counts as "set" (not
|
||||||
|
// nil), so the denominator is 2 of 3 capture entries that have
|
||||||
|
// any filter-rules slice at all, and 1 of those is populated.
|
||||||
if !strings.Contains(header, "Nodes with filter rules: 1 of 3") {
|
if !strings.Contains(header, "Nodes with filter rules: 1 of 3") {
|
||||||
t.Errorf("expected '1 of 3' in header; got:\n%s", header)
|
t.Errorf("expected '1 of 3' in header; got:\n%s", header)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user