diff --git a/hscontrol/servertest/ping_test.go b/hscontrol/servertest/ping_test.go new file mode 100644 index 00000000..4170ce02 --- /dev/null +++ b/hscontrol/servertest/ping_test.go @@ -0,0 +1,169 @@ +package servertest_test + +import ( + "testing" + "time" + + "github.com/juanfont/headscale/hscontrol/servertest" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/types/change" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "tailscale.com/tailcfg" +) + +// TestPingNode verifies the full ping round-trip: the server sends a +// PingRequest via MapResponse, the real controlclient.Direct handles it +// by making a HEAD request back over Noise, and the ping tracker records +// the latency. +func TestPingNode(t *testing.T) { + t.Parallel() + + h := servertest.NewHarness(t, 1) + + nm := h.Client(0).Netmap() + require.NotNil(t, nm) + require.True(t, nm.SelfNode.Valid()) + + nodeID := types.NodeID(nm.SelfNode.ID()) //nolint:gosec + + st := h.Server.State() + pingID, responseCh := st.RegisterPing(nodeID) + + defer st.CancelPing(pingID) + + callbackURL := h.Server.URL + "/machine/ping-response?id=" + pingID + h.Server.App.Change(change.PingNode(nodeID, &tailcfg.PingRequest{ + URL: callbackURL, + Log: true, + })) + + select { + case latency := <-responseCh: + assert.Positive(t, latency, "latency should be positive, got %v", latency) + assert.Less(t, latency, 10*time.Second, "latency should be reasonable, got %v", latency) + case <-time.After(15 * time.Second): + t.Fatal("ping response not received within 15s") + } +} + +// TestPingDisconnectedNode verifies that pinging a disconnected node +// results in no response (the channel never receives). +func TestPingDisconnectedNode(t *testing.T) { + t.Parallel() + + h := servertest.NewHarness(t, 1) + + nm := h.Client(0).Netmap() + require.NotNil(t, nm) + + nodeID := types.NodeID(nm.SelfNode.ID()) //nolint:gosec + + // Disconnect the client. + h.Client(0).Disconnect(t) + + st := h.Server.State() + pingID, responseCh := st.RegisterPing(nodeID) + + defer st.CancelPing(pingID) + + callbackURL := h.Server.URL + "/machine/ping-response?id=" + pingID + h.Server.App.Change(change.PingNode(nodeID, &tailcfg.PingRequest{ + URL: callbackURL, + Log: true, + })) + + select { + case <-responseCh: + t.Fatal("should not receive response from disconnected node") + case <-time.After(3 * time.Second): + // Expected: no response. + } +} + +// TestPingTwoSameNode verifies that two concurrent pings to the same +// node complete independently. +func TestPingTwoSameNode(t *testing.T) { + t.Parallel() + + h := servertest.NewHarness(t, 1) + + nm := h.Client(0).Netmap() + require.NotNil(t, nm) + + nodeID := types.NodeID(nm.SelfNode.ID()) //nolint:gosec + + st := h.Server.State() + + pingID1, ch1 := st.RegisterPing(nodeID) + defer st.CancelPing(pingID1) + + pingID2, ch2 := st.RegisterPing(nodeID) + defer st.CancelPing(pingID2) + + require.NotEqual(t, pingID1, pingID2) + + // Send both PingRequests. + url1 := h.Server.URL + "/machine/ping-response?id=" + pingID1 + url2 := h.Server.URL + "/machine/ping-response?id=" + pingID2 + + h.Server.App.Change(change.PingNode(nodeID, &tailcfg.PingRequest{ + URL: url1, + })) + h.Server.App.Change(change.PingNode(nodeID, &tailcfg.PingRequest{ + URL: url2, + })) + + timeout := time.After(15 * time.Second) + + var got1, got2 bool + + for !got1 || !got2 { + select { + case latency := <-ch1: + assert.GreaterOrEqual(t, latency, time.Duration(0)) + + got1 = true + case latency := <-ch2: + assert.GreaterOrEqual(t, latency, time.Duration(0)) + + got2 = true + case <-timeout: + t.Fatalf("timed out: got1=%v got2=%v", got1, got2) + } + } +} + +// TestPingResolveByHostname verifies that ResolveNode can find a node +// by hostname and that the resolved node can be pinged. +func TestPingResolveByHostname(t *testing.T) { + t.Parallel() + + h := servertest.NewHarness(t, 1, servertest.WithDefaultClientOptions( + servertest.WithHostname("my-test-host"), + )) + + st := h.Server.State() + + // Resolve by hostname. + node, ok := st.ResolveNode("my-test-host") + require.True(t, ok, "should resolve node by hostname") + + nodeID := node.ID() + + pingID, responseCh := st.RegisterPing(nodeID) + defer st.CancelPing(pingID) + + callbackURL := h.Server.URL + "/machine/ping-response?id=" + pingID + h.Server.App.Change(change.PingNode(nodeID, &tailcfg.PingRequest{ + URL: callbackURL, + Log: true, + })) + + select { + case latency := <-responseCh: + assert.Positive(t, latency) + case <-time.After(15 * time.Second): + t.Fatal("ping response not received") + } +} diff --git a/hscontrol/state/ping_test.go b/hscontrol/state/ping_test.go new file mode 100644 index 00000000..595688fe --- /dev/null +++ b/hscontrol/state/ping_test.go @@ -0,0 +1,156 @@ +package state + +import ( + "sync" + "testing" + "time" + + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPingTracker_RegisterComplete(t *testing.T) { + pt := newPingTracker() + + pingID, ch := pt.register(types.NodeID(1)) + assert.NotEmpty(t, pingID) + + // Complete in a goroutine since it sends on the channel. + go func() { + assert.True(t, pt.complete(pingID)) + }() + + select { + case latency := <-ch: + assert.GreaterOrEqual(t, latency, time.Duration(0), "latency should be non-negative") + assert.Less(t, latency, 5*time.Second, "latency should be reasonable") + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for ping response") + } +} + +func TestPingTracker_CompleteUnknown(t *testing.T) { + pt := newPingTracker() + assert.False(t, pt.complete("nonexistent")) +} + +func TestPingTracker_CancelThenComplete(t *testing.T) { + pt := newPingTracker() + + pingID, ch := pt.register(types.NodeID(1)) + pt.cancel(pingID) + assert.False(t, pt.complete(pingID)) + + // Channel should never receive. + select { + case <-ch: + t.Fatal("channel should not receive after cancel") + case <-time.After(50 * time.Millisecond): + // Expected: no response. + } +} + +func TestPingTracker_DoubleComplete(t *testing.T) { + pt := newPingTracker() + + pingID, ch := pt.register(types.NodeID(1)) + assert.True(t, pt.complete(pingID)) + + // Drain the channel. + <-ch + + // Second complete should return false. + assert.False(t, pt.complete(pingID)) +} + +func TestPingTracker_ConcurrentDifferentIDs(t *testing.T) { + pt := newPingTracker() + + const count = 10 + + ids := make([]string, count) + chs := make([]<-chan time.Duration, count) + + for i := range count { + ids[i], chs[i] = pt.register(types.NodeID(i + 1)) + } + + // Complete in reverse order concurrently. + var wg sync.WaitGroup + + for i := count - 1; i >= 0; i-- { + wg.Add(1) + + go func(idx int) { + defer wg.Done() + + assert.True(t, pt.complete(ids[idx])) + }(i) + } + + // All channels should receive. + for i := range count { + select { + case latency := <-chs[i]: + assert.GreaterOrEqual(t, latency, time.Duration(0)) + case <-time.After(5 * time.Second): + t.Fatalf("timed out waiting for ping %d", i) + } + } + + wg.Wait() +} + +func TestPingTracker_TwoToSameNode(t *testing.T) { + pt := newPingTracker() + nodeID := types.NodeID(42) + + id1, ch1 := pt.register(nodeID) + id2, ch2 := pt.register(nodeID) + + require.NotEqual(t, id1, id2, "ping IDs should be unique") + + // Complete only the first. + assert.True(t, pt.complete(id1)) + + select { + case latency := <-ch1: + assert.GreaterOrEqual(t, latency, time.Duration(0)) + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for first ping") + } + + // Second should still be pending. + select { + case <-ch2: + t.Fatal("second channel should not have received yet") + default: + // Expected. + } + + // Now complete the second. + assert.True(t, pt.complete(id2)) + + select { + case latency := <-ch2: + assert.GreaterOrEqual(t, latency, time.Duration(0)) + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for second ping") + } +} + +func TestPingTracker_LatencyNonNegative(t *testing.T) { + pt := newPingTracker() + + pingID, ch := pt.register(types.NodeID(1)) + assert.True(t, pt.complete(pingID)) + + select { + case latency := <-ch: + assert.GreaterOrEqual(t, latency, time.Duration(0), "latency should be non-negative") + assert.Less(t, latency, 5*time.Second, "latency should be reasonable") + case <-time.After(5 * time.Second): + t.Fatal("timed out") + } +} diff --git a/hscontrol/types/change/change_test.go b/hscontrol/types/change/change_test.go index dc2dd0af..729dc468 100644 --- a/hscontrol/types/change/change_test.go +++ b/hscontrol/types/change/change_test.go @@ -89,6 +89,11 @@ func TestChange_IsEmpty(t *testing.T) { response: Change{PeerPatches: []*tailcfg.PeerChange{{}}}, want: false, }, + { + name: "PingRequest not empty", + response: Change{PingRequest: &tailcfg.PingRequest{URL: "https://example.com"}}, + want: false, + }, } for _, tt := range tests { @@ -263,6 +268,24 @@ func TestChange_Merge(t *testing.T) { r2: Change{TargetNode: 42}, want: Change{TargetNode: 42, IncludeSelf: true}, }, + { + name: "PingRequest preserved from first", + r1: Change{PingRequest: &tailcfg.PingRequest{URL: "first"}}, + r2: Change{IncludeSelf: true}, + want: Change{PingRequest: &tailcfg.PingRequest{URL: "first"}, IncludeSelf: true}, + }, + { + name: "PingRequest preserved from second when first is nil", + r1: Change{IncludeSelf: true}, + r2: Change{PingRequest: &tailcfg.PingRequest{URL: "second"}}, + want: Change{PingRequest: &tailcfg.PingRequest{URL: "second"}, IncludeSelf: true}, + }, + { + name: "PingRequest first wins when both set", + r1: Change{PingRequest: &tailcfg.PingRequest{URL: "first"}}, + r2: Change{PingRequest: &tailcfg.PingRequest{URL: "second"}}, + want: Change{PingRequest: &tailcfg.PingRequest{URL: "first"}}, + }, } for _, tt := range tests { @@ -417,6 +440,14 @@ func TestChange_Type(t *testing.T) { response: PolicyOnly(), want: "config", }, + { + name: "ping request", + response: Change{ + PingRequest: &tailcfg.PingRequest{URL: "https://example.com"}, + TargetNode: 1, + }, + want: "ping", + }, { name: "empty is unknown", response: Change{}, @@ -432,6 +463,17 @@ func TestChange_Type(t *testing.T) { } } +func TestPingNode(t *testing.T) { + pr := &tailcfg.PingRequest{URL: "https://example.com/ping", URLIsNoise: true, Log: true} + r := PingNode(42, pr) + assert.Equal(t, "ping node", r.Reason) + assert.Equal(t, types.NodeID(42), r.TargetNode) + assert.Equal(t, pr, r.PingRequest) + assert.True(t, r.IsTargetedToNode()) + assert.False(t, r.IsEmpty()) + assert.Equal(t, "ping", r.Type()) +} + func TestUniqueNodeIDs(t *testing.T) { tests := []struct { name string