Initial commit

This commit is contained in:
Tove 2025-12-17 19:26:17 +01:00
commit 67efc837b2
Signed by: TudbuT
GPG key ID: B3CF345217F202D3
15 changed files with 703 additions and 0 deletions

1
.envrc Normal file
View file

@ -0,0 +1 @@
use nix

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
.direnv/
/bombai.toml
# Added by cargo
/target

13
Caddyfile Normal file
View file

@ -0,0 +1,13 @@
git.example.com {
@read method GET HEAD
reverse_proxy @read 127.0.0.1:42067 {
@fallback status 421
handle_response @fallback
@iocaine status 423
handle_response @iocaine {
reverse_proxy 127.0.0.1:42069 # iocaine needs to be configured to always serve its poison for this.
}
}
reverse_proxy localhost:42067
}

110
Cargo.lock generated Normal file
View file

@ -0,0 +1,110 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "bombai"
version = "0.1.0"
dependencies = [
"deborrow",
"flate2",
"horrorhttp",
"microlock",
"nanoserde",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "deborrow"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5f99cc7a6632788aab2c734a6e1e1d8302658f70fb45d99a6e32154a24cc2a2"
dependencies = [
"deborrow-macro",
]
[[package]]
name = "deborrow-macro"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "551a13c0871ba8964b30d2407fdfd4c9b8e5f289950c152ff3d0d8de5be6b948"
[[package]]
name = "flate2"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "horrorhttp"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9940b42c31d4b7bc31b55b7aaa8c3bfb08ab9ffc5142aa7f882ff7a59719fd1a"
dependencies = [
"readformat",
]
[[package]]
name = "microlock"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72e86c3eafbef4b37cd39b2ee82cc06d4d76231f86f230d14cf8ebdf304125bd"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "nanoserde"
version = "0.2.1"
source = "git+https://github.com/tudbut/nanoserde#fc010f51957432aec80dba0a70af0dadc3cbe38f"
dependencies = [
"nanoserde-derive",
]
[[package]]
name = "nanoserde-derive"
version = "0.2.1"
source = "git+https://github.com/tudbut/nanoserde#fc010f51957432aec80dba0a70af0dadc3cbe38f"
[[package]]
name = "readformat"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e1fd097dab477324dfeb476d75627e38fd1a7437bc0e94751084be499d7e6b1"
[[package]]
name = "simd-adler32"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"

11
Cargo.toml Normal file
View file

@ -0,0 +1,11 @@
[package]
name = "bombai"
version = "0.1.0"
edition = "2024"
[dependencies]
deborrow = "0.3.1"
flate2 = "1.1.5"
horrorhttp = "0.2.1"
microlock = "0.3.1"
nanoserde.git = "https://github.com/tudbut/nanoserde"

33
LICENSE Normal file
View file

@ -0,0 +1,33 @@
WTFPL+-AI
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE PLUS MINUS AI
Version 1, August 2025
Copyright (C) 2025 TudbuT
Everyone is permitted to copy and distribute verbatim or modified
copies of this license document, and changing it is allowed as long
as the name is changed.
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE PLUS MINUS AI
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. You do not willingly contribute to the spread/use of
Generative AI technologies with, within, or without this
work.
1. You do not relicense this work.
2. Otherwise you just DO WHAT THE FUCK YOU WANT TO!
DISCLAIMER
This 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 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.
So in short:
DO NOT USE GENAI,
DO WHATEVER YOU WANT,
BUT IF IT BREAKS SOMETHING,
DONT COME CRYING — OR SUING — TO ME.

29
README.md Normal file
View file

@ -0,0 +1,29 @@
# bombai: bomba ai
instead of letting the ai boom bomb our websites, lets bomb the ai in return.
# config
defalt config is automatically dropped to disk and can also be found at src/bombai.toml
# how to
add to caddyfile as per the caddyfile in this repo. the iocaine part is not required.
```caddyfile
@read method GET HEAD
reverse_proxy @read 127.0.0.1:42067 {
@fallback status 421
handle_response @fallback
# optional, if using fail_response.data = http
@iocaine status 423
handle_response @iocaine {
reverse_proxy 127.0.0.1:42069 # iocaine needs to be configured to always serve its poison for this.
}
}
```
# license
wtfpl+-ai. no ai allowed, everything else allowed.

12
shell.nix Normal file
View file

@ -0,0 +1,12 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
nativeBuildInputs = with pkgs; [
rust-analyzer
cargo
rustc
clippy
rustfmt
cargo-watch
];
}

