From 0f869b30e28135048f2e3229e88a494f39a846d8 Mon Sep 17 00:00:00 2001 From: Sameh Abouel-saad Date: Sun, 10 Aug 2025 22:21:26 +0300 Subject: [PATCH 1/4] fix: refactor relay URLs validation --- rmb-sdk-go/peer/peer.go | 58 +++++++++++++++++++++++++++-------------- 1 file changed, 39 insertions(+), 19 deletions(-) diff --git a/rmb-sdk-go/peer/peer.go b/rmb-sdk-go/peer/peer.go index b9bffb1a..afa4fef8 100644 --- a/rmb-sdk-go/peer/peer.go +++ b/rmb-sdk-go/peer/peer.go @@ -11,7 +11,6 @@ import ( "fmt" "net/url" "slices" - "sort" "strings" "time" @@ -145,12 +144,8 @@ func generateSecureKey(identity substrate.Identity) (*secp256k1.PrivateKey, erro priv := secp256k1.PrivKeyFromBytes(keyPair.Seed()) return priv, nil } - -// getRelayConnections tries to connect to all relays and returns only the successful ones -// getRelayConnections returns InnerConnections for all valid relay URLs -func getRelayConnections(relayURLs []string, identity substrate.Identity, session string, twinID uint32) ([]string, []InnerConnection, error) { - var validRelayURLs []string - var connections []InnerConnection +func validateRelayURLs(relayURLs []string) ([]*url.URL, error) { + var validRelayURLs []*url.URL for _, relayURL := range relayURLs { parsedURL, err := url.Parse(relayURL) @@ -158,21 +153,46 @@ func getRelayConnections(relayURLs []string, identity substrate.Identity, sessio log.Warn().Err(err).Str("url", relayURL).Msg("failed to parse relay URL, skipping") continue } - validRelayURLs = append(validRelayURLs, parsedURL.Host) - conn := NewConnection(identity, relayURL, session, twinID) - connections = append(connections, conn) + // make sure it is ws or wss + if parsedURL.Scheme != "ws" && parsedURL.Scheme != "wss" { + log.Warn().Str("url", relayURL).Msg("relay URL must be ws or wss, skipping") + continue + } + validRelayURLs = append(validRelayURLs, parsedURL) } - if len(connections) == 0 { - return nil, nil, ErrNoValidRelayURLs + if len(validRelayURLs) == 0 { + return nil, ErrNoValidRelayURLs } - sort.Slice(validRelayURLs, func(i, j int) bool { - return strings.ToLower(validRelayURLs[i]) < strings.ToLower(validRelayURLs[j]) + slices.SortFunc(validRelayURLs, func(a, b *url.URL) int { + return strings.Compare(a.Host, b.Host) + }) + validRelayURLs = slices.CompactFunc(validRelayURLs, func(a, b *url.URL) bool { + return a.Host == b.Host }) - validRelayURLs = slices.Compact(validRelayURLs) - return validRelayURLs, connections, nil + return validRelayURLs, nil +} + +// getRelayConnections tries to connect to all relays and returns only the successful ones +// getRelayConnections returns InnerConnections for all valid relay URLs +func getRelayConnections(relayURLs []string, identity substrate.Identity, session string, twinID uint32) ([]string, []InnerConnection, error) { + validRelayURLs, err := validateRelayURLs(relayURLs) + if err != nil { + return nil, nil, err + } + connections := make([]InnerConnection, 0, len(validRelayURLs)) + hosts := make([]string, 0, len(validRelayURLs)) + + for _, relayURL := range validRelayURLs { + conn := NewConnection(identity, relayURL.String(), session, twinID) + connections = append(connections, conn) + host := strings.ToLower(relayURL.Host) + hosts = append(hosts, host) + } + + return hosts, connections, nil } func getIdentity(keytype string, mnemonics string) (substrate.Identity, error) { @@ -266,7 +286,7 @@ func NewPeer( publicKey = privKey.PubKey().SerializeCompressed() } - relayURLs, conns, err := getRelayConnections(cfg.relayURLs, identity, cfg.session, twin.ID) + hosts, conns, err := getRelayConnections(cfg.relayURLs, identity, cfg.session, twin.ID) if err != nil { return nil, err } @@ -281,7 +301,7 @@ func NewPeer( } } - joinURLs := strings.Join(relayURLs, "_") + joinURLs := strings.Join(hosts, "_") if !bytes.Equal(twin.E2EKey, publicKey) || twin.Relay == nil || joinURLs != *twin.Relay { log.Info().Str("Relay url/s", joinURLs).Msg("twin relay/public key didn't match, updating on chain ...") if _, err = subConn.UpdateTwin(identity, joinURLs, publicKey); err != nil { @@ -320,7 +340,7 @@ func NewPeer( cons: cons, handler: handler, encoder: cfg.encoder, - relays: relayURLs, + relays: hosts, } go cl.process(ctx) From 6cfd642970722e54d29e22e2cef6f2b4155212ac Mon Sep 17 00:00:00 2001 From: Sameh Abouel-saad Date: Tue, 12 Aug 2025 01:38:16 +0300 Subject: [PATCH 2/4] normalize relay URL handling and add tests --- rmb-sdk-go/peer/peer.go | 17 ++++-- rmb-sdk-go/peer/relay_urls_test.go | 88 ++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 6 deletions(-) create mode 100644 rmb-sdk-go/peer/relay_urls_test.go diff --git a/rmb-sdk-go/peer/peer.go b/rmb-sdk-go/peer/peer.go index afa4fef8..72d0e33d 100644 --- a/rmb-sdk-go/peer/peer.go +++ b/rmb-sdk-go/peer/peer.go @@ -148,7 +148,7 @@ func validateRelayURLs(relayURLs []string) ([]*url.URL, error) { var validRelayURLs []*url.URL for _, relayURL := range relayURLs { - parsedURL, err := url.Parse(relayURL) + parsedURL, err := url.Parse(strings.ToLower(relayURL)) if err != nil { log.Warn().Err(err).Str("url", relayURL).Msg("failed to parse relay URL, skipping") continue @@ -158,6 +158,11 @@ func validateRelayURLs(relayURLs []string) ([]*url.URL, error) { log.Warn().Str("url", relayURL).Msg("relay URL must be ws or wss, skipping") continue } + // make sure Hostname is not empty + if parsedURL.Hostname() == "" { + log.Warn().Str("url", relayURL).Msg("relay URL must have a hostname, skipping") + continue + } validRelayURLs = append(validRelayURLs, parsedURL) } @@ -165,13 +170,13 @@ func validateRelayURLs(relayURLs []string) ([]*url.URL, error) { return nil, ErrNoValidRelayURLs } - slices.SortFunc(validRelayURLs, func(a, b *url.URL) int { - return strings.Compare(a.Host, b.Host) - }) validRelayURLs = slices.CompactFunc(validRelayURLs, func(a, b *url.URL) bool { - return a.Host == b.Host + return a.Hostname() == b.Hostname() }) + slices.SortFunc(validRelayURLs, func(a, b *url.URL) int { + return strings.Compare(a.Hostname(), b.Hostname()) + }) return validRelayURLs, nil } @@ -188,7 +193,7 @@ func getRelayConnections(relayURLs []string, identity substrate.Identity, sessio for _, relayURL := range validRelayURLs { conn := NewConnection(identity, relayURL.String(), session, twinID) connections = append(connections, conn) - host := strings.ToLower(relayURL.Host) + host := relayURL.Hostname() hosts = append(hosts, host) } diff --git a/rmb-sdk-go/peer/relay_urls_test.go b/rmb-sdk-go/peer/relay_urls_test.go new file mode 100644 index 00000000..1712facf --- /dev/null +++ b/rmb-sdk-go/peer/relay_urls_test.go @@ -0,0 +1,88 @@ +package peer + +import ( + "reflect" + "testing" +) + +func TestValidateRelayURLs_DedupSort(t *testing.T) { + inputs := []string{ + "ws://Relay.Grid.tf", // same host different case, ws + "wss://relay.grid.tf", // same host wss + "wss://relay.grid.tf:443", // same host with default port -> ignored for dedup + "ws://relay.grid.tf/some/path", // same host path -> ignored for dedup + "wss://beta.grid.tf", // distinct host + "http://not-allowed.example", // invalid scheme + "://bad://url", // unparsable + "wss://relay.dev.grid.tf:443", // another host + "ws://relay.dev.grid.tf", // duplicate host, ws should be ignored in favor of wss entry + } + + urls, err := validateRelayURLs(inputs) + if err != nil { + // should not error; we have valid entries + t.Fatalf("unexpected error: %v", err) + } + + // Expect unique hostnames (lowercased) sorted: [beta.grid.tf relay.dev.grid.tf relay.grid.tf] + if len(urls) != 3 { + t.Fatalf("expected 3 unique hosts, got %d", len(urls)) + } + + expectedHosts := []string{"beta.grid.tf", "relay.dev.grid.tf", "relay.grid.tf"} + gotHosts := []string{urls[0].Hostname(), urls[1].Hostname(), urls[2].Hostname()} + if !reflect.DeepEqual(expectedHosts, gotHosts) { + t.Fatalf("hosts mismatch\nexpected: %v\n got: %v", expectedHosts, gotHosts) + } +} + +func TestValidateRelayURLs_AllInvalid(t *testing.T) { + inputs := []string{"http://a", "ftp://b", "::::"} + _, err := validateRelayURLs(inputs) + if err == nil { + t.Fatalf("expected error for no valid relay URLs") + } + if err != ErrNoValidRelayURLs { + t.Fatalf("expected ErrNoValidRelayURLs, got %v", err) + } +} + +func TestGetRelayConnections_HostsNormalizedAndAligned(t *testing.T) { + inputs := []string{ + "ws://relay.grid.tf:443/path", // same host as below, different scheme/port/path + "wss://relay.grid.tf", + "wss://Relay.Dev.Grid.TF", // case-insensitive host + } + + hosts, conns, err := getRelayConnections(inputs, nil, "sess", 42) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(hosts) != len(conns) { + t.Fatalf("hosts and connections length mismatch: %d vs %d", len(hosts), len(conns)) + } + + expectedHosts := []string{"relay.dev.grid.tf", "relay.grid.tf"} + if !reflect.DeepEqual(expectedHosts, hosts) { + t.Fatalf("normalized hosts mismatch\nexpected: %v\n got: %v", expectedHosts, hosts) + } +} + +func TestValidateRelayURLs_DeterministicSort(t *testing.T) { + inputs := []string{ + "wss://relay.grid.tf", + "wss://relay.dev.grid.tf", + "wss://beta.grid.tf", + } + + urls, err := validateRelayURLs(inputs) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expectedHosts := []string{"beta.grid.tf", "relay.dev.grid.tf", "relay.grid.tf"} + gotHosts := []string{urls[0].Hostname(), urls[1].Hostname(), urls[2].Hostname()} + if !reflect.DeepEqual(expectedHosts, gotHosts) { + t.Fatalf("hosts mismatch\nexpected: %v\n got: %v", expectedHosts, gotHosts) + } +} From 1383de76ce37e3942ba2177a1bb1c34efa084ba6 Mon Sep 17 00:00:00 2001 From: Sameh Abouel-saad Date: Wed, 20 Aug 2025 15:15:24 +0300 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20non-adjacent=20duplicate=20hosts=20w?= =?UTF-8?q?on=E2=80=99t=20be=20removed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rmb-sdk-go/peer/peer.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/rmb-sdk-go/peer/peer.go b/rmb-sdk-go/peer/peer.go index 72d0e33d..62e6ac69 100644 --- a/rmb-sdk-go/peer/peer.go +++ b/rmb-sdk-go/peer/peer.go @@ -170,13 +170,12 @@ func validateRelayURLs(relayURLs []string) ([]*url.URL, error) { return nil, ErrNoValidRelayURLs } - validRelayURLs = slices.CompactFunc(validRelayURLs, func(a, b *url.URL) bool { - return a.Hostname() == b.Hostname() - }) - slices.SortFunc(validRelayURLs, func(a, b *url.URL) int { return strings.Compare(a.Hostname(), b.Hostname()) }) + validRelayURLs = slices.CompactFunc(validRelayURLs, func(a, b *url.URL) bool { + return a.Hostname() == b.Hostname() + }) return validRelayURLs, nil } From c59f9197985f766e40b8a9e09b0200119478faf9 Mon Sep 17 00:00:00 2001 From: Sameh Abouel-saad Date: Wed, 20 Aug 2025 15:33:32 +0300 Subject: [PATCH 4/4] test: Add non-adjacent duplicates test --- rmb-sdk-go/peer/relay_urls_test.go | 31 ++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/rmb-sdk-go/peer/relay_urls_test.go b/rmb-sdk-go/peer/relay_urls_test.go index 1712facf..7a6e108d 100644 --- a/rmb-sdk-go/peer/relay_urls_test.go +++ b/rmb-sdk-go/peer/relay_urls_test.go @@ -36,6 +36,37 @@ func TestValidateRelayURLs_DedupSort(t *testing.T) { } } +func TestValidateRelayURLs_DedupSort_NonAdjacent(t *testing.T) { + inputs := []string{ + "ws://Relay.Grid.tf", // same host different case, ws + "wss://beta.grid.tf", // distinct host + "http://not-allowed.example", // invalid scheme + "://bad://url", // unparsable + "wss://relay.dev.grid.tf:443", // another host + "wss://relay.grid.tf", // same host wss + "ws://relay.dev.grid.tf", // duplicate host, ws should be ignored in favor of wss entry + "wss://relay.grid.tf:443", // same host with default port -> ignored for dedup + "ws://relay.grid.tf/some/path", // same host path -> ignored for dedup + } + + urls, err := validateRelayURLs(inputs) + if err != nil { + // should not error; we have valid entries + t.Fatalf("unexpected error: %v", err) + } + + // Expect unique hostnames (lowercased) sorted: [beta.grid.tf relay.dev.grid.tf relay.grid.tf] + if len(urls) != 3 { + t.Fatalf("expected 3 unique hosts, got %d", len(urls)) + } + + expectedHosts := []string{"beta.grid.tf", "relay.dev.grid.tf", "relay.grid.tf"} + gotHosts := []string{urls[0].Hostname(), urls[1].Hostname(), urls[2].Hostname()} + if !reflect.DeepEqual(expectedHosts, gotHosts) { + t.Fatalf("hosts mismatch\nexpected: %v\n got: %v", expectedHosts, gotHosts) + } +} + func TestValidateRelayURLs_AllInvalid(t *testing.T) { inputs := []string{"http://a", "ftp://b", "::::"} _, err := validateRelayURLs(inputs)