[backend] Implement federation control (ISH-2)
This commit is contained in:
parent
e59053cdcb
commit
5978f1abc4
15 changed files with 6203 additions and 35 deletions
|
@ -37,9 +37,12 @@ public sealed class Config {
|
|||
}
|
||||
|
||||
public sealed class SecuritySection {
|
||||
public bool AuthorizedFetch { get; init; } = true;
|
||||
public ExceptionVerbosity ExceptionVerbosity { get; init; } = ExceptionVerbosity.Basic;
|
||||
public Enums.Registrations Registrations { get; init; } = Enums.Registrations.Closed;
|
||||
public bool AuthorizedFetch { get; init; } = true;
|
||||
public ExceptionVerbosity ExceptionVerbosity { get; init; } = ExceptionVerbosity.Basic;
|
||||
public Enums.Registrations Registrations { get; init; } = Enums.Registrations.Closed;
|
||||
public Enums.FederationMode FederationMode { get; init; } = Enums.FederationMode.BlockList;
|
||||
public Enums.ItemVisibility ExposeFederationList { get; init; } = Enums.ItemVisibility.Registered;
|
||||
public Enums.ItemVisibility ExposeBlockReasons { get; init; } = Enums.ItemVisibility.Registered;
|
||||
}
|
||||
|
||||
public sealed class DatabaseSection {
|
||||
|
|
|
@ -1,6 +1,17 @@
|
|||
namespace Iceshrimp.Backend.Core.Configuration;
|
||||
|
||||
public static class Enums {
|
||||
public enum FederationMode {
|
||||
BlockList = 0,
|
||||
AllowList = 1
|
||||
}
|
||||
|
||||
public enum ItemVisibility {
|
||||
Hide = 0,
|
||||
Registered = 1,
|
||||
Public = 2
|
||||
}
|
||||
|
||||
public enum Registrations {
|
||||
Closed = 0,
|
||||
Invite = 1,
|
||||
|
|
|
@ -79,6 +79,8 @@ public class DatabaseContext(DbContextOptions<DatabaseContext> options)
|
|||
public virtual DbSet<UserPublickey> UserPublickeys { get; init; } = null!;
|
||||
public virtual DbSet<UserSecurityKey> UserSecurityKeys { get; init; } = null!;
|
||||
public virtual DbSet<Webhook> Webhooks { get; init; } = null!;
|
||||
public virtual DbSet<AllowedInstance> AllowedInstances { get; init; } = null!;
|
||||
public virtual DbSet<BlockedInstance> BlockedInstances { get; init; } = null!;
|
||||
public virtual DbSet<DataProtectionKey> DataProtectionKeys { get; init; } = null!;
|
||||
|
||||
public static NpgsqlDataSource GetDataSource(Config.DatabaseSection? config) {
|
||||
|
|
5964
Iceshrimp.Backend/Core/Database/Migrations/20240207164121_AddFederationControl.Designer.cs
generated
Normal file
5964
Iceshrimp.Backend/Core/Database/Migrations/20240207164121_AddFederationControl.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,49 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Iceshrimp.Backend.Core.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddFederationControl : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "allowed_instance",
|
||||
columns: table => new
|
||||
{
|
||||
host = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
imported = table.Column<bool>(type: "boolean", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_allowed_instance", x => x.host);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "blocked_instance",
|
||||
columns: table => new
|
||||
{
|
||||
host = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
reason = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
|
||||
imported = table.Column<bool>(type: "boolean", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_blocked_instance", x => x.host);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "allowed_instance");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "blocked_instance");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -197,6 +197,22 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
|
|||
b.ToTable("access_token");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.AllowedInstance", b =>
|
||||
{
|
||||
b.Property<string>("Host")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("host");
|
||||
|
||||
b.Property<bool>("IsImported")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("imported");
|
||||
|
||||
b.HasKey("Host");
|
||||
|
||||
b.ToTable("allowed_instance");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.Announcement", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
|
@ -532,6 +548,27 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
|
|||
b.ToTable("auth_session");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.BlockedInstance", b =>
|
||||
{
|
||||
b.Property<string>("Host")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("host");
|
||||
|
||||
b.Property<bool>("IsImported")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("imported");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("reason");
|
||||
|
||||
b.HasKey("Host");
|
||||
|
||||
b.ToTable("blocked_instance");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.Blocking", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
|
|
14
Iceshrimp.Backend/Core/Database/Tables/AllowedInstance.cs
Normal file
14
Iceshrimp.Backend/Core/Database/Tables/AllowedInstance.cs
Normal file
|
@ -0,0 +1,14 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Iceshrimp.Backend.Core.Database.Tables;
|
||||
|
||||
[Table("allowed_instance")]
|
||||
public class AllowedInstance {
|
||||
[Key]
|
||||
[Column("host")]
|
||||
[StringLength(256)]
|
||||
public string Host { get; set; } = null!;
|
||||
|
||||
[Column("imported")] public bool IsImported { get; set; }
|
||||
}
|
18
Iceshrimp.Backend/Core/Database/Tables/BlockedInstance.cs
Normal file
18
Iceshrimp.Backend/Core/Database/Tables/BlockedInstance.cs
Normal file
|
@ -0,0 +1,18 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Iceshrimp.Backend.Core.Database.Tables;
|
||||
|
||||
[Table("blocked_instance")]
|
||||
public class BlockedInstance {
|
||||
[Key]
|
||||
[Column("host")]
|
||||
[StringLength(256)]
|
||||
public string Host { get; set; } = null!;
|
||||
|
||||
[Column("reason")]
|
||||
[StringLength(1024)]
|
||||
public string? Reason { get; set; } = null!;
|
||||
|
||||
[Column("imported")] public bool IsImported { get; set; }
|
||||
}
|
|
@ -1,8 +1,8 @@
|
|||
using System.Threading.RateLimiting;
|
||||
using Iceshrimp.Backend.Controllers.Mastodon.Renderers;
|
||||
using Iceshrimp.Backend.Controllers.Schemas;
|
||||
using Iceshrimp.Backend.Core.Configuration;
|
||||
using Iceshrimp.Backend.Core.Database;
|
||||
using Iceshrimp.Backend.Core.Federation.ActivityPub;
|
||||
using Iceshrimp.Backend.Core.Federation.WebFinger;
|
||||
using Iceshrimp.Backend.Core.Middleware;
|
||||
using Iceshrimp.Backend.Core.Services;
|
||||
|
@ -21,26 +21,27 @@ public static class ServiceExtensions {
|
|||
//services.AddTransient<T>();
|
||||
|
||||
// Scoped = instantiated per request
|
||||
services.AddScoped<ActivityRenderer>();
|
||||
services.AddScoped<UserRenderer>();
|
||||
services.AddScoped<NoteRenderer>();
|
||||
services.AddScoped<UserResolver>();
|
||||
services.AddScoped<ActivityPub.ActivityRenderer>();
|
||||
services.AddScoped<ActivityPub.UserRenderer>();
|
||||
services.AddScoped<ActivityPub.NoteRenderer>();
|
||||
services.AddScoped<ActivityPub.UserResolver>();
|
||||
services.AddScoped<UserService>();
|
||||
services.AddScoped<NoteService>();
|
||||
services.AddScoped<ActivityDeliverService>();
|
||||
services.AddScoped<ActivityHandlerService>();
|
||||
services.AddScoped<ActivityPub.ActivityDeliverService>();
|
||||
services.AddScoped<ActivityPub.ActivityHandlerService>();
|
||||
services.AddScoped<WebFingerService>();
|
||||
services.AddScoped<ActivityPub.FederationControlService>();
|
||||
services.AddScoped<AuthorizedFetchMiddleware>();
|
||||
services.AddScoped<AuthenticationMiddleware>();
|
||||
|
||||
//TODO: make this prettier
|
||||
services.AddScoped<Controllers.Mastodon.Renderers.UserRenderer>();
|
||||
services.AddScoped<Controllers.Mastodon.Renderers.NoteRenderer>();
|
||||
services.AddScoped<UserRenderer>();
|
||||
services.AddScoped<NoteRenderer>();
|
||||
|
||||
// Singleton = instantiated once across application lifetime
|
||||
services.AddSingleton<HttpClient>();
|
||||
services.AddSingleton<HttpRequestService>();
|
||||
services.AddSingleton<ActivityFetcherService>();
|
||||
services.AddSingleton<ActivityPub.ActivityFetcherService>();
|
||||
services.AddSingleton<QueueService>();
|
||||
services.AddSingleton<ErrorHandlerMiddleware>();
|
||||
services.AddSingleton<RequestBufferingMiddleware>();
|
||||
|
|
|
@ -19,41 +19,56 @@ public class ActivityHandlerService(
|
|||
DatabaseContext db,
|
||||
QueueService queueService,
|
||||
ActivityRenderer activityRenderer,
|
||||
IOptions<Config.InstanceSection> config
|
||||
IOptions<Config.InstanceSection> config,
|
||||
FederationControlService federationCtrl
|
||||
) {
|
||||
public Task PerformActivityAsync(ASActivity activity, string? inboxUserId) {
|
||||
public async Task PerformActivityAsync(ASActivity activity, string? inboxUserId) {
|
||||
logger.LogDebug("Processing activity: {activity}", activity.Id);
|
||||
if (activity.Actor == null) throw new Exception("Cannot perform activity as actor 'null'");
|
||||
if (activity.Actor == null)
|
||||
throw GracefulException.UnprocessableEntity("Cannot perform activity as actor 'null'");
|
||||
if (await federationCtrl.ShouldBlockAsync(activity.Actor.Id))
|
||||
throw GracefulException.UnprocessableEntity("Instance is blocked");
|
||||
|
||||
//TODO: validate inboxUserId
|
||||
|
||||
switch (activity.Type) {
|
||||
case ASActivity.Types.Create: {
|
||||
//TODO: implement the rest
|
||||
if (activity.Object is ASNote note) return noteSvc.ProcessNoteAsync(note, activity.Actor);
|
||||
throw GracefulException.UnprocessableEntity("Create activity object is invalid");
|
||||
if (activity.Object is not ASNote note)
|
||||
throw GracefulException.UnprocessableEntity("Create activity object is invalid");
|
||||
await noteSvc.ProcessNoteAsync(note, activity.Actor);
|
||||
return;
|
||||
}
|
||||
case ASActivity.Types.Follow: {
|
||||
if (activity.Object is { } obj) return FollowAsync(obj, activity.Actor, activity.Id);
|
||||
throw GracefulException.UnprocessableEntity("Follow activity object is invalid");
|
||||
if (activity.Object is not { } obj)
|
||||
throw GracefulException.UnprocessableEntity("Follow activity object is invalid");
|
||||
await FollowAsync(obj, activity.Actor, activity.Id);
|
||||
return;
|
||||
}
|
||||
case ASActivity.Types.Unfollow: {
|
||||
if (activity.Object is { } obj) return UnfollowAsync(obj, activity.Actor);
|
||||
throw GracefulException.UnprocessableEntity("Unfollow activity object is invalid");
|
||||
if (activity.Object is not { } obj)
|
||||
throw GracefulException.UnprocessableEntity("Unfollow activity object is invalid");
|
||||
await UnfollowAsync(obj, activity.Actor);
|
||||
return;
|
||||
}
|
||||
case ASActivity.Types.Accept: {
|
||||
if (activity.Object is { } obj) return AcceptAsync(obj, activity.Actor);
|
||||
throw GracefulException.UnprocessableEntity("Accept activity object is invalid");
|
||||
if (activity.Object is not { } obj)
|
||||
throw GracefulException.UnprocessableEntity("Accept activity object is invalid");
|
||||
await AcceptAsync(obj, activity.Actor);
|
||||
return;
|
||||
}
|
||||
case ASActivity.Types.Reject: {
|
||||
if (activity.Object is { } obj) return RejectAsync(obj, activity.Actor);
|
||||
throw GracefulException.UnprocessableEntity("Reject activity object is invalid");
|
||||
if (activity.Object is not { } obj)
|
||||
throw GracefulException.UnprocessableEntity("Reject activity object is invalid");
|
||||
await RejectAsync(obj, activity.Actor);
|
||||
return;
|
||||
}
|
||||
case ASActivity.Types.Undo: {
|
||||
//TODO: implement the rest
|
||||
if (activity.Object is ASActivity { Type: ASActivity.Types.Follow, Object: not null } undoActivity)
|
||||
return UnfollowAsync(undoActivity.Object, activity.Actor);
|
||||
throw new NotImplementedException("Unsupported undo operation");
|
||||
if (activity.Object is not ASActivity { Type: ASActivity.Types.Follow, Object: not null } undoActivity)
|
||||
throw new NotImplementedException("Unsupported undo operation");
|
||||
await UnfollowAsync(undoActivity.Object, activity.Actor);
|
||||
return;
|
||||
}
|
||||
default: {
|
||||
throw new NotImplementedException($"Activity type {activity.Type} is unknown");
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
using Iceshrimp.Backend.Core.Configuration;
|
||||
using Iceshrimp.Backend.Core.Database;
|
||||
using Iceshrimp.Backend.Core.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Iceshrimp.Backend.Core.Federation.ActivityPub;
|
||||
|
||||
[SuppressMessage("ReSharper", "SuggestBaseTypeForParameterInConstructor",
|
||||
Justification = "We need IOptionsSnapshot for config hot reload")]
|
||||
public class FederationControlService(IOptionsSnapshot<Config.SecuritySection> options, DatabaseContext db) {
|
||||
//TODO: we need some level of caching here
|
||||
public async Task<bool> ShouldBlockAsync(params string[] hosts) {
|
||||
hosts = hosts.Select(p => p.StartsWith("http://") || p.StartsWith("https://") ? new Uri(p).Host : p)
|
||||
.Select(p => p.ToPunycode())
|
||||
.ToArray();
|
||||
|
||||
// We want to check for fully qualified domains *and* subdomains of them
|
||||
if (options.Value.FederationMode == Enums.FederationMode.AllowList) {
|
||||
return !await db.AllowedInstances.AnyAsync(p => hosts.Any(host => host == p.Host ||
|
||||
host.EndsWith("." + p.Host)));
|
||||
}
|
||||
|
||||
return await db.BlockedInstances.AnyAsync(p => hosts.Any(host => host == p.Host ||
|
||||
host.EndsWith("." + p.Host)));
|
||||
}
|
||||
}
|
|
@ -2,7 +2,6 @@ using System.Diagnostics.CodeAnalysis;
|
|||
using System.Net;
|
||||
using Iceshrimp.Backend.Core.Configuration;
|
||||
using Iceshrimp.Backend.Core.Database;
|
||||
using Iceshrimp.Backend.Core.Federation.ActivityPub;
|
||||
using Iceshrimp.Backend.Core.Federation.Cryptography;
|
||||
using Iceshrimp.Backend.Core.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
@ -14,8 +13,9 @@ public class AuthorizedFetchMiddleware(
|
|||
[SuppressMessage("ReSharper", "SuggestBaseTypeForParameterInConstructor")]
|
||||
IOptionsSnapshot<Config.SecuritySection> config,
|
||||
DatabaseContext db,
|
||||
UserResolver userResolver,
|
||||
ActivityPub.UserResolver userResolver,
|
||||
UserService userSvc,
|
||||
ActivityPub.FederationControlService fedCtrlSvc,
|
||||
ILogger<AuthorizedFetchMiddleware> logger) : IMiddleware {
|
||||
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) {
|
||||
var attribute = ctx.GetEndpoint()?.Metadata.GetMetadata<AuthorizedFetchAttribute>();
|
||||
|
@ -36,17 +36,24 @@ public class AuthorizedFetchMiddleware(
|
|||
var sig = HttpSignature.Parse(sigHeader.ToString());
|
||||
|
||||
// First, we check if we already have the key
|
||||
var key = await db.UserPublickeys.FirstOrDefaultAsync(p => p.KeyId == sig.KeyId);
|
||||
var key = await db.UserPublickeys.Include(p => p.User).FirstOrDefaultAsync(p => p.KeyId == sig.KeyId);
|
||||
|
||||
// If we don't, we need to try to fetch it
|
||||
if (key == null) {
|
||||
var user = await userResolver.ResolveAsync(sig.KeyId);
|
||||
key = await db.UserPublickeys.FirstOrDefaultAsync(p => p.User == user);
|
||||
key = await db.UserPublickeys.Include(p => p.User).FirstOrDefaultAsync(p => p.User == user);
|
||||
}
|
||||
|
||||
// If we still don't have the key, something went wrong and we need to throw an exception
|
||||
if (key == null) throw new GracefulException("Failed to fetch key of signature user");
|
||||
|
||||
if (key.User.Host == null)
|
||||
throw new GracefulException("Remote user must have a host");
|
||||
|
||||
// We want to check both the user host & the keyId host (as account & web domain might be different)
|
||||
if (await fedCtrlSvc.ShouldBlockAsync(key.User.Host, key.KeyId))
|
||||
throw GracefulException.Forbidden("Instance is blocked");
|
||||
|
||||
List<string> headers = request.ContentLength > 0 || attribute.ForceBody
|
||||
? ["(request-target)", "digest", "host", "date"]
|
||||
: ["(request-target)", "host", "date"];
|
||||
|
|
|
@ -20,6 +20,12 @@ public class DeliverQueue {
|
|||
var httpRqSvc = scope.GetRequiredService<HttpRequestService>();
|
||||
var cache = scope.GetRequiredService<IDistributedCache>();
|
||||
var db = scope.GetRequiredService<DatabaseContext>();
|
||||
var fedCtrl = scope.GetRequiredService<ActivityPub.FederationControlService>();
|
||||
|
||||
if (await fedCtrl.ShouldBlockAsync(job.InboxUrl)) {
|
||||
logger.LogDebug("Refusing to deliver activity to blocked instance ({uri})", job.InboxUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogDebug("Delivering activity to: {uri}", job.InboxUrl);
|
||||
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
using Iceshrimp.Backend.Core.Federation.ActivityPub;
|
||||
using Iceshrimp.Backend.Core.Federation.ActivityStreams;
|
||||
using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
|
||||
using Iceshrimp.Backend.Core.Middleware;
|
||||
|
@ -27,8 +26,9 @@ public class InboxQueue {
|
|||
throw new GracefulException("Job data is not an ASActivity", $"Type: {obj.Type}");
|
||||
}
|
||||
|
||||
var apHandler = scope.GetRequiredService<ActivityHandlerService>();
|
||||
var apHandler = scope.GetRequiredService<ActivityPub.ActivityHandlerService>();
|
||||
var logger = scope.GetRequiredService<ILogger<InboxQueue>>();
|
||||
|
||||
logger.LogTrace("Preparation took {ms} ms", job.Duration);
|
||||
await apHandler.PerformActivityAsync(activity, job.InboxUserId);
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ AccountDomain = example.org
|
|||
|
||||
[Security]
|
||||
;; Whether to require incoming ActivityPub requests carry a valid HTTP or LD signature
|
||||
;; It is highly recommend you keep this enabled if you intend to use block- or allowlist federation
|
||||
AuthorizedFetch = true
|
||||
|
||||
;; The level of detail in API error responses
|
||||
|
@ -18,6 +19,18 @@ ExceptionVerbosity = Basic
|
|||
;; Options: [Closed, Invite, Open]
|
||||
Registrations = Closed
|
||||
|
||||
;; Whether to use a blocklist or allowlist for controlling who can federate with this instance
|
||||
;; Options: [BlockList, AllowList]
|
||||
FederationMode = BlockList
|
||||
|
||||
;; Whether to expose the list of blocked/allowed instances publicly, for registered users only, or not at all
|
||||
;; Options = [Public, Registered, Hide]
|
||||
ExposeFederationList = Registered
|
||||
|
||||
;; Whether to expose the reason for instance blocks publicly, for registered users only, or not at all
|
||||
;; Options = [Public, Registered, Hide]
|
||||
ExposeBlockReasons = Registered
|
||||
|
||||
[Database]
|
||||
Host = localhost
|
||||
Port = 5432
|
||||
|
|
Loading…
Add table
Reference in a new issue