79
src/bombai.toml Normal file
View file

@ -0,0 +1,79 @@
# bomba + ai = bombai.
#
# sends zip bombs, infinite data, or other bullshit to AI when it requests lots of data.
# example config for a forgejo instance.
# whether to group counters by address. bots may use many addresses, so this
# may not be useful
by_addr = false
[handler]
listen_port = 42067 # this is six seven software
pass_response = 421 # response code on pass (should be handled by reverse proxy)
[fail_response]
status = 200
status_name = "OK"
data = "generated" # valid: "generated", "file", "http"
# used when data = "generated"
[fail_response.generated]
gzip = true # gzip the response for a zip bomb experience
length = "1G" # length of the response in bytes, or "none" for no length header (extra evil but maybe unsupported)
content-type = "text/html" # so AIs actually ingest it
byte = 97 # 97 = 0x61 = 'a' - byte to fill the length with
start_message = "bombai anti ai bomb. have fun<br>\n" # text to insert at the start
# used when data = "file"
[fail_response.file]
is_html = true # set to true to serve an html file. otherwise sends the file back as-is
path = "failure.html"
# used when data = "http"
[fail_response.http]
response = 423 # response code to give on failure (should be handled by reverse proxy)
continuous_failure.enable = true
continuous_failure.path = "/failure" # path to redirect to. all sub-paths are also failure.
[[paths]]
# whether requesting paths in this area is very costly for the server
# must be true for bombai to be active here.
# set to false to OK these paths.
costly = false
paths = [
"/*/*/archive/main.*" # archives of main branch
"/*/*/archive/master.*" # archives of master branch
"/*/*/archive/*.*.*" # archives of releases
]
[[paths]]
# whether requesting paths in this area is very costly for the server
# must be true for bombai to be active here.
# set to false to OK these paths.
costly = true
# whether requesting the same path multiple times is fine
cached = true
counter.max = 10 # when to start returning bullshit
counter.decay = 1 # per hour
paths = [
"/*/*/archive/*" # archives of repos
]
[[paths]]
# whether requesting paths in this area is very costly for the server
# must be true for bombai to be active here.
# set to false to OK these paths.
costly = true
# whether requesting the same path multiple times is fine
cached = false
counter.max = 1000 # when to start returning bullshit
counter.decay = 100 # per hour
paths = [
"/*"
]

73
src/checker.rs Normal file
View file

