mirror of
https://github.com/juanfont/headscale.git
synced 2026-04-10 19:17:25 +02:00
hscontrol/policy/v2: add ProtocolPort.MarshalJSON for Grant serialization
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
This commit is contained in:
committed by
Kristoffer Dalby
parent
1c31f04fab
commit
90c9555876
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user