noteguard

the nostr relay spam guardian
git clone git://jb55.com/noteguard
Log | Files | Refs | README | LICENSE

commit 24a1c0dfc297956e3533b2bc52cc08c2197646f1
Author: William Casarin <jb55@jb55.com>
Date:   Sun,  7 Jul 2024 22:29:29 -0500

initial commit

Diffstat:
A.gitignore | 7+++++++
ACOPYING | 19+++++++++++++++++++
ACargo.lock | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACargo.toml | 13+++++++++++++
AMakefile | 2++
AREADME.md | 23+++++++++++++++++++++++
Anoteguard.toml | 6++++++
Asrc/filters/mod.rs | 5+++++
Asrc/filters/rate_limit.rs | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/filters/whitelist.rs | 26++++++++++++++++++++++++++
Asrc/lib.rs | 6++++++
Asrc/main.rs | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/messages.rs | 37+++++++++++++++++++++++++++++++++++++
Asrc/note_filter.rs | 20++++++++++++++++++++
Atest/test-inputs | 5+++++
15 files changed, 509 insertions(+), 0 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -0,0 +1,7 @@ +target/ +.direnv/ +.buildcmd +.build-result +shell.nix +.envrc +tags diff --git a/COPYING b/COPYING @@ -0,0 +1,19 @@ +Copyright 2024 Damus Nostr, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the “Software”), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Cargo.lock b/Cargo.lock @@ -0,0 +1,143 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "noteguard" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "toml", + "tracing", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "pin-project-lite" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" + +[[package]] +name = "proc-macro2" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "serde" +version = "1.0.204" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc76f558e0cbb2a839d37354c575f1dc3fdc6546b5be373ba43d95f231bf7c12" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.204" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e0d21c9a8cae1235ad58a00c11cb40d4b1e5c784f1ef2c537876ed6ffd8b7c5" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "syn" +version = "2.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "201fcda3845c23e8212cd466bfebf0bd20694490fc0356ae8e428e0824a915a6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[package]] +name = "tracing" +version = "0.1.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" diff --git a/Cargo.toml b/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "noteguard" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +toml = "0.5" +tracing = "0.1.40" + + + diff --git a/Makefile b/Makefile @@ -0,0 +1,2 @@ +tags: + find src -name '*.rs' | xargs ctags diff --git a/README.md b/README.md @@ -0,0 +1,23 @@ + +# noteguard + +A high performance note filter plugin system for [strfry] + +## Usage + +Filters are registered and loaded from the [noteguard.toml](noteguard.toml) config. + +You can add any new filter you want by implementing the `NoteFilter` trait and registering it with noteguard via the `register_filter` method. + +The `pipeline` config specifies the order in which filters are run. When the first `reject` or `shadowReject` action is hit, then the pipeline stops and returns the rejection error. + +```toml + +pipeline = ["ratelimit"] + +[filters.ratelimit] +notes_per_second = 1 +whitelist = ["127.0.0.1"] +``` + +[strfry]: https://github.com/hoytech/strfry diff --git a/noteguard.toml b/noteguard.toml @@ -0,0 +1,6 @@ + +pipeline = ["ratelimit"] + +[filters.ratelimit] +notes_per_second = 1 +whitelist = ["127.0.0.1"] diff --git a/src/filters/mod.rs b/src/filters/mod.rs @@ -0,0 +1,5 @@ +mod rate_limit; +mod whitelist; + +pub use rate_limit::RateLimit; +pub use whitelist::Whitelist; diff --git a/src/filters/rate_limit.rs b/src/filters/rate_limit.rs @@ -0,0 +1,54 @@ +use crate::{Action, InputMessage, NoteFilter, OutputMessage}; +use serde::Deserialize; +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +pub struct RateInfo { + pub last_note: Instant, +} + +#[derive(Deserialize, Default)] +pub struct RateLimit { + pub notes_per_second: u64, + pub whitelist: Option<Vec<String>>, + + #[serde(skip)] + pub sources: HashMap<String, RateInfo>, +} + +impl NoteFilter for RateLimit { + fn filter_note(&mut self, msg: &InputMessage) -> OutputMessage { + if let Some(whitelist) = &self.whitelist { + if whitelist.contains(&msg.source_info) { + return OutputMessage::new(msg.event.id.clone(), Action::Accept, None); + } + } + + if self.sources.contains_key(&msg.source_info) { + let now = Instant::now(); + let entry = self.sources.get_mut(&msg.source_info).expect("impossiburu"); + if now - entry.last_note < Duration::from_secs(self.notes_per_second) { + return OutputMessage::new( + msg.event.id.clone(), + Action::Reject, + Some("rate-limited: you are noting too fast".to_string()), + ); + } else { + entry.last_note = Instant::now(); + return OutputMessage::new(msg.event.id.clone(), Action::Accept, None); + } + } else { + self.sources.insert( + msg.source_info.to_owned(), + RateInfo { + last_note: Instant::now(), + }, + ); + return OutputMessage::new(msg.event.id.clone(), Action::Accept, None); + } + } + + fn name(&self) -> &'static str { + "ratelimit" + } +} diff --git a/src/filters/whitelist.rs b/src/filters/whitelist.rs @@ -0,0 +1,26 @@ +use crate::{Action, InputMessage, NoteFilter, OutputMessage}; +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct Whitelist { + pub pubkeys: Vec<String>, + pub ips: Vec<String>, +} + +impl NoteFilter for Whitelist { + fn filter_note(&mut self, msg: &InputMessage) -> OutputMessage { + if self.pubkeys.contains(&msg.event.pubkey) || self.ips.contains(&msg.source_info) { + OutputMessage::new(msg.event.id.clone(), Action::Accept, None) + } else { + OutputMessage::new( + msg.event.id.clone(), + Action::Reject, + Some("blocked: pubkey not on the whitelist".to_string()), + ) + } + } + + fn name(&self) -> &'static str { + "whitelist" + } +} diff --git a/src/lib.rs b/src/lib.rs @@ -0,0 +1,6 @@ +pub mod filters; +mod messages; +mod note_filter; + +pub use messages::{Action, InputMessage, OutputMessage}; +pub use note_filter::{Note, NoteFilter}; diff --git a/src/main.rs b/src/main.rs @@ -0,0 +1,143 @@ +use noteguard::filters::RateLimit; +use noteguard::{Action, InputMessage, NoteFilter, OutputMessage}; +use serde::de::DeserializeOwned; +use serde::Deserialize; +use std::collections::HashMap; +use std::io::{self, BufRead, Read, Write}; + +#[derive(Deserialize)] +struct Config { + pipeline: Vec<String>, + filters: HashMap<String, toml::Value>, +} + +type ConstructFilter = Box<fn(toml::Value) -> Result<Box<dyn NoteFilter>, toml::de::Error>>; + +#[derive(Default)] +struct Noteguard { + registered_filters: HashMap<String, ConstructFilter>, + loaded_filters: Vec<Box<dyn NoteFilter>>, +} + +impl Noteguard { + pub fn new() -> Self { + let mut noteguard = Noteguard::default(); + noteguard.register_builtin_filters(); + noteguard + } + + pub fn register_filter<F: NoteFilter + 'static + Default + DeserializeOwned>(&mut self) { + self.registered_filters.insert( + F::name(&F::default()).to_string(), + Box::new(|filter_config| { + filter_config + .try_into() + .map(|filter: F| Box::new(filter) as Box<dyn NoteFilter>) + }), + ); + } + + /// All builtin filters are registered here, and are made available with + /// every new instance of [`Noteguard`] + fn register_builtin_filters(&mut self) { + self.register_filter::<RateLimit>(); + } + + /// Run the loaded filters. You must call `load_config` before calling this, otherwise + /// not filters will be run. + fn run(&mut self, input: InputMessage) -> OutputMessage { + let mut mout: Option<OutputMessage> = None; + + let id = input.event.id.clone(); + for filter in &mut self.loaded_filters { + let out = filter.filter_note(&input); + match out.action { + Action::Accept => { + mout = Some(out); + continue; + } + Action::Reject => { + return out; + } + Action::ShadowReject => { + return out; + } + } + } + + mout.unwrap_or_else(|| OutputMessage::new(id, Action::Accept, None)) + } + + /// Initializes a noteguard config. If it finds any filter configurations + /// matching the registered filters, it loads those into our filter pipeline. + fn load_config(&mut self, config: &Config) -> Result<(), toml::de::Error> { + self.loaded_filters.clear(); + + for (name, config_value) in &config.filters { + if let Some(constructor) = self.registered_filters.get(name.as_str()) { + let filter = constructor(config_value.clone())?; + self.loaded_filters.push(filter); + } else { + panic!("Found config settings with no matching filter: {}", name); + } + } + + Ok(()) + } +} + +fn main() { + let config_path = "noteguard.toml"; + let mut noteguard = Noteguard::new(); + + let config: Config = { + let mut file = std::fs::File::open(config_path).expect("Failed to open config file"); + let mut contents = String::new(); + file.read_to_string(&mut contents) + .expect("Failed to read config file"); + toml::from_str(&contents).expect("Failed to parse config file") + }; + + noteguard + .load_config(&config) + .expect("Expected filter config to be loaded ok"); + + let stdin = io::stdin(); + let stdout = io::stdout(); + let handle = stdout.lock(); + let mut writer = io::BufWriter::new(handle); + + for line in stdin.lock().lines() { + match line { + Ok(input) => { + let input_message: InputMessage = match serde_json::from_str(&input) { + Ok(msg) => msg, + Err(e) => { + eprintln!("Failed to parse input: {}", e); + continue; + } + }; + + if input_message.message_type != "new" { + eprintln!("Unexpected request type"); + continue; + } + + let output_message = noteguard.run(input_message); + + match serde_json::to_string(&output_message) { + Ok(json) => { + writeln!(writer, "{}", json).unwrap(); + writer.flush().unwrap(); + } + Err(e) => { + eprintln!("Failed to serialize output: {}", e); + } + } + } + Err(e) => { + eprintln!("Failed to read line: {}", e); + } + } + } +} diff --git a/src/messages.rs b/src/messages.rs @@ -0,0 +1,37 @@ +use crate::Note; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize)] +pub struct InputMessage { + #[serde(rename = "type")] + pub message_type: String, + pub event: Note, + #[serde(rename = "receivedAt")] + pub received_at: u64, + #[serde(rename = "sourceType")] + pub source_type: String, + #[serde(rename = "sourceInfo")] + pub source_info: String, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Action { + Accept, + Reject, + ShadowReject, +} + +#[derive(Serialize)] +pub struct OutputMessage { + pub id: String, + pub action: Action, + #[serde(skip_serializing_if = "Option::is_none")] + pub msg: Option<String>, +} + +impl OutputMessage { + pub fn new(id: String, action: Action, msg: Option<String>) -> Self { + OutputMessage { id, action, msg } + } +} diff --git a/src/note_filter.rs b/src/note_filter.rs @@ -0,0 +1,20 @@ +use crate::{InputMessage, OutputMessage}; +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct Note { + pub id: String, + pub pubkey: String, + pub content: String, + pub created_at: i64, + pub kind: i64, + pub tags: Vec<Vec<String>>, + pub sig: String, +} + +pub trait NoteFilter { + fn filter_note(&mut self, msg: &InputMessage) -> OutputMessage; + + /// A key corresponding to an entry in the noteguard.toml file. + fn name(&self) -> &'static str; +} diff --git a/test/test-inputs b/test/test-inputs @@ -0,0 +1,5 @@ +{"type": "new","receivedAt":12345,"sourceType":"IP4","sourceInfo": "127.0.0.1","event":{"id": "68421a122cef086512b2c5bd29ca6285ced8bd8e302e347e3c5d90466c860a76","pubkey": "16c21558762108afc34e4ff19e4ed51d9a48f79e0c34531efc423d21ab435e93","created_at": 1720408658,"kind": 1,"tags": [],"content": "hi","sig": "7b76471744ded2b720ca832cdc89e670f6093ce38aeef55a5c6a4e077883d7d80dda1e9051032fb1faa1c3c212c517e93ee42b3ceac8e8e9b04bad46a361de90"}} +{"type": "new","receivedAt":12345,"sourceType":"IP4","sourceInfo": "127.0.0.1","event":{"id": "68421a122cef086512b2c5bd29ca6285ced8bd8e302e347e3c5d90466c860a76","pubkey": "16c21558762108afc34e4ff19e4ed51d9a48f79e0c34531efc423d21ab435e93","created_at": 1720408658,"kind": 1,"tags": [],"content": "hi","sig": "7b76471744ded2b720ca832cdc89e670f6093ce38aeef55a5c6a4e077883d7d80dda1e9051032fb1faa1c3c212c517e93ee42b3ceac8e8e9b04bad46a361de90"}} +{"type": "new","receivedAt":12345,"sourceType":"IP4","sourceInfo": "127.0.0.1","event":{"id": "68421a122cef086512b2c5bd29ca6285ced8bd8e302e347e3c5d90466c860a76","pubkey": "16c21558762108afc34e4ff19e4ed51d9a48f79e0c34531efc423d21ab435e93","created_at": 1720408658,"kind": 1,"tags": [],"content": "hi","sig": "7b76471744ded2b720ca832cdc89e670f6093ce38aeef55a5c6a4e077883d7d80dda1e9051032fb1faa1c3c212c517e93ee42b3ceac8e8e9b04bad46a361de90"}} +{"type": "new","receivedAt":12345,"sourceType":"IP4","sourceInfo": "127.0.0.2","event":{"id": "68421a122cef086512b2c5bd29ca6285ced8bd8e302e347e3c5d90466c860a76","pubkey": "16c21558762108afc34e4ff19e4ed51d9a48f79e0c34531efc423d21ab435e93","created_at": 1720408658,"kind": 1,"tags": [],"content": "hi","sig": "7b76471744ded2b720ca832cdc89e670f6093ce38aeef55a5c6a4e077883d7d80dda1e9051032fb1faa1c3c212c517e93ee42b3ceac8e8e9b04bad46a361de90"}} +{"type": "new","receivedAt":12345,"sourceType":"IP4","sourceInfo": "127.0.0.2","event":{"id": "68421a122cef086512b2c5bd29ca6285ced8bd8e302e347e3c5d90466c860a76","pubkey": "16c21558762108afc34e4ff19e4ed51d9a48f79e0c34531efc423d21ab435e93","created_at": 1720408658,"kind": 1,"tags": [],"content": "hi","sig": "7b76471744ded2b720ca832cdc89e670f6093ce38aeef55a5c6a4e077883d7d80dda1e9051032fb1faa1c3c212c517e93ee42b3ceac8e8e9b04bad46a361de90"}}