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

  1. Register the tag in tagOwners, e.g. "tag:egress": ["group:netops"], and ensure the subnet-router node actually carries that tag
  2. Remove the via entry if the tag is retired
  3. 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

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.