From 24a1c0dfc297956e3533b2bc52cc08c2197646f1 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Sun, 7 Jul 2024 22:29:29 -0500 Subject: [PATCH] initial commit --- .gitignore | 7 ++ COPYING | 19 +++++ Cargo.lock | 143 ++++++++++++++++++++++++++++++++++++++ Cargo.toml | 13 ++++ Makefile | 2 + README.md | 23 ++++++ noteguard.toml | 6 ++ src/filters/mod.rs | 5 ++ src/filters/rate_limit.rs | 54 ++++++++++++++ src/filters/whitelist.rs | 26 +++++++ src/lib.rs | 6 ++ src/main.rs | 143 ++++++++++++++++++++++++++++++++++++++ src/messages.rs | 37 ++++++++++ src/note_filter.rs | 20 ++++++ test/test-inputs | 5 ++ 15 files changed, 509 insertions(+) create mode 100644 .gitignore create mode 100644 COPYING create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 Makefile create mode 100644 README.md create mode 100644 noteguard.toml create mode 100644 src/filters/mod.rs create mode 100644 src/filters/rate_limit.rs create mode 100644 src/filters/whitelist.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/messages.rs create mode 100644 src/note_filter.rs create mode 100644 test/test-inputs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..64b7a3b --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +target/ +.direnv/ +.buildcmd +.build-result +shell.nix +.envrc +tags diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..b668831 --- /dev/null +++ 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 new file mode 100644 index 0000000..d7b48bf --- /dev/null +++ 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 new file mode 100644 index 0000000..977b688 --- /dev/null +++ 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 new file mode 100644 index 0000000..6bd5638 --- /dev/null +++ b/Makefile @@ -0,0 +1,2 @@ +tags: + find src -name '*.rs' | xargs ctags diff --git a/README.md b/README.md new file mode 100644 index 0000000..c218e94 --- /dev/null +++ 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 new file mode 100644 index 0000000..d9eccb2 --- /dev/null +++ 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 new file mode 100644 index 0000000..d351551 --- /dev/null +++ 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 new file mode 100644 index 0000000..ea4f7bc --- /dev/null +++ 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>, + + #[serde(skip)] + pub sources: HashMap, +} + +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 new file mode 100644 index 0000000..1244466 --- /dev/null +++ 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, + pub ips: Vec, +} + +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 new file mode 100644 index 0000000..534567d --- /dev/null +++ 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 new file mode 100644 index 0000000..8a79c7f --- /dev/null +++ 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, + filters: HashMap, +} + +type ConstructFilter = Box Result, toml::de::Error>>; + +#[derive(Default)] +struct Noteguard { + registered_filters: HashMap, + loaded_filters: Vec>, +} + +impl Noteguard { + pub fn new() -> Self { + let mut noteguard = Noteguard::default(); + noteguard.register_builtin_filters(); + noteguard + } + + pub fn register_filter(&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) + }), + ); + } + + /// 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::(); + } + + /// 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 = 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 new file mode 100644 index 0000000..b0a37d4 --- /dev/null +++ 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, +} + +impl OutputMessage { + pub fn new(id: String, action: Action, msg: Option) -> Self { + OutputMessage { id, action, msg } + } +} diff --git a/src/note_filter.rs b/src/note_filter.rs new file mode 100644 index 0000000..7f11504 --- /dev/null +++ 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>, + 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 new file mode 100644 index 0000000..16f713a --- /dev/null +++ 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"}}