{"record":{"id":"65d10e2255815bea","repo":"juanfont/headscale","slug":"ssh-tests-dst-contains-unknown-tag","errorCode":null,"errorMessage":"SSH tests dst contains unknown tag","messagePattern":"SSH tests dst contains unknown tag","errorType":"validation","errorClass":"ErrSSHTestDstUnknownTag","httpStatus":null,"severity":"error","filePath":"hscontrol/policy/v2/types.go","lineNumber":154,"sourceCode":"\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\n\treturn resolved{ips: *ips}, nil\n}\n\nfunc newResolvedAddresses(ips *netipx.IPSet, err error) (ResolvedAddresses, error) {\n\tif ips == nil {","sourceCodeStart":136,"sourceCodeEnd":172,"githubUrl":"https://github.com/juanfont/headscale/blob/565fd254d06c4c7f9a8cad1714a43445c79ba420/hscontrol/policy/v2/types.go#L136-L172","documentation":"ErrSSHTestDstUnknownTag is returned by validateSSHTestDestination (hscontrol/policy/v2/types.go:3341-3343) when an sshTests dst names a tag that is not declared in tagOwners. Tag entries must exist before they can be SSH test targets. Notably, a dst like \"tag:server:22\" (with a port suffix) also lands here: the parser only checks the tag: prefix, so the port suffix makes the lookup miss and it surfaces as an unknown tag (comment at types.go:3334-3336).","triggerScenarios":"sshTests dst \"tag:prod\" when tagOwners has no \"tag:prod\" key; or dst \"tag:server:22\" where the :22 suffix turns the whole string into a non-declared tag. Raised when pol.TagOwners.Contains(a) fails (or pol is nil).","commonSituations":"Typos in tag names between sshTests and tagOwners; renaming a tag in tagOwners but not in sshTests; habitually adding :22 port to SSH test destinations (SSH tests take no ports); running validation before tagOwners is populated.","solutions":["Check tagOwners declares the exact tag from the error message (quoted in it)","Fix typos or rename the sshTests dst to match the declared tag","Remove any :port suffix from ssh dst entries — SSH tests take bare hosts/tags only","If the tag is intentional, add it to tagOwners with an owner"],"exampleFix":"// before\n\"tagOwners\": {\"tag:server\": [\"group:admin\"]}\n\"sshTests\": [{\"src\": \"group:admin\", \"dst\": [\"tag:server:22\"], \"accept\": [\"root\"]}]\n// after\n\"tagOwners\": {\"tag:server\": [\"group:admin\"]}\n\"sshTests\": [{\"src\": \"group:admin\", \"dst\": [\"tag:server\"], \"accept\": [\"root\"]}]","handlingStrategy":"type-guard","validationCode":"// Ensure every tag used in sshTests dst exists in tagOwners before saving\nfunc sshTestTagsDeclared(pol *Policy) bool {\n    for _, t := range pol.SSHTests {\n        for _, dst := range t.Dst {\n            if tag, ok := dst.(*Tag); ok {\n                if err := pol.TagOwners.Contains(tag); err != nil { return false }\n            }\n        }\n    }\n    return true\n}","typeGuard":"// Strip port suffixes: sshTests dst must be a bare tag or IP\nfunc isBareSSHTestDst(dst string) bool {\n    // \"tag:server:22\" has two colons; valid forms are \"tag:x\" or an IP/host\n    return strings.Count(dst, \":\") <= 1 || strings.Contains(dst, \"]\")\n}","tryCatchPattern":"if errors.Is(err, policyv2.ErrSSHTestDstUnknownTag) {\n    // message quotes the tag: add it to tagOwners or fix the name; strip any :port suffix\n}","preventionTips":["SSH test destinations take no ports — never append :22","Keep tag names consistent between tagOwners and sshTests (single source/renames in one commit)","Run validation immediately after renaming tags"],"tags":["policy","ssh","tests","tags","validation"],"backgroundTag":null,"analyzedSha":"565fd254d06c4c7f9a8cad1714a43445c79ba420","analyzedAt":"2026-08-15T13:12:30.133Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}