tstest/natlab: test home DERP is re-reported after a profile switch (#20051)

Add a vmtest that guards the fix in #20025: after an in-process control
client swap (profile switch / interactive re-login), magicsock's NetInfo
dedup cache (netInfoLast) must be cleared so the structurally-identical
post-switch NetInfo (same PreferredDERP, same NAT shape) is re-reported to
the new control session rather than suppressed as unchanged.

The test brings a node up, pins its home DERP so the reported NetInfo is
identical across the switch, records the home DERP the test control learned,
switches to a fresh login profile on the same control/network/NAT/DERP, and
asserts the control re-learns the same non-zero home DERP for the node's new
identity. Without ResetNetInfoLast the assertion times out at HomeDERP=0.

To support this, vnet now serves the test control on port 443 (TLS) in
addition to port 80: an immediate re-login makes a fresh noise dial, and
because the prior dial was recent the control client forces an HTTPS (443)
dial (controlhttp.Dialer.forceNoise443), which the harness previously did
not answer. The control endpoint gets its own self-signed cert (the existing
selfSignedDERPCert helper, renamed to the generic selfSignedCert); the cert
is not validated since control noise dials authenticate via the Noise
handshake, so it only needs a TLS peer to complete the forced 443 dial.

Add Env.ForcePreferredDERP and Env.Relogin helpers for the above.

Updates #20024

Signed-off-by: Mike O'Driscoll <mikeo@tailscale.com>
This commit is contained in:
Mike O'Driscoll
2026-06-08 12:29:39 -04:00
committed by GitHub
parent 618b606b46
commit 732bde6e86
3 changed files with 181 additions and 9 deletions
+62
View File
@@ -1166,6 +1166,68 @@ func (e *Env) RotateDiscoKey(n *Node) {
}
}
// ForcePreferredDERP pins n's home DERP to the given region via the
// "force-prefer-derp" debug action, so its reported NetInfo.PreferredDERP is
// deterministic. The force lives on the long-lived magicsock.Conn and so
// persists across an in-process profile switch. It fatals the test on error.
func (e *Env) ForcePreferredDERP(n *Node, region int) {
e.t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
b, err := json.Marshal(region)
if err != nil {
e.t.Fatalf("ForcePreferredDERP(%s): %v", n.name, err)
}
if err := n.agent.DebugActionBody(ctx, "force-prefer-derp", bytes.NewReader(b)); err != nil {
e.t.Fatalf("ForcePreferredDERP(%s, %d): %v", n.name, region, err)
}
}
// Relogin switches n to a fresh login profile on the same test control server,
// in-process (no daemon restart), so it comes up under a NEW node identity while
// keeping the same long-lived magicsock.Conn. This is the control-client swap
// that an interactive login or profile switch performs, and is what the
// home-DERP re-report fix guards (see [magicsock.Conn.ResetNetInfoLast]).
//
// It switches to an empty profile (the in-process control-client swap the
// LocalAPI PUT /profiles/ performs) and then logs back in with "tailscale up",
// which both points the new control client at the test control and drives
// registration to completion. It waits for the node to return to Running and
// fatals the test on error.
func (e *Env) Relogin(n *Node) {
e.t.Helper()
// Generous timeout: the profile switch triggers a fresh registration +
// netcheck + DERP connect, which is slow under TCG (no KVM).
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
defer cancel()
// Switch to a fresh, empty login profile. This runs the in-process control-
// client swap (resetForProfileChangeLocked -> setControlClientLocked) that
// clears the home-DERP dedup cache under test, while preserving the existing
// magicsock.Conn (and any forced home DERP from [Env.ForcePreferredDERP]).
if err := n.agent.SwitchToEmptyProfile(ctx); err != nil {
e.t.Fatalf("Relogin(%s): SwitchToEmptyProfile: %v", n.name, err)
}
// Log back in to the same test control. "tailscale up --login-server" points
// the new control client at the test control and drives registration to
// completion (testcontrol auto-authorizes), the same path Env.Start uses.
if err := e.tailscaleUp(ctx, n); err != nil {
e.t.Fatalf("Relogin(%s): up: %v", n.name, err)
}
if err := tstest.WaitFor(60*time.Second, func() error {
st, err := n.agent.Status(ctx)
if err != nil {
return err
}
if st.BackendState != "Running" {
return fmt.Errorf("backend state = %q, want Running", st.BackendState)
}
return nil
}); err != nil {
e.t.Fatalf("Relogin(%s): %v", n.name, err)
}
}
// RestartTailscaled signals tailscaled on n to die so that its supervisor
// (gokrazy) restarts it. It then waits for tailscaled to come back to the
// "Running" backend state. It fatals the test on error.