forked from Deuxfleurs/garage
294 lines
8.6 KiB
Rust
294 lines
8.6 KiB
Rust
use std::collections::HashMap;
|
|
use std::net::SocketAddr;
|
|
use std::sync::Arc;
|
|
|
|
use async_trait::async_trait;
|
|
|
|
use futures::future::Future;
|
|
use http::header::{ACCESS_CONTROL_ALLOW_METHODS, ACCESS_CONTROL_ALLOW_ORIGIN, ALLOW};
|
|
use hyper::{Body, Request, Response, StatusCode};
|
|
|
|
use opentelemetry::trace::SpanRef;
|
|
|
|
#[cfg(feature = "metrics")]
|
|
use opentelemetry_prometheus::PrometheusExporter;
|
|
#[cfg(feature = "metrics")]
|
|
use prometheus::{Encoder, TextEncoder};
|
|
|
|
use garage_model::garage::Garage;
|
|
use garage_rpc::system::ClusterHealthStatus;
|
|
use garage_util::error::Error as GarageError;
|
|
|
|
use crate::generic_server::*;
|
|
|
|
use crate::admin::bucket::*;
|
|
use crate::admin::cluster::*;
|
|
use crate::admin::error::*;
|
|
use crate::admin::key::*;
|
|
use crate::admin::router::{Authorization, Endpoint};
|
|
|
|
pub struct AdminApiServer {
|
|
garage: Arc<Garage>,
|
|
#[cfg(feature = "metrics")]
|
|
exporter: PrometheusExporter,
|
|
metrics_token: Option<String>,
|
|
admin_token: Option<String>,
|
|
}
|
|
|
|
impl AdminApiServer {
|
|
pub fn new(
|
|
garage: Arc<Garage>,
|
|
#[cfg(feature = "metrics")] exporter: PrometheusExporter,
|
|
) -> Self {
|
|
let cfg = &garage.config.admin;
|
|
let metrics_token = cfg
|
|
.metrics_token
|
|
.as_ref()
|
|
.map(|tok| format!("Bearer {}", tok));
|
|
let admin_token = cfg
|
|
.admin_token
|
|
.as_ref()
|
|
.map(|tok| format!("Bearer {}", tok));
|
|
Self {
|
|
garage,
|
|
#[cfg(feature = "metrics")]
|
|
exporter,
|
|
metrics_token,
|
|
admin_token,
|
|
}
|
|
}
|
|
|
|
pub async fn run(
|
|
self,
|
|
bind_addr: SocketAddr,
|
|
shutdown_signal: impl Future<Output = ()>,
|
|
) -> Result<(), GarageError> {
|
|
let region = self.garage.config.s3_api.s3_region.clone();
|
|
ApiServer::new(region, self)
|
|
.run_server(bind_addr, shutdown_signal)
|
|
.await
|
|
}
|
|
|
|
fn handle_options(&self, _req: &Request<Body>) -> Result<Response<Body>, Error> {
|
|
Ok(Response::builder()
|
|
.status(StatusCode::NO_CONTENT)
|
|
.header(ALLOW, "OPTIONS, GET, POST")
|
|
.header(ACCESS_CONTROL_ALLOW_METHODS, "OPTIONS, GET, POST")
|
|
.header(ACCESS_CONTROL_ALLOW_ORIGIN, "*")
|
|
.body(Body::empty())?)
|
|
}
|
|
|
|
async fn handle_check_website_enabled(
|
|
&self,
|
|
req: Request<Body>,
|
|
) -> Result<Response<Body>, Error> {
|
|
let query_params: HashMap<String, String> = req
|
|
.uri()
|
|
.query()
|
|
.map(|v| {
|
|
url::form_urlencoded::parse(v.as_bytes())
|
|
.into_owned()
|
|
.collect()
|
|
})
|
|
.unwrap_or_else(HashMap::new);
|
|
|
|
let has_domain_key = query_params.contains_key("domain");
|
|
|
|
if !has_domain_key {
|
|
return Err(Error::bad_request("No domain query string found"));
|
|
}
|
|
|
|
let domain = query_params
|
|
.get("domain")
|
|
.ok_or_internal_error("Could not parse domain query string")?;
|
|
|
|
let bucket_id = self
|
|
.garage
|
|
.bucket_helper()
|
|
.resolve_global_bucket_name(&domain)
|
|
.await?
|
|
.ok_or(HelperError::NoSuchBucket(domain.to_string()))?;
|
|
|
|
let bucket = self
|
|
.garage
|
|
.bucket_helper()
|
|
.get_existing_bucket(bucket_id)
|
|
.await?;
|
|
|
|
let bucket_state = bucket.state.as_option().unwrap();
|
|
let bucket_website_config = bucket_state.website_config.get();
|
|
|
|
match bucket_website_config {
|
|
Some(_v) => {
|
|
Ok(Response::builder()
|
|
.status(StatusCode::OK)
|
|
.body(Body::from(format!(
|
|
"Bucket '{domain}' is authorized for website hosting"
|
|
)))?)
|
|
}
|
|
None => Err(Error::bad_request(format!(
|
|
"Bucket '{domain}' is not authorized for website hosting"
|
|
))),
|
|
}
|
|
}
|
|
|
|
fn handle_health(&self) -> Result<Response<Body>, Error> {
|
|
let health = self.garage.system.health();
|
|
|
|
let (status, status_str) = match health.status {
|
|
ClusterHealthStatus::Healthy => (StatusCode::OK, "Garage is fully operational"),
|
|
ClusterHealthStatus::Degraded => (
|
|
StatusCode::OK,
|
|
"Garage is operational but some storage nodes are unavailable",
|
|
),
|
|
ClusterHealthStatus::Unavailable => (
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Quorum is not available for some/all partitions, reads and writes will fail",
|
|
),
|
|
};
|
|
let status_str = format!(
|
|
"{}\nConsult the full health check API endpoint at /v0/health for more details\n",
|
|
status_str
|
|
);
|
|
|
|
Ok(Response::builder()
|
|
.status(status)
|
|
.header(http::header::CONTENT_TYPE, "text/plain")
|
|
.body(Body::from(status_str))?)
|
|
}
|
|
|
|
fn handle_metrics(&self) -> Result<Response<Body>, Error> {
|
|
#[cfg(feature = "metrics")]
|
|
{
|
|
use opentelemetry::trace::Tracer;
|
|
|
|
let mut buffer = vec![];
|
|
let encoder = TextEncoder::new();
|
|
|
|
let tracer = opentelemetry::global::tracer("garage");
|
|
let metric_families = tracer.in_span("admin/gather_metrics", |_| {
|
|
self.exporter.registry().gather()
|
|
});
|
|
|
|
encoder
|
|
.encode(&metric_families, &mut buffer)
|
|
.ok_or_internal_error("Could not serialize metrics")?;
|
|
|
|
Ok(Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header(http::header::CONTENT_TYPE, encoder.format_type())
|
|
.body(Body::from(buffer))?)
|
|
}
|
|
#[cfg(not(feature = "metrics"))]
|
|
Err(Error::bad_request(
|
|
"Garage was built without the metrics feature".to_string(),
|
|
))
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ApiHandler for AdminApiServer {
|
|
const API_NAME: &'static str = "admin";
|
|
const API_NAME_DISPLAY: &'static str = "Admin";
|
|
|
|
type Endpoint = Endpoint;
|
|
type Error = Error;
|
|
|
|
fn parse_endpoint(&self, req: &Request<Body>) -> Result<Endpoint, Error> {
|
|
Endpoint::from_request(req)
|
|
}
|
|
|
|
async fn handle(
|
|
&self,
|
|
req: Request<Body>,
|
|
endpoint: Endpoint,
|
|
) -> Result<Response<Body>, Error> {
|
|
let expected_auth_header =
|
|
match endpoint.authorization_type() {
|
|
Authorization::None => None,
|
|
Authorization::MetricsToken => self.metrics_token.as_ref(),
|
|
Authorization::AdminToken => match &self.admin_token {
|
|
None => return Err(Error::forbidden(
|
|
"Admin token isn't configured, admin API access is disabled for security.",
|
|
)),
|
|
Some(t) => Some(t),
|
|
},
|
|
};
|
|
|
|
if let Some(h) = expected_auth_header {
|
|
match req.headers().get("Authorization") {
|
|
None => return Err(Error::forbidden("Authorization token must be provided")),
|
|
Some(v) => {
|
|
let authorized = v.to_str().map(|hv| hv.trim() == h).unwrap_or(false);
|
|
if !authorized {
|
|
return Err(Error::forbidden("Invalid authorization token provided"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
match endpoint {
|
|
Endpoint::Options => self.handle_options(&req),
|
|
Endpoint::CheckWebsiteEnabled => self.handle_check_website_enabled(req).await,
|
|
Endpoint::Health => self.handle_health(),
|
|
Endpoint::Metrics => self.handle_metrics(),
|
|
Endpoint::GetClusterStatus => handle_get_cluster_status(&self.garage).await,
|
|
Endpoint::GetClusterHealth => handle_get_cluster_health(&self.garage).await,
|
|
Endpoint::ConnectClusterNodes => handle_connect_cluster_nodes(&self.garage, req).await,
|
|
// Layout
|
|
Endpoint::GetClusterLayout => handle_get_cluster_layout(&self.garage).await,
|
|
Endpoint::UpdateClusterLayout => handle_update_cluster_layout(&self.garage, req).await,
|
|
Endpoint::ApplyClusterLayout => handle_apply_cluster_layout(&self.garage, req).await,
|
|
Endpoint::RevertClusterLayout => handle_revert_cluster_layout(&self.garage, req).await,
|
|
// Keys
|
|
Endpoint::ListKeys => handle_list_keys(&self.garage).await,
|
|
Endpoint::GetKeyInfo { id, search } => {
|
|
handle_get_key_info(&self.garage, id, search).await
|
|
}
|
|
Endpoint::CreateKey => handle_create_key(&self.garage, req).await,
|
|
Endpoint::ImportKey => handle_import_key(&self.garage, req).await,
|
|
Endpoint::UpdateKey { id } => handle_update_key(&self.garage, id, req).await,
|
|
Endpoint::DeleteKey { id } => handle_delete_key(&self.garage, id).await,
|
|
// Buckets
|
|
Endpoint::ListBuckets => handle_list_buckets(&self.garage).await,
|
|
Endpoint::GetBucketInfo { id, global_alias } => {
|
|
handle_get_bucket_info(&self.garage, id, global_alias).await
|
|
}
|
|
Endpoint::CreateBucket => handle_create_bucket(&self.garage, req).await,
|
|
Endpoint::DeleteBucket { id } => handle_delete_bucket(&self.garage, id).await,
|
|
Endpoint::UpdateBucket { id } => handle_update_bucket(&self.garage, id, req).await,
|
|
// Bucket-key permissions
|
|
Endpoint::BucketAllowKey => {
|
|
handle_bucket_change_key_perm(&self.garage, req, true).await
|
|
}
|
|
Endpoint::BucketDenyKey => {
|
|
handle_bucket_change_key_perm(&self.garage, req, false).await
|
|
}
|
|
// Bucket aliasing
|
|
Endpoint::GlobalAliasBucket { id, alias } => {
|
|
handle_global_alias_bucket(&self.garage, id, alias).await
|
|
}
|
|
Endpoint::GlobalUnaliasBucket { id, alias } => {
|
|
handle_global_unalias_bucket(&self.garage, id, alias).await
|
|
}
|
|
Endpoint::LocalAliasBucket {
|
|
id,
|
|
access_key_id,
|
|
alias,
|
|
} => handle_local_alias_bucket(&self.garage, id, access_key_id, alias).await,
|
|
Endpoint::LocalUnaliasBucket {
|
|
id,
|
|
access_key_id,
|
|
alias,
|
|
} => handle_local_unalias_bucket(&self.garage, id, access_key_id, alias).await,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ApiEndpoint for Endpoint {
|
|
fn name(&self) -> &'static str {
|
|
Endpoint::name(self)
|
|
}
|
|
|
|
fn add_span_attributes(&self, _span: SpanRef<'_>) {}
|
|
}
|