package tunnel import ( "context" "crypto/ed25519" "crypto/rand" "encoding/base64" "io" "net/http" "net/http/httptest" "net/url" "path/filepath" "strings" "testing" "time" ) // An Eleven-shaped backend the door probe accepts. func fakeInstance(t *testing.T) *httptest.Server { t.Helper() s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/version" { w.Header().Set("Content-Type", "application/json") io.WriteString(w, `{"build":"test","version":"test-1"}`) return } io.WriteString(w, "instance says hi") })) t.Cleanup(s.Close) return s } func relayGateway(t *testing.T) (*Gateway, *httptest.Server) { t.Helper() claims, err := OpenClaims(filepath.Join(t.TempDir(), "claims.db")) if err != nil { t.Fatal(err) } gw := &Gateway{Domain: "test.local", RelayDomain: "relay.local", ClaimsDB: claims, DialTimeout: 5 * time.Second, RelayGrace: 200 * time.Millisecond} srv := httptest.NewServer(gw) t.Cleanup(srv.Close) u, _ := url.Parse(srv.URL) gw.ControlHost = u.Hostname() return gw, srv } // A relay claims a hostname, loses its connection, and gets the SAME // hostname back on reconnect; meanwhile the sleeping hostname answers 503 // instead of vanishing. func TestRelayClaimReconnectAndGrace(t *testing.T) { backend := fakeInstance(t) gw, srv := relayGateway(t) gw.RelayGrace = 10 * time.Second // long enough to observe sleeping _, priv, _ := ed25519.GenerateKey(rand.Reader) run := func(ctx context.Context) (string, chan error) { cl := &Client{Gateway: srv.URL, LocalAddr: strings.TrimPrefix(backend.URL, "http://"), Identity: priv} ready := make(chan string, 1) done := make(chan error, 1) go func() { done <- cl.Run(ctx, func(h string) { ready <- h }) }() select { case h := <-ready: return h, done case err := <-done: t.Fatalf("client exited early: %v", err) case <-time.After(5 * time.Second): t.Fatal("no ready") } return "", done } ctx1, cancel1 := context.WithCancel(context.Background()) host1, _ := run(ctx1) if !strings.HasPrefix(host1, "r-") || !strings.HasSuffix(host1, ".relay.local") { t.Fatalf("unexpected relay host %q", host1) } // Drop the connection: the hostname sleeps (503) rather than vanishing. cancel1() deadline := time.Now().Add(3 * time.Second) for { gw.mu.Lock() tun := gw.tunnels[host1] sleeping := tun != nil && tun.sleeping gw.mu.Unlock() if sleeping { break } if time.Now().After(deadline) { t.Fatal("tunnel never went to sleep") } time.Sleep(20 * time.Millisecond) } rt := http.DefaultTransport.(*http.Transport).Clone() httpc := &http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { r.Host = host1 return rt.RoundTrip(r) })} resp, err := httpc.Get(srv.URL + "/") if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusServiceUnavailable { t.Fatalf("sleeping relay answered %d, want 503", resp.StatusCode) } // Reconnect with the same identity: same hostname, traffic flows again. ctx2, cancel2 := context.WithCancel(context.Background()) defer cancel2() host2, _ := run(ctx2) if host2 != host1 { t.Fatalf("reconnect got %q, want the claimed %q", host2, host1) } resp, err = httpc.Get(srv.URL + "/") if err != nil { t.Fatal(err) } body, _ := io.ReadAll(resp.Body) resp.Body.Close() if string(body) != "instance says hi" { t.Fatalf("after reattach body = %q", body) } // A different identity gets a different hostname. _, priv2, _ := ed25519.GenerateKey(rand.Reader) cl2 := &Client{Gateway: srv.URL, LocalAddr: strings.TrimPrefix(backend.URL, "http://"), Identity: priv2} ready2 := make(chan string, 1) ctx3, cancel3 := context.WithCancel(context.Background()) defer cancel3() go cl2.Run(ctx3, func(h string) { ready2 <- h }) select { case h := <-ready2: if h == host1 { t.Fatal("second identity was handed the first identity's hostname") } case <-time.After(5 * time.Second): t.Fatal("second identity never became ready") } } // After the grace window expires, the hostname is fully retired (route // withdrawn) — but the claim survives, so the next connect gets it back. func TestRelayGraceExpiry(t *testing.T) { backend := fakeInstance(t) gw, srv := relayGateway(t) // grace 200ms closed := make(chan string, 1) gw.OnClose = func(h string) { closed <- h } _, priv, _ := ed25519.GenerateKey(rand.Reader) cl := &Client{Gateway: srv.URL, LocalAddr: strings.TrimPrefix(backend.URL, "http://"), Identity: priv} ready := make(chan string, 1) ctx, cancel := context.WithCancel(context.Background()) go cl.Run(ctx, func(h string) { ready <- h }) var host string select { case host = <-ready: case <-time.After(5 * time.Second): t.Fatal("no ready") } cancel() select { case got := <-closed: if got != host { t.Fatalf("OnClose got %q", got) } case <-time.After(5 * time.Second): t.Fatal("grace never expired") } if owner, _ := gw.ClaimsDB.Owner(host); owner == "" { t.Fatal("claim was lost with the grace expiry") } } // A gateway without relay support refuses relay hellos with a clear message. func TestRelayRefusedWithoutDomain(t *testing.T) { backend := fakeInstance(t) gw := &Gateway{Domain: "test.local"} srv := httptest.NewServer(gw) defer srv.Close() u, _ := url.Parse(srv.URL) gw.ControlHost = u.Hostname() _, priv, _ := ed25519.GenerateKey(rand.Reader) cl := &Client{Gateway: srv.URL, LocalAddr: strings.TrimPrefix(backend.URL, "http://"), Identity: priv} err := cl.Run(context.Background(), func(string) {}) if err == nil || !strings.Contains(err.Error(), "doesn't offer relay") { t.Fatalf("expected relay refusal, got %v", err) } } func TestVerifyClaimSig(t *testing.T) { pub, priv, _ := ed25519.GenerateKey(rand.Reader) pk := base64.RawURLEncoding.EncodeToString(pub) sig := base64.RawURLEncoding.EncodeToString(ed25519.Sign(priv, []byte("nonce-1"))) if err := VerifyClaimSig(pk, "nonce-1", sig); err != nil { t.Fatalf("valid sig rejected: %v", err) } if err := VerifyClaimSig(pk, "nonce-2", sig); err == nil { t.Fatal("replayed sig accepted for a different nonce") } if err := VerifyClaimSig("not-a-key", "nonce-1", sig); err == nil { t.Fatal("garbage key accepted") } } func TestSweepIdleFreesOnlyAbandonedClaims(t *testing.T) { claims, err := OpenClaims(filepath.Join(t.TempDir(), "claims.db")) if err != nil { t.Fatal(err) } for _, h := range []string{"r-old.relay.local", "r-live.relay.local", "r-fresh.relay.local"} { if err := claims.Claim(h, "pk-"+h); err != nil { t.Fatal(err) } } // Backdate two claims past the idle cutoff; r-live stays connected. old := time.Now().Add(-72 * time.Hour).Unix() for _, h := range []string{"r-old.relay.local", "r-live.relay.local"} { if _, err := claims.sql.Exec(`UPDATE relay_claims SET last_seen = ? WHERE host = ?`, old, h); err != nil { t.Fatal(err) } } freed, err := claims.SweepIdle(24*time.Hour, map[string]bool{"r-live.relay.local": true}) if err != nil { t.Fatal(err) } if len(freed) != 1 || freed[0] != "r-old.relay.local" { t.Fatalf("expected only r-old freed, got %v", freed) } if h, _ := claims.HostFor("pk-r-live.relay.local"); h != "r-live.relay.local" { t.Fatal("connected claim was swept despite being in keep") } if h, _ := claims.HostFor("pk-r-fresh.relay.local"); h != "r-fresh.relay.local" { t.Fatal("fresh claim was swept") } if h, _ := claims.HostFor("pk-r-old.relay.local"); h != "" { t.Fatal("idle claim survived the sweep") } } func TestDeleteByHostReturnsHostnameToPool(t *testing.T) { claims, err := OpenClaims(filepath.Join(t.TempDir(), "claims.db")) if err != nil { t.Fatal(err) } if err := claims.Claim("r-gone.relay.local", "pk-1"); err != nil { t.Fatal(err) } if err := claims.DeleteByHost("r-gone.relay.local"); err != nil { t.Fatal(err) } if h, _ := claims.HostFor("pk-1"); h != "" { t.Fatal("deleted claim still resolves for its old keypair") } // The hostname and the keypair are both free again — either can re-claim. if err := claims.Claim("r-gone.relay.local", "pk-2"); err != nil { t.Fatalf("freed hostname couldn't be re-claimed: %v", err) } if err := claims.Claim("r-new.relay.local", "pk-1"); err != nil { t.Fatalf("freed keypair couldn't claim a new hostname: %v", err) } }