juanfont/headscale · error

saving node(%d) after adding IPs: %w

Error message

saving node(%d) after adding IPs: %w

What it means

After computing new or removed IPs for a node, backfill persists only the ipv4/ipv6 columns via Updates(Select(...)). This error is that UPDATE failing at the DB layer — constraint violation, lock, or connectivity. The comment in code notes Select() is deliberate so removing an IP (writing nil) is not skipped as a zero value.

Source

Thrown at hscontrol/db/ip.go:374

				ret = append(ret, fmt.Sprintf("removing IPv4 %q from Node(%d) %q", node.IPv4.String(), node.ID, node.Hostname))
				node.IPv4 = nil
				changed = true
			}

			// IPv6 prefix is not set, but node has IP, remove
			if i.prefix6 == nil && node.IPv6 != nil {
				ret = append(ret, fmt.Sprintf("removing IPv6 %q from Node(%d) %q", node.IPv6.String(), node.ID, node.Hostname))
				node.IPv6 = nil
				changed = true
			}

			if changed {
				// Use Updates() with Select() to only update IP fields, avoiding overwriting
				// other fields like Expiry. We need Select() because Updates() alone skips
				// zero values, but we DO want to update IPv4/IPv6 to nil when removing them.
				err := tx.Model(node).Select("ipv4", "ipv6").Updates(node).Error
				if err != nil {
					return fmt.Errorf("saving node(%d) after adding IPs: %w", node.ID, err)
				}
			}
		}

		return nil
	})

	return ret, err
}

func (i *IPAllocator) FreeIPs(ips []netip.Addr) {
	i.mu.Lock()
	defer i.mu.Unlock()

	for _, ip := range ips {
		i.usedIPs.Remove(ip)
	}
}

View on GitHub (pinned to 565fd254d0)

Solutions

  1. Inspect the wrapped error for the specific constraint or lock message
  2. Query for duplicate ipv4/ipv6 values across nodes and fix the offending rows
  3. If lock-related on SQLite, serialize startup backfill away from heavy registration load
  4. Re-run; backfill re-evaluates each node idempotently
Defensive patterns

Strategy: try-catch

Try / catch

if _, err := db.BackfillNodeIPs(ipAlloc); err != nil {
	if isTransientDBError(err) {
		// retry at next startup; backfill is idempotent
	}
	log.Error().Err(err).Msg("backfill save failed")
}

Prevention

When it happens

Trigger: Unique index conflict when another row already holds the allocated IP; SQLite lock timeout; connection loss mid-transaction; a manually inserted row violating assumptions.

Common situations: Two nodes assigned the same IP after a partial restore; concurrent registration racing backfill on SQLite; DB volume full.

Related errors


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