Start work on admin API

This commit is contained in:
asonix 2022-11-17 13:14:29 -06:00
parent 08374d0382
commit fe844a807f
9 changed files with 471 additions and 4 deletions

42
Cargo.lock generated
View file

@ -291,6 +291,7 @@ dependencies = [
"awc", "awc",
"background-jobs", "background-jobs",
"base64", "base64",
"bcrypt",
"clap", "clap",
"config", "config",
"console-subscriber", "console-subscriber",
@ -544,6 +545,18 @@ version = "1.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b645a089122eccb6111b4f81cbc1a49f5900ac4666bb93ac027feaecf15607bf" 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]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.3.2" version = "1.3.2"
@ -559,6 +572,16 @@ dependencies = [
"generic-array", "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]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.11.1" version = "3.11.1"
@ -614,6 +637,16 @@ dependencies = [
"num-traits", "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]] [[package]]
name = "clap" name = "clap"
version = "4.0.26" version = "4.0.26"
@ -1313,6 +1346,15 @@ dependencies = [
"hashbrown", "hashbrown",
] ]
[[package]]
name = "inout"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5"
dependencies = [
"generic-array",
]
[[package]] [[package]]
name = "instant" name = "instant"
version = "0.1.12" version = "0.1.12"

View file

@ -29,6 +29,7 @@ activitystreams = "0.7.0-alpha.19"
activitystreams-ext = "0.1.0-alpha.2" activitystreams-ext = "0.1.0-alpha.2"
ammonia = "3.1.0" ammonia = "3.1.0"
awc = { version = "3.0.0", default-features = false, features = ["rustls"] } awc = { version = "3.0.0", default-features = false, features = ["rustls"] }
bcrypt = "0.13"
base64 = "0.13" base64 = "0.13"
clap = { version = "4.0.0", features = ["derive"] } clap = { version = "4.0.0", features = ["derive"] }
config = "0.13.0" config = "0.13.0"

24
src/admin.rs Normal file
View file

@ -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<String>,
}
#[derive(serde::Deserialize, serde::Serialize)]
pub(crate) struct AllowedDomains {
allowed_domains: Vec<String>,
}
#[derive(serde::Deserialize, serde::Serialize)]
pub(crate) struct BlockedDomains {
blocked_domains: Vec<String>,
}
#[derive(serde::Deserialize, serde::Serialize)]
pub(crate) struct ConnectedActors {
connected_actors: Vec<IriString>,
}

87
src/admin/client.rs Normal file
View file

@ -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<String>,
) -> Result<(), Error> {
post_domains(client, config, domains, AdminUrlKind::Allow).await
}
pub(crate) async fn block(
client: &Client,
config: &Config,
domains: Vec<String>,
) -> Result<(), Error> {
post_domains(client, config, domains, AdminUrlKind::Block).await
}
pub(crate) async fn allowed(client: &Client, config: &Config) -> Result<AllowedDomains, Error> {
get_results(client, config, AdminUrlKind::Allowed).await
}
pub(crate) async fn blocked(client: &Client, config: &Config) -> Result<BlockedDomains, Error> {
get_results(client, config, AdminUrlKind::Blocked).await
}
pub(crate) async fn connected(client: &Client, config: &Config) -> Result<ConnectedActors, Error> {
get_results(client, config, AdminUrlKind::Connected).await
}
async fn get_results<T: DeserializeOwned>(
client: &Client,
config: &Config,
url_kind: AdminUrlKind,
) -> Result<T, Error> {
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<String>,
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(())
}

42
src/admin/routes.rs Normal file
View file

