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
- Fetch the raw body manually (curl --unix-socket .../check-ip-forwarding) and inspect it against the {"Warning": ...} shape
- Align client and tailscaled versions
- Remove any intermediary that rewrites local API responses
- 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
- Do not equate 'check unavailable' with 'forwarding misconfigured' in caller logic
- Keep client and daemon versions aligned when running subnet-router checks
- Avoid wrapping local API responses in transforming proxies or test doubles that drop JSON
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
- Parsing and encoding errors: unexpected token, malformed input — why parsers reject input and how to find the real culprit.
Related errors
- failed to unmarshal JSON into %T: %w
- no exit node IP to enable & prior exit node IP was never res
- no prior exit node to enable
- unexpected output: no delimiter
- both GatewayAddr and SelfAddr must be provided if one is
AI-assisted analysis of tailscale/tailscale@cfe32b8be6 (2026-08-15).
Data as JSON: /api/errors/5b4dc62451a0d763.
Report an issue: GitHub.