juanfont/headscale · error

test destination port must be a single port

Error message

test destination port must be a single port

What it means

ErrTestDestinationMultiPort is returned by validateTestDestination (hscontrol/policy/v2/types.go:3255) when a tests-block accept/deny destination does not name exactly one single port. The parsed port list must have exactly one range whose First equals its Last; wildcards (*), comma lists, and ranges like 80-443 are rejected because a multi-port destination has no single allow/deny answer.

Source

Thrown at hscontrol/policy/v2/types.go:149

	ErrInvalidProtocolNumber       = errors.New("invalid protocol number")
	ErrProtocolLeadingZero         = errors.New("leading 0 not permitted in protocol number")
	ErrProtocolOutOfRange          = errors.New("protocol number out of range (0-255)")
	ErrAutogroupNotSupported       = errors.New("autogroup not supported in headscale")
	ErrAutogroupInternetSrc        = errors.New("autogroup:internet can only be used in ACL destinations")
	ErrAutogroupSelfSrc            = errors.New("\"autogroup:self\" not valid on the src side of a rule")
	ErrAutogroupNotSupportedACLSrc = errors.New("autogroup not supported for ACL sources")
	ErrAutogroupNotSupportedACLDst = errors.New("autogroup not supported for ACL destinations")
	ErrAutogroupDangerAllDst       = errors.New("cannot use autogroup:danger-all as a dst")
	ErrAutogroupNotSupportedSSHSrc = errors.New("autogroup not supported for SSH sources")
	ErrAutogroupNotSupportedSSHDst = errors.New("autogroup not supported for SSH destinations")
	ErrHostNotDefined              = errors.New("host not defined in policy")
	ErrSSHSourceAliasNotSupported  = errors.New("alias not supported for SSH source")
	ErrSSHDestAliasNotSupported    = errors.New("alias not supported for SSH destination")
	ErrUnknownField                = errors.New("unknown field")
	ErrProtocolNoSpecificPorts     = errors.New("protocol does not support specific ports")
	ErrTestEmptyAssertions         = errors.New("test entry must have at least one of \"accept\" or \"deny\"")
	ErrTestProtocolNotAllowed      = errors.New("test protocol must be tcp, udp, sctp, or empty")
	ErrTestDestinationMultiPort    = errors.New("test destination port must be a single port")
	ErrTestDestinationCIDR         = errors.New("test destination must be a single host, not a CIDR range")
	ErrAutogroupInternetTestDst    = errors.New("autogroup:internet not valid as a test destination")
	ErrSSHTestEmptySrc             = errors.New("SSH tests entry must have a non-empty src")
	ErrSSHTestEmptyDst             = errors.New("SSH tests entry must have at least one dst")
	ErrSSHTestDstUnknownTag        = errors.New("SSH tests dst contains unknown tag")
	ErrSSHTestDstDisallowedElement = errors.New("SSH tests dst contains disallowed element")
)

type resolved struct {
	ips netipx.IPSet
}

func newResolved(ipb *netipx.IPSetBuilder) (resolved, error) {
	ips, err := ipb.IPSet()
	if err != nil {
		return resolved{}, err
	}

View on GitHub (pinned to 565fd254d0)

Solutions

  1. Narrow the test dst to one exact port, e.g. "tag:server:443"
  2. Split multi-port assertions into one entry per port (or one per range endpoint)
  3. Keep ranges and wildcards in grants/ACLs only, not tests

Example fix

// before
"tests": [{"src": "user1", "accept": ["tag:web:80,443"]}]
// after
"tests": [
  {"src": "user1", "accept": ["tag:web:80"]},
  {"src": "user1", "accept": ["tag:web:443"]}
]
Defensive patterns

Strategy: validation

Validate before calling

// Check a tests dst string pins one port before adding it
func testDstSinglePort(dst string) bool {
    i := strings.LastIndex(dst, ":")
    if i == -1 { return false }
    port := dst[i+1:]
    if strings.ContainsAny(port, "-,") || port == "*" { return false }
    n, err := strconv.Atoi(port)
    return err == nil && n >= 1 && n <= 65535
}

Try / catch

if errors.Is(err, policyv2.ErrTestDestinationMultiPort) {
    // split the dst into one entry per port
}

Prevention

When it happens

Trigger: Test destinations such as "tag:server:*", "10.0.0.1:80,443", or "tag:server:1000-2000" — i.e. len(awp.Ports) != 1 or Ports[0].First != Ports[0].Last. Raised during validateTests for each accept/deny entry.

Common situations: Copy-pasting an ACL dst (where ranges and lists are fine) into a tests block; testing a whole service range in one entry instead of splitting; forgetting the tests block is stricter than grant destinations by design (comment at types.go:3192-3196).

Related errors


AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15). Data as JSON: /api/errors/c9003cc8bf004c20. Report an issue: GitHub.