[backend/federation] Add relay support (ISH-266)

This commit is contained in:
Laura Hausmann 2024-09-27 22:42:47 +02:00
parent a5fa4e4be9
commit df26db0585
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
17 changed files with 307 additions and 27 deletions

View file

@ -33,7 +33,8 @@ public class AdminController(
ActivityPub.NoteRenderer noteRenderer,
ActivityPub.UserRenderer userRenderer,
IOptions<Config.InstanceSection> config,
QueueService queueSvc
QueueService queueSvc,
RelayService relaySvc
) : ControllerBase
{
[HttpPost("invites/generate")]
@ -119,7 +120,7 @@ public class AdminController(
await foreach (var job in jobs)
await queueSvc.RetryJobAsync(job);
}
[HttpPost("queue/{queue}/retry-range/{from::guid}/{to::guid}")]
[ProducesResults(HttpStatusCode.OK)]
public async Task RetryRange(string queue, Guid from, Guid to)
@ -144,6 +145,38 @@ public class AdminController(
await queueSvc.AbandonJobAsync(job);
}
[HttpGet("relays")]
[ProducesResults(HttpStatusCode.OK)]
public async Task<List<RelaySchemas.RelayResponse>> GetRelays()
{
return await db.Relays
.ToArrayAsync()
.ContinueWithResult(res => res.Select(p => new RelaySchemas.RelayResponse
{
Id = p.Id,
Inbox = p.Inbox,
Status = (RelaySchemas.RelayStatus)p.Status
})
.ToList());
}
[HttpPost("relays")]
[ProducesResults(HttpStatusCode.OK)]
public async Task SubscribeToRelay(RelaySchemas.RelayRequest rq)
{
await relaySvc.SubscribeToRelay(rq.Inbox);
}
[HttpDelete("relays/{id}")]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task UnsubscribeFromRelay(string id)
{
var relay = await db.Relays.FirstOrDefaultAsync(p => p.Id == id) ??
throw GracefulException.NotFound("Relay not found");
await relaySvc.UnsubscribeFromRelay(relay);
}
[UseNewtonsoftJson]
[HttpGet("activities/notes/{id}")]
[OverrideResultType<ASNote>]

View file

@ -4053,6 +4053,10 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
.HasColumnName("isModerator")
.HasComment("Whether the User is a moderator.");
b.Property<bool>("IsRelayActor")
.HasColumnType("boolean")
.HasColumnName("isRelayActor");
b.Property<bool>("IsSilenced")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
@ -4067,6 +4071,10 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
.HasColumnName("isSuspended")
.HasComment("Whether the User is suspended.");
b.Property<bool>("IsSystem")
.HasColumnType("boolean")
.HasColumnName("isSystem");
b.Property<DateTime?>("LastActiveDate")
.HasColumnType("timestamp with time zone")
.HasColumnName("lastActiveDate");

View file

@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Infrastructure;
#nullable disable
namespace Iceshrimp.Backend.Core.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20240927214613_AddRelayAndSystemUserColumns")]
public partial class AddRelayAndSystemUserColumns : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "isRelayActor",
table: "user",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "isSystem",
table: "user",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.Sql("""
UPDATE "user" SET "isSystem" = TRUE WHERE "host" IS NULL AND ("usernameLower" = 'instance.actor' OR "usernameLower" = 'relay.actor');
""");
migrationBuilder.Sql("""
UPDATE "user" SET "isRelayActor" = TRUE WHERE "inbox" IN (SELECT "inbox" FROM "relay");
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "isRelayActor",
table: "user");
migrationBuilder.DropColumn(
name: "isSystem",
table: "user");
}
}
}

View file

@ -12,9 +12,9 @@ public class Relay
[PgName("relay_status_enum")]
public enum RelayStatus
{
[PgName("requesting")] Requesting,
[PgName("accepted")] Accepted,
[PgName("rejected")] Rejected
[PgName("requesting")] Requesting = 0,
[PgName("accepted")] Accepted = 1,
[PgName("rejected")] Rejected = 2
}
[Key]

View file

@ -124,6 +124,10 @@ public class User : IEntity
[Column("isBot")]
public bool IsBot { get; set; }
[Column("isSystem")] public bool IsSystem { get; set; }
[Column("isRelayActor")] public bool IsRelayActor { get; set; }
/// <summary>
/// Whether the User is a cat.
/// </summary>
@ -491,8 +495,8 @@ public class User : IEntity
[NotMapped] public bool? PrecomputedIsRequested { get; set; }
[NotMapped] public bool? PrecomputedIsRequestedBy { get; set; }
[Projectable] public bool IsLocalUser => Host == null;
[Projectable] public bool IsRemoteUser => Host != null;
[Projectable] public bool IsLocalUser => Host == null;
[Projectable] public bool IsRemoteUser => Host != null;
[Projectable] public string IdenticonUrlPath => $"/identicon/{Id}";
[Key]

View file

@ -82,7 +82,8 @@ public static class ServiceExtensions
.AddScoped<UserProfileRenderer>()
.AddScoped<CacheService>()
.AddScoped<MetaService>()
.AddScoped<StorageMaintenanceService>();
.AddScoped<StorageMaintenanceService>()
.AddScoped<RelayService>();
// Singleton = instantiated once across application lifetime
services

View file

@ -64,4 +64,19 @@ public class ActivityDeliverService(
await DeliverToAsync(activity, actor, recipients);
}
public async Task DeliverToAsync(ASActivity activity, User actor, string recipientInbox)
{
logger.LogDebug("Queuing deliver-to-inbox job for activity {id}", activity.Id);
if (activity.Actor == null) throw new Exception("Actor must not be null");
await queueService.DeliverQueue.EnqueueAsync(new DeliverJobData
{
RecipientHost = new Uri(recipientInbox).Host,
InboxUrl = recipientInbox,
Payload = activity.CompactToPayload(),
ContentType = "application/activity+json",
UserId = actor.Id
});
}
}

View file

@ -26,7 +26,8 @@ public class ActivityHandlerService(
ObjectResolver objectResolver,
FollowupTaskService followupTaskSvc,
EmojiService emojiSvc,
EventService eventSvc
EventService eventSvc,
RelayService relaySvc
)
{
public async Task PerformActivityAsync(ASActivity activity, string? inboxUserId, string? authenticatedUserId)
@ -187,6 +188,13 @@ public class ActivityHandlerService(
if (activity.Object is not ASFollow obj)
throw GracefulException.UnprocessableEntity("Accept activity object is invalid");
var relayPrefix = $"https://{config.Value.WebDomain}/activities/follow-relay/";
if (obj.Id.StartsWith(relayPrefix))
{
await relaySvc.HandleAccept(actor, obj.Id[relayPrefix.Length..]);
return;
}
var prefix = $"https://{config.Value.WebDomain}/follows/";
if (!obj.Id.StartsWith(prefix))
throw GracefulException.UnprocessableEntity($"Object id '{obj.Id}' not a valid follow request id");
@ -227,6 +235,13 @@ public class ActivityHandlerService(
if (activity.Object is not ASFollow follow)
throw GracefulException.UnprocessableEntity("Reject activity object is invalid");
var relayPrefix = $"https://{config.Value.WebDomain}/activities/follow-relay/";
if (follow.Id.StartsWith(relayPrefix))
{
await relaySvc.HandleReject(resolvedActor, follow.Id[relayPrefix.Length..]);
return;
}
if (follow is not { Actor: not null })
throw GracefulException.UnprocessableEntity("Refusing to reject object with invalid follow object");
@ -436,6 +451,9 @@ public class ActivityHandlerService(
return;
}
if (resolvedActor.IsRelayActor)
return;
if (await db.Notes.AnyAsync(p => p.Uri == activity.Id))
{
logger.LogDebug("Renote '{id}' already exists, skipping", activity.Id);

View file

@ -112,6 +112,16 @@ public class ActivityRenderer(
RenderFollowId(follower, followee, relationshipId));
}
public ASFollow RenderFollow(User actor, Relay relay)
{
return new ASFollow
{
Id = $"https://{config.Value.WebDomain}/activities/follow-relay/{relay.Id}",
Actor = userRenderer.RenderLite(actor),
Object = new ASObject { Id = "https://www.w3.org/ns/activitystreams#Public" }
};
}
public ASActivity RenderUnfollow(User follower, User followee, Guid? relationshipId)
{
if (follower.IsLocalUser && followee.IsLocalUser)

View file

@ -174,9 +174,21 @@ public class UserResolver(
return (finalAcct, finalUri);
}
private static string? GetAcctUri(WebFingerResponse fingerRes) => (fingerRes.Aliases ?? [])
.Prepend(fingerRes.Subject)
.FirstOrDefault(p => p.StartsWith("acct:"));
private static string? GetAcctUri(WebFingerResponse fingerRes)
{
var acct = (fingerRes.Aliases ?? [])
.Prepend(fingerRes.Subject)
.FirstOrDefault(p => p.StartsWith("acct:"));
if (acct != null) return acct;
// AodeRelay doesn't prefix its actor's subject with acct, so we have to fall back to guessing here
acct = (fingerRes.Aliases ?? [])
.Prepend(fingerRes.Subject)
.FirstOrDefault(p => !p.Contains(':') &&
!p.Contains(' ') &&
p.Split("@").Length == 2);
return acct is not null ? $"acct:{acct}" : acct;
}
public static string NormalizeQuery(string query)
{

View file

@ -91,6 +91,9 @@ public class ASAnnounce : ASActivity
public class ASDelete : ASActivity
{
public ASDelete() => Type = Types.Delete;
[J($"{Constants.ActivityStreamsNs}#to")]
public List<ASObjectBase>? To { get; set; }
}
public class ASFollow : ASActivity

View file

@ -161,8 +161,10 @@ public class InboxValidationMiddleware(
logger.LogDebug("Error validating HTTP signature: {error}", e.Message);
}
if ((!verified || (key?.User.Uri != null && activity.Actor?.Id != key.User.Uri)) &&
(activity is ASDelete || config.Value.AcceptLdSignatures))
if (
(!verified || (key?.User.Uri != null && activity.Actor?.Id != key.User.Uri)) &&
(activity is ASDelete || config.Value.AcceptLdSignatures)
)
{
if (activity is ASDelete)
logger.LogDebug("Activity is ASDelete & actor uri is not matching, trying LD signature next...");

View file

@ -80,19 +80,43 @@ public class PreDeliverQueue(int parallelism)
}
foreach (var inboxQueryResult in inboxQueryResults)
{
// @formatter:off
await queueSvc.DeliverQueue.EnqueueAsync(new DeliverJobData
{
RecipientHost =
inboxQueryResult.Host ??
throw new Exception("Recipient host must not be null"),
InboxUrl =
inboxQueryResult.InboxUrl ??
throw new
Exception("Recipient inboxUrl must not be null"),
Payload = payload,
ContentType = "application/activity+json",
UserId = jobData.ActorId
RecipientHost = inboxQueryResult.Host ?? throw new Exception("Recipient host must not be null"),
InboxUrl = inboxQueryResult.InboxUrl ?? throw new Exception("Recipient inboxUrl must not be null"),
Payload = payload,
ContentType = "application/activity+json",
UserId = jobData.ActorId
});
// @formatter:on
}
if (activity is ASCreate or ASDelete { Object: ASNote })
{
var relays = await db.Relays.ToArrayAsync(token);
if (relays is []) return;
if (!config.Value.AttachLdSignatures || activity is ASDelete)
{
if (activity is ASDelete del) del.To = [new ASObjectBase($"{Constants.ActivityStreamsNs}#Public")];
var keypair = await db.UserKeypairs.FirstAsync(p => p.UserId == jobData.ActorId, token);
payload = await activity.SignAndCompactAsync(keypair);
}
foreach (var relay in relays)
{
await queueSvc.DeliverQueue.EnqueueAsync(new DeliverJobData
{
RecipientHost = new Uri(relay.Inbox).Host,
InboxUrl = relay.Inbox,
Payload = payload,
ContentType = "application/activity+json",
UserId = jobData.ActorId
});
}
}
}
}

View file

@ -0,0 +1,75 @@
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Helpers;
using Iceshrimp.Backend.Core.Middleware;
using Microsoft.EntityFrameworkCore;
namespace Iceshrimp.Backend.Core.Services;
public class RelayService(
DatabaseContext db,
SystemUserService systemUserSvc,
ActivityPub.ActivityRenderer activityRenderer,
ActivityPub.ActivityDeliverService deliverSvc,
ActivityPub.UserRenderer userRenderer
)
{
public async Task SubscribeToRelay(string uri)
{
uri = new Uri(uri).AbsoluteUri;
if (await db.Relays.AnyAsync(p => p.Inbox == uri)) return;
var relay = new Relay
{
Id = IdHelpers.GenerateSlowflakeId(),
Inbox = uri,
Status = Relay.RelayStatus.Requesting
};
db.Add(relay);
await db.SaveChangesAsync();
var actor = await systemUserSvc.GetRelayActorAsync();
var activity = activityRenderer.RenderFollow(actor, relay);
await deliverSvc.DeliverToAsync(activity, actor, uri);
}
public async Task UnsubscribeFromRelay(Relay relay)
{
var actor = await systemUserSvc.GetRelayActorAsync();
var follow = activityRenderer.RenderFollow(actor, relay);
var activity = activityRenderer.RenderUndo(userRenderer.RenderLite(actor), follow);
await deliverSvc.DeliverToAsync(activity, actor, relay.Inbox);
db.Remove(relay);
await db.SaveChangesAsync();
}
public async Task HandleAccept(User actor, string id)
{
// @formatter:off
if (await db.Relays.FirstOrDefaultAsync(p => p.Id == id) is not { } relay)
throw GracefulException.UnprocessableEntity($"Relay with id {id} was not found");
if (relay.Inbox != new Uri(actor.Inbox ?? throw new Exception("Relay actor must have an inbox")).AbsoluteUri)
throw GracefulException.UnprocessableEntity($"Relay inbox ({relay.Inbox}) does not match relay actor inbox ({actor.Inbox})");
// @formatter:on
relay.Status = Relay.RelayStatus.Accepted;
actor.IsRelayActor = true;
await db.SaveChangesAsync();
}
public async Task HandleReject(User actor, string id)
{
// @formatter:off
if (db.Relays.FirstOrDefault(p => p.Id == id) is not { } relay)
throw GracefulException.UnprocessableEntity($"Relay with id {id} was not found");
if (relay.Inbox != new Uri(actor.Inbox ?? throw new Exception("Relay actor must have an inbox")).AbsoluteUri)
throw GracefulException.UnprocessableEntity($"Relay inbox ({relay.Inbox}) does not match relay actor inbox ({actor.Inbox})");
// @formatter:on
relay.Status = Relay.RelayStatus.Rejected;
actor.IsRelayActor = true;
await db.SaveChangesAsync();
}
}

View file

@ -79,7 +79,8 @@ public class SystemUserService(ILogger<SystemUserService> logger, DatabaseContex
IsAdmin = false,
IsLocked = true,
IsExplorable = false,
IsBot = true
IsBot = true,
IsSystem = true
};
var userKeypair = new UserKeypair

View file

@ -27,10 +27,10 @@ CharacterLimit = 8192
;; It is highly recommend you keep this enabled if you intend to use block- or allowlist federation
AuthorizedFetch = true
;; Whether to attach LD signatures to outgoing activities
;; Whether to attach LD signatures to outgoing activities. Outgoing relayed activities get signed regardless of this option.
AttachLdSignatures = false
;; Whether to accept activities signed using LD signatures
;; Whether to accept activities signed using LD signatures. Needs to be enabled for relayed activities to be accepted.
AcceptLdSignatures = false
;; Whether to allow requests to IPv4 & IPv6 loopback addresses

View file

@ -0,0 +1,23 @@
namespace Iceshrimp.Shared.Schemas.Web;
public class RelaySchemas
{
public enum RelayStatus
{
Requesting = 0,
Accepted = 1,
Rejected = 2
}
public class RelayResponse
{
public required string Id { get; set; }
public required string Inbox { get; set; }
public required RelayStatus Status { get; set; }
}
public class RelayRequest
{
public required string Inbox { get; set; }
}
}