feat: Initialize forgejo-guardian
Signed-off-by: Awiteb <a@4rs.nl>
This commit is contained in:
parent
28c828f06d
commit
d12c45ed63
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/) build 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