juanfont/headscale · error
tag %q not found
Error message
tag %q not found
What it means
Thrown in Policy.validate()'s grants loop (hscontrol/policy/v2/types.go:2654) when a via tag ("via" array of a grant, used for subnet-route through-tag matching) is not declared in tagOwners. The wording 'tag %q not found' deliberately matches Tailscale SaaS (per the comment at :2650) and differs from the ACL wording; it is a dynamic message with no sentinel error, so callers cannot use errors.Is for it — a comment marks this intentional (nolint:err113).
Source
Thrown at hscontrol/policy/v2/types.go:2654
if err != nil {
errs = append(errs, err)
}
case *Tag:
err := p.TagOwners.Contains(h)
if err != nil {
errs = append(errs, err)
}
}
}
// Validate via tags. Wording matches Tailscale SaaS
// ("tag %q not found"), which differs from the ACL-src
// wording ("src=tag not found: %q").
for _, viaTag := range grant.Via {
err := p.TagOwners.Contains(&viaTag)
if err != nil {
//nolint:err113 // SaaS-aligned dynamic phrasing; no caller does errors.Is.
errs = append(errs, fmt.Errorf("tag %q not found", viaTag))
}
}
// Validate grant-specific source/destination combinations.
// Grants are stricter than ACLs: wildcard (*) src with autogroup:self
// dst is rejected because * includes tags, and tags cannot use
// autogroup:self.
err := validateGrantSrcDstCombination(grant.Sources, grant.Destinations)
if err != nil {
errs = append(errs, err)
}
}
for _, na := range p.NodeAttrs {
// SaaS accepts entries with neither attr nor ipPool (they
// compile to a no-op); headscale follows suit so policies
// captured against SaaS round-trip cleanly.
for _, target := range na.Targets {View on GitHub (pinned to 565fd254d0)
Solutions
- Register the tag in tagOwners, e.g. "tag:egress": ["group:netops"], and ensure the subnet-router node actually carries that tag
- Remove the via entry if the tag is retired
- Because there is no sentinel, match this failure by the 'tag %q not found' message prefix or simply read the joined validation output
Example fix
// before
"grants": [{ "src": ["group:eng"], "via": ["egress"], "dst": ["10.0.0.0/8"], "ip": ["tcp/80"] }]
// after
"tagOwners": { "tag:egress": ["group:netops"] },
"grants": [{ "src": ["group:eng"], "via": ["egress"], "dst": ["10.0.0.0/8"], "ip": ["tcp/80"] }] Defensive patterns
Strategy: validation
Validate before calling
func grantViaTagsDeclared(p *policyv2.Policy) []string {
var missing []string
for _, g := range p.Grants {
for _, via := range g.Via {
if err := p.TagOwners.Contains(&via); err != nil { missing = append(missing, string(via)) }
}
}
return missing
} Type guard
func isViaTag(s string) bool { return !strings.ContainsAny(s, ":/") && net.ParseIP(s) == nil } // via entries are bare tag names Try / catch
// No sentinel exists (SaaS-aligned dynamic message); match textually.
if err := pol.Validate(); strings.Contains(err.Error(), "tag \"" /* + name */ + "\" not found") { /* register tag in tagOwners */ } Prevention
- Via tags are bare names (no tag: prefix) but must still exist in tagOwners
- A via tag must also be applied to a real subnet-router node or no traffic matches
When it happens
Trigger: A grants entry with "via": ["egress"] where "egress" is not a tagOwners key. Via tags steer traffic through nodes bearing the tag (subnet routers), so an undeclared tag can never match a node.
Common situations: Configuring 4via6/subnet-router grants and forgetting to register the router tag; renaming router tags in tagOwners without updating via references; assuming via does lookup in hosts or DNS rather than tagOwners.
Related errors
AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15).
Data as JSON: /api/errors/2ba51bd92b850006.
Report an issue: GitHub.