467 lines
16 KiB
Rust
467 lines
16 KiB
Rust
async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next) -> Response {
|
|
let transport = request
|
|
.extensions()
|
|
.get::<Transport>()
|
|
.copied()
|
|
.unwrap_or_default();
|
|
let peer = request.extensions().get::<PeerAddr>().copied();
|
|
let raw_host = match admin_request_authority(&request, transport.tls) {
|
|
Ok(value) => value,
|
|
Err(()) => return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_host")),
|
|
};
|
|
let headers = request.headers();
|
|
let scheme = match external_admin_scheme(&state, headers, peer, transport) {
|
|
Ok(value) => value,
|
|
Err(()) => return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_origin")),
|
|
};
|
|
let external_authority =
|
|
match external_admin_authority(&state, headers, peer, &raw_host, scheme == "https") {
|
|
Ok(value) => value,
|
|
Err(()) => return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_host")),
|
|
};
|
|
if headers.get_all(ORIGIN).iter().count() > 1
|
|
|| headers.get(ORIGIN).is_some_and(|h| {
|
|
h.to_str()
|
|
.ok()
|
|
.is_none_or(|origin| !admin_origin_matches(origin, scheme, &external_authority))
|
|
})
|
|
{
|
|
return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_origin"));
|
|
}
|
|
if headers
|
|
.get("sec-fetch-site")
|
|
.is_some_and(|h| h == "cross-site")
|
|
{
|
|
return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_origin"));
|
|
}
|
|
let path = request.uri().path().to_owned();
|
|
let public = anonymous_allowed(request.method(), &path);
|
|
let unsafe_method = request.method() != Method::GET
|
|
&& request.method() != Method::HEAD
|
|
&& request.method() != Method::OPTIONS;
|
|
let secret = auth::cookie_secret(headers);
|
|
let certificate_api_session = if certificate_api_operation(request.method(), &path) {
|
|
match certificate_api_credentials(headers) {
|
|
Ok(None) => None,
|
|
Ok(Some((key_id, token))) => {
|
|
if peer.is_some_and(|peer| !peer.0.ip().is_loopback()) && scheme != "https" {
|
|
return secure(reject(
|
|
StatusCode::FORBIDDEN,
|
|
"certificate_api.https_required",
|
|
));
|
|
}
|
|
let token_hash = auth::secret_hash(&token);
|
|
match state
|
|
.store
|
|
.verify_certificate_api_credential(&key_id, &token_hash)
|
|
.await
|
|
{
|
|
Ok(true) => Some(Session {
|
|
token_hash,
|
|
csrf: String::new(),
|
|
username: format!("certificate-api:{key_id}"),
|
|
expires_at: auth::now().saturating_add(60),
|
|
generation: 0,
|
|
}),
|
|
Ok(false) => {
|
|
return secure(reject(
|
|
StatusCode::UNAUTHORIZED,
|
|
"certificate_api.invalid_token",
|
|
))
|
|
}
|
|
Err(error) => return secure(auth::internal(error).into_response()),
|
|
}
|
|
}
|
|
Err(()) => {
|
|
return secure(reject(
|
|
StatusCode::UNAUTHORIZED,
|
|
"certificate_api.invalid_token",
|
|
))
|
|
}
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
let external_certificate_api = certificate_api_session.is_some();
|
|
let session_seconds = state
|
|
.config
|
|
.load()
|
|
.envelope
|
|
.config
|
|
.administration
|
|
.session_seconds();
|
|
// Panel document routes optionally authenticate so a direct refresh can choose
|
|
// the application shell when the browser still has a valid cookie. Public static
|
|
// login resources and capability discovery never load operational state.
|
|
let session = if certificate_api_session.is_some() {
|
|
certificate_api_session
|
|
} else if !public || panel_document_path(&path) {
|
|
match secret.as_deref() {
|
|
Some(secret) => match state
|
|
.store
|
|
.session_at_with_browser_lifetime(secret, auth::now(), session_seconds)
|
|
.await
|
|
{
|
|
Ok(value) => value,
|
|
Err(error) => return secure(auth::internal(error).into_response()),
|
|
},
|
|
None => None,
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
if !public && session.is_none() {
|
|
return secure(unauthorized(&request));
|
|
}
|
|
if unsafe_method && !external_certificate_api {
|
|
if single_header(headers, "origin")
|
|
.is_none_or(|origin| !admin_origin_matches(origin, scheme, &external_authority))
|
|
|| single_header(headers, "x-rustproxy-request") != Some("1")
|
|
{
|
|
return secure(reject(StatusCode::FORBIDDEN, "auth.csrf"));
|
|
}
|
|
if !public
|
|
&& !session.as_ref().is_some_and(|session| {
|
|
single_header(headers, "x-csrf-token")
|
|
.is_some_and(|provided| auth::equal_secret(provided, &session.csrf))
|
|
})
|
|
{
|
|
return secure(reject(StatusCode::FORBIDDEN, "auth.csrf"));
|
|
}
|
|
}
|
|
if let Some(session) = session.clone() {
|
|
request.extensions_mut().insert(session);
|
|
}
|
|
let admin_origin = AdminOrigin {
|
|
secure: scheme == "https",
|
|
};
|
|
request.extensions_mut().insert(admin_origin);
|
|
request.extensions_mut().insert(transport);
|
|
let mut response = secure(next.run(request).await);
|
|
let refresh_session_cookie = !matches!(
|
|
path.as_str(),
|
|
"/api/auth/logout" | "/api/auth/logout-all" | "/api/auth/password"
|
|
);
|
|
if refresh_session_cookie {
|
|
if let (Some(secret), Some(session)) = (secret.as_deref(), session.as_ref()) {
|
|
let remaining = session.expires_at.saturating_sub(auth::now());
|
|
if remaining > 0 {
|
|
if let Ok(cookie) = HeaderValue::from_str(&auth::session_cookie(
|
|
secret,
|
|
admin_origin.secure,
|
|
remaining,
|
|
)) {
|
|
response.headers_mut().append(SET_COOKIE, cookie);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if matches!(path.as_str(), "/docs" | "/docs/") && response.status().is_success() {
|
|
// Swagger UI sets inline layout styles. This exception is limited to the
|
|
// authenticated documentation document; scripts remain self-only/no-eval.
|
|
response.headers_mut().insert("content-security-policy", HeaderValue::from_static(
|
|
"default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"));
|
|
}
|
|
response
|
|
}
|
|
async fn auth_status(State(state): State<Arc<App>>) -> Result<Json<Value>, ApiError> {
|
|
// The one bit needed to render first-account setup; no version, paths, names,
|
|
// listeners, provider state, metrics or configuration before authentication.
|
|
Ok(Json(
|
|
json!({"setup_required":state.store.admin_record().await.map_err(auth::internal)?.is_none()}),
|
|
))
|
|
}
|
|
#[derive(Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct Setup {
|
|
token: String,
|
|
username: String,
|
|
password: String,
|
|
}
|
|
#[derive(Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct Login {
|
|
username: String,
|
|
password: String,
|
|
}
|
|
#[derive(Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct PasswordChange {
|
|
current_password: String,
|
|
new_password: String,
|
|
}
|
|
async fn setup(
|
|
State(state): State<Arc<App>>,
|
|
input: Result<Json<Setup>, axum::extract::rejection::JsonRejection>,
|
|
) -> Result<Json<Value>, ApiError> {
|
|
let Json(input) = input.map_err(public::invalid_json)?;
|
|
state
|
|
.auth
|
|
.setup(&input.token, &input.username, input.password)
|
|
.await?;
|
|
Ok(Json(json!({"initialized":true})))
|
|
}
|
|
fn session_payload(session: &Session, session_seconds: i64) -> Value {
|
|
json!({"username":session.username, "csrf_token":session.csrf, "expires_at":session.expires_at,
|
|
"idle_timeout_seconds":session_seconds, "session_lifetime_seconds":session_seconds})
|
|
}
|
|
async fn login(
|
|
State(state): State<Arc<App>>,
|
|
Extension(origin): Extension<AdminOrigin>,
|
|
headers: HeaderMap,
|
|
input: Result<Json<Login>, axum::extract::rejection::JsonRejection>,
|
|
) -> Result<Response, ApiError> {
|
|
let Json(input) = input.map_err(public::invalid_json)?;
|
|
let previous = auth::cookie_secret(&headers);
|
|
let session_seconds = state
|
|
.config
|
|
.load()
|
|
.envelope
|
|
.config
|
|
.administration
|
|
.session_seconds();
|
|
let issued = state
|
|
.auth
|
|
.login(
|
|
&input.username,
|
|
input.password,
|
|
previous.as_deref(),
|
|
session_seconds,
|
|
)
|
|
.await?;
|
|
let mut response = Json(session_payload(&issued.session, session_seconds)).into_response();
|
|
response.headers_mut().insert(
|
|
SET_COOKIE,
|
|
auth::session_cookie(&issued.secret, origin.secure, session_seconds)
|
|
.parse()
|
|
.map_err(auth::internal)?,
|
|
);
|
|
Ok(response)
|
|
}
|
|
async fn session(
|
|
State(state): State<Arc<App>>,
|
|
Extension(session): Extension<Session>,
|
|
) -> Json<Value> {
|
|
let session_seconds = state
|
|
.config
|
|
.load()
|
|
.envelope
|
|
.config
|
|
.administration
|
|
.session_seconds();
|
|
Json(session_payload(&session, session_seconds))
|
|
}
|
|
|
|
const LIVE_LOG_COLUMNS: &[&str] = &[
|
|
"time",
|
|
"request",
|
|
"host",
|
|
"source_ip",
|
|
"vhost",
|
|
"backend",
|
|
"status",
|
|
"duration",
|
|
"cache",
|
|
"outcome",
|
|
"transport",
|
|
"websocket",
|
|
"upstream_error",
|
|
];
|
|
const BACKEND_TABLE_COLUMNS: &[&str] = &[
|
|
"vhost",
|
|
"backend",
|
|
"status",
|
|
"active",
|
|
"requests",
|
|
"errors",
|
|
"latency",
|
|
"traffic",
|
|
"generation",
|
|
"generationRequests",
|
|
"generationAge",
|
|
"rotations",
|
|
"lastFailure",
|
|
"lastRecovery",
|
|
];
|
|
|
|
fn default_admin_theme() -> String {
|
|
"dark".to_owned()
|
|
}
|
|
fn default_backend_table_columns() -> Vec<String> {
|
|
BACKEND_TABLE_COLUMNS
|
|
.iter()
|
|
.map(|value| (*value).to_owned())
|
|
.collect()
|
|
}
|
|
fn default_backend_table_sort() -> String {
|
|
"vhost".to_owned()
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct AdminPreferences {
|
|
live_log_columns: Vec<String>,
|
|
#[serde(default = "default_admin_theme")]
|
|
theme: String,
|
|
#[serde(default = "default_backend_table_columns")]
|
|
backend_table_columns: Vec<String>,
|
|
#[serde(default = "default_backend_table_sort")]
|
|
backend_table_sort: String,
|
|
#[serde(default)]
|
|
backend_table_descending: bool,
|
|
}
|
|
|
|
impl Default for AdminPreferences {
|
|
fn default() -> Self {
|
|
Self {
|
|
live_log_columns: LIVE_LOG_COLUMNS
|
|
.iter()
|
|
.map(|value| (*value).to_owned())
|
|
.collect(),
|
|
theme: default_admin_theme(),
|
|
backend_table_columns: default_backend_table_columns(),
|
|
backend_table_sort: default_backend_table_sort(),
|
|
backend_table_descending: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn validate_admin_preferences(preferences: &AdminPreferences) -> Result<(), ApiError> {
|
|
if preferences.live_log_columns.is_empty()
|
|
|| preferences.live_log_columns.len() > LIVE_LOG_COLUMNS.len()
|
|
{
|
|
return Err(ApiError(
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
"at least one live-log column must be visible".into(),
|
|
));
|
|
}
|
|
let mut seen = std::collections::HashSet::new();
|
|
for column in &preferences.live_log_columns {
|
|
if !LIVE_LOG_COLUMNS.contains(&column.as_str()) || !seen.insert(column.as_str()) {
|
|
return Err(ApiError(
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
"invalid live-log column preference".into(),
|
|
));
|
|
}
|
|
}
|
|
if !matches!(preferences.theme.as_str(), "dark" | "light") {
|
|
return Err(ApiError(
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
"invalid administration theme preference".into(),
|
|
));
|
|
}
|
|
if preferences.backend_table_columns.is_empty()
|
|
|| preferences.backend_table_columns.len() > BACKEND_TABLE_COLUMNS.len()
|
|
{
|
|
return Err(ApiError(
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
"at least one backend table column must be visible".into(),
|
|
));
|
|
}
|
|
let mut seen = std::collections::HashSet::new();
|
|
for column in &preferences.backend_table_columns {
|
|
if !BACKEND_TABLE_COLUMNS.contains(&column.as_str()) || !seen.insert(column.as_str()) {
|
|
return Err(ApiError(
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
"invalid backend table column preference".into(),
|
|
));
|
|
}
|
|
}
|
|
if !BACKEND_TABLE_COLUMNS.contains(&preferences.backend_table_sort.as_str()) {
|
|
return Err(ApiError(
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
"invalid backend table sort preference".into(),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn preferences(
|
|
State(state): State<Arc<App>>,
|
|
Extension(session): Extension<Session>,
|
|
) -> Result<Json<AdminPreferences>, ApiError> {
|
|
let stored = state
|
|
.store
|
|
.admin_preferences(&session.username)
|
|
.await
|
|
.map_err(auth::internal)?;
|
|
let preferences = match stored {
|
|
Some(document) => match serde_json::from_str::<AdminPreferences>(&document) {
|
|
Ok(preferences) if validate_admin_preferences(&preferences).is_ok() => preferences,
|
|
Ok(_) | Err(_) => {
|
|
tracing::warn!(user = %session.username, "invalid admin view preferences ignored");
|
|
AdminPreferences::default()
|
|
}
|
|
},
|
|
None => AdminPreferences::default(),
|
|
};
|
|
Ok(Json(preferences))
|
|
}
|
|
|
|
async fn update_preferences(
|
|
State(state): State<Arc<App>>,
|
|
Extension(session): Extension<Session>,
|
|
input: Result<Json<AdminPreferences>, axum::extract::rejection::JsonRejection>,
|
|
) -> Result<Json<AdminPreferences>, ApiError> {
|
|
let Json(preferences) = input.map_err(public::invalid_json)?;
|
|
validate_admin_preferences(&preferences)?;
|
|
let document = serde_json::to_string(&preferences).map_err(auth::internal)?;
|
|
if !state
|
|
.store
|
|
.save_admin_preferences(&session.username, &document)
|
|
.await
|
|
.map_err(auth::internal)?
|
|
{
|
|
return Err(ApiError(
|
|
StatusCode::UNAUTHORIZED,
|
|
"auth.invalid_credentials".into(),
|
|
));
|
|
}
|
|
Ok(Json(preferences))
|
|
}
|
|
fn logged_out(secure_cookie: bool) -> Response {
|
|
let mut response = Json(json!({"logged_out":true})).into_response();
|
|
response.headers_mut().insert(
|
|
SET_COOKIE,
|
|
HeaderValue::from_str(&auth::expired_cookie(secure_cookie))
|
|
.expect("static cookie attributes"),
|
|
);
|
|
response
|
|
}
|
|
async fn logout(
|
|
State(state): State<Arc<App>>,
|
|
Extension(origin): Extension<AdminOrigin>,
|
|
Extension(session): Extension<Session>,
|
|
) -> Result<Response, ApiError> {
|
|
state
|
|
.store
|
|
.end_session(&session)
|
|
.await
|
|
.map_err(auth::internal)?;
|
|
Ok(logged_out(origin.secure))
|
|
}
|
|
async fn logout_all(
|
|
State(state): State<Arc<App>>,
|
|
Extension(origin): Extension<AdminOrigin>,
|
|
Extension(session): Extension<Session>,
|
|
) -> Result<Response, ApiError> {
|
|
state
|
|
.store
|
|
.revoke_all_sessions(&session.username)
|
|
.await
|
|
.map_err(auth::internal)?;
|
|
Ok(logged_out(origin.secure))
|
|
}
|
|
async fn password(
|
|
State(state): State<Arc<App>>,
|
|
Extension(origin): Extension<AdminOrigin>,
|
|
Extension(session): Extension<Session>,
|
|
Json(input): Json<PasswordChange>,
|
|
) -> Result<Response, ApiError> {
|
|
state
|
|
.auth
|
|
.change_password(&session, input.current_password, input.new_password)
|
|
.await?;
|
|
Ok(logged_out(origin.secure))
|
|
}
|