diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cd92c63 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + push: + branches: + - master + - "feat/**" + pull_request: + branches: + - master + +permissions: + contents: read + +concurrency: + group: server-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup .NET 10 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + + - name: Restore + run: dotnet restore src/OpenNet.Server/OpenNet.Server.slnx + + - name: Build + run: dotnet build src/OpenNet.Server/OpenNet.Server.slnx --configuration Release --no-restore + + - name: Test + run: dotnet test src/OpenNet.Server/OpenNet.Server.slnx --configuration Release --no-build --no-restore diff --git a/docs/long-term-seeding-directory-status.md b/docs/long-term-seeding-directory-status.md new file mode 100644 index 0000000..2b96891 --- /dev/null +++ b/docs/long-term-seeding-directory-status.md @@ -0,0 +1,101 @@ +# Long-Term Seeding Content Directory Status — 2026-09-26 + +This file records the Server-side implementation merged by the long-term seeding prototype. + +## Feature branch + +- `feat/long-term-seeding-directory` +- based directly on `master` +- CI: Restore / Build / Test green before merge + +## Implemented control plane + +The Content Directory provides: + +- logical Content objects with multiple cryptographic identities; +- node inventory registration with generation conflict protection and registration idempotency; +- node lease / heartbeat and expired-node cleanup; +- observed-source-address endpoint policy; +- bounded content lookup; +- wakeup queue, polling, completion, retry backoff and seed-ready TTL; +- canonical BEP 52 metadata cache and binary manifest retrieval; +- SQLite / MySQL provider wiring; +- privacy-preserving ResourceKey observations and bounded ResourceKey lookup. + +## ResourceKey model + +ResourceKey is discovery metadata, not content identity. + +The Server accepts a ResourceKey observation only from a currently leased node that owns the referenced content in its registered inventory. The observation maps the hashed resource descriptor to an existing Content object. + +Implemented behavior includes: + +- node resource announcement; +- ResourceKey normalization / validation; +- ResourceKey -> candidate Content lookup; +- multiple candidates where observations disagree; +- observation counts and last-observed timestamps; +- same-node remapping when a resource changes; +- invalidation when the node removes the content from its inventory; +- tests covering ownership, remapping and lookup semantics. + +Raw HTTP URLs are not required by this API. Secret-bearing URLs therefore do not need to be persisted by the Directory. + +## Canonical publication validation + +Canonical torrent publication requires an authoritative registered BEP 52 file-root identity. + +The Server validates: + +- canonical protocol version; +- canonical bencode structure; +- fixed `OpenNet.Content.v1` name/path; +- fixed 1 MiB piece length; +- exact content size; +- raw SHA-256 of the bencoded `info` dictionary; +- claimed v2 info-hash; +- BEP 52 pieces root; +- piece-layer length; +- piece-layer Merkle reduction to the registered file root. + +Conflicting canonical metadata for a logical Content is rejected. + +## Inventory refresh semantics + +A full inventory refresh is differential by ContentId. + +For unchanged content the Server preserves pending wakeup state, retry state and seed-ready TTL. Content removed from the refreshed inventory loses node presence and its node-scoped ResourceKey observations. + +## Current API surface + +Base route: + +```text +/api/v1/content +``` + +The current controller includes: + +- node registration; +- heartbeat; +- content lookup with optional prepare / wakeup; +- wakeup polling and completion; +- canonical manifest retrieval; +- node ResourceKey announcement; +- ResourceKey lookup. + +The controller/contracts remain the wire-schema authority. + +## Remaining production security work + +This is still an alpha control plane. Production hardening remains outside this prototype: + +- node public-key identity; +- signed / authenticated control requests; +- peer tickets; +- verified endpoint reachability; +- NAT traversal integration; +- rate limiting / abuse quotas; +- production EF migrations / retention policy. + +The current endpoint proof is the observed control-connection IP plus a client-declared listening port; it is not a reachability proof. diff --git a/src/OpenNet.Server/OpenNet.Server.Tests/ContentDirectoryServiceTests.cs b/src/OpenNet.Server/OpenNet.Server.Tests/ContentDirectoryServiceTests.cs new file mode 100644 index 0000000..761497b --- /dev/null +++ b/src/OpenNet.Server/OpenNet.Server.Tests/ContentDirectoryServiceTests.cs @@ -0,0 +1,623 @@ +using System.Net; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using OpenNet.Server.Application; +using OpenNet.Server.Contracts; +using OpenNet.Server.Infrastructure; +using OpenNet.Server.Options; + +namespace OpenNet.Server.Tests; + +[TestClass] +public sealed class ContentDirectoryServiceTests +{ + [TestMethod] + public async Task RegisterAndLookupRoundTripsMultipleAliases() + { + await using SqliteConnection connection = new("Data Source=:memory:"); + await connection.OpenAsync(); + + DbContextOptions dbOptions = + new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + await using var db = new ContentDirectoryDbContext(dbOptions); + await db.Database.EnsureCreatedAsync(); + + var service = new ContentDirectoryService( + db, + Microsoft.Extensions.Options.Options.Create(new ContentDirectoryOptions + { + LeaseLifetimeSeconds = 300, + MaxInventoryItems = 100, + MaxLookupPeers = 20 + })); + + RegisterContentNodeResponse registration = await service.RegisterAsync( + new RegisterContentNodeRequest + { + NodeId = "test-node-0001", + Generation = 1, + RegistrationId = Guid.NewGuid(), + Endpoints = + [ + new PeerEndpointRequest + { + Transport = PeerTransportContract.Tcp, + AddressFamily = PeerAddressFamilyContract.Ipv4, + Port = 6881 + }, + new PeerEndpointRequest + { + Transport = PeerTransportContract.Utp, + AddressFamily = PeerAddressFamilyContract.Ipv4, + Port = 6881 + } + ], + Contents = + [ + new ContentAnnouncementRequest + { + Size = 123456, + Identities = + [ + new ContentIdentityContract + { + Algorithm = 1, + Digest = new string('a', 64) + }, + new ContentIdentityContract + { + Algorithm = 128, + Digest = new string('b', 40) + } + ] + } + ] + }, + IPAddress.Parse("203.0.113.10"), + CancellationToken.None); + + Assert.AreEqual(1, registration.ContentCount); + + ContentLookupResponse? lookup = await service.LookupAsync( + 128, + new string('B', 40), + null, + null, + false, + CancellationToken.None); + + Assert.IsNotNull(lookup); + Assert.AreEqual(123456, lookup.Size); + Assert.AreEqual(1, lookup.Peers.Count); + Assert.AreEqual("test-node-0001", lookup.Peers[0].NodeId); + Assert.IsFalse(lookup.Peers[0].Ready); + Assert.IsTrue(lookup.Peers[0].Endpoints.All( + endpoint => endpoint.Address == "203.0.113.10")); + Assert.IsTrue(lookup.Peers[0].Endpoints.Any( + endpoint => endpoint.Transport == PeerTransportContract.Tcp)); + Assert.IsTrue(lookup.Peers[0].Endpoints.Any( + endpoint => endpoint.Transport == PeerTransportContract.Utp)); + + ContentHeartbeatResponse? heartbeat = await service.HeartbeatAsync( + "test-node-0001", + registration.LeaseId, + CancellationToken.None); + Assert.IsNotNull(heartbeat); + Assert.IsTrue(heartbeat.LeaseExpiresAtUtc > DateTimeOffset.UtcNow); + } + + [TestMethod] + public async Task RegistrationRejectsDuplicateAliasAcrossInventoryItems() + { + await using SqliteConnection connection = new("Data Source=:memory:"); + await connection.OpenAsync(); + + DbContextOptions dbOptions = + new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + await using var db = new ContentDirectoryDbContext(dbOptions); + await db.Database.EnsureCreatedAsync(); + + var service = new ContentDirectoryService( + db, + Microsoft.Extensions.Options.Options.Create(new ContentDirectoryOptions())); + + var request = new RegisterContentNodeRequest + { + NodeId = "test-node-0002", + Generation = 1, + RegistrationId = Guid.NewGuid(), + Contents = + [ + new ContentAnnouncementRequest + { + Size = 1, + Identities = + [ + new ContentIdentityContract + { + Algorithm = 1, + Digest = new string('c', 64) + } + ] + }, + new ContentAnnouncementRequest + { + Size = 2, + Identities = + [ + new ContentIdentityContract + { + Algorithm = 1, + Digest = new string('c', 64) + } + ] + } + ] + }; + + await Assert.ThrowsAsync( + () => service.RegisterAsync( + request, + IPAddress.Parse("203.0.113.11"), + CancellationToken.None)); + } + + [TestMethod] + public async Task SameRegistrationCanRetryAfterResponseLoss() + { + await using SqliteConnection connection = new("Data Source=:memory:"); + await connection.OpenAsync(); + + DbContextOptions dbOptions = + new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + await using var db = new ContentDirectoryDbContext(dbOptions); + await db.Database.EnsureCreatedAsync(); + + var service = new ContentDirectoryService( + db, + Microsoft.Extensions.Options.Options.Create(new ContentDirectoryOptions())); + + Guid registrationId = Guid.NewGuid(); + var request = new RegisterContentNodeRequest + { + NodeId = "test-node-retry", + Generation = 1, + RegistrationId = registrationId, + Endpoints = + [ + new PeerEndpointRequest + { + Transport = PeerTransportContract.Tcp, + Port = 6881 + } + ], + Contents = + [ + new ContentAnnouncementRequest + { + Size = 42, + Identities = + [ + new ContentIdentityContract + { + Algorithm = 1, + Digest = new string('d', 64) + } + ] + } + ] + }; + + RegisterContentNodeResponse first = await service.RegisterAsync( + request, + IPAddress.Parse("203.0.113.20"), + CancellationToken.None); + + // Simulate the server committing the first request while its HTTP + // response is lost. The client repeats the same generation and + // RegistrationId without knowing the newly-issued lease. + RegisterContentNodeResponse retry = await service.RegisterAsync( + request, + IPAddress.Parse("203.0.113.20"), + CancellationToken.None); + + Assert.AreEqual(1, retry.AcceptedGeneration); + Assert.AreEqual(1, retry.ContentCount); + Assert.AreNotEqual(first.LeaseId, retry.LeaseId); + } + + [TestMethod] + public async Task SameGenerationWithDifferentRegistrationIsRejected() + { + await using SqliteConnection connection = new("Data Source=:memory:"); + await connection.OpenAsync(); + + DbContextOptions dbOptions = + new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + await using var db = new ContentDirectoryDbContext(dbOptions); + await db.Database.EnsureCreatedAsync(); + + var service = new ContentDirectoryService( + db, + Microsoft.Extensions.Options.Options.Create(new ContentDirectoryOptions())); + + RegisterContentNodeRequest Build(Guid registrationId) => new() + { + NodeId = "test-node-generation", + Generation = 1, + RegistrationId = registrationId, + Contents = + [ + new ContentAnnouncementRequest + { + Size = 1, + Identities = + [ + new ContentIdentityContract + { + Algorithm = 3, + Digest = new string('e', 64) + } + ] + } + ] + }; + + await service.RegisterAsync( + Build(Guid.NewGuid()), + IPAddress.Parse("203.0.113.21"), + CancellationToken.None); + + await Assert.ThrowsAsync( + () => service.RegisterAsync( + Build(Guid.NewGuid()), + IPAddress.Parse("203.0.113.21"), + CancellationToken.None)); + } +} + + +[TestClass] +public sealed class ContentDirectoryWakeupTests +{ + private static byte[] BuildCanonicalManifest( + long size, + string rootHex, + out string infoHash) + { + byte[] root = Convert.FromHexString(rootHex); + using var info = new MemoryStream(); + void WriteAscii(Stream stream, string value) + { + byte[] bytes = System.Text.Encoding.ASCII.GetBytes(value); + stream.Write(bytes); + } + + WriteAscii(info, "d9:file treed7:contentd0:d6:lengthi"); + WriteAscii(info, size.ToString( + System.Globalization.CultureInfo.InvariantCulture)); + WriteAscii(info, "e11:pieces root32:"); + info.Write(root); + WriteAscii(info, + "eee12:meta versioni2e4:name18:OpenNet.Content.v1" + + "12:piece lengthi1048576ee"); + + byte[] infoBytes = info.ToArray(); + infoHash = Convert.ToHexString( + System.Security.Cryptography.SHA256.HashData(infoBytes)) + .ToLowerInvariant(); + + using var torrent = new MemoryStream(); + WriteAscii(torrent, "d4:info"); + torrent.Write(infoBytes); + WriteAscii(torrent, "12:piece layersdee"); + return torrent.ToArray(); + } + + [TestMethod] + public async Task LookupWakeupCompletionPublishesReadyCanonicalManifest() + { + await using SqliteConnection connection = new("Data Source=:memory:"); + await connection.OpenAsync(); + + DbContextOptions dbOptions = + new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + await using var db = new ContentDirectoryDbContext(dbOptions); + await db.Database.EnsureCreatedAsync(); + + var service = new ContentDirectoryService( + db, + Microsoft.Extensions.Options.Options.Create( + new ContentDirectoryOptions())); + + RegisterContentNodeResponse registration = await service.RegisterAsync( + new RegisterContentNodeRequest + { + NodeId = "test-node-wakeup", + Generation = 1, + RegistrationId = Guid.NewGuid(), + Endpoints = + [ + new PeerEndpointRequest + { + AddressFamily = PeerAddressFamilyContract.Ipv4, + Transport = PeerTransportContract.Tcp, + Port = 6881 + } + ], + Contents = + [ + new ContentAnnouncementRequest + { + Size = 1024, + Identities = + [ + new ContentIdentityContract + { + Algorithm = 1, + Digest = new string('f', 64) + } + ] + } + ] + }, + IPAddress.Parse("203.0.113.30"), + CancellationToken.None); + + ContentLookupResponse? pending = await service.LookupAsync( + 1, + new string('f', 64), + 20, + null, + true, + CancellationToken.None); + Assert.IsNotNull(pending); + Assert.IsFalse(pending.ManifestAvailable); + Assert.IsTrue(pending.Peers.All(peer => !peer.Ready)); + + IReadOnlyList? wakeups = + await service.PollWakeupsAsync( + "test-node-wakeup", + registration.LeaseId, + null, + CancellationToken.None); + Assert.IsNotNull(wakeups); + Assert.HasCount(1, wakeups); + + byte[] manifest = BuildCanonicalManifest( + 1024, + new string('f', 64), + out string canonicalInfoHash); + bool completed = await service.CompleteWakeupAsync( + "test-node-wakeup", + wakeups[0].WakeRequestId, + new CompleteContentWakeupRequest + { + LeaseId = registration.LeaseId, + Succeeded = true, + CanonicalProtocolVersion = 1, + CanonicalInfoHashV2 = canonicalInfoHash, + CanonicalTorrentBase64 = Convert.ToBase64String(manifest) + }, + CancellationToken.None); + Assert.IsTrue(completed); + + ContentLookupResponse? ready = await service.LookupAsync( + 1, + new string('f', 64), + 20, + null, + true, + CancellationToken.None); + Assert.IsNotNull(ready); + Assert.IsTrue(ready.ManifestAvailable); + Assert.AreEqual(canonicalInfoHash, ready.CanonicalInfoHashV2); + Assert.IsTrue(ready.Peers.Any(peer => peer.Ready)); + + byte[]? stored = await service.GetCanonicalManifestAsync( + ready.ContentId, + CancellationToken.None); + Assert.IsNotNull(stored); + CollectionAssert.AreEqual(manifest, stored); + } + + [TestMethod] + public async Task CompleteWakeupRejectsCanonicalManifestWithoutBep52Root() + { + await using SqliteConnection connection = new("Data Source=:memory:"); + await connection.OpenAsync(); + + DbContextOptions dbOptions = + new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + await using var db = new ContentDirectoryDbContext(dbOptions); + await db.Database.EnsureCreatedAsync(); + + var service = new ContentDirectoryService( + db, + Microsoft.Extensions.Options.Options.Create( + new ContentDirectoryOptions())); + + RegisterContentNodeResponse registration = await service.RegisterAsync( + new RegisterContentNodeRequest + { + NodeId = "test-node-no-v2-root", + Generation = 1, + RegistrationId = Guid.NewGuid(), + Contents = + [ + new ContentAnnouncementRequest + { + Size = 1024, + Identities = + [ + new ContentIdentityContract + { + Algorithm = 3, + Digest = new string('d', 64) + } + ] + } + ] + }, + IPAddress.Parse("203.0.113.31"), + CancellationToken.None); + + ContentLookupResponse? lookup = await service.LookupAsync( + 3, + new string('d', 64), + 20, + null, + true, + CancellationToken.None); + Assert.IsNotNull(lookup); + + IReadOnlyList? wakeups = + await service.PollWakeupsAsync( + "test-node-no-v2-root", + registration.LeaseId, + null, + CancellationToken.None); + Assert.IsNotNull(wakeups); + Assert.HasCount(1, wakeups); + + await Assert.ThrowsAsync( + () => service.CompleteWakeupAsync( + "test-node-no-v2-root", + wakeups[0].WakeRequestId, + new CompleteContentWakeupRequest + { + LeaseId = registration.LeaseId, + Succeeded = true, + CanonicalProtocolVersion = 1, + CanonicalInfoHashV2 = new string('a', 64), + CanonicalTorrentBase64 = + Convert.ToBase64String([1, 2, 3]) + }, + CancellationToken.None)); + } + + [TestMethod] + public async Task InventoryRefreshPreservesReadyStateForUnchangedContent() + { + await using SqliteConnection connection = new("Data Source=:memory:"); + await connection.OpenAsync(); + + DbContextOptions dbOptions = + new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + await using var db = new ContentDirectoryDbContext(dbOptions); + await db.Database.EnsureCreatedAsync(); + + var service = new ContentDirectoryService( + db, + Microsoft.Extensions.Options.Options.Create( + new ContentDirectoryOptions())); + + RegisterContentNodeRequest Build( + long generation, + Guid registrationId, + Guid? previousLeaseId) => new() + { + NodeId = "test-node-preserve-ready", + Generation = generation, + RegistrationId = registrationId, + PreviousLeaseId = previousLeaseId, + Endpoints = + [ + new PeerEndpointRequest + { + AddressFamily = PeerAddressFamilyContract.Ipv4, + Transport = PeerTransportContract.Tcp, + Port = 6881 + } + ], + Contents = + [ + new ContentAnnouncementRequest + { + Size = 2048, + Identities = + [ + new ContentIdentityContract + { + Algorithm = 1, + Digest = new string('9', 64) + } + ] + } + ] + }; + + RegisterContentNodeResponse first = await service.RegisterAsync( + Build(1, Guid.NewGuid(), null), + IPAddress.Parse("203.0.113.40"), + CancellationToken.None); + + ContentLookupResponse? pending = await service.LookupAsync( + 1, + new string('9', 64), + 20, + null, + true, + CancellationToken.None); + Assert.IsNotNull(pending); + + IReadOnlyList? wakeups = + await service.PollWakeupsAsync( + "test-node-preserve-ready", + first.LeaseId, + null, + CancellationToken.None); + Assert.IsNotNull(wakeups); + Assert.HasCount(1, wakeups); + + byte[] manifest = BuildCanonicalManifest( + 2048, + new string('9', 64), + out string canonicalInfoHash); + Assert.IsTrue(await service.CompleteWakeupAsync( + "test-node-preserve-ready", + wakeups[0].WakeRequestId, + new CompleteContentWakeupRequest + { + LeaseId = first.LeaseId, + Succeeded = true, + CanonicalProtocolVersion = 1, + CanonicalInfoHashV2 = canonicalInfoHash, + CanonicalTorrentBase64 = + Convert.ToBase64String(manifest) + }, + CancellationToken.None)); + + RegisterContentNodeResponse refreshed = await service.RegisterAsync( + Build(2, Guid.NewGuid(), first.LeaseId), + IPAddress.Parse("203.0.113.40"), + CancellationToken.None); + + ContentLookupResponse? ready = await service.LookupAsync( + 1, + new string('9', 64), + 20, + null, + false, + CancellationToken.None); + Assert.IsNotNull(ready); + Assert.IsTrue(ready.Peers.Any(peer => + peer.NodeId == "test-node-preserve-ready" && peer.Ready)); + Assert.AreEqual(2, refreshed.AcceptedGeneration); + } +} diff --git a/src/OpenNet.Server/OpenNet.Server.Tests/ResourceDirectoryTests.cs b/src/OpenNet.Server/OpenNet.Server.Tests/ResourceDirectoryTests.cs new file mode 100644 index 0000000..55c961f --- /dev/null +++ b/src/OpenNet.Server/OpenNet.Server.Tests/ResourceDirectoryTests.cs @@ -0,0 +1,324 @@ +using System.Net; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OpenNet.Server.Application; +using OpenNet.Server.Contracts; +using OpenNet.Server.Infrastructure; +using OpenNet.Server.Options; + +namespace OpenNet.Server.Tests; + +[TestClass] +public sealed class ResourceDirectoryTests +{ + private static async Task<(ContentDirectoryDbContext Db, ContentDirectoryService Service, RegisterContentNodeResponse Registration)> + CreateRegisteredNodeAsync( + SqliteConnection connection, + string nodeId, + string bep52Digest, + string sha256Digest) + { + DbContextOptions dbOptions = + new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + var db = new ContentDirectoryDbContext(dbOptions); + await db.Database.EnsureCreatedAsync(); + + var service = new ContentDirectoryService( + db, + Microsoft.Extensions.Options.Options.Create( + new ContentDirectoryOptions())); + + RegisterContentNodeResponse registration = await service.RegisterAsync( + new RegisterContentNodeRequest + { + NodeId = nodeId, + Generation = 1, + RegistrationId = Guid.NewGuid(), + Endpoints = + [ + new PeerEndpointRequest + { + AddressFamily = PeerAddressFamilyContract.Ipv4, + Transport = PeerTransportContract.Tcp, + Port = 6881 + } + ], + Contents = + [ + new ContentAnnouncementRequest + { + Size = 4096, + Identities = + [ + new ContentIdentityContract + { + Algorithm = 1, + Digest = bep52Digest + }, + new ContentIdentityContract + { + Algorithm = 3, + Digest = sha256Digest + } + ] + } + ] + }, + IPAddress.Parse("203.0.113.50"), + CancellationToken.None); + + return (db, service, registration); + } + + [TestMethod] + public async Task ResourceAnnouncementCanResolveRegisteredContent() + { + await using SqliteConnection connection = new("Data Source=:memory:"); + await connection.OpenAsync(); + + string root = new string('1', 64); + string sha256 = new string('2', 64); + var context = await CreateRegisteredNodeAsync( + connection, + "resource-node-0001", + root, + sha256); + await using ContentDirectoryDbContext db = context.Db; + + bool announced = await context.Service.AnnounceResourceAsync( + "resource-node-0001", + new ResourceAnnouncementRequest + { + LeaseId = context.Registration.LeaseId, + ResourceKey = new ResourceKeyContract + { + Algorithm = 1, + Digest = new string('a', 64) + }, + ContentIdentity = new ContentIdentityContract + { + Algorithm = 1, + Digest = root + } + }, + CancellationToken.None); + Assert.IsTrue(announced); + + ResourceLookupResponse? lookup = + await context.Service.LookupResourceAsync( + 1, + new string('A', 64), + null, + CancellationToken.None); + Assert.IsNotNull(lookup); + Assert.HasCount(1, lookup.Candidates); + Assert.AreEqual(4096, lookup.Candidates[0].Size); + Assert.IsTrue(lookup.Candidates[0].Identities.Any( + identity => identity.Algorithm == 1 + && identity.Digest == root)); + Assert.IsTrue(lookup.Candidates[0].Identities.Any( + identity => identity.Algorithm == 3 + && identity.Digest == sha256)); + } + + [TestMethod] + public async Task ResourceAnnouncementRequiresCurrentInventoryOwnership() + { + await using SqliteConnection connection = new("Data Source=:memory:"); + await connection.OpenAsync(); + + var context = await CreateRegisteredNodeAsync( + connection, + "resource-node-0002", + new string('3', 64), + new string('4', 64)); + await using ContentDirectoryDbContext db = context.Db; + + await Assert.ThrowsAsync( + () => context.Service.AnnounceResourceAsync( + "resource-node-0002", + new ResourceAnnouncementRequest + { + LeaseId = context.Registration.LeaseId, + ResourceKey = new ResourceKeyContract + { + Algorithm = 1, + Digest = new string('b', 64) + }, + ContentIdentity = new ContentIdentityContract + { + Algorithm = 3, + Digest = new string('9', 64) + } + }, + CancellationToken.None)); + } + + [TestMethod] + public async Task SameNodeCanRemapChangedResource() + { + await using SqliteConnection connection = new("Data Source=:memory:"); + await connection.OpenAsync(); + + DbContextOptions dbOptions = + new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + await using var db = new ContentDirectoryDbContext(dbOptions); + await db.Database.EnsureCreatedAsync(); + var service = new ContentDirectoryService( + db, + Microsoft.Extensions.Options.Options.Create( + new ContentDirectoryOptions())); + + string firstRoot = new string('5', 64); + string secondRoot = new string('6', 64); + RegisterContentNodeResponse registration = await service.RegisterAsync( + new RegisterContentNodeRequest + { + NodeId = "resource-node-remap", + Generation = 1, + RegistrationId = Guid.NewGuid(), + Contents = + [ + new ContentAnnouncementRequest + { + Size = 100, + Identities = + [ + new ContentIdentityContract + { + Algorithm = 1, + Digest = firstRoot + } + ] + }, + new ContentAnnouncementRequest + { + Size = 200, + Identities = + [ + new ContentIdentityContract + { + Algorithm = 1, + Digest = secondRoot + } + ] + } + ] + }, + IPAddress.Parse("203.0.113.51"), + CancellationToken.None); + + ResourceKeyContract key = new() + { + Algorithm = 1, + Digest = new string('c', 64) + }; + + Assert.IsTrue(await service.AnnounceResourceAsync( + "resource-node-remap", + new ResourceAnnouncementRequest + { + LeaseId = registration.LeaseId, + ResourceKey = key, + ContentIdentity = new ContentIdentityContract + { + Algorithm = 1, + Digest = firstRoot + } + }, + CancellationToken.None)); + + Assert.IsTrue(await service.AnnounceResourceAsync( + "resource-node-remap", + new ResourceAnnouncementRequest + { + LeaseId = registration.LeaseId, + ResourceKey = key, + ContentIdentity = new ContentIdentityContract + { + Algorithm = 1, + Digest = secondRoot + } + }, + CancellationToken.None)); + + ResourceLookupResponse? lookup = await service.LookupResourceAsync( + 1, + key.Digest, + null, + CancellationToken.None); + Assert.IsNotNull(lookup); + Assert.HasCount(1, lookup.Candidates); + Assert.AreEqual(200, lookup.Candidates[0].Size); + Assert.IsTrue(lookup.Candidates[0].Identities.Any( + identity => identity.Digest == secondRoot)); + } + + [TestMethod] + public async Task InventoryRemovalInvalidatesResourceObservation() + { + await using SqliteConnection connection = + new("Data Source=:memory:"); + await connection.OpenAsync(); + + string root = new string('7', 64); + string sha256 = new string('8', 64); + var context = await CreateRegisteredNodeAsync( + connection, + "resource-node-removal", + root, + sha256); + await using ContentDirectoryDbContext db = context.Db; + + ResourceKeyContract key = new() + { + Algorithm = 1, + Digest = new string('d', 64) + }; + + Assert.IsTrue(await context.Service.AnnounceResourceAsync( + "resource-node-removal", + new ResourceAnnouncementRequest + { + LeaseId = context.Registration.LeaseId, + ResourceKey = key, + ContentIdentity = new ContentIdentityContract + { + Algorithm = 1, + Digest = root + } + }, + CancellationToken.None)); + + Assert.IsNotNull(await context.Service.LookupResourceAsync( + 1, + key.Digest, + null, + CancellationToken.None)); + + RegisterContentNodeResponse refreshed = + await context.Service.RegisterAsync( + new RegisterContentNodeRequest + { + NodeId = "resource-node-removal", + Generation = 2, + RegistrationId = Guid.NewGuid(), + PreviousLeaseId = context.Registration.LeaseId, + Contents = [] + }, + IPAddress.Parse("203.0.113.50"), + CancellationToken.None); + Assert.AreEqual(0, refreshed.ContentCount); + + Assert.IsNull(await context.Service.LookupResourceAsync( + 1, + key.Digest, + null, + CancellationToken.None)); + } +} diff --git a/src/OpenNet.Server/OpenNet.Server.Tests/UpdateCatalogServiceTests.cs b/src/OpenNet.Server/OpenNet.Server.Tests/UpdateCatalogServiceTests.cs index 5331894..50e61f5 100644 --- a/src/OpenNet.Server/OpenNet.Server.Tests/UpdateCatalogServiceTests.cs +++ b/src/OpenNet.Server/OpenNet.Server.Tests/UpdateCatalogServiceTests.cs @@ -52,7 +52,7 @@ private static UpdateCatalogService CreateService( params UpdatePackageOptions[] packages) { return new UpdateCatalogService( - Options.Create(new UpdateCatalogOptions + Microsoft.Extensions.Options.Options.Create(new UpdateCatalogOptions { Packages = packages })); diff --git a/src/OpenNet.Server/OpenNet.Server/Application/CanonicalTorrentValidator.cs b/src/OpenNet.Server/OpenNet.Server/Application/CanonicalTorrentValidator.cs new file mode 100644 index 0000000..7cc349a --- /dev/null +++ b/src/OpenNet.Server/OpenNet.Server/Application/CanonicalTorrentValidator.cs @@ -0,0 +1,544 @@ +using System.Security.Cryptography; +using System.Text; + +namespace OpenNet.Server.Application; + +internal static class CanonicalTorrentValidator +{ + private const int CanonicalPieceLength = 1024 * 1024; + private const int Bep52BlockLength = 16 * 1024; + + public static bool Validate( + byte[] metainfo, + long expectedSize, + string expectedFileRootHex, + string claimedInfoHashHex, + out string error) + { + error = string.Empty; + if (expectedSize <= 0) + { + error = "Canonical v2 metadata is not defined for empty content."; + return false; + } + + byte[] expectedRoot; + try + { + expectedRoot = Convert.FromHexString(expectedFileRootHex); + } + catch (FormatException) + { + error = "The registered BEP 52 root is malformed."; + return false; + } + + if (expectedRoot.Length != 32) + { + error = "The registered BEP 52 root must be 32 bytes."; + return false; + } + + Node root; + try + { + var parser = new Parser(metainfo); + root = parser.Parse(); + } + catch (FormatException exception) + { + error = "Invalid bencoded canonical torrent: " + exception.Message; + return false; + } + + try + { + if (root.Dictionary is null || root.Dictionary.Count != 2) + { + error = "Canonical torrent must contain exactly info and piece layers."; + return false; + } + + Node? info = FindAscii(root, "info"); + Node? pieceLayers = FindAscii(root, "piece layers"); + if (info?.Dictionary is null || pieceLayers?.Dictionary is null) + { + error = "Canonical torrent is missing info or piece layers."; + return false; + } + + string actualInfoHash = Convert.ToHexString( + SHA256.HashData( + metainfo.AsSpan(info.Start, info.End - info.Start))) + .ToLowerInvariant(); + if (!actualInfoHash.Equals( + claimedInfoHashHex, + StringComparison.OrdinalIgnoreCase)) + { + error = "Canonical v2 info-hash does not match the bencoded info dictionary."; + return false; + } + + if (info.Dictionary.Count != 4 + || ReadInteger(info, "meta version") != 2 + || ReadInteger(info, "piece length") != CanonicalPieceLength + || !ReadBytes(info, "name").AsSpan() + .SequenceEqual("OpenNet.Content.v1"u8)) + { + error = "Canonical info dictionary does not match OpenNet.Content.v1."; + return false; + } + + Node? fileTree = FindAscii(info, "file tree"); + if (fileTree?.Dictionary is null || fileTree.Dictionary.Count != 1) + { + error = "Canonical file tree must contain exactly one file."; + return false; + } + + Entry fileEntry = fileTree.Dictionary[0]; + if (!fileEntry.Key.AsSpan().SequenceEqual("content"u8) + || fileEntry.Value.Dictionary is not { Count: 1 } fileNode + || fileNode[0].Key.Length != 0 + || fileNode[0].Value.Dictionary is not { Count: 2 }) + { + error = "Canonical file path must be OpenNet.Content.v1/content."; + return false; + } + + Node leafNode = fileNode[0].Value; + if (ReadInteger(leafNode, "length") != expectedSize) + { + error = "Canonical torrent file size does not match the registered content."; + return false; + } + + byte[] piecesRoot = ReadBytes(leafNode, "pieces root"); + if (!piecesRoot.AsSpan().SequenceEqual(expectedRoot)) + { + error = "Canonical torrent pieces root does not match the registered BEP 52 identity."; + return false; + } + + long expectedPieces64 = + 1 + ((expectedSize - 1) / CanonicalPieceLength); + if (expectedPieces64 > int.MaxValue) + { + error = "Canonical content contains too many pieces."; + return false; + } + + int expectedPieces = (int)expectedPieces64; + if (expectedPieces == 1) + { + if (pieceLayers.Dictionary.Count != 0) + { + error = "Single-piece canonical content must not carry a piece layer."; + return false; + } + + return true; + } + + if (pieceLayers.Dictionary.Count != 1) + { + error = "Multi-piece canonical content must carry exactly one piece layer."; + return false; + } + + Entry layer = pieceLayers.Dictionary[0]; + long expectedLayerBytes = (long)expectedPieces * 32; + if (!layer.Key.AsSpan().SequenceEqual(expectedRoot) + || layer.Value.Bytes is not { } layerBytes + || layerBytes.LongLength != expectedLayerBytes) + { + error = "Canonical piece layer key or size is inconsistent."; + return false; + } + + var pieceRoots = new byte[expectedPieces][]; + for (int index = 0; index < expectedPieces; ++index) + { + pieceRoots[index] = + layerBytes.AsSpan(index * 32, 32).ToArray(); + } + + byte[] computedRoot = MerkleRoot( + pieceRoots, + PieceLayerPadHash()); + if (!computedRoot.AsSpan().SequenceEqual(expectedRoot)) + { + error = "Canonical piece layer does not resolve to the registered BEP 52 root."; + return false; + } + + return true; + } + catch (Exception exception) when ( + exception is FormatException + or OverflowException + or ArgumentException) + { + error = "Invalid canonical torrent: " + exception.Message; + return false; + } + } + + private static long ReadInteger(Node dictionary, string key) + { + Node? node = FindAscii(dictionary, key); + if (node?.Integer is not long value) + { + throw new FormatException($"Missing integer field '{key}'."); + } + + return value; + } + + private static byte[] ReadBytes(Node dictionary, string key) + { + Node? node = FindAscii(dictionary, key); + if (node?.Bytes is not { } value) + { + throw new FormatException($"Missing byte-string field '{key}'."); + } + + return value; + } + + private static Node? FindAscii(Node dictionary, string key) + { + if (dictionary.Dictionary is null) + { + return null; + } + + ReadOnlySpan wanted = Encoding.ASCII.GetBytes(key); + foreach (Entry entry in dictionary.Dictionary) + { + if (entry.Key.AsSpan().SequenceEqual(wanted)) + { + return entry.Value; + } + } + + return null; + } + + private static byte[] PieceLayerPadHash() + { + byte[] pad = new byte[32]; + for (int pieces = 1; + pieces < CanonicalPieceLength / Bep52BlockLength; + pieces *= 2) + { + pad = HashPair(pad, pad); + } + + return pad; + } + + private static byte[] MerkleRoot( + IReadOnlyList leaves, + byte[] initialPad) + { + if (leaves.Count == 0) + { + throw new ArgumentException("Merkle tree requires at least one leaf."); + } + + int targetLeaves = 1; + while (targetLeaves < leaves.Count) + { + targetLeaves <<= 1; + } + + var level = leaves.Select(leaf => leaf.ToArray()).ToList(); + byte[] pad = initialPad.ToArray(); + int logicalLeaves = targetLeaves; + + while (logicalLeaves > 1) + { + var next = new List((level.Count + 1) / 2); + int index = 0; + for (; index + 1 < level.Count; index += 2) + { + next.Add(HashPair(level[index], level[index + 1])); + } + + if (index < level.Count) + { + next.Add(HashPair(level[index], pad)); + } + + pad = HashPair(pad, pad); + level = next; + logicalLeaves >>= 1; + } + + if (level.Count != 1) + { + throw new FormatException("Invalid canonical Merkle layer."); + } + + return level[0]; + } + + private static byte[] HashPair( + ReadOnlySpan left, + ReadOnlySpan right) + { + Span input = stackalloc byte[64]; + left.CopyTo(input[..32]); + right.CopyTo(input[32..]); + return SHA256.HashData(input); + } + + private sealed class Node + { + public required int Start { get; init; } + public required int End { get; init; } + public long? Integer { get; init; } + public byte[]? Bytes { get; init; } + public List? List { get; init; } + public List? Dictionary { get; init; } + } + + private sealed record Entry(byte[] Key, Node Value); + + private sealed class Parser(byte[] data) + { + private int offset; + private int nodes; + + public Node Parse() + { + Node root = ReadNode(0); + if (offset != data.Length) + { + throw new FormatException("Trailing bytes after root value."); + } + + return root; + } + + private Node ReadNode(int depth) + { + if (depth > 64) + { + throw new FormatException("Bencode nesting exceeds 64 levels."); + } + + if (++nodes > 1_000_000) + { + throw new FormatException("Bencode contains too many nodes."); + } + + if (offset >= data.Length) + { + throw new FormatException("Unexpected end of input."); + } + + int start = offset; + byte token = data[offset]; + if (token == (byte)'i') + { + ++offset; + long integer = ReadIntegerToken(); + return new Node + { + Start = start, + End = offset, + Integer = integer + }; + } + + if (token == (byte)'l') + { + ++offset; + var list = new List(); + while (!TryConsumeEnd()) + { + list.Add(ReadNode(depth + 1)); + } + + return new Node + { + Start = start, + End = offset, + List = list + }; + } + + if (token == (byte)'d') + { + ++offset; + var dictionary = new List(); + byte[]? previousKey = null; + while (!TryConsumeEnd()) + { + byte[] key = ReadByteString(); + if (previousKey is not null + && CompareBytes(previousKey, key) >= 0) + { + throw new FormatException( + "Dictionary keys are not strictly sorted."); + } + + previousKey = key; + dictionary.Add(new Entry( + key, + ReadNode(depth + 1))); + } + + return new Node + { + Start = start, + End = offset, + Dictionary = dictionary + }; + } + + if (token is >= (byte)'0' and <= (byte)'9') + { + byte[] bytes = ReadByteString(); + return new Node + { + Start = start, + End = offset, + Bytes = bytes + }; + } + + throw new FormatException( + $"Unexpected bencode token 0x{token:x2}."); + } + + private bool TryConsumeEnd() + { + if (offset >= data.Length) + { + throw new FormatException("Unterminated container."); + } + + if (data[offset] != (byte)'e') + { + return false; + } + + ++offset; + return true; + } + + private long ReadIntegerToken() + { + int start = offset; + while (offset < data.Length && data[offset] != (byte)'e') + { + ++offset; + } + + if (offset >= data.Length) + { + throw new FormatException("Unterminated integer."); + } + + ReadOnlySpan token = data.AsSpan(start, offset - start); + ++offset; + if (token.IsEmpty + || token[0] == (byte)'+' + || (token.Length > 1 && token[0] == (byte)'0') + || (token.Length >= 2 + && token[0] == (byte)'-' + && token[1] == (byte)'0') + || (token[0] == (byte)'-' && token.Length == 1)) + { + throw new FormatException("Non-canonical integer."); + } + + for (int index = token[0] == (byte)'-' ? 1 : 0; + index < token.Length; + ++index) + { + if (token[index] is < (byte)'0' or > (byte)'9') + { + throw new FormatException("Invalid integer."); + } + } + + string text = Encoding.ASCII.GetString(token); + if (!long.TryParse( + text, + System.Globalization.NumberStyles.AllowLeadingSign, + System.Globalization.CultureInfo.InvariantCulture, + out long value)) + { + throw new FormatException("Invalid integer."); + } + + return value; + } + + private byte[] ReadByteString() + { + int lengthStart = offset; + while (offset < data.Length + && data[offset] is >= (byte)'0' and <= (byte)'9') + { + ++offset; + } + + if (offset == lengthStart + || offset >= data.Length + || data[offset] != (byte)':') + { + throw new FormatException("Invalid byte-string length."); + } + + ReadOnlySpan lengthToken = + data.AsSpan(lengthStart, offset - lengthStart); + if (lengthToken.Length > 1 + && lengthToken[0] == (byte)'0') + { + throw new FormatException("Non-canonical byte-string length."); + } + + if (!int.TryParse( + Encoding.ASCII.GetString(lengthToken), + System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, + out int length) + || length < 0) + { + throw new FormatException("Invalid byte-string length."); + } + + ++offset; + if (length > data.Length - offset) + { + throw new FormatException("Byte string exceeds input."); + } + + byte[] value = data.AsSpan(offset, length).ToArray(); + offset += length; + return value; + } + + private static int CompareBytes( + ReadOnlySpan left, + ReadOnlySpan right) + { + int length = Math.Min(left.Length, right.Length); + for (int index = 0; index < length; ++index) + { + int comparison = left[index].CompareTo(right[index]); + if (comparison != 0) + { + return comparison; + } + } + + return left.Length.CompareTo(right.Length); + } + } +} diff --git a/src/OpenNet.Server/OpenNet.Server/Application/ContentDirectoryCleanupService.cs b/src/OpenNet.Server/OpenNet.Server/Application/ContentDirectoryCleanupService.cs new file mode 100644 index 0000000..fdb1286 --- /dev/null +++ b/src/OpenNet.Server/OpenNet.Server/Application/ContentDirectoryCleanupService.cs @@ -0,0 +1,73 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using OpenNet.Server.Domain; +using OpenNet.Server.Infrastructure; +using OpenNet.Server.Options; + +namespace OpenNet.Server.Application; + +public sealed class ContentDirectoryCleanupService( + IServiceScopeFactory scopeFactory, + IOptions options, + ILogger logger) + : BackgroundService +{ + private readonly TimeSpan interval = TimeSpan.FromSeconds( + Math.Clamp(options.Value.LeaseLifetimeSeconds / 2, 30, 300)); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(interval); + + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + try + { + await using AsyncServiceScope scope = scopeFactory.CreateAsyncScope(); + ContentDirectoryDbContext db = + scope.ServiceProvider.GetRequiredService(); + + DateTimeOffset now = DateTimeOffset.UtcNow; + List expiredResources = + await db.ResourceObservations + .Where(observation => observation.ExpiresUtc <= now) + .ToListAsync(stoppingToken); + if (expiredResources.Count != 0) + { + db.ResourceObservations.RemoveRange(expiredResources); + } + + List expiredNodes = await db.ContentNodes + .Where(node => node.LeaseExpiresUtc <= now) + .ToListAsync(stoppingToken); + + if (expiredNodes.Count != 0) + { + db.ContentNodes.RemoveRange(expiredNodes); + } + + if (expiredNodes.Count == 0 && expiredResources.Count == 0) + { + continue; + } + + await db.SaveChangesAsync(stoppingToken); + + logger.LogDebug( + "Removed {ExpiredNodeCount} expired content-directory nodes and {ExpiredResourceCount} expired resource observations.", + expiredNodes.Count, + expiredResources.Count); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception exception) + { + logger.LogWarning( + exception, + "Content-directory lease cleanup failed."); + } + } + } +} diff --git a/src/OpenNet.Server/OpenNet.Server/Application/ContentDirectoryService.cs b/src/OpenNet.Server/OpenNet.Server/Application/ContentDirectoryService.cs new file mode 100644 index 0000000..a9d681e --- /dev/null +++ b/src/OpenNet.Server/OpenNet.Server/Application/ContentDirectoryService.cs @@ -0,0 +1,814 @@ +using System.Net; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using OpenNet.Server.Contracts; +using OpenNet.Server.Domain; +using OpenNet.Server.Infrastructure; +using OpenNet.Server.Options; + +namespace OpenNet.Server.Application; + +public sealed class ContentDirectoryConflictException(string message) : Exception(message); + +public sealed class ContentDirectoryService( + ContentDirectoryDbContext dbContext, + IOptions options) +{ + private readonly ContentDirectoryOptions directoryOptions = options.Value; + + private sealed record NormalizedIdentity( + ContentIdentityAlgorithm Algorithm, + string DigestHex); + + private sealed record NormalizedAnnouncement( + long Size, + IReadOnlyList Identities); + + public async Task RegisterAsync( + RegisterContentNodeRequest request, + IPAddress observedRemoteAddress, + CancellationToken cancellationToken) + { + if (request.Contents.Count > directoryOptions.MaxInventoryItems) + { + throw new ArgumentException( + $"Inventory contains {request.Contents.Count} items; the configured limit is {directoryOptions.MaxInventoryItems}.", + nameof(request.Contents)); + } + + if (request.RegistrationId == Guid.Empty) + { + throw new ArgumentException( + "RegistrationId must be a non-empty UUID.", + nameof(request.RegistrationId)); + } + + string nodeId = request.NodeId.Trim(); + if (nodeId.Length < 8 || nodeId.Length > 128) + { + throw new ArgumentException("NodeId length must be between 8 and 128 characters.", nameof(request.NodeId)); + } + + List announcements = + request.Contents.Select(NormalizeAnnouncement).ToList(); + EnsureRequestAliasesAreUnambiguous(announcements); + + IPAddress observedAddress = ContentIdentityRules.NormalizeObservedAddress(observedRemoteAddress); + DateTimeOffset now = DateTimeOffset.UtcNow; + DateTimeOffset leaseExpiresUtc = now.AddSeconds(directoryOptions.LeaseLifetimeSeconds); + + await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); + + ContentNode? node = await dbContext.ContentNodes + .SingleOrDefaultAsync(item => item.NodeId == nodeId, cancellationToken); + + if (node is null) + { + node = new ContentNode + { + NodeId = nodeId, + CreatedUtc = now + }; + dbContext.ContentNodes.Add(node); + } + else + { + if (request.Generation < node.Generation) + { + throw new ContentDirectoryConflictException( + $"Inventory generation {request.Generation} is older than the server generation {node.Generation}."); + } + + if (request.Generation == node.Generation + && node.LastRegistrationId != request.RegistrationId) + { + throw new ContentDirectoryConflictException( + "The inventory generation is already committed by a different registration request."); + } + + if (request.Generation > node.Generation + && node.LeaseExpiresUtc > now + && request.PreviousLeaseId != node.LeaseId) + { + throw new ContentDirectoryConflictException( + "The node already has an active lease. Supply PreviousLeaseId to replace its inventory."); + } + } + + node.Generation = request.Generation; + node.LastRegistrationId = request.RegistrationId; + node.LeaseId = Guid.NewGuid(); + node.LeaseExpiresUtc = leaseExpiresUtc; + node.LastSeenUtc = now; + + List oldEndpoints = await dbContext.ContentNodeEndpoints + .Where(endpoint => endpoint.NodeId == nodeId) + .ToListAsync(cancellationToken); + dbContext.ContentNodeEndpoints.RemoveRange(oldEndpoints); + + List oldPresences = await dbContext.ContentPresences + .Where(presence => presence.NodeId == nodeId) + .ToListAsync(cancellationToken); + Dictionary previousPresences = + oldPresences.ToDictionary(presence => presence.ContentId); + + bool observedIpv6 = observedAddress.AddressFamily + == System.Net.Sockets.AddressFamily.InterNetworkV6; + PeerAddressFamilyContract observedFamily = observedIpv6 + ? PeerAddressFamilyContract.Ipv6 + : PeerAddressFamilyContract.Ipv4; + + foreach (PeerEndpointRequest endpoint in request.Endpoints + .Where(endpoint => endpoint.AddressFamily == observedFamily) + .DistinctBy(endpoint => (endpoint.Transport, endpoint.Port))) + { + dbContext.ContentNodeEndpoints.Add(new ContentNodeEndpoint + { + NodeId = nodeId, + Address = observedAddress.ToString(), + IsIpv6 = observedIpv6, + Port = endpoint.Port, + Transport = endpoint.Transport switch + { + PeerTransportContract.Tcp => ContentPeerTransport.Tcp, + PeerTransportContract.Utp => ContentPeerTransport.Utp, + _ => throw new ArgumentOutOfRangeException(nameof(endpoint.Transport)) + } + }); + } + + Dictionary<(ContentIdentityAlgorithm Algorithm, string Digest), Guid> existingIdentityMap = + await LoadExistingIdentityMapAsync(announcements, cancellationToken); + + var registeredContentIds = new HashSet(); + foreach (NormalizedAnnouncement announcement in announcements) + { + Guid[] matchedContentIds = announcement.Identities + .Select(identity => existingIdentityMap.TryGetValue( + (identity.Algorithm, identity.DigestHex), + out Guid contentId) + ? contentId + : Guid.Empty) + .Where(contentId => contentId != Guid.Empty) + .Distinct() + .ToArray(); + + if (matchedContentIds.Length > 1) + { + throw new ContentDirectoryConflictException( + "The supplied identities already resolve to different server content objects."); + } + + ContentObject content; + if (matchedContentIds.Length == 1) + { + content = await dbContext.Contents.SingleAsync( + item => item.Id == matchedContentIds[0], + cancellationToken); + if (content.Size != announcement.Size) + { + throw new ContentDirectoryConflictException( + "An existing content identity was registered with a different file size."); + } + } + else + { + content = new ContentObject + { + Id = Guid.NewGuid(), + Size = announcement.Size, + CreatedUtc = now + }; + dbContext.Contents.Add(content); + } + + foreach (NormalizedIdentity identity in announcement.Identities) + { + var key = (identity.Algorithm, identity.DigestHex); + if (!existingIdentityMap.ContainsKey(key)) + { + dbContext.ContentIdentities.Add(new ContentIdentityRecord + { + ContentId = content.Id, + Algorithm = identity.Algorithm, + DigestHex = identity.DigestHex + }); + existingIdentityMap[key] = content.Id; + } + } + + if (registeredContentIds.Add(content.Id)) + { + if (previousPresences.Remove(content.Id, out ContentPresence? presence)) + { + // Inventory refreshes must not tear down a seed session that + // was just woken for the same content. Preserve the demand/ + // readiness state while advancing the inventory generation. + presence.Generation = request.Generation; + presence.UpdatedUtc = now; + } + else + { + dbContext.ContentPresences.Add(new ContentPresence + { + NodeId = nodeId, + ContentId = content.Id, + Generation = request.Generation, + UpdatedUtc = now + }); + } + } + } + + if (previousPresences.Count != 0) + { + Guid[] removedContentIds = previousPresences.Keys.ToArray(); + List staleResourceObservations = + await dbContext.ResourceObservations + .Where(observation => + observation.NodeId == nodeId + && removedContentIds.Contains(observation.ContentId)) + .ToListAsync(cancellationToken); + if (staleResourceObservations.Count != 0) + { + dbContext.ResourceObservations.RemoveRange( + staleResourceObservations); + } + + dbContext.ContentPresences.RemoveRange(previousPresences.Values); + } + + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + + return new RegisterContentNodeResponse( + node.LeaseId, + node.LeaseExpiresUtc, + node.Generation, + registeredContentIds.Count); + } + + public async Task HeartbeatAsync( + string nodeId, + Guid leaseId, + CancellationToken cancellationToken) + { + DateTimeOffset now = DateTimeOffset.UtcNow; + ContentNode? node = await dbContext.ContentNodes.SingleOrDefaultAsync( + item => item.NodeId == nodeId, + cancellationToken); + + if (node is null || node.LeaseId != leaseId || node.LeaseExpiresUtc <= now) + { + return null; + } + + node.LastSeenUtc = now; + node.LeaseExpiresUtc = now.AddSeconds(directoryOptions.LeaseLifetimeSeconds); + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentHeartbeatResponse(node.LeaseExpiresUtc); + } + + public async Task LookupAsync( + int algorithmValue, + string digest, + int? requestedMaxPeers, + string? excludeNodeId, + bool prepare, + CancellationToken cancellationToken) + { + ContentIdentityAlgorithm algorithm = (ContentIdentityAlgorithm)algorithmValue; + string normalizedDigest = ContentIdentityRules.NormalizeDigest(algorithm, digest); + DateTimeOffset now = DateTimeOffset.UtcNow; + + ContentIdentityRecord? identity = await dbContext.ContentIdentities + .AsNoTracking() + .Include(item => item.Content) + .SingleOrDefaultAsync( + item => item.Algorithm == algorithm && item.DigestHex == normalizedDigest, + cancellationToken); + + if (identity is null) + { + return null; + } + + int maxPeers = Math.Clamp( + requestedMaxPeers ?? directoryOptions.MaxLookupPeers, + 1, + directoryOptions.MaxLookupPeers); + + IQueryable query = dbContext.ContentPresences + .Where(presence => + presence.ContentId == identity.ContentId + && presence.Node.LeaseExpiresUtc > now); + + if (!string.IsNullOrWhiteSpace(excludeNodeId)) + { + query = query.Where(presence => presence.NodeId != excludeNodeId); + } + + List presences = await query + .Include(presence => presence.Node) + .ThenInclude(node => node.Endpoints) + .OrderByDescending(presence => presence.SeedReadyUntilUtc > now) + .ThenByDescending(presence => presence.Node.LastSeenUtc) + .Take(maxPeers) + .ToListAsync(cancellationToken); + + bool queuedWakeup = false; + DateTimeOffset wakeExpiredBefore = + now.AddSeconds(-directoryOptions.WakeRequestLifetimeSeconds); + + if (prepare) + { + foreach (ContentPresence presence in presences) + { + bool ready = presence.SeedReadyUntilUtc > now + && identity.Content.CanonicalProtocolVersion == 1 + && !string.IsNullOrWhiteSpace(identity.Content.CanonicalInfoHashV2) + && identity.Content.CanonicalTorrent is { Length: > 0 }; + if (ready) + { + continue; + } + + bool pending = presence.WakeRequestId.HasValue + && presence.WakeRequestedUtc > wakeExpiredBefore; + bool backoffElapsed = !presence.NextWakeAllowedUtc.HasValue + || presence.NextWakeAllowedUtc <= now; + if (!pending && backoffElapsed) + { + presence.WakeRequestId = Guid.NewGuid(); + presence.WakeRequestedUtc = now; + presence.UpdatedUtc = now; + queuedWakeup = true; + } + } + + if (queuedWakeup) + { + await dbContext.SaveChangesAsync(cancellationToken); + } + } + + bool manifestAvailable = + identity.Content.CanonicalProtocolVersion == 1 + && !string.IsNullOrWhiteSpace(identity.Content.CanonicalInfoHashV2) + && identity.Content.CanonicalTorrent is { Length: > 0 }; + + var peers = presences.Select(presence => + new ContentPeerResponse( + presence.NodeId, + presence.Node.LeaseExpiresUtc, + manifestAvailable && presence.SeedReadyUntilUtc > now, + presence.Node.Endpoints + .Select(endpoint => new PeerEndpointResponse( + endpoint.Address, + endpoint.IsIpv6, + endpoint.Port, + endpoint.Transport switch + { + ContentPeerTransport.Tcp => PeerTransportContract.Tcp, + ContentPeerTransport.Utp => PeerTransportContract.Utp, + _ => throw new InvalidOperationException("Unknown peer transport.") + }, + "observed-address-unverified-port")) + .ToArray())) + .ToArray(); + + return new ContentLookupResponse( + identity.ContentId, + new ContentIdentityContract + { + Algorithm = algorithmValue, + Digest = normalizedDigest + }, + identity.Content.Size, + identity.Content.CanonicalProtocolVersion, + identity.Content.CanonicalInfoHashV2, + manifestAvailable, + peers.Any(peer => peer.Ready) ? 0 : 1000, + now, + peers); + } + + public async Task?> PollWakeupsAsync( + string nodeId, + Guid leaseId, + int? requestedMaxItems, + CancellationToken cancellationToken) + { + DateTimeOffset now = DateTimeOffset.UtcNow; + ContentNode? node = await dbContext.ContentNodes + .AsNoTracking() + .SingleOrDefaultAsync(item => item.NodeId == nodeId, cancellationToken); + if (node is null || node.LeaseId != leaseId || node.LeaseExpiresUtc <= now) + { + return null; + } + + int maxItems = Math.Clamp( + requestedMaxItems ?? directoryOptions.MaxWakeupsPerPoll, + 1, + directoryOptions.MaxWakeupsPerPoll); + DateTimeOffset validAfter = + now.AddSeconds(-directoryOptions.WakeRequestLifetimeSeconds); + + List pending = await dbContext.ContentPresences + .AsNoTracking() + .Where(presence => + presence.NodeId == nodeId + && presence.WakeRequestId != null + && presence.WakeRequestedUtc > validAfter) + .Include(presence => presence.Content) + .ThenInclude(content => content.Identities) + .OrderBy(presence => presence.WakeRequestedUtc) + .Take(maxItems) + .ToListAsync(cancellationToken); + + return pending.Select(presence => new ContentWakeupResponse( + presence.WakeRequestId!.Value, + presence.ContentId, + presence.Content.Size, + presence.Content.Identities + .Select(identity => new ContentIdentityContract + { + Algorithm = (int)identity.Algorithm, + Digest = identity.DigestHex + }) + .ToArray())) + .ToArray(); + } + + public async Task CompleteWakeupAsync( + string nodeId, + Guid wakeRequestId, + CompleteContentWakeupRequest request, + CancellationToken cancellationToken) + { + DateTimeOffset now = DateTimeOffset.UtcNow; + ContentNode? node = await dbContext.ContentNodes + .SingleOrDefaultAsync(item => item.NodeId == nodeId, cancellationToken); + if (node is null || node.LeaseId != request.LeaseId || node.LeaseExpiresUtc <= now) + { + return false; + } + + ContentPresence? presence = await dbContext.ContentPresences + .Include(item => item.Content) + .SingleOrDefaultAsync( + item => item.NodeId == nodeId + && item.WakeRequestId == wakeRequestId, + cancellationToken); + if (presence is null) + { + return false; + } + + if (!request.Succeeded) + { + presence.WakeRequestId = null; + presence.WakeRequestedUtc = null; + presence.SeedReadyUntilUtc = null; + presence.NextWakeAllowedUtc = + now.AddSeconds(directoryOptions.WakeRetryBackoffSeconds); + presence.UpdatedUtc = now; + await dbContext.SaveChangesAsync(cancellationToken); + return true; + } + + string? bep52Root = await dbContext.ContentIdentities + .AsNoTracking() + .Where(identity => identity.ContentId == presence.ContentId + && identity.Algorithm + == ContentIdentityAlgorithm.Bep52FileRootSha256) + .Select(identity => identity.DigestHex) + .SingleOrDefaultAsync(cancellationToken); + if (bep52Root is null) + { + throw new ContentDirectoryConflictException( + "Canonical v2 metadata requires an authoritative BEP 52 file-root identity."); + } + + if (request.CanonicalProtocolVersion != 1) + { + throw new ArgumentException( + "Only canonical protocol version 1 is supported.", + nameof(request.CanonicalProtocolVersion)); + } + + string infoHash = (request.CanonicalInfoHashV2 ?? string.Empty) + .Trim() + .ToLowerInvariant(); + if (infoHash.Length != 64 || !infoHash.All(Uri.IsHexDigit)) + { + throw new ArgumentException( + "CanonicalInfoHashV2 must contain exactly 64 hexadecimal characters.", + nameof(request.CanonicalInfoHashV2)); + } + + byte[] manifest; + try + { + manifest = Convert.FromBase64String( + request.CanonicalTorrentBase64 ?? string.Empty); + } + catch (FormatException exception) + { + throw new ArgumentException( + "CanonicalTorrentBase64 is not valid Base64.", + nameof(request.CanonicalTorrentBase64), + exception); + } + + if (manifest.Length == 0 + || manifest.Length > directoryOptions.MaxCanonicalTorrentBytes) + { + throw new ArgumentException( + $"Canonical torrent metadata must contain 1 to {directoryOptions.MaxCanonicalTorrentBytes} bytes.", + nameof(request.CanonicalTorrentBase64)); + } + + if (!CanonicalTorrentValidator.Validate( + manifest, + presence.Content.Size, + bep52Root, + infoHash, + out string validationError)) + { + throw new ContentDirectoryConflictException(validationError); + } + + ContentObject content = presence.Content; + if (!string.IsNullOrWhiteSpace(content.CanonicalInfoHashV2) + && !content.CanonicalInfoHashV2.Equals( + infoHash, StringComparison.OrdinalIgnoreCase)) + { + throw new ContentDirectoryConflictException( + "The node produced a different canonical v2 info-hash for this content."); + } + + if (content.CanonicalTorrent is { Length: > 0 } + && !content.CanonicalTorrent.AsSpan().SequenceEqual(manifest)) + { + throw new ContentDirectoryConflictException( + "The node produced different canonical torrent metadata for this content."); + } + + content.CanonicalProtocolVersion = 1; + content.CanonicalInfoHashV2 = infoHash; + content.CanonicalTorrent = manifest; + + presence.WakeRequestId = null; + presence.WakeRequestedUtc = null; + presence.NextWakeAllowedUtc = now; + presence.SeedReadyUntilUtc = + now.AddSeconds(directoryOptions.SeedReadyLifetimeSeconds); + presence.UpdatedUtc = now; + + await dbContext.SaveChangesAsync(cancellationToken); + return true; + } + + public async Task GetCanonicalManifestAsync( + Guid contentId, + CancellationToken cancellationToken) + { + return await dbContext.Contents + .AsNoTracking() + .Where(content => content.Id == contentId + && content.CanonicalProtocolVersion == 1) + .Select(content => content.CanonicalTorrent) + .SingleOrDefaultAsync(cancellationToken); + } + + + public async Task AnnounceResourceAsync( + string nodeId, + ResourceAnnouncementRequest request, + CancellationToken cancellationToken) + { + DateTimeOffset now = DateTimeOffset.UtcNow; + ContentNode? node = await dbContext.ContentNodes + .SingleOrDefaultAsync(item => item.NodeId == nodeId, cancellationToken); + if (node is null + || node.LeaseId != request.LeaseId + || node.LeaseExpiresUtc <= now) + { + return false; + } + + ResourceKeyAlgorithm resourceAlgorithm = + (ResourceKeyAlgorithm)request.ResourceKey.Algorithm; + string resourceDigest = ResourceKeyRules.NormalizeDigest( + resourceAlgorithm, + request.ResourceKey.Digest); + + ContentIdentityAlgorithm contentAlgorithm = + (ContentIdentityAlgorithm)request.ContentIdentity.Algorithm; + string contentDigest = ContentIdentityRules.NormalizeDigest( + contentAlgorithm, + request.ContentIdentity.Digest); + + ContentIdentityRecord? identity = await dbContext.ContentIdentities + .AsNoTracking() + .SingleOrDefaultAsync( + item => item.Algorithm == contentAlgorithm + && item.DigestHex == contentDigest, + cancellationToken); + if (identity is null) + { + throw new ContentDirectoryConflictException( + "The announced content identity is not registered."); + } + + bool nodeOwnsContent = await dbContext.ContentPresences + .AsNoTracking() + .AnyAsync( + presence => presence.NodeId == nodeId + && presence.ContentId == identity.ContentId, + cancellationToken); + if (!nodeOwnsContent) + { + throw new ContentDirectoryConflictException( + "The node cannot announce a resource mapping for content outside its current inventory."); + } + + ResourceObservation? observation = await dbContext.ResourceObservations + .SingleOrDefaultAsync( + item => item.Algorithm == resourceAlgorithm + && item.DigestHex == resourceDigest + && item.NodeId == nodeId, + cancellationToken); + + if (observation is null) + { + observation = new ResourceObservation + { + Algorithm = resourceAlgorithm, + DigestHex = resourceDigest, + NodeId = nodeId + }; + dbContext.ResourceObservations.Add(observation); + } + + observation.ContentId = identity.ContentId; + observation.ObservedUtc = now; + observation.ExpiresUtc = + now.AddSeconds(directoryOptions.ResourceHintLifetimeSeconds); + + await dbContext.SaveChangesAsync(cancellationToken); + return true; + } + + public async Task LookupResourceAsync( + int algorithmValue, + string digest, + int? requestedMaxCandidates, + CancellationToken cancellationToken) + { + ResourceKeyAlgorithm algorithm = (ResourceKeyAlgorithm)algorithmValue; + string normalizedDigest = ResourceKeyRules.NormalizeDigest( + algorithm, + digest); + DateTimeOffset now = DateTimeOffset.UtcNow; + + int maxCandidates = Math.Clamp( + requestedMaxCandidates ?? directoryOptions.MaxResourceCandidates, + 1, + directoryOptions.MaxResourceCandidates); + + List observations = await dbContext.ResourceObservations + .AsNoTracking() + .Where(observation => + observation.Algorithm == algorithm + && observation.DigestHex == normalizedDigest + && observation.ExpiresUtc > now + && observation.Node.LeaseExpiresUtc > now + && dbContext.ContentPresences.Any(presence => + presence.NodeId == observation.NodeId + && presence.ContentId == observation.ContentId)) + .Include(observation => observation.Content) + .ThenInclude(content => content.Identities) + .OrderByDescending(observation => observation.ObservedUtc) + .Take(directoryOptions.MaxResourceObservationsPerLookup) + .ToListAsync(cancellationToken); + + if (observations.Count == 0) + { + return null; + } + + ResourceCandidateResponse[] candidates = observations + .GroupBy(observation => observation.ContentId) + .Select(group => + { + ResourceObservation newest = group + .OrderByDescending(item => item.ObservedUtc) + .First(); + return new ResourceCandidateResponse( + newest.ContentId, + newest.Content.Size, + group.Select(item => item.NodeId).Distinct().Count(), + group.Max(item => item.ObservedUtc), + newest.Content.Identities + .Select(identity => new ContentIdentityContract + { + Algorithm = (int)identity.Algorithm, + Digest = identity.DigestHex + }) + .OrderBy(identity => identity.Algorithm) + .ToArray()); + }) + .OrderByDescending(candidate => candidate.ObservationCount) + .ThenByDescending(candidate => candidate.LastObservedUtc) + .Take(maxCandidates) + .ToArray(); + + return new ResourceLookupResponse( + new ResourceKeyContract + { + Algorithm = algorithmValue, + Digest = normalizedDigest + }, + now, + candidates); + } + + private static NormalizedAnnouncement NormalizeAnnouncement( + ContentAnnouncementRequest request) + { + if (request.Identities.Count == 0) + { + throw new ArgumentException("Every content item must contain at least one identity."); + } + + var identities = request.Identities + .Select(identity => + { + ContentIdentityAlgorithm algorithm = + (ContentIdentityAlgorithm)identity.Algorithm; + return new NormalizedIdentity( + algorithm, + ContentIdentityRules.NormalizeDigest(algorithm, identity.Digest)); + }) + .Distinct() + .ToArray(); + + return new NormalizedAnnouncement(request.Size, identities); + } + + private static void EnsureRequestAliasesAreUnambiguous( + IReadOnlyList announcements) + { + var owners = new Dictionary<(ContentIdentityAlgorithm Algorithm, string Digest), int>(); + for (int index = 0; index < announcements.Count; ++index) + { + foreach (NormalizedIdentity identity in announcements[index].Identities) + { + var key = (identity.Algorithm, identity.DigestHex); + if (owners.TryGetValue(key, out int existingIndex) && existingIndex != index) + { + throw new ContentDirectoryConflictException( + "The same content identity appears in more than one inventory item."); + } + + owners[key] = index; + } + } + } + + private async Task> + LoadExistingIdentityMapAsync( + IReadOnlyList announcements, + CancellationToken cancellationToken) + { + var result = + new Dictionary<(ContentIdentityAlgorithm Algorithm, string Digest), Guid>(); + + foreach (IGrouping group in announcements + .SelectMany(announcement => announcement.Identities) + .Distinct() + .GroupBy(identity => identity.Algorithm)) + { + string[] digests = group.Select(identity => identity.DigestHex).Distinct().ToArray(); + foreach (string[] chunk in digests.Chunk(400)) + { + List matches = await dbContext.ContentIdentities + .AsNoTracking() + .Where(identity => + identity.Algorithm == group.Key + && chunk.Contains(identity.DigestHex)) + .ToListAsync(cancellationToken); + + foreach (ContentIdentityRecord match in matches) + { + result[(match.Algorithm, match.DigestHex)] = match.ContentId; + } + } + } + + return result; + } +} diff --git a/src/OpenNet.Server/OpenNet.Server/Contracts/ContentDirectoryContracts.cs b/src/OpenNet.Server/OpenNet.Server/Contracts/ContentDirectoryContracts.cs new file mode 100644 index 0000000..dcf057f --- /dev/null +++ b/src/OpenNet.Server/OpenNet.Server/Contracts/ContentDirectoryContracts.cs @@ -0,0 +1,159 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace OpenNet.Server.Contracts; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum PeerTransportContract +{ + Tcp, + Utp +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum PeerAddressFamilyContract +{ + Ipv4 = 4, + Ipv6 = 6 +} + +public sealed class ContentIdentityContract +{ + [Range(1, short.MaxValue)] + public int Algorithm { get; init; } + + [Required] + public required string Digest { get; init; } +} + +public sealed class ContentAnnouncementRequest +{ + [Range(0, long.MaxValue)] + public long Size { get; init; } + + [Required, MinLength(1), MaxLength(8)] + public required List Identities { get; init; } +} + +public sealed class PeerEndpointRequest +{ + public PeerTransportContract Transport { get; init; } + + public PeerAddressFamilyContract AddressFamily { get; init; } + + [Range(1, 65535)] + public int Port { get; init; } +} + +public sealed class RegisterContentNodeRequest +{ + [Required, StringLength(128, MinimumLength = 8)] + public required string NodeId { get; init; } + + [Range(0, long.MaxValue)] + public long Generation { get; init; } + + public Guid RegistrationId { get; init; } + + public Guid? PreviousLeaseId { get; init; } + + [MaxLength(8)] + public List Endpoints { get; init; } = []; + + [Required] + public List Contents { get; init; } = []; +} + +public sealed record RegisterContentNodeResponse( + Guid LeaseId, + DateTimeOffset LeaseExpiresAtUtc, + long AcceptedGeneration, + int ContentCount); + +public sealed class ContentHeartbeatRequest +{ + public Guid LeaseId { get; init; } +} + +public sealed record ContentHeartbeatResponse( + DateTimeOffset LeaseExpiresAtUtc); + +public sealed record PeerEndpointResponse( + string Address, + bool IsIpv6, + int Port, + PeerTransportContract Transport, + string Verification); + +public sealed record ContentPeerResponse( + string NodeId, + DateTimeOffset LeaseExpiresAtUtc, + bool Ready, + IReadOnlyList Endpoints); + +public sealed record ContentLookupResponse( + Guid ContentId, + ContentIdentityContract Identity, + long Size, + int CanonicalProtocolVersion, + string? CanonicalInfoHashV2, + bool ManifestAvailable, + int RetryAfterMilliseconds, + DateTimeOffset GeneratedAtUtc, + IReadOnlyList Peers); + +public sealed record ContentWakeupResponse( + Guid WakeRequestId, + Guid ContentId, + long Size, + IReadOnlyList Identities); + +public sealed class CompleteContentWakeupRequest +{ + public Guid LeaseId { get; init; } + + public bool Succeeded { get; init; } + + public int CanonicalProtocolVersion { get; init; } = 1; + + public string? CanonicalInfoHashV2 { get; init; } + + public string? CanonicalTorrentBase64 { get; init; } + + [StringLength(512)] + public string? Error { get; init; } +} + + +public sealed class ResourceKeyContract +{ + public int Algorithm { get; init; } + + [Required] + [StringLength(128, MinimumLength = 1)] + public string Digest { get; init; } = string.Empty; +} + +public sealed class ResourceAnnouncementRequest +{ + public Guid LeaseId { get; init; } + + [Required] + public ResourceKeyContract ResourceKey { get; init; } = new(); + + [Required] + public ContentIdentityContract ContentIdentity { get; init; } = + new() { Digest = string.Empty }; +} + +public sealed record ResourceCandidateResponse( + Guid ContentId, + long Size, + int ObservationCount, + DateTimeOffset LastObservedUtc, + IReadOnlyList Identities); + +public sealed record ResourceLookupResponse( + ResourceKeyContract ResourceKey, + DateTimeOffset GeneratedAtUtc, + IReadOnlyList Candidates); diff --git a/src/OpenNet.Server/OpenNet.Server/Controllers/ContentDirectoryController.cs b/src/OpenNet.Server/OpenNet.Server/Controllers/ContentDirectoryController.cs new file mode 100644 index 0000000..fc862d3 --- /dev/null +++ b/src/OpenNet.Server/OpenNet.Server/Controllers/ContentDirectoryController.cs @@ -0,0 +1,229 @@ +using Microsoft.AspNetCore.Mvc; +using OpenNet.Server.Application; +using OpenNet.Server.Contracts; + +namespace OpenNet.Server.Controllers; + +[ApiController] +[Route("api/v1/content")] +public sealed class ContentDirectoryController(ContentDirectoryService directory) + : ControllerBase +{ + [HttpPost("nodes/register")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Register( + [FromBody] RegisterContentNodeRequest request, + CancellationToken cancellationToken) + { + if (HttpContext.Connection.RemoteIpAddress is not { } remoteAddress) + { + return Problem( + statusCode: StatusCodes.Status400BadRequest, + detail: "The server could not determine the request's remote address."); + } + + try + { + RegisterContentNodeResponse response = await directory.RegisterAsync( + request, + remoteAddress, + cancellationToken); + return Ok(response); + } + catch (ContentDirectoryConflictException exception) + { + return Conflict(new ProblemDetails + { + Status = StatusCodes.Status409Conflict, + Title = "Content directory conflict", + Detail = exception.Message + }); + } + catch (ArgumentException exception) + { + ModelState.AddModelError(exception.ParamName ?? string.Empty, exception.Message); + return ValidationProblem(ModelState); + } + } + + [HttpPost("nodes/{nodeId}/heartbeat")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Heartbeat( + string nodeId, + [FromBody] ContentHeartbeatRequest request, + CancellationToken cancellationToken) + { + ContentHeartbeatResponse? response = await directory.HeartbeatAsync( + nodeId, + request.LeaseId, + cancellationToken); + return response is null ? NotFound() : Ok(response); + } + + [HttpGet("lookup")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Lookup( + [FromQuery] int algorithm, + [FromQuery] string digest, + [FromQuery] int? maxPeers, + [FromQuery] string? excludeNodeId, + [FromQuery] bool prepare = true, + CancellationToken cancellationToken = default) + { + try + { + ContentLookupResponse? response = await directory.LookupAsync( + algorithm, + digest, + maxPeers, + excludeNodeId, + prepare, + cancellationToken); + return response is null ? NotFound() : Ok(response); + } + catch (ArgumentException exception) + { + ModelState.AddModelError(exception.ParamName ?? string.Empty, exception.Message); + return ValidationProblem(ModelState); + } + } + [HttpGet("nodes/{nodeId}/wakeups")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> PollWakeups( + string nodeId, + [FromQuery] Guid leaseId, + [FromQuery] int? maxItems, + CancellationToken cancellationToken) + { + IReadOnlyList? response = + await directory.PollWakeupsAsync( + nodeId, + leaseId, + maxItems, + cancellationToken); + return response is null ? NotFound() : Ok(response); + } + + [HttpPost("nodes/{nodeId}/wakeups/{wakeRequestId:guid}/complete")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task CompleteWakeup( + string nodeId, + Guid wakeRequestId, + [FromBody] CompleteContentWakeupRequest request, + CancellationToken cancellationToken) + { + try + { + bool completed = await directory.CompleteWakeupAsync( + nodeId, + wakeRequestId, + request, + cancellationToken); + return completed ? NoContent() : NotFound(); + } + catch (ContentDirectoryConflictException exception) + { + return Conflict(new ProblemDetails + { + Status = StatusCodes.Status409Conflict, + Title = "Canonical content conflict", + Detail = exception.Message + }); + } + catch (ArgumentException exception) + { + ModelState.AddModelError( + exception.ParamName ?? string.Empty, + exception.Message); + return ValidationProblem(ModelState); + } + } + + [HttpGet("manifests/{contentId:guid}")] + [Produces("application/x-bittorrent")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetManifest( + Guid contentId, + CancellationToken cancellationToken) + { + byte[]? manifest = await directory.GetCanonicalManifestAsync( + contentId, + cancellationToken); + return manifest is null + ? NotFound() + : File(manifest, "application/x-bittorrent"); + } + + + [HttpPost("nodes/{nodeId}/resources")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task AnnounceResource( + string nodeId, + [FromBody] ResourceAnnouncementRequest request, + CancellationToken cancellationToken) + { + try + { + bool announced = await directory.AnnounceResourceAsync( + nodeId, + request, + cancellationToken); + return announced ? NoContent() : NotFound(); + } + catch (ContentDirectoryConflictException exception) + { + return Conflict(new ProblemDetails + { + Status = StatusCodes.Status409Conflict, + Title = "Resource announcement conflict", + Detail = exception.Message + }); + } + catch (ArgumentException exception) + { + ModelState.AddModelError( + exception.ParamName ?? string.Empty, + exception.Message); + return ValidationProblem(ModelState); + } + } + + [HttpGet("resources/lookup")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> LookupResource( + [FromQuery] int algorithm, + [FromQuery] string digest, + [FromQuery] int? maxCandidates, + CancellationToken cancellationToken) + { + try + { + ResourceLookupResponse? response = + await directory.LookupResourceAsync( + algorithm, + digest, + maxCandidates, + cancellationToken); + return response is null ? NotFound() : Ok(response); + } + catch (ArgumentException exception) + { + ModelState.AddModelError( + exception.ParamName ?? string.Empty, + exception.Message); + return ValidationProblem(ModelState); + } + } + +} diff --git a/src/OpenNet.Server/OpenNet.Server/Domain/ContentDirectoryModels.cs b/src/OpenNet.Server/OpenNet.Server/Domain/ContentDirectoryModels.cs new file mode 100644 index 0000000..03bbba2 --- /dev/null +++ b/src/OpenNet.Server/OpenNet.Server/Domain/ContentDirectoryModels.cs @@ -0,0 +1,164 @@ +using System.Net; + +namespace OpenNet.Server.Domain; + +public enum ContentIdentityAlgorithm : short +{ + Bep52FileRootSha256 = 1, + WholeFileSha1 = 2, + WholeFileSha256 = 3, + BitCometLtSeed160Opaque = 128 +} + +public enum ContentPeerTransport : byte +{ + Tcp = 1, + Utp = 2 +} + +public enum ResourceKeyAlgorithm : short +{ + ExactUrlSha256V1 = 1, + HttpValidatorSha256V1 = 2 +} + +public sealed class ContentNode +{ + public required string NodeId { get; set; } + public long Generation { get; set; } + public Guid LastRegistrationId { get; set; } + public Guid LeaseId { get; set; } + public DateTimeOffset LeaseExpiresUtc { get; set; } + public DateTimeOffset LastSeenUtc { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset CreatedUtc { get; set; } = DateTimeOffset.UtcNow; + public ICollection Endpoints { get; set; } = []; + public ICollection Presences { get; set; } = []; + public ICollection ResourceObservations { get; set; } = []; +} + +public sealed class ContentNodeEndpoint +{ + public long Id { get; set; } + public required string NodeId { get; set; } + public ContentNode Node { get; set; } = null!; + public required string Address { get; set; } + public bool IsIpv6 { get; set; } + public int Port { get; set; } + public ContentPeerTransport Transport { get; set; } +} + +public sealed class ContentObject +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public long Size { get; set; } + public DateTimeOffset CreatedUtc { get; set; } = DateTimeOffset.UtcNow; + public int CanonicalProtocolVersion { get; set; } + public string? CanonicalInfoHashV2 { get; set; } + public byte[]? CanonicalTorrent { get; set; } + public ICollection Identities { get; set; } = []; + public ICollection Presences { get; set; } = []; + public ICollection ResourceObservations { get; set; } = []; +} + +public sealed class ContentIdentityRecord +{ + public long Id { get; set; } + public Guid ContentId { get; set; } + public ContentObject Content { get; set; } = null!; + public ContentIdentityAlgorithm Algorithm { get; set; } + public required string DigestHex { get; set; } +} + +public sealed class ContentPresence +{ + public required string NodeId { get; set; } + public ContentNode Node { get; set; } = null!; + public Guid ContentId { get; set; } + public ContentObject Content { get; set; } = null!; + public long Generation { get; set; } + public Guid? WakeRequestId { get; set; } + public DateTimeOffset? WakeRequestedUtc { get; set; } + public DateTimeOffset? NextWakeAllowedUtc { get; set; } + public DateTimeOffset? SeedReadyUntilUtc { get; set; } + public DateTimeOffset UpdatedUtc { get; set; } = DateTimeOffset.UtcNow; +} + + +public sealed class ResourceObservation +{ + public ResourceKeyAlgorithm Algorithm { get; set; } + public required string DigestHex { get; set; } + public required string NodeId { get; set; } + public ContentNode Node { get; set; } = null!; + public Guid ContentId { get; set; } + public ContentObject Content { get; set; } = null!; + public DateTimeOffset ObservedUtc { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset ExpiresUtc { get; set; } +} + +public static class ResourceKeyRules +{ + public static int ExpectedDigestBytes(ResourceKeyAlgorithm algorithm) => algorithm switch + { + ResourceKeyAlgorithm.ExactUrlSha256V1 => 32, + ResourceKeyAlgorithm.HttpValidatorSha256V1 => 32, + _ => 0 + }; + + public static string NormalizeDigest(ResourceKeyAlgorithm algorithm, string digestHex) + { + int expectedBytes = ExpectedDigestBytes(algorithm); + if (expectedBytes == 0) + { + throw new ArgumentException( + $"Unsupported resource-key algorithm {(short)algorithm}.", + nameof(algorithm)); + } + + string normalized = digestHex.Trim().ToLowerInvariant(); + if (normalized.Length != expectedBytes * 2 || !normalized.All(Uri.IsHexDigit)) + { + throw new ArgumentException( + $"Digest for {algorithm} must contain exactly {expectedBytes * 2} hexadecimal characters.", + nameof(digestHex)); + } + + return normalized; + } +} + +public static class ContentIdentityRules +{ + public static int ExpectedDigestBytes(ContentIdentityAlgorithm algorithm) => algorithm switch + { + ContentIdentityAlgorithm.Bep52FileRootSha256 => 32, + ContentIdentityAlgorithm.WholeFileSha1 => 20, + ContentIdentityAlgorithm.WholeFileSha256 => 32, + ContentIdentityAlgorithm.BitCometLtSeed160Opaque => 20, + _ => 0 + }; + + public static string NormalizeDigest(ContentIdentityAlgorithm algorithm, string digestHex) + { + int expectedBytes = ExpectedDigestBytes(algorithm); + if (expectedBytes == 0) + { + throw new ArgumentException($"Unsupported content identity algorithm {(short)algorithm}.", nameof(algorithm)); + } + + string normalized = digestHex.Trim().ToLowerInvariant(); + if (normalized.Length != expectedBytes * 2 || !normalized.All(Uri.IsHexDigit)) + { + throw new ArgumentException( + $"Digest for {algorithm} must contain exactly {expectedBytes * 2} hexadecimal characters.", + nameof(digestHex)); + } + + return normalized; + } + + public static IPAddress NormalizeObservedAddress(IPAddress address) + { + return address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address; + } +} diff --git a/src/OpenNet.Server/OpenNet.Server/Infrastructure/ContentDirectoryDatabaseInitializer.cs b/src/OpenNet.Server/OpenNet.Server/Infrastructure/ContentDirectoryDatabaseInitializer.cs new file mode 100644 index 0000000..2acfdd5 --- /dev/null +++ b/src/OpenNet.Server/OpenNet.Server/Infrastructure/ContentDirectoryDatabaseInitializer.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore; + +namespace OpenNet.Server.Infrastructure; + +public static class ContentDirectoryDatabaseInitializer +{ + public static async Task InitializeAsync(IServiceProvider services) + { + await using AsyncServiceScope scope = services.CreateAsyncScope(); + ContentDirectoryDbContext dbContext = + scope.ServiceProvider.GetRequiredService(); + + await dbContext.Database.EnsureCreatedAsync(); + } +} diff --git a/src/OpenNet.Server/OpenNet.Server/Infrastructure/ContentDirectoryDbContext.cs b/src/OpenNet.Server/OpenNet.Server/Infrastructure/ContentDirectoryDbContext.cs new file mode 100644 index 0000000..ece7578 --- /dev/null +++ b/src/OpenNet.Server/OpenNet.Server/Infrastructure/ContentDirectoryDbContext.cs @@ -0,0 +1,150 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using OpenNet.Server.Domain; + +namespace OpenNet.Server.Infrastructure; + +public sealed class ContentDirectoryDbContext(DbContextOptions options) + : DbContext(options) +{ + public DbSet ContentNodes => Set(); + public DbSet ContentNodeEndpoints => Set(); + public DbSet Contents => Set(); + public DbSet ContentIdentities => Set(); + public DbSet ContentPresences => Set(); + public DbSet ResourceObservations => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // SQLite does not natively translate relational comparisons over + // DateTimeOffset. Store all directory timestamps as UTC Unix + // milliseconds so lease/readiness filters remain server-side and + // behave identically on SQLite and MySQL. + var utcInstant = new ValueConverter( + value => value.ToUnixTimeMilliseconds(), + value => DateTimeOffset.FromUnixTimeMilliseconds(value)); + var nullableUtcInstant = new ValueConverter( + value => value.HasValue + ? value.Value.ToUnixTimeMilliseconds() + : null, + value => value.HasValue + ? DateTimeOffset.FromUnixTimeMilliseconds(value.Value) + : null); + modelBuilder.Entity(entity => + { + entity.ToTable("ContentNodes"); + entity.HasKey(node => node.NodeId); + entity.Property(node => node.NodeId).HasMaxLength(128); + entity.Property(node => node.LeaseExpiresUtc) + .HasConversion(utcInstant); + entity.Property(node => node.LastSeenUtc) + .HasConversion(utcInstant); + entity.Property(node => node.CreatedUtc) + .HasConversion(utcInstant); + entity.HasIndex(node => node.LeaseExpiresUtc); + entity.HasIndex(node => node.LastRegistrationId); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("ContentNodeEndpoints"); + entity.HasKey(endpoint => endpoint.Id); + entity.Property(endpoint => endpoint.Address).HasMaxLength(45).IsRequired(); + entity.Property(endpoint => endpoint.Transport).HasConversion(); + entity.HasIndex(endpoint => new + { + endpoint.NodeId, + endpoint.Address, + endpoint.Port, + endpoint.Transport + }).IsUnique(); + entity.HasOne(endpoint => endpoint.Node) + .WithMany(node => node.Endpoints) + .HasForeignKey(endpoint => endpoint.NodeId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("Contents"); + entity.HasKey(content => content.Id); + entity.Property(content => content.CreatedUtc) + .HasConversion(utcInstant); + entity.HasIndex(content => content.Size); + entity.Property(content => content.CanonicalInfoHashV2).HasMaxLength(64); + entity.Property(content => content.CanonicalTorrent); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("ContentIdentities"); + entity.HasKey(identity => identity.Id); + entity.Property(identity => identity.Algorithm).HasConversion(); + entity.Property(identity => identity.DigestHex).HasMaxLength(64).IsRequired(); + entity.HasIndex(identity => new { identity.Algorithm, identity.DigestHex }).IsUnique(); + entity.HasOne(identity => identity.Content) + .WithMany(content => content.Identities) + .HasForeignKey(identity => identity.ContentId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("ContentPresences"); + entity.HasKey(presence => new { presence.NodeId, presence.ContentId }); + entity.Property(presence => presence.WakeRequestedUtc) + .HasConversion(nullableUtcInstant); + entity.Property(presence => presence.NextWakeAllowedUtc) + .HasConversion(nullableUtcInstant); + entity.Property(presence => presence.SeedReadyUntilUtc) + .HasConversion(nullableUtcInstant); + entity.Property(presence => presence.UpdatedUtc) + .HasConversion(utcInstant); + entity.HasIndex(presence => presence.ContentId); + entity.HasIndex(presence => presence.WakeRequestId); + entity.HasIndex(presence => presence.SeedReadyUntilUtc); + entity.HasOne(presence => presence.Node) + .WithMany(node => node.Presences) + .HasForeignKey(presence => presence.NodeId) + .OnDelete(DeleteBehavior.Cascade); + entity.HasOne(presence => presence.Content) + .WithMany(content => content.Presences) + .HasForeignKey(presence => presence.ContentId) + .OnDelete(DeleteBehavior.Cascade); + }); + modelBuilder.Entity(entity => + { + entity.ToTable("ResourceObservations"); + entity.HasKey(observation => new + { + observation.Algorithm, + observation.DigestHex, + observation.NodeId + }); + entity.Property(observation => observation.Algorithm) + .HasConversion(); + entity.Property(observation => observation.DigestHex) + .HasMaxLength(64) + .IsRequired(); + entity.Property(observation => observation.ObservedUtc) + .HasConversion(utcInstant); + entity.Property(observation => observation.ExpiresUtc) + .HasConversion(utcInstant); + entity.HasIndex(observation => new + { + observation.Algorithm, + observation.DigestHex, + observation.ExpiresUtc + }); + entity.HasIndex(observation => observation.ContentId); + entity.HasOne(observation => observation.Node) + .WithMany(node => node.ResourceObservations) + .HasForeignKey(observation => observation.NodeId) + .OnDelete(DeleteBehavior.Cascade); + entity.HasOne(observation => observation.Content) + .WithMany(content => content.ResourceObservations) + .HasForeignKey(observation => observation.ContentId) + .OnDelete(DeleteBehavior.Cascade); + }); + } +} diff --git a/src/OpenNet.Server/OpenNet.Server/Options/ContentDirectoryOptions.cs b/src/OpenNet.Server/OpenNet.Server/Options/ContentDirectoryOptions.cs new file mode 100644 index 0000000..e58e6b5 --- /dev/null +++ b/src/OpenNet.Server/OpenNet.Server/Options/ContentDirectoryOptions.cs @@ -0,0 +1,41 @@ +using System.ComponentModel.DataAnnotations; + +namespace OpenNet.Server.Options; + +public sealed class ContentDirectoryOptions +{ + public const string SectionName = "ContentDirectory"; + + [Range(30, 3600)] + public int LeaseLifetimeSeconds { get; init; } = 300; + + [Range(1, 100000)] + public int MaxInventoryItems { get; init; } = 50000; + + [Range(1, 100)] + public int MaxLookupPeers { get; init; } = 20; + + [Range(5, 300)] + public int WakeRequestLifetimeSeconds { get; init; } = 30; + + [Range(1, 300)] + public int WakeRetryBackoffSeconds { get; init; } = 10; + + [Range(30, 900)] + public int SeedReadyLifetimeSeconds { get; init; } = 240; + + [Range(1, 256)] + public int MaxWakeupsPerPoll { get; init; } = 32; + + [Range(1024, 64 * 1024 * 1024)] + public int MaxCanonicalTorrentBytes { get; init; } = 8 * 1024 * 1024; + + [Range(60, 30 * 24 * 60 * 60)] + public int ResourceHintLifetimeSeconds { get; init; } = 7 * 24 * 60 * 60; + + [Range(1, 32)] + public int MaxResourceCandidates { get; init; } = 8; + + [Range(1, 1024)] + public int MaxResourceObservationsPerLookup { get; init; } = 256; +} diff --git a/src/OpenNet.Server/OpenNet.Server/Program.cs b/src/OpenNet.Server/OpenNet.Server/Program.cs index 7b9b906..234edec 100644 --- a/src/OpenNet.Server/OpenNet.Server/Program.cs +++ b/src/OpenNet.Server/OpenNet.Server/Program.cs @@ -16,6 +16,11 @@ public static async Task Main(string[] args) .BindConfiguration(TraversalDirectoryOptions.SectionName) .ValidateDataAnnotations() .ValidateOnStart(); + builder.Services + .AddOptions() + .BindConfiguration(ContentDirectoryOptions.SectionName) + .ValidateDataAnnotations() + .ValidateOnStart(); builder.Services .AddOptions() .BindConfiguration(UpdateCatalogOptions.SectionName) @@ -31,29 +36,26 @@ public static async Task Main(string[] args) builder.Configuration["DatabaseProvider"] ?? "Sqlite"; builder.Services.AddDbContext( - options => - { - if (databaseProvider.Equals("MySql", StringComparison.OrdinalIgnoreCase)) - { - options.UseMySQL(connectionString); - } - else if (databaseProvider.Equals("Sqlite", StringComparison.OrdinalIgnoreCase)) - { - options.UseSqlite(connectionString); - } - else - { - throw new InvalidOperationException( - $"Unsupported DatabaseProvider '{databaseProvider}'. " - + "Use 'Sqlite' or 'MySql'."); - } - }); + options => ConfigureDatabase(options, databaseProvider, connectionString)); + + string? configuredContentConnectionString = + builder.Configuration.GetConnectionString("ContentDirectory"); + string contentConnectionString = configuredContentConnectionString + ?? (databaseProvider.Equals("Sqlite", StringComparison.OrdinalIgnoreCase) + ? "Data Source=opennet-content.db" + : throw new InvalidOperationException( + "ConnectionStrings:ContentDirectory must be configured when DatabaseProvider is MySql.")); + builder.Services.AddDbContext( + options => ConfigureDatabase(options, databaseProvider, contentConnectionString)); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddHostedService(); builder.Services.AddSingleton(); builder.Services.AddMemoryCache(); builder.Services.AddHealthChecks() - .AddDbContextCheck("traversal-database"); + .AddDbContextCheck("traversal-database") + .AddDbContextCheck("content-directory-database"); builder.Services.AddControllers(); builder.Services.AddOpenApi(); @@ -69,6 +71,27 @@ public static async Task Main(string[] args) app.MapHealthChecks("/health"); await TraversalDatabaseInitializer.InitializeAsync(app.Services); + await ContentDirectoryDatabaseInitializer.InitializeAsync(app.Services); await app.RunAsync(); } + + private static void ConfigureDatabase( + DbContextOptionsBuilder options, + string databaseProvider, + string connectionString) + { + if (databaseProvider.Equals("MySql", StringComparison.OrdinalIgnoreCase)) + { + options.UseMySQL(connectionString); + } + else if (databaseProvider.Equals("Sqlite", StringComparison.OrdinalIgnoreCase)) + { + options.UseSqlite(connectionString); + } + else + { + throw new InvalidOperationException( + $"Unsupported DatabaseProvider '{databaseProvider}'. Use 'Sqlite' or 'MySql'."); + } + } } diff --git a/src/OpenNet.Server/OpenNet.Server/appsettings.json b/src/OpenNet.Server/OpenNet.Server/appsettings.json index 4952208..688f9e7 100644 --- a/src/OpenNet.Server/OpenNet.Server/appsettings.json +++ b/src/OpenNet.Server/OpenNet.Server/appsettings.json @@ -1,7 +1,8 @@ { "DatabaseProvider": "Sqlite", "ConnectionStrings": { - "TraversalDirectory": "Data Source=opennet-server.db" + "TraversalDirectory": "Data Source=opennet-server.db", + "ContentDirectory": "Data Source=opennet-content.db" }, "TraversalDirectory": { "HeartbeatLifetimeSeconds": 180, @@ -19,5 +20,18 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "ContentDirectory": { + "LeaseLifetimeSeconds": 300, + "MaxInventoryItems": 50000, + "MaxLookupPeers": 20, + "WakeRequestLifetimeSeconds": 30, + "WakeRetryBackoffSeconds": 10, + "SeedReadyLifetimeSeconds": 240, + "MaxWakeupsPerPoll": 32, + "MaxCanonicalTorrentBytes": 8388608, + "ResourceHintLifetimeSeconds": 604800, + "MaxResourceCandidates": 8, + "MaxResourceObservationsPerLookup": 256 + } }