From 90c95558764d0427fa1940afac03584108848388 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Mon, 23 Feb 2026 04:36:34 +0100 Subject: [PATCH] hscontrol/policy/v2: add ProtocolPort.MarshalJSON for Grant serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement ProtocolPort.MarshalJSON to produce string format matching UnmarshalJSON expectations (e.g. "tcp:443", "udp:10000-20000", "*"). Add comprehensive TestGrantMarshalJSON with 10 test cases: - IP-based grants with TCP, UDP, ICMP, and wildcard protocols - Single ports, port ranges, and wildcard ports - Capability-based grants using app field - Grants with both ip and app fields - Grants with via field for route filtering - Testing omitempty behavior for ip, app, and via fields - JSON round-trip validation (marshal → unmarshal → compare) Add omitempty tag to Grant.InternetProtocols to avoid marshaling null when field is empty. Updates #2180 --- hscontrol/policy/v2/types.go | 28 +++- hscontrol/policy/v2/types_test.go | 249 ++++++++++++++++++++++++++++++ 2 files changed, 276 insertions(+), 1 deletion(-) diff --git a/hscontrol/policy/v2/types.go b/hscontrol/policy/v2/types.go index ea61de06..090546f0 100644 --- a/hscontrol/policy/v2/types.go +++ b/hscontrol/policy/v2/types.go @@ -816,6 +816,32 @@ func (ve *ProtocolPort) UnmarshalJSON(b []byte) error { return nil } +func (ve ProtocolPort) MarshalJSON() ([]byte, error) { + // Handle wildcard protocol with all ports + if ve.Protocol == ProtocolNameWildcard && len(ve.Ports) == 1 && + ve.Ports[0].First == 0 && ve.Ports[0].Last == 65535 { + return json.Marshal("*") + } + + // Build port string + var portParts []string + + for _, portRange := range ve.Ports { + if portRange.First == portRange.Last { + portParts = append(portParts, strconv.FormatUint(uint64(portRange.First), 10)) + } else { + portParts = append(portParts, fmt.Sprintf("%d-%d", portRange.First, portRange.Last)) + } + } + + portStr := strings.Join(portParts, ",") + + // Combine protocol and ports + result := fmt.Sprintf("%s:%s", ve.Protocol, portStr) + + return json.Marshal(result) +} + func isWildcard(str string) bool { return str == "*" } @@ -1733,7 +1759,7 @@ type Grant struct { Destinations Aliases `json:"dst"` // TODO(kradalby): validate that either of these fields are included - InternetProtocols []ProtocolPort `json:"ip"` + InternetProtocols []ProtocolPort `json:"ip,omitempty"` App tailcfg.PeerCapMap `json:"app,omitzero"` // TODO(kradalby): implement via diff --git a/hscontrol/policy/v2/types_test.go b/hscontrol/policy/v2/types_test.go index ad1b53be..43e4d64a 100644 --- a/hscontrol/policy/v2/types_test.go +++ b/hscontrol/policy/v2/types_test.go @@ -1,6 +1,7 @@ package v2 import ( + "bytes" "encoding/json" "net/netip" "strings" @@ -4911,3 +4912,251 @@ func TestACLToGrants(t *testing.T) { }) } } + +func TestGrantMarshalJSON(t *testing.T) { + tests := []struct { + name string + grant Grant + wantJSON string + }{ + { + name: "ip-based-grant-tcp-single-port", + grant: Grant{ + Sources: Aliases{gp("group:eng")}, + Destinations: Aliases{tp("tag:server")}, + InternetProtocols: []ProtocolPort{ + { + Protocol: ProtocolNameTCP, + Ports: []tailcfg.PortRange{{First: 443, Last: 443}}, + }, + }, + }, + wantJSON: `{ + "src": ["group:eng"], + "dst": ["tag:server"], + "ip": ["tcp:443"] + }`, + }, + { + name: "ip-based-grant-udp-port-range", + grant: Grant{ + Sources: Aliases{up("alice@example.com")}, + Destinations: Aliases{tp("tag:voip")}, + InternetProtocols: []ProtocolPort{ + { + Protocol: ProtocolNameUDP, + Ports: []tailcfg.PortRange{{First: 10000, Last: 20000}}, + }, + }, + }, + wantJSON: `{ + "src": ["alice@example.com"], + "dst": ["tag:voip"], + "ip": ["udp:10000-20000"] + }`, + }, + { + name: "ip-based-grant-wildcard-protocol", + grant: Grant{ + Sources: Aliases{gp("group:admin")}, + Destinations: Aliases{Asterix(0)}, + InternetProtocols: []ProtocolPort{ + { + Protocol: ProtocolNameWildcard, + Ports: []tailcfg.PortRange{tailcfg.PortRangeAny}, + }, + }, + }, + wantJSON: `{ + "src": ["group:admin"], + "dst": ["*"], + "ip": ["*"] + }`, + }, + { + name: "ip-based-grant-icmp", + grant: Grant{ + Sources: Aliases{gp("group:monitoring")}, + Destinations: Aliases{tp("tag:servers")}, + InternetProtocols: []ProtocolPort{ + { + Protocol: ProtocolNameICMP, + Ports: []tailcfg.PortRange{tailcfg.PortRangeAny}, + }, + }, + }, + wantJSON: `{ + "src": ["group:monitoring"], + "dst": ["tag:servers"], + "ip": ["icmp:0-65535"] + }`, + }, + { + name: "ip-based-grant-multiple-protocols", + grant: Grant{ + Sources: Aliases{gp("group:web")}, + Destinations: Aliases{tp("tag:lb")}, + InternetProtocols: []ProtocolPort{ + { + Protocol: ProtocolNameTCP, + Ports: []tailcfg.PortRange{{First: 80, Last: 80}}, + }, + { + Protocol: ProtocolNameTCP, + Ports: []tailcfg.PortRange{{First: 443, Last: 443}}, + }, + }, + }, + wantJSON: `{ + "src": ["group:web"], + "dst": ["tag:lb"], + "ip": ["tcp:80", "tcp:443"] + }`, + }, + { + name: "capability-based-grant", + grant: Grant{ + Sources: Aliases{gp("group:admins")}, + Destinations: Aliases{tp("tag:database")}, + App: tailcfg.PeerCapMap{ + "backup": []tailcfg.RawMessage{ + tailcfg.RawMessage(`{"action":"read"}`), + tailcfg.RawMessage(`{"action":"write"}`), + }, + }, + }, + wantJSON: `{ + "src": ["group:admins"], + "dst": ["tag:database"], + "app": { + "backup": [ + {"action":"read"}, + {"action":"write"} + ] + } + }`, + }, + { + name: "grant-with-both-ip-and-app", + grant: Grant{ + Sources: Aliases{up("bob@example.com")}, + Destinations: Aliases{tp("tag:app-server")}, + InternetProtocols: []ProtocolPort{ + { + Protocol: ProtocolNameTCP, + Ports: []tailcfg.PortRange{{First: 8080, Last: 8080}}, + }, + }, + App: tailcfg.PeerCapMap{ + "admin": []tailcfg.RawMessage{ + tailcfg.RawMessage(`{"level":"superuser"}`), + }, + }, + }, + wantJSON: `{ + "src": ["bob@example.com"], + "dst": ["tag:app-server"], + "ip": ["tcp:8080"], + "app": { + "admin": [{"level":"superuser"}] + } + }`, + }, + { + name: "grant-with-via", + grant: Grant{ + Sources: Aliases{gp("group:remote-workers")}, + Destinations: Aliases{tp("tag:internal")}, + InternetProtocols: []ProtocolPort{ + { + Protocol: ProtocolNameTCP, + Ports: []tailcfg.PortRange{tailcfg.PortRangeAny}, + }, + }, + Via: []Tag{ + *tp("tag:gateway1"), + *tp("tag:gateway2"), + }, + }, + wantJSON: `{ + "src": ["group:remote-workers"], + "dst": ["tag:internal"], + "ip": ["tcp:0-65535"], + "via": ["tag:gateway1", "tag:gateway2"] + }`, + }, + { + name: "grant-omitzero-app-field", + grant: Grant{ + Sources: Aliases{gp("group:users")}, + Destinations: Aliases{tp("tag:web")}, + InternetProtocols: []ProtocolPort{ + { + Protocol: ProtocolNameTCP, + Ports: []tailcfg.PortRange{{First: 80, Last: 80}}, + }, + }, + App: nil, + }, + wantJSON: `{ + "src": ["group:users"], + "dst": ["tag:web"], + "ip": ["tcp:80"] + }`, + }, + { + name: "grant-omitzero-via-field", + grant: Grant{ + Sources: Aliases{gp("group:users")}, + Destinations: Aliases{tp("tag:api")}, + InternetProtocols: []ProtocolPort{ + { + Protocol: ProtocolNameTCP, + Ports: []tailcfg.PortRange{{First: 443, Last: 443}}, + }, + }, + Via: nil, + }, + wantJSON: `{ + "src": ["group:users"], + "dst": ["tag:api"], + "ip": ["tcp:443"] + }`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Marshal the Grant to JSON + gotJSON, err := json.Marshal(tt.grant) + if err != nil { + t.Fatalf("failed to marshal Grant: %v", err) + } + + // Compact the expected JSON to remove whitespace for comparison + var wantCompact bytes.Buffer + + err = json.Compact(&wantCompact, []byte(tt.wantJSON)) + if err != nil { + t.Fatalf("failed to compact expected JSON: %v", err) + } + + // Compare JSON strings + if string(gotJSON) != wantCompact.String() { + t.Errorf("Grant.MarshalJSON() mismatch:\ngot: %s\nwant: %s", string(gotJSON), wantCompact.String()) + } + + // Test round-trip: unmarshal and compare with original + var unmarshaled Grant + + err = json.Unmarshal(gotJSON, &unmarshaled) + if err != nil { + t.Fatalf("failed to unmarshal JSON: %v", err) + } + + if diff := cmp.Diff(tt.grant, unmarshaled); diff != "" { + t.Errorf("Grant round-trip mismatch (-original +unmarshaled):\n%s", diff) + } + }) + } +}