using Xunit; namespace RadicadeServer.Tests; public class NameserverRedirectTests { [Theory] [InlineData("wss://notifications.rec.net/hub/v1?id=a", "http://127.0.0.1:3031", "ws://127.0.0.1:3031/notify/hub/v1?id=a")] [InlineData("ws://notify.rec.net/hub/v1", "https://private.example", "wss://private.example/notify/hub/v1")] [InlineData("wss://notifications.radicade.internal/notify/hub/v1", "https://private.example", "wss://private.example/notify/hub/v1")] // The socket the client actually opens now: the SignalR transport submits it through the same // funnel as the REST calls, and a socket URL the funnel leaves alone is a host with no DNS // record - the hub never connects and the boot dies on it. The nameserver advertises the // service under its full name, so this host is the one that has to come back to /notify. [InlineData("wss://notifications.radicade.app/hub/v1?&id=q041FbQsdxYHA8PQ2oC8dg", "https://ns.radicade.app", "wss://ns.radicade.app/notify/hub/v1?&id=q041FbQsdxYHA8PQ2oC8dg")] [InlineData("wss://other.example/hub/v1", "https://private.example", "wss://other.example/hub/v1")] public void SocketRoutingUsesConfiguredServerTransport(string input, string server, string expected) => Assert.Equal(expected, NameserverRedirect.RewriteWebSocket(input, server)); [Theory] [InlineData("https://ns.rec.net", "https://ns.radicade.app/ns")] [InlineData("https://ns.rec.net/nameserver?build=20230301", "https://ns.radicade.app/nameserver?build=20230301")] [InlineData("http://NS.REC.NET/ns", "https://ns.radicade.app/ns")] [InlineData("https://ns.radicade.internal/ns", "https://ns.radicade.app/ns")] public void StockClientBootstrapsWithoutPatchedMetadata(string input, string expected) => Assert.Equal(expected, NameserverRedirect.Rewrite(input, "https://ns.radicade.app/")); // The stock client resolves the image host itself, so a patch that only rewrote the // bootstrap host left every avatar, photo and thumbnail pointed at the real RecNet. // img.rec.net serves bare image names; this server serves them under /images. [Theory] [InlineData("https://img.rec.net/abc123.png", "https://ns.radicade.app/images/abc123.png")] [InlineData("https://img.rec.net/abc123.png?sig=p1", "https://ns.radicade.app/images/abc123.png?sig=p1")] [InlineData("https://IMG.REC.NET/DefaultProfileImage.jpg", "https://ns.radicade.app/images/DefaultProfileImage.jpg")] [InlineData("https://images.rec.net/abc123.png", "https://ns.radicade.app/images/abc123.png")] // A loader that already adds the /images or /img segment must not have it doubled. [InlineData("https://img.rec.net/images/abc123.png", "https://ns.radicade.app/images/abc123.png")] [InlineData("https://img.rec.net/img/abc123.png", "https://ns.radicade.app/images/abc123.png")] public void ImageHostRequestsReachTheServersImageRoute(string input, string expected) => Assert.Equal(expected, NameserverRedirect.Rewrite(input, "https://ns.radicade.app")); // Room saves and their Unity asset bundles download from the CDN host by blob name; // the same traffic is served here at the root, so the path passes through unchanged. [Theory] [InlineData("https://cdn.rec.net/room/9c1f.bin", "https://ns.radicade.app/room/9c1f.bin")] [InlineData("https://cdn.rec.net/unity/12345/0/1", "https://ns.radicade.app/unity/12345/0/1")] [InlineData("https://data.rec.net/9c1f.bin", "https://ns.radicade.app/data/9c1f.bin")] [InlineData("https://storage.rec.net/upload", "https://ns.radicade.app/storage/upload")] [InlineData("https://notify.rec.net/hub/v1/negotiate", "https://ns.radicade.app/notify/hub/v1/negotiate")] [InlineData("https://api.rec.net/api/config/v2", "https://ns.radicade.app/api/config/v2")] [InlineData("https://accounts.rec.net/account/me", "https://ns.radicade.app/accounts/account/me")] [InlineData("https://rooms.rec.net/rooms/2?include=all", "https://ns.radicade.app/rooms/rooms/2?include=all")] [InlineData("https://matchmaking.rec.net/player/login", "https://ns.radicade.app/matchmaking/player/login")] [InlineData("https://auth.rec.net/connect/token", "https://ns.radicade.app/auth/connect/token")] [InlineData("https://chat.rec.net/thread?maxCount=30", "https://ns.radicade.app/chat/thread?maxCount=30")] public void ServiceHostRequestsKeepTheirPathOnTheConfiguredServer(string input, string expected) => Assert.Equal(expected, NameserverRedirect.Rewrite(input, "https://ns.radicade.app")); // The nameserver names each service on its own host now, and the client builds every call // as that base plus its own path literal - so the host has to come back to exactly the // route the path-prefixed base used to produce. api is the one bare base, and cdn keeps // the /cdn segment its advertised base has always carried. [Theory] [InlineData("https://auth.radicade.app/connect/token", "https://ns.radicade.app/auth/connect/token")] [InlineData("https://api.radicade.app/api/config/v2", "https://ns.radicade.app/api/config/v2")] [InlineData("https://accounts.radicade.app/account/me", "https://ns.radicade.app/accounts/account/me")] [InlineData("https://rooms.radicade.app/rooms/2?include=all", "https://ns.radicade.app/rooms/rooms/2?include=all")] [InlineData("https://matchmaking.radicade.app/player/login", "https://ns.radicade.app/matchmaking/player/login")] [InlineData("https://chat.radicade.app/thread?maxCount=30", "https://ns.radicade.app/chat/thread?maxCount=30")] [InlineData("https://images.radicade.app/abc123.png", "https://ns.radicade.app/images/abc123.png")] // A loader that already adds the /images or /img segment must not have it doubled. [InlineData("https://images.radicade.app/img/abc123.png", "https://ns.radicade.app/images/abc123.png")] [InlineData("https://cdn.radicade.app/room/9c1f.bin", "https://ns.radicade.app/cdn/room/9c1f.bin")] [InlineData("https://data.radicade.app/9c1f.bin", "https://ns.radicade.app/data/9c1f.bin")] // The notify service is the one advertised name that is not its own route: the host is // notifications.radicade.app, the route it has to land on is /notify. [InlineData("https://notifications.radicade.app/hub/v1/negotiate", "https://ns.radicade.app/notify/hub/v1/negotiate")] [InlineData("https://www.radicade.app/creator", "https://ns.radicade.app/www/creator")] [InlineData("https://stringscdn.radicade.app/strings.json", "https://ns.radicade.app/stringscdn/strings.json")] [InlineData("https://studiocdn.radicade.app/roomunityasset/x", "https://ns.radicade.app/studiocdn/roomunityasset/x")] [InlineData("https://thorn.radicade.app/endpoint", "https://ns.radicade.app/thorn/endpoint")] [InlineData("https://videos.radicade.app/blob.mp4", "https://ns.radicade.app/videos/blob.mp4")] public void NameserverServiceHostsRedirectOntoTheirOwnRoute(string input, string expected) => Assert.Equal(expected, NameserverRedirect.Rewrite(input, "https://ns.radicade.app")); [Theory] [InlineData("https://ns.radicade.app/ns")] [InlineData("https://example.com/?url=https://ns.rec.net")] [InlineData("https://ns.rec.net.example.com/ns")] [InlineData("https://api.steampowered.com/")] // Only the listed hosts are rewritten: a lookalike domain is someone else's traffic. [InlineData("https://img.rec.net.evil.example/abc.png")] [InlineData("https://myimg.rec.net/abc.png")] // The service domain is not a wildcard either - the apex is the site, not a service, and // a host that merely ends in it is someone else's. [InlineData("https://auth.radicade.app.evil.example/connect/token")] [InlineData("https://myauth.radicade.app/connect/token")] [InlineData("https://radicade.app/ns")] // The notify service's old short name was retired in favour of notifications.radicade.app, // and the rename was clean: nothing maps this host any more, so a request still naming it // is left exactly as it is - there is no second spelling of the service to drift from. [InlineData("https://notify.radicade.app/hub/v1/negotiate")] public void LeavesOtherRequestsAndAlreadyPatchedClientsUntouched(string input) => Assert.Equal(input, NameserverRedirect.Rewrite(input, "https://ns.radicade.app")); [Fact] public void PatchedPublicServerRequestsFollowLocalConfiguration() { Assert.Equal("http://127.0.0.1:3031/ns", NameserverRedirect.Rewrite("https://ns.radicade.app/ns", "http://127.0.0.1:3031")); Assert.Equal("http://127.0.0.1:3031/api/config/v2?build=20230301", NameserverRedirect.Rewrite("https://ns.radicade.app/api/config/v2?build=20230301", "http://127.0.0.1:3031")); Assert.Equal("https://ns.radicade.app.example.com/ns", NameserverRedirect.Rewrite("https://ns.radicade.app.example.com/ns", "http://127.0.0.1:3031")); } [Fact] public void RedirectedServiceHostsFollowLocalConfigurationToo() { Assert.Equal("http://127.0.0.1:3031/images/abc.png", NameserverRedirect.Rewrite("https://img.rec.net/abc.png", "http://127.0.0.1:3031")); Assert.Equal("http://127.0.0.1:3031/room/9c1f.bin", NameserverRedirect.Rewrite("https://cdn.rec.net/room/9c1f.bin", "http://127.0.0.1:3031")); } [Fact] public void RejectsMalformedServerConfiguration() => Assert.Throws(() => NameserverRedirect.Rewrite("https://ns.rec.net", "localhost:3030")); }