package tunnel import ( "bytes" "context" "crypto/rand" "fmt" "io" "sync" "testing" "time" "git.smesh.lol/nostrino/internal/memrelay" "git.smesh.lol/nostrino/pkg/crypto/secp256k1" "git.smesh.lol/nostrino/pkg/nostr" "git.smesh.lol/nostrino/pkg/relay" ) func keyWithByte(b byte) [32]byte { var k [32]byte k[31] = b return k } func testPair(t *testing.T) (*memrelay.Relay, *Listener, [32]byte, DialConfig) { t.Helper() r := memrelay.New() t.Cleanup(r.Close) gwKey := keyWithByte(7) gwPub, ok := secp256k1.PubKey(gwKey) if !ok { t.Fatal("gateway key") } factory := func(string) (relay.Transport, error) { return r.Dial("") } l, err := Listen(context.Background(), ListenConfig{ Relays: []string{"mem://relay"}, Key: gwKey, Chains: []string{"regtest"}, TransportFactory: factory, IdleTimeout: 10 * time.Second, Heartbeat: time.Second, }) if err != nil { t.Fatalf("listen: %v", err) } t.Cleanup(func() { l.Close() }) u := &URI{ Gateway: gwPub, Relays: []string{"mem://relay"}, Chains: []string{"regtest"}, Version: 1, } return r, l, gwKey, DialConfig{ URI: u, Key: keyWithByte(9), Chain: "regtest", TransportFactory: factory, IdleTimeout: 10 * time.Second, Heartbeat: time.Second, } } func acceptEcho(t *testing.T, l *Listener) { t.Helper() ctx := context.Background() go func() { for { c, err := l.Accept(ctx) if err != nil { return } go func() { defer c.Close() buf := make([]byte, 32<<10) total := 0 for { n, err := c.Read(buf) if n > 0 { total += n } if n > 0 { if _, werr := c.Write(buf[:n]); werr != nil { return } } if err != nil { return } } }() } }() } func dialPair(t *testing.T, l *Listener, cfg DialConfig) *Conn { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() c, err := Dial(ctx, cfg) if err != nil { t.Fatalf("dial: %v", err) } t.Cleanup(func() { c.Close() }) return c } func TestTunnelEcho(t *testing.T) { _, l, _, cfg := testPair(t) acceptEcho(t, l) c := dialPair(t, l, cfg) c.SetDeadline(time.Now().Add(20 * time.Second)) payload := make([]byte, 1<<20) if _, err := rand.Read(payload); err != nil { t.Fatal(err) } var wg sync.WaitGroup wg.Add(1) var werr error go func() { defer wg.Done() _, werr = c.Write(payload) }() got := make([]byte, len(payload)) if _, err := io.ReadFull(c, got); err != nil { t.Fatalf("read: %v", err) } wg.Wait() if werr != nil { t.Fatalf("write: %v", werr) } if !bytes.Equal(got, payload) { t.Fatal("echo mismatch") } if c.PeerHex() == "" || c.Chain() != "regtest" { t.Fatalf("peer=%q chain=%q", c.PeerHex(), c.Chain()) } } func TestTunnelRetransmitWithDrops(t *testing.T) { r, l, _, cfg := testPair(t) acceptEcho(t, l) c := dialPair(t, l, cfg) // Lose roughly one in nine deliveries in both directions. The stream must // still deliver every byte in order. r.SetDrop(9) defer r.SetDrop(0) c.SetDeadline(time.Now().Add(30 * time.Second)) payload := make([]byte, 512<<10) if _, err := rand.Read(payload); err != nil { t.Fatal(err) } go c.Write(payload) got := make([]byte, len(payload)) if _, err := io.ReadFull(c, got); err != nil { t.Fatalf("read: %v", err) } if !bytes.Equal(got, payload) { t.Fatal("echo mismatch after drops") } } func TestTunnelBackpressure(t *testing.T) { _, l, _, cfg := testPair(t) // Accept the stream but never read from it: the receiver's queue fills, its // credit reaches zero, and the sender must stop instead of buffering the // whole transfer in memory. This is the frozen-worker case. ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() accepted := make(chan *Conn, 1) go func() { c, err := l.Accept(ctx) if err == nil { accepted <- c } }() c := dialPair(t, l, cfg) select { case held := <-accepted: defer held.Close() case <-ctx.Done(): t.Fatal("accept timed out") } payload := make([]byte, 4<<20) done := make(chan error, 1) go func() { _, err := c.Write(payload) done <- err }() select { case err := <-done: t.Fatalf("write completed without a reader (err=%v); flow control failed", err) case <-time.After(500 * time.Millisecond): } c.Reset() select { case <-done: case <-time.After(5 * time.Second): t.Fatal("write did not unblock after reset") } } func TestTunnelMultipleClients(t *testing.T) { _, l, _, cfg := testPair(t) acceptEcho(t, l) var wg sync.WaitGroup for i := 0; i < 3; i++ { i := i wg.Add(1) go func() { defer wg.Done() cfg := cfg cfg.Key = keyWithByte(byte(20 + i)) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() c, err := Dial(ctx, cfg) if err != nil { t.Errorf("dial %d: %v", i, err) return } defer c.Close() c.SetDeadline(time.Now().Add(20 * time.Second)) payload := bytes.Repeat([]byte{byte(i)}, 128<<10) go c.Write(payload) got := make([]byte, len(payload)) if _, err := io.ReadFull(c, got); err != nil { t.Errorf("client %d read: %v", i, err) return } if !bytes.Equal(got, payload) { t.Errorf("client %d echo mismatch", i) } }() } wg.Wait() } func TestTunnelMultipleRelays(t *testing.T) { r1, r2 := memrelay.New(), memrelay.New() t.Cleanup(r1.Close) t.Cleanup(r2.Close) gwKey := keyWithByte(7) gwPub, _ := secp256k1.PubKey(gwKey) factory := func(url string) (relay.Transport, error) { switch url { case "mem://a": return r1.Dial("") case "mem://b": return r2.Dial("") default: return nil, fmt.Errorf("unknown relay %s", url) } } l, err := Listen(context.Background(), ListenConfig{ Relays: []string{"mem://a", "mem://b"}, Key: gwKey, Chains: []string{"regtest"}, TransportFactory: factory, IdleTimeout: 10 * time.Second, Heartbeat: time.Second, }) if err != nil { t.Fatal(err) } t.Cleanup(func() { l.Close() }) acceptEcho(t, l) u := &URI{Gateway: gwPub, Relays: []string{"mem://a", "mem://b"}, Version: 1} cfg := DialConfig{ URI: u, Key: keyWithByte(9), Chain: "regtest", TransportFactory: factory, IdleTimeout: 10 * time.Second, Heartbeat: time.Second, } c := dialPair(t, l, cfg) c.SetDeadline(time.Now().Add(20 * time.Second)) round := func(size int, tag byte) { payload := bytes.Repeat([]byte{tag}, size) go c.Write(payload) got := make([]byte, len(payload)) if _, err := io.ReadFull(c, got); err != nil { t.Fatalf("read: %v", err) } if !bytes.Equal(got, payload) { t.Fatal("echo mismatch") } } round(128<<10, 'a') // Lose every delivery on the first relay; the stream continues on the // second without a new session. r1.SetDrop(1) round(128<<10, 'b') } func TestTunnelRejectsChain(t *testing.T) { _, _, _, cfg := testPair(t) cfg.URI.Chains = []string{"btc"} cfg.Chain = "btc" ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() if _, err := Dial(ctx, cfg); err == nil { t.Fatal("dial succeeded for an unserved chain") } } func TestTunnelListenerAllowlist(t *testing.T) { r := memrelay.New() t.Cleanup(r.Close) gwKey := keyWithByte(7) gwPub, _ := secp256k1.PubKey(gwKey) clientKey := keyWithByte(9) clientPub, _ := secp256k1.PubKey(clientKey) allowed := keyWithByte(33) allowedPub, _ := secp256k1.PubKey(allowed) factory := func(string) (relay.Transport, error) { return r.Dial("") } l, err := Listen(context.Background(), ListenConfig{ Relays: []string{"mem://relay"}, Key: gwKey, Allowlist: []string{nostr.Hex(allowedPub[:])}, TransportFactory: factory, }) if err != nil { t.Fatal(err) } defer l.Close() u := &URI{Gateway: gwPub, Relays: []string{"mem://relay"}, Version: 1} dial := func(key [32]byte) error { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() c, err := Dial(ctx, DialConfig{URI: u, Key: key, TransportFactory: factory}) if err == nil { c.Close() } return err } if err := dial(clientKey); err == nil { t.Fatal("disallowed client connected") } if err := dial(allowed); err != nil { t.Fatalf("allowed client failed: %v", err) } _ = clientPub } func TestURIParse(t *testing.T) { key := keyWithByte(7) pub, _ := secp256k1.PubKey(key) hexPub := nostr.Hex(pub[:]) raw := "nostr+chaindata://" + hexPub + "?relay=wss%3A%2F%2Frelay.example&relay=ws%3A%2F%2F127.0.0.1%3A3334&bridge=wss%3A%2F%2Fbridge.example%2Fnostrino&chains=btc%2Cxbt&v=1&name=home" u, err := ParseURI(raw) if err != nil { t.Fatalf("parse: %v", err) } if len(u.Relays) != 2 || u.Relays[0] != "wss://relay.example" || u.Relays[1] != "ws://127.0.0.1:3334" { t.Fatalf("relays: %#v", u.Relays) } if len(u.Chains) != 2 || !u.ChainAllowed("xbt") || u.ChainAllowed("testnet") { t.Fatalf("chains: %#v", u.Chains) } if len(u.Bridges) != 1 || u.Bridges[0] != "wss://bridge.example/nostrino" { t.Fatalf("bridges: %#v", u.Bridges) } round, err := ParseURI(u.String()) if err != nil { t.Fatalf("round trip: %v", err) } if round.Gateway != u.Gateway || len(round.Relays) != 2 || len(round.Bridges) != 1 || len(round.Chains) != 2 || round.Name != "home" { t.Fatalf("round trip mismatch: %#v", round) } for _, bad := range []string{ "http://" + hexPub + "?relay=ws://x", "nostr+chaindata://" + hexPub, "nostr+chaindata://deadbeef?relay=ws://x", "nostr+chaindata://" + hexPub + "?relay=http://x", } { if _, err := ParseURI(bad); err == nil { t.Errorf("accepted %q", bad) } } }