{"record":{"id":"c9003cc8bf004c20","repo":"juanfont/headscale","slug":"test-destination-port-must-be-a-single-port","errorCode":null,"errorMessage":"test destination port must be a single port","messagePattern":"test destination port must be a single port","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"hscontrol/policy/v2/types.go","lineNumber":149,"sourceCode":"\tErrInvalidProtocolNumber       = errors.New(\"invalid protocol number\")\n\tErrProtocolLeadingZero         = errors.New(\"leading 0 not permitted in protocol number\")\n\tErrProtocolOutOfRange          = errors.New(\"protocol number out of range (0-255)\")\n\tErrAutogroupNotSupported       = errors.New(\"autogroup not supported in headscale\")\n\tErrAutogroupInternetSrc        = errors.New(\"autogroup:internet can only be used in ACL destinations\")\n\tErrAutogroupSelfSrc            = errors.New(\"\\\"autogroup:self\\\" not valid on the src side of a rule\")\n\tErrAutogroupNotSupportedACLSrc = errors.New(\"autogroup not supported for ACL sources\")\n\tErrAutogroupNotSupportedACLDst = errors.New(\"autogroup not supported for ACL destinations\")\n\tErrAutogroupDangerAllDst       = errors.New(\"cannot use autogroup:danger-all as a dst\")\n\tErrAutogroupNotSupportedSSHSrc = errors.New(\"autogroup not supported for SSH sources\")\n\tErrAutogroupNotSupportedSSHDst = errors.New(\"autogroup not supported for SSH destinations\")\n\tErrHostNotDefined              = errors.New(\"host not defined in policy\")\n\tErrSSHSourceAliasNotSupported  = errors.New(\"alias not supported for SSH source\")\n\tErrSSHDestAliasNotSupported    = errors.New(\"alias not supported for SSH destination\")\n\tErrUnknownField                = errors.New(\"unknown field\")\n\tErrProtocolNoSpecificPorts     = errors.New(\"protocol does not support specific ports\")\n\tErrTestEmptyAssertions         = errors.New(\"test entry must have at least one of \\\"accept\\\" or \\\"deny\\\"\")\n\tErrTestProtocolNotAllowed      = errors.New(\"test protocol must be tcp, udp, sctp, or empty\")\n\tErrTestDestinationMultiPort    = errors.New(\"test destination port must be a single port\")\n\tErrTestDestinationCIDR         = errors.New(\"test destination must be a single host, not a CIDR range\")\n\tErrAutogroupInternetTestDst    = errors.New(\"autogroup:internet not valid as a test destination\")\n\tErrSSHTestEmptySrc             = errors.New(\"SSH tests entry must have a non-empty src\")\n\tErrSSHTestEmptyDst             = errors.New(\"SSH tests entry must have at least one dst\")\n\tErrSSHTestDstUnknownTag        = errors.New(\"SSH tests dst contains unknown tag\")\n\tErrSSHTestDstDisallowedElement = errors.New(\"SSH tests dst contains disallowed element\")\n)\n\ntype resolved struct {\n\tips netipx.IPSet\n}\n\nfunc newResolved(ipb *netipx.IPSetBuilder) (resolved, error) {\n\tips, err := ipb.IPSet()\n\tif err != nil {\n\t\treturn resolved{}, err\n\t}\n","sourceCodeStart":131,"sourceCodeEnd":167,"githubUrl":"https://github.com/juanfont/headscale/blob/565fd254d06c4c7f9a8cad1714a43445c79ba420/hscontrol/policy/v2/types.go#L131-L167","documentation":"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.","triggerScenarios":"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.","commonSituations":"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).","solutions":["Narrow the test dst to one exact port, e.g. \"tag:server:443\"","Split multi-port assertions into one entry per port (or one per range endpoint)","Keep ranges and wildcards in grants/ACLs only, not tests"],"exampleFix":"// before\n\"tests\": [{\"src\": \"user1\", \"accept\": [\"tag:web:80,443\"]}]\n// after\n\"tests\": [\n  {\"src\": \"user1\", \"accept\": [\"tag:web:80\"]},\n  {\"src\": \"user1\", \"accept\": [\"tag:web:443\"]}\n]","handlingStrategy":"validation","validationCode":"// Check a tests dst string pins one port before adding it\nfunc testDstSinglePort(dst string) bool {\n    i := strings.LastIndex(dst, \":\")\n    if i == -1 { return false }\n    port := dst[i+1:]\n    if strings.ContainsAny(port, \"-,\") || port == \"*\" { return false }\n    n, err := strconv.Atoi(port)\n    return err == nil && n >= 1 && n <= 65535\n}","typeGuard":null,"tryCatchPattern":"if errors.Is(err, policyv2.ErrTestDestinationMultiPort) {\n    // split the dst into one entry per port\n}","preventionTips":["Write tests destinations as host:port with exactly one numeric port","Split range/list assertions into multiple test entries","Keep multi-port forms in grants only"],"tags":["policy","tests","ports","validation"],"backgroundTag":null,"analyzedSha":"565fd254d06c4c7f9a8cad1714a43445c79ba420","analyzedAt":"2026-08-15T13:12:30.133Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}