Skip to content

fix(clientinfo): nil-guard the dhcp table in SetSelfIP to stop the netmon panic loop#306

Open
SAY-5 wants to merge 1 commit intoControl-D-Inc:mainfrom
SAY-5:fix/setselfip-nil-dhcp
Open

fix(clientinfo): nil-guard the dhcp table in SetSelfIP to stop the netmon panic loop#306
SAY-5 wants to merge 1 commit intoControl-D-Inc:mainfrom
SAY-5:fix/setselfip-nil-dhcp

Conversation

@SAY-5
Copy link
Copy Markdown

@SAY-5 SAY-5 commented Apr 18, 2026

What

Table.SetSelfIP in internal/clientinfo/client_info.go unconditionally dereferences t.dhcp:

func (t *Table) SetSelfIP(ip string) {
    t.selfIPLock.Lock()
    defer t.selfIPLock.Unlock()
    t.selfIP = ip
    t.dhcp.selfIP = t.selfIP
    t.dhcp.addSelf()
}

But Table.init() only allocates t.dhcp on two paths:

  1. The custom-client-ID path, which calls initSelfDiscover() and builds a fresh &dhcp{...}.
  2. The if t.discoverDHCP() branch farther down.

Any config that disables DHCP discovery (discover_dhcp = "false") and does not set a custom client ID never assigns t.dhcp, so it stays nil for the lifetime of the process. The first time monitorNetworkChanges sees a carrier/address event and calls SetSelfIP, the netmon goroutine panics:

panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0xd0 pc=0x6797a4]

internal/clientinfo.(*Table).SetSelfIP(...)
        internal/clientinfo/client_info.go:176 +0x94
cmd/cli.(*prog).monitorNetworkChanges.func2(...)
        cmd/cli/dns_proxy.go:1577 +0xfe0
created by tailscale.com/net/netmon.(*Monitor).handlePotentialChange ...

Because monitorNetworkChanges fires on every carrier/address transition (WiFi reconnect, DHCP renewal, VPN up/down), this is not a one-off crash — #305 documents 96 panics on a single ARM64 system over 28 days, all with discover_dhcp = "false". Every boot and every WiFi reconnect reliably reproduces it.

Fix

Early-return from SetSelfIP when t.dhcp is nil:

func (t *Table) SetSelfIP(ip string) {
    t.selfIPLock.Lock()
    defer t.selfIPLock.Unlock()
    t.selfIP = ip
    if t.dhcp == nil {
        return
    }
    t.dhcp.selfIP = t.selfIP
    t.dhcp.addSelf()
}

t.selfIP is the single source of truth for SelfIP() (RLock'd reader), and has already been updated above. addSelf() only exists to mirror that into the DHCP resolver's self-entry table — when we have no DHCP resolver, there is nothing to mirror into.

Configs with DHCP discovery on, or with a custom client ID, are unaffected: t.dhcp is non-nil through init(), the new branch skips, and the rest of the method runs exactly as before.

Why here and not at the init site

Allocating an empty &dhcp{} in init() even when DHCP discovery is disabled would pull the zero-value DHCP resolver into t.ipResolvers / t.macResolvers / t.hostnameResolvers and change the resolver set for configs that explicitly opted out. A guard at the write site is the narrowest fix that matches the existing "don't do DHCP if discover_dhcp is false" semantics.

Test plan

  • Reproduce on main with a config that sets discover_dhcp = "false", discover_arp = "false", etc., and no cd_uid. Trigger a carrier change (e.g. ip link set <iface> down && ip link set <iface> up) — ctrld panics with the stack trace above.
  • Rebuild with this patch. SetSelfIP returns cleanly, SelfIP() returns the latest IP, carrier changes no longer crash the binary.

Fixes #305

…tmon panic loop

Table.SetSelfIP unconditionally dereferenced t.dhcp:

	t.dhcp.selfIP = t.selfIP
	t.dhcp.addSelf()

but Table.init() only assigns t.dhcp when DHCP discovery is on AND
there is no custom client ID (the custom-ID path takes
initSelfDiscover() and allocates its own dhcp; disabling DHCP and
supplying no custom ID leaves t.dhcp as nil). Any config with
discover_dhcp = "false" and no cd_uid therefore panics the first time
SetSelfIP is called, which is from monitorNetworkChanges on every
netmon event:

	panic: runtime error: invalid memory address or nil pointer dereference
	[signal SIGSEGV: segmentation violation code=0x1 addr=0xd0 ...]
	internal/clientinfo.(*Table).SetSelfIP(...)
	    internal/clientinfo/client_info.go:176 +0x94
	cmd/cli.(*prog).monitorNetworkChanges.func2(...)
	    cmd/cli/dns_proxy.go:1577 +0xfe0

Because monitorNetworkChanges fires on every carrier/address change,
real deployments see a crash per DHCP acquisition, WiFi reconnect, or
VPN up/down. The reporter of Control-D-Inc#305 recorded 96 crashes across 28 days
on one ARM64 box with discover_dhcp = "false".

When t.dhcp is nil we have nothing to do: the selfIP cache on t itself
has already been updated above, and addSelf() exists only to push that
into the dhcp resolver's self-mapping. Return early so clients with
DHCP discovery disabled simply use the selfIP we just recorded instead
of tripping SIGSEGV.

Fixes Control-D-Inc#305

Signed-off-by: SAY-5 <SAY-5@users.noreply.github.com>
@cuonglm
Copy link
Copy Markdown
Collaborator

cuonglm commented Apr 20, 2026

Thanks for sending over this fix!

Because our current development workflow is tied closely to an internal issue tracker, we aren't able to merge external pull requests directly at this time. However, we’ve noted the issue and will implement a fix in an upcoming release. We appreciate the contribution!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

panic: nil pointer dereference in SetSelfIP()

2 participants