pub mod auth;
pub mod domains;
pub mod routes;
pub mod upstreams;
pub mod wellknown;
use crate::admin::api::ApiResponse;
use crate::error::{ProxyError, ProxyResult};
use crate::saas::api_keys::ApiKeyService;
use crate::saas::auth::{AuthContext, SaasAuthLayer};
use crate::saas::manager::DomainManager;
use crate::saas::options::SaasOptions;
use crate::saas::verification::DomainVerificationService;
use crate::tls::CertificateStore;
use axum::{
http::StatusCode,
middleware,
routing::{delete, get, post, put},
Router,
};
use sqlx::PgPool;
use std::sync::Arc;
#[derive(Clone)]
pub struct SaasState {
pub domain_manager: Arc<DomainManager>,
pub api_key_service: Arc<ApiKeyService>,
pub auth_layer: Arc<SaasAuthLayer>,
pub pool: PgPool,
pub cert_store: Arc<CertificateStore>,
}
impl SaasState {
pub async fn new(
pool: PgPool,
jwt_secret: Option<String>,
cert_dir: impl AsRef<std::path::Path>,
) -> ProxyResult<Self> {
let options = SaasOptions {
jwt_secret,
..SaasOptions::default()
};
Self::with_options(pool, options, cert_dir).await
}
pub async fn with_options(
pool: PgPool,
options: SaasOptions,
cert_dir: impl AsRef<std::path::Path>,
) -> ProxyResult<Self> {
let verification_service = Arc::new(DomainVerificationService::new());
let domain_manager = Arc::new(DomainManager::with_policy(
pool.clone(),
verification_service,
options.domains,
));
let api_key_service = Arc::new(ApiKeyService::new(pool.clone()));
let pool_for_handlers = pool.clone();
let auth_layer = Arc::new(
SaasAuthLayer::new(pool, options.jwt_secret)
.with_jwt_audience(options.jwt_audience)
.with_jwt_issuer(options.jwt_issuer),
);
let cert_store =
Arc::new(CertificateStore::new(pool_for_handlers.clone(), cert_dir).await?);
Ok(Self {
domain_manager,
api_key_service,
auth_layer,
pool: pool_for_handlers,
cert_store,
})
}
}
pub fn saas_router(state: SaasState) -> Router {
let auth_layer = state.auth_layer.clone();
let public_routes = Router::new().route(
"/.well-known/postrust-verification/{token}",
get(wellknown::handle_verification_challenge),
);
#[cfg(feature = "acme")]
let public_routes = public_routes.route(
"/.well-known/acme-challenge/{token}",
get(wellknown::handle_acme_challenge),
);
let protected_routes = Router::new();
#[cfg(feature = "acme")]
let protected_routes =
protected_routes.route("/domains/{id}/ssl/provision", post(domains::provision_ssl));
let protected_routes = protected_routes
.route("/auth/api-keys", post(auth::create_api_key))
.route("/auth/api-keys", get(auth::list_api_keys))
.route("/auth/api-keys/{id}", delete(auth::revoke_api_key))
.route("/tenant/me", get(auth::get_current_tenant))
.route("/tenant/usage", get(auth::get_tenant_usage))
.route("/domains", get(domains::list_domains))
.route("/domains", post(domains::create_domain))
.route("/domains/{id}", get(domains::get_domain))
.route("/domains/{id}", delete(domains::delete_domain))
.route("/domains/{id}/verify", post(domains::verify_domain))
.route("/domains/{id}/enable", post(domains::enable_domain))
.route("/domains/{id}", put(domains::update_domain))
.route("/domains/{id}/disable", post(domains::disable_domain))
.route(
"/domains/{id}/ssl/upload",
post(domains::upload_certificate),
)
.route("/domains/{domain_id}/routes", get(routes::list_routes))
.route("/domains/{domain_id}/routes", post(routes::create_route))
.route("/domains/{domain_id}/routes/{id}", get(routes::get_route))
.route(
"/domains/{domain_id}/routes/{id}",
put(routes::update_route),
)
.route(
"/domains/{domain_id}/routes/{id}",
delete(routes::delete_route),
)
.route("/upstreams", get(upstreams::list_upstreams))
.route("/upstreams", post(upstreams::create_upstream))
.route("/upstreams/{id}", get(upstreams::get_upstream))
.route("/upstreams/{id}", put(upstreams::update_upstream))
.route("/upstreams/{id}", delete(upstreams::delete_upstream))
.route("/upstreams/{id}/backends", post(upstreams::add_backend))
.route(
"/upstreams/{id}/backends/{backend_id}",
delete(upstreams::remove_backend),
)
.layer(middleware::from_fn_with_state(
auth_layer,
crate::saas::auth::auth_middleware,
))
.with_state(state.clone());
Router::new()
.merge(public_routes.with_state(state.clone()))
.nest("/api/v1", protected_routes)
}
pub fn error_response(error: ProxyError) -> (StatusCode, axum::Json<ApiResponse<()>>) {
let (status, code, message) = match error {
ProxyError::Validation(message) => (StatusCode::BAD_REQUEST, "validation_failed", message),
ProxyError::NotFound(message) => (StatusCode::NOT_FOUND, "not_found", message),
ProxyError::Conflict(message) => (StatusCode::CONFLICT, "conflict", message),
ProxyError::QuotaExceeded(message) => {
(StatusCode::PAYMENT_REQUIRED, "quota_exceeded", message)
}
ProxyError::Forbidden(message) => (StatusCode::FORBIDDEN, "forbidden", message),
ProxyError::Auth(message) => (StatusCode::UNAUTHORIZED, "unauthorized", message),
ProxyError::Verification(message) => {
(StatusCode::BAD_REQUEST, "verification_failed", message)
}
ProxyError::TenantSuspended => (
StatusCode::FORBIDDEN,
"tenant_suspended",
"Tenant account is suspended".to_string(),
),
ProxyError::RateLimitExceeded => (
StatusCode::TOO_MANY_REQUESTS,
"rate_limited",
"Too many attempts; try again later".to_string(),
),
ProxyError::Database(error) => return database_error(error),
other => {
tracing::error!(error = %other, "SaaS API request failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal_error",
"Internal server error".to_string(),
)
}
};
(
status,
axum::Json(ApiResponse::<()>::error_with_code(code, message)),
)
}
fn database_error(error: sqlx::Error) -> (StatusCode, axum::Json<ApiResponse<()>>) {
let kind = error.as_database_error().map(|e| e.kind());
let (status, code, message) = match kind {
Some(sqlx::error::ErrorKind::UniqueViolation) => (
StatusCode::CONFLICT,
"conflict",
"A resource with these values already exists",
),
Some(sqlx::error::ErrorKind::ForeignKeyViolation) => (
StatusCode::CONFLICT,
"conflict",
"The request refers to a resource that does not exist or is still in use",
),
Some(sqlx::error::ErrorKind::CheckViolation | sqlx::error::ErrorKind::NotNullViolation) => {
(
StatusCode::BAD_REQUEST,
"validation_failed",
"A value in the request is not allowed",
)
}
_ => {
tracing::error!(%error, "SaaS API database error");
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal_error",
"Internal server error",
)
}
};
if status.is_client_error() {
tracing::debug!(%error, "SaaS API request refused by a constraint");
}
(
status,
axum::Json(ApiResponse::<()>::error_with_code(code, message)),
)
}
pub(crate) fn require_scope(
auth: &AuthContext,
scope: &str,
) -> Result<(), axum::response::Response> {
use axum::response::IntoResponse;
if auth.grants(scope) {
return Ok(());
}
Err((
StatusCode::FORBIDDEN,
axum::Json(ApiResponse::<()>::error_with_code(
"insufficient_scope",
format!("Insufficient permissions: this requires the {scope} scope"),
)),
)
.into_response())
}
#[cfg(test)]
mod tests {
use super::*;
fn render(error: ProxyError) -> (StatusCode, serde_json::Value) {
let (status, axum::Json(body)) = error_response(error);
(status, serde_json::to_value(body).unwrap())
}
#[test]
fn errors_of_ours_are_not_described() {
for error in [
ProxyError::Database(sqlx::Error::PoolTimedOut),
ProxyError::Database(sqlx::Error::Protocol(
"relation \"proxy_domains\" does not exist".into(),
)),
ProxyError::Io(std::io::Error::other(
"/var/lib/postrust/certs: permission denied",
)),
ProxyError::Tls("bad key".into()),
ProxyError::Internal("secret detail".into()),
] {
let (status, body) = render(error);
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(body["code"], "internal_error");
assert_eq!(body["error"], "Internal server error");
}
}
#[test]
fn errors_about_the_request_keep_their_message() {
let (status, body) = render(ProxyError::Validation("the domain is empty".into()));
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["code"], "validation_failed");
assert_eq!(body["error"], "the domain is empty");
let (status, body) = render(ProxyError::RateLimitExceeded);
assert_eq!(status, StatusCode::TOO_MANY_REQUESTS);
assert_eq!(body["code"], "rate_limited");
}
}