tailscale/tailscale · warning

invalid JSON from check-ip-forwarding: %w

Error message

invalid JSON from check-ip-forwarding: %w

What it means

Returned by Client.CheckIPForwarding (client/local/local.go:898) when the body from GET /localapi/v0/check-ip-forwarding — already a 200, since get200 succeeded — cannot be unmarshaled into the expected {Warning string} shape. The json error is wrapped, so offset/reason details survive. The endpoint's actual findings arrive as a Warning string returned as a separate error, not this one.

Source

Thrown at client/local/local.go:898

// CheckIPForwarding asks the local Tailscale daemon whether it looks like the
// machine is properly configured to forward IP packets as a subnet router
// or exit node.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) CheckIPForwarding(ctx context.Context) error {
	if !buildfeatures.HasAdvertiseRoutes {
		return nil
	}
	body, err := lc.get200(ctx, "/localapi/v0/check-ip-forwarding")
	if err != nil {
		return err
	}
	var jres struct {
		Warning string
	}
	if err := json.Unmarshal(body, &jres); err != nil {
		return fmt.Errorf("invalid JSON from check-ip-forwarding: %w", err)
	}
	if jres.Warning != "" {
		return errors.New(jres.Warning)
	}
	return nil
}

// CheckUDPGROForwarding asks the local Tailscale daemon whether it looks like
// the machine is optimally configured to forward UDP packets as a subnet router
// or exit node.
//
// API maturity: this method is not considered a stable API and is
// subject to change between releases.
func (lc *Client) CheckUDPGROForwarding(ctx context.Context) error {
	body, err := lc.get200(ctx, "/localapi/v0/check-udp-gro-forwarding")
	if err != nil {
		return err
	}

View on GitHub (pinned to cfe32b8be6)

Solutions

  1. Fetch the raw body manually (curl --unix-socket .../check-ip-forwarding) and inspect it against the {"Warning": ...} shape
  2. Align client and tailscaled versions
  3. Remove any intermediary that rewrites local API responses
  4. Treat the failure as 'check unavailable' rather than 'forwarding broken' in caller logic
Defensive patterns

Strategy: try-catch

Type guard

func isCheckIPForwardingDecode(err error) bool {
	var se *json.SyntaxError
	return strings.Contains(err.Error(), "invalid JSON from check-ip-forwarding") && errors.As(err, &se)
}

Try / catch

if err := lc.CheckIPForwarding(ctx); err != nil {
	if isCheckIPForwardingDecode(err) {
		// 200 but unparseable body: version skew or an intermediary — do not
		// report forwarding as broken
		log.Printf("check-ip-forwarding unreadable: %v", err)
		return nil
	}
	return err // real warning from the daemon
}

Prevention

When it happens

Trigger: A tailscaled version returning a different payload for check-ip-forwarding (schema drift); an empty or HTML body served with 200 by a proxy or wrapper transport in front of the local API; daemon internals erroring mid-response.

Common situations: Version-skewed subnet-router/exit-node check tooling; instrumented transports mangling the response; custom local API servers in tests returning wrong bodies.

Understand the failure class

Related errors


AI-assisted analysis of tailscale/tailscale@cfe32b8be6 (2026-08-15). Data as JSON: /api/errors/5b4dc62451a0d763. Report an issue: GitHub.