commit 24a1c0dfc297956e3533b2bc52cc08c2197646f1
Author: William Casarin <jb55@jb55.com>
Date: Sun, 7 Jul 2024 22:29:29 -0500
initial commit
Diffstat:
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"}}