@ -0,0 +1,73 @@
use horrorhttp::Connection;
use crate::{CONFIG, Counter, Directive};
impl Counter {
pub fn update(&mut self) {
let hours_elapsed = self.last_decay.time_elapsed().as_millis() as f64 / 1_000. / 3600.;
let decays_per_hour = self.decay;
if hours_elapsed < 1. / decays_per_hour {
return;
}
self.current = (self.current - hours_elapsed * decays_per_hour).max(0.);
self.last_decay.restart();
}
pub fn is_bad(&mut self) -> bool {
self.update();
self.current >= self.max
}
}
impl Directive {
pub fn check_and_insert(&self, connection: &Connection) -> bool {
let path_in = connection.path.trim_matches('/');
let mut visited_paths = self.visited_paths.lock().unwrap();
if self.cached && visited_paths.contains(&connection.path) {
return false;
}
let path_segments_in: Vec<_> = path_in.split("/").collect();
for path in self.paths.iter() {
if path.matches("/").count() <= path_segments_in.len()
&& path
.split("/")
.zip(path_segments_in.iter())
.all(|(a, b)| if a == "*" { true } else { a == *b })
{
visited_paths.insert(path_in.to_owned());
let mut counter = self.counter.lock().unwrap();
counter.update();
counter.current += 1.;
return true;
}
}
false
}
}
pub fn request_is_okay(connection: &mut Connection, directives: &[Directive]) -> bool {
if CONFIG["fail_response.data"].str() == "http"
&& CONFIG["fail_response.http.continuous_failure.enable"].boolean()
&& connection
.path
.starts_with(CONFIG["fail_response.http.continuous_failure.path"].str())
{
println!(" is in continuous failure");
return false;
}
for (i, directive) in directives.iter().enumerate() {
if directive.check_and_insert(connection) {
if directive.costly {
println!(" matched costly directive {i}");
if directive.counter.lock().unwrap().is_bad() {
println!(" counter is bad");
return false;
}
} else {
println!(" matched non-costly directive {i} :)");
return true;
}
}
}
true
}

164
src/main.rs Normal file
View file

@ -0,0 +1,164 @@
pub mod checker;
pub mod responder;
use std::{
collections::{BTreeMap, HashMap, HashSet},
fs,
mem::ManuallyDrop,
net::TcpListener,
ops::Deref,
sync::{LazyLock, Mutex},
};
use deborrow::deborrow;
use horrorhttp::{BodyReaderHeader, ConnectionState, ResponseWriter};
use microlock::timer::{Timer, TimerDuration};
use nanoserde::{Toml, TomlParser};
use crate::responder::BullshitResponder;
#[derive(Clone)]
pub struct Counter {
pub max: f64,
pub current: f64,
pub decay: f64,
pub last_decay: Timer,
}
#[derive(Clone)]
pub struct Directive {
pub costly: bool,
pub cached: bool,
pub paths: Vec<String>,
pub visited_paths: CloneMutex<HashSet<String>>,
pub counter: CloneMutex<Counter>,
}
// insanity
pub struct CloneMutex<T>(Mutex<T>);
impl<T: Clone> Clone for CloneMutex<T> {
fn clone(&self) -> Self {
Self(Mutex::new(self.0.lock().unwrap().clone()))
}
}
impl<T> Deref for CloneMutex<T> {
type Target = Mutex<T>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
pub struct Intercept<'a>(&'a Vec<Directive>);
impl<'a> ConnectionState for Intercept<'a> {
fn handle(
self: Box<Self>,
connection: &mut horrorhttp::Connection,
) -> Option<Box<dyn ConnectionState>> {
println!(
"Handling request from {}.",
connection
.headers
.get("X-Forwarded-For")
.cloned()
.unwrap_or_else(|| connection.socket.peer_addr().unwrap().ip().to_string())
);
if checker::request_is_okay(connection, self.0) {
println!("Request is OK.");
Some(Box::new(ResponseWriter::new().with_status(
CONFIG["handler.pass_response"].num() as u32,
"Misdirected Request",
)))
} else {
println!("Request is not OK. Sending you to the gallows.");
Some(Box::new(BullshitResponder))
}
}
}
const DEFAULT_CONFIG: &str = include_str!("./bombai.toml");
pub static CONFIG: LazyLock<BTreeMap<String, Toml>> = LazyLock::new(|| {
let s = {
match fs::read_to_string("./bombai.toml") {
Ok(x) => x,
Err(_) => {
fs::write("./bombai.toml", DEFAULT_CONFIG).unwrap();
println!("dropped default config file because no other config was found");
DEFAULT_CONFIG.to_owned()
}
}
};
TomlParser::parse(&s).unwrap()
});
fn main() {
println!("bomba + ai = bombai. it bombs AI.");
println!("config location: ./bombai.toml");
println!();
println!("this is six seven software.");
println!();
let directives = CONFIG["paths"].arr();
let directives: ManuallyDrop<Vec<Directive>> =
ManuallyDrop::new(transform_directives(directives));
let mut directives_by_addr: ManuallyDrop<
HashMap<std::net::IpAddr, ManuallyDrop<Vec<Directive>>>,
> = ManuallyDrop::new(HashMap::new());
let port = CONFIG["handler.listen_port"].num() as u16;
let server = TcpListener::bind(("::0", port)).unwrap();
println!("listening on [::0]:{port}");
while let Ok((stream, addr)) = server.accept() {
// SAFETY: safe because:
// - all borrows are not mutable.
// - everything is ManuallyDrop
let local_directives = unsafe {
deborrow(if CONFIG["by_addr"].boolean() {
match directives_by_addr.get(&addr.ip()) {
Some(x) => x,
None => {
let directives = directives.clone();
directives_by_addr.insert(addr.ip(), directives);
directives_by_addr.get(&addr.ip()).unwrap()
}
}
} else {
&directives
})
};
horrorhttp::handle(stream, BodyReaderHeader, Intercept(local_directives));
}
}
fn transform_directives(directives: &[BTreeMap<String, Toml>]) -> Vec<Directive> {
directives
.iter()
.map(|x| {
println!("{x:?}");
let costly = x["costly"].boolean();
Directive {
costly,
cached: if costly { x["cached"].boolean() } else { false },
paths: x["paths"]
.simple_arr()
.iter()
.map(|x| x.str().trim_matches('/').to_owned())
.collect(),
visited_paths: CloneMutex(Mutex::new(HashSet::new())),
counter: CloneMutex(Mutex::new(Counter {
max: if costly {
x["counter.max"].num()
} else {
f64::INFINITY
},
current: 0.,
decay: if costly { x["counter.decay"].num() } else { 0. },
last_decay: Timer::new(TimerDuration::Elapsed),
})),
}
})
.collect()
}