@ -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<Domains>,
) -> Result<HttpResponse, Error> {
admin.db_ref().add_allows(domains).await?;
Ok(HttpResponse::NoContent().finish())
}
pub(crate) async fn block(
admin: Admin,
Json(Domains { domains }): Json<Domains>,
) -> Result<HttpResponse, Error> {
admin.db_ref().add_blocks(domains).await?;
Ok(HttpResponse::NoContent().finish())
}
pub(crate) async fn allowed(admin: Admin) -> Result<HttpResponse, Error> {
let allowed_domains = admin.db_ref().allowed_domains().await?;
Ok(HttpResponse::Ok().json(AllowedDomains { allowed_domains }))
}
pub(crate) async fn blocked(admin: Admin) -> Result<HttpResponse, Error> {
let blocked_domains = admin.db_ref().blocks().await?;
Ok(HttpResponse::Ok().json(BlockedDomains { blocked_domains }))
}
pub(crate) async fn connected(admin: Admin) -> Result<HttpResponse, Error> {
let connected_actors = admin.db_ref().connected_ids().await?;
Ok(HttpResponse::Ok().json(ConnectedActors { connected_actors }))
}

View file

@ -1,6 +1,7 @@
use crate::{ use crate::{
data::{ActorCache, State}, data::{ActorCache, State},
error::Error, error::Error,
extractors::{AdminConfig, XApiToken},
middleware::MyVerify, middleware::MyVerify,
requests::Requests, requests::Requests,
}; };
@ -32,6 +33,7 @@ pub(crate) struct ParsedConfig {
opentelemetry_url: Option<IriString>, opentelemetry_url: Option<IriString>,
telegram_token: Option<String>, telegram_token: Option<String>,
telegram_admin_handle: Option<String>, telegram_admin_handle: Option<String>,
api_token: Option<String>,
} }
#[derive(Clone)] #[derive(Clone)]
@ -49,6 +51,7 @@ pub struct Config {
opentelemetry_url: Option<IriString>, opentelemetry_url: Option<IriString>,
telegram_token: Option<String>, telegram_token: Option<String>,
telegram_admin_handle: Option<String>, telegram_admin_handle: Option<String>,
api_token: Option<String>,
} }
#[derive(Debug)] #[derive(Debug)]
@ -65,6 +68,15 @@ pub enum UrlKind {
Outbox, Outbox,
} }
#[derive(Debug)]
pub enum AdminUrlKind {
Allow,
Block,
Allowed,
Blocked,
Connected,
}
impl std::fmt::Debug for Config { impl std::fmt::Debug for Config {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Config") f.debug_struct("Config")
@ -84,6 +96,7 @@ impl std::fmt::Debug for Config {
) )
.field("telegram_token", &"[redacted]") .field("telegram_token", &"[redacted]")
.field("telegram_admin_handle", &self.telegram_admin_handle) .field("telegram_admin_handle", &self.telegram_admin_handle)
.field("api_token", &"[redacted]")
.finish() .finish()
} }
} }
@ -93,7 +106,7 @@ impl Config {
let config = config::Config::builder() let config = config::Config::builder()
.set_default("hostname", "localhost:8080")? .set_default("hostname", "localhost:8080")?
.set_default("addr", "127.0.0.1")? .set_default("addr", "127.0.0.1")?
.set_default::<_, u64>("port", 8080)? .set_default("port", 8080u64)?
.set_default("debug", true)? .set_default("debug", true)?
.set_default("restricted_mode", false)? .set_default("restricted_mode", false)?
.set_default("validate_signatures", false)? .set_default("validate_signatures", false)?
@ -104,6 +117,7 @@ impl Config {
.set_default("opentelemetry_url", None as Option<&str>)? .set_default("opentelemetry_url", None as Option<&str>)?
.set_default("telegram_token", None as Option<&str>)? .set_default("telegram_token", None as Option<&str>)?
.set_default("telegram_admin_handle", 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()) .add_source(Environment::default())
.build()?; .build()?;
@ -126,6 +140,7 @@ impl Config {
opentelemetry_url: config.opentelemetry_url, opentelemetry_url: config.opentelemetry_url,
telegram_token: config.telegram_token, telegram_token: config.telegram_token,
telegram_admin_handle: config.telegram_admin_handle, 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<XApiToken> {
self.api_token.clone().map(XApiToken::new)
}
pub(crate) fn admin_config(&self) -> Option<actix_web::web::Data<AdminConfig>> {
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) { pub(crate) fn bind_address(&self) -> (IpAddr, u16) {
(self.addr, self.port) (self.addr, self.port)
} }
@ -281,4 +314,26 @@ impl Config {
Ok(iri) 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<IriString, Error> {
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)
}
} }

View file

@ -180,6 +180,9 @@ pub(crate) enum ErrorKind {
#[error("Failed to extract fields from {0}")] #[error("Failed to extract fields from {0}")]
Extract(&'static str), Extract(&'static str),
#[error("No API Token supplied")]
MissingApiToken,
} }
impl ResponseError for Error { impl ResponseError for Error {

194
src/extractors.rs Normal file
View file

@ -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<Self, Error> {
Ok(AdminConfig {
hashed_api_token: bcrypt::hash(api_token, DEFAULT_COST).map_err(Error::bcrypt_hash)?,
})
}
fn verify(&self, token: XApiToken) -> Result<bool, Error> {
Ok(bcrypt::verify(&self.hashed_api_token, &token.0).map_err(Error::bcrypt_verify)?)
}
}
pub(crate) struct Admin {
db: Data<Db>,
}
impl Admin {
#[tracing::instrument(level = "debug", skip(req))]
fn verify(req: &HttpRequest) -> Result<Self, Error> {
let hashed_api_token = req
.app_data::<Data<AdminConfig>>()
.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::<Data<Db>>().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<Result<Self, Self::Error>>;
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<M: HttpMessage>(msg: &M) -> Result<Self, ParseError> {
from_one_raw_str(msg.headers().get(Self::name()))
}
}
impl TryIntoHeaderValue for XApiToken {
type Error = InvalidHeaderValue;
fn try_into_value(self) -> Result<HeaderValue, Self::Error> {
HeaderValue::from_str(&self.0)
}
}
impl FromStr for XApiToken {
type Err = Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(XApiToken(s.to_string()))
}
}

View file

@ -12,12 +12,14 @@ use tracing_error::ErrorLayer;
use tracing_log::LogTracer; use tracing_log::LogTracer;
use tracing_subscriber::{filter::Targets, fmt::format::FmtSpan, layer::SubscriberExt, Layer}; use tracing_subscriber::{filter::Targets, fmt::format::FmtSpan, layer::SubscriberExt, Layer};
mod admin;
mod apub; mod apub;
mod args; mod args;
mod config; mod config;
mod data; mod data;
mod db; mod db;
mod error; mod error;
mod extractors;
mod jobs; mod jobs;
mod middleware; mod middleware;
mod requests; mod requests;
@ -135,15 +137,22 @@ async fn main() -> Result<(), anyhow::Error> {
let bind_address = config.bind_address(); let bind_address = config.bind_address();
HttpServer::new(move || { HttpServer::new(move || {
App::new() let app = App::new()
.wrap(TracingLogger::default())
.app_data(web::Data::new(db.clone())) .app_data(web::Data::new(db.clone()))
.app_data(web::Data::new(state.clone())) .app_data(web::Data::new(state.clone()))
.app_data(web::Data::new(state.requests(&config))) .app_data(web::Data::new(state.requests(&config)))
.app_data(web::Data::new(actors.clone())) .app_data(web::Data::new(actors.clone()))
.app_data(web::Data::new(config.clone())) .app_data(web::Data::new(config.clone()))
.app_data(web::Data::new(job_server.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("/").route(web::get().to(index)))
.service(web::resource("/media/{path}").route(web::get().to(routes::media))) .service(web::resource("/media/{path}").route(web::get().to(routes::media)))
.service( .service(
@ -165,6 +174,16 @@ async fn main() -> Result<(), anyhow::Error> {
.service(web::resource("/nodeinfo").route(web::get().to(nodeinfo_meta))), .service(web::resource("/nodeinfo").route(web::get().to(nodeinfo_meta))),
) )
.service(web::resource("/static/{filename}").route(web::get().to(statics))) .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)? .bind(bind_address)?
.run() .run()