using Microsoft.Extensions.Logging.Abstractions; using MongoDB.Entities; using PrivaPub.Federation.Crawler; using PrivaPub.Federation.Moderation; using PrivaPub.Federation.Objects; using PrivaPub.Infrastructure.Http; using PrivaPub.Infrastructure.Jobs; using PrivaPub.Infrastructure.Statistics; using PrivaPub.Models; using PrivaPub.Models.Federation; using PrivaPub.Models.Jobs; using PrivaPub.Tests.Support; using PrivaPub.Tests.Support.Host; using System.Net; using System.Text; namespace PrivaPub.Tests.Statistics { public class RobotsTxtTests { const string Robots = """ # comments are ignored User-agent: * Disallow: /private Allow: /private/ok User-agent: PrivaPub-Stargazer User-agent: other Disallow: /api/ Allow: /api/v1/instance$ User-agent: greedy Disallow: /*.json$ """; [Fact] public void The_crawlers_own_group_wins_and_the_longest_rule_decides() { var mine = RobotsTxt.Parse(Robots, "PrivaPub-Stargazer"); var anyone = RobotsTxt.Parse(Robots, "SomethingElse"); var greedy = RobotsTxt.Parse(Robots, "greedy"); Assert.False(mine.Allows("/api/v1/instance/peers")); Assert.True(mine.Allows("/api/v1/instance")); Assert.True(mine.Allows("/private")); Assert.True(mine.Allows("/.well-known/nodeinfo")); Assert.False(anyone.Allows("/private/nope")); Assert.True(anyone.Allows("/private/ok/yes")); Assert.True(anyone.Allows("/api/v1/instance/peers")); Assert.False(greedy.Allows("/nodeinfo/2.1.json")); Assert.True(greedy.Allows("/nodeinfo/2.1.json?x=1")); } [Fact] public void An_error_keeps_the_crawler_out_and_a_missing_file_lets_it_in() { Assert.True(RobotsTxt.From(404, default, Stargazer.Token).Allows("/anything")); Assert.False(RobotsTxt.From(503, default, Stargazer.Token).Allows("/anything")); Assert.False(RobotsTxt.From(0, default, Stargazer.Token).Allows("/anything")); Assert.False(RobotsTxt.From(200, "User-agent: *\nDisallow: /", Stargazer.Token).Allows("/robots.txt")); Assert.True(RobotsTxt.From(200, "User-agent: *\nDisallow:", Stargazer.Token).Allows("/x")); } [Fact] public void Peer_lists_yield_only_names_we_could_ever_reach() { var names = new[] { "Social.Example.org.", "10.0.0.1", "host.example.org:8443", "x.onion", "printer.local", "bücher.example.org", "a.test", "noDot", "social.example.org" }; Assert.Equal(new[] { "social.example.org", "xn--bcher-kva.example.org" }, PeerHosts.Filter(names, _ => true)); Assert.Contains("a.test", PeerHosts.Filter(names, _ => true, testNetwork: true)); Assert.Empty(PeerHosts.Filter(names, _ => false)); } [Fact] public void A_truncated_peers_list_still_gives_what_was_read() { var items = new List(); FederationHttp.ReadStrings(Encoding.UTF8.GetBytes("[\"a.example\", 3, {\"x\":1}, \"b.example\", \"c.exa"), 10, items); var capped = new List(); FederationHttp.ReadStrings(Encoding.UTF8.GetBytes("[\"a\",\"b\",\"c\"]"), 2, capped); Assert.Equal(new[] { "a.example", "b.example" }, items); Assert.Equal(new[] { "a", "b" }, capped); } } [Trait("Category", "Integration")] public sealed class CrawlerTests : IAsyncLifetime { Peer _peer; readonly string _run = Guid.NewGuid().ToString("N")[..10]; public async ValueTask InitializeAsync() { Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); _peer = await Peer.Start(); } public async ValueTask DisposeAsync() { if (_peer != default) await _peer.DisposeAsync(); } sealed class PeerDescriber : InstanceDescriber { readonly string _peer; public PeerDescriber(IFederationHttp http, string peer) : base(http) => _peer = peer; protected override string Address(string url) => $"{_peer}/{new Uri(url).Host}{new Uri(url).PathAndQuery}"; } sealed class PeerCrawler : InstanceCrawler { readonly string _peer; public PeerCrawler(IFederationHttp http, InstanceDescriber describer, IDomainBlocks blocks, CrawlerOptions crawler, string peer, MemoryLedger ledger) : base(http, describer, blocks, new StaticOptions(new StatisticsOptions { Crawler = crawler }), new StaticOptions(new AppConfiguration { BackendBaseAddress = "https://privapub.test" }), new StaticOptions(new FederationOptions { AllowPrivateNetworks = true }), ledger) => _peer = peer; protected override string Address(string url) => $"{_peer}/{new Uri(url).Host}{new Uri(url).PathAndQuery}"; } (PeerCrawler Crawler, MemoryLedger Ledger) Crawler(CrawlerOptions options, IDomainBlocks blocks = default) { var http = Peer.Http(); var ledger = new MemoryLedger(); return (new PeerCrawler(http, new PeerDescriber(http, _peer.A), blocks ?? new NoBlocks(), options, _peer.A, ledger), ledger); } string Host(string name) => $"{name}{_run}.example.org"; void Serve(string host, string robots = default, int robotsStatus = 200, params string[] peers) { if (robots != default) _peer.ServeText($"/{host}/robots.txt", robots, "text/plain"); else _peer.Answer($"/{host}/robots.txt", robotsStatus); _peer.Serve($"/{host}/.well-known/nodeinfo", $$"""{"links":[{"rel":"http://nodeinfo.diaspora.software/ns/schema/2.0","href":"https://{{host}}/nodeinfo/2.0"}]}"""); _peer.Serve($"/{host}/nodeinfo/2.0", """{"software":{"name":"lemmy","version":"0.19.9"},"usage":{"users":{"total":3}}}"""); _peer.Serve($"/{host}/api/v1/instance/peers", "[" + string.Join(",", peers.Select(p => $"\"{p}\"")) + "]"); } [Fact] public async Task A_crawl_describes_a_crawled_server_and_learns_its_peers_politely() { var token = TestContext.Current.CancellationToken; var host = Host("seed"); var blocked = Host("blocked"); Serve(host, "User-agent: *\nDisallow: /private", 200, Host("new1"), Host("new2"), blocked, "10.0.0.1", Host("new1")); var blocks = new DomainBlocks(NullLogger.Instance); blocks.Load(new[] { new DomainBlock { Domain = blocked, Severity = DomainBlockSeverity.Silence } }); await DB.Default.SaveAsync(new RemoteInstance { Host = host, Seen = "crawled" }, token); var (crawler, ledger) = Crawler(new CrawlerOptions { Enabled = true }, blocks); await crawler.Handle(new Job { Kind = JobKind.CrawlInstance, Payload = host }, token); var seed = await DB.Default.Find().Match(i => i.Host == host).ExecuteSingleAsync(token); Assert.Equal(("lemmy", 5, false), (seed.Software, seed.PeersCount!.Value, seed.RobotsDisallowed!.Value)); Assert.NotNull(seed.LastCrawledAt); var found = await DB.Default.Find().Match(i => i.Host == Host("new1") || i.Host == Host("new2") || i.Host == blocked).ExecuteAsync(token); Assert.Equal(new[] { Host("new1"), Host("new2") }, found.Select(i => i.Host).OrderBy(h => h)); Assert.All(found, i => Assert.Equal("crawled", i.Seen)); var visited = _peer.Requests.Where(r => r.Path.StartsWith("/" + host + "/")).ToList(); Assert.All(visited, r => Assert.Contains(r.Path[(host.Length + 1)..], new[] { "/robots.txt", "/.well-known/nodeinfo", "/nodeinfo/2.0", "/api/v1/instance/peers" })); Assert.All(visited, r => Assert.StartsWith("PrivaPub-Stargazer/", r.Headers["User-Agent"])); Assert.All(visited, r => Assert.Contains("/stargazing)", r.Headers["User-Agent"])); Assert.Equal(("crawl", "ok"), (Assert.Single(ledger.Events).Event.Channel, ledger.Events.Single().Event.Outcome)); } [Fact] public async Task Robots_txt_and_its_absence_are_obeyed_and_a_touched_server_is_not_described_again() { var token = TestContext.Current.CancellationToken; var shy = Host("shy"); var broken = Host("broken"); var touched = Host("touched"); Serve(shy, "User-agent: PrivaPub-Stargazer\nDisallow: /api/", 200, Host("hidden")); Serve(broken, default, 503, Host("never")); Serve(touched, "", 200); await DB.Default.SaveAsync(new[] { new RemoteInstance { Host = shy, Seen = "crawled" }, new RemoteInstance { Host = touched, Seen = "touched" } }, token); var (crawler, ledger) = Crawler(new CrawlerOptions { Enabled = true }); await crawler.Handle(new Job { Payload = shy }, token); await crawler.Handle(new Job { Payload = broken }, token); await crawler.Handle(new Job { Payload = touched }, token); Assert.DoesNotContain(_peer.Requests, r => r.Path == $"/{shy}/api/v1/instance/peers"); Assert.Contains(_peer.Requests, r => r.Path == $"/{shy}/nodeinfo/2.0"); Assert.Equal(new[] { $"/{broken}/robots.txt" }, _peer.Requests.Where(r => r.Path.StartsWith($"/{broken}/")).Select(r => r.Path)); Assert.True((await DB.Default.Find().Match(i => i.Host == broken).ExecuteSingleAsync(token)).RobotsDisallowed); Assert.DoesNotContain(_peer.Requests, r => r.Path.StartsWith($"/{touched}/.well-known/")); Assert.Contains(ledger.Events, e => e.Event.Host == broken && e.Event.Reason == "robots-disallowed"); Assert.False(await DB.Default.Find().Match(i => i.Host == Host("hidden") || i.Host == Host("never")).ExecuteAnyAsync(token)); } [Fact] public async Task A_disabled_crawler_does_nothing_and_the_caps_hold() { var token = TestContext.Current.CancellationToken; var host = Host("capped"); Serve(host, "", 200, Enumerable.Range(0, 20).Select(i => Host($"many{i}-")).ToArray()); await DB.Default.SaveAsync(new RemoteInstance { Host = host, Seen = "touched" }, token); await Crawler(new CrawlerOptions { Enabled = false }).Crawler.Handle(new Job { Payload = host }, token); Assert.DoesNotContain(_peer.Requests, r => r.Path.StartsWith($"/{host}/")); await Crawler(new CrawlerOptions { Enabled = true, MaxNewHostsPerCrawl = 3 }).Crawler.Handle(new Job { Payload = host }, token); Assert.Equal(3, await DB.Default.CountAsync(i => i.Host.StartsWith("many") && i.Host.EndsWith($"{_run}.example.org"), cancellation: token)); } [Fact] public async Task The_planner_spreads_due_servers_across_the_hour_and_skips_fresh_and_blocked_ones() { var token = TestContext.Current.CancellationToken; var due = Host("due"); var fresh = Host("fresh"); var blocked = Host("plannedblock"); await DB.Default.SaveAsync(new[] { new RemoteInstance { Host = due, Seen = "crawled", LastCrawledAt = new DateTime(2001, 1, 1, 0, 0, 0, DateTimeKind.Utc) }, new RemoteInstance { Host = fresh, Seen = "crawled", LastCrawledAt = DateTime.UtcNow }, new RemoteInstance { Host = blocked, Seen = "crawled", LastCrawledAt = new DateTime(2001, 1, 1, 0, 0, 0, DateTimeKind.Utc) } }, token); var blocks = new DomainBlocks(NullLogger.Instance); blocks.Load(new[] { new DomainBlock { Domain = blocked, Severity = DomainBlockSeverity.Suspend } }); var options = new StatisticsOptions { Crawler = new CrawlerOptions { Enabled = true, HostsPerHour = 600, Seeds = new() { Host("seedling"), "10.1.1.1" } } }; await new CrawlPlanner(new JobQueue(j => j.DedupeKey == "never"), blocks, new StaticOptions(options)).Handle(new Job(), token); await new CrawlPlanner(new JobQueue(j => j.DedupeKey == "never"), blocks, new StaticOptions(new StatisticsOptions())).Handle(new Job(), token); var planned = (await DB.Default.Find().Match(j => j.Kind == JobKind.CrawlInstance && j.Payload.EndsWith($"{_run}.example.org")).ExecuteAsync(token)).Select(j => j.Payload).ToList(); Assert.Contains(due, planned); Assert.Contains(Host("seedling"), planned); Assert.DoesNotContain(fresh, planned); Assert.DoesNotContain(blocked, planned); Assert.Equal("crawled", (await DB.Default.Find().Match(i => i.Host == Host("seedling")).ExecuteSingleAsync(token)).Seen); } } [Trait("Category", "Integration")] public sealed class StargazingPageTests : IAsyncLifetime { PrivaPubHost _host; public async ValueTask InitializeAsync() { Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); _host = await PrivaPubHost.Shared(); } public ValueTask DisposeAsync() => ValueTask.CompletedTask; [Fact] public async Task The_page_names_the_crawler_says_it_is_off_and_how_to_keep_it_out() { using var client = _host.Client(); using var response = await client.GetAsync("/stargazing", TestContext.Current.CancellationToken); var page = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Contains("PrivaPub-Stargazer/", page); Assert.Contains("https://privapub.test/stargazing", page); Assert.Contains("off", page); Assert.Contains("Disallow: /", page); Assert.Contains("DB-IP", page); Assert.StartsWith("default-src 'none'", response.Headers.GetValues("Content-Security-Policy").Single()); } } }