29
src/responder/file.rs Normal file
View file

@ -0,0 +1,29 @@
use std::{fs, io::Write};
use horrorhttp::{ConnectionState, ResponseWriter};
use crate::CONFIG;
pub struct FileBullshitResponder;
impl ConnectionState for FileBullshitResponder {
fn handle(
self: Box<Self>,
connection: &mut horrorhttp::Connection,
) -> Option<Box<dyn ConnectionState>> {
let data = fs::read(CONFIG["fail_response.file.path"].str()).unwrap();
if CONFIG["fail_response.file.is_html"].boolean() {
return Some(Box::new(
ResponseWriter::new()
.with_status(
CONFIG["fail_response.status"].num() as u32,
CONFIG["fail_response.status_name"].str(),
)
.with_header("Content-Type", "text/html")
.with_body(data),
));
}
let _ = connection.socket.write_all(&data);
None
}
}

View file

@ -0,0 +1,84 @@
use std::io::Write;
use std::sync::LazyLock;
use flate2::{Compression, write::GzEncoder};
use horrorhttp::{Connection, ConnectionState, ResponseWriter};
use crate::CONFIG;
static BODY: LazyLock<Vec<u8>> = LazyLock::new(gen_body);
static KILOBYTE: LazyLock<Vec<u8>> = LazyLock::new(|| {
let mut kilobyte = Vec::new();
for _ in 0..1024 {
kilobyte.push(CONFIG["fail_response.generated.byte"].num() as u8);
}
kilobyte
});
pub struct GeneratedBullshitResponder;
impl ConnectionState for GeneratedBullshitResponder {
fn handle(self: Box<Self>, connection: &mut Connection) -> Option<Box<dyn ConnectionState>> {
let mut response_writer = ResponseWriter::new()
.with_status(
CONFIG["fail_response.status"].num() as u32,
CONFIG["fail_response.status_name"].str(),
)
.with_header(
"Content-Type",
CONFIG["fail_response.generated.content-type"].str(),
);
if CONFIG["fail_response.generated.gzip"].boolean() {
response_writer = response_writer
.with_header("Content-Encoding", "gzip")
.with_body(BODY.clone());
} else {
let length = &CONFIG["fail_response.generated.length"];
if length.str() != "none" {
response_writer = response_writer
.with_header("Content-Length", get_length(length.str()).to_string())
}
connection.add_next(Box::new(GeneratedBullshitSpammer));
}
Some(Box::new(response_writer))
}
}
pub struct GeneratedBullshitSpammer;
impl ConnectionState for GeneratedBullshitSpammer {
fn handle(self: Box<Self>, connection: &mut Connection) -> Option<Box<dyn ConnectionState>> {
println!("i am spammer of bytes");
while connection.socket.write_all(&KILOBYTE).is_ok() {}
None
}
}
fn gen_body() -> Vec<u8> {
if CONFIG["fail_response.generated.gzip"].boolean() {
println!("i am constructor of zip bomb");
let mut encoder = GzEncoder::new(Vec::new(), Compression::best());
for _ in 0..get_length(CONFIG["fail_response.generated.length"].str()) / 1024 {
encoder.write_all(&KILOBYTE).unwrap();
}
println!("done");
encoder.finish().unwrap()
} else {
vec![]
}
}
fn get_length(s: &str) -> u64 {
let s = s.trim_end_matches("B");
let num: u64 = s.trim_matches(char::is_alphabetic).parse().unwrap();
match &s[s.len() - 1..=s.len() - 1] {
"K" => num * 1024,
"M" => num * 1024 * 1024,
"G" => num * 1024 * 1024 * 1024,
"T" => num * 1024 * 1024 * 1024 * 1024,
_ if s.chars().all(char::is_numeric) => num,
_ => panic!("invalid length for generated response"),
}
}

