mirror of
https://github.com/juanfont/headscale.git
synced 2025-09-16 17:50:44 +02:00
policy: reject unsupported fields (#2764)
This commit is contained in:
parent
1b1c989268
commit
2938d03878
@ -89,6 +89,8 @@ upstream is changed.
|
|||||||
[#2663](https://github.com/juanfont/headscale/pull/2663)
|
[#2663](https://github.com/juanfont/headscale/pull/2663)
|
||||||
- OIDC: Update user with claims from UserInfo _before_ comparing with allowed
|
- OIDC: Update user with claims from UserInfo _before_ comparing with allowed
|
||||||
groups, email and domain [#2663](https://github.com/juanfont/headscale/pull/2663)
|
groups, email and domain [#2663](https://github.com/juanfont/headscale/pull/2663)
|
||||||
|
- Policy will now reject invalid fields, making it easier to spot spelling errors
|
||||||
|
[#2764](https://github.com/juanfont/headscale/pull/2764)
|
||||||
|
|
||||||
## 0.26.1 (2025-06-06)
|
## 0.26.1 (2025-06-06)
|
||||||
|
|
||||||
|
2
go.mod
2
go.mod
@ -18,6 +18,7 @@ require (
|
|||||||
github.com/fsnotify/fsnotify v1.9.0
|
github.com/fsnotify/fsnotify v1.9.0
|
||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
github.com/go-gormigrate/gormigrate/v2 v2.1.4
|
github.com/go-gormigrate/gormigrate/v2 v2.1.4
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874
|
||||||
github.com/gofrs/uuid/v5 v5.3.2
|
github.com/gofrs/uuid/v5 v5.3.2
|
||||||
github.com/google/go-cmp v0.7.0
|
github.com/google/go-cmp v0.7.0
|
||||||
github.com/gorilla/mux v1.8.1
|
github.com/gorilla/mux v1.8.1
|
||||||
@ -131,7 +132,6 @@ require (
|
|||||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||||
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
|
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
|
||||||
github.com/go-jose/go-jose/v4 v4.1.0 // indirect
|
github.com/go-jose/go-jose/v4 v4.1.0 // indirect
|
||||||
github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 // indirect
|
|
||||||
github.com/go-logr/logr v1.4.2 // indirect
|
github.com/go-logr/logr v1.4.2 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
|
@ -222,6 +222,7 @@ func TestReduceFilterRules(t *testing.T) {
|
|||||||
Ports: tailcfg.PortRangeAny,
|
Ports: tailcfg.PortRangeAny,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
IPProto: []int{6, 17},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
SrcIPs: []string{
|
SrcIPs: []string{
|
||||||
@ -236,6 +237,7 @@ func TestReduceFilterRules(t *testing.T) {
|
|||||||
Ports: tailcfg.PortRangeAny,
|
Ports: tailcfg.PortRangeAny,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
IPProto: []int{6, 17},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -371,10 +373,12 @@ func TestReduceFilterRules(t *testing.T) {
|
|||||||
Ports: tailcfg.PortRangeAny,
|
Ports: tailcfg.PortRangeAny,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
IPProto: []int{6, 17},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
SrcIPs: []string{"100.64.0.1/32", "100.64.0.2/32", "fd7a:115c:a1e0::1/128", "fd7a:115c:a1e0::2/128"},
|
SrcIPs: []string{"100.64.0.1/32", "100.64.0.2/32", "fd7a:115c:a1e0::1/128", "fd7a:115c:a1e0::2/128"},
|
||||||
DstPorts: hsExitNodeDestForTest,
|
DstPorts: hsExitNodeDestForTest,
|
||||||
|
IPProto: []int{6, 17},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -478,6 +482,7 @@ func TestReduceFilterRules(t *testing.T) {
|
|||||||
Ports: tailcfg.PortRangeAny,
|
Ports: tailcfg.PortRangeAny,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
IPProto: []int{6, 17},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
SrcIPs: []string{"100.64.0.1/32", "100.64.0.2/32", "fd7a:115c:a1e0::1/128", "fd7a:115c:a1e0::2/128"},
|
SrcIPs: []string{"100.64.0.1/32", "100.64.0.2/32", "fd7a:115c:a1e0::1/128", "fd7a:115c:a1e0::2/128"},
|
||||||
@ -513,6 +518,7 @@ func TestReduceFilterRules(t *testing.T) {
|
|||||||
{IP: "200.0.0.0/5", Ports: tailcfg.PortRangeAny},
|
{IP: "200.0.0.0/5", Ports: tailcfg.PortRangeAny},
|
||||||
{IP: "208.0.0.0/4", Ports: tailcfg.PortRangeAny},
|
{IP: "208.0.0.0/4", Ports: tailcfg.PortRangeAny},
|
||||||
},
|
},
|
||||||
|
IPProto: []int{6, 17},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -588,6 +594,7 @@ func TestReduceFilterRules(t *testing.T) {
|
|||||||
Ports: tailcfg.PortRangeAny,
|
Ports: tailcfg.PortRangeAny,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
IPProto: []int{6, 17},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
SrcIPs: []string{"100.64.0.1/32", "100.64.0.2/32", "fd7a:115c:a1e0::1/128", "fd7a:115c:a1e0::2/128"},
|
SrcIPs: []string{"100.64.0.1/32", "100.64.0.2/32", "fd7a:115c:a1e0::1/128", "fd7a:115c:a1e0::2/128"},
|
||||||
@ -601,6 +608,7 @@ func TestReduceFilterRules(t *testing.T) {
|
|||||||
Ports: tailcfg.PortRangeAny,
|
Ports: tailcfg.PortRangeAny,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
IPProto: []int{6, 17},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -676,6 +684,7 @@ func TestReduceFilterRules(t *testing.T) {
|
|||||||
Ports: tailcfg.PortRangeAny,
|
Ports: tailcfg.PortRangeAny,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
IPProto: []int{6, 17},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
SrcIPs: []string{"100.64.0.1/32", "100.64.0.2/32", "fd7a:115c:a1e0::1/128", "fd7a:115c:a1e0::2/128"},
|
SrcIPs: []string{"100.64.0.1/32", "100.64.0.2/32", "fd7a:115c:a1e0::1/128", "fd7a:115c:a1e0::2/128"},
|
||||||
@ -689,6 +698,7 @@ func TestReduceFilterRules(t *testing.T) {
|
|||||||
Ports: tailcfg.PortRangeAny,
|
Ports: tailcfg.PortRangeAny,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
IPProto: []int{6, 17},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -756,6 +766,7 @@ func TestReduceFilterRules(t *testing.T) {
|
|||||||
Ports: tailcfg.PortRangeAny,
|
Ports: tailcfg.PortRangeAny,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
IPProto: []int{6, 17},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -1736,7 +1747,7 @@ func TestSSHPolicyRules(t *testing.T) {
|
|||||||
]
|
]
|
||||||
}`,
|
}`,
|
||||||
expectErr: true,
|
expectErr: true,
|
||||||
errorMessage: `SSH action "invalid" is not valid, must be accept or check`,
|
errorMessage: `invalid SSH action "invalid", must be one of: accept, check`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid-check-period",
|
name: "invalid-check-period",
|
||||||
|
@ -28,7 +28,7 @@ func (pol *Policy) compileFilterRules(
|
|||||||
var rules []tailcfg.FilterRule
|
var rules []tailcfg.FilterRule
|
||||||
|
|
||||||
for _, acl := range pol.ACLs {
|
for _, acl := range pol.ACLs {
|
||||||
if acl.Action != "accept" {
|
if acl.Action != ActionAccept {
|
||||||
return nil, ErrInvalidAction
|
return nil, ErrInvalidAction
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -41,12 +41,7 @@ func (pol *Policy) compileFilterRules(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(kradalby): integrate type into schema
|
protocols, _ := acl.Protocol.parseProtocol()
|
||||||
// TODO(kradalby): figure out the _ is wildcard stuff
|
|
||||||
protocols, _, err := parseProtocol(acl.Protocol)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("parsing policy, protocol err: %w ", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var destPorts []tailcfg.NetPortRange
|
var destPorts []tailcfg.NetPortRange
|
||||||
for _, dest := range acl.Destinations {
|
for _, dest := range acl.Destinations {
|
||||||
@ -132,9 +127,9 @@ func (pol *Policy) compileSSHPolicy(
|
|||||||
|
|
||||||
var action tailcfg.SSHAction
|
var action tailcfg.SSHAction
|
||||||
switch rule.Action {
|
switch rule.Action {
|
||||||
case "accept":
|
case SSHActionAccept:
|
||||||
action = sshAction(true, 0)
|
action = sshAction(true, 0)
|
||||||
case "check":
|
case SSHActionCheck:
|
||||||
action = sshAction(true, time.Duration(rule.CheckPeriod))
|
action = sshAction(true, time.Duration(rule.CheckPeriod))
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("parsing SSH policy, unknown action %q, index: %d: %w", rule.Action, index, err)
|
return nil, fmt.Errorf("parsing SSH policy, unknown action %q, index: %d: %w", rule.Action, index, err)
|
||||||
|
@ -92,6 +92,7 @@ func TestParsing(t *testing.T) {
|
|||||||
{IP: "::/0", Ports: tailcfg.PortRange{First: 3389, Last: 3389}},
|
{IP: "::/0", Ports: tailcfg.PortRange{First: 3389, Last: 3389}},
|
||||||
{IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny},
|
{IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny},
|
||||||
},
|
},
|
||||||
|
IPProto: []int{protocolTCP, protocolUDP},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
@ -193,6 +194,7 @@ func TestParsing(t *testing.T) {
|
|||||||
DstPorts: []tailcfg.NetPortRange{
|
DstPorts: []tailcfg.NetPortRange{
|
||||||
{IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny},
|
{IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny},
|
||||||
},
|
},
|
||||||
|
IPProto: []int{protocolTCP, protocolUDP},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
@ -229,6 +231,7 @@ func TestParsing(t *testing.T) {
|
|||||||
Ports: tailcfg.PortRange{First: 5400, Last: 5500},
|
Ports: tailcfg.PortRange{First: 5400, Last: 5500},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
IPProto: []int{protocolTCP, protocolUDP},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
@ -268,6 +271,7 @@ func TestParsing(t *testing.T) {
|
|||||||
DstPorts: []tailcfg.NetPortRange{
|
DstPorts: []tailcfg.NetPortRange{
|
||||||
{IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny},
|
{IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny},
|
||||||
},
|
},
|
||||||
|
IPProto: []int{protocolTCP, protocolUDP},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
@ -301,6 +305,7 @@ func TestParsing(t *testing.T) {
|
|||||||
DstPorts: []tailcfg.NetPortRange{
|
DstPorts: []tailcfg.NetPortRange{
|
||||||
{IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny},
|
{IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny},
|
||||||
},
|
},
|
||||||
|
IPProto: []int{protocolTCP, protocolUDP},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
@ -334,6 +339,7 @@ func TestParsing(t *testing.T) {
|
|||||||
DstPorts: []tailcfg.NetPortRange{
|
DstPorts: []tailcfg.NetPortRange{
|
||||||
{IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny},
|
{IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny},
|
||||||
},
|
},
|
||||||
|
IPProto: []int{protocolTCP, protocolUDP},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
package v2
|
package v2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
@ -10,6 +8,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-json-experiment/json"
|
||||||
|
|
||||||
"github.com/juanfont/headscale/hscontrol/types"
|
"github.com/juanfont/headscale/hscontrol/types"
|
||||||
"github.com/juanfont/headscale/hscontrol/util"
|
"github.com/juanfont/headscale/hscontrol/util"
|
||||||
"github.com/prometheus/common/model"
|
"github.com/prometheus/common/model"
|
||||||
@ -23,6 +23,13 @@ import (
|
|||||||
"tailscale.com/util/slicesx"
|
"tailscale.com/util/slicesx"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Global JSON options for consistent parsing across all struct unmarshaling
|
||||||
|
var policyJSONOpts = []json.Options{
|
||||||
|
json.DefaultOptionsV2(),
|
||||||
|
json.MatchCaseInsensitiveNames(true),
|
||||||
|
json.RejectUnknownMembers(true),
|
||||||
|
}
|
||||||
|
|
||||||
const Wildcard = Asterix(0)
|
const Wildcard = Asterix(0)
|
||||||
|
|
||||||
type Asterix int
|
type Asterix int
|
||||||
@ -614,10 +621,8 @@ type AliasWithPorts struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (ve *AliasWithPorts) UnmarshalJSON(b []byte) error {
|
func (ve *AliasWithPorts) UnmarshalJSON(b []byte) error {
|
||||||
// TODO(kradalby): use encoding/json/v2 (go-json-experiment)
|
|
||||||
dec := json.NewDecoder(bytes.NewReader(b))
|
|
||||||
var v any
|
var v any
|
||||||
if err := dec.Decode(&v); err != nil {
|
if err := json.Unmarshal(b, &v); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -735,7 +740,7 @@ type Aliases []Alias
|
|||||||
|
|
||||||
func (a *Aliases) UnmarshalJSON(b []byte) error {
|
func (a *Aliases) UnmarshalJSON(b []byte) error {
|
||||||
var aliases []AliasEnc
|
var aliases []AliasEnc
|
||||||
err := json.Unmarshal(b, &aliases)
|
err := json.Unmarshal(b, &aliases, policyJSONOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -825,7 +830,7 @@ type AutoApprovers []AutoApprover
|
|||||||
|
|
||||||
func (aa *AutoApprovers) UnmarshalJSON(b []byte) error {
|
func (aa *AutoApprovers) UnmarshalJSON(b []byte) error {
|
||||||
var autoApprovers []AutoApproverEnc
|
var autoApprovers []AutoApproverEnc
|
||||||
err := json.Unmarshal(b, &autoApprovers)
|
err := json.Unmarshal(b, &autoApprovers, policyJSONOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -920,7 +925,7 @@ type Owners []Owner
|
|||||||
|
|
||||||
func (o *Owners) UnmarshalJSON(b []byte) error {
|
func (o *Owners) UnmarshalJSON(b []byte) error {
|
||||||
var owners []OwnerEnc
|
var owners []OwnerEnc
|
||||||
err := json.Unmarshal(b, &owners)
|
err := json.Unmarshal(b, &owners, policyJSONOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -994,18 +999,46 @@ func (g Groups) Contains(group *Group) error {
|
|||||||
// that all group names conform to the expected format, which is always prefixed
|
// that all group names conform to the expected format, which is always prefixed
|
||||||
// with "group:". If any group name is invalid, an error is returned.
|
// with "group:". If any group name is invalid, an error is returned.
|
||||||
func (g *Groups) UnmarshalJSON(b []byte) error {
|
func (g *Groups) UnmarshalJSON(b []byte) error {
|
||||||
var rawGroups map[string][]string
|
// First unmarshal as a generic map to validate group names first
|
||||||
if err := json.Unmarshal(b, &rawGroups); err != nil {
|
var rawMap map[string]interface{}
|
||||||
|
if err := json.Unmarshal(b, &rawMap); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate group names first before checking data types
|
||||||
|
for key := range rawMap {
|
||||||
|
group := Group(key)
|
||||||
|
if err := group.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then validate each field can be converted to []string
|
||||||
|
rawGroups := make(map[string][]string)
|
||||||
|
for key, value := range rawMap {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case []interface{}:
|
||||||
|
// Convert []interface{} to []string
|
||||||
|
var stringSlice []string
|
||||||
|
for _, item := range v {
|
||||||
|
if str, ok := item.(string); ok {
|
||||||
|
stringSlice = append(stringSlice, str)
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf(`Group "%s" contains invalid member type, expected string but got %T`, key, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rawGroups[key] = stringSlice
|
||||||
|
case string:
|
||||||
|
return fmt.Errorf(`Group "%s" value must be an array of users, got string: "%s"`, key, v)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf(`Group "%s" value must be an array of users, got %T`, key, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
*g = make(Groups)
|
*g = make(Groups)
|
||||||
for key, value := range rawGroups {
|
for key, value := range rawGroups {
|
||||||
group := Group(key)
|
group := Group(key)
|
||||||
if err := group.Validate(); err != nil {
|
// Group name already validated above
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var usernames Usernames
|
var usernames Usernames
|
||||||
|
|
||||||
for _, u := range value {
|
for _, u := range value {
|
||||||
@ -1031,7 +1064,7 @@ type Hosts map[Host]Prefix
|
|||||||
|
|
||||||
func (h *Hosts) UnmarshalJSON(b []byte) error {
|
func (h *Hosts) UnmarshalJSON(b []byte) error {
|
||||||
var rawHosts map[string]string
|
var rawHosts map[string]string
|
||||||
if err := json.Unmarshal(b, &rawHosts); err != nil {
|
if err := json.Unmarshal(b, &rawHosts, policyJSONOpts...); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1242,13 +1275,290 @@ func resolveAutoApprovers(p *Policy, users types.Users, nodes views.Slice[types.
|
|||||||
return ret, exitNodeSet, nil
|
return ret, exitNodeSet, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Action represents the action to take for an ACL rule.
|
||||||
|
type Action string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ActionAccept Action = "accept"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SSHAction represents the action to take for an SSH rule.
|
||||||
|
type SSHAction string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SSHActionAccept SSHAction = "accept"
|
||||||
|
SSHActionCheck SSHAction = "check"
|
||||||
|
)
|
||||||
|
|
||||||
|
// String returns the string representation of the Action.
|
||||||
|
func (a Action) String() string {
|
||||||
|
return string(a)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON implements JSON unmarshaling for Action.
|
||||||
|
func (a *Action) UnmarshalJSON(b []byte) error {
|
||||||
|
str := strings.Trim(string(b), `"`)
|
||||||
|
switch str {
|
||||||
|
case "accept":
|
||||||
|
*a = ActionAccept
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid action %q, must be %q", str, ActionAccept)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON implements JSON marshaling for Action.
|
||||||
|
func (a Action) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(string(a))
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns the string representation of the SSHAction.
|
||||||
|
func (a SSHAction) String() string {
|
||||||
|
return string(a)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON implements JSON unmarshaling for SSHAction.
|
||||||
|
func (a *SSHAction) UnmarshalJSON(b []byte) error {
|
||||||
|
str := strings.Trim(string(b), `"`)
|
||||||
|
switch str {
|
||||||
|
case "accept":
|
||||||
|
*a = SSHActionAccept
|
||||||
|
case "check":
|
||||||
|
*a = SSHActionCheck
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid SSH action %q, must be one of: accept, check", str)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON implements JSON marshaling for SSHAction.
|
||||||
|
func (a SSHAction) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(string(a))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protocol represents a network protocol with its IANA number and descriptions.
|
||||||
|
type Protocol string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ProtocolICMP Protocol = "icmp"
|
||||||
|
ProtocolIGMP Protocol = "igmp"
|
||||||
|
ProtocolIPv4 Protocol = "ipv4"
|
||||||
|
ProtocolIPInIP Protocol = "ip-in-ip"
|
||||||
|
ProtocolTCP Protocol = "tcp"
|
||||||
|
ProtocolEGP Protocol = "egp"
|
||||||
|
ProtocolIGP Protocol = "igp"
|
||||||
|
ProtocolUDP Protocol = "udp"
|
||||||
|
ProtocolGRE Protocol = "gre"
|
||||||
|
ProtocolESP Protocol = "esp"
|
||||||
|
ProtocolAH Protocol = "ah"
|
||||||
|
ProtocolIPv6ICMP Protocol = "ipv6-icmp"
|
||||||
|
ProtocolSCTP Protocol = "sctp"
|
||||||
|
ProtocolFC Protocol = "fc"
|
||||||
|
ProtocolWildcard Protocol = "*"
|
||||||
|
)
|
||||||
|
|
||||||
|
// String returns the string representation of the Protocol.
|
||||||
|
func (p Protocol) String() string {
|
||||||
|
return string(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description returns the human-readable description of the Protocol.
|
||||||
|
func (p Protocol) Description() string {
|
||||||
|
switch p {
|
||||||
|
case ProtocolICMP:
|
||||||
|
return "Internet Control Message Protocol"
|
||||||
|
case ProtocolIGMP:
|
||||||
|
return "Internet Group Management Protocol"
|
||||||
|
case ProtocolIPv4:
|
||||||
|
return "IPv4 encapsulation"
|
||||||
|
case ProtocolTCP:
|
||||||
|
return "Transmission Control Protocol"
|
||||||
|
case ProtocolEGP:
|
||||||
|
return "Exterior Gateway Protocol"
|
||||||
|
case ProtocolIGP:
|
||||||
|
return "Interior Gateway Protocol"
|
||||||
|
case ProtocolUDP:
|
||||||
|
return "User Datagram Protocol"
|
||||||
|
case ProtocolGRE:
|
||||||
|
return "Generic Routing Encapsulation"
|
||||||
|
case ProtocolESP:
|
||||||
|
return "Encapsulating Security Payload"
|
||||||
|
case ProtocolAH:
|
||||||
|
return "Authentication Header"
|
||||||
|
case ProtocolIPv6ICMP:
|
||||||
|
return "Internet Control Message Protocol for IPv6"
|
||||||
|
case ProtocolSCTP:
|
||||||
|
return "Stream Control Transmission Protocol"
|
||||||
|
case ProtocolFC:
|
||||||
|
return "Fibre Channel"
|
||||||
|
case ProtocolWildcard:
|
||||||
|
return "Wildcard (not supported - use specific protocol)"
|
||||||
|
default:
|
||||||
|
return "Unknown Protocol"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseProtocol converts a Protocol to its IANA protocol numbers and wildcard requirement.
|
||||||
|
// Since validation happens during UnmarshalJSON, this method should not fail for valid Protocol values.
|
||||||
|
func (p Protocol) parseProtocol() ([]int, bool) {
|
||||||
|
switch p {
|
||||||
|
case "":
|
||||||
|
// Empty protocol applies to TCP and UDP traffic only
|
||||||
|
return []int{protocolTCP, protocolUDP}, false
|
||||||
|
case ProtocolWildcard:
|
||||||
|
// Wildcard protocol - defensive handling (should not reach here due to validation)
|
||||||
|
return nil, false
|
||||||
|
case ProtocolIGMP:
|
||||||
|
return []int{protocolIGMP}, true
|
||||||
|
case ProtocolIPv4, ProtocolIPInIP:
|
||||||
|
return []int{protocolIPv4}, true
|
||||||
|
case ProtocolTCP:
|
||||||
|
return []int{protocolTCP}, false
|
||||||
|
case ProtocolEGP:
|
||||||
|
return []int{protocolEGP}, true
|
||||||
|
case ProtocolIGP:
|
||||||
|
return []int{protocolIGP}, true
|
||||||
|
case ProtocolUDP:
|
||||||
|
return []int{protocolUDP}, false
|
||||||
|
case ProtocolGRE:
|
||||||
|
return []int{protocolGRE}, true
|
||||||
|
case ProtocolESP:
|
||||||
|
return []int{protocolESP}, true
|
||||||
|
case ProtocolAH:
|
||||||
|
return []int{protocolAH}, true
|
||||||
|
case ProtocolSCTP:
|
||||||
|
return []int{protocolSCTP}, false
|
||||||
|
case ProtocolICMP:
|
||||||
|
return []int{protocolICMP, protocolIPv6ICMP}, true
|
||||||
|
default:
|
||||||
|
// Try to parse as a numeric protocol number
|
||||||
|
// This should not fail since validation happened during unmarshaling
|
||||||
|
protocolNumber, _ := strconv.Atoi(string(p))
|
||||||
|
|
||||||
|
// Determine if wildcard is needed based on protocol number
|
||||||
|
needsWildcard := protocolNumber != protocolTCP &&
|
||||||
|
protocolNumber != protocolUDP &&
|
||||||
|
protocolNumber != protocolSCTP
|
||||||
|
|
||||||
|
return []int{protocolNumber}, needsWildcard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON implements JSON unmarshaling for Protocol.
|
||||||
|
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
|
||||||
|
if err := p.validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate checks if the Protocol is valid.
|
||||||
|
func (p Protocol) validate() error {
|
||||||
|
switch p {
|
||||||
|
case "", ProtocolICMP, ProtocolIGMP, ProtocolIPv4, ProtocolIPInIP,
|
||||||
|
ProtocolTCP, ProtocolEGP, ProtocolIGP, ProtocolUDP, ProtocolGRE,
|
||||||
|
ProtocolESP, ProtocolAH, ProtocolSCTP:
|
||||||
|
return nil
|
||||||
|
case ProtocolWildcard:
|
||||||
|
// Wildcard "*" is not allowed - Tailscale rejects it
|
||||||
|
return fmt.Errorf("proto name \"*\" not known; use protocol number 0-255 or protocol name (icmp, tcp, udp, etc.)")
|
||||||
|
default:
|
||||||
|
// Try to parse as a numeric protocol number
|
||||||
|
str := string(p)
|
||||||
|
|
||||||
|
// Check for leading zeros (not allowed by Tailscale)
|
||||||
|
if str == "0" || (len(str) > 1 && str[0] == '0') {
|
||||||
|
return fmt.Errorf("leading 0 not permitted in protocol number \"%s\"", str)
|
||||||
|
}
|
||||||
|
|
||||||
|
protocolNumber, err := strconv.Atoi(str)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid protocol %q: must be a known protocol name or valid protocol number 0-255", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
if protocolNumber < 0 || protocolNumber > 255 {
|
||||||
|
return fmt.Errorf("protocol number %d out of range (0-255)", protocolNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON implements JSON marshaling for Protocol.
|
||||||
|
func (p Protocol) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(string(p))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protocol constants matching the IANA numbers
|
||||||
|
const (
|
||||||
|
protocolICMP = 1 // Internet Control Message
|
||||||
|
protocolIGMP = 2 // Internet Group Management
|
||||||
|
protocolIPv4 = 4 // IPv4 encapsulation
|
||||||
|
protocolTCP = 6 // Transmission Control
|
||||||
|
protocolEGP = 8 // Exterior Gateway Protocol
|
||||||
|
protocolIGP = 9 // any private interior gateway (used by Cisco for their IGRP)
|
||||||
|
protocolUDP = 17 // User Datagram
|
||||||
|
protocolGRE = 47 // Generic Routing Encapsulation
|
||||||
|
protocolESP = 50 // Encap Security Payload
|
||||||
|
protocolAH = 51 // Authentication Header
|
||||||
|
protocolIPv6ICMP = 58 // ICMP for IPv6
|
||||||
|
protocolSCTP = 132 // Stream Control Transmission Protocol
|
||||||
|
protocolFC = 133 // Fibre Channel
|
||||||
|
)
|
||||||
|
|
||||||
type ACL struct {
|
type ACL struct {
|
||||||
Action string `json:"action"` // TODO(kradalby): add strict type
|
Action Action `json:"action"`
|
||||||
Protocol string `json:"proto"` // TODO(kradalby): add strict type
|
Protocol Protocol `json:"proto"`
|
||||||
Sources Aliases `json:"src"`
|
Sources Aliases `json:"src"`
|
||||||
Destinations []AliasWithPorts `json:"dst"`
|
Destinations []AliasWithPorts `json:"dst"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON implements custom unmarshalling for ACL that ignores fields starting with '#'.
|
||||||
|
// headscale-admin uses # in some field names to add metadata, so we will ignore
|
||||||
|
// those to ensure it doesnt break.
|
||||||
|
// https://github.com/GoodiesHQ/headscale-admin/blob/214a44a9c15c92d2b42383f131b51df10c84017c/src/lib/common/acl.svelte.ts#L38
|
||||||
|
func (a *ACL) UnmarshalJSON(b []byte) error {
|
||||||
|
// First unmarshal into a map to filter out comment fields
|
||||||
|
var raw map[string]any
|
||||||
|
if err := json.Unmarshal(b, &raw, policyJSONOpts...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any fields that start with '#'
|
||||||
|
filtered := make(map[string]any)
|
||||||
|
for key, value := range raw {
|
||||||
|
if !strings.HasPrefix(key, "#") {
|
||||||
|
filtered[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marshal the filtered map back to JSON
|
||||||
|
filteredBytes, err := json.Marshal(filtered)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a type alias to avoid infinite recursion
|
||||||
|
type aclAlias ACL
|
||||||
|
var temp aclAlias
|
||||||
|
|
||||||
|
// Unmarshal into the temporary struct using the v2 JSON options
|
||||||
|
if err := json.Unmarshal(filteredBytes, &temp, policyJSONOpts...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the result back to the original struct
|
||||||
|
*a = ACL(temp)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Policy represents a Tailscale Network Policy.
|
// Policy represents a Tailscale Network Policy.
|
||||||
// TODO(kradalby):
|
// TODO(kradalby):
|
||||||
// Add validation method checking:
|
// Add validation method checking:
|
||||||
@ -1266,7 +1576,7 @@ type Policy struct {
|
|||||||
Hosts Hosts `json:"hosts,omitempty"`
|
Hosts Hosts `json:"hosts,omitempty"`
|
||||||
TagOwners TagOwners `json:"tagOwners,omitempty"`
|
TagOwners TagOwners `json:"tagOwners,omitempty"`
|
||||||
ACLs []ACL `json:"acls,omitempty"`
|
ACLs []ACL `json:"acls,omitempty"`
|
||||||
AutoApprovers AutoApproverPolicy `json:"autoApprovers,omitempty"`
|
AutoApprovers AutoApproverPolicy `json:"autoApprovers"`
|
||||||
SSHs []SSH `json:"ssh,omitempty"`
|
SSHs []SSH `json:"ssh,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1444,13 +1754,14 @@ func (p *Policy) validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate protocol-port compatibility
|
||||||
|
if err := validateProtocolPortCompatibility(acl.Protocol, acl.Destinations); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, ssh := range p.SSHs {
|
for _, ssh := range p.SSHs {
|
||||||
if ssh.Action != "accept" && ssh.Action != "check" {
|
|
||||||
errs = append(errs, fmt.Errorf("SSH action %q is not valid, must be accept or check", ssh.Action))
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, user := range ssh.Users {
|
for _, user := range ssh.Users {
|
||||||
if strings.HasPrefix(string(user), "autogroup:") {
|
if strings.HasPrefix(string(user), "autogroup:") {
|
||||||
maybeAuto := AutoGroup(user)
|
maybeAuto := AutoGroup(user)
|
||||||
@ -1564,7 +1875,7 @@ func (p *Policy) validate() error {
|
|||||||
|
|
||||||
// SSH controls who can ssh into which machines.
|
// SSH controls who can ssh into which machines.
|
||||||
type SSH struct {
|
type SSH struct {
|
||||||
Action string `json:"action"`
|
Action SSHAction `json:"action"`
|
||||||
Sources SSHSrcAliases `json:"src"`
|
Sources SSHSrcAliases `json:"src"`
|
||||||
Destinations SSHDstAliases `json:"dst"`
|
Destinations SSHDstAliases `json:"dst"`
|
||||||
Users SSHUsers `json:"users"`
|
Users SSHUsers `json:"users"`
|
||||||
@ -1595,7 +1906,7 @@ func (g Groups) MarshalJSON() ([]byte, error) {
|
|||||||
|
|
||||||
func (a *SSHSrcAliases) UnmarshalJSON(b []byte) error {
|
func (a *SSHSrcAliases) UnmarshalJSON(b []byte) error {
|
||||||
var aliases []AliasEnc
|
var aliases []AliasEnc
|
||||||
err := json.Unmarshal(b, &aliases)
|
err := json.Unmarshal(b, &aliases, policyJSONOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -1618,7 +1929,7 @@ func (a *SSHSrcAliases) UnmarshalJSON(b []byte) error {
|
|||||||
|
|
||||||
func (a *SSHDstAliases) UnmarshalJSON(b []byte) error {
|
func (a *SSHDstAliases) UnmarshalJSON(b []byte) error {
|
||||||
var aliases []AliasEnc
|
var aliases []AliasEnc
|
||||||
err := json.Unmarshal(b, &aliases)
|
err := json.Unmarshal(b, &aliases, policyJSONOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -1762,9 +2073,13 @@ func unmarshalPolicy(b []byte) (*Policy, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ast.Standardize()
|
ast.Standardize()
|
||||||
acl := ast.Pack()
|
if err = json.Unmarshal(ast.Pack(), &policy, policyJSONOpts...); err != nil {
|
||||||
|
var serr *json.SemanticError
|
||||||
if err = json.Unmarshal(acl, &policy); err != nil {
|
if errors.As(err, &serr) && serr.Err == json.ErrUnknownName {
|
||||||
|
ptr := serr.JSONPointer
|
||||||
|
name := ptr.LastToken()
|
||||||
|
return nil, fmt.Errorf("unknown field %q", name)
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("parsing policy from bytes: %w", err)
|
return nil, fmt.Errorf("parsing policy from bytes: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1775,6 +2090,25 @@ func unmarshalPolicy(b []byte) (*Policy, error) {
|
|||||||
return &policy, nil
|
return &policy, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
// validateProtocolPortCompatibility checks that only TCP, UDP, and SCTP protocols
|
||||||
expectedTokenItems = 2
|
// can have specific ports. All other protocols should only use wildcard ports.
|
||||||
)
|
func validateProtocolPortCompatibility(protocol Protocol, destinations []AliasWithPorts) error {
|
||||||
|
// Only TCP, UDP, and SCTP support specific ports
|
||||||
|
supportsSpecificPorts := protocol == ProtocolTCP || protocol == ProtocolUDP || protocol == ProtocolSCTP || protocol == ""
|
||||||
|
|
||||||
|
if supportsSpecificPorts {
|
||||||
|
return nil // No validation needed for these protocols
|
||||||
|
}
|
||||||
|
|
||||||
|
// For all other protocols, check that all destinations use wildcard ports
|
||||||
|
for _, dst := range destinations {
|
||||||
|
for _, portRange := range dst.Ports {
|
||||||
|
// Check if it's not a wildcard port (0-65535)
|
||||||
|
if !(portRange.First == 0 && portRange.Last == 65535) {
|
||||||
|
return fmt.Errorf("protocol %q does not support specific ports; only \"*\" is allowed", protocol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
@ -352,20 +352,6 @@ func TestUnmarshalPolicy(t *testing.T) {
|
|||||||
name: "2652-asterix-error-better-explain",
|
name: "2652-asterix-error-better-explain",
|
||||||
input: `
|
input: `
|
||||||
{
|
{
|
||||||
"acls": [
|
|
||||||
{
|
|
||||||
"action": "accept",
|
|
||||||
"src": [
|
|
||||||
"*"
|
|
||||||
],
|
|
||||||
"dst": [
|
|
||||||
"*:*"
|
|
||||||
],
|
|
||||||
"proto": [
|
|
||||||
"*:*"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ssh": [
|
"ssh": [
|
||||||
{
|
{
|
||||||
"action": "accept",
|
"action": "accept",
|
||||||
@ -375,9 +361,7 @@ func TestUnmarshalPolicy(t *testing.T) {
|
|||||||
"dst": [
|
"dst": [
|
||||||
"*"
|
"*"
|
||||||
],
|
],
|
||||||
"proto": [
|
"users": ["root"]
|
||||||
"*:*"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -992,6 +976,500 @@ func TestUnmarshalPolicy(t *testing.T) {
|
|||||||
`,
|
`,
|
||||||
wantErr: `first port must be >0, or use '*' for wildcard`,
|
wantErr: `first port must be >0, or use '*' for wildcard`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "disallow-unsupported-fields",
|
||||||
|
input: `
|
||||||
|
{
|
||||||
|
// rules doesnt exists, we have "acls"
|
||||||
|
"rules": [
|
||||||
|
]
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
wantErr: `unknown field "rules"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "disallow-unsupported-fields-nested",
|
||||||
|
input: `
|
||||||
|
{
|
||||||
|
"acls": [
|
||||||
|
{ "action": "accept", "BAD": ["FOO:BAR:FOO:BAR"], "NOT": ["BAD:BAD:BAD:BAD"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
wantErr: `unknown field`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-group-name",
|
||||||
|
input: `
|
||||||
|
{
|
||||||
|
"groups": {
|
||||||
|
"group:test": ["user@example.com"],
|
||||||
|
"INVALID_GROUP_FIELD": ["user@example.com"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
wantErr: `Group has to start with "group:", got: "INVALID_GROUP_FIELD"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-group-datatype",
|
||||||
|
input: `
|
||||||
|
{
|
||||||
|
"groups": {
|
||||||
|
"group:test": ["user@example.com"],
|
||||||
|
"group:invalid": "should fail"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
wantErr: `Group "group:invalid" value must be an array of users, got string: "should fail"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-group-name-and-datatype-fails-on-name-first",
|
||||||
|
input: `
|
||||||
|
{
|
||||||
|
"groups": {
|
||||||
|
"group:test": ["user@example.com"],
|
||||||
|
"INVALID_GROUP_FIELD": "should fail"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
wantErr: `Group has to start with "group:", got: "INVALID_GROUP_FIELD"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "disallow-unsupported-fields-hosts-level",
|
||||||
|
input: `
|
||||||
|
{
|
||||||
|
"hosts": {
|
||||||
|
"host1": "10.0.0.1",
|
||||||
|
"INVALID_HOST_FIELD": "should fail"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
wantErr: `Hostname "INVALID_HOST_FIELD" contains an invalid IP address: "should fail"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "disallow-unsupported-fields-tagowners-level",
|
||||||
|
input: `
|
||||||
|
{
|
||||||
|
"tagOwners": {
|
||||||
|
"tag:test": ["user@example.com"],
|
||||||
|
"INVALID_TAG_FIELD": "should fail"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
wantErr: `tag has to start with "tag:", got: "INVALID_TAG_FIELD"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "disallow-unsupported-fields-acls-level",
|
||||||
|
input: `
|
||||||
|
{
|
||||||
|
"acls": [
|
||||||
|
{
|
||||||
|
"action": "accept",
|
||||||
|
"proto": "tcp",
|
||||||
|
"src": ["*"],
|
||||||
|
"dst": ["*:*"],
|
||||||
|
"INVALID_ACL_FIELD": "should fail"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
wantErr: `unknown field "INVALID_ACL_FIELD"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "disallow-unsupported-fields-ssh-level",
|
||||||
|
input: `
|
||||||
|
{
|
||||||
|
"ssh": [
|
||||||
|
{
|
||||||
|
"action": "accept",
|
||||||
|
"src": ["user@example.com"],
|
||||||
|
"dst": ["user@example.com"],
|
||||||
|
"users": ["root"],
|
||||||
|
"INVALID_SSH_FIELD": "should fail"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
wantErr: `unknown field "INVALID_SSH_FIELD"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "disallow-unsupported-fields-policy-level",
|
||||||
|
input: `
|
||||||
|
{
|
||||||
|
"acls": [
|
||||||
|
{
|
||||||
|
"action": "accept",
|
||||||
|
"proto": "tcp",
|
||||||
|
"src": ["*"],
|
||||||
|
"dst": ["*:*"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"INVALID_POLICY_FIELD": "should fail at policy level"
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
wantErr: `unknown field "INVALID_POLICY_FIELD"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "disallow-unsupported-fields-autoapprovers-level",
|
||||||
|
input: `
|
||||||
|
{
|
||||||
|
"autoApprovers": {
|
||||||
|
"routes": {
|
||||||
|
"10.0.0.0/8": ["user@example.com"]
|
||||||
|
},
|
||||||
|
"exitNode": ["user@example.com"],
|
||||||
|
"INVALID_AUTO_APPROVER_FIELD": "should fail"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
wantErr: `unknown field "INVALID_AUTO_APPROVER_FIELD"`,
|
||||||
|
},
|
||||||
|
// headscale-admin uses # in some field names to add metadata, so we will ignore
|
||||||
|
// those to ensure it doesnt break.
|
||||||
|
// https://github.com/GoodiesHQ/headscale-admin/blob/214a44a9c15c92d2b42383f131b51df10c84017c/src/lib/common/acl.svelte.ts#L38
|
||||||
|
{
|
||||||
|
name: "hash-fields-are-allowed-but-ignored",
|
||||||
|
input: `
|
||||||
|
{
|
||||||
|
"acls": [
|
||||||
|
{
|
||||||
|
"#ha-test": "SOME VALUE",
|
||||||
|
"action": "accept",
|
||||||
|
"src": [
|
||||||
|
"10.0.0.1"
|
||||||
|
],
|
||||||
|
"dst": [
|
||||||
|
"autogroup:internet:*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
want: &Policy{
|
||||||
|
ACLs: []ACL{
|
||||||
|
{
|
||||||
|
Action: "accept",
|
||||||
|
Sources: Aliases{
|
||||||
|
pp("10.0.0.1/32"),
|
||||||
|
},
|
||||||
|
Destinations: []AliasWithPorts{
|
||||||
|
{
|
||||||
|
Alias: ptr.To(AutoGroup("autogroup:internet")),
|
||||||
|
Ports: []tailcfg.PortRange{tailcfg.PortRangeAny},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ssh-asterix-invalid-acl-input",
|
||||||
|
input: `
|
||||||
|
{
|
||||||
|
"ssh": [
|
||||||
|
{
|
||||||
|
"action": "accept",
|
||||||
|
"src": [
|
||||||
|
"user@example.com"
|
||||||
|
],
|
||||||
|
"dst": [
|
||||||
|
"user@example.com"
|
||||||
|
],
|
||||||
|
"users": ["root"],
|
||||||
|
"proto": "tcp"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
wantErr: `unknown field "proto"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "protocol-wildcard-not-allowed",
|
||||||
|
input: `
|
||||||
|
{
|
||||||
|
"acls": [
|
||||||
|
{
|
||||||
|
"action": "accept",
|
||||||
|
"proto": "*",
|
||||||
|
"src": ["*"],
|
||||||
|
"dst": ["*:*"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
wantErr: `proto name "*" not known; use protocol number 0-255 or protocol name (icmp, tcp, udp, etc.)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "protocol-case-insensitive-uppercase",
|
||||||
|
input: `
|
||||||
|
{
|
||||||
|
"acls": [
|
||||||
|
{
|
||||||
|
"action": "accept",
|
||||||
|
"proto": "ICMP",
|
||||||
|
"src": ["*"],
|
||||||
|
"dst": ["*:*"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
want: &Policy{
|
||||||
|
ACLs: []ACL{
|
||||||
|
{
|
||||||
|
Action: "accept",
|
||||||
|
Protocol: "icmp",
|
||||||
|
Sources: Aliases{
|
||||||
|
Wildcard,
|
||||||
|
},
|
||||||
|
Destinations: []AliasWithPorts{
|
||||||
|
{
|
||||||
|
Alias: Wildcard,
|
||||||
|
Ports: []tailcfg.PortRange{tailcfg.PortRangeAny},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "protocol-case-insensitive-mixed",
|
||||||
|
input: `
|
||||||
|
{
|
||||||
|
"acls": [
|
||||||
|
{
|
||||||
|
"action": "accept",
|
||||||
|
"proto": "IcmP",
|
||||||
|
"src": ["*"],
|
||||||
|
"dst": ["*:*"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
want: &Policy{
|
||||||
|
ACLs: []ACL{
|
||||||
|
{
|
||||||
|
Action: "accept",
|
||||||
|
Protocol: "icmp",
|
||||||
|
Sources: Aliases{
|
||||||
|
Wildcard,
|
||||||
|
},
|
||||||
|
Destinations: []AliasWithPorts{
|
||||||
|
{
|
||||||
|
Alias: Wildcard,
|
||||||
|
Ports: []tailcfg.PortRange{tailcfg.PortRangeAny},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "protocol-leading-zero-not-permitted",
|
||||||
|
input: `
|
||||||
|
{
|
||||||
|
"acls": [
|
||||||
|
{
|
||||||
|
"action": "accept",
|
||||||
|
"proto": "0",
|
||||||
|
"src": ["*"],
|
||||||
|
"dst": ["*:*"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
wantErr: `leading 0 not permitted in protocol number "0"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "protocol-empty-applies-to-tcp-udp-only",
|
||||||
|
input: `
|
||||||
|
{
|
||||||
|
"acls": [
|
||||||
|
{
|
||||||
|
"action": "accept",
|
||||||
|
"src": ["*"],
|
||||||
|
"dst": ["*:80"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
want: &Policy{
|
||||||
|
ACLs: []ACL{
|
||||||
|
{
|
||||||
|
Action: "accept",
|
||||||
|
Protocol: "",
|
||||||
|
Sources: Aliases{
|
||||||
|
Wildcard,
|
||||||
|
},
|
||||||
|
Destinations: []AliasWithPorts{
|
||||||
|
{
|
||||||
|
Alias: Wildcard,
|
||||||
|
Ports: []tailcfg.PortRange{{First: 80, Last: 80}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "protocol-icmp-with-specific-port-not-allowed",
|
||||||
|
input: `
|
||||||
|
{
|
||||||
|
"acls": [
|
||||||
|
{
|
||||||
|
"action": "accept",
|
||||||
|
"proto": "icmp",
|
||||||
|
"src": ["*"],
|
||||||
|
"dst": ["*:80"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
wantErr: `protocol "icmp" does not support specific ports; only "*" is allowed`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "protocol-icmp-with-wildcard-port-allowed",
|
||||||
|
input: `
|
||||||
|
{
|
||||||
|
"acls": [
|
||||||
|
{
|
||||||
|
"action": "accept",
|
||||||
|
"proto": "icmp",
|
||||||
|
"src": ["*"],
|
||||||
|
"dst": ["*:*"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
want: &Policy{
|
||||||
|
ACLs: []ACL{
|
||||||
|
{
|
||||||
|
Action: "accept",
|
||||||
|
Protocol: "icmp",
|
||||||
|
Sources: Aliases{
|
||||||
|
Wildcard,
|
||||||
|
},
|
||||||
|
Destinations: []AliasWithPorts{
|
||||||
|
{
|
||||||
|
Alias: Wildcard,
|
||||||
|
Ports: []tailcfg.PortRange{tailcfg.PortRangeAny},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "protocol-gre-with-specific-port-not-allowed",
|
||||||
|
input: `
|
||||||
|
{
|
||||||
|
"acls": [
|
||||||
|
{
|
||||||
|
"action": "accept",
|
||||||
|
"proto": "gre",
|
||||||
|
"src": ["*"],
|
||||||
|
"dst": ["*:443"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
wantErr: `protocol "gre" does not support specific ports; only "*" is allowed`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "protocol-tcp-with-specific-port-allowed",
|
||||||
|
input: `
|
||||||
|
{
|
||||||
|
"acls": [
|
||||||
|
{
|
||||||
|
"action": "accept",
|
||||||
|
"proto": "tcp",
|
||||||
|
"src": ["*"],
|
||||||
|
"dst": ["*:80"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
want: &Policy{
|
||||||
|
ACLs: []ACL{
|
||||||
|
{
|
||||||
|
Action: "accept",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Sources: Aliases{
|
||||||
|
Wildcard,
|
||||||
|
},
|
||||||
|
Destinations: []AliasWithPorts{
|
||||||
|
{
|
||||||
|
Alias: Wildcard,
|
||||||
|
Ports: []tailcfg.PortRange{{First: 80, Last: 80}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "protocol-udp-with-specific-port-allowed",
|
||||||
|
input: `
|
||||||
|
{
|
||||||
|
"acls": [
|
||||||
|
{
|
||||||
|
"action": "accept",
|
||||||
|
"proto": "udp",
|
||||||
|
"src": ["*"],
|
||||||
|
"dst": ["*:53"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
want: &Policy{
|
||||||
|
ACLs: []ACL{
|
||||||
|
{
|
||||||
|
Action: "accept",
|
||||||
|
Protocol: "udp",
|
||||||
|
Sources: Aliases{
|
||||||
|
Wildcard,
|
||||||
|
},
|
||||||
|
Destinations: []AliasWithPorts{
|
||||||
|
{
|
||||||
|
Alias: Wildcard,
|
||||||
|
Ports: []tailcfg.PortRange{{First: 53, Last: 53}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "protocol-sctp-with-specific-port-allowed",
|
||||||
|
input: `
|
||||||
|
{
|
||||||
|
"acls": [
|
||||||
|
{
|
||||||
|
"action": "accept",
|
||||||
|
"proto": "sctp",
|
||||||
|
"src": ["*"],
|
||||||
|
"dst": ["*:9000"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
want: &Policy{
|
||||||
|
ACLs: []ACL{
|
||||||
|
{
|
||||||
|
Action: "accept",
|
||||||
|
Protocol: "sctp",
|
||||||
|
Sources: Aliases{
|
||||||
|
Wildcard,
|
||||||
|
},
|
||||||
|
Destinations: []AliasWithPorts{
|
||||||
|
{
|
||||||
|
Alias: Wildcard,
|
||||||
|
Ports: []tailcfg.PortRange{{First: 9000, Last: 9000}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmps := append(util.Comparers,
|
cmps := append(util.Comparers,
|
||||||
@ -2091,3 +2569,291 @@ func TestNodeCanHaveTag(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestACL_UnmarshalJSON_WithCommentFields(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected ACL
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "basic ACL with comment fields",
|
||||||
|
input: `{
|
||||||
|
"#comment": "This is a comment",
|
||||||
|
"action": "accept",
|
||||||
|
"proto": "tcp",
|
||||||
|
"src": ["user1@example.com"],
|
||||||
|
"dst": ["tag:server:80"]
|
||||||
|
}`,
|
||||||
|
expected: ACL{
|
||||||
|
Action: "accept",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Sources: []Alias{mustParseAlias("user1@example.com")},
|
||||||
|
Destinations: []AliasWithPorts{
|
||||||
|
{
|
||||||
|
Alias: mustParseAlias("tag:server"),
|
||||||
|
Ports: []tailcfg.PortRange{{First: 80, Last: 80}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple comment fields",
|
||||||
|
input: `{
|
||||||
|
"#description": "Allow access to web servers",
|
||||||
|
"#note": "Created by admin",
|
||||||
|
"#created_date": "2024-01-15",
|
||||||
|
"action": "accept",
|
||||||
|
"proto": "tcp",
|
||||||
|
"src": ["group:developers"],
|
||||||
|
"dst": ["10.0.0.0/24:443"]
|
||||||
|
}`,
|
||||||
|
expected: ACL{
|
||||||
|
Action: "accept",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Sources: []Alias{mustParseAlias("group:developers")},
|
||||||
|
Destinations: []AliasWithPorts{
|
||||||
|
{
|
||||||
|
Alias: mustParseAlias("10.0.0.0/24"),
|
||||||
|
Ports: []tailcfg.PortRange{{First: 443, Last: 443}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "comment field with complex object value",
|
||||||
|
input: `{
|
||||||
|
"#metadata": {
|
||||||
|
"description": "Complex comment object",
|
||||||
|
"tags": ["web", "production"],
|
||||||
|
"created_by": "admin"
|
||||||
|
},
|
||||||
|
"action": "accept",
|
||||||
|
"proto": "udp",
|
||||||
|
"src": ["*"],
|
||||||
|
"dst": ["autogroup:internet:53"]
|
||||||
|
}`,
|
||||||
|
expected: ACL{
|
||||||
|
Action: ActionAccept,
|
||||||
|
Protocol: "udp",
|
||||||
|
Sources: []Alias{Wildcard},
|
||||||
|
Destinations: []AliasWithPorts{
|
||||||
|
{
|
||||||
|
Alias: mustParseAlias("autogroup:internet"),
|
||||||
|
Ports: []tailcfg.PortRange{{First: 53, Last: 53}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid action should fail",
|
||||||
|
input: `{
|
||||||
|
"action": "deny",
|
||||||
|
"proto": "tcp",
|
||||||
|
"src": ["*"],
|
||||||
|
"dst": ["*:*"]
|
||||||
|
}`,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no comment fields",
|
||||||
|
input: `{
|
||||||
|
"action": "accept",
|
||||||
|
"proto": "icmp",
|
||||||
|
"src": ["tag:client"],
|
||||||
|
"dst": ["tag:server:*"]
|
||||||
|
}`,
|
||||||
|
expected: ACL{
|
||||||
|
Action: ActionAccept,
|
||||||
|
Protocol: "icmp",
|
||||||
|
Sources: []Alias{mustParseAlias("tag:client")},
|
||||||
|
Destinations: []AliasWithPorts{
|
||||||
|
{
|
||||||
|
Alias: mustParseAlias("tag:server"),
|
||||||
|
Ports: []tailcfg.PortRange{tailcfg.PortRangeAny},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only comment fields",
|
||||||
|
input: `{
|
||||||
|
"#comment": "This rule is disabled",
|
||||||
|
"#reason": "Temporary disable for maintenance"
|
||||||
|
}`,
|
||||||
|
expected: ACL{
|
||||||
|
Action: Action(""),
|
||||||
|
Protocol: Protocol(""),
|
||||||
|
Sources: nil,
|
||||||
|
Destinations: nil,
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid JSON",
|
||||||
|
input: `{
|
||||||
|
"#comment": "This is a comment",
|
||||||
|
"action": "accept",
|
||||||
|
"proto": "tcp"
|
||||||
|
"src": ["invalid json"]
|
||||||
|
}`,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid field after comment filtering",
|
||||||
|
input: `{
|
||||||
|
"#comment": "This is a comment",
|
||||||
|
"action": "accept",
|
||||||
|
"proto": "tcp",
|
||||||
|
"src": ["user1@example.com"],
|
||||||
|
"dst": ["invalid-destination"]
|
||||||
|
}`,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var acl ACL
|
||||||
|
err := json.Unmarshal([]byte(tt.input), &acl)
|
||||||
|
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Error(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.expected.Action, acl.Action)
|
||||||
|
assert.Equal(t, tt.expected.Protocol, acl.Protocol)
|
||||||
|
assert.Equal(t, len(tt.expected.Sources), len(acl.Sources))
|
||||||
|
assert.Equal(t, len(tt.expected.Destinations), len(acl.Destinations))
|
||||||
|
|
||||||
|
// Compare sources
|
||||||
|
for i, expectedSrc := range tt.expected.Sources {
|
||||||
|
if i < len(acl.Sources) {
|
||||||
|
assert.Equal(t, expectedSrc, acl.Sources[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare destinations
|
||||||
|
for i, expectedDst := range tt.expected.Destinations {
|
||||||
|
if i < len(acl.Destinations) {
|
||||||
|
assert.Equal(t, expectedDst.Alias, acl.Destinations[i].Alias)
|
||||||
|
assert.Equal(t, expectedDst.Ports, acl.Destinations[i].Ports)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestACL_UnmarshalJSON_Roundtrip(t *testing.T) {
|
||||||
|
// Test that marshaling and unmarshaling preserves data (excluding comments)
|
||||||
|
original := ACL{
|
||||||
|
Action: "accept",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Sources: []Alias{mustParseAlias("group:admins")},
|
||||||
|
Destinations: []AliasWithPorts{
|
||||||
|
{
|
||||||
|
Alias: mustParseAlias("tag:server"),
|
||||||
|
Ports: []tailcfg.PortRange{{First: 22, Last: 22}, {First: 80, Last: 80}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marshal to JSON
|
||||||
|
jsonBytes, err := json.Marshal(original)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Unmarshal back
|
||||||
|
var unmarshaled ACL
|
||||||
|
err = json.Unmarshal(jsonBytes, &unmarshaled)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Should be equal
|
||||||
|
assert.Equal(t, original.Action, unmarshaled.Action)
|
||||||
|
assert.Equal(t, original.Protocol, unmarshaled.Protocol)
|
||||||
|
assert.Equal(t, len(original.Sources), len(unmarshaled.Sources))
|
||||||
|
assert.Equal(t, len(original.Destinations), len(unmarshaled.Destinations))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestACL_UnmarshalJSON_PolicyIntegration(t *testing.T) {
|
||||||
|
// Test that ACL unmarshaling works within a Policy context
|
||||||
|
policyJSON := `{
|
||||||
|
"groups": {
|
||||||
|
"group:developers": ["user1@example.com", "user2@example.com"]
|
||||||
|
},
|
||||||
|
"tagOwners": {
|
||||||
|
"tag:server": ["group:developers"]
|
||||||
|
},
|
||||||
|
"acls": [
|
||||||
|
{
|
||||||
|
"#description": "Allow developers to access servers",
|
||||||
|
"#priority": "high",
|
||||||
|
"action": "accept",
|
||||||
|
"proto": "tcp",
|
||||||
|
"src": ["group:developers"],
|
||||||
|
"dst": ["tag:server:22,80,443"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"#note": "Allow all other traffic",
|
||||||
|
"action": "accept",
|
||||||
|
"proto": "tcp",
|
||||||
|
"src": ["*"],
|
||||||
|
"dst": ["*:*"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
policy, err := unmarshalPolicy([]byte(policyJSON))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, policy)
|
||||||
|
|
||||||
|
// Check that ACLs were parsed correctly
|
||||||
|
require.Len(t, policy.ACLs, 2)
|
||||||
|
|
||||||
|
// First ACL
|
||||||
|
acl1 := policy.ACLs[0]
|
||||||
|
assert.Equal(t, ActionAccept, acl1.Action)
|
||||||
|
assert.Equal(t, Protocol("tcp"), acl1.Protocol)
|
||||||
|
require.Len(t, acl1.Sources, 1)
|
||||||
|
require.Len(t, acl1.Destinations, 1)
|
||||||
|
|
||||||
|
// Second ACL
|
||||||
|
acl2 := policy.ACLs[1]
|
||||||
|
assert.Equal(t, ActionAccept, acl2.Action)
|
||||||
|
assert.Equal(t, Protocol("tcp"), acl2.Protocol)
|
||||||
|
require.Len(t, acl2.Sources, 1)
|
||||||
|
require.Len(t, acl2.Destinations, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestACL_UnmarshalJSON_InvalidAction(t *testing.T) {
|
||||||
|
// Test that invalid actions are rejected
|
||||||
|
policyJSON := `{
|
||||||
|
"acls": [
|
||||||
|
{
|
||||||
|
"action": "deny",
|
||||||
|
"proto": "tcp",
|
||||||
|
"src": ["*"],
|
||||||
|
"dst": ["*:*"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
_, err := unmarshalPolicy([]byte(policyJSON))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), `invalid action "deny"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to parse aliases for testing
|
||||||
|
func mustParseAlias(s string) Alias {
|
||||||
|
alias, err := parseAlias(s)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return alias
|
||||||
|
}
|
||||||
|
@ -2,7 +2,6 @@ package v2
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@ -97,72 +96,3 @@ func parsePort(portStr string) (uint16, error) {
|
|||||||
|
|
||||||
return uint16(port), nil
|
return uint16(port), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// For some reason golang.org/x/net/internal/iana is an internal package.
|
|
||||||
const (
|
|
||||||
protocolICMP = 1 // Internet Control Message
|
|
||||||
protocolIGMP = 2 // Internet Group Management
|
|
||||||
protocolIPv4 = 4 // IPv4 encapsulation
|
|
||||||
protocolTCP = 6 // Transmission Control
|
|
||||||
protocolEGP = 8 // Exterior Gateway Protocol
|
|
||||||
protocolIGP = 9 // any private interior gateway (used by Cisco for their IGRP)
|
|
||||||
protocolUDP = 17 // User Datagram
|
|
||||||
protocolGRE = 47 // Generic Routing Encapsulation
|
|
||||||
protocolESP = 50 // Encap Security Payload
|
|
||||||
protocolAH = 51 // Authentication Header
|
|
||||||
protocolIPv6ICMP = 58 // ICMP for IPv6
|
|
||||||
protocolSCTP = 132 // Stream Control Transmission Protocol
|
|
||||||
ProtocolFC = 133 // Fibre Channel
|
|
||||||
)
|
|
||||||
|
|
||||||
// parseProtocol reads the proto field of the ACL and generates a list of
|
|
||||||
// protocols that will be allowed, following the IANA IP protocol number
|
|
||||||
// https://www.iana.org/assignments/protocol-numbers/protocol-numbers.xhtml
|
|
||||||
//
|
|
||||||
// If the ACL proto field is empty, it allows ICMPv4, ICMPv6, TCP, and UDP,
|
|
||||||
// as per Tailscale behaviour (see tailcfg.FilterRule).
|
|
||||||
//
|
|
||||||
// Also returns a boolean indicating if the protocol
|
|
||||||
// requires all the destinations to use wildcard as port number (only TCP,
|
|
||||||
// UDP and SCTP support specifying ports).
|
|
||||||
func parseProtocol(protocol string) ([]int, bool, error) {
|
|
||||||
switch protocol {
|
|
||||||
case "":
|
|
||||||
return nil, false, nil
|
|
||||||
case "igmp":
|
|
||||||
return []int{protocolIGMP}, true, nil
|
|
||||||
case "ipv4", "ip-in-ip":
|
|
||||||
return []int{protocolIPv4}, true, nil
|
|
||||||
case "tcp":
|
|
||||||
return []int{protocolTCP}, false, nil
|
|
||||||
case "egp":
|
|
||||||
return []int{protocolEGP}, true, nil
|
|
||||||
case "igp":
|
|
||||||
return []int{protocolIGP}, true, nil
|
|
||||||
case "udp":
|
|
||||||
return []int{protocolUDP}, false, nil
|
|
||||||
case "gre":
|
|
||||||
return []int{protocolGRE}, true, nil
|
|
||||||
case "esp":
|
|
||||||
return []int{protocolESP}, true, nil
|
|
||||||
case "ah":
|
|
||||||
return []int{protocolAH}, true, nil
|
|
||||||
case "sctp":
|
|
||||||
return []int{protocolSCTP}, false, nil
|
|
||||||
case "icmp":
|
|
||||||
return []int{protocolICMP, protocolIPv6ICMP}, true, nil
|
|
||||||
|
|
||||||
default:
|
|
||||||
protocolNumber, err := strconv.Atoi(protocol)
|
|
||||||
if err != nil {
|
|
||||||
return nil, false, fmt.Errorf("parsing protocol number: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO(kradalby): What is this?
|
|
||||||
needsWildcard := protocolNumber != protocolTCP &&
|
|
||||||
protocolNumber != protocolUDP &&
|
|
||||||
protocolNumber != protocolSCTP
|
|
||||||
|
|
||||||
return []int{protocolNumber}, needsWildcard, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -1885,7 +1885,7 @@ func TestPolicyBrokenConfigCommand(t *testing.T) {
|
|||||||
policyFilePath,
|
policyFilePath,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert.ErrorContains(t, err, "compiling filter rules: invalid action")
|
assert.ErrorContains(t, err, `invalid action "unknown-action"`)
|
||||||
|
|
||||||
// The new policy was invalid, the old one should still be in place, which
|
// The new policy was invalid, the old one should still be in place, which
|
||||||
// is none.
|
// is none.
|
||||||
|
@ -1481,7 +1481,7 @@ func TestSubnetRouteACL(t *testing.T) {
|
|||||||
wantClientFilter := []filter.Match{
|
wantClientFilter := []filter.Match{
|
||||||
{
|
{
|
||||||
IPProto: views.SliceOf([]ipproto.Proto{
|
IPProto: views.SliceOf([]ipproto.Proto{
|
||||||
ipproto.TCP, ipproto.UDP, ipproto.ICMPv4, ipproto.ICMPv6,
|
ipproto.TCP, ipproto.UDP,
|
||||||
}),
|
}),
|
||||||
Srcs: []netip.Prefix{
|
Srcs: []netip.Prefix{
|
||||||
netip.MustParsePrefix("100.64.0.1/32"),
|
netip.MustParsePrefix("100.64.0.1/32"),
|
||||||
@ -1513,7 +1513,7 @@ func TestSubnetRouteACL(t *testing.T) {
|
|||||||
wantSubnetFilter := []filter.Match{
|
wantSubnetFilter := []filter.Match{
|
||||||
{
|
{
|
||||||
IPProto: views.SliceOf([]ipproto.Proto{
|
IPProto: views.SliceOf([]ipproto.Proto{
|
||||||
ipproto.TCP, ipproto.UDP, ipproto.ICMPv4, ipproto.ICMPv6,
|
ipproto.TCP, ipproto.UDP,
|
||||||
}),
|
}),
|
||||||
Srcs: []netip.Prefix{
|
Srcs: []netip.Prefix{
|
||||||
netip.MustParsePrefix("100.64.0.1/32"),
|
netip.MustParsePrefix("100.64.0.1/32"),
|
||||||
@ -1535,7 +1535,7 @@ func TestSubnetRouteACL(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
IPProto: views.SliceOf([]ipproto.Proto{
|
IPProto: views.SliceOf([]ipproto.Proto{
|
||||||
ipproto.TCP, ipproto.UDP, ipproto.ICMPv4, ipproto.ICMPv6,
|
ipproto.TCP, ipproto.UDP,
|
||||||
}),
|
}),
|
||||||
Srcs: []netip.Prefix{
|
Srcs: []netip.Prefix{
|
||||||
netip.MustParsePrefix("100.64.0.1/32"),
|
netip.MustParsePrefix("100.64.0.1/32"),
|
||||||
|
Loading…
Reference in New Issue
Block a user