diff --git a/hscontrol/policy/v2/policytester_compat_test.go b/hscontrol/policy/v2/policytester_compat_test.go index ba0a2b1f..9feb1537 100644 --- a/hscontrol/policy/v2/policytester_compat_test.go +++ b/hscontrol/policy/v2/policytester_compat_test.go @@ -37,9 +37,7 @@ import ( // Each entry is a real bug to fix in a follow-up; documenting them here // keeps the compat suite green and the divergence list visible. var knownPolicyTesterDivergences = map[string]string{ //nolint:gosec // strings here are human-readable notes, not credentials - "policytest-allpass-acls-and-grants-mixed": "evaluator denies tag:client → webserver:80 in mixed acls+grants policy; SaaS accepts (Updates #1803)", - "policytest-proto-numeric": "validateProtocolPortCompatibility rejects numeric proto \"6\" with specific ports; SaaS accepts (Updates #1803)", - "policytest-accept-fail-proto-numeric-mismatch": "validateProtocolPortCompatibility rejects numeric proto \"6\" with specific ports; SaaS accepts (Updates #1803)", + "policytest-allpass-acls-and-grants-mixed": "evaluator denies tag:client → webserver:80 in mixed acls+grants policy; SaaS accepts (Updates #1803)", } // policyTesterCompatUsers / policyTesterCompatNodes mirror the small diff --git a/hscontrol/policy/v2/types.go b/hscontrol/policy/v2/types.go index 435616ba..5d305075 100644 --- a/hscontrol/policy/v2/types.go +++ b/hscontrol/policy/v2/types.go @@ -1756,13 +1756,27 @@ func (p *Protocol) toIANAProtocolNumbers() []int { } // UnmarshalJSON implements JSON unmarshaling for Protocol. +// +// Tailscale accepts both named ("tcp") and numeric IANA ("6") forms. +// Storing whichever form the user wrote leaves downstream code with +// two equivalents to handle separately, and any consumer that +// branches on the named form would silently mishandle the numeric +// equivalent. Canonicalising to the named form here makes Protocol +// hold one value post-parse — every downstream consumer sees the +// same form regardless of what the user wrote. func (p *Protocol) UnmarshalJSON(b []byte) error { str := strings.Trim(string(b), `"`) // Normalize to lowercase for case-insensitive matching *p = Protocol(strings.ToLower(str)) - // Validate the protocol + num, atoiErr := strconv.Atoi(string(*p)) + if atoiErr == nil && num >= 0 && num <= 255 { + if name, ok := ProtocolNumberToName[num]; ok { + *p = name + } + } + err := p.validate() if err != nil { return err diff --git a/hscontrol/policy/v2/types_test.go b/hscontrol/policy/v2/types_test.go index e9e024b3..55350c03 100644 --- a/hscontrol/policy/v2/types_test.go +++ b/hscontrol/policy/v2/types_test.go @@ -2187,6 +2187,42 @@ func TestUnmarshalPolicy(t *testing.T) { `, wantErr: `test destination must be a single host, not a CIDR range`, }, + // Tailscale accepts numeric IANA protocol form ("6", "17", + // "132") wherever the named form is allowed, including with + // specific ports. validateProtocolPortCompatibility today only + // recognises the named constants and rejects the numeric form. + { + name: "protocol-numeric-tcp-with-specific-port-allowed", + input: ` +{ + "acls": [ + { + "action": "accept", + "proto": "6", + "src": ["*"], + "dst": ["*:443"] + } + ] +} +`, + want: &Policy{ + ACLs: []ACL{ + { + Action: "accept", + Protocol: "tcp", + Sources: Aliases{ + Wildcard, + }, + Destinations: []AliasWithPorts{ + { + Alias: Wildcard, + Ports: []tailcfg.PortRange{{First: 443, Last: 443}}, + }, + }, + }, + }, + }, + }, } cmps := append(util.Comparers,