29
src/responder/http.rs Normal file
View file

@ -0,0 +1,29 @@
use horrorhttp::{ConnectionState, ResponseWriter};
use crate::CONFIG;
pub struct HttpBullshitResponder;
impl ConnectionState for HttpBullshitResponder {
fn handle(
self: Box<Self>,
connection: &mut horrorhttp::Connection,
) -> Option<Box<dyn ConnectionState>> {
let continuous_failure_path = CONFIG["fail_response.http.continuous_failure.path"].str();
let response_writer = if CONFIG["fail_response.http.continuous_failure.enable"].boolean()
&& connection.path.starts_with(continuous_failure_path)
{
ResponseWriter::new().with_status(302, "Found").with_header(
"Location",
"/".to_owned() + continuous_failure_path.trim_matches('/') + "/" + &connection.path,
)
} else {
ResponseWriter::new().with_status(
CONFIG["fail_response.http.response"].num() as u32,
"HttpFailResponse",
)
};
Some(Box::new(response_writer))
}
}

29
src/responder/mod.rs Normal file
View file

@ -0,0 +1,29 @@
pub mod file;
pub mod generated;
pub mod http;
use horrorhttp::ConnectionState;
use crate::{
CONFIG,
responder::{
file::FileBullshitResponder, generated::GeneratedBullshitResponder,
http::HttpBullshitResponder,
},
};
pub struct BullshitResponder;
impl ConnectionState for BullshitResponder {
fn handle(
self: Box<Self>,
_connection: &mut horrorhttp::Connection,
) -> Option<Box<dyn ConnectionState>> {
match CONFIG["fail_response.data"].str() {
"http" => Some(Box::new(HttpBullshitResponder)),
"generated" => Some(Box::new(GeneratedBullshitResponder)),
"file" => Some(Box::new(FileBullshitResponder)),
_ => panic!("invalid fail_response.data. must be one of 'http', 'generated', 'file'"),
}
}
}