Repository: juanfont/headscale

Issue:
[Feature] Reject invalid dst in policy
### Use case

Improve pre-flight validation of policy and better align with policy validation of tailscale.com.

### Description

Headscale currently accepts two values for "dst" which get rejected by tailscale.com.

#### Destination without port
This policy is currently accepted by Headscale while being rejected by tailscale.com with the error message `Error: dst="100.64.0.1": hostport must contain a colon (":")`

```json
{
  "acls": [
    {
      "action": "accept",
      "src": [
        "*"
      ],
      "dst": [
        "100.64.0.1"
      ]
    }
  ]
}
```

#### Destination with invalid port
This policy is currently accepted by Headscale while being rejected by tailscale.com with the error message `Error: dst="100.64.0.1:0": port range "0": first port must be >0, or use '*' for wildcard`

```json
{
  "acls": [
    {
      "action": "accept",
      "src": [
        "*"
      ],
      "dst": [
        "100.64.0.1:0"
      ]
    }
  ]
}
```

Both are breaking changes as Headscale would refuse to start. The second one is a bit more nuanced as such a rule was required in Headscale < 0.26 to make subnet routers visible to peers. However, the documentation for 0.26 does no longer contain this rule.

### Contribution

- [ ] I can write the design doc for this feature
- [ ] I can contribute this feature

### How can it be implemented?

_No response_

Discussion:

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6bca556d..e6645ec5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,11 @@
 
 ## Next
 
+### BREAKING
+
+- Policy: Zero or empty destination port is no longer allowed
+  [#2606](https://github.com/juanfont/headscale/pull/2606)
+
 ## 0.26.0 (2025-05-14)
 
 ### BREAKING
diff --git a/hscontrol/policy/v2/types.go b/hscontrol/policy/v2/types.go
index a49f55de..d10136a0 100644
--- a/hscontrol/policy/v2/types.go
+++ b/hscontrol/policy/v2/types.go
@@ -3,6 +3,7 @@ package v2
 import (
 	"bytes"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"net/netip"
 	"strings"
@@ -467,6 +468,8 @@ func (ve *AliasWithPorts) UnmarshalJSON(b []byte) error {
 				return err
 			}
 			ve.Ports = ports
+		} else {
+			return errors.New(`hostport must contain a colon (":")`)
 		}
 
 		ve.Alias, err = parseAlias(vs)
diff --git a/hscontrol/policy/v2/utils.go b/hscontrol/policy/v2/utils.go
index 9c962af8..2c551eda 100644
--- a/hscontrol/policy/v2/utils.go
+++ b/hscontrol/policy/v2/utils.go
@@ -73,6 +73,10 @@ func parsePortRange(portDef string) ([]tailcfg.PortRange, error) {
 				return nil, err
 			}
 
+			if port < 1 {
+				return nil, errors.New("first port must be >0, or use '*' for wildcard")
+			}
+
 			portRanges = append(portRanges, tailcfg.PortRange{First: port, Last: port})
 		}
 	}



Test patch:
diff --git a/hscontrol/policy/v2/types_test.go b/hscontrol/policy/v2/types_test.go
index 3808b547..e9fa6263 100644
--- a/hscontrol/policy/v2/types_test.go
+++ b/hscontrol/policy/v2/types_test.go
@@ -706,6 +706,44 @@ func TestUnmarshalPolicy(t *testing.T) {
 `,
 			wantErr: `Tag "tag:notdefined" is not defined in the Policy, please define or remove the reference to it`,
 		},
+		{
+			name: "missing-dst-port-is-err",
+			input: `
+			{
+  "acls": [
+    {
+      "action": "accept",
+      "src": [
+        "*"
+      ],
+      "dst": [
+        "100.64.0.1"
+      ]
+    }
+  ]
+}
+`,
+			wantErr: `hostport must contain a colon (":")`,
+		},
+		{
+			name: "dst-port-zero-is-err",
+			input: `
+			{
+  "acls": [
+    {
+      "action": "accept",
+      "src": [
+        "*"
+      ],
+      "dst": [
+        "100.64.0.1:0"
+      ]
+    }
+  ]
+}
+`,
+			wantErr: `first port must be >0, or use '*' for wildcard`,
+		},
 	}
 
 	cmps := append(util.Comparers, cmp.Comparer(func(x, y Prefix) bool {