juanfont/headscale · error · ErrNodeAttrsIPPoolReserved

%w: %q overlaps %q

Error message

%w: %q overlaps %q

What it means

Thrown by validateNodeAttrIPPool (hscontrol/policy/v2/types.go:2102). The nodeAttrs ipPool prefix lies inside CGNAT but overlaps a Tailscale-reserved subrange: 100.100.100.0/24 (MagicDNS/TSMP) or 100.115.92.0/23 (Quad100/IPN service), defined in reservedTSRanges (types.go:2015). These subranges carry infrastructure services and must not be handed out as pool space. Like all ipPool entries it is additionally rejected by ErrNodeAttrIPPoolUnsupported because headscale lacks the IP allocator.

Source

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

	return nil
}

// validateNodeAttrIPPool rejects ipPool entries outside the CGNAT range or
// overlapping the Tailscale-reserved subranges (MagicDNS, Quad100/IPN). A
// [netip.Prefix] is considered "within" CGNAT when it is at least as specific as
// 100.64.0.0/10 and its first address lies inside it.
func validateNodeAttrIPPool(prefix netip.Prefix) error {
	cgnat := tsaddr.CGNATRange()
	masked := prefix.Masked()

	if masked.Bits() < cgnat.Bits() || !cgnat.Contains(masked.Addr()) {
		return fmt.Errorf("%w: %q", ErrNodeAttrsIPPoolOutOfRange, prefix)
	}

	for _, reserved := range reservedTSRanges {
		if masked.Overlaps(reserved) {
			return fmt.Errorf("%w: %q overlaps %q", ErrNodeAttrsIPPoolReserved, prefix, reserved)
		}
	}

	return nil
}

func validateAutogroupForSSHSrc(src *AutoGroup) error {
	if src == nil {
		return nil
	}

	if src.Is(AutoGroupInternet) {
		return ErrAutogroupInternetSrc
	}

	if !slices.Contains(autogroupForSSHSrc, *src) {
		return fmt.Errorf("%w: %q, can be %v", ErrAutogroupNotSupportedSSHSrc, *src, autogroupForSSHSrc)
	}

View on GitHub (pinned to 565fd254d0)

Solutions

  1. Remove the ipPool key entirely — headscale does not implement ipPool (issue 2912)
  2. If you must keep the entry for SaaS round-tripping, carve a pool that excludes both reserved blocks, e.g. 100.64.0.0/11 covers 100.64.0.0–100.95.255.255 which avoids both

Example fix

// before
"ipPool": ["100.64.0.0/10"]

// after
(no ipPool key; or "100.64.0.0/11")
Defensive patterns

Strategy: validation

Validate before calling

var reserved = []netip.Prefix{netip.MustParsePrefix("100.100.100.0/24"), netip.MustParsePrefix("100.115.92.0/23")}
func overlapsReserved(p netip.Prefix) bool {
    m := p.Masked()
    for _, r := range reserved { if m.Overlaps(r) { return true } }
    return false
}

Type guard

func isSafeIPPool(p netip.Prefix) bool {
    c := tsaddr.CGNATRange(); m := p.Masked()
    if m.Bits() < c.Bits() || !c.Contains(m.Addr()) { return false }
    for _, r := range reserved { if m.Overlaps(r) { return false } }
    return true
}

Try / catch

if err := pol.Validate(); errors.Is(err, policyv2.ErrNodeAttrsIPPoolReserved) { /* shrink pool below 100.100.100.0 */ }

Prevention

When it happens

Trigger: A nodeAttrs ipPool such as "100.64.0.0/10" (the whole CGNAT range, which necessarily covers both reserved blocks), "100.100.100.0/24", or "100.115.92.0/23" in a validated policy.

Common situations: Using the full /10 as a pool for simplicity; guessing a pool that happens to cover the Quad100 address 100.100.100.100 or the IPN range; copying example configs written for the SaaS ip-pool KB article.

Related errors


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