diff --git a/tstest/natlab/vmtest/vmtest.go b/tstest/natlab/vmtest/vmtest.go index 6f5abdc97..fdf4d4245 100644 --- a/tstest/natlab/vmtest/vmtest.go +++ b/tstest/natlab/vmtest/vmtest.go @@ -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. diff --git a/tstest/natlab/vmtest/vmtest_test.go b/tstest/natlab/vmtest/vmtest_test.go index ec6bfd86b..f06975a91 100644 --- a/tstest/natlab/vmtest/vmtest_test.go +++ b/tstest/natlab/vmtest/vmtest_test.go @@ -820,6 +820,98 @@ func checkDiscoRotated(t *testing.T, env *vmtest.Env, a, b, pingFrom, pingTo *vm return newDisco } +// TestHomeDERPReportedAfterRelogin is a regression test for the bug where, after +// an in-process control-client swap (an interactive login or profile switch), +// magicsock's NetInfo de-dup cache (netInfoLast) survived the swap. Because the +// post-relogin NetInfo was structurally identical (same PreferredDERP, same NAT +// shape), it was suppressed as unchanged and never re-reported to the new +// control session, so control never learned the node's home DERP and peers +// couldn't reach it over DERP. See ipn/ipnlocal: +// setControlClientLocked -> MagicConn().ResetNetInfoLast. +// +// The test brings a node up, records the home DERP region the test control +// learned, re-logs the node in (new node identity, same control/network/NAT/ +// DERP), and asserts control re-learns the same non-zero home DERP for the new +// identity. Without the fix this assertion times out at HomeDERP==0. +func TestHomeDERPReportedAfterRelogin(t *testing.T) { + env := vmtest.New(t) + net := env.AddNetwork("2.1.1.1", "192.168.1.1/24", vnet.EasyNAT) + n := env.AddNode("node", net, vmtest.OS(vmtest.Gokrazy)) + + baseStep := env.AddStep("Record initial home DERP from control") + switchStep := env.AddStep("Re-login (logout + up)") + verifyStep := env.AddStep("Verify home DERP re-reported to control after relogin") + + env.Start() + + cs := env.ControlServer() + + // Pin the home DERP region so the reported NetInfo (including PreferredDERP) + // is identical before and after the switch. natlab has two DERP regions with + // no latency differentiation, so the natural pick could differ across the + // switch; a changed PreferredDERP would NOT be de-duped and would mask the + // regression. The force persists on the long-lived magicsock.Conn across the + // in-process profile switch. + const region = 1 + env.ForcePreferredDERP(n, region) + + // Baseline: control learned the (forced) home DERP for the initial identity. + baseStep.Begin() + st := env.Status(n) + oldKey := st.Self.PublicKey + if err := tstest.WaitFor(30*time.Second, func() error { + cn := cs.Node(oldKey) + if cn == nil { + return fmt.Errorf("control has no node for initial key %v", oldKey.ShortString()) + } + if cn.HomeDERP != region { + return fmt.Errorf("control home DERP for initial identity = %d, want %d", cn.HomeDERP, region) + } + return nil + }); err != nil { + baseStep.Fatal(err) + } + t.Logf("[node] initial: key=%s homeDERP=%d", oldKey.ShortString(), region) + baseStep.End(nil) + + // Re-login on the same control/network/NAT/DERP. The new identity reports a + // structurally-identical NetInfo, which the buggy de-dup would have + // suppressed. (logout + up funnels through the same + // resetForProfileChangeLocked -> setControlClientLocked path as a + // localapi PUT /profiles/ switch.) + switchStep.Begin() + env.Relogin(n) + st2 := env.Status(n) + newKey := st2.Self.PublicKey + if newKey == oldKey { + switchStep.Fatalf("node key unchanged after relogin: %v", newKey.ShortString()) + } + t.Logf("[node] after relogin: key=%s", newKey.ShortString()) + switchStep.End(nil) + + // Regression assertion: control must re-learn the same non-zero home DERP for + // the new identity. Times out at HomeDERP==0 without ResetNetInfoLast. + verifyStep.Begin() + if err := tstest.WaitFor(60*time.Second, func() error { + cn := cs.Node(newKey) + if cn == nil { + return fmt.Errorf("control has no node for new key %v yet", newKey.ShortString()) + } + if cn.HomeDERP == 0 { + return fmt.Errorf("home DERP not re-reported after profile switch (HomeDERP=0)") + } + if cn.HomeDERP != region { + return fmt.Errorf("home DERP region changed across switch: was %d, now %d", region, cn.HomeDERP) + } + return nil + }); err != nil { + env.DumpStatus(n) + verifyStep.Fatal(err) + } + t.Logf("[node] home DERP %d re-reported to control after profile switch", region) + verifyStep.End(nil) +} + // TestMullvadExitNode verifies that a Tailscale client whose netmap contains // a plain-WireGuard exit node (the way Mullvad exit nodes are wired up by // the control plane) can route internet traffic through it, with the source diff --git a/tstest/natlab/vnet/vnet.go b/tstest/natlab/vnet/vnet.go index 867e6ebfa..0d0a4ab0e 100644 --- a/tstest/natlab/vnet/vnet.go +++ b/tstest/natlab/vnet/vnet.go @@ -374,13 +374,22 @@ func (n *network) acceptTCP(r *tcp.ForwarderRequest) { return } - if destPort == 80 && fakeControl.Match(destIP) { + if fakeControl.Match(destIP) && (destPort == 80 || destPort == 443) { r.Complete(false) tc := gonet.NewTCPConn(&wq, ep) context.AfterFunc(n.s.shutdownCtx, func() { tc.SetDeadline(time.Now()) }) + // The control client's noise dialer forces an HTTPS (port 443) dial when + // it made a noise dial recently — e.g. an immediate re-login or profile + // switch; see controlhttp.Dialer.forceNoise443. Serve the test control + // over TLS on 443 too so that path reaches it. (The cert isn't + // validated: noise dials authenticate via the Noise handshake.) + var ln net.Listener = netutil.NewOneConnListener(tc, nil) + if destPort == 443 { + ln = netutil.NewOneConnListener(tls.Server(tc, n.s.controlTLS), nil) + } hs := &http.Server{Handler: n.s.control} n.s.wg.Go(func() { - hs.Serve(netutil.NewOneConnListener(tc, nil)) + hs.Serve(ln) }) return } @@ -776,7 +785,7 @@ type derpServer struct { // want to exercise sha256-raw cert pinning can read the certSHA256Hex via // [Server.DERPCertSHA256Hex]. func newDERPServer(hostname string) *derpServer { - tlsConfig, certHex := selfSignedDERPCert(hostname) + tlsConfig, certHex := selfSignedCert(hostname) ds := &derpServer{ srv: derpserver.New(key.NewNode(), logger.Discard), tlsConfig: tlsConfig, @@ -790,10 +799,10 @@ func newDERPServer(hostname string) *derpServer { return ds } -// selfSignedDERPCert builds a self-signed ECDSA P-256 cert valid for hostname -// and returns a *tls.Config that serves it, along with the SHA-256 hex digest -// of the cert's DER bytes. -func selfSignedDERPCert(hostname string) (*tls.Config, string) { +// selfSignedCert builds a self-signed ECDSA P-256 cert valid for hostname and +// returns a *tls.Config that serves it, along with the SHA-256 hex digest of +// the cert's DER bytes (used by DERP for sha256-raw cert pinning). +func selfSignedCert(hostname string) (*tls.Config, string) { key, err := ecdsa.GenerateKey(elliptic.P256(), crand.Reader) if err != nil { panic(fmt.Sprintf("vnet: generating DERP cert key: %v", err)) @@ -840,7 +849,12 @@ type Server struct { networks set.Set[*network] networkByWAN *bart.Table[*network] - control *testcontrol.Server + control *testcontrol.Server + // controlTLS is a self-signed cert for serving the test control over HTTPS + // (port 443) in addition to plaintext HTTP. The control client does not + // validate this cert (noise dials authenticate via the Noise handshake, not + // the outer TLS); it exists only so the forced-443 dial path has a TLS peer. + controlTLS *tls.Config derps []*derpServer fakeACME *fakeACMEServer pcapWriter *pcapWriter @@ -888,6 +902,9 @@ func (s *Server) SetDHCPCallback(fn func(MAC, int, layers.DHCPMsgType, netip.Add // hostname verification succeeds for tests that pin via sha256-raw. var derpHostnames = []string{"derp1.tailscale", "derp2.tailscale"} +// controlHostname is the hostname the fake control server is reached at. +const controlHostname = "control.tailscale" + var derpMap = &tailcfg.DERPMap{ Regions: map[int]*tailcfg.DERPRegion{ 1: { @@ -933,7 +950,7 @@ func New(c *Config) (*Server, error) { control: &testcontrol.Server{ DERPMap: derpMap, - ExplicitBaseURL: "http://control.tailscale", + ExplicitBaseURL: "http://" + controlHostname, }, fakeACME: newFakeACMEServer("http://acme.example"), @@ -950,6 +967,7 @@ func New(c *Config) (*Server, error) { return nil } s.fakeACME.lookupTXT = s.lookupTXT + s.controlTLS, _ = selfSignedCert(controlHostname) for _, host := range derpHostnames { s.derps = append(s.derps, newDERPServer(host)) }