start fediverse guard

EBO <eric.bouhana@softalys.com> committé le 2026-05-14 21:29

2902d73364688b47b2681b9494ec2d041eb5d0bb

1 parent(s)

13 fichiers modifiés +1579 -8
M Cargo.lock
+47 -8
@@ -1245,6 +1245,20 @@ dependencies = [
1245 1245 ]
1246 1246
1247 1247 [[package]]
1248 +name = "dashmap"
1249 +version = "6.1.0"
1250 +source = "registry+https://github.com/rust-lang/crates.io-index"
1251 +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
1252 +dependencies = [
1253 + "cfg-if",
1254 + "crossbeam-utils",
1255 + "hashbrown 0.14.5",
1256 + "lock_api",
1257 + "once_cell",
1258 + "parking_lot_core",
1259 +]
1260 +
1261 +[[package]]
1248 1262 name = "deadpool"
1249 1263 version = "0.12.3"
1250 1264 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1536,7 +1550,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1536 1550 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
1537 1551 dependencies = [
1538 1552 "libc",
1539 - "windows-sys 0.52.0",
1553 + "windows-sys 0.61.2",
1540 1554 ]
1541 1555
1542 1556 [[package]]
@@ -1962,6 +1976,12 @@ dependencies = [
1962 1976
1963 1977 [[package]]
1964 1978 name = "hashbrown"
1979 +version = "0.14.5"
1980 +source = "registry+https://github.com/rust-lang/crates.io-index"
1981 +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
1982 +
1983 +[[package]]
1984 +name = "hashbrown"
1965 1985 version = "0.15.5"
1966 1986 source = "registry+https://github.com/rust-lang/crates.io-index"
1967 1987 checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
@@ -2220,7 +2240,7 @@ dependencies = [
2220 2240 "libc",
2221 2241 "percent-encoding",
2222 2242 "pin-project-lite",
2223 - "socket2 0.5.10",
2243 + "socket2 0.6.3",
2224 2244 "system-configuration",
2225 2245 "tokio",
2226 2246 "tower-layer",
@@ -3046,7 +3066,7 @@ version = "5.0.0"
3046 3066 source = "registry+https://github.com/rust-lang/crates.io-index"
3047 3067 checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
3048 3068 dependencies = [
3049 - "base64 0.21.7",
3069 + "base64 0.22.1",
3050 3070 "chrono",
3051 3071 "getrandom 0.2.17",
3052 3072 "http",
@@ -3578,7 +3598,7 @@ dependencies = [
3578 3598 "quinn-udp",
3579 3599 "rustc-hash",
3580 3600 "rustls",
3581 - "socket2 0.5.10",
3601 + "socket2 0.6.3",
3582 3602 "thiserror 2.0.18",
3583 3603 "tokio",
3584 3604 "tracing",
@@ -3615,9 +3635,9 @@ dependencies = [
3615 3635 "cfg_aliases",
3616 3636 "libc",
3617 3637 "once_cell",
3618 - "socket2 0.5.10",
3638 + "socket2 0.6.3",
3619 3639 "tracing",
3620 - "windows-sys 0.52.0",
3640 + "windows-sys 0.59.0",
3621 3641 ]
3622 3642
3623 3643 [[package]]
@@ -4027,7 +4047,7 @@ dependencies = [
4027 4047 "errno",
4028 4048 "libc",
4029 4049 "linux-raw-sys",
4030 - "windows-sys 0.52.0",
4050 + "windows-sys 0.61.2",
4031 4051 ]
4032 4052
4033 4053 [[package]]
@@ -4189,6 +4209,25 @@ dependencies = [
4189 4209 ]
4190 4210
4191 4211 [[package]]
4212 +name = "rustwarden-fediverse-guard"
4213 +version = "0.1.0"
4214 +dependencies = [
4215 + "async-trait",
4216 + "chrono",
4217 + "dashmap",
4218 + "rustwarden-core",
4219 + "rustwarden-fediverse",
4220 + "sea-orm",
4221 + "sea-orm-migration",
4222 + "serde",
4223 + "serde_json",
4224 + "thiserror 2.0.18",
4225 + "tokio",
4226 + "tracing",
4227 + "uuid",
4228 +]
4229 +
4230 +[[package]]
4192 4231 name = "rustwarden-fediverse-ui"
4193 4232 version = "0.1.0"
4194 4233 dependencies = [
@@ -5125,7 +5164,7 @@ dependencies = [
5125 5164 "getrandom 0.4.2",
5126 5165 "once_cell",
5127 5166 "rustix",
5128 - "windows-sys 0.52.0",
5167 + "windows-sys 0.61.2",
5129 5168 ]
5130 5169
5131 5170 [[package]]
M Cargo.toml
+1 -0
@@ -2,6 +2,7 @@
2 2 members = [
3 3 "crates/rustwarden-core",
4 4 "crates/rustwarden-fediverse",
5 + "crates/rustwarden-fediverse-guard",
5 6 "crates/rustwarden-fediverse-ui",
6 7 "crates/rustwarden-ui",
7 8 "app",
A crates/rustwarden-fediverse-guard/Cargo.toml
+44 -0
@@ -0,0 +1,44 @@
1 +[package]
2 +name = "rustwarden-fediverse-guard"
3 +version = "0.1.0"
4 +edition.workspace = true
5 +rust-version.workspace = true
6 +description = "Real-time abuse detection and ban management for the Rustwarden ActivityPub federation layer (RFC 0028+)"
7 +
8 +[features]
9 +default = ["ui"]
10 +# Reserved for the admin UI module (handlers + templates).
11 +ui = []
12 +
13 +[dependencies]
14 +rustwarden-core = { path = "../rustwarden-core" }
15 +rustwarden-fediverse = { path = "../rustwarden-fediverse" }
16 +# Async runtime
17 +tokio = { version = "1", features = ["sync", "time", "rt", "macros"] }
18 +async-trait = "0.1"
19 +# ORM + migrations
20 +sea-orm = { version = "2.0.0-rc.20", features = ["sqlx-postgres", "runtime-tokio-native-tls", "macros"] }
21 +sea-orm-migration = { version = "2.0.0-rc.20", features = ["sqlx-postgres", "runtime-tokio-native-tls"] }
22 +# Concurrent hot-path
23 +dashmap = "6"
24 +# Serialization
25 +serde = { version = "1.0", features = ["derive"] }
26 +serde_json = "1.0"
27 +# Identifiers / time
28 +uuid = { version = "1.0", features = ["v4", "serde"] }
29 +chrono = { version = "0.4", features = ["serde"] }
30 +# Errors
31 +thiserror = "2.0"
32 +# Telemetry
33 +tracing = "0.1"
34 +
35 +[lints]
36 +workspace = true
37 +
38 +[dev-dependencies]
39 +sea-orm = { version = "2.0.0-rc.20", features = ["mock"] }
40 +tokio = { version = "1", features = ["full", "test-util"] }
41 +
42 +[[bin]]
43 +name = "migrate-fediverse-guard"
44 +path = "migration/src/main.rs"
A crates/rustwarden-fediverse-guard/migration/src/lib.rs
+39 -0
@@ -0,0 +1,39 @@
1 +//! SeaORM migrations for the rustwarden-fediverse-guard crate.
2 +//!
3 +//! Single fused migration that creates the three tables backing the
4 +//! guard runtime:
5 +//!
6 +//! - `fediverse_guard_events` (raw event log, sliding-window source)
7 +//! - `fediverse_guard_bans` (active + historical bans)
8 +//! - `fediverse_guard_acl` (manual allow / deny rules)
9 +//!
10 +//! The migrator uses a **dedicated** tracking table
11 +//! `seaql_fediverse_guard_migrations` so it can be applied
12 +//! independently of the core migrator.
13 +//!
14 +//! Run after both core and fediverse migrators:
15 +//! ```bash
16 +//! cargo run --bin migrate -- up
17 +//! cargo run -p rustwarden-fediverse --bin migrate-fediverse -- up
18 +//! cargo run -p rustwarden-fediverse-guard --bin migrate-fediverse-guard -- up
19 +//! ```
20 +
21 +pub use sea_orm_migration::prelude::*;
22 +
23 +pub mod m20260514_000001_fediverse_guard_schema;
24 +
25 +/// Migrator for the rustwarden-fediverse-guard crate.
26 +pub struct FediverseGuardMigrator;
27 +
28 +#[async_trait::async_trait]
29 +impl MigratorTrait for FediverseGuardMigrator {
30 + fn migration_table_name() -> sea_orm::DynIden {
31 + Alias::new("seaql_fediverse_guard_migrations").into_iden()
32 + }
33 +
34 + fn migrations() -> Vec<Box<dyn MigrationTrait>> {
35 + vec![Box::new(
36 + m20260514_000001_fediverse_guard_schema::Migration,
37 + )]
38 + }
39 +}
A crates/rustwarden-fediverse-guard/migration/src/m20260514_000001_fediverse_guard_schema.rs
+262 -0
@@ -0,0 +1,262 @@
1 +//! Migration `m20260514_000001_fediverse_guard_schema`.
2 +//!
3 +//! Creates the three tables backing the fediverse-guard runtime:
4 +//!
5 +//! - `fediverse_guard_events` — raw event log, indexed by
6 +//! `(domain, created_at DESC)` to feed sliding-window detectors.
7 +//! - `fediverse_guard_bans` — active + historical bans, indexed by
8 +//! `(domain)` with a partial-unique on rows whose `lifted_at IS NULL`.
9 +//! - `fediverse_guard_acl` — manual allow/deny rules with FK to
10 +//! `users.id`.
11 +//!
12 +//! All columns use UTC timestamps. UUID primary keys are caller-
13 +//! generated (Rust-side `uuid::Uuid::new_v4()`) — Postgres' `gen_random_uuid()`
14 +//! is reserved for the rare DDL-only inserts (none here).
15 +
16 +use sea_orm_migration::prelude::*;
17 +use sea_orm_migration::sea_orm::ConnectionTrait;
18 +
19 +#[derive(DeriveMigrationName)]
20 +pub struct Migration;
21 +
22 +#[async_trait::async_trait]
23 +impl MigrationTrait for Migration {
24 + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
25 + // ---- fediverse_guard_events ----------------------------------
26 + manager
27 + .create_table(
28 + Table::create()
29 + .table(FediverseGuardEvents::Table)
30 + .col(
31 + ColumnDef::new(FediverseGuardEvents::Id)
32 + .uuid()
33 + .not_null()
34 + .primary_key(),
35 + )
36 + .col(
37 + ColumnDef::new(FediverseGuardEvents::EventType)
38 + .text()
39 + .not_null(),
40 + )
41 + .col(
42 + ColumnDef::new(FediverseGuardEvents::Domain)
43 + .text()
44 + .not_null(),
45 + )
46 + .col(ColumnDef::new(FediverseGuardEvents::ActorUri).text().null())
47 + .col(
48 + ColumnDef::new(FediverseGuardEvents::RemoteIdentityId)
49 + .uuid()
50 + .null(),
51 + )
52 + .col(
53 + ColumnDef::new(FediverseGuardEvents::Severity)
54 + .text()
55 + .not_null(),
56 + )
57 + .col(
58 + ColumnDef::new(FediverseGuardEvents::HttpStatus)
59 + .integer()
60 + .null(),
61 + )
62 + .col(
63 + ColumnDef::new(FediverseGuardEvents::Details)
64 + .json_binary()
65 + .null(),
66 + )
67 + .col(
68 + ColumnDef::new(FediverseGuardEvents::CreatedAt)
69 + .timestamp_with_time_zone()
70 + .not_null(),
71 + )
72 + .to_owned(),
73 + )
74 + .await?;
75 +
76 + let conn = manager.get_connection();
77 + conn.execute_unprepared(
78 + "CREATE INDEX fediverse_guard_events_domain_time_idx \
79 + ON fediverse_guard_events (domain, created_at DESC)",
80 + )
81 + .await?;
82 + conn.execute_unprepared(
83 + "CREATE INDEX fediverse_guard_events_type_time_idx \
84 + ON fediverse_guard_events (event_type, created_at DESC)",
85 + )
86 + .await?;
87 +
88 + // ---- fediverse_guard_bans ------------------------------------
89 + manager
90 + .create_table(
91 + Table::create()
92 + .table(FediverseGuardBans::Table)
93 + .col(
94 + ColumnDef::new(FediverseGuardBans::Id)
95 + .uuid()
96 + .not_null()
97 + .primary_key(),
98 + )
99 + .col(
100 + ColumnDef::new(FediverseGuardBans::Domain)
101 + .text()
102 + .not_null(),
103 + )
104 + .col(ColumnDef::new(FediverseGuardBans::ActorUri).text().null())
105 + .col(
106 + ColumnDef::new(FediverseGuardBans::Reason)
107 + .text()
108 + .not_null(),
109 + )
110 + .col(
111 + ColumnDef::new(FediverseGuardBans::Severity)
112 + .text()
113 + .not_null(),
114 + )
115 + .col(
116 + ColumnDef::new(FediverseGuardBans::TriggeredBy)
117 + .text()
118 + .not_null(),
119 + )
120 + .col(
121 + ColumnDef::new(FediverseGuardBans::BannedAt)
122 + .timestamp_with_time_zone()
123 + .not_null(),
124 + )
125 + .col(
126 + ColumnDef::new(FediverseGuardBans::ExpiresAt)
127 + .timestamp_with_time_zone()
128 + .null(),
129 + )
130 + .col(
131 + ColumnDef::new(FediverseGuardBans::LiftedAt)
132 + .timestamp_with_time_zone()
133 + .null(),
134 + )
135 + .col(
136 + ColumnDef::new(FediverseGuardBans::LiftedBy)
137 + .uuid()
138 + .null(),
139 + )
140 + .to_owned(),
141 + )
142 + .await?;
143 +
144 + conn.execute_unprepared(
145 + "CREATE INDEX fediverse_guard_bans_active_idx \
146 + ON fediverse_guard_bans (domain) WHERE lifted_at IS NULL",
147 + )
148 + .await?;
149 +
150 + // ---- fediverse_guard_acl -------------------------------------
151 + manager
152 + .create_table(
153 + Table::create()
154 + .table(FediverseGuardAcl::Table)
155 + .col(
156 + ColumnDef::new(FediverseGuardAcl::Id)
157 + .uuid()
158 + .not_null()
159 + .primary_key(),
160 + )
161 + .col(
162 + ColumnDef::new(FediverseGuardAcl::Kind)
163 + .text()
164 + .not_null(),
165 + )
166 + .col(
167 + ColumnDef::new(FediverseGuardAcl::Domain)
168 + .text()
169 + .not_null(),
170 + )
171 + .col(ColumnDef::new(FediverseGuardAcl::Reason).text().null())
172 + .col(
173 + ColumnDef::new(FediverseGuardAcl::CreatedBy)
174 + .uuid()
175 + .not_null(),
176 + )
177 + .col(
178 + ColumnDef::new(FediverseGuardAcl::CreatedAt)
179 + .timestamp_with_time_zone()
180 + .not_null(),
181 + )
182 + .col(
183 + ColumnDef::new(FediverseGuardAcl::UpdatedAt)
184 + .timestamp_with_time_zone()
185 + .not_null(),
186 + )
187 + .to_owned(),
188 + )
189 + .await?;
190 +
191 + conn.execute_unprepared(
192 + "CREATE UNIQUE INDEX fediverse_guard_acl_kind_domain_uniq \
193 + ON fediverse_guard_acl (kind, domain)",
194 + )
195 + .await?;
196 +
197 + Ok(())
198 + }
199 +
200 + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
201 + let conn = manager.get_connection();
202 + conn.execute_unprepared("DROP INDEX IF EXISTS fediverse_guard_acl_kind_domain_uniq")
203 + .await?;
204 + manager
205 + .drop_table(Table::drop().table(FediverseGuardAcl::Table).to_owned())
206 + .await?;
207 + conn.execute_unprepared("DROP INDEX IF EXISTS fediverse_guard_bans_active_idx")
208 + .await?;
209 + manager
210 + .drop_table(Table::drop().table(FediverseGuardBans::Table).to_owned())
211 + .await?;
212 + conn.execute_unprepared("DROP INDEX IF EXISTS fediverse_guard_events_type_time_idx")
213 + .await?;
214 + conn.execute_unprepared("DROP INDEX IF EXISTS fediverse_guard_events_domain_time_idx")
215 + .await?;
216 + manager
217 + .drop_table(Table::drop().table(FediverseGuardEvents::Table).to_owned())
218 + .await?;
219 + Ok(())
220 + }
221 +}
222 +
223 +#[derive(DeriveIden)]
224 +enum FediverseGuardEvents {
225 + Table,
226 + Id,
227 + EventType,
228 + Domain,
229 + ActorUri,
230 + RemoteIdentityId,
231 + Severity,
232 + HttpStatus,
233 + Details,
234 + CreatedAt,
235 +}
236 +
237 +#[derive(DeriveIden)]
238 +enum FediverseGuardBans {
239 + Table,
240 + Id,
241 + Domain,
242 + ActorUri,
243 + Reason,
244 + Severity,
245 + TriggeredBy,
246 + BannedAt,
247 + ExpiresAt,
248 + LiftedAt,
249 + LiftedBy,
250 +}
251 +
252 +#[derive(DeriveIden)]
253 +enum FediverseGuardAcl {
254 + Table,
255 + Id,
256 + Kind,
257 + Domain,
258 + Reason,
259 + CreatedBy,
260 + CreatedAt,
261 + UpdatedAt,
262 +}
A crates/rustwarden-fediverse-guard/migration/src/main.rs
+11 -0
@@ -0,0 +1,11 @@
1 +//! CLI entry point for SeaORM migrations of the rustwarden-fediverse-guard crate.
2 +
3 +use sea_orm_migration::prelude::*;
4 +
5 +#[path = "lib.rs"]
6 +mod migration;
7 +
8 +#[tokio::main]
9 +async fn main() {
10 + cli::run_cli(migration::FediverseGuardMigrator).await;
11 +}
A crates/rustwarden-fediverse-guard/src/ban.rs
+312 -0
@@ -0,0 +1,312 @@
1 +//! Ban-decision engine on top of the [`GuardStore`].
2 +//!
3 +//! Composition is:
4 +//!
5 +//! 1. ACL `Deny` rule → ban
6 +//! 2. Active `BanEntry` in store → ban
7 +//! 3. ACL `Allow` rule → never ban (defence in depth even if a
8 +//! detector forgets to check)
9 +//! 4. otherwise → allow
10 +//!
11 +//! Auto-ban requests pass through [`BanManager::auto_ban`], which
12 +//! honours the dry-run flag of [`crate::GuardConfig`] and refuses to
13 +//! ban allow-listed domains.
14 +
15 +use std::sync::Arc;
16 +
17 +use chrono::Utc;
18 +use rustwarden_core::error::AppError;
19 +use uuid::Uuid;
20 +
21 +use crate::config::GuardConfig;
22 +use crate::events::{GuardEvent, GuardEventKind, Severity};
23 +use crate::store::{AclEntry, AclKind, BanEntry, GuardStore};
24 +
25 +/// Effective decision for one (domain, actor_uri?) pair.
26 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27 +pub enum BanDecision {
28 + /// Allow the request (no ban, no deny rule).
29 + Allow,
30 + /// Deny the request (active ban or deny ACL).
31 + Deny,
32 +}
33 +
34 +/// Wraps a [`GuardStore`] with the [`GuardConfig`] runtime knobs and
35 +/// exposes a small surface used by handlers and detectors.
36 +#[derive(Clone)]
37 +pub struct BanManager {
38 + store: Arc<dyn GuardStore>,
39 + config: GuardConfig,
40 +}
41 +
42 +impl BanManager {
43 + /// Build a new manager.
44 + #[must_use]
45 + pub fn new(store: Arc<dyn GuardStore>, config: GuardConfig) -> Self {
46 + Self { store, config }
47 + }
48 +
49 + /// Borrow the underlying store. Useful for tests and read-only
50 + /// admin paths.
51 + #[must_use]
52 + pub fn store(&self) -> &Arc<dyn GuardStore> {
53 + &self.store
54 + }
55 +
56 + /// Borrow the active [`GuardConfig`].
57 + #[must_use]
58 + pub fn config(&self) -> &GuardConfig {
59 + &self.config
60 + }
61 +
62 + /// Effective decision for `domain` right now.
63 + ///
64 + /// # Errors
65 + ///
66 + /// Propagates errors from the underlying store.
67 + pub async fn decide(&self, domain: &str) -> Result<BanDecision, AppError> {
68 + if !self.config.enabled {
69 + return Ok(BanDecision::Allow);
70 + }
71 + if let Some(acl) = self.store.acl_for_domain(domain).await? {
72 + match acl.kind {
73 + AclKind::Deny => return Ok(BanDecision::Deny),
74 + AclKind::Allow => return Ok(BanDecision::Allow),
75 + }
76 + }
77 + let now = Utc::now();
78 + let active = self.store.active_bans_for_domain(domain, now).await?;
79 + if active.is_empty() {
80 + Ok(BanDecision::Allow)
81 + } else {
82 + Ok(BanDecision::Deny)
83 + }
84 + }
85 +
86 + /// Convenience around [`Self::decide`] returning `true` when the
87 + /// domain is banned.
88 + ///
89 + /// # Errors
90 + ///
91 + /// Propagates errors from the underlying store.
92 + pub async fn is_banned(&self, domain: &str) -> Result<bool, AppError> {
93 + Ok(matches!(self.decide(domain).await?, BanDecision::Deny))
94 + }
95 +
96 + /// Persist a ban triggered by a detector.
97 + ///
98 + /// No-op when the guard is disabled, when `dry_run` is on, when
99 + /// the domain has an explicit allow ACL, or when an equivalent
100 + /// active ban already exists.
101 + ///
102 + /// # Errors
103 + ///
104 + /// Propagates errors from the underlying store.
105 + pub async fn auto_ban(
106 + &self,
107 + domain: &str,
108 + detector_name: &'static str,
109 + reason: impl Into<String>,
110 + ) -> Result<Option<BanEntry>, AppError> {
111 + if !self.config.enabled || self.config.dry_run {
112 + return Ok(None);
113 + }
114 + if let Some(acl) = self.store.acl_for_domain(domain).await? {
115 + if acl.kind == AclKind::Allow {
116 + tracing::debug!(domain, detector_name, "auto-ban skipped: ACL allow");
117 + return Ok(None);
118 + }
119 + }
120 + let now = Utc::now();
121 + if !self.store.active_bans_for_domain(domain, now).await?.is_empty() {
122 + return Ok(None);
123 + }
124 + let expires_at = self.config.auto_ban_duration.map(|d| {
125 + now + chrono::Duration::from_std(d).unwrap_or_else(|_| chrono::Duration::hours(1))
126 + });
127 + let ban = BanEntry {
128 + id: Uuid::new_v4(),
129 + domain: domain.to_string(),
130 + actor_uri: None,
131 + reason: reason.into(),
132 + severity: "suspend".to_string(),
133 + triggered_by: format!("auto:{detector_name}"),
134 + banned_at: now,
135 + expires_at,
136 + lifted_at: None,
137 + lifted_by: None,
138 + };
139 + self.store.insert_ban(&ban).await?;
140 + let evt = GuardEvent {
141 + id: Uuid::new_v4(),
142 + kind: GuardEventKind::Banned,
143 + severity: Severity::Critical,
144 + domain: domain.to_string(),
145 + actor_uri: None,
146 + remote_identity_id: None,
147 + http_status: None,
148 + details: serde_json::json!({
149 + "detector": detector_name,
150 + "ban_id": ban.id,
151 + }),
152 + created_at: now,
153 + };
154 + self.store.insert_event(&evt).await?;
155 + tracing::warn!(
156 + domain,
157 + detector_name,
158 + ban_id = %ban.id,
159 + "fediverse guard auto-ban triggered",
160 + );
161 + Ok(Some(ban))
162 + }
163 +
164 + /// Insert or update an ACL entry.
165 + ///
166 + /// # Errors
167 + ///
168 + /// Propagates errors from the underlying store.
169 + pub async fn upsert_acl(&self, entry: &AclEntry) -> Result<(), AppError> {
170 + self.store.upsert_acl(entry).await
171 + }
172 +
173 + /// Lift a ban manually (admin action).
174 + ///
175 + /// # Errors
176 + ///
177 + /// Propagates errors from the underlying store.
178 + pub async fn lift(&self, ban_id: Uuid, by: Uuid) -> Result<(), AppError> {
179 + self.store.lift_ban(ban_id, Some(by), Utc::now()).await?;
180 + let evt = GuardEvent {
181 + id: Uuid::new_v4(),
182 + kind: GuardEventKind::Unbanned,
183 + severity: Severity::Info,
184 + domain: String::new(), // resolved by reader if needed
185 + actor_uri: None,
186 + remote_identity_id: None,
187 + http_status: None,
188 + details: serde_json::json!({"ban_id": ban_id, "lifted_by": by}),
189 + created_at: Utc::now(),
190 + };
191 + self.store.insert_event(&evt).await
192 + }
193 +}
194 +
195 +#[cfg(test)]
196 +mod tests {
197 + use super::*;
198 + use crate::store::MemoryStore;
199 +
200 + fn manager() -> BanManager {
201 + BanManager::new(Arc::new(MemoryStore::new()), GuardConfig::for_tests())
202 + }
203 +
204 + #[tokio::test]
205 + async fn unknown_domain_is_allowed() {
206 + let m = manager();
207 + assert_eq!(m.decide("clean.tld").await.unwrap(), BanDecision::Allow);
208 + }
209 +
210 + #[tokio::test]
211 + async fn deny_acl_forces_ban() {
212 + let m = manager();
213 + m.upsert_acl(&AclEntry {
214 + id: Uuid::new_v4(),
215 + kind: AclKind::Deny,
216 + domain: "evil.tld".into(),
217 + reason: Some("admin".into()),
218 + created_by: Uuid::new_v4(),
219 + created_at: Utc::now(),
220 + updated_at: Utc::now(),
221 + })
222 + .await
223 + .unwrap();
224 + assert_eq!(m.decide("evil.tld").await.unwrap(), BanDecision::Deny);
225 + }
226 +
227 + #[tokio::test]
228 + async fn allow_acl_blocks_auto_ban() {
229 + let m = manager();
230 + m.upsert_acl(&AclEntry {
231 + id: Uuid::new_v4(),
232 + kind: AclKind::Allow,
233 + domain: "trusted.tld".into(),
234 + reason: Some("peer".into()),
235 + created_by: Uuid::new_v4(),
236 + created_at: Utc::now(),
237 + updated_at: Utc::now(),
238 + })
239 + .await
240 + .unwrap();
241 + let result = m
242 + .auto_ban("trusted.tld", "signature_failure", "test")
243 + .await
244 + .unwrap();
245 + assert!(result.is_none(), "ACL allow must prevent auto-ban");
246 + assert_eq!(m.decide("trusted.tld").await.unwrap(), BanDecision::Allow);
247 + }
248 +
249 + #[tokio::test]
250 + async fn auto_ban_inserts_active_row_and_emits_event() {
251 + let m = manager();
252 + let ban = m
253 + .auto_ban("evil.tld", "signature_failure", "10 sig failures in 5 min")
254 + .await
255 + .unwrap()
256 + .expect("auto_ban must persist a ban");
257 + assert_eq!(ban.domain, "evil.tld");
258 + assert!(ban.triggered_by.starts_with("auto:"));
259 + assert_eq!(m.decide("evil.tld").await.unwrap(), BanDecision::Deny);
260 + }
261 +
262 + #[tokio::test]
263 + async fn auto_ban_is_idempotent_within_window() {
264 + let m = manager();
265 + let first = m.auto_ban("evil.tld", "x", "r").await.unwrap();
266 + let second = m.auto_ban("evil.tld", "x", "r").await.unwrap();
267 + assert!(first.is_some());
268 + assert!(second.is_none(), "second auto_ban must be a no-op");
269 + }
270 +
271 + #[tokio::test]
272 + async fn dry_run_emits_nothing() {
273 + let mut cfg = GuardConfig::for_tests();
274 + cfg.dry_run = true;
275 + let m = BanManager::new(Arc::new(MemoryStore::new()), cfg);
276 + let result = m.auto_ban("evil.tld", "x", "r").await.unwrap();
277 + assert!(result.is_none());
278 + assert_eq!(m.decide("evil.tld").await.unwrap(), BanDecision::Allow);
279 + }
280 +
281 + #[tokio::test]
282 + async fn disabled_guard_always_allows() {
283 + let mut cfg = GuardConfig::for_tests();
284 + cfg.enabled = false;
285 + let m = BanManager::new(Arc::new(MemoryStore::new()), cfg);
286 + // Even with a deny ACL the guard is off.
287 + m.upsert_acl(&AclEntry {
288 + id: Uuid::new_v4(),
289 + kind: AclKind::Deny,
290 + domain: "evil.tld".into(),
291 + reason: None,
292 + created_by: Uuid::new_v4(),
293 + created_at: Utc::now(),
294 + updated_at: Utc::now(),
295 + })
296 + .await
297 + .unwrap();
298 + assert_eq!(m.decide("evil.tld").await.unwrap(), BanDecision::Allow);
299 + }
300 +
301 + #[tokio::test]
302 + async fn lift_marks_ban_inactive_and_emits_unbanned_event() {
303 + let m = manager();
304 + let ban = m
305 + .auto_ban("evil.tld", "x", "r")
306 + .await
307 + .unwrap()
308 + .expect("ban persisted");
309 + m.lift(ban.id, Uuid::new_v4()).await.unwrap();
310 + assert_eq!(m.decide("evil.tld").await.unwrap(), BanDecision::Allow);
311 + }
312 +}
A crates/rustwarden-fediverse-guard/src/config.rs
+205 -0
@@ -0,0 +1,205 @@
1 +//! Runtime configuration for the fediverse guard.
2 +//!
3 +//! Parsed once from `FEDIVERSE_GUARD_*` env vars at boot via
4 +//! [`GuardConfig::from_env`]. Production deployments override defaults
5 +//! through env vars; tests can build a `GuardConfig` literal directly.
6 +
7 +use std::time::Duration;
8 +
9 +use thiserror::Error;
10 +
11 +/// Errors raised while loading [`GuardConfig`] from the environment.
12 +#[derive(Debug, Error)]
13 +pub enum GuardConfigError {
14 + /// An env var was set to a value that could not be parsed.
15 + #[error("invalid value for {name}: {source}")]
16 + InvalidValue {
17 + /// Name of the env var that failed to parse.
18 + name: &'static str,
19 + /// Underlying parser error.
20 + #[source]
21 + source: Box<dyn std::error::Error + Send + Sync>,
22 + },
23 +}
24 +
25 +/// Per-detector tunable threshold. A source (domain or actor) is
26 +/// auto-banned when more than `count` qualifying events are observed
27 +/// within `window`.
28 +#[derive(Debug, Clone, Copy)]
29 +pub struct DetectorThreshold {
30 + /// Maximum events tolerated within the sliding window.
31 + pub count: u32,
32 + /// Sliding-window duration over which `count` is enforced.
33 + pub window: Duration,
34 +}
35 +
36 +impl DetectorThreshold {
37 + /// Build a threshold from explicit values.
38 + #[must_use]
39 + pub const fn new(count: u32, window_secs: u64) -> Self {
40 + Self {
41 + count,
42 + window: Duration::from_secs(window_secs),
43 + }
44 + }
45 +}
46 +
47 +/// Top-level runtime configuration for the fediverse guard.
48 +#[derive(Debug, Clone)]
49 +pub struct GuardConfig {
50 + /// Master kill-switch. When `false`, no detector runs and
51 + /// [`crate::BanManager::is_banned`] always returns `false`.
52 + pub enabled: bool,
53 + /// When `true`, detectors emit events and tracing logs but no ban
54 + /// is ever persisted. Use this to validate thresholds in
55 + /// production before flipping the switch.
56 + pub dry_run: bool,
57 + /// HTTP signatures that fail verification, per signing actor's
58 + /// domain.
59 + pub signature_failures: DetectorThreshold,
60 + /// Detected replays (Date drift / duplicate nonce / signature
61 + /// already seen).
62 + pub replay_attempts: DetectorThreshold,
63 + /// Attempts by the local outbound fetcher to talk to a private/
64 + /// loopback/link-local IP. Surfaces SSRF probing campaigns.
65 + pub fetch_ssrf: DetectorThreshold,
66 + /// Inbox payloads that fail size, schema, or activity-type
67 + /// validation.
68 + pub inbox_abuse: DetectorThreshold,
69 + /// AP-native auth challenges initiated without subsequent consume.
70 + pub auth_spam: DetectorThreshold,
71 + /// TTL applied to auto-bans. `None` means permanent.
72 + pub auto_ban_duration: Option<Duration>,
73 + /// Retention for raw events in `fediverse_guard_events`.
74 + pub events_retention: Duration,
75 +}
76 +
77 +impl GuardConfig {
78 + /// Load configuration from `FEDIVERSE_GUARD_*` environment
79 + /// variables, falling back to documented defaults for any var
80 + /// that is unset.
81 + ///
82 + /// # Errors
83 + ///
84 + /// Returns [`GuardConfigError::InvalidValue`] when an env var
85 + /// is set to a value that cannot be parsed as the expected
86 + /// type.
87 + pub fn from_env() -> Result<Self, GuardConfigError> {
88 + Ok(Self {
89 + enabled: parse_bool_env("FEDIVERSE_GUARD_ENABLED", true)?,
90 + dry_run: parse_bool_env("FEDIVERSE_GUARD_DRY_RUN", false)?,
91 + signature_failures: DetectorThreshold {
92 + count: parse_u32_env("FEDIVERSE_GUARD_SIG_FAILURE_THRESHOLD", 10)?,
93 + window: Duration::from_secs(parse_u64_env(
94 + "FEDIVERSE_GUARD_SIG_FAILURE_WINDOW_SECS",
95 + 300,
96 + )?),
97 + },
98 + replay_attempts: DetectorThreshold {
99 + count: parse_u32_env("FEDIVERSE_GUARD_REPLAY_THRESHOLD", 3)?,
100 + window: Duration::from_secs(parse_u64_env(
101 + "FEDIVERSE_GUARD_REPLAY_WINDOW_SECS",
102 + 600,
103 + )?),
104 + },
105 + fetch_ssrf: DetectorThreshold {
106 + count: parse_u32_env("FEDIVERSE_GUARD_SSRF_THRESHOLD", 5)?,
107 + window: Duration::from_secs(parse_u64_env(
108 + "FEDIVERSE_GUARD_SSRF_WINDOW_SECS",
109 + 600,
110 + )?),
111 + },
112 + inbox_abuse: DetectorThreshold {
113 + count: parse_u32_env("FEDIVERSE_GUARD_INBOX_ABUSE_THRESHOLD", 20)?,
114 + window: Duration::from_secs(parse_u64_env(
115 + "FEDIVERSE_GUARD_INBOX_ABUSE_WINDOW_SECS",
116 + 300,
117 + )?),
118 + },
119 + auth_spam: DetectorThreshold {
120 + count: parse_u32_env("FEDIVERSE_GUARD_AUTH_SPAM_THRESHOLD", 30)?,
121 + window: Duration::from_secs(parse_u64_env(
122 + "FEDIVERSE_GUARD_AUTH_SPAM_WINDOW_SECS",
123 + 300,
124 + )?),
125 + },
126 + auto_ban_duration: {
127 + let secs = parse_u64_env("FEDIVERSE_GUARD_AUTO_BAN_DURATION_SECS", 3600)?;
128 + if secs == 0 { None } else { Some(Duration::from_secs(secs)) }
129 + },
130 + events_retention: Duration::from_secs(
131 + parse_u64_env("FEDIVERSE_GUARD_EVENTS_RETENTION_DAYS", 90)? * 24 * 3600,
132 + ),
133 + })
134 + }
135 +
136 + /// Build a permissive `GuardConfig` for tests.
137 + #[must_use]
138 + pub fn for_tests() -> Self {
139 + Self {
140 + enabled: true,
141 + dry_run: false,
142 + signature_failures: DetectorThreshold::new(3, 60),
143 + replay_attempts: DetectorThreshold::new(2, 60),
144 + fetch_ssrf: DetectorThreshold::new(3, 60),
145 + inbox_abuse: DetectorThreshold::new(5, 60),
146 + auth_spam: DetectorThreshold::new(5, 60),
147 + auto_ban_duration: Some(Duration::from_secs(60)),
148 + events_retention: Duration::from_secs(3600),
149 + }
150 + }
151 +}
152 +
153 +fn parse_bool_env(name: &'static str, default: bool) -> Result<bool, GuardConfigError> {
154 + match std::env::var(name) {
155 + Ok(v) => match v.to_ascii_lowercase().as_str() {
156 + "true" | "1" | "yes" => Ok(true),
157 + "false" | "0" | "no" => Ok(false),
158 + other => Err(GuardConfigError::InvalidValue {
159 + name,
160 + source: format!("expected true/false, got {other:?}").into(),
161 + }),
162 + },
163 + Err(_) => Ok(default),
164 + }
165 +}
166 +
167 +fn parse_u32_env(name: &'static str, default: u32) -> Result<u32, GuardConfigError> {
168 + match std::env::var(name) {
169 + Ok(v) => v.parse::<u32>().map_err(|e| GuardConfigError::InvalidValue {
170 + name,
171 + source: Box::new(e),
172 + }),
173 + Err(_) => Ok(default),
174 + }
175 +}
176 +
177 +fn parse_u64_env(name: &'static str, default: u64) -> Result<u64, GuardConfigError> {
178 + match std::env::var(name) {
179 + Ok(v) => v.parse::<u64>().map_err(|e| GuardConfigError::InvalidValue {
180 + name,
181 + source: Box::new(e),
182 + }),
183 + Err(_) => Ok(default),
184 + }
185 +}
186 +
187 +#[cfg(test)]
188 +mod tests {
189 + use super::*;
190 +
191 + #[test]
192 + fn for_tests_yields_sane_defaults() {
193 + let cfg = GuardConfig::for_tests();
194 + assert!(cfg.enabled);
195 + assert!(!cfg.dry_run);
196 + assert_eq!(cfg.signature_failures.count, 3);
197 + }
198 +
199 + #[test]
200 + fn detector_threshold_new_converts_secs_to_duration() {
201 + let t = DetectorThreshold::new(7, 120);
202 + assert_eq!(t.count, 7);
203 + assert_eq!(t.window, Duration::from_secs(120));
204 + }
205 +}
A crates/rustwarden-fediverse-guard/src/events.rs
+141 -0
@@ -0,0 +1,141 @@
1 +//! Stable on-the-wire format for guard events plus the [`GuardEventSink`]
2 +//! trait that backends (tracing, file, fan-out) implement.
3 +
4 +use async_trait::async_trait;
5 +use chrono::{DateTime, Utc};
6 +use serde::{Deserialize, Serialize};
7 +use uuid::Uuid;
8 +
9 +/// Discriminator describing what an observer reports.
10 +///
11 +/// Each variant corresponds to one of the five detectors. Variants are
12 +/// serialised in `snake_case` to remain stable in JSON logs consumed
13 +/// by external SIEMs.
14 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
15 +#[serde(rename_all = "snake_case")]
16 +pub enum GuardEventKind {
17 + /// A request's HTTP signature failed cryptographic verification.
18 + SignatureFailure,
19 + /// Replay window violated: stale `Date` header or duplicate nonce.
20 + ReplayAttempt,
21 + /// Outbound fetcher attempted to reach a private/loopback/link-local
22 + /// IP (SSRF guard hit).
23 + FetchSsrf,
24 + /// Inbox payload failed size, schema, or type validation.
25 + InboxAbuse,
26 + /// AP-native auth challenge initiated but never consumed; high
27 + /// volume from one domain signals scripted abuse.
28 + AuthSpam,
29 + /// A domain or actor was banned (auto or manual). Reported by the
30 + /// `BanManager` itself, not by detectors.
31 + Banned,
32 + /// A domain or actor ban was lifted (TTL expiry or admin action).
33 + Unbanned,
34 +}
35 +
36 +/// Severity hint attached to each event. Mirrors the levels accepted
37 +/// by `fediverse_guard_events.severity`.
38 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
39 +#[serde(rename_all = "lowercase")]
40 +pub enum Severity {
41 + /// Informational (e.g. ban lifted by TTL).
42 + Info,
43 + /// Low — anomaly worth recording but not actionable alone.
44 + Low,
45 + /// Medium — repeated occurrences should trigger a review.
46 + Medium,
47 + /// High — likely abuse; detector seuils probably reached soon.
48 + High,
49 + /// Critical — confirmed abuse; auto-ban just triggered.
50 + Critical,
51 +}
52 +
53 +/// One observable guard event.
54 +///
55 +/// Events are produced by the federation crate via the
56 +/// `FediverseEventObserver` trait and consumed by the guard's detector
57 +/// chain and sinks.
58 +#[derive(Debug, Clone, Serialize, Deserialize)]
59 +pub struct GuardEvent {
60 + /// Surrogate UUID for the event row.
61 + pub id: Uuid,
62 + /// What kind of anomaly is being reported.
63 + pub kind: GuardEventKind,
64 + /// Severity hint.
65 + pub severity: Severity,
66 + /// Domain the event is attributed to (lowercased, no scheme).
67 + pub domain: String,
68 + /// Optional ActivityPub actor URI (e.g.
69 + /// `https://a.tld/users/alice`) when the event narrows to one actor.
70 + pub actor_uri: Option<String>,
71 + /// FK to `remote_identities.id` when the actor has been resolved.
72 + pub remote_identity_id: Option<Uuid>,
73 + /// Optional HTTP status returned by the offending request, when
74 + /// applicable.
75 + pub http_status: Option<i32>,
76 + /// Detector-specific metadata (e.g. `{"skew_secs": 3601}`).
77 + /// Always a JSON object; never `null`.
78 + pub details: serde_json::Value,
79 + /// Server-side timestamp.
80 + pub created_at: DateTime<Utc>,
81 +}
82 +
83 +impl GuardEvent {
84 + /// Convenience constructor with sensible defaults for fields
85 + /// rarely populated by detectors.
86 + #[must_use]
87 + pub fn new(kind: GuardEventKind, domain: impl Into<String>, severity: Severity) -> Self {
88 + Self {
89 + id: Uuid::new_v4(),
90 + kind,
91 + severity,
92 + domain: domain.into(),
93 + actor_uri: None,
94 + remote_identity_id: None,
95 + http_status: None,
96 + details: serde_json::Value::Object(serde_json::Map::new()),
97 + created_at: Utc::now(),
98 + }
99 + }
100 +}
101 +
102 +/// Pluggable sink for [`GuardEvent`].
103 +///
104 +/// Implementations must be non-blocking: a slow sink should buffer
105 +/// internally rather than back-pressure the federation hot path.
106 +#[async_trait]
107 +pub trait GuardEventSink: Send + Sync {
108 + /// Emit one event. Errors are swallowed by the caller — sinks
109 + /// must not propagate failures into the request path.
110 + async fn emit(&self, event: &GuardEvent);
111 +}
112 +
113 +#[cfg(test)]
114 +mod tests {
115 + use super::*;
116 +
117 + #[test]
118 + fn guard_event_new_fills_defaults() {
119 + let evt = GuardEvent::new(GuardEventKind::SignatureFailure, "evil.tld", Severity::High);
120 + assert_eq!(evt.kind, GuardEventKind::SignatureFailure);
121 + assert_eq!(evt.domain, "evil.tld");
122 + assert!(evt.actor_uri.is_none());
123 + assert!(evt.http_status.is_none());
124 + assert!(evt.details.is_object());
125 + }
126 +
127 + #[test]
128 + fn guard_event_round_trips_through_json() {
129 + let evt = GuardEvent::new(GuardEventKind::ReplayAttempt, "a.tld", Severity::Medium);
130 + let json = serde_json::to_string(&evt).unwrap();
131 + let back: GuardEvent = serde_json::from_str(&json).unwrap();
132 + assert_eq!(back.kind, GuardEventKind::ReplayAttempt);
133 + assert_eq!(back.domain, "a.tld");
134 + }
135 +
136 + #[test]
137 + fn guard_event_kind_serialises_snake_case() {
138 + let s = serde_json::to_string(&GuardEventKind::SignatureFailure).unwrap();
139 + assert_eq!(s, "\"signature_failure\"");
140 + }
141 +}
A crates/rustwarden-fediverse-guard/src/lib.rs
+30 -0
@@ -0,0 +1,30 @@
1 +//! Real-time abuse detection and ban management for the Rustwarden
2 +//! ActivityPub federation layer (RFC 0028+).
3 +//!
4 +//! Mirrors the pattern of `gitrust-ssh-guard` adapted to ActivityPub
5 +//! semantics: domain- and actor-level events feed a sliding-window
6 +//! detector chain that may auto-ban abusive sources. Admin overrides
7 +//! and a manual allow/deny ACL sit on top, all backed by a hybrid
8 +//! in-memory plus Postgres store for hot-path checks at request time.
9 +//!
10 +//! # Architecture
11 +//!
12 +//! ```text
13 +//! incoming AP event ───► GuardEvent ───► sinks (tracing/file)
14 +//! └──► store.insert_event()
15 +//! └──► detectors (per-domain windows)
16 +//! └► BanManager::auto_ban()
17 +//! └► store.insert_ban()
18 +//! request hot path ──► BanManager::is_banned(domain) ──► allow / deny
19 +//! ```
20 +
21 +pub mod ban;
22 +pub mod config;
23 +pub mod events;
24 +pub mod sinks;
25 +pub mod store;
26 +
27 +pub use ban::{BanDecision, BanManager};
28 +pub use config::{DetectorThreshold, GuardConfig, GuardConfigError};
29 +pub use events::{GuardEvent, GuardEventKind, GuardEventSink};
30 +pub use store::{AclEntry, AclKind, BanEntry, GuardStore, MemoryStore};
A crates/rustwarden-fediverse-guard/src/sinks.rs
+98 -0
@@ -0,0 +1,98 @@
1 +//! Built-in [`GuardEventSink`] implementations.
2 +//!
3 +//! Two sinks are provided out of the box:
4 +//!
5 +//! - [`TracingSink`] — emits each event via `tracing::warn!` under
6 +//! the `rustwarden_fediverse_guard` target, picked up by journald,
7 +//! `tracing_subscriber` console layers, or OpenTelemetry exporters.
8 +//! - [`MultiSink`] — fan-out to several sinks sequentially. A slow
9 +//! sink does **not** back-pressure the federation hot path because
10 +//! `emit` is fire-and-forget on each.
11 +
12 +use std::sync::Arc;
13 +
14 +use async_trait::async_trait;
15 +
16 +use crate::events::{GuardEvent, GuardEventSink};
17 +
18 +/// Sink that emits each event via `tracing::warn!`.
19 +#[derive(Debug, Default, Clone, Copy)]
20 +pub struct TracingSink;
21 +
22 +impl TracingSink {
23 + /// Build a new tracing sink.
24 + #[must_use]
25 + pub const fn new() -> Self {
26 + Self
27 + }
28 +}
29 +
30 +#[async_trait]
31 +impl GuardEventSink for TracingSink {
32 + async fn emit(&self, event: &GuardEvent) {
33 + tracing::warn!(
34 + target: "rustwarden_fediverse_guard",
35 + event_kind = ?event.kind,
36 + severity = ?event.severity,
37 + domain = %event.domain,
38 + actor_uri = event.actor_uri.as_deref().unwrap_or(""),
39 + details = %event.details,
40 + "fediverse guard event",
41 + );
42 + }
43 +}
44 +
45 +/// Fan-out sink that calls every child in sequence.
46 +pub struct MultiSink {
47 + children: Vec<Arc<dyn GuardEventSink>>,
48 +}
49 +
50 +impl MultiSink {
51 + /// Build a new multi-sink from a vector of children.
52 + #[must_use]
53 + pub fn new(children: Vec<Arc<dyn GuardEventSink>>) -> Self {
54 + Self { children }
55 + }
56 +}
57 +
58 +#[async_trait]
59 +impl GuardEventSink for MultiSink {
60 + async fn emit(&self, event: &GuardEvent) {
61 + for child in &self.children {
62 + child.emit(event).await;
63 + }
64 + }
65 +}
66 +
67 +#[cfg(test)]
68 +mod tests {
69 + use super::*;
70 + use crate::events::{GuardEventKind, Severity};
71 + use std::sync::Mutex;
72 +
73 + struct Recorder {
74 + events: Mutex<Vec<GuardEvent>>,
75 + }
76 +
77 + #[async_trait]
78 + impl GuardEventSink for Recorder {
79 + async fn emit(&self, event: &GuardEvent) {
80 + self.events.lock().unwrap().push(event.clone());
81 + }
82 + }
83 +
84 + #[tokio::test]
85 + async fn multi_sink_dispatches_to_every_child() {
86 + let r1 = Arc::new(Recorder {
87 + events: Mutex::new(Vec::new()),
88 + });
89 + let r2 = Arc::new(Recorder {
90 + events: Mutex::new(Vec::new()),
91 + });
92 + let multi = MultiSink::new(vec![r1.clone() as Arc<dyn GuardEventSink>, r2.clone()]);
93 + let evt = GuardEvent::new(GuardEventKind::SignatureFailure, "x.tld", Severity::Medium);
94 + multi.emit(&evt).await;
95 + assert_eq!(r1.events.lock().unwrap().len(), 1);
96 + assert_eq!(r2.events.lock().unwrap().len(), 1);
97 + }
98 +}
A crates/rustwarden-fediverse-guard/src/store/memory.rs
+187 -0
@@ -0,0 +1,187 @@
1 +//! In-process [`GuardStore`] backend.
2 +//!
3 +//! Implemented with `DashMap` for O(1) hot-path reads. Events, bans,
4 +//! and ACLs live only in RAM — restarting the process clears
5 +//! everything. Use this in tests, or wrapped by a write-through
6 +//! `HybridStore` in production.
7 +
8 +use async_trait::async_trait;
9 +use chrono::{DateTime, Utc};
10 +use dashmap::DashMap;
11 +use rustwarden_core::error::AppError;
12 +use uuid::Uuid;
13 +
14 +use super::{AclEntry, BanEntry, GuardStore};
15 +use crate::events::{GuardEvent, GuardEventKind};
16 +
17 +/// Concurrent in-memory store.
18 +#[derive(Default)]
19 +pub struct MemoryStore {
20 + events: DashMap<String, Vec<GuardEvent>>,
21 + bans: DashMap<Uuid, BanEntry>,
22 + acl: DashMap<String, AclEntry>,
23 +}
24 +
25 +impl MemoryStore {
26 + /// Build an empty store.
27 + #[must_use]
28 + pub fn new() -> Self {
29 + Self::default()
30 + }
31 +}
32 +
33 +#[async_trait]
34 +impl GuardStore for MemoryStore {
35 + async fn insert_event(&self, event: &GuardEvent) -> Result<(), AppError> {
36 + self.events
37 + .entry(event.domain.clone())
38 + .or_default()
39 + .push(event.clone());
40 + Ok(())
41 + }
42 +
43 + async fn count_events_since(
44 + &self,
45 + domain: &str,
46 + kind: GuardEventKind,
47 + since: DateTime<Utc>,
48 + ) -> Result<u64, AppError> {
49 + let count = self
50 + .events
51 + .get(domain)
52 + .map(|bucket| {
53 + bucket
54 + .iter()
55 + .filter(|e| e.kind == kind && e.created_at >= since)
56 + .count() as u64
57 + })
58 + .unwrap_or(0);
59 + Ok(count)
60 + }
61 +
62 + async fn insert_ban(&self, ban: &BanEntry) -> Result<(), AppError> {
63 + self.bans.insert(ban.id, ban.clone());
64 + Ok(())
65 + }
66 +
67 + async fn active_bans_for_domain(
68 + &self,
69 + domain: &str,
70 + now: DateTime<Utc>,
71 + ) -> Result<Vec<BanEntry>, AppError> {
72 + let out = self
73 + .bans
74 + .iter()
75 + .filter(|kv| kv.value().domain == domain && kv.value().is_active_at(now))
76 + .map(|kv| kv.value().clone())
77 + .collect();
78 + Ok(out)
79 + }
80 +
81 + async fn lift_ban(
82 + &self,
83 + ban_id: Uuid,
84 + by: Option<Uuid>,
85 + at: DateTime<Utc>,
86 + ) -> Result<(), AppError> {
87 + if let Some(mut row) = self.bans.get_mut(&ban_id) {
88 + row.lifted_at = Some(at);
89 + row.lifted_by = by;
90 + }
91 + Ok(())
92 + }
93 +
94 + async fn upsert_acl(&self, entry: &AclEntry) -> Result<(), AppError> {
95 + self.acl.insert(entry.domain.clone(), entry.clone());
96 + Ok(())
97 + }
98 +
99 + async fn acl_for_domain(&self, domain: &str) -> Result<Option<AclEntry>, AppError> {
100 + Ok(self.acl.get(domain).map(|kv| kv.value().clone()))
101 + }
102 +
103 + async fn delete_acl(&self, id: Uuid) -> Result<(), AppError> {
104 + self.acl.retain(|_, v| v.id != id);
105 + Ok(())
106 + }
107 +}
108 +
109 +#[cfg(test)]
110 +mod tests {
111 + use super::*;
112 + use crate::events::{GuardEvent, GuardEventKind, Severity};
113 + use crate::store::AclKind;
114 +
115 + fn evt(domain: &str, kind: GuardEventKind) -> GuardEvent {
116 + GuardEvent::new(kind, domain, Severity::Medium)
117 + }
118 +
119 + #[tokio::test]
120 + async fn insert_and_count_events_by_kind_and_window() {
121 + let store = MemoryStore::new();
122 + let e1 = evt("a.tld", GuardEventKind::SignatureFailure);
123 + let e2 = evt("a.tld", GuardEventKind::SignatureFailure);
124 + let e3 = evt("a.tld", GuardEventKind::ReplayAttempt);
125 + store.insert_event(&e1).await.unwrap();
126 + store.insert_event(&e2).await.unwrap();
127 + store.insert_event(&e3).await.unwrap();
128 + let cutoff = Utc::now() - chrono::Duration::minutes(1);
129 + let n = store
130 + .count_events_since("a.tld", GuardEventKind::SignatureFailure, cutoff)
131 + .await
132 + .unwrap();
133 + assert_eq!(n, 2);
134 + }
135 +
136 + #[tokio::test]
137 + async fn ban_lifecycle_insert_active_lift() {
138 + let store = MemoryStore::new();
139 + let now = Utc::now();
140 + let ban = BanEntry {
141 + id: Uuid::new_v4(),
142 + domain: "evil.tld".into(),
143 + actor_uri: None,
144 + reason: "auto-ban".into(),
145 + severity: "suspend".into(),
146 + triggered_by: "auto:test".into(),
147 + banned_at: now,
148 + expires_at: Some(now + chrono::Duration::hours(1)),
149 + lifted_at: None,
150 + lifted_by: None,
151 + };
152 + let ban_id = ban.id;
153 + store.insert_ban(&ban).await.unwrap();
154 + let active = store
155 + .active_bans_for_domain("evil.tld", now + chrono::Duration::minutes(10))
156 + .await
157 + .unwrap();
158 + assert_eq!(active.len(), 1);
159 + let admin = Uuid::new_v4();
160 + store.lift_ban(ban_id, Some(admin), Utc::now()).await.unwrap();
161 + let active_after = store
162 + .active_bans_for_domain("evil.tld", now + chrono::Duration::minutes(10))
163 + .await
164 + .unwrap();
165 + assert!(active_after.is_empty());
166 + }
167 +
168 + #[tokio::test]
169 + async fn acl_upsert_lookup_delete() {
170 + let store = MemoryStore::new();
171 + let entry = AclEntry {
172 + id: Uuid::new_v4(),
173 + kind: AclKind::Allow,
174 + domain: "trusted.tld".into(),
175 + reason: Some("peer".into()),
176 + created_by: Uuid::new_v4(),
177 + created_at: Utc::now(),
178 + updated_at: Utc::now(),
179 + };
180 + store.upsert_acl(&entry).await.unwrap();
181 + let found = store.acl_for_domain("trusted.tld").await.unwrap();
182 + assert!(found.is_some());
183 + store.delete_acl(entry.id).await.unwrap();
184 + let after = store.acl_for_domain("trusted.tld").await.unwrap();
185 + assert!(after.is_none());
186 + }
187 +}
A crates/rustwarden-fediverse-guard/src/store/mod.rs
+202 -0
@@ -0,0 +1,202 @@
1 +//! Persistence layer for the fediverse guard.
2 +//!
3 +//! Three interchangeable backends implement the [`GuardStore`] trait:
4 +//!
5 +//! - [`MemoryStore`] — in-process `DashMap`, O(1) hot-path, lost on restart.
6 +//! - `PostgresStore` (forthcoming) — SeaORM-backed durable store.
7 +//! - `HybridStore` (forthcoming) — memory cache + write-through Postgres
8 +//! plus boot-time rehydration.
9 +
10 +mod memory;
11 +
12 +use async_trait::async_trait;
13 +use chrono::{DateTime, Utc};
14 +use rustwarden_core::error::AppError;
15 +use serde::{Deserialize, Serialize};
16 +use uuid::Uuid;
17 +
18 +pub use memory::MemoryStore;
19 +
20 +use crate::events::{GuardEvent, GuardEventKind};
21 +
22 +/// Active ban row.
23 +#[derive(Debug, Clone, Serialize, Deserialize)]
24 +pub struct BanEntry {
25 + /// Surrogate UUID for the ban row.
26 + pub id: Uuid,
27 + /// Lower-cased domain the ban applies to (always populated).
28 + pub domain: String,
29 + /// Optional ActivityPub actor URI to narrow the ban to a single
30 + /// account on `domain`. `None` means the whole domain is banned.
31 + pub actor_uri: Option<String>,
32 + /// Human-readable reason recorded for the audit trail.
33 + pub reason: String,
34 + /// Severity: `"suspend"`, `"silence"`, or `"rate_limit"`.
35 + pub severity: String,
36 + /// `"auto:<detector_name>"` for detector-driven bans, or
37 + /// `"manual:<admin_uuid>"` for an admin's manual action.
38 + pub triggered_by: String,
39 + /// When the ban was first applied.
40 + pub banned_at: DateTime<Utc>,
41 + /// `None` means permanent.
42 + pub expires_at: Option<DateTime<Utc>>,
43 + /// Set when the ban was lifted (TTL or admin override).
44 + pub lifted_at: Option<DateTime<Utc>>,
45 + /// Admin user that lifted the ban, when applicable.
46 + pub lifted_by: Option<Uuid>,
47 +}
48 +
49 +impl BanEntry {
50 + /// Returns `true` if this row is still active at `now` (i.e. not
51 + /// lifted and either permanent or before `expires_at`).
52 + #[must_use]
53 + pub fn is_active_at(&self, now: DateTime<Utc>) -> bool {
54 + if self.lifted_at.is_some() {
55 + return false;
56 + }
57 + match self.expires_at {
58 + Some(exp) => now < exp,
59 + None => true,
60 + }
61 + }
62 +}
63 +
64 +/// Manual allow/deny list entry. Allow rules bypass auto-ban; deny
65 +/// rules force ban even before a detector trips.
66 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
67 +#[serde(rename_all = "lowercase")]
68 +pub enum AclKind {
69 + /// The domain is whitelisted; auto-ban detectors must not target it.
70 + Allow,
71 + /// The domain is blacklisted; treated as permanently banned.
72 + Deny,
73 +}
74 +
75 +/// Manual ACL row.
76 +#[derive(Debug, Clone, Serialize, Deserialize)]
77 +pub struct AclEntry {
78 + /// Surrogate UUID for the ACL row.
79 + pub id: Uuid,
80 + /// Allow or deny.
81 + pub kind: AclKind,
82 + /// Lower-cased domain the entry applies to.
83 + pub domain: String,
84 + /// Free-form reason captured for audit.
85 + pub reason: Option<String>,
86 + /// FK to `users.id` of the admin that created the entry.
87 + pub created_by: Uuid,
88 + /// When the entry was first inserted.
89 + pub created_at: DateTime<Utc>,
90 + /// Last mutation timestamp.
91 + pub updated_at: DateTime<Utc>,
92 +}
93 +
94 +/// Abstract backend for guard state.
95 +///
96 +/// Implementations must be cheap to clone (`Arc`) and safe to share
97 +/// across handlers and background workers.
98 +#[async_trait]
99 +pub trait GuardStore: Send + Sync {
100 + /// Persist one raw event.
101 + async fn insert_event(&self, event: &GuardEvent) -> Result<(), AppError>;
102 +
103 + /// Count events of `kind` for `domain` since `since`. Powers the
104 + /// sliding-window detectors.
105 + async fn count_events_since(
106 + &self,
107 + domain: &str,
108 + kind: GuardEventKind,
109 + since: DateTime<Utc>,
110 + ) -> Result<u64, AppError>;
111 +
112 + /// Insert a new ban row. Implementations may decline silently if
113 + /// an equivalent active ban already exists.
114 + async fn insert_ban(&self, ban: &BanEntry) -> Result<(), AppError>;
115 +
116 + /// Return every active ban whose domain matches `domain` and that
117 + /// is still effective at `now`.
118 + async fn active_bans_for_domain(
119 + &self,
120 + domain: &str,
121 + now: DateTime<Utc>,
122 + ) -> Result<Vec<BanEntry>, AppError>;
123 +
124 + /// Mark a ban as lifted.
125 + async fn lift_ban(
126 + &self,
127 + ban_id: Uuid,
128 + by: Option<Uuid>,
129 + at: DateTime<Utc>,
130 + ) -> Result<(), AppError>;
131 +
132 + /// Insert or update an ACL row.
133 + async fn upsert_acl(&self, entry: &AclEntry) -> Result<(), AppError>;
134 +
135 + /// Look up the ACL kind for `domain`, if any.
136 + async fn acl_for_domain(&self, domain: &str) -> Result<Option<AclEntry>, AppError>;
137 +
138 + /// Remove an ACL row by id.
139 + async fn delete_acl(&self, id: Uuid) -> Result<(), AppError>;
140 +}
141 +
142 +/// Normalise a user-supplied domain string to the canonical form used
143 +/// everywhere in this crate (lowercase, stripped of scheme/trailing
144 +/// slash/whitespace).
145 +#[must_use]
146 +pub fn normalise_domain(input: &str) -> String {
147 + input
148 + .trim()
149 + .trim_start_matches("https://")
150 + .trim_start_matches("http://")
151 + .trim_end_matches('/')
152 + .to_ascii_lowercase()
153 +}
154 +
155 +#[cfg(test)]
156 +mod tests {
157 + use super::*;
158 +
159 + #[test]
160 + fn normalise_domain_strips_scheme_and_trailing_slash() {
161 + assert_eq!(normalise_domain(" https://Foo.TLD/ "), "foo.tld");
162 + assert_eq!(normalise_domain("http://bar.org"), "bar.org");
163 + assert_eq!(normalise_domain("baz.net"), "baz.net");
164 + }
165 +
166 + #[test]
167 + fn ban_entry_is_active_until_expires_at() {
168 + let now = Utc::now();
169 + let active = BanEntry {
170 + id: Uuid::new_v4(),
171 + domain: "evil.tld".into(),
172 + actor_uri: None,
173 + reason: "spam".into(),
174 + severity: "suspend".into(),
175 + triggered_by: "auto:signature_failure".into(),
176 + banned_at: now - chrono::Duration::minutes(1),
177 + expires_at: Some(now + chrono::Duration::hours(1)),
178 + lifted_at: None,
179 + lifted_by: None,
180 + };
181 + assert!(active.is_active_at(now));
182 + assert!(!active.is_active_at(now + chrono::Duration::hours(2)));
183 + }
184 +
185 + #[test]
186 + fn ban_entry_lifted_is_inactive_even_before_expiry() {
187 + let now = Utc::now();
188 + let lifted = BanEntry {
189 + id: Uuid::new_v4(),
190 + domain: "evil.tld".into(),
191 + actor_uri: None,
192 + reason: "spam".into(),
193 + severity: "suspend".into(),
194 + triggered_by: "manual:admin".into(),
195 + banned_at: now - chrono::Duration::minutes(10),
196 + expires_at: Some(now + chrono::Duration::hours(1)),
197 + lifted_at: Some(now - chrono::Duration::minutes(1)),
198 + lifted_by: Some(Uuid::new_v4()),
199 + };
200 + assert!(!lifted.is_active_at(now));
201 + }
202 +}