diff --git a/Cargo.lock b/Cargo.lock index 11c7c30..4e93720 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -291,6 +291,7 @@ dependencies = [ "awc", "background-jobs", "base64", + "bcrypt", "clap", "config", "console-subscriber", @@ -544,6 +545,18 @@ version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b645a089122eccb6111b4f81cbc1a49f5900ac4666bb93ac027feaecf15607bf" +[[package]] +name = "bcrypt" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e7c93a3fb23b2fdde989b2c9ec4dd153063ec81f408507f84c090cd91c6641" +dependencies = [ + "base64", + "blowfish", + "getrandom", + "zeroize", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -559,6 +572,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "bumpalo" version = "3.11.1" @@ -614,6 +637,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "cipher" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1873270f8f7942c191139cb8a40fd228da6c3fd2fc376d7e92d47aa14aeb59e" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.0.26" @@ -1313,6 +1346,15 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +dependencies = [ + "generic-array", +] + [[package]] name = "instant" version = "0.1.12" diff --git a/Cargo.toml b/Cargo.toml index b35191e..4aed898 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ activitystreams = "0.7.0-alpha.19" activitystreams-ext = "0.1.0-alpha.2" ammonia = "3.1.0" awc = { version = "3.0.0", default-features = false, features = ["rustls"] } +bcrypt = "0.13" base64 = "0.13" clap = { version = "4.0.0", features = ["derive"] } config = "0.13.0" diff --git a/src/admin.rs b/src/admin.rs new file mode 100644 index 0000000..c3fb836 --- /dev/null +++ b/src/admin.rs @@ -0,0 +1,24 @@ +use activitystreams::iri_string::types::IriString; + +pub mod client; +pub mod routes; + +#[derive(serde::Deserialize, serde::Serialize)] +pub(crate) struct Domains { + domains: Vec, +} + +#[derive(serde::Deserialize, serde::Serialize)] +pub(crate) struct AllowedDomains { + allowed_domains: Vec, +} + +#[derive(serde::Deserialize, serde::Serialize)] +pub(crate) struct BlockedDomains { + blocked_domains: Vec, +} + +#[derive(serde::Deserialize, serde::Serialize)] +pub(crate) struct ConnectedActors { + connected_actors: Vec, +} diff --git a/src/admin/client.rs b/src/admin/client.rs new file mode 100644 index 0000000..3bcfc8b --- /dev/null +++ b/src/admin/client.rs @@ -0,0 +1,87 @@ +use crate::{ + admin::{AllowedDomains, BlockedDomains, ConnectedActors, Domains}, + config::{AdminUrlKind, Config}, + error::{Error, ErrorKind}, +}; +use awc::Client; +use serde::de::DeserializeOwned; + +pub(crate) async fn allow( + client: &Client, + config: &Config, + domains: Vec, +) -> Result<(), Error> { + post_domains(client, config, domains, AdminUrlKind::Allow).await +} + +pub(crate) async fn block( + client: &Client, + config: &Config, + domains: Vec, +) -> Result<(), Error> { + post_domains(client, config, domains, AdminUrlKind::Block).await +} + +pub(crate) async fn allowed(client: &Client, config: &Config) -> Result { + get_results(client, config, AdminUrlKind::Allowed).await +} + +pub(crate) async fn blocked(client: &Client, config: &Config) -> Result { + get_results(client, config, AdminUrlKind::Blocked).await +} + +pub(crate) async fn connected(client: &Client, config: &Config) -> Result { + get_results(client, config, AdminUrlKind::Connected).await +} + +async fn get_results( + client: &Client, + config: &Config, + url_kind: AdminUrlKind, +) -> Result { + let x_api_token = config.x_api_token().ok_or(ErrorKind::MissingApiToken)?; + + let iri = config.generate_admin_url(url_kind); + + let mut res = client + .get(iri.as_str()) + .insert_header(x_api_token) + .send() + .await + .map_err(|e| ErrorKind::SendRequest(iri.to_string(), e.to_string()))?; + + if !res.status().is_success() { + return Err(ErrorKind::Status(iri.to_string(), res.status()).into()); + } + + let t = res + .json() + .await + .map_err(|e| ErrorKind::ReceiveResponse(iri.to_string(), e.to_string()))?; + + Ok(t) +} + +async fn post_domains( + client: &Client, + config: &Config, + domains: Vec, + url_kind: AdminUrlKind, +) -> Result<(), Error> { + let x_api_token = config.x_api_token().ok_or(ErrorKind::MissingApiToken)?; + + let iri = config.generate_admin_url(url_kind); + + let res = client + .post(iri.as_str()) + .insert_header(x_api_token) + .send_json(&Domains { domains }) + .await + .map_err(|e| ErrorKind::SendRequest(iri.to_string(), e.to_string()))?; + + if !res.status().is_success() { + tracing::warn!("Failed to allow domains"); + } + + Ok(()) +} diff --git a/src/admin/routes.rs b/src/admin/routes.rs new file mode 100644 index 0000000..68b78a7 --- /dev/null +++ b/src/admin/routes.rs @@ -0,0 +1,42 @@ +use crate::{ + admin::{AllowedDomains, BlockedDomains, ConnectedActors, Domains}, + error::Error, + extractors::Admin, +}; +use actix_web::{web::Json, HttpResponse}; + +pub(crate) async fn allow( + admin: Admin, + Json(Domains { domains }): Json, +) -> Result { + admin.db_ref().add_allows(domains).await?; + + Ok(HttpResponse::NoContent().finish()) +} + +pub(crate) async fn block( + admin: Admin, + Json(Domains { domains }): Json, +) -> Result { + admin.db_ref().add_blocks(domains).await?; + + Ok(HttpResponse::NoContent().finish()) +} + +pub(crate) async fn allowed(admin: Admin) -> Result { + let allowed_domains = admin.db_ref().allowed_domains().await?; + + Ok(HttpResponse::Ok().json(AllowedDomains { allowed_domains })) +} + +pub(crate) async fn blocked(admin: Admin) -> Result { + let blocked_domains = admin.db_ref().blocks().await?; + + Ok(HttpResponse::Ok().json(BlockedDomains { blocked_domains })) +} + +pub(crate) async fn connected(admin: Admin) -> Result { + let connected_actors = admin.db_ref().connected_ids().await?; + + Ok(HttpResponse::Ok().json(ConnectedActors { connected_actors })) +} diff --git a/src/config.rs b/src/config.rs index e42b755..750a443 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,7 @@ use crate::{ data::{ActorCache, State}, error::Error, + extractors::{AdminConfig, XApiToken}, middleware::MyVerify, requests::Requests, }; @@ -32,6 +33,7 @@ pub(crate) struct ParsedConfig { opentelemetry_url: Option, telegram_token: Option, telegram_admin_handle: Option, + api_token: Option, } #[derive(Clone)] @@ -49,6 +51,7 @@ pub struct Config { opentelemetry_url: Option, telegram_token: Option, telegram_admin_handle: Option, + api_token: Option, } #[derive(Debug)] @@ -65,6 +68,15 @@ pub enum UrlKind { Outbox, } +#[derive(Debug)] +pub enum AdminUrlKind { + Allow, + Block, + Allowed, + Blocked, + Connected, +} + impl std::fmt::Debug for Config { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Config") @@ -84,6 +96,7 @@ impl std::fmt::Debug for Config { ) .field("telegram_token", &"[redacted]") .field("telegram_admin_handle", &self.telegram_admin_handle) + .field("api_token", &"[redacted]") .finish() } } @@ -93,7 +106,7 @@ impl Config { let config = config::Config::builder() .set_default("hostname", "localhost:8080")? .set_default("addr", "127.0.0.1")? - .set_default::<_, u64>("port", 8080)? + .set_default("port", 8080u64)? .set_default("debug", true)? .set_default("restricted_mode", false)? .set_default("validate_signatures", false)? @@ -104,6 +117,7 @@ impl Config { .set_default("opentelemetry_url", None as Option<&str>)? .set_default("telegram_token", None as Option<&str>)? .set_default("telegram_admin_handle", None as Option<&str>)? + .set_default("api_token", None as Option<&str>)? .add_source(Environment::default()) .build()?; @@ -126,6 +140,7 @@ impl Config { opentelemetry_url: config.opentelemetry_url, telegram_token: config.telegram_token, telegram_admin_handle: config.telegram_admin_handle, + api_token: config.api_token, }) } @@ -158,6 +173,24 @@ impl Config { } } + pub(crate) fn x_api_token(&self) -> Option { + self.api_token.clone().map(XApiToken::new) + } + + pub(crate) fn admin_config(&self) -> Option> { + if let Some(api_token) = &self.api_token { + match AdminConfig::build(api_token) { + Ok(conf) => Some(actix_web::web::Data::new(conf)), + Err(e) => { + tracing::error!("Error creating admin config: {}", e); + None + } + } + } else { + None + } + } + pub(crate) fn bind_address(&self) -> (IpAddr, u16) { (self.addr, self.port) } @@ -281,4 +314,26 @@ impl Config { Ok(iri) } + + pub(crate) fn generate_admin_url(&self, kind: AdminUrlKind) -> IriString { + self.do_generate_admin_url(kind) + .expect("Generated valid IRI") + } + + fn do_generate_admin_url(&self, kind: AdminUrlKind) -> Result { + let iri = match kind { + AdminUrlKind::Allow => FixedBaseResolver::new(self.base_uri.as_ref()) + .try_resolve(IriRelativeStr::new("api/v1/admin/allow")?.as_ref())?, + AdminUrlKind::Block => FixedBaseResolver::new(self.base_uri.as_ref()) + .try_resolve(IriRelativeStr::new("api/v1/admin/block")?.as_ref())?, + AdminUrlKind::Allowed => FixedBaseResolver::new(self.base_uri.as_ref()) + .try_resolve(IriRelativeStr::new("api/v1/admin/allowed")?.as_ref())?, + AdminUrlKind::Blocked => FixedBaseResolver::new(self.base_uri.as_ref()) + .try_resolve(IriRelativeStr::new("api/v1/admin/blocked")?.as_ref())?, + AdminUrlKind::Connected => FixedBaseResolver::new(self.base_uri.as_ref()) + .try_resolve(IriRelativeStr::new("api/v1/admin/connected")?.as_ref())?, + }; + + Ok(iri) + } } diff --git a/src/error.rs b/src/error.rs index 0b7716b..eb602b0 100644 --- a/src/error.rs +++ b/src/error.rs @@ -180,6 +180,9 @@ pub(crate) enum ErrorKind { #[error("Failed to extract fields from {0}")] Extract(&'static str), + + #[error("No API Token supplied")] + MissingApiToken, } impl ResponseError for Error { diff --git a/src/extractors.rs b/src/extractors.rs new file mode 100644 index 0000000..742f016 --- /dev/null +++ b/src/extractors.rs @@ -0,0 +1,194 @@ +use actix_web::{ + dev::Payload, + error::ParseError, + http::{ + header::{from_one_raw_str, Header, HeaderName, HeaderValue, TryIntoHeaderValue}, + StatusCode, + }, + web::Data, + FromRequest, HttpMessage, HttpRequest, HttpResponse, ResponseError, +}; +use bcrypt::{BcryptError, DEFAULT_COST}; +use http_signature_normalization_actix::prelude::InvalidHeaderValue; +use std::{ + convert::Infallible, + future::{ready, Ready}, + str::FromStr, +}; +use tracing_error::SpanTrace; + +use crate::db::Db; + +#[derive(Clone)] +pub(crate) struct AdminConfig { + hashed_api_token: String, +} + +impl AdminConfig { + pub(crate) fn build(api_token: &str) -> Result { + Ok(AdminConfig { + hashed_api_token: bcrypt::hash(api_token, DEFAULT_COST).map_err(Error::bcrypt_hash)?, + }) + } + + fn verify(&self, token: XApiToken) -> Result { + Ok(bcrypt::verify(&self.hashed_api_token, &token.0).map_err(Error::bcrypt_verify)?) + } +} + +pub(crate) struct Admin { + db: Data, +} + +impl Admin { + #[tracing::instrument(level = "debug", skip(req))] + fn verify(req: &HttpRequest) -> Result { + let hashed_api_token = req + .app_data::>() + .ok_or_else(Error::missing_config)?; + + let x_api_token = XApiToken::parse(req).map_err(Error::parse_header)?; + + if hashed_api_token.verify(x_api_token)? { + let db = req.app_data::>().ok_or_else(Error::missing_db)?; + + return Ok(Self { db: db.clone() }); + } + + Err(Error::invalid()) + } + + pub(crate) fn db_ref(&self) -> &Db { + &self.db + } +} + +#[derive(Debug, thiserror::Error)] +#[error("Failed authentication")] +pub(crate) struct Error { + context: SpanTrace, + #[source] + kind: ErrorKind, +} + +impl Error { + fn invalid() -> Self { + Error { + context: SpanTrace::capture(), + kind: ErrorKind::Invalid, + } + } + + fn missing_config() -> Self { + Error { + context: SpanTrace::capture(), + kind: ErrorKind::MissingConfig, + } + } + + fn missing_db() -> Self { + Error { + context: SpanTrace::capture(), + kind: ErrorKind::MissingDb, + } + } + + fn bcrypt_verify(e: BcryptError) -> Self { + Error { + context: SpanTrace::capture(), + kind: ErrorKind::BCryptVerify(e), + } + } + + fn bcrypt_hash(e: BcryptError) -> Self { + Error { + context: SpanTrace::capture(), + kind: ErrorKind::BCryptHash(e), + } + } + + fn parse_header(e: ParseError) -> Self { + Error { + context: SpanTrace::capture(), + kind: ErrorKind::ParseHeader(e), + } + } +} + +#[derive(Debug, thiserror::Error)] +enum ErrorKind { + #[error("Invalid API Token")] + Invalid, + + #[error("Missing Config")] + MissingConfig, + + #[error("Missing Db")] + MissingDb, + + #[error("Verifying")] + BCryptVerify(#[source] BcryptError), + + #[error("Hashing")] + BCryptHash(#[source] BcryptError), + + #[error("Parse Header")] + ParseHeader(#[source] ParseError), +} + +impl ResponseError for Error { + fn status_code(&self) -> StatusCode { + match self.kind { + ErrorKind::Invalid | ErrorKind::ParseHeader(_) => StatusCode::BAD_REQUEST, + _ => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn error_response(&self) -> HttpResponse { + HttpResponse::build(self.status_code()) + .json(serde_json::json!({ "msg": self.kind.to_string() })) + } +} + +impl FromRequest for Admin { + type Error = Error; + type Future = Ready>; + + fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { + ready(Admin::verify(req)) + } +} + +pub(crate) struct XApiToken(String); + +impl XApiToken { + pub(crate) fn new(token: String) -> Self { + Self(token) + } +} + +impl Header for XApiToken { + fn name() -> HeaderName { + HeaderName::from_static("x-api-token") + } + + fn parse(msg: &M) -> Result { + from_one_raw_str(msg.headers().get(Self::name())) + } +} + +impl TryIntoHeaderValue for XApiToken { + type Error = InvalidHeaderValue; + + fn try_into_value(self) -> Result { + HeaderValue::from_str(&self.0) + } +} + +impl FromStr for XApiToken { + type Err = Infallible; + + fn from_str(s: &str) -> Result { + Ok(XApiToken(s.to_string())) + } +} diff --git a/src/main.rs b/src/main.rs index 3302bc4..1ef2cd9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,12 +12,14 @@ use tracing_error::ErrorLayer; use tracing_log::LogTracer; use tracing_subscriber::{filter::Targets, fmt::format::FmtSpan, layer::SubscriberExt, Layer}; +mod admin; mod apub; mod args; mod config; mod data; mod db; mod error; +mod extractors; mod jobs; mod middleware; mod requests; @@ -135,15 +137,22 @@ async fn main() -> Result<(), anyhow::Error> { let bind_address = config.bind_address(); HttpServer::new(move || { - App::new() - .wrap(TracingLogger::default()) + let app = App::new() .app_data(web::Data::new(db.clone())) .app_data(web::Data::new(state.clone())) .app_data(web::Data::new(state.requests(&config))) .app_data(web::Data::new(actors.clone())) .app_data(web::Data::new(config.clone())) .app_data(web::Data::new(job_server.clone())) - .app_data(web::Data::new(media.clone())) + .app_data(web::Data::new(media.clone())); + + let app = if let Some(data) = config.admin_config() { + app.app_data(data) + } else { + app + }; + + app.wrap(TracingLogger::default()) .service(web::resource("/").route(web::get().to(index))) .service(web::resource("/media/{path}").route(web::get().to(routes::media))) .service( @@ -165,6 +174,16 @@ async fn main() -> Result<(), anyhow::Error> { .service(web::resource("/nodeinfo").route(web::get().to(nodeinfo_meta))), ) .service(web::resource("/static/{filename}").route(web::get().to(statics))) + .service( + web::scope("/api/v1").service( + web::scope("/admin") + .route("/allow", web::post().to(admin::routes::allow)) + .route("/block", web::post().to(admin::routes::block)) + .route("/allowed", web::get().to(admin::routes::allowed)) + .route("/blocked", web::get().to(admin::routes::blocked)) + .route("/connected", web::get().to(admin::routes::connected)), + ), + ) }) .bind(bind_address)? .run()