feat: Initialize forgejo-guardian
Signed-off-by: Awiteb <a@4rs.nl>
This commit is contained in:
parent
28c828f06d
commit
c41bf5c940
16 changed files with 2453 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
/target
|
||||||
|
forgejo-guardian.toml
|
1640
Cargo.lock
generated
Normal file
1640
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
32
Cargo.toml
Normal file
32
Cargo.toml
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
[package]
|
||||||
|
name = "forgejo-guardian"
|
||||||
|
description = "Simple Forgejo instance guardian, banning users and alerting admins based on certain regular expressions"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
authors = ["Awiteb <a@4rs.nl>"]
|
||||||
|
repository = "https://git.4rs.nl/awiteb/forgejo-guardian"
|
||||||
|
license = "AGPL-3.0-or-later"
|
||||||
|
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { version = "1.0.214", features = ["derive"] }
|
||||||
|
serde_json = "1.0.132"
|
||||||
|
toml = "0.8.19"
|
||||||
|
tracing = "0.1.40"
|
||||||
|
tracing-subscriber = "0.3.18"
|
||||||
|
thiserror = "2.0.2"
|
||||||
|
regex = "1.11.1"
|
||||||
|
|
||||||
|
reqwest = { version = "0.12.9", default-features = false, features = [
|
||||||
|
"charset",
|
||||||
|
"http2",
|
||||||
|
"rustls-tls",
|
||||||
|
] }
|
||||||
|
tokio-util = { version = "0.7.12", default-features = false }
|
||||||
|
tokio = { version = "1.41.1", default-features = false, features = [
|
||||||
|
"rt-multi-thread",
|
||||||
|
"macros",
|
||||||
|
"sync",
|
||||||
|
"signal",
|
||||||
|
] }
|
||||||
|
url = { version = "2.5.3", default-features = false, features = ["serde"] }
|
100
README.md
Normal file
100
README.md
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
# Forgejo Guardian
|
||||||
|
|
||||||
|
Simple Forgejo instance guardian, banning users and alerting admins based on certain regular expressions (regex)
|
||||||
|
|
||||||
|
<!-- [![Forgejo CI Status](https://git.4rs.nl/awiteb/forgejo-guardian/badges/workflows/ci.yml/badge.svg)](https://git.4rs.nl/awiteb/forgejo-guardian)
|
||||||
|
[![Forgejo CD Status](https://git.4rs.nl/awiteb/forgejo-guardian/badges/workflows/cd.yml/badge.svg)](https://git.4rs.nl/awiteb/forgejo-guardian) -->
|
||||||
|
|
||||||
|
[![agplv3-or-later](https://www.gnu.org/graphics/agplv3-88x31.png)](https://www.gnu.org/licenses/agpl-3.0.html)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
You can let [cargo](https://doc.rust-lang.org/cargo/) install the binary for you, or build it yourself. <!-- You can also download the pre-built binaries from the [releases](https://git.4rs.nl/awiteb/forgejo-guardian/releases) page. -->
|
||||||
|
|
||||||
|
### Build it
|
||||||
|
|
||||||
|
#### `cargo-install`
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> This will install the binary in `~/.cargo/bin/forgejo-guardian`. Make sure to add this directory to your `PATH`.
|
||||||
|
> If you want to update it, run `cargo install ...` again.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo install --git https://git.4rs.nl/awiteb/forgejo-guardian
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `cargo-install` (from source)
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> Then when you want to update it, pull the changes and run `cargo install --path .` again.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://git.4rs.nl/awiteb/forgejo-guardian
|
||||||
|
cd forgejo-guardian
|
||||||
|
cargo install --path .
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Build (from source)
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> The binary will be in `./target/release/forgejo-guardian`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://git.4rs.nl/awiteb/forgejo-guardian
|
||||||
|
cd forgejo-guardian
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
We use `TOML` format for configuration, the default configuration file is `/app/forgejo-guardian.toml`, but you can specify a different one with `FORGEJO_GUARDIAN_CONFIG` environment variable.
|
||||||
|
|
||||||
|
### Structure
|
||||||
|
|
||||||
|
In our configuration file, we have two main sections:
|
||||||
|
|
||||||
|
- `forgejo`: Forgejo instance configuration
|
||||||
|
- `expressions`: Regular expressions to match against
|
||||||
|
<!-- - `telegram`: Telegram bot configuration -->
|
||||||
|
|
||||||
|
#### `forgejo`
|
||||||
|
|
||||||
|
Forgejo configuration section, with the following fields:
|
||||||
|
|
||||||
|
- `instance_url`: Forgejo instance URL (must be HTTPS or HTTP)
|
||||||
|
- `token`: Token to use to get the new users and ban them, requires `read:admin` and `write:admin` scopes.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[forgejo]
|
||||||
|
instance_url = "https://forgejo.example
|
||||||
|
token = "your-token"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `expressions`
|
||||||
|
|
||||||
|
Expressions configuration section, with the following fields:
|
||||||
|
|
||||||
|
- `ban`: Regular expressions to match against to ban the user
|
||||||
|
- `sus`: Regular expressions to match against to alert the admins
|
||||||
|
|
||||||
|
`ban` and `sus` are tables, and each one have the following fields:
|
||||||
|
|
||||||
|
- `usernames`: Regular expressions to match against the usernames
|
||||||
|
- `full_names`: Regular expressions to match against the full names
|
||||||
|
- `biographies`: Regular expressions to match against the biographies
|
||||||
|
- `emails`: Regular expressions to match against the emails
|
||||||
|
- `websites`: Regular expressions to match against the websites
|
||||||
|
- `locations`: Regular expressions to match against the locations
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[expressions.ban]
|
||||||
|
usernames = ['^admin.*$']
|
||||||
|
|
||||||
|
[expressions.sus]
|
||||||
|
usernames = ['^mod.*$']
|
||||||
|
```
|
||||||
|
|
13
rust-toolchain.toml
Normal file
13
rust-toolchain.toml
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
[toolchain]
|
||||||
|
# We use nightly in development only, the project will always be compliant with
|
||||||
|
# the latest stable release and the MSRV as defined in `Cargo.toml` file.
|
||||||
|
channel = "nightly-2024-11-12"
|
||||||
|
components = [
|
||||||
|
"rustc",
|
||||||
|
"cargo",
|
||||||
|
"rust-std",
|
||||||
|
"rust-src",
|
||||||
|
"rustfmt",
|
||||||
|
"rust-analyzer",
|
||||||
|
"clippy",
|
||||||
|
]
|
21
rustfmt.toml
Normal file
21
rustfmt.toml
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
unstable_features = true
|
||||||
|
style_edition = "2021"
|
||||||
|
|
||||||
|
combine_control_expr = false
|
||||||
|
wrap_comments = true
|
||||||
|
condense_wildcard_suffixes = true
|
||||||
|
edition = "2021"
|
||||||
|
enum_discrim_align_threshold = 20
|
||||||
|
force_multiline_blocks = true
|
||||||
|
format_code_in_doc_comments = true
|
||||||
|
format_generated_files = false
|
||||||
|
format_macro_matchers = true
|
||||||
|
format_strings = true
|
||||||
|
imports_layout = "HorizontalVertical"
|
||||||
|
newline_style = "Unix"
|
||||||
|
normalize_comments = true
|
||||||
|
reorder_impl_items = true
|
||||||
|
group_imports = "StdExternalCrate"
|
||||||
|
single_line_let_else_max_width = 0
|
||||||
|
struct_field_align_threshold = 20
|
||||||
|
use_try_shorthand = true
|
132
src/config.rs
Normal file
132
src/config.rs
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
// Simple Forgejo instance guardian, banning users and alerting admins based on
|
||||||
|
// certain regular expressions. Copyright (C) 2024 Awiteb <a@4rs.nl>
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published
|
||||||
|
// by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://gnu.org/licenses/agpl.txt>.
|
||||||
|
|
||||||
|
/// The environment variable of the config file path
|
||||||
|
pub(crate) const CONFIG_PATH_ENV: &str = "FORGEJO_GUARDIAN_CONFIG";
|
||||||
|
/// Defult config path location
|
||||||
|
pub(crate) const DEFAULT_CONFIG_PATH: &str = "/app/forgejo-guardian.toml";
|
||||||
|
|
||||||
|
use regex::Regex;
|
||||||
|
use serde::{de, Deserialize};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
/// Deserialize a string into a `url::Url`
|
||||||
|
///
|
||||||
|
/// This will check if the url is `http` or `https` and if it is a valid url
|
||||||
|
fn deserialize_str_url<'de, D>(deserializer: D) -> Result<Url, D::Error>
|
||||||
|
where
|
||||||
|
D: de::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let url = Url::parse(&String::deserialize(deserializer)?)
|
||||||
|
.map_err(|e| de::Error::custom(e.to_string()))?;
|
||||||
|
if url.scheme() != "http" && url.scheme() != "https" {
|
||||||
|
return Err(de::Error::custom("URL scheme must be http or https"));
|
||||||
|
}
|
||||||
|
Ok(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deserialize a vector of strings into a vector of `regex::Regex`
|
||||||
|
fn deserialize_regex_vec<'de, D>(deserializer: D) -> Result<Vec<Regex>, D::Error>
|
||||||
|
where
|
||||||
|
D: de::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
Vec::<String>::deserialize(deserializer)?
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| Regex::new(&s))
|
||||||
|
.collect::<Result<_, _>>()
|
||||||
|
.map_err(|e| de::Error::custom(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The forgejo config of the guard
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Forgejo {
|
||||||
|
/// The bot token
|
||||||
|
///
|
||||||
|
/// Required Permissions:
|
||||||
|
/// - `read:admin`: To list the users
|
||||||
|
/// - `write:admin`: To ban the users
|
||||||
|
pub token: String,
|
||||||
|
/// The instance, e.g. `https://example.com` or `https://example.com/` or `http://example.com:8080`
|
||||||
|
#[serde(rename = "instance_url", deserialize_with = "deserialize_str_url")]
|
||||||
|
pub instance: Url,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The expression
|
||||||
|
#[derive(Deserialize, Debug, Default)]
|
||||||
|
pub struct Expr {
|
||||||
|
/// The regular expressions that the action will be performed if they are
|
||||||
|
/// present in the username
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde(deserialize_with = "deserialize_regex_vec")]
|
||||||
|
pub usernames: Vec<Regex>,
|
||||||
|
|
||||||
|
/// The regular expressions that the action will be performed if they are
|
||||||
|
/// present in the user full_name
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde(deserialize_with = "deserialize_regex_vec")]
|
||||||
|
pub full_names: Vec<Regex>,
|
||||||
|
|
||||||
|
/// The regular expressions that the action will be performed if they are
|
||||||
|
/// present in the user biography
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde(deserialize_with = "deserialize_regex_vec")]
|
||||||
|
pub biographies: Vec<Regex>,
|
||||||
|
|
||||||
|
/// The regular expressions that the action will be performed if they are
|
||||||
|
/// present in the user email
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde(deserialize_with = "deserialize_regex_vec")]
|
||||||
|
pub emails: Vec<Regex>,
|
||||||
|
|
||||||
|
/// The regular expressions that the action will be performed if they are
|
||||||
|
/// present in the user website
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde(deserialize_with = "deserialize_regex_vec")]
|
||||||
|
pub websites: Vec<Regex>,
|
||||||
|
|
||||||
|
/// The regular expressions that the action will be performed if they are
|
||||||
|
/// present in the user location
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde(deserialize_with = "deserialize_regex_vec")]
|
||||||
|
pub locations: Vec<Regex>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// the expressions
|
||||||
|
#[derive(Deserialize, Debug, Default)]
|
||||||
|
pub struct Exprs {
|
||||||
|
/// Direct ban expressions.
|
||||||
|
///
|
||||||
|
/// Users are directly banned if any of the expressions are true
|
||||||
|
#[serde(default)]
|
||||||
|
pub ban: Expr,
|
||||||
|
|
||||||
|
/// Alert expressions.
|
||||||
|
///
|
||||||
|
/// Moderators will be notified via Telegram if one of the expressions are
|
||||||
|
/// true
|
||||||
|
#[serde(default)]
|
||||||
|
pub sus: Expr,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// forgejo-guard configuration
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
/// Configuration for the forgejo guard itself
|
||||||
|
pub forgejo: Forgejo,
|
||||||
|
/// The expressions, which are used to determine the actions
|
||||||
|
#[serde(default)]
|
||||||
|
pub expressions: Exprs,
|
||||||
|
}
|
42
src/error.rs
Normal file
42
src/error.rs
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
// Simple Forgejo instance guardian, banning users and alerting admins based on
|
||||||
|
// certain regular expressions. Copyright (C) 2024 Awiteb <a@4rs.nl>
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published
|
||||||
|
// by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://gnu.org/licenses/agpl.txt>.
|
||||||
|
|
||||||
|
use crate::config::{CONFIG_PATH_ENV, DEFAULT_CONFIG_PATH};
|
||||||
|
|
||||||
|
/// Result of the guard
|
||||||
|
pub type GuardResult<T> = Result<T, GuardError>;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum GuardError {
|
||||||
|
/// IO errors
|
||||||
|
#[error("IO error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
/// reqwest error
|
||||||
|
#[error("Sending request error: {0}")]
|
||||||
|
RequestError(#[from] reqwest::Error),
|
||||||
|
/// Invalid response from Forgejo
|
||||||
|
#[error("Invalid response from Forgejo, the error `{0:?}` request `{1:?}`")]
|
||||||
|
InvalidForgejoResponse(String, reqwest::Request),
|
||||||
|
/// Faild to get the config file
|
||||||
|
#[error(
|
||||||
|
"The configuration file could not be accessed, its path is not in the `{CONFIG_PATH_ENV}` \
|
||||||
|
environment variable nor is it in the default path `{DEFAULT_CONFIG_PATH}`"
|
||||||
|
)]
|
||||||
|
CantGetConfigFile,
|
||||||
|
/// Faild to deserialize the config file
|
||||||
|
#[error("Failed to deserialize the config: {0}")]
|
||||||
|
FaildDeserializeConfig(#[from] toml::de::Error),
|
||||||
|
}
|
40
src/forgejo_api/ban_user.rs
Normal file
40
src/forgejo_api/ban_user.rs
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
// Simple Forgejo instance guardian, banning users and alerting admins based on
|
||||||
|
// certain regular expressions. Copyright (C) 2024 Awiteb <a@4rs.nl>
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published
|
||||||
|
// by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://gnu.org/licenses/agpl.txt>.
|
||||||
|
|
||||||
|
use reqwest::Method;
|
||||||
|
|
||||||
|
use crate::error::GuardResult;
|
||||||
|
|
||||||
|
/// Ban a user from the instance, purging their data.
|
||||||
|
pub async fn ban_user(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
instance: &url::Url,
|
||||||
|
token: &str,
|
||||||
|
username: &str,
|
||||||
|
) -> GuardResult<()> {
|
||||||
|
let res = client
|
||||||
|
.execute(super::build_request(
|
||||||
|
Method::DELETE,
|
||||||
|
instance,
|
||||||
|
token,
|
||||||
|
&format!("/api/v1/admin/users/{username}?purge=true"),
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
tracing::debug!("Ban user response: {:?}", &res);
|
||||||
|
tracing::debug!("Body: {}", res.text().await.unwrap_or_default());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
44
src/forgejo_api/get_users.rs
Normal file
44
src/forgejo_api/get_users.rs
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
// Simple Forgejo instance guardian, banning users and alerting admins based on
|
||||||
|
// certain regular expressions. Copyright (C) 2024 Awiteb <a@4rs.nl>
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published
|
||||||
|
// by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://gnu.org/licenses/agpl.txt>.
|
||||||
|
|
||||||
|
use reqwest::Method;
|
||||||
|
|
||||||
|
use super::ForgejoUser;
|
||||||
|
use crate::error::{GuardError, GuardResult};
|
||||||
|
|
||||||
|
/// Returns the first page of users from the instance
|
||||||
|
pub async fn get_users(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
instance: &url::Url,
|
||||||
|
token: &str,
|
||||||
|
) -> GuardResult<Vec<ForgejoUser>> {
|
||||||
|
let req = super::build_request(Method::GET, instance, token, "/api/v1/admin/users");
|
||||||
|
let res = client
|
||||||
|
.execute(req.try_clone().expect("There is no body"))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !res.status().is_success() {
|
||||||
|
return Err(GuardError::InvalidForgejoResponse(
|
||||||
|
format!("Status code: {status}", status = res.status()),
|
||||||
|
req,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::debug!("Get users response: {res:?}");
|
||||||
|
|
||||||
|
serde_json::from_str(&res.text().await?)
|
||||||
|
.map_err(|err| GuardError::InvalidForgejoResponse(err.to_string(), req))
|
||||||
|
}
|
41
src/forgejo_api/mod.rs
Normal file
41
src/forgejo_api/mod.rs
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
// Simple Forgejo instance guardian, banning users and alerting admins based on
|
||||||
|
// certain regular expressions. Copyright (C) 2024 Awiteb <a@4rs.nl>
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published
|
||||||
|
// by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://gnu.org/licenses/agpl.txt>.
|
||||||
|
|
||||||
|
//! Simple SDK for Forgejo API, only for banning users and getting users.
|
||||||
|
|
||||||
|
mod ban_user;
|
||||||
|
mod get_users;
|
||||||
|
mod user;
|
||||||
|
|
||||||
|
pub use ban_user::*;
|
||||||
|
pub use get_users::*;
|
||||||
|
use reqwest::{Method, Request};
|
||||||
|
pub use user::*;
|
||||||
|
|
||||||
|
/// Build a request with the given method, instance, token and endpoint.
|
||||||
|
pub fn build_request(method: Method, instance: &url::Url, token: &str, endpoint: &str) -> Request {
|
||||||
|
let url = instance.join(endpoint).unwrap();
|
||||||
|
let mut req = Request::new(method, url);
|
||||||
|
|
||||||
|
req.headers_mut().insert(
|
||||||
|
"Authorization",
|
||||||
|
format!("token {token}").try_into().expect("Is valid"),
|
||||||
|
);
|
||||||
|
req.headers_mut()
|
||||||
|
.insert("accept", "application/json".try_into().expect("Is valid"));
|
||||||
|
|
||||||
|
req
|
||||||
|
}
|
44
src/forgejo_api/user.rs
Normal file
44
src/forgejo_api/user.rs
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
// Simple Forgejo instance guardian, banning users and alerting admins based on
|
||||||
|
// certain regular expressions. Copyright (C) 2024 Awiteb <a@4rs.nl>
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published
|
||||||
|
// by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://gnu.org/licenses/agpl.txt>.
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
/// Forgejo user
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct ForgejoUser {
|
||||||
|
/// User id, incremental integer
|
||||||
|
pub id: usize,
|
||||||
|
/// Avatar URL
|
||||||
|
pub avatar_url: url::Url,
|
||||||
|
/// HTML URL
|
||||||
|
pub html_url: url::Url,
|
||||||
|
/// Is admin
|
||||||
|
pub is_admin: bool,
|
||||||
|
/// Username
|
||||||
|
#[serde(rename = "login")]
|
||||||
|
pub username: String,
|
||||||
|
/// Full name
|
||||||
|
pub full_name: String,
|
||||||
|
/// Biography (AKA bio, profile description)
|
||||||
|
#[serde(rename = "description")]
|
||||||
|
pub biography: String,
|
||||||
|
/// Email
|
||||||
|
pub email: String,
|
||||||
|
/// Website
|
||||||
|
pub website: String,
|
||||||
|
/// Location
|
||||||
|
pub location: String,
|
||||||
|
}
|
72
src/main.rs
Normal file
72
src/main.rs
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
// Simple Forgejo instance guardian, banning users and alerting admins based on
|
||||||
|
// certain regular expressions. Copyright (C) 2024 Awiteb <a@4rs.nl>
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published
|
||||||
|
// by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://gnu.org/licenses/agpl.txt>.
|
||||||
|
|
||||||
|
use std::{process::ExitCode, sync::Arc, time::Duration};
|
||||||
|
|
||||||
|
use tokio::{signal::ctrl_c, sync};
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
pub mod config;
|
||||||
|
pub mod error;
|
||||||
|
pub mod forgejo_api;
|
||||||
|
pub mod traits;
|
||||||
|
pub mod users_fetcher;
|
||||||
|
pub mod utils;
|
||||||
|
|
||||||
|
async fn try_main() -> error::GuardResult<()> {
|
||||||
|
let config = Arc::new(utils::get_config()?);
|
||||||
|
let cancellation_token = CancellationToken::new();
|
||||||
|
// Suspicious users are sent and received in this channel, users who meet the
|
||||||
|
// `alert` expressions
|
||||||
|
let (sus_sender, _sus_receiver) = sync::mpsc::channel::<forgejo_api::ForgejoUser>(100);
|
||||||
|
|
||||||
|
tracing::info!("The instance: {}", config.forgejo.instance);
|
||||||
|
tracing::debug!("The config exprs: {:#?}", config.expressions);
|
||||||
|
|
||||||
|
tokio::spawn(users_fetcher::users_fetcher(
|
||||||
|
Arc::clone(&config),
|
||||||
|
cancellation_token.clone(),
|
||||||
|
sus_sender.clone(),
|
||||||
|
));
|
||||||
|
|
||||||
|
// TODO: Sus worker, who will receive sus users
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = ctrl_c() => {
|
||||||
|
cancellation_token.cancel();
|
||||||
|
}
|
||||||
|
_ = cancellation_token.cancelled() => {}
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!("Waiting for graceful shutdown");
|
||||||
|
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> ExitCode {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_max_level(utils::get_log_level())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
if let Err(err) = try_main().await {
|
||||||
|
eprintln!("{err}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
}
|
42
src/traits.rs
Normal file
42
src/traits.rs
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
// Simple Forgejo instance guardian, banning users and alerting admins based on
|
||||||
|
// certain regular expressions. Copyright (C) 2024 Awiteb <a@4rs.nl>
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published
|
||||||
|
// by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://gnu.org/licenses/agpl.txt>.
|
||||||
|
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
use crate::{config::Expr, forgejo_api::ForgejoUser};
|
||||||
|
|
||||||
|
/// Trait for checking if a user matches one of the expressions
|
||||||
|
pub trait ExprChecker {
|
||||||
|
/// Returns the first matching expression, if any
|
||||||
|
fn is_match(&self, user: &ForgejoUser) -> Option<Regex>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExprChecker for Expr {
|
||||||
|
fn is_match<'a>(&'a self, user: &ForgejoUser) -> Option<Regex> {
|
||||||
|
let one_of = |hay, exprs: &'a Vec<Regex>| exprs.iter().find(|re| re.is_match(hay));
|
||||||
|
[
|
||||||
|
one_of(&user.username, &self.usernames),
|
||||||
|
one_of(&user.full_name, &self.full_names),
|
||||||
|
one_of(&user.biography, &self.biographies),
|
||||||
|
one_of(&user.email, &self.emails),
|
||||||
|
one_of(&user.website, &self.websites),
|
||||||
|
one_of(&user.location, &self.locations),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.find_map(|v| v)
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
}
|
142
src/users_fetcher.rs
Normal file
142
src/users_fetcher.rs
Normal file
|
@ -0,0 +1,142 @@
|
||||||
|
// Simple Forgejo instance guardian, banning users and alerting admins based on
|
||||||
|
// certain regular expressions. Copyright (C) 2024 Awiteb <a@4rs.nl>
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published
|
||||||
|
// by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://gnu.org/licenses/agpl.txt>.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
sync::{
|
||||||
|
atomic::{AtomicUsize, Ordering},
|
||||||
|
Arc,
|
||||||
|
},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use tokio::sync::mpsc::Sender;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
config::Config,
|
||||||
|
error::GuardResult,
|
||||||
|
forgejo_api::{self, get_users, ForgejoUser},
|
||||||
|
traits::ExprChecker,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Get the new instance users, the vector may be empty if there are no new users
|
||||||
|
///
|
||||||
|
/// Forgejo use intger ids for the users, so we can use the last user id to get
|
||||||
|
/// the new users.
|
||||||
|
async fn get_new_users(
|
||||||
|
request_client: &reqwest::Client,
|
||||||
|
last_user_id: usize,
|
||||||
|
config: &Config,
|
||||||
|
) -> GuardResult<Vec<ForgejoUser>> {
|
||||||
|
Ok(get_users(
|
||||||
|
request_client,
|
||||||
|
&config.forgejo.instance,
|
||||||
|
&config.forgejo.token,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|u| u.id > last_user_id)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if ban or suspect a new user
|
||||||
|
async fn check_new_user(
|
||||||
|
user: ForgejoUser,
|
||||||
|
request_client: &reqwest::Client,
|
||||||
|
config: &Config,
|
||||||
|
sus_sender: &Sender<ForgejoUser>,
|
||||||
|
) {
|
||||||
|
if let Some(re) = config.expressions.ban.is_match(&user) {
|
||||||
|
tracing::info!("@{} has been banned because `{re}`", user.username);
|
||||||
|
if let Err(err) = forgejo_api::ban_user(
|
||||||
|
request_client,
|
||||||
|
&config.forgejo.instance,
|
||||||
|
&config.forgejo.token,
|
||||||
|
&user.username,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!("Error while banning a user: {err}");
|
||||||
|
}
|
||||||
|
} else if let Some(re) = config.expressions.sus.is_match(&user) {
|
||||||
|
tracing::info!("@{} has been suspected because `{re}`", user.username);
|
||||||
|
let _ = sus_sender.send(user).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check for new users and send the suspected users to the channel and ban the
|
||||||
|
/// banned users
|
||||||
|
async fn check_new_users(
|
||||||
|
last_user_id: Arc<AtomicUsize>,
|
||||||
|
sus_sender: Sender<ForgejoUser>,
|
||||||
|
request_client: Arc<reqwest::Client>,
|
||||||
|
config: Arc<Config>,
|
||||||
|
) {
|
||||||
|
match get_new_users(
|
||||||
|
&request_client,
|
||||||
|
last_user_id.load(Ordering::Relaxed),
|
||||||
|
&config,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(new_users) => {
|
||||||
|
if !new_users.is_empty() {
|
||||||
|
tracing::debug!("Found {} new user(s)", new_users.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(uid) = new_users.iter().max_by_key(|u| u.id).map(|u| u.id) {
|
||||||
|
tracing::debug!("New last user id: {uid}");
|
||||||
|
last_user_id.store(uid, Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
for user in new_users {
|
||||||
|
check_new_user(user, &request_client, &config, &sus_sender).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!("Error while fetching new users: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The users fetcher, it will check for new users every period and send the
|
||||||
|
/// suspected users to the channel
|
||||||
|
pub async fn users_fetcher(
|
||||||
|
config: Arc<Config>,
|
||||||
|
cancellation_token: CancellationToken,
|
||||||
|
sus_sender: Sender<ForgejoUser>,
|
||||||
|
) {
|
||||||
|
let last_user_id = Arc::new(AtomicUsize::new(0));
|
||||||
|
let request_client = Arc::new(reqwest::Client::new());
|
||||||
|
|
||||||
|
tracing::info!("Starting users fetcher");
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(120)) => {
|
||||||
|
tokio::spawn(check_new_users(
|
||||||
|
Arc::clone(&last_user_id),
|
||||||
|
sus_sender.clone(),
|
||||||
|
Arc::clone(&request_client),
|
||||||
|
Arc::clone(&config),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ = cancellation_token.cancelled() => {
|
||||||
|
tracing::info!("Users fetcher has been stopped successfully.");
|
||||||
|
break
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
46
src/utils.rs
Normal file
46
src/utils.rs
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
// Simple Forgejo instance guardian, banning users and alerting admins based on
|
||||||
|
// certain regular expressions. Copyright (C) 2024 Awiteb <a@4rs.nl>
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published
|
||||||
|
// by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://gnu.org/licenses/agpl.txt>.
|
||||||
|
|
||||||
|
use std::{fs, path::PathBuf, str::FromStr};
|
||||||
|
|
||||||
|
use tracing::level_filters::LevelFilter;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
config::{Config, CONFIG_PATH_ENV, DEFAULT_CONFIG_PATH},
|
||||||
|
error::{GuardError, GuardResult},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Returns the log level from `RUST_LOG` environment variable
|
||||||
|
pub fn get_log_level() -> LevelFilter {
|
||||||
|
std::env::var("RUST_LOG")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| LevelFilter::from_str(s.as_str()).ok())
|
||||||
|
.unwrap_or(LevelFilter::INFO)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the guard config
|
||||||
|
pub fn get_config() -> GuardResult<Config> {
|
||||||
|
let config_path = if let Ok(path) = std::env::var(CONFIG_PATH_ENV) {
|
||||||
|
PathBuf::from(path)
|
||||||
|
} else if matches!(fs::exists(DEFAULT_CONFIG_PATH), Ok(true)) {
|
||||||
|
PathBuf::from(DEFAULT_CONFIG_PATH)
|
||||||
|
} else {
|
||||||
|
return Err(GuardError::CantGetConfigFile);
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!("Config path: {}", config_path.display());
|
||||||
|
toml::from_str(&fs::read_to_string(&config_path)?).map_err(Into::into)
|
||||||
|
}
|
Loading…
Reference in a new issue