mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 02:17:46 +08:00
feat(security): harden gateway boundaries and usage policies
Consolidate subscription usage policy enforcement, privacy-safe persistence, and gateway security hardening into one reviewable change. Includes bounded HTTP and execution envelopes, header and protocol guards, DNS and relay validation, authentication and secret projection hardening, secure backup/install paths, and regression coverage.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
use super::ADMIN_AWS_REGIONS;
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext, SystemExportMode};
|
||||
use crate::handlers::admin::shared::attach_admin_audit_response;
|
||||
use crate::handlers::admin::shared::build_proxy_error_response;
|
||||
use crate::handlers::admin::system::shared::configs::{
|
||||
@@ -23,10 +23,15 @@ use crate::handlers::admin::system::shared::update::{
|
||||
prepare_admin_system_update_task, read_update_history, read_update_task_status,
|
||||
self_update_supported, start_admin_system_rollback_task, start_admin_system_update_task,
|
||||
};
|
||||
use crate::handlers::admin::system::{
|
||||
execute_admin_system_import_exclusively, release_admin_system_import_lease,
|
||||
try_acquire_admin_system_import_lease, AdminSystemImportLockError,
|
||||
};
|
||||
use crate::important_notification::build_important_notification_test_payload;
|
||||
use crate::maintenance::{ManualUsageCleanupMode, ManualUsageCleanupOptions};
|
||||
use crate::GatewayError;
|
||||
use aether_data_contracts::repository::usage::UsageCleanupTargets;
|
||||
use aether_runtime_state::RuntimeLockLease;
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
http,
|
||||
@@ -34,6 +39,7 @@ use axum::{
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::future::Future;
|
||||
use std::time::Instant;
|
||||
use url::form_urlencoded;
|
||||
|
||||
@@ -233,12 +239,21 @@ pub(super) async fn maybe_build_local_admin_core_system_response(
|
||||
&& request_method == http::Method::GET
|
||||
&& request_path == "/api/admin/system/config/export"
|
||||
{
|
||||
return Ok(Some(attach_admin_audit_response(
|
||||
Json(state.build_admin_system_config_export_payload().await?).into_response(),
|
||||
"admin_system_config_exported",
|
||||
"export_system_config",
|
||||
"system_config_export",
|
||||
"global",
|
||||
return Ok(Some(sensitive_system_export_response(
|
||||
attach_admin_audit_response(
|
||||
Json(
|
||||
state
|
||||
.build_admin_system_config_export_payload(
|
||||
SystemExportMode::InteractiveDownload,
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
.into_response(),
|
||||
"admin_system_config_exported",
|
||||
"export_system_config",
|
||||
"system_config_export",
|
||||
"global",
|
||||
),
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -255,30 +270,46 @@ pub(super) async fn maybe_build_local_admin_core_system_response(
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
return Ok(Some(
|
||||
match state.import_admin_system_config(request_body).await? {
|
||||
Ok(payload) => attach_admin_audit_response(
|
||||
Json(payload).into_response(),
|
||||
"admin_system_config_imported",
|
||||
"import_system_config",
|
||||
"system_config_import",
|
||||
"global",
|
||||
),
|
||||
Err((status, payload)) => (status, Json(payload)).into_response(),
|
||||
},
|
||||
));
|
||||
let import_result = match execute_admin_system_import_with_lock(
|
||||
state,
|
||||
state.import_admin_system_config(request_body),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(response) => return Ok(Some(response)),
|
||||
};
|
||||
return Ok(Some(match import_result? {
|
||||
Ok(payload) => attach_admin_audit_response(
|
||||
Json(payload).into_response(),
|
||||
"admin_system_config_imported",
|
||||
"import_system_config",
|
||||
"system_config_import",
|
||||
"global",
|
||||
),
|
||||
Err((status, payload)) => (status, Json(payload)).into_response(),
|
||||
}));
|
||||
}
|
||||
|
||||
if decision.route_kind.as_deref() == Some("users_export")
|
||||
&& request_method == http::Method::GET
|
||||
&& request_path == "/api/admin/system/users/export"
|
||||
{
|
||||
return Ok(Some(attach_admin_audit_response(
|
||||
Json(state.build_admin_system_users_export_payload().await?).into_response(),
|
||||
"admin_system_users_exported",
|
||||
"export_system_users",
|
||||
"user_export",
|
||||
"all_users",
|
||||
return Ok(Some(sensitive_system_export_response(
|
||||
attach_admin_audit_response(
|
||||
Json(
|
||||
state
|
||||
.build_admin_system_users_export_payload(
|
||||
SystemExportMode::InteractiveDownload,
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
.into_response(),
|
||||
"admin_system_users_exported",
|
||||
"export_system_users",
|
||||
"user_export",
|
||||
"all_users",
|
||||
),
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -295,39 +326,52 @@ pub(super) async fn maybe_build_local_admin_core_system_response(
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
return Ok(Some(
|
||||
match state
|
||||
.import_admin_system_users(
|
||||
request_body,
|
||||
decision
|
||||
.admin_principal
|
||||
.as_ref()
|
||||
.map(|principal| principal.user_id.as_str()),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Ok(payload) => attach_admin_audit_response(
|
||||
Json(payload).into_response(),
|
||||
"admin_system_users_imported",
|
||||
"import_system_users",
|
||||
"system_users_import",
|
||||
"global",
|
||||
),
|
||||
Err((status, payload)) => (status, Json(payload)).into_response(),
|
||||
},
|
||||
));
|
||||
let import_result = match execute_admin_system_import_with_lock(
|
||||
state,
|
||||
state.import_admin_system_users(
|
||||
request_body,
|
||||
decision
|
||||
.admin_principal
|
||||
.as_ref()
|
||||
.map(|principal| principal.user_id.as_str()),
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(response) => return Ok(Some(response)),
|
||||
};
|
||||
return Ok(Some(match import_result? {
|
||||
Ok(payload) => attach_admin_audit_response(
|
||||
Json(payload).into_response(),
|
||||
"admin_system_users_imported",
|
||||
"import_system_users",
|
||||
"system_users_import",
|
||||
"global",
|
||||
),
|
||||
Err((status, payload)) => (status, Json(payload)).into_response(),
|
||||
}));
|
||||
}
|
||||
|
||||
if decision.route_kind.as_deref() == Some("data_export")
|
||||
&& request_method == http::Method::GET
|
||||
&& request_path == "/api/admin/system/data/export"
|
||||
{
|
||||
return Ok(Some(attach_admin_audit_response(
|
||||
Json(state.build_admin_system_data_export_payload().await?).into_response(),
|
||||
"admin_system_data_exported",
|
||||
"export_system_data",
|
||||
"system_data_export",
|
||||
"global",
|
||||
return Ok(Some(sensitive_system_export_response(
|
||||
attach_admin_audit_response(
|
||||
Json(
|
||||
state
|
||||
.build_admin_system_data_export_payload(
|
||||
SystemExportMode::InteractiveDownload,
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
.into_response(),
|
||||
"admin_system_data_exported",
|
||||
"export_system_data",
|
||||
"system_data_export",
|
||||
"global",
|
||||
),
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -344,27 +388,31 @@ pub(super) async fn maybe_build_local_admin_core_system_response(
|
||||
.into_response(),
|
||||
));
|
||||
};
|
||||
return Ok(Some(
|
||||
match state
|
||||
.import_admin_system_data(
|
||||
request_body,
|
||||
decision
|
||||
.admin_principal
|
||||
.as_ref()
|
||||
.map(|principal| principal.user_id.as_str()),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Ok(payload) => attach_admin_audit_response(
|
||||
Json(payload).into_response(),
|
||||
"admin_system_data_imported",
|
||||
"import_system_data",
|
||||
"system_data_import",
|
||||
"global",
|
||||
),
|
||||
Err((status, payload)) => (status, Json(payload)).into_response(),
|
||||
},
|
||||
));
|
||||
let import_result = match execute_admin_system_import_with_lock(
|
||||
state,
|
||||
state.import_admin_system_data(
|
||||
request_body,
|
||||
decision
|
||||
.admin_principal
|
||||
.as_ref()
|
||||
.map(|principal| principal.user_id.as_str()),
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(response) => return Ok(Some(response)),
|
||||
};
|
||||
return Ok(Some(match import_result? {
|
||||
Ok(payload) => attach_admin_audit_response(
|
||||
Json(payload).into_response(),
|
||||
"admin_system_data_imported",
|
||||
"import_system_data",
|
||||
"system_data_import",
|
||||
"global",
|
||||
),
|
||||
Err((status, payload)) => (status, Json(payload)).into_response(),
|
||||
}));
|
||||
}
|
||||
|
||||
if decision.route_kind.as_deref() == Some("s3_backup_run")
|
||||
@@ -1103,6 +1151,62 @@ fn bad_manual_cleanup_request(detail: impl Into<String>) -> Response<Body> {
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn sensitive_system_export_response(mut response: Response<Body>) -> Response<Body> {
|
||||
response.headers_mut().insert(
|
||||
http::header::CACHE_CONTROL,
|
||||
http::HeaderValue::from_static("no-store"),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
async fn acquire_admin_system_import_lock(
|
||||
state: &AdminAppState<'_>,
|
||||
) -> Result<RuntimeLockLease, Response<Body>> {
|
||||
try_acquire_admin_system_import_lease(state.app())
|
||||
.await
|
||||
.map_err(admin_system_import_lock_error_response)
|
||||
}
|
||||
|
||||
async fn execute_admin_system_import_with_lock<F, T>(
|
||||
state: &AdminAppState<'_>,
|
||||
operation: F,
|
||||
) -> Result<T, Response<Body>>
|
||||
where
|
||||
F: Future<Output = T>,
|
||||
{
|
||||
execute_admin_system_import_exclusively(state.app(), operation)
|
||||
.await
|
||||
.map_err(admin_system_import_lock_error_response)
|
||||
}
|
||||
|
||||
fn admin_system_import_lock_error_response(error: AdminSystemImportLockError) -> Response<Body> {
|
||||
match error {
|
||||
AdminSystemImportLockError::Conflict => (
|
||||
http::StatusCode::CONFLICT,
|
||||
Json(json!({
|
||||
"detail": "已有系统数据导入正在执行,请等待当前导入完成后再试"
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
AdminSystemImportLockError::Unavailable => (
|
||||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({ "detail": "系统数据导入服务暂时不可用" })),
|
||||
)
|
||||
.into_response(),
|
||||
AdminSystemImportLockError::Lost => (
|
||||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({
|
||||
"detail": "系统数据导入锁已丢失,操作已停止;可能存在部分已提交变更,请检查运行状态后重试"
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn release_admin_system_import_lock(state: &AdminAppState<'_>, lock: &RuntimeLockLease) {
|
||||
release_admin_system_import_lease(state.app(), lock).await;
|
||||
}
|
||||
|
||||
fn query_param(query_string: Option<&str>, name: &str) -> Option<String> {
|
||||
let query = query_string.filter(|value| !value.is_empty())?;
|
||||
form_urlencoded::parse(query.as_bytes())
|
||||
@@ -1142,6 +1246,26 @@ fn parse_older_than_days_query(query_string: Option<&str>) -> Result<Option<u32>
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_system_import_lock_serializes_and_releases_imports() {
|
||||
let app = crate::AppState::new().expect("app state should build");
|
||||
let state = AdminAppState::new(&app);
|
||||
|
||||
let first = acquire_admin_system_import_lock(&state)
|
||||
.await
|
||||
.expect("first import should acquire the lock");
|
||||
let conflict = acquire_admin_system_import_lock(&state)
|
||||
.await
|
||||
.expect_err("concurrent import must be rejected");
|
||||
assert_eq!(conflict.status(), http::StatusCode::CONFLICT);
|
||||
|
||||
release_admin_system_import_lock(&state, &first).await;
|
||||
let second = acquire_admin_system_import_lock(&state)
|
||||
.await
|
||||
.expect("lock should be reusable after release");
|
||||
release_admin_system_import_lock(&state, &second).await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_cleanup_request_defaults_to_policy_targets() {
|
||||
let options = parse_manual_usage_cleanup_request(None).expect("default request is valid");
|
||||
@@ -1188,4 +1312,14 @@ mod tests {
|
||||
|
||||
assert!(parse_manual_usage_cleanup_request(Some(&body)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sensitive_system_exports_are_never_cacheable() {
|
||||
let response = sensitive_system_export_response(Json(json!({})).into_response());
|
||||
|
||||
assert_eq!(
|
||||
response.headers().get(http::header::CACHE_CONTROL),
|
||||
Some(&http::HeaderValue::from_static("no-store"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use aether_runtime_state::{RuntimeLockLease, RuntimeState};
|
||||
use tokio::time::MissedTickBehavior;
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
const ADMIN_SYSTEM_IMPORT_LOCK_KEY: &str = "admin:system:import";
|
||||
const ADMIN_SYSTEM_IMPORT_LOCK_TTL: Duration = Duration::from_secs(60 * 60 * 6);
|
||||
const ADMIN_SYSTEM_IMPORT_LOCK_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(60 * 5);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum AdminSystemImportLockError {
|
||||
Conflict,
|
||||
Unavailable,
|
||||
Lost,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum AdminSystemImportRaceOutcome<T> {
|
||||
OperationCompleted(T),
|
||||
LeaseLost,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum AdminSystemImportLockRenewalFailure<E> {
|
||||
Lost,
|
||||
Backend(E),
|
||||
}
|
||||
|
||||
struct AdminSystemImportLeaseGuard {
|
||||
runtime_state: Arc<RuntimeState>,
|
||||
lease: Option<RuntimeLockLease>,
|
||||
}
|
||||
|
||||
impl AdminSystemImportLeaseGuard {
|
||||
fn new(app: &AppState, lease: RuntimeLockLease) -> Self {
|
||||
Self {
|
||||
runtime_state: app.runtime_state.clone(),
|
||||
lease: Some(lease),
|
||||
}
|
||||
}
|
||||
|
||||
fn lease(&self) -> &RuntimeLockLease {
|
||||
self.lease
|
||||
.as_ref()
|
||||
.expect("admin system import lease guard must own a lease")
|
||||
}
|
||||
|
||||
async fn release(&mut self) -> Result<(), AdminSystemImportLockError> {
|
||||
let Some(lease) = self.lease.clone() else {
|
||||
return Ok(());
|
||||
};
|
||||
match self.runtime_state.lock_release(&lease).await {
|
||||
Ok(true) => {
|
||||
self.lease.take();
|
||||
Ok(())
|
||||
}
|
||||
Ok(false) => {
|
||||
tracing::warn!(
|
||||
lock_key = %lease.key,
|
||||
"admin system import lock was no longer owned during release"
|
||||
);
|
||||
self.lease.take();
|
||||
Err(AdminSystemImportLockError::Lost)
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(error = %error, "admin system import lock release failed");
|
||||
// Keep the lease in the guard so Drop can make one best-effort retry.
|
||||
Err(AdminSystemImportLockError::Lost)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AdminSystemImportLeaseGuard {
|
||||
fn drop(&mut self) {
|
||||
let Some(lease) = self.lease.take() else {
|
||||
return;
|
||||
};
|
||||
let runtime_state = self.runtime_state.clone();
|
||||
let Ok(handle) = tokio::runtime::Handle::try_current() else {
|
||||
return;
|
||||
};
|
||||
drop(handle.spawn(async move {
|
||||
match runtime_state.lock_release(&lease).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => tracing::warn!(
|
||||
lock_key = %lease.key,
|
||||
"admin system import lock was no longer owned during asynchronous release"
|
||||
),
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
error = %error,
|
||||
lock_key = %lease.key,
|
||||
"admin system import lock asynchronous release failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn try_acquire_admin_system_import_lease(
|
||||
app: &AppState,
|
||||
) -> Result<RuntimeLockLease, AdminSystemImportLockError> {
|
||||
match app
|
||||
.runtime_state()
|
||||
.lock_try_acquire(
|
||||
ADMIN_SYSTEM_IMPORT_LOCK_KEY,
|
||||
app.tunnel.local_instance_id(),
|
||||
ADMIN_SYSTEM_IMPORT_LOCK_TTL,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(lock)) => Ok(lock),
|
||||
Ok(None) => Err(AdminSystemImportLockError::Conflict),
|
||||
Err(error) => {
|
||||
tracing::warn!(error = %error, "admin system import lock acquisition failed");
|
||||
Err(AdminSystemImportLockError::Unavailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn release_admin_system_import_lease(app: &AppState, lock: &RuntimeLockLease) {
|
||||
match app.runtime_state().lock_release(lock).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => tracing::warn!(
|
||||
lock_key = %lock.key,
|
||||
"admin system import lock was no longer owned during release"
|
||||
),
|
||||
Err(error) => {
|
||||
tracing::warn!(error = %error, "admin system import lock release failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn require_successful_admin_system_import_lock_renewal<E>(
|
||||
result: Result<bool, E>,
|
||||
) -> Result<(), AdminSystemImportLockRenewalFailure<E>> {
|
||||
match result {
|
||||
Ok(true) => Ok(()),
|
||||
Ok(false) => Err(AdminSystemImportLockRenewalFailure::Lost),
|
||||
Err(error) => Err(AdminSystemImportLockRenewalFailure::Backend(error)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_admin_system_import_lease_loss(
|
||||
runtime_state: Arc<RuntimeState>,
|
||||
lease: RuntimeLockLease,
|
||||
) {
|
||||
let mut heartbeat = tokio::time::interval(ADMIN_SYSTEM_IMPORT_LOCK_HEARTBEAT_INTERVAL);
|
||||
heartbeat.set_missed_tick_behavior(MissedTickBehavior::Delay);
|
||||
heartbeat.tick().await;
|
||||
loop {
|
||||
heartbeat.tick().await;
|
||||
match require_successful_admin_system_import_lock_renewal(
|
||||
runtime_state
|
||||
.lock_renew(&lease, ADMIN_SYSTEM_IMPORT_LOCK_TTL)
|
||||
.await,
|
||||
) {
|
||||
Ok(()) => {}
|
||||
Err(AdminSystemImportLockRenewalFailure::Lost) => {
|
||||
tracing::warn!(
|
||||
lock_key = %lease.key,
|
||||
fencing_token = lease.fencing_token,
|
||||
"admin system import lock is no longer owned; cancelling the import"
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(AdminSystemImportLockRenewalFailure::Backend(error)) => {
|
||||
tracing::warn!(
|
||||
error = %error,
|
||||
lock_key = %lease.key,
|
||||
fencing_token = lease.fencing_token,
|
||||
"admin system import lock renewal failed; cancelling the import"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn race_admin_system_import_with_lease_loss<F, L, T>(
|
||||
operation: F,
|
||||
lease_loss: L,
|
||||
) -> AdminSystemImportRaceOutcome<T>
|
||||
where
|
||||
F: Future<Output = T>,
|
||||
L: Future<Output = ()>,
|
||||
{
|
||||
tokio::pin!(operation);
|
||||
tokio::pin!(lease_loss);
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = &mut lease_loss => AdminSystemImportRaceOutcome::LeaseLost,
|
||||
result = &mut operation => AdminSystemImportRaceOutcome::OperationCompleted(result),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn execute_admin_system_import_exclusively<F, T>(
|
||||
app: &AppState,
|
||||
operation: F,
|
||||
) -> Result<T, AdminSystemImportLockError>
|
||||
where
|
||||
F: Future<Output = T>,
|
||||
{
|
||||
let lease = try_acquire_admin_system_import_lease(app).await?;
|
||||
let mut guard = AdminSystemImportLeaseGuard::new(app, lease);
|
||||
let lease_loss =
|
||||
wait_for_admin_system_import_lease_loss(guard.runtime_state.clone(), guard.lease().clone());
|
||||
|
||||
match race_admin_system_import_with_lease_loss(operation, lease_loss).await {
|
||||
AdminSystemImportRaceOutcome::OperationCompleted(result) => {
|
||||
guard.release().await?;
|
||||
Ok(result)
|
||||
}
|
||||
AdminSystemImportRaceOutcome::LeaseLost => Err(AdminSystemImportLockError::Lost),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::convert::Infallible;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
struct DropSignal(Arc<AtomicBool>);
|
||||
|
||||
impl Drop for DropSignal {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_system_import_lock_renewal_requires_current_ownership() {
|
||||
assert_eq!(
|
||||
require_successful_admin_system_import_lock_renewal::<Infallible>(Ok(true)),
|
||||
Ok(())
|
||||
);
|
||||
assert_eq!(
|
||||
require_successful_admin_system_import_lock_renewal::<Infallible>(Ok(false)),
|
||||
Err(AdminSystemImportLockRenewalFailure::Lost)
|
||||
);
|
||||
assert_eq!(
|
||||
require_successful_admin_system_import_lock_renewal(Err("redis unavailable")),
|
||||
Err(AdminSystemImportLockRenewalFailure::Backend(
|
||||
"redis unavailable"
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_system_import_operation_is_cancelled_when_lease_is_lost() {
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let operation_dropped = dropped.clone();
|
||||
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
|
||||
let operation = async move {
|
||||
let _drop_signal = DropSignal(operation_dropped);
|
||||
let _ = started_tx.send(());
|
||||
std::future::pending::<()>().await;
|
||||
};
|
||||
let lease_loss = async move {
|
||||
started_rx
|
||||
.await
|
||||
.expect("operation should be polled before reporting lease loss");
|
||||
};
|
||||
|
||||
let outcome = race_admin_system_import_with_lease_loss(operation, lease_loss).await;
|
||||
|
||||
assert_eq!(outcome, AdminSystemImportRaceOutcome::LeaseLost);
|
||||
assert!(dropped.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completed_admin_system_import_releases_lease_for_reuse() {
|
||||
let app = AppState::new().expect("app state should build");
|
||||
|
||||
let result = execute_admin_system_import_exclusively(&app, async { 42_u8 })
|
||||
.await
|
||||
.expect("import should complete");
|
||||
assert_eq!(result, 42);
|
||||
|
||||
let lease = try_acquire_admin_system_import_lease(&app)
|
||||
.await
|
||||
.expect("completed import should release its lease");
|
||||
release_admin_system_import_lease(&app, &lease).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_admin_system_import_releases_lease_without_waiting_for_ttl() {
|
||||
let app = AppState::new().expect("app state should build");
|
||||
let task_app = app.clone();
|
||||
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
|
||||
let task = tokio::spawn(async move {
|
||||
execute_admin_system_import_exclusively(&task_app, async move {
|
||||
let _ = started_tx.send(());
|
||||
std::future::pending::<()>().await;
|
||||
})
|
||||
.await
|
||||
});
|
||||
started_rx
|
||||
.await
|
||||
.expect("operation should start after acquiring the lease");
|
||||
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
|
||||
let lease = tokio::time::timeout(Duration::from_secs(1), async {
|
||||
loop {
|
||||
match try_acquire_admin_system_import_lease(&app).await {
|
||||
Ok(lease) => break lease,
|
||||
Err(AdminSystemImportLockError::Conflict) => tokio::task::yield_now().await,
|
||||
Err(error) => panic!("unexpected lock acquisition error: {error:?}"),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("cancelled import should release its lease promptly");
|
||||
release_admin_system_import_lease(&app, &lease).await;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
use crate::control::{
|
||||
management_token_permission_catalog_payload,
|
||||
management_token_permissions_cover_all_assignable_permissions,
|
||||
normalize_assignable_management_token_permissions,
|
||||
management_token_permission_catalog_payload, normalize_assignable_management_token_permissions,
|
||||
};
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
use crate::handlers::admin::shared::{query_param_optional_bool, query_param_value};
|
||||
@@ -58,6 +56,35 @@ fn admin_management_token_read_only_response() -> Response<Body> {
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn admin_management_token_secret_response(mut response: Response<Body>) -> Response<Body> {
|
||||
response.headers_mut().insert(
|
||||
http::header::CACHE_CONTROL,
|
||||
http::HeaderValue::from_static("no-store"),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
fn admin_management_token_internal_error_response(
|
||||
trace_id: &str,
|
||||
event_name: &'static str,
|
||||
error: impl std::fmt::Debug,
|
||||
) -> Response<Body> {
|
||||
tracing::error!(
|
||||
event_name,
|
||||
trace_id,
|
||||
error = ?error,
|
||||
"management token operation failed"
|
||||
);
|
||||
(
|
||||
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({
|
||||
"detail": "Management Token 服务暂不可用,请稍后重试",
|
||||
"trace_id": trace_id,
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct AdminManagementTokenCreateInput {
|
||||
name: String,
|
||||
@@ -93,8 +120,12 @@ fn hash_admin_management_token(value: &str) -> String {
|
||||
}
|
||||
|
||||
fn admin_management_token_prefix(value: &str) -> Option<String> {
|
||||
(!value.is_empty())
|
||||
.then(|| value[..value.len().min(ADMIN_MANAGEMENT_TOKEN_DISPLAY_PREFIX_LEN)].to_string())
|
||||
(!value.is_empty()).then(|| {
|
||||
value
|
||||
.chars()
|
||||
.take(ADMIN_MANAGEMENT_TOKEN_DISPLAY_PREFIX_LEN)
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn admin_parse_management_token_allowed_ips(
|
||||
@@ -228,13 +259,14 @@ fn admin_parse_management_token_update_input(
|
||||
async fn admin_management_token_user_summary(
|
||||
state: &AdminAppState<'_>,
|
||||
user_id: &str,
|
||||
trace_id: &str,
|
||||
) -> Result<StoredManagementTokenUserSummary, Response<Body>> {
|
||||
let Some(user) = state.find_user_auth_by_id(user_id).await.map_err(|err| {
|
||||
(
|
||||
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "detail": format!("management token user lookup failed: {err:?}") })),
|
||||
admin_management_token_internal_error_response(
|
||||
trace_id,
|
||||
"admin_management_token_user_lookup_failed",
|
||||
err,
|
||||
)
|
||||
.into_response()
|
||||
})?
|
||||
else {
|
||||
return Err((
|
||||
@@ -243,19 +275,15 @@ async fn admin_management_token_user_summary(
|
||||
)
|
||||
.into_response());
|
||||
};
|
||||
StoredManagementTokenUserSummary::new(
|
||||
user.id,
|
||||
user.email,
|
||||
user.username,
|
||||
user.role,
|
||||
StoredManagementTokenUserSummary::new(user.id, user.email, user.username, user.role).map_err(
|
||||
|err| {
|
||||
admin_management_token_internal_error_response(
|
||||
trace_id,
|
||||
"admin_management_token_user_summary_build_failed",
|
||||
err,
|
||||
)
|
||||
},
|
||||
)
|
||||
.map_err(|err| {
|
||||
(
|
||||
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "detail": format!("management token user summary build failed: {err:?}") })),
|
||||
)
|
||||
.into_response()
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_build_local_admin_management_tokens_response(
|
||||
@@ -275,17 +303,12 @@ pub(crate) async fn maybe_build_local_admin_management_tokens_response(
|
||||
.as_ref()
|
||||
.and_then(|principal| principal.management_token_id.as_deref())
|
||||
.is_some();
|
||||
let management_token_is_full = decision
|
||||
.admin_principal
|
||||
.as_ref()
|
||||
.and_then(|principal| principal.management_token_permissions.as_deref())
|
||||
.is_none_or(management_token_permissions_cover_all_assignable_permissions);
|
||||
if is_management_token && !management_token_is_full {
|
||||
if is_management_token {
|
||||
return Ok(Some(
|
||||
(
|
||||
http::StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"detail": "不允许使用 Management Token 管理其他 Token,请使用 Web 界面或 JWT 认证"
|
||||
"detail": "不允许使用 Management Token 管理其他 Token,请使用管理员会话认证"
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
@@ -359,7 +382,12 @@ pub(crate) async fn maybe_build_local_admin_management_tokens_response(
|
||||
let Some(admin_principal) = decision.admin_principal.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let user = match admin_management_token_user_summary(state, &admin_principal.user_id).await
|
||||
let user = match admin_management_token_user_summary(
|
||||
state,
|
||||
&admin_principal.user_id,
|
||||
request_context.trace_id(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(value) => value,
|
||||
Err(response) => return Ok(Some(response)),
|
||||
@@ -380,15 +408,17 @@ pub(crate) async fn maybe_build_local_admin_management_tokens_response(
|
||||
};
|
||||
|
||||
return Ok(Some(match state.create_management_token(&record).await? {
|
||||
LocalMutationOutcome::Applied(token) => (
|
||||
http::StatusCode::CREATED,
|
||||
Json(json!({
|
||||
"message": "Management Token 创建成功",
|
||||
"token": raw_token,
|
||||
"data": build_management_token_payload(&token, Some(&record.user)),
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
LocalMutationOutcome::Applied(token) => admin_management_token_secret_response(
|
||||
(
|
||||
http::StatusCode::CREATED,
|
||||
Json(json!({
|
||||
"message": "Management Token 创建成功",
|
||||
"token": raw_token,
|
||||
"data": build_management_token_payload(&token, Some(&record.user)),
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
),
|
||||
LocalMutationOutcome::Invalid(detail) => {
|
||||
admin_management_token_bad_request_response(detail)
|
||||
}
|
||||
@@ -546,12 +576,14 @@ pub(crate) async fn maybe_build_local_admin_management_tokens_response(
|
||||
|
||||
return Ok(Some(
|
||||
match state.regenerate_management_token_secret(&mutation).await? {
|
||||
LocalMutationOutcome::Applied(token) => Json(json!({
|
||||
"message": "Token 已重新生成",
|
||||
"token": raw_token,
|
||||
"data": build_management_token_payload(&token, Some(&existing.user)),
|
||||
}))
|
||||
.into_response(),
|
||||
LocalMutationOutcome::Applied(token) => admin_management_token_secret_response(
|
||||
Json(json!({
|
||||
"message": "Token 已重新生成",
|
||||
"token": raw_token,
|
||||
"data": build_management_token_payload(&token, Some(&existing.user)),
|
||||
}))
|
||||
.into_response(),
|
||||
),
|
||||
LocalMutationOutcome::NotFound => admin_management_token_not_found_response(),
|
||||
LocalMutationOutcome::Invalid(detail) => {
|
||||
admin_management_token_bad_request_response(detail)
|
||||
@@ -563,3 +595,20 @@ pub(crate) async fn maybe_build_local_admin_management_tokens_response(
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn plaintext_management_token_responses_are_never_cacheable() {
|
||||
let response = admin_management_token_secret_response(
|
||||
Json(json!({ "token": "ae-secret" })).into_response(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
response.headers().get(http::header::CACHE_CONTROL),
|
||||
Some(&http::HeaderValue::from_static("no-store"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
mod adaptive;
|
||||
mod core;
|
||||
mod import_lock;
|
||||
mod management_tokens;
|
||||
mod modules;
|
||||
mod proxy_nodes;
|
||||
mod routes;
|
||||
pub(super) mod shared;
|
||||
|
||||
pub(crate) use self::import_lock::{
|
||||
execute_admin_system_import_exclusively, release_admin_system_import_lease,
|
||||
try_acquire_admin_system_import_lease, AdminSystemImportLockError,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use self::proxy_nodes::{
|
||||
clear_proxy_node_references_with_cache_failure_for_tests,
|
||||
|
||||
@@ -19,11 +19,9 @@ use aether_admin::system::{
|
||||
build_admin_proxy_node_payload, build_admin_proxy_nodes_data_unavailable_response,
|
||||
build_admin_proxy_nodes_not_found_response,
|
||||
};
|
||||
use aether_contracts::tunnel::{
|
||||
TUNNEL_RELAY_FORWARDED_BY_HEADER, TUNNEL_RELAY_OWNER_INSTANCE_HEADER,
|
||||
};
|
||||
use aether_contracts::tunnel::TUNNEL_RELAY_FORWARDED_BY_HEADER;
|
||||
use aether_data::repository::management_tokens::{
|
||||
CreateManagementTokenRecord, StoredManagementTokenUserSummary,
|
||||
CreateManagementTokenRecord, StoredManagementToken, StoredManagementTokenUserSummary,
|
||||
};
|
||||
use aether_data::repository::proxy_nodes::{ProxyNodeEventQuery, ProxyNodeMetricsStep};
|
||||
use axum::{
|
||||
@@ -103,7 +101,7 @@ struct ProxyNodeUnregisterRequest {
|
||||
node_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Deserialize)]
|
||||
struct ManualProxyNodeCreateRequest {
|
||||
name: String,
|
||||
proxy_url: String,
|
||||
@@ -115,7 +113,7 @@ struct ManualProxyNodeCreateRequest {
|
||||
region: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Deserialize)]
|
||||
struct ManualProxyNodeUpdateRequest {
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
@@ -129,7 +127,7 @@ struct ManualProxyNodeUpdateRequest {
|
||||
region: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Deserialize)]
|
||||
struct ProxyNodeTestUrlRequest {
|
||||
proxy_url: String,
|
||||
#[serde(default)]
|
||||
@@ -177,6 +175,7 @@ const MAX_PROXY_CONNECTIVITY_RESPONSE_BYTES: usize = 64 * 1024;
|
||||
const PROXY_NODE_METRICS_MAX_POINTS: usize = 50_000;
|
||||
const PROXY_NODE_METRICS_1M_MAX_WINDOW_SECS: u64 = 30 * 24 * 60 * 60;
|
||||
const PROXY_NODE_METRICS_1H_MAX_WINDOW_SECS: u64 = 365 * 24 * 60 * 60;
|
||||
const PROXY_INSTALL_INTERNAL_ERROR_DETAIL: &str = "Service temporarily unavailable";
|
||||
|
||||
#[cfg(test)]
|
||||
fn manual_proxy_connectivity_probe_url_override() -> &'static std::sync::RwLock<Option<String>> {
|
||||
@@ -233,6 +232,7 @@ pub(crate) async fn maybe_build_local_admin_proxy_nodes_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
headers: &http::HeaderMap,
|
||||
remote_addr: &std::net::SocketAddr,
|
||||
request_body: Option<&Bytes>,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
let Some(decision) = request_context.decision() else {
|
||||
@@ -374,9 +374,11 @@ pub(crate) async fn maybe_build_local_admin_proxy_nodes_response(
|
||||
.tunnel
|
||||
.register_secure_tunnel_key(node.id.clone(), key);
|
||||
}
|
||||
state.app().tunnel.request_close_proxies_for_node(&node.id);
|
||||
return Ok(Some(
|
||||
Json(json!({
|
||||
"node_id": node.id,
|
||||
"tunnel_generation": node.tunnel_generation,
|
||||
"node": build_admin_proxy_node_payload(&node),
|
||||
}))
|
||||
.into_response(),
|
||||
@@ -434,6 +436,7 @@ pub(crate) async fn maybe_build_local_admin_proxy_nodes_response(
|
||||
let Some(node) = state.unregister_proxy_node(&node_id).await? else {
|
||||
return Ok(Some(build_admin_proxy_nodes_not_found_response()));
|
||||
};
|
||||
state.app().tunnel.request_close_proxies_for_node(&node.id);
|
||||
return Ok(Some(
|
||||
Json(json!({
|
||||
"message": "unregistered",
|
||||
@@ -483,7 +486,7 @@ pub(crate) async fn maybe_build_local_admin_proxy_nodes_response(
|
||||
Ok(node_name) => node_name,
|
||||
Err(response) => return Ok(Some(response)),
|
||||
};
|
||||
let raw_token =
|
||||
let (token_record, raw_token) =
|
||||
match create_proxy_install_management_token(state, request_context, &node_name).await {
|
||||
Ok(token) => token,
|
||||
Err(response) => return Ok(Some(response)),
|
||||
@@ -493,7 +496,9 @@ pub(crate) async fn maybe_build_local_admin_proxy_nodes_response(
|
||||
state.app(),
|
||||
request_context.public(),
|
||||
headers,
|
||||
remote_addr,
|
||||
node_name,
|
||||
&token_record,
|
||||
raw_token,
|
||||
)
|
||||
.await,
|
||||
@@ -555,6 +560,7 @@ pub(crate) async fn maybe_build_local_admin_proxy_nodes_response(
|
||||
let Some(_deleted_node) = state.delete_proxy_node(&node_id).await? else {
|
||||
return Ok(build_admin_proxy_nodes_not_found_response());
|
||||
};
|
||||
state.app().tunnel.request_close_proxies_for_node(&node_id);
|
||||
Ok(Json(json!({
|
||||
"message": build_delete_proxy_node_message(&cleanup),
|
||||
"node_id": node_id,
|
||||
@@ -901,13 +907,7 @@ struct DeletedProxyNodeCleanup {
|
||||
fn build_admin_proxy_node_detail_payload(
|
||||
node: &aether_data::repository::proxy_nodes::StoredProxyNode,
|
||||
) -> Value {
|
||||
let mut payload = build_admin_proxy_node_payload(node);
|
||||
if node.is_manual {
|
||||
if let Value::Object(object) = &mut payload {
|
||||
object.insert("proxy_password".to_string(), json!(node.proxy_password));
|
||||
}
|
||||
}
|
||||
payload
|
||||
build_admin_proxy_node_payload(node)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -1147,12 +1147,33 @@ async fn test_proxy_node_connectivity(
|
||||
);
|
||||
}
|
||||
};
|
||||
let proxy_url = proxy_url_with_auth(
|
||||
let proxy_password = match state.app().decrypt_proxy_node_password(&node.id).await {
|
||||
Ok(password) => password,
|
||||
Err(_) => {
|
||||
return build_proxy_connectivity_result(
|
||||
&probe_url,
|
||||
PROXY_CONNECTIVITY_TIMEOUT_SECS,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
Some("手动节点密码不可用".to_string()),
|
||||
);
|
||||
}
|
||||
};
|
||||
let Some(proxy_url) = proxy_url_with_auth(
|
||||
&endpoint.proxy_url,
|
||||
node.proxy_username.as_deref(),
|
||||
node.proxy_password.as_deref(),
|
||||
)
|
||||
.unwrap_or(endpoint.proxy_url);
|
||||
proxy_password.as_deref(),
|
||||
) else {
|
||||
return build_proxy_connectivity_result(
|
||||
&probe_url,
|
||||
PROXY_CONNECTIVITY_TIMEOUT_SECS,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
Some("手动节点认证配置不可用".to_string()),
|
||||
);
|
||||
};
|
||||
return test_manual_proxy_connectivity(&proxy_url).await;
|
||||
}
|
||||
|
||||
@@ -1260,24 +1281,17 @@ async fn test_manual_proxy_connectivity_with_probe_url(
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
Some(sanitize_proxy_error(&format_upstream_request_error(&error))),
|
||||
Some(sanitize_proxy_error(&error.to_string())),
|
||||
);
|
||||
}
|
||||
};
|
||||
let mut builder = reqwest::Client::builder()
|
||||
let builder = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.proxy(proxy)
|
||||
.user_agent("aether-gateway/proxy-connectivity");
|
||||
if proxy_url
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.starts_with("https://")
|
||||
{
|
||||
builder = builder.danger_accept_invalid_certs(true);
|
||||
}
|
||||
let client = match builder.build() {
|
||||
Ok(client) => client,
|
||||
Err(error) => {
|
||||
@@ -1306,8 +1320,13 @@ async fn test_manual_proxy_connectivity_with_probe_url(
|
||||
}
|
||||
};
|
||||
let status = response.status();
|
||||
let body = match response.text().await {
|
||||
Ok(body) => body,
|
||||
let body = match aether_http::read_response_bytes_with_limit(
|
||||
response,
|
||||
MAX_PROXY_CONNECTIVITY_RESPONSE_BYTES,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(body) => String::from_utf8_lossy(&body).into_owned(),
|
||||
Err(error) => {
|
||||
return build_proxy_connectivity_result(
|
||||
probe_url,
|
||||
@@ -1315,7 +1334,7 @@ async fn test_manual_proxy_connectivity_with_probe_url(
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
Some(sanitize_proxy_error(&format_upstream_request_error(&error))),
|
||||
Some(sanitize_proxy_error(&error.to_string())),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1424,10 +1443,21 @@ async fn probe_tunnel_proxy_connectivity_via_owner(
|
||||
relay_base_url: &str,
|
||||
owner_instance_id: &str,
|
||||
) -> Result<TunnelConnectivityProbeResult, String> {
|
||||
let owner_url = build_tunnel_owner_relay_url(relay_base_url, node_id)?;
|
||||
let owner_url = crate::tunnel::build_tunnel_owner_relay_url(relay_base_url, node_id)?;
|
||||
let payload = build_tunnel_probe_relay_envelope(probe_url, timeout_secs)?;
|
||||
let relay_auth = state.tunnel.build_relay_auth_headers(
|
||||
owner_instance_id,
|
||||
node_id,
|
||||
true,
|
||||
false,
|
||||
&payload,
|
||||
&[],
|
||||
)?;
|
||||
let started_at = Instant::now();
|
||||
let response = state
|
||||
.client
|
||||
let owner_client =
|
||||
crate::tunnel::owner_forward_client_for_url(&state.owner_forward_client, &owner_url)
|
||||
.await?;
|
||||
let request = owner_client
|
||||
.post(owner_url)
|
||||
.header(
|
||||
http::header::CONTENT_TYPE,
|
||||
@@ -1437,23 +1467,20 @@ async fn probe_tunnel_proxy_connectivity_via_owner(
|
||||
TUNNEL_RELAY_FORWARDED_BY_HEADER,
|
||||
state.tunnel.local_instance_id(),
|
||||
)
|
||||
.header(TUNNEL_RELAY_OWNER_INSTANCE_HEADER, owner_instance_id)
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.body(build_tunnel_probe_relay_envelope(probe_url, timeout_secs)?)
|
||||
.body(payload);
|
||||
let response = relay_auth
|
||||
.apply(request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("owner tunnel relay probe failed: {error}"))?;
|
||||
.map_err(|error| crate::tunnel::owner_forward_request_error(&error))?;
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|error| format!("failed to read owner tunnel relay probe body: {error}"))?;
|
||||
if body.len() > MAX_PROXY_CONNECTIVITY_RESPONSE_BYTES {
|
||||
return Err(format!(
|
||||
"owner tunnel relay probe body exceeds {} bytes",
|
||||
MAX_PROXY_CONNECTIVITY_RESPONSE_BYTES
|
||||
));
|
||||
}
|
||||
let body = aether_http::read_response_bytes_with_limit(
|
||||
response,
|
||||
MAX_PROXY_CONNECTIVITY_RESPONSE_BYTES,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "failed to read owner tunnel relay probe body".to_string())?;
|
||||
|
||||
Ok(TunnelConnectivityProbeResult {
|
||||
status: status.as_u16(),
|
||||
@@ -1489,23 +1516,6 @@ fn build_tunnel_probe_relay_envelope(
|
||||
Ok(envelope)
|
||||
}
|
||||
|
||||
fn build_tunnel_owner_relay_url(relay_base_url: &str, node_id: &str) -> Result<String, String> {
|
||||
let mut url = url::Url::parse(relay_base_url)
|
||||
.map_err(|error| format!("invalid owner relay base url: {error}"))?;
|
||||
{
|
||||
let mut segments = url
|
||||
.path_segments_mut()
|
||||
.map_err(|_| "owner relay base url cannot be a base-less URL".to_string())?;
|
||||
segments.pop_if_empty();
|
||||
segments.push("api");
|
||||
segments.push("internal");
|
||||
segments.push("tunnel");
|
||||
segments.push("relay");
|
||||
segments.push(node_id.trim());
|
||||
}
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
fn validate_register_request(
|
||||
input: ProxyNodeRegisterRequest,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
@@ -1577,6 +1587,7 @@ fn validate_register_request(
|
||||
|
||||
Ok(
|
||||
aether_data::repository::proxy_nodes::ProxyNodeRegistrationMutation {
|
||||
node_id: None,
|
||||
name,
|
||||
ip,
|
||||
port: i32::from(input.port.unwrap_or_default()),
|
||||
@@ -1611,6 +1622,7 @@ fn validate_manual_create_request(
|
||||
|
||||
Ok(
|
||||
aether_data::repository::proxy_nodes::ProxyNodeManualCreateMutation {
|
||||
node_id: None,
|
||||
name: normalize_required_string(&input.name, "name", 100)?,
|
||||
ip: endpoint.node_ip,
|
||||
port: endpoint.node_port,
|
||||
@@ -1665,12 +1677,12 @@ fn validate_proxy_test_url_request(
|
||||
let username = normalize_optional_string(input.username.as_deref(), "username", 255)?;
|
||||
let password = normalize_optional_string(input.password.as_deref(), "password", 500)?;
|
||||
let endpoint = normalize_manual_proxy_endpoint(&input.proxy_url)?;
|
||||
Ok(proxy_url_with_auth(
|
||||
proxy_url_with_auth(
|
||||
&endpoint.proxy_url,
|
||||
username.as_deref(),
|
||||
password.as_deref(),
|
||||
)
|
||||
.unwrap_or(endpoint.proxy_url))
|
||||
.ok_or_else(|| bad_request_response("password 需要非空 username,且代理 URL 必须支持认证"))
|
||||
}
|
||||
|
||||
fn admin_proxy_node_upgrade_action_node_id_from_path(path: &str, suffix: &str) -> Option<String> {
|
||||
@@ -1760,6 +1772,7 @@ async fn dispatch_proxy_node_upgrade_targets(
|
||||
.update_proxy_node_remote_config(
|
||||
&aether_data::repository::proxy_nodes::ProxyNodeRemoteConfigMutation {
|
||||
node_id: node.id.clone(),
|
||||
expected_tunnel_generation: None,
|
||||
node_name: None,
|
||||
allowed_ports: None,
|
||||
log_level: None,
|
||||
@@ -1851,6 +1864,7 @@ fn validate_heartbeat_request(
|
||||
Ok(
|
||||
aether_data::repository::proxy_nodes::ProxyNodeHeartbeatMutation {
|
||||
node_id,
|
||||
expected_tunnel_generation: None,
|
||||
heartbeat_interval: input.heartbeat_interval,
|
||||
active_connections: input.active_connections,
|
||||
total_requests_delta: input.total_requests,
|
||||
@@ -1964,6 +1978,7 @@ fn validate_remote_config_request(
|
||||
Ok(
|
||||
aether_data::repository::proxy_nodes::ProxyNodeRemoteConfigMutation {
|
||||
node_id,
|
||||
expected_tunnel_generation: None,
|
||||
node_name,
|
||||
allowed_ports,
|
||||
log_level,
|
||||
@@ -2036,6 +2051,12 @@ fn parse_manual_proxy_endpoint(
|
||||
if !parsed.username().is_empty() || parsed.password().is_some() {
|
||||
return Err(format!("{field} 不应包含用户名或密码,请使用独立字段"));
|
||||
}
|
||||
if !matches!(parsed.path(), "" | "/") || parsed.query().is_some() || parsed.fragment().is_some()
|
||||
{
|
||||
return Err(format!(
|
||||
"{field} 必须是代理 origin,不能包含 path、query 或 fragment"
|
||||
));
|
||||
}
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.map(str::trim)
|
||||
@@ -2119,13 +2140,74 @@ fn normalize_ip_address(value: &str) -> Result<String, Response<Body>> {
|
||||
}
|
||||
|
||||
fn sanitize_proxy_error(detail: &str) -> String {
|
||||
match detail.split_once("://") {
|
||||
Some((scheme, rest)) => match rest.split_once('@') {
|
||||
Some((_, tail)) => format!("{scheme}://***@{tail}"),
|
||||
None => detail.to_string(),
|
||||
},
|
||||
None => detail.to_string(),
|
||||
const HTTP_STATUS_PREFIX: &str = "代理探测返回 HTTP ";
|
||||
const CLASSIFICATION_PREFIX_BYTES: usize = 4 * 1024;
|
||||
|
||||
if let Some(status) = detail
|
||||
.strip_prefix(HTTP_STATUS_PREFIX)
|
||||
.and_then(|rest| {
|
||||
rest.split(|character: char| !character.is_ascii_digit())
|
||||
.next()
|
||||
})
|
||||
.and_then(|status| status.parse::<u16>().ok())
|
||||
.filter(|status| (100..=599).contains(status))
|
||||
{
|
||||
return format!("{HTTP_STATUS_PREFIX}{status}");
|
||||
}
|
||||
|
||||
let detail = if detail.len() <= CLASSIFICATION_PREFIX_BYTES {
|
||||
detail
|
||||
} else {
|
||||
let mut end = CLASSIFICATION_PREFIX_BYTES;
|
||||
while !detail.is_char_boundary(end) {
|
||||
end = end.saturating_sub(1);
|
||||
}
|
||||
&detail[..end]
|
||||
};
|
||||
let normalized = detail.to_ascii_lowercase();
|
||||
if normalized.contains("timed out") || normalized.contains("timeout") {
|
||||
return "代理探测超时".to_string();
|
||||
}
|
||||
if normalized.contains("overloaded")
|
||||
|| normalized.contains("backpressure")
|
||||
|| normalized.contains("congested")
|
||||
|| normalized.contains("busy")
|
||||
{
|
||||
return "代理探测服务繁忙".to_string();
|
||||
}
|
||||
if normalized.contains("unauthorized")
|
||||
|| normalized.contains("forbidden")
|
||||
|| normalized.contains("authentication")
|
||||
|| normalized.contains("credential")
|
||||
{
|
||||
return "代理认证失败".to_string();
|
||||
}
|
||||
if normalized.contains("too large")
|
||||
|| normalized.contains("body exceeds")
|
||||
|| normalized.contains("response exceeds")
|
||||
{
|
||||
return "代理探测响应过大".to_string();
|
||||
}
|
||||
if normalized.contains("response body")
|
||||
|| normalized.contains("body read")
|
||||
|| normalized.contains("decode")
|
||||
{
|
||||
return "代理探测响应读取失败".to_string();
|
||||
}
|
||||
if normalized.contains("connect")
|
||||
|| normalized.contains("dns")
|
||||
|| normalized.contains("socket")
|
||||
|| normalized.contains("not connected")
|
||||
|| normalized.contains("unavailable")
|
||||
|| normalized.contains("offline")
|
||||
{
|
||||
return "代理连接失败".to_string();
|
||||
}
|
||||
|
||||
// Error strings can originate in reqwest, a remote gateway, or a tunnel
|
||||
// peer. Keep arbitrary URLs, credentials, paths, and control characters
|
||||
// out of the admin response by projecting unknown details to one category.
|
||||
"代理探测失败".to_string()
|
||||
}
|
||||
|
||||
fn proxy_url_with_auth(
|
||||
@@ -2133,13 +2215,22 @@ fn proxy_url_with_auth(
|
||||
username: Option<&str>,
|
||||
password: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let username = username.map(str::trim).filter(|value| !value.is_empty())?;
|
||||
let username = username.filter(|value| !value.is_empty());
|
||||
let password = password.filter(|value| !value.is_empty());
|
||||
let mut parsed = url::Url::parse(proxy_url).ok()?;
|
||||
if !matches!(parsed.scheme(), "http" | "https" | "socks5" | "socks5h")
|
||||
|| parsed.host_str().is_none()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if username.is_none() && password.is_none() {
|
||||
return Some(parsed.to_string());
|
||||
}
|
||||
let username = username.unwrap_or("");
|
||||
if parsed.set_username(username).is_err() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let password = password.map(str::trim).filter(|value| !value.is_empty());
|
||||
if parsed.set_password(password).is_err() {
|
||||
return None;
|
||||
}
|
||||
@@ -2152,27 +2243,18 @@ fn parse_proxy_probe_exit_ip(body: &str) -> Option<String> {
|
||||
if key.trim() != "ip" {
|
||||
return None;
|
||||
}
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(value.to_string())
|
||||
value
|
||||
.trim()
|
||||
.parse::<std::net::IpAddr>()
|
||||
.ok()
|
||||
.map(|ip| ip.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn format_proxy_probe_status_error(status: reqwest::StatusCode, body: &str) -> String {
|
||||
let body = body.trim();
|
||||
if body.is_empty() {
|
||||
return format!("代理探测返回 HTTP {}", status.as_u16());
|
||||
}
|
||||
|
||||
let truncated = if body.chars().count() > 200 {
|
||||
let shortened: String = body.chars().take(200).collect();
|
||||
format!("{shortened}...")
|
||||
} else {
|
||||
body.to_string()
|
||||
};
|
||||
format!("代理探测返回 HTTP {}: {truncated}", status.as_u16())
|
||||
fn format_proxy_probe_status_error(status: reqwest::StatusCode, _body: &str) -> String {
|
||||
// The body is controlled by the probe target and may echo proxy
|
||||
// credentials or contain private upstream diagnostics.
|
||||
format!("代理探测返回 HTTP {}", status.as_u16())
|
||||
}
|
||||
|
||||
fn validate_optional_counter(value: Option<i64>, field: &str) -> Result<(), Response<Body>> {
|
||||
@@ -2238,14 +2320,14 @@ fn hash_proxy_install_management_token(value: &str) -> String {
|
||||
}
|
||||
|
||||
fn proxy_install_management_token_prefix(value: &str) -> Option<String> {
|
||||
(!value.is_empty()).then(|| value[..value.len().min(12)].to_string())
|
||||
(!value.is_empty()).then(|| value.chars().take(12).collect())
|
||||
}
|
||||
|
||||
async fn create_proxy_install_management_token(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
node_name: &str,
|
||||
) -> Result<String, Response<Body>> {
|
||||
) -> Result<(StoredManagementToken, String), Response<Body>> {
|
||||
let Some(principal) = request_context
|
||||
.decision()
|
||||
.and_then(|decision| decision.admin_principal.as_ref())
|
||||
@@ -2257,39 +2339,70 @@ async fn create_proxy_install_management_token(
|
||||
.into_response());
|
||||
};
|
||||
|
||||
let (allowed_ips, expires_at_unix_secs) =
|
||||
if let Some(parent_token_id) = principal.management_token_id.as_deref() {
|
||||
let parent = match state.get_management_token_with_user(parent_token_id).await {
|
||||
Ok(Some(parent)) => parent,
|
||||
Ok(None) => return Err(proxy_install_parent_token_denied_response()),
|
||||
Err(_) => {
|
||||
return Err(proxy_install_internal_error_response(
|
||||
"proxy_install_parent_management_token_lookup",
|
||||
"repository_lookup_failed",
|
||||
))
|
||||
}
|
||||
};
|
||||
let now = chrono::Utc::now().timestamp().max(0) as u64;
|
||||
if parent.token.user_id != principal.user_id
|
||||
|| !parent.token.is_active
|
||||
|| parent
|
||||
.token
|
||||
.expires_at_unix_secs
|
||||
.is_some_and(|expires_at| expires_at <= now)
|
||||
{
|
||||
return Err(proxy_install_parent_token_denied_response());
|
||||
}
|
||||
let permissions = crate::control::management_token_permission_keys_from_value(
|
||||
parent.token.permissions.as_ref(),
|
||||
)
|
||||
.map_err(|_| proxy_install_parent_token_denied_response())?;
|
||||
let Some(decision) = request_context.decision() else {
|
||||
return Err(proxy_install_parent_token_denied_response());
|
||||
};
|
||||
crate::control::validate_management_token_admin_route_permission(
|
||||
request_context.method(),
|
||||
decision,
|
||||
permissions.as_deref(),
|
||||
)
|
||||
.map_err(|_| proxy_install_parent_token_denied_response())?;
|
||||
(
|
||||
parent.token.allowed_ips.clone(),
|
||||
parent.token.expires_at_unix_secs,
|
||||
)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
let user = match state.app().find_user_auth_by_id(&principal.user_id).await {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
return Err((
|
||||
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "detail": format!("admin user lookup failed: {err:?}") })),
|
||||
)
|
||||
.into_response())
|
||||
Err(_) => {
|
||||
return Err(proxy_install_internal_error_response(
|
||||
"proxy_install_admin_user_lookup",
|
||||
"repository_lookup_failed",
|
||||
))
|
||||
}
|
||||
};
|
||||
let user = user
|
||||
.map(|user| {
|
||||
StoredManagementTokenUserSummary::new(
|
||||
user.id,
|
||||
user.email,
|
||||
user.username,
|
||||
user.role,
|
||||
let Some(user) = user else {
|
||||
return Err(proxy_install_parent_token_denied_response());
|
||||
};
|
||||
if !user.is_active || user.is_deleted || !user.role.eq_ignore_ascii_case("admin") {
|
||||
return Err(proxy_install_parent_token_denied_response());
|
||||
}
|
||||
let user = StoredManagementTokenUserSummary::new(user.id, user.email, user.username, user.role)
|
||||
.map_err(|_| {
|
||||
proxy_install_internal_error_response(
|
||||
"proxy_install_management_token_user_summary_build",
|
||||
"invalid_user_summary",
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
StoredManagementTokenUserSummary::new(
|
||||
principal.user_id.clone(),
|
||||
None,
|
||||
principal.user_id.clone(),
|
||||
principal.user_role.clone(),
|
||||
)
|
||||
})
|
||||
.map_err(|err| {
|
||||
(
|
||||
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "detail": format!("management token user summary build failed: {err:?}") })),
|
||||
)
|
||||
.into_response()
|
||||
})?;
|
||||
|
||||
let raw_token = generate_proxy_install_management_token_plaintext();
|
||||
@@ -2307,14 +2420,15 @@ async fn create_proxy_install_management_token(
|
||||
token_prefix: proxy_install_management_token_prefix(&raw_token),
|
||||
name: format!("aether-tunnel {node_name} {short_id}"),
|
||||
description: Some("Created by proxy node one-click installer".to_string()),
|
||||
allowed_ips: None,
|
||||
allowed_ips,
|
||||
permissions: Some(json!(["admin:proxy_nodes:write"])),
|
||||
expires_at_unix_secs: None,
|
||||
is_active: true,
|
||||
expires_at_unix_secs,
|
||||
// The bearer secret is not usable until its one-time install session is consumed.
|
||||
is_active: false,
|
||||
};
|
||||
|
||||
match state.app().create_management_token(&record).await {
|
||||
Ok(LocalMutationOutcome::Applied(_)) => Ok(raw_token),
|
||||
Ok(LocalMutationOutcome::Applied(stored)) => Ok((stored, raw_token)),
|
||||
Ok(LocalMutationOutcome::Invalid(detail)) => Err(bad_request_response(detail)),
|
||||
Ok(LocalMutationOutcome::Unavailable) => {
|
||||
Err(build_admin_proxy_nodes_data_unavailable_response())
|
||||
@@ -2324,14 +2438,38 @@ async fn create_proxy_install_management_token(
|
||||
Json(json!({ "detail": "管理员不存在" })),
|
||||
)
|
||||
.into_response()),
|
||||
Err(err) => Err((
|
||||
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "detail": format!("management token create failed: {err:?}") })),
|
||||
)
|
||||
.into_response()),
|
||||
Err(_) => Err(proxy_install_internal_error_response(
|
||||
"proxy_install_management_token_create",
|
||||
"repository_write_failed",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn proxy_install_internal_error_response(
|
||||
operation: &'static str,
|
||||
error_category: &'static str,
|
||||
) -> Response<Body> {
|
||||
warn!(
|
||||
event_name = "proxy_install_internal_error",
|
||||
operation, error_category, "proxy install operation failed"
|
||||
);
|
||||
(
|
||||
http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "detail": PROXY_INSTALL_INTERNAL_ERROR_DETAIL })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn proxy_install_parent_token_denied_response() -> Response<Body> {
|
||||
(
|
||||
http::StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"detail": "parent management token is no longer authorized to create install sessions"
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn parse_proxy_node_event_query(
|
||||
query: Option<&str>,
|
||||
) -> Result<ProxyNodeEventQuery, Response<Body>> {
|
||||
@@ -2419,3 +2557,132 @@ fn bad_request_response(detail: impl Into<String>) -> Response<Body> {
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn proxy_auth_url_construction_never_falls_back_to_unauthenticated() {
|
||||
assert_eq!(
|
||||
proxy_url_with_auth("http://proxy.example:8080", None, None).as_deref(),
|
||||
Some("http://proxy.example:8080/")
|
||||
);
|
||||
assert_eq!(
|
||||
proxy_url_with_auth("http://proxy.example:8080", None, Some("secret")).as_deref(),
|
||||
Some("http://:[email protected]:8080/")
|
||||
);
|
||||
assert!(proxy_url_with_auth("not a proxy url", Some("alice"), Some("secret")).is_none());
|
||||
assert!(proxy_url_with_auth("mailto:[email protected]", Some("alice"), None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_proxy_endpoint_accepts_only_an_origin_without_ambiguous_components() {
|
||||
for value in [
|
||||
"http://proxy.example:8080/path",
|
||||
"http://proxy.example:8080?token=secret",
|
||||
"http://proxy.example:8080#fragment",
|
||||
"http://alice:[email protected]:8080",
|
||||
"file:///tmp/proxy",
|
||||
] {
|
||||
assert!(
|
||||
parse_manual_proxy_endpoint(value, "proxy_url").is_err(),
|
||||
"proxy URL should be rejected: {value}"
|
||||
);
|
||||
}
|
||||
|
||||
for value in [
|
||||
"http://proxy.example:8080",
|
||||
"https://proxy.example:8443/",
|
||||
"socks5://proxy.example:1080",
|
||||
"socks5h://proxy.example:1080",
|
||||
] {
|
||||
assert!(
|
||||
parse_manual_proxy_endpoint(value, "proxy_url").is_ok(),
|
||||
"proxy origin should be accepted: {value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_connectivity_errors_are_projected_without_sensitive_details() {
|
||||
let details = [
|
||||
"request failed for https://alice:[email protected]/probe?access_token=query-secret",
|
||||
"Bearer bearer-secret\r\nx-injected: true",
|
||||
"failed to open /private/var/proxy-secret.pem",
|
||||
];
|
||||
|
||||
for detail in details {
|
||||
let projected = sanitize_proxy_error(detail);
|
||||
for secret in [
|
||||
"alice",
|
||||
"proxy-secret",
|
||||
"10.0.0.8",
|
||||
"query-secret",
|
||||
"bearer-secret",
|
||||
"x-injected",
|
||||
"/private/var",
|
||||
] {
|
||||
assert!(!projected.contains(secret), "leaked {secret}: {projected}");
|
||||
}
|
||||
assert!(!projected.contains(['\r', '\n']));
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
sanitize_proxy_error("connection timed out for https://secret.internal"),
|
||||
"代理探测超时"
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_proxy_error("DNS connect error for http://10.0.0.2"),
|
||||
"代理连接失败"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_probe_status_error_does_not_echo_untrusted_body() {
|
||||
let detail = format_proxy_probe_status_error(
|
||||
reqwest::StatusCode::BAD_GATEWAY,
|
||||
"Bearer upstream-secret at http://10.0.0.9/private?token=query-secret",
|
||||
);
|
||||
|
||||
assert_eq!(detail, "代理探测返回 HTTP 502");
|
||||
assert_eq!(sanitize_proxy_error(&detail), detail);
|
||||
assert!(!detail.contains("upstream-secret"));
|
||||
assert!(!detail.contains("10.0.0.9"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_probe_exit_ip_only_accepts_ip_addresses() {
|
||||
assert_eq!(
|
||||
parse_proxy_probe_exit_ip("fl=1\nip=2001:db8::1\nts=2"),
|
||||
Some("2001:db8::1".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_proxy_probe_exit_ip("ip=Bearer upstream-secret\nx=1"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_install_internal_error_response_hides_internal_details() {
|
||||
let response = proxy_install_internal_error_response(
|
||||
"secret-bearing operation https://internal.example/token",
|
||||
"Bearer super-secret",
|
||||
);
|
||||
|
||||
assert_eq!(response.status(), http::StatusCode::INTERNAL_SERVER_ERROR);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("internal error response body should be readable");
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&body).expect("internal error response should be JSON");
|
||||
|
||||
assert_eq!(
|
||||
payload,
|
||||
json!({ "detail": PROXY_INSTALL_INTERNAL_ERROR_DETAIL })
|
||||
);
|
||||
let body = String::from_utf8_lossy(&body);
|
||||
assert!(!body.contains("internal.example"));
|
||||
assert!(!body.contains("super-secret"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ pub(crate) async fn maybe_build_local_admin_system_response(
|
||||
&request.state(),
|
||||
&request.request_context(),
|
||||
request.request_headers(),
|
||||
request.remote_addr(),
|
||||
request.request_body(),
|
||||
)
|
||||
.await?
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use crate::handlers::admin::model::ADMIN_EXTERNAL_MODELS_PROXY_NODE_CONFIG_KEY;
|
||||
use crate::handlers::admin::request::AdminAppState;
|
||||
use crate::handlers::shared::unix_secs_to_rfc3339;
|
||||
use crate::handlers::shared::{
|
||||
bark_device_key_binding, encrypt_bark_device_key, encrypt_smtp_password, smtp_password_binding,
|
||||
system_config_bool, system_config_string, unix_secs_to_rfc3339,
|
||||
};
|
||||
use crate::GatewayError;
|
||||
use aether_admin::system::{
|
||||
admin_system_config_default_value as admin_system_config_default_value_pure,
|
||||
@@ -13,7 +16,6 @@ use aether_admin::system::{
|
||||
normalize_admin_system_config_key as normalize_admin_system_config_key_pure,
|
||||
parse_admin_system_config_update,
|
||||
};
|
||||
use aether_crypto::encrypt_python_fernet_plaintext;
|
||||
use axum::body::Bytes;
|
||||
use axum::http;
|
||||
use serde_json::json;
|
||||
@@ -125,18 +127,70 @@ pub(crate) async fn apply_admin_system_config_update(
|
||||
if is_sensitive_admin_system_config_key(&normalized_key)
|
||||
&& value.as_str().is_some_and(|raw| !raw.is_empty())
|
||||
{
|
||||
let Some(encryption_key) = state
|
||||
.encryption_key()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
else {
|
||||
let plaintext = value
|
||||
.as_str()
|
||||
.expect("sensitive config value was a non-empty string");
|
||||
let encrypted = if normalized_key.eq_ignore_ascii_case("smtp_password") {
|
||||
let host = state
|
||||
.read_system_config_json_value("smtp_host")
|
||||
.await?
|
||||
.and_then(|value| system_config_string(Some(&value)));
|
||||
let port = state
|
||||
.read_system_config_json_value("smtp_port")
|
||||
.await?
|
||||
.map(|value| crate::email_delivery::system_config_u16(Some(&value), 587))
|
||||
.unwrap_or(587);
|
||||
let user = state
|
||||
.read_system_config_json_value("smtp_user")
|
||||
.await?
|
||||
.and_then(|value| system_config_string(Some(&value)));
|
||||
let use_tls = state
|
||||
.read_system_config_json_value("smtp_use_tls")
|
||||
.await?
|
||||
.map(|value| system_config_bool(Some(&value), true))
|
||||
.unwrap_or(true);
|
||||
let use_ssl = state
|
||||
.read_system_config_json_value("smtp_use_ssl")
|
||||
.await?
|
||||
.map(|value| system_config_bool(Some(&value), false))
|
||||
.unwrap_or(false);
|
||||
let Some(binding) = host.as_deref().and_then(|host| {
|
||||
smtp_password_binding(host, port, user.as_deref(), use_tls, use_ssl)
|
||||
}) else {
|
||||
return Ok(Err((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
json!({
|
||||
"detail": "保存 SMTP 密码前必须先配置有效的 smtp_host 和 smtp_user"
|
||||
}),
|
||||
)));
|
||||
};
|
||||
encrypt_smtp_password(state.app(), &binding, plaintext)
|
||||
} else if normalized_key.eq_ignore_ascii_case("module.bark_push.device_key") {
|
||||
let server_url = state
|
||||
.read_system_config_json_value("module.bark_push.server_url")
|
||||
.await?
|
||||
.and_then(|value| system_config_string(Some(&value)))
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| "https://api.day.app".to_string());
|
||||
let Some(binding) = bark_device_key_binding(&server_url) else {
|
||||
return Ok(Err((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
json!({
|
||||
"detail": "保存 Bark Device Key 前必须先配置有效的 module.bark_push.server_url"
|
||||
}),
|
||||
)));
|
||||
};
|
||||
encrypt_bark_device_key(state.app(), &binding, plaintext)
|
||||
} else {
|
||||
state.encrypt_system_config_secret(&normalized_key, plaintext)
|
||||
};
|
||||
let Some(encrypted) = encrypted else {
|
||||
return Ok(Err((
|
||||
http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
json!({ "detail": "系统配置写入需要可用的加密密钥" }),
|
||||
)));
|
||||
};
|
||||
let plaintext = value.as_str().unwrap();
|
||||
value = json!(encrypt_python_fernet_plaintext(encryption_key, plaintext)
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?);
|
||||
value = json!(encrypted);
|
||||
}
|
||||
|
||||
let updated = state
|
||||
|
||||
@@ -3,6 +3,7 @@ mod support;
|
||||
|
||||
pub(crate) use self::providers::build_admin_system_export_providers_payload;
|
||||
pub(crate) use self::support::{
|
||||
decrypt_admin_system_export_secret, ADMIN_SYSTEM_CONFIG_EXPORT_VERSION,
|
||||
ADMIN_SYSTEM_EXPORT_PAGE_LIMIT,
|
||||
decrypt_admin_system_export_secret, project_admin_system_export_json,
|
||||
project_admin_system_export_optional_url, project_admin_system_export_url,
|
||||
ADMIN_SYSTEM_CONFIG_EXPORT_VERSION, ADMIN_SYSTEM_EXPORT_PAGE_LIMIT,
|
||||
};
|
||||
|
||||
@@ -1,22 +1,44 @@
|
||||
use super::support::{
|
||||
collect_admin_system_export_provider_endpoint_formats,
|
||||
decrypt_admin_system_export_provider_config, decrypt_admin_system_export_secret,
|
||||
resolve_admin_system_export_key_api_formats, ADMIN_SYSTEM_EXPORT_PAGE_LIMIT,
|
||||
collect_admin_system_export_provider_endpoint_formats, project_admin_system_export_body_rules,
|
||||
project_admin_system_export_header_rules, project_admin_system_export_json,
|
||||
project_admin_system_export_optional_url, project_admin_system_export_provider_config,
|
||||
project_admin_system_export_proxy, project_admin_system_export_url,
|
||||
resolve_admin_system_export_key_api_formats,
|
||||
};
|
||||
use crate::handlers::admin::admin_provider_ops_credential_snapshot;
|
||||
use crate::handlers::admin::request::{
|
||||
AdminAppState, SystemExportMode, ADMIN_SYSTEM_EXPORT_CREDENTIALS_NOT_EXPORTED,
|
||||
};
|
||||
use crate::handlers::admin::request::AdminAppState;
|
||||
use crate::GatewayError;
|
||||
use aether_admin::system::{
|
||||
AdminSystemConfigEndpoint, AdminSystemConfigProvider, AdminSystemConfigProviderKey,
|
||||
AdminSystemConfigProviderModel,
|
||||
};
|
||||
use aether_data_contracts::repository::global_models::AdminProviderModelListQuery;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub(crate) async fn build_admin_system_export_providers_payload(
|
||||
state: &AdminAppState<'_>,
|
||||
global_model_name_by_id: &BTreeMap<String, String>,
|
||||
mode: SystemExportMode,
|
||||
) -> Result<Vec<AdminSystemConfigProvider>, GatewayError> {
|
||||
let providers = state.list_provider_catalog_providers(false).await?;
|
||||
let mut providers = state.list_provider_catalog_providers(false).await?;
|
||||
let mut provider_ops_credentials = BTreeMap::new();
|
||||
if mode.credentials_are_exported() {
|
||||
for provider in &mut providers {
|
||||
let has_provider_ops = provider
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.and_then(|config| config.get("provider_ops"))
|
||||
.is_some();
|
||||
if !has_provider_ops {
|
||||
continue;
|
||||
}
|
||||
let snapshot = admin_provider_ops_credential_snapshot(state, provider).await?;
|
||||
provider_ops_credentials.insert(provider.id.clone(), snapshot.credentials);
|
||||
*provider = snapshot.provider;
|
||||
}
|
||||
}
|
||||
let provider_ids = providers
|
||||
.iter()
|
||||
.map(|provider| provider.id.clone())
|
||||
@@ -46,188 +68,232 @@ pub(crate) async fn build_admin_system_export_providers_payload(
|
||||
let mut provider_models_by_provider = BTreeMap::<String, Vec<_>>::new();
|
||||
for provider in &providers {
|
||||
let models = state
|
||||
.list_admin_provider_models(&AdminProviderModelListQuery {
|
||||
provider_id: provider.id.clone(),
|
||||
is_active: None,
|
||||
offset: 0,
|
||||
limit: ADMIN_SYSTEM_EXPORT_PAGE_LIMIT,
|
||||
})
|
||||
.list_all_admin_provider_models_for_system_transfer(&provider.id)
|
||||
.await?;
|
||||
provider_models_by_provider.insert(provider.id.clone(), models);
|
||||
}
|
||||
|
||||
Ok(providers
|
||||
providers
|
||||
.iter()
|
||||
.map(|provider| {
|
||||
let endpoints = endpoints_by_provider
|
||||
.remove(&provider.id)
|
||||
.unwrap_or_default();
|
||||
let provider_endpoint_formats =
|
||||
collect_admin_system_export_provider_endpoint_formats(&endpoints);
|
||||
let endpoints_data = endpoints
|
||||
.iter()
|
||||
.map(|endpoint| AdminSystemConfigEndpoint {
|
||||
api_format: endpoint.api_format.clone(),
|
||||
base_url: endpoint.base_url.clone(),
|
||||
header_rules: endpoint.header_rules.clone(),
|
||||
body_rules: endpoint.body_rules.clone(),
|
||||
max_retries: endpoint.max_retries,
|
||||
is_active: endpoint.is_active,
|
||||
custom_path: endpoint.custom_path.clone(),
|
||||
config: endpoint.config.clone(),
|
||||
format_acceptance_config: endpoint.format_acceptance_config.clone(),
|
||||
proxy: endpoint.proxy.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
.map(
|
||||
|provider| -> Result<AdminSystemConfigProvider, GatewayError> {
|
||||
let endpoints = endpoints_by_provider
|
||||
.remove(&provider.id)
|
||||
.unwrap_or_default();
|
||||
let provider_endpoint_formats =
|
||||
collect_admin_system_export_provider_endpoint_formats(&endpoints);
|
||||
let endpoints_data = endpoints
|
||||
.iter()
|
||||
.map(|endpoint| AdminSystemConfigEndpoint {
|
||||
api_format: endpoint.api_format.clone(),
|
||||
base_url: project_admin_system_export_url(mode, &endpoint.base_url),
|
||||
header_rules: project_admin_system_export_header_rules(
|
||||
mode,
|
||||
endpoint.header_rules.as_ref(),
|
||||
),
|
||||
body_rules: project_admin_system_export_body_rules(
|
||||
mode,
|
||||
endpoint.body_rules.as_ref(),
|
||||
),
|
||||
max_retries: endpoint.max_retries,
|
||||
is_active: endpoint.is_active,
|
||||
custom_path: endpoint.custom_path.clone(),
|
||||
config: project_admin_system_export_json(mode, endpoint.config.as_ref()),
|
||||
format_acceptance_config: project_admin_system_export_json(
|
||||
mode,
|
||||
endpoint.format_acceptance_config.as_ref(),
|
||||
),
|
||||
proxy: project_admin_system_export_proxy(mode, endpoint.proxy.as_ref()),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut keys = keys_by_provider.remove(&provider.id).unwrap_or_default();
|
||||
keys.sort_by(|left, right| {
|
||||
left.internal_priority
|
||||
.cmp(&right.internal_priority)
|
||||
.then(
|
||||
left.created_at_unix_ms
|
||||
.unwrap_or(0)
|
||||
.cmp(&right.created_at_unix_ms.unwrap_or(0)),
|
||||
let mut keys = keys_by_provider.remove(&provider.id).unwrap_or_default();
|
||||
keys.sort_by(|left, right| {
|
||||
left.internal_priority
|
||||
.cmp(&right.internal_priority)
|
||||
.then(
|
||||
left.created_at_unix_ms
|
||||
.unwrap_or(0)
|
||||
.cmp(&right.created_at_unix_ms.unwrap_or(0)),
|
||||
)
|
||||
.then(left.id.cmp(&right.id))
|
||||
});
|
||||
let keys_data = keys
|
||||
.iter()
|
||||
.map(
|
||||
|key| -> Result<AdminSystemConfigProviderKey, GatewayError> {
|
||||
let api_formats = resolve_admin_system_export_key_api_formats(
|
||||
key.api_formats.as_ref(),
|
||||
&provider_endpoint_formats,
|
||||
);
|
||||
let auth_config = if mode.credentials_are_exported() {
|
||||
state
|
||||
.app()
|
||||
.decrypt_provider_catalog_key_auth_config(key)?
|
||||
.map(serde_json::Value::String)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let api_key = if mode.credentials_are_exported() {
|
||||
state.app().decrypt_provider_catalog_key_api_key(key)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(AdminSystemConfigProviderKey {
|
||||
api_key,
|
||||
auth_type: Some(key.auth_type.clone()),
|
||||
auth_config,
|
||||
name: Some(key.name.clone()),
|
||||
note: key.note.clone(),
|
||||
api_formats: Some(api_formats.clone()),
|
||||
supported_endpoints: Some(api_formats),
|
||||
rate_multipliers: project_admin_system_export_json(
|
||||
mode,
|
||||
key.rate_multipliers.as_ref(),
|
||||
),
|
||||
internal_priority: Some(key.internal_priority),
|
||||
global_priority_by_format: project_admin_system_export_json(
|
||||
mode,
|
||||
key.global_priority_by_format.as_ref(),
|
||||
),
|
||||
auth_type_by_format: project_admin_system_export_json(
|
||||
mode,
|
||||
key.auth_type_by_format.as_ref(),
|
||||
),
|
||||
allow_auth_channel_mismatch_formats: key
|
||||
.allow_auth_channel_mismatch_formats
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
}),
|
||||
rpm_limit: key.rpm_limit,
|
||||
allowed_models: key.allowed_models.as_ref().and_then(|value| {
|
||||
value.as_array().map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
}),
|
||||
capabilities: project_admin_system_export_json(
|
||||
mode,
|
||||
key.capabilities.as_ref(),
|
||||
),
|
||||
cache_ttl_minutes: Some(key.cache_ttl_minutes),
|
||||
max_probe_interval_minutes: Some(key.max_probe_interval_minutes),
|
||||
auto_fetch_models: Some(key.auto_fetch_models),
|
||||
locked_models: key.locked_models.as_ref().and_then(|value| {
|
||||
value.as_array().map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
}),
|
||||
model_include_patterns: key
|
||||
.model_include_patterns
|
||||
.as_ref()
|
||||
.and_then(|value| {
|
||||
value.as_array().map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
}),
|
||||
model_exclude_patterns: key
|
||||
.model_exclude_patterns
|
||||
.as_ref()
|
||||
.and_then(|value| {
|
||||
value.as_array().map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
}),
|
||||
is_active: mode.preserves_active_state() && key.is_active,
|
||||
proxy: project_admin_system_export_proxy(mode, key.proxy.as_ref()),
|
||||
fingerprint: project_admin_system_export_json(
|
||||
mode,
|
||||
key.fingerprint.as_ref(),
|
||||
),
|
||||
credential_state: (!mode.credentials_are_exported()).then(|| {
|
||||
ADMIN_SYSTEM_EXPORT_CREDENTIALS_NOT_EXPORTED.to_string()
|
||||
}),
|
||||
})
|
||||
},
|
||||
)
|
||||
.then(left.id.cmp(&right.id))
|
||||
});
|
||||
let keys_data = keys
|
||||
.iter()
|
||||
.map(|key| {
|
||||
let api_formats = resolve_admin_system_export_key_api_formats(
|
||||
key.api_formats.as_ref(),
|
||||
&provider_endpoint_formats,
|
||||
);
|
||||
let auth_config = key
|
||||
.encrypted_auth_config
|
||||
.as_deref()
|
||||
.and_then(|ciphertext| {
|
||||
decrypt_admin_system_export_secret(state, ciphertext)
|
||||
})
|
||||
.map(serde_json::Value::String);
|
||||
AdminSystemConfigProviderKey {
|
||||
api_key: key.encrypted_api_key.as_deref().map(|ciphertext| {
|
||||
decrypt_admin_system_export_secret(state, ciphertext)
|
||||
.unwrap_or_default()
|
||||
}),
|
||||
auth_type: Some(key.auth_type.clone()),
|
||||
auth_config,
|
||||
name: Some(key.name.clone()),
|
||||
note: key.note.clone(),
|
||||
api_formats: Some(api_formats.clone()),
|
||||
supported_endpoints: Some(api_formats),
|
||||
rate_multipliers: key.rate_multipliers.clone(),
|
||||
internal_priority: Some(key.internal_priority),
|
||||
global_priority_by_format: key.global_priority_by_format.clone(),
|
||||
auth_type_by_format: key.auth_type_by_format.clone(),
|
||||
allow_auth_channel_mismatch_formats: key
|
||||
.allow_auth_channel_mismatch_formats
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
}),
|
||||
rpm_limit: key.rpm_limit,
|
||||
allowed_models: key.allowed_models.as_ref().and_then(|value| {
|
||||
value.as_array().map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
}),
|
||||
capabilities: key.capabilities.clone(),
|
||||
cache_ttl_minutes: Some(key.cache_ttl_minutes),
|
||||
max_probe_interval_minutes: Some(key.max_probe_interval_minutes),
|
||||
auto_fetch_models: Some(key.auto_fetch_models),
|
||||
locked_models: key.locked_models.as_ref().and_then(|value| {
|
||||
value.as_array().map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
}),
|
||||
model_include_patterns: key.model_include_patterns.as_ref().and_then(
|
||||
|value| {
|
||||
value.as_array().map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
},
|
||||
),
|
||||
model_exclude_patterns: key.model_exclude_patterns.as_ref().and_then(
|
||||
|value| {
|
||||
value.as_array().map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
},
|
||||
),
|
||||
is_active: key.is_active,
|
||||
proxy: key.proxy.clone(),
|
||||
fingerprint: key.fingerprint.clone(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
.collect::<Result<Vec<_>, GatewayError>>()?;
|
||||
|
||||
let models_data = provider_models_by_provider
|
||||
.remove(&provider.id)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|model| AdminSystemConfigProviderModel {
|
||||
global_model_name: global_model_name_by_id.get(&model.global_model_id).cloned(),
|
||||
provider_model_name: model.provider_model_name,
|
||||
provider_model_mappings: model.provider_model_mappings,
|
||||
price_per_request: model.price_per_request,
|
||||
tiered_pricing: model.tiered_pricing,
|
||||
supports_vision: model.supports_vision,
|
||||
supports_function_calling: model.supports_function_calling,
|
||||
supports_streaming: model.supports_streaming,
|
||||
supports_extended_thinking: model.supports_extended_thinking,
|
||||
supports_image_generation: model.supports_image_generation,
|
||||
is_active: model.is_active,
|
||||
config: model.config,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let models_data = provider_models_by_provider
|
||||
.remove(&provider.id)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|model| AdminSystemConfigProviderModel {
|
||||
global_model_name: global_model_name_by_id
|
||||
.get(&model.global_model_id)
|
||||
.cloned(),
|
||||
provider_model_name: model.provider_model_name,
|
||||
provider_model_mappings: project_admin_system_export_json(
|
||||
mode,
|
||||
model.provider_model_mappings.as_ref(),
|
||||
),
|
||||
price_per_request: model.price_per_request,
|
||||
tiered_pricing: project_admin_system_export_json(
|
||||
mode,
|
||||
model.tiered_pricing.as_ref(),
|
||||
),
|
||||
supports_vision: model.supports_vision,
|
||||
supports_function_calling: model.supports_function_calling,
|
||||
supports_streaming: model.supports_streaming,
|
||||
supports_extended_thinking: model.supports_extended_thinking,
|
||||
supports_image_generation: model.supports_image_generation,
|
||||
is_active: model.is_active,
|
||||
config: project_admin_system_export_json(mode, model.config.as_ref()),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
AdminSystemConfigProvider {
|
||||
name: provider.name.clone(),
|
||||
description: provider.description.clone(),
|
||||
website: provider.website.clone(),
|
||||
provider_type: Some(provider.provider_type.clone()),
|
||||
billing_type: provider.billing_type.clone(),
|
||||
monthly_quota_usd: provider.monthly_quota_usd,
|
||||
quota_reset_day: provider.quota_reset_day,
|
||||
provider_priority: Some(provider.provider_priority),
|
||||
keep_priority_on_conversion: Some(provider.keep_priority_on_conversion),
|
||||
enable_format_conversion: Some(provider.enable_format_conversion),
|
||||
is_active: provider.is_active,
|
||||
concurrent_limit: provider.concurrent_limit,
|
||||
max_retries: provider.max_retries,
|
||||
stream_first_byte_timeout: provider.stream_first_byte_timeout_secs,
|
||||
request_timeout: provider.request_timeout_secs,
|
||||
proxy: provider.proxy.clone(),
|
||||
config: decrypt_admin_system_export_provider_config(
|
||||
state,
|
||||
provider.config.as_ref(),
|
||||
),
|
||||
endpoints: endpoints_data,
|
||||
api_keys: keys_data,
|
||||
models: models_data,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>())
|
||||
Ok(AdminSystemConfigProvider {
|
||||
name: provider.name.clone(),
|
||||
description: provider.description.clone(),
|
||||
website: project_admin_system_export_optional_url(
|
||||
mode,
|
||||
provider.website.as_deref(),
|
||||
),
|
||||
provider_type: Some(provider.provider_type.clone()),
|
||||
billing_type: provider.billing_type.clone(),
|
||||
monthly_quota_usd: provider.monthly_quota_usd,
|
||||
quota_reset_day: provider.quota_reset_day,
|
||||
provider_priority: Some(provider.provider_priority),
|
||||
keep_priority_on_conversion: Some(provider.keep_priority_on_conversion),
|
||||
enable_format_conversion: Some(provider.enable_format_conversion),
|
||||
is_active: provider.is_active,
|
||||
concurrent_limit: provider.concurrent_limit,
|
||||
max_retries: provider.max_retries,
|
||||
stream_first_byte_timeout: provider.stream_first_byte_timeout_secs,
|
||||
request_timeout: provider.request_timeout_secs,
|
||||
proxy: project_admin_system_export_proxy(mode, provider.proxy.as_ref()),
|
||||
config: project_admin_system_export_provider_config(
|
||||
state,
|
||||
mode,
|
||||
provider.config.as_ref(),
|
||||
provider_ops_credentials.get(&provider.id),
|
||||
)?,
|
||||
endpoints: endpoints_data,
|
||||
api_keys: keys_data,
|
||||
models: models_data,
|
||||
})
|
||||
},
|
||||
)
|
||||
.collect::<Result<Vec<_>, GatewayError>>()
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
use super::super::configs::is_sensitive_admin_system_config_key;
|
||||
use crate::api::ai::admin_endpoint_signature_parts;
|
||||
use crate::handlers::admin::request::AdminAppState;
|
||||
use crate::handlers::shared::decrypt_catalog_secret_with_fallbacks;
|
||||
use crate::handlers::admin::request::{AdminAppState, SystemExportMode};
|
||||
use crate::handlers::shared::{
|
||||
decrypt_catalog_secret_with_fallbacks, PROVIDER_OPS_PERSISTENT_SECRET_FIELDS,
|
||||
PROVIDER_OPS_TRANSIENT_METADATA_FIELDS, PROVIDER_OPS_TRANSIENT_SECRET_FIELDS,
|
||||
};
|
||||
use aether_admin::provider::redaction::{
|
||||
admin_secret_safe_body_rules, admin_secret_safe_header_rules, admin_secret_safe_json,
|
||||
admin_secret_safe_proxy, admin_secret_safe_url,
|
||||
};
|
||||
pub(crate) use aether_admin::system::ADMIN_SYSTEM_CONFIG_EXPORT_VERSION;
|
||||
use aether_admin::system::ADMIN_SYSTEM_PROVIDER_OPS_SENSITIVE_CREDENTIAL_FIELDS;
|
||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogEndpoint;
|
||||
|
||||
pub(crate) const ADMIN_SYSTEM_EXPORT_PAGE_LIMIT: usize = 10_000;
|
||||
@@ -47,11 +53,18 @@ pub(super) fn collect_admin_system_export_provider_endpoint_formats(
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn decrypt_admin_system_export_provider_config(
|
||||
state: &AdminAppState<'_>,
|
||||
pub(super) fn project_admin_system_export_provider_config(
|
||||
_state: &AdminAppState<'_>,
|
||||
mode: SystemExportMode,
|
||||
config: Option<&serde_json::Value>,
|
||||
) -> Option<serde_json::Value> {
|
||||
let mut decrypted = config.cloned()?;
|
||||
provider_ops_plaintext_credentials: Option<&serde_json::Map<String, serde_json::Value>>,
|
||||
) -> Result<Option<serde_json::Value>, crate::GatewayError> {
|
||||
if !mode.credentials_are_exported() {
|
||||
return Ok(project_admin_system_export_json(mode, config));
|
||||
}
|
||||
let Some(mut decrypted) = config.cloned() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(credentials) = decrypted
|
||||
.get_mut("provider_ops")
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
@@ -60,17 +73,96 @@ pub(super) fn decrypt_admin_system_export_provider_config(
|
||||
.and_then(|connector| connector.get_mut("credentials"))
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
else {
|
||||
return Some(decrypted);
|
||||
return Ok(Some(decrypted));
|
||||
};
|
||||
|
||||
for field in ADMIN_SYSTEM_PROVIDER_OPS_SENSITIVE_CREDENTIAL_FIELDS {
|
||||
let Some(serde_json::Value::String(ciphertext)) = credentials.get(*field).cloned() else {
|
||||
continue;
|
||||
};
|
||||
if let Some(plaintext) = decrypt_admin_system_export_secret(state, &ciphertext) {
|
||||
credentials.insert((*field).to_string(), serde_json::Value::String(plaintext));
|
||||
for field in PROVIDER_OPS_TRANSIENT_SECRET_FIELDS
|
||||
.iter()
|
||||
.chain(PROVIDER_OPS_TRANSIENT_METADATA_FIELDS)
|
||||
{
|
||||
credentials.remove(*field);
|
||||
}
|
||||
let plaintext_credentials = provider_ops_plaintext_credentials.ok_or_else(|| {
|
||||
crate::GatewayError::Internal(
|
||||
"RecoveryBackup 缺少已认证的 Provider Ops 凭据快照".to_string(),
|
||||
)
|
||||
})?;
|
||||
for field in PROVIDER_OPS_PERSISTENT_SECRET_FIELDS {
|
||||
if let Some(plaintext) = plaintext_credentials.get(*field) {
|
||||
credentials.insert((*field).to_string(), plaintext.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Some(decrypted)
|
||||
Ok(Some(decrypted))
|
||||
}
|
||||
|
||||
pub(crate) fn project_admin_system_export_json(
|
||||
mode: SystemExportMode,
|
||||
value: Option<&serde_json::Value>,
|
||||
) -> Option<serde_json::Value> {
|
||||
value.map(|value| {
|
||||
if mode.credentials_are_exported() {
|
||||
value.clone()
|
||||
} else {
|
||||
admin_secret_safe_json(Some(value))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn project_admin_system_export_header_rules(
|
||||
mode: SystemExportMode,
|
||||
value: Option<&serde_json::Value>,
|
||||
) -> Option<serde_json::Value> {
|
||||
value.map(|value| {
|
||||
if mode.credentials_are_exported() {
|
||||
value.clone()
|
||||
} else {
|
||||
admin_secret_safe_header_rules(Some(value))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn project_admin_system_export_body_rules(
|
||||
mode: SystemExportMode,
|
||||
value: Option<&serde_json::Value>,
|
||||
) -> Option<serde_json::Value> {
|
||||
value.map(|value| {
|
||||
if mode.credentials_are_exported() {
|
||||
value.clone()
|
||||
} else {
|
||||
admin_secret_safe_body_rules(Some(value))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn project_admin_system_export_proxy(
|
||||
mode: SystemExportMode,
|
||||
value: Option<&serde_json::Value>,
|
||||
) -> Option<serde_json::Value> {
|
||||
value.map(|value| {
|
||||
if mode.credentials_are_exported() {
|
||||
value.clone()
|
||||
} else {
|
||||
admin_secret_safe_proxy(Some(value))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn project_admin_system_export_optional_url(
|
||||
mode: SystemExportMode,
|
||||
value: Option<&str>,
|
||||
) -> Option<String> {
|
||||
value.and_then(|value| {
|
||||
if mode.credentials_are_exported() {
|
||||
Some(value.to_string())
|
||||
} else {
|
||||
admin_secret_safe_url(Some(value))
|
||||
.as_str()
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn project_admin_system_export_url(mode: SystemExportMode, value: &str) -> String {
|
||||
project_admin_system_export_optional_url(mode, Some(value)).unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -248,7 +248,7 @@ pub(crate) fn oauth_module_config_is_valid(
|
||||
pub(crate) fn ldap_module_config_is_valid(
|
||||
config: Option<&aether_data::repository::auth_modules::StoredLdapModuleConfig>,
|
||||
) -> bool {
|
||||
admin_system_kernel::ldap_module_config_is_valid(config)
|
||||
crate::handlers::shared::ldap_module_config_is_valid(config)
|
||||
}
|
||||
|
||||
pub(crate) async fn build_admin_module_runtime_state(
|
||||
|
||||
@@ -28,6 +28,7 @@ use std::time::Duration;
|
||||
const AETHER_RELEASES_API_URL: &str =
|
||||
"https://api.github.com/repos/fawney19/Aether/releases?per_page=20";
|
||||
const AETHER_RELEASE_TAG_URL_BASE: &str = "https://github.com/fawney19/Aether/releases/tag";
|
||||
const MAX_GITHUB_RELEASES_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
|
||||
const SOURCE_BUILD_UPDATE_BLOCKER: &str = "当前为源码构建,请使用 git pull 后重新编译。";
|
||||
const SOURCE_BUILD_RELEASE_BLOCKER: &str = "当前为源码构建,请手动切换到对应标签后重新编译。";
|
||||
|
||||
@@ -348,18 +349,22 @@ async fn fetch_github_releases_with_client(
|
||||
rate_limited: false,
|
||||
})?;
|
||||
let status = response.status();
|
||||
let body =
|
||||
aether_http::read_response_bytes_with_limit(response, MAX_GITHUB_RELEASES_RESPONSE_BYTES)
|
||||
.await
|
||||
.map_err(|err| GitHubReleaseFetchError {
|
||||
message: format!("读取 GitHub Releases 响应失败: {err}"),
|
||||
rate_limited: false,
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
let body = String::from_utf8_lossy(&body);
|
||||
return Err(github_release_response_error(status, &body));
|
||||
}
|
||||
|
||||
response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|err| GitHubReleaseFetchError {
|
||||
message: format!("解析 GitHub Releases 失败: {err}"),
|
||||
rate_limited: false,
|
||||
})
|
||||
serde_json::from_slice(&body).map_err(|err| GitHubReleaseFetchError {
|
||||
message: format!("解析 GitHub Releases 失败: {err}"),
|
||||
rate_limited: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn github_release_response_error(
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
use crate::email_delivery::{probe_smtp_connection, system_config_u16, SmtpDeliveryConfig};
|
||||
use crate::handlers::admin::request::AdminAppState;
|
||||
use crate::handlers::shared::{system_config_bool, system_config_string};
|
||||
use crate::handlers::shared::{
|
||||
decrypt_or_migrate_smtp_password, smtp_password_binding, system_config_bool,
|
||||
system_config_string,
|
||||
};
|
||||
use crate::GatewayError;
|
||||
use axum::body::Bytes;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[derive(Default, Deserialize)]
|
||||
struct AdminSmtpTestRequest {
|
||||
smtp_host: Option<serde_json::Value>,
|
||||
smtp_port: Option<serde_json::Value>,
|
||||
@@ -18,7 +21,7 @@ struct AdminSmtpTestRequest {
|
||||
smtp_from_name: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
struct ResolvedSmtpConfig {
|
||||
host: Option<String>,
|
||||
port: u16,
|
||||
@@ -75,43 +78,70 @@ async fn resolve_admin_smtp_config(
|
||||
.read_system_config_json_value("smtp_from_name")
|
||||
.await?;
|
||||
|
||||
let stored_password = system_config_string(smtp_password.as_ref()).map(|value| {
|
||||
state
|
||||
.decrypt_catalog_secret_with_fallbacks(&value)
|
||||
.unwrap_or(value)
|
||||
});
|
||||
let host = request
|
||||
.smtp_host
|
||||
.as_ref()
|
||||
.and_then(|value| system_config_string(Some(value)))
|
||||
.or_else(|| system_config_string(smtp_host.as_ref()));
|
||||
let port = request
|
||||
.smtp_port
|
||||
.as_ref()
|
||||
.map(|value| system_config_u16(Some(value), 587))
|
||||
.unwrap_or_else(|| system_config_u16(smtp_port.as_ref(), 587));
|
||||
let user = request
|
||||
.smtp_user
|
||||
.as_ref()
|
||||
.and_then(|value| system_config_string(Some(value)))
|
||||
.or_else(|| system_config_string(smtp_user.as_ref()));
|
||||
let use_tls = request
|
||||
.smtp_use_tls
|
||||
.as_ref()
|
||||
.map(|value| system_config_bool(Some(value), true))
|
||||
.unwrap_or_else(|| system_config_bool(smtp_use_tls.as_ref(), true));
|
||||
let use_ssl = request
|
||||
.smtp_use_ssl
|
||||
.as_ref()
|
||||
.map(|value| system_config_bool(Some(value), false))
|
||||
.unwrap_or_else(|| system_config_bool(smtp_use_ssl.as_ref(), false));
|
||||
let requested_password = request
|
||||
.smtp_password
|
||||
.as_ref()
|
||||
.and_then(|value| system_config_string(Some(value)));
|
||||
let password = if requested_password.is_some() {
|
||||
requested_password
|
||||
} else {
|
||||
let saved_binding = system_config_string(smtp_host.as_ref()).and_then(|saved_host| {
|
||||
smtp_password_binding(
|
||||
&saved_host,
|
||||
system_config_u16(smtp_port.as_ref(), 587),
|
||||
system_config_string(smtp_user.as_ref()).as_deref(),
|
||||
system_config_bool(smtp_use_tls.as_ref(), true),
|
||||
system_config_bool(smtp_use_ssl.as_ref(), false),
|
||||
)
|
||||
});
|
||||
let effective_binding = host.as_deref().and_then(|effective_host| {
|
||||
smtp_password_binding(effective_host, port, user.as_deref(), use_tls, use_ssl)
|
||||
});
|
||||
match (saved_binding.as_ref(), effective_binding.as_ref()) {
|
||||
(Some(saved), Some(effective)) if saved == effective => {
|
||||
match system_config_string(smtp_password.as_ref()) {
|
||||
Some(value) => Some(
|
||||
decrypt_or_migrate_smtp_password(state.as_ref(), effective, value).await?,
|
||||
),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
Ok(ResolvedSmtpConfig {
|
||||
host: request
|
||||
.smtp_host
|
||||
.as_ref()
|
||||
.and_then(|value| system_config_string(Some(value)))
|
||||
.or_else(|| system_config_string(smtp_host.as_ref())),
|
||||
port: request
|
||||
.smtp_port
|
||||
.as_ref()
|
||||
.map(|value| system_config_u16(Some(value), 587))
|
||||
.unwrap_or_else(|| system_config_u16(smtp_port.as_ref(), 587)),
|
||||
user: request
|
||||
.smtp_user
|
||||
.as_ref()
|
||||
.and_then(|value| system_config_string(Some(value)))
|
||||
.or_else(|| system_config_string(smtp_user.as_ref())),
|
||||
password: request
|
||||
.smtp_password
|
||||
.as_ref()
|
||||
.and_then(|value| system_config_string(Some(value)))
|
||||
.or(stored_password),
|
||||
use_tls: request
|
||||
.smtp_use_tls
|
||||
.as_ref()
|
||||
.map(|value| system_config_bool(Some(value), true))
|
||||
.unwrap_or_else(|| system_config_bool(smtp_use_tls.as_ref(), true)),
|
||||
use_ssl: request
|
||||
.smtp_use_ssl
|
||||
.as_ref()
|
||||
.map(|value| system_config_bool(Some(value), false))
|
||||
.unwrap_or_else(|| system_config_bool(smtp_use_ssl.as_ref(), false)),
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
password,
|
||||
use_tls,
|
||||
use_ssl,
|
||||
from_email: request
|
||||
.smtp_from_email
|
||||
.as_ref()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,9 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
|
||||
|
||||
const EXPLICIT_UPDATE_PROXY_ENV_KEYS: &[&str] = &["AETHER_UPDATE_PROXY_URL", "UPDATE_PROXY_URL"];
|
||||
|
||||
const UPDATE_PROXY_ENV_KEYS: &[&str] = &[
|
||||
@@ -20,8 +24,15 @@ pub(crate) fn build_update_http_client(
|
||||
timeout: Duration,
|
||||
label: &str,
|
||||
) -> Result<reqwest::Client, String> {
|
||||
let mut builder = base_update_http_client_builder(timeout);
|
||||
if let Some(proxy_url) = update_proxy_url_from_env() {
|
||||
let proxy_url = update_proxy_url_from_env();
|
||||
let proxy_host = proxy_url
|
||||
.as_deref()
|
||||
.and_then(update_proxy_host)
|
||||
.map(|host| host.trim_end_matches('.').to_ascii_lowercase());
|
||||
let mut builder = base_update_http_client_builder(timeout)
|
||||
.no_proxy()
|
||||
.dns_resolver(Arc::new(SafeUpdateDnsResolver::new(proxy_host)));
|
||||
if let Some(proxy_url) = proxy_url {
|
||||
let proxy = reqwest::Proxy::all(proxy_url)
|
||||
.map_err(|_| format!("创建{label}代理失败,请检查更新代理环境变量"))?
|
||||
.no_proxy(reqwest::NoProxy::from_env());
|
||||
@@ -38,6 +49,7 @@ pub(crate) fn build_direct_update_http_client(
|
||||
) -> Result<reqwest::Client, String> {
|
||||
base_update_http_client_builder(timeout)
|
||||
.no_proxy()
|
||||
.dns_resolver(Arc::new(SafeUpdateDnsResolver::new(None)))
|
||||
.build()
|
||||
.map_err(|err| format!("创建{label}客户端失败: {err}"))
|
||||
}
|
||||
@@ -47,13 +59,97 @@ pub(crate) fn has_explicit_update_proxy_env() -> bool {
|
||||
}
|
||||
|
||||
fn base_update_http_client_builder(timeout: Duration) -> reqwest::ClientBuilder {
|
||||
reqwest::Client::builder().timeout(timeout)
|
||||
reqwest::Client::builder()
|
||||
.timeout(timeout)
|
||||
.redirect(reqwest::redirect::Policy::custom(|attempt| {
|
||||
if attempt.previous().len() >= 10 {
|
||||
return attempt.error("too many update download redirects");
|
||||
}
|
||||
if is_trusted_update_url(attempt.url()) {
|
||||
attempt.follow()
|
||||
} else {
|
||||
attempt.error("update download redirected to an untrusted URL")
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SafeUpdateDnsResolver {
|
||||
private_allowed_host: Option<String>,
|
||||
}
|
||||
|
||||
impl SafeUpdateDnsResolver {
|
||||
fn new(private_allowed_host: Option<String>) -> Self {
|
||||
Self {
|
||||
private_allowed_host,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolve for SafeUpdateDnsResolver {
|
||||
fn resolve(&self, name: Name) -> Resolving {
|
||||
let host = name.as_str().trim_end_matches('.').to_ascii_lowercase();
|
||||
let allow_private = self.private_allowed_host.as_deref() == Some(host.as_str());
|
||||
Box::pin(async move {
|
||||
let addresses = aether_http::lookup_host_with_limits(
|
||||
host.as_str(),
|
||||
0,
|
||||
aether_http::DEFAULT_DNS_LOOKUP_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| -> Box<dyn std::error::Error + Send + Sync> { Box::new(error) })?;
|
||||
validate_update_resolved_addrs(&addresses, allow_private).map_err(|message| {
|
||||
Box::new(std::io::Error::other(message)) as Box<dyn std::error::Error + Send + Sync>
|
||||
})?;
|
||||
Ok(Box::new(addresses.into_iter()) as Addrs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_update_resolved_addrs(
|
||||
addresses: &[SocketAddr],
|
||||
allow_private: bool,
|
||||
) -> Result<(), &'static str> {
|
||||
if addresses.is_empty() {
|
||||
return Err("update DNS resolution returned no addresses");
|
||||
}
|
||||
if !allow_private
|
||||
&& addresses
|
||||
.iter()
|
||||
.any(|address| aether_http::is_private_or_reserved_ip(address.ip()))
|
||||
{
|
||||
return Err("update DNS resolution returned a private or reserved address");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn is_trusted_update_url(url: &url::Url) -> bool {
|
||||
if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() {
|
||||
return false;
|
||||
}
|
||||
let Some(host) = url.host_str() else {
|
||||
return false;
|
||||
};
|
||||
host.eq_ignore_ascii_case("github.com")
|
||||
|| host.eq_ignore_ascii_case("api.github.com")
|
||||
|| host.eq_ignore_ascii_case("objects.githubusercontent.com")
|
||||
|| host.ends_with(".objects.githubusercontent.com")
|
||||
|| host.eq_ignore_ascii_case("release-assets.githubusercontent.com")
|
||||
|| host.ends_with(".release-assets.githubusercontent.com")
|
||||
}
|
||||
|
||||
fn update_proxy_url_from_env() -> Option<String> {
|
||||
read_nonempty_env_value(UPDATE_PROXY_ENV_KEYS)
|
||||
}
|
||||
|
||||
fn update_proxy_host(proxy_url: &str) -> Option<String> {
|
||||
let parsed = url::Url::parse(proxy_url)
|
||||
.ok()
|
||||
.filter(url::Url::has_host)
|
||||
.or_else(|| url::Url::parse(&format!("http://{proxy_url}")).ok())?;
|
||||
parsed.host_str().map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
pub(crate) fn update_github_token_from_env() -> Option<String> {
|
||||
read_nonempty_env_value(UPDATE_GITHUB_TOKEN_ENV_KEYS)
|
||||
}
|
||||
@@ -66,3 +162,53 @@ fn read_nonempty_env_value(keys: &[&str]) -> Option<String> {
|
||||
.filter(|value| !value.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{is_trusted_update_url, update_proxy_host, validate_update_resolved_addrs};
|
||||
use std::net::SocketAddr;
|
||||
|
||||
#[test]
|
||||
fn update_url_trust_rejects_credentials_and_untrusted_hosts() {
|
||||
for trusted in [
|
||||
"https://github.com/fawney19/Aether/releases/download/v1/aether.tar.gz",
|
||||
"https://api.github.com/repos/fawney19/Aether/releases",
|
||||
"https://objects.githubusercontent.com/github-production-release-asset/test",
|
||||
"https://release-assets.githubusercontent.com/github-production-release-asset/test",
|
||||
] {
|
||||
assert!(is_trusted_update_url(&url::Url::parse(trusted).unwrap()));
|
||||
}
|
||||
for untrusted in [
|
||||
"http://github.com/fawney19/Aether/releases/download/v1/aether.tar.gz",
|
||||
"https://github.com.evil.example/aether.tar.gz",
|
||||
"https://[email protected]/aether.tar.gz",
|
||||
"https://example.com/aether.tar.gz",
|
||||
] {
|
||||
assert!(!is_trusted_update_url(&url::Url::parse(untrusted).unwrap()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_dns_rejects_private_or_mixed_target_answers() {
|
||||
let public = "8.8.8.8:443".parse::<SocketAddr>().unwrap();
|
||||
let private = "127.0.0.1:443".parse::<SocketAddr>().unwrap();
|
||||
|
||||
assert!(validate_update_resolved_addrs(&[public], false).is_ok());
|
||||
assert!(validate_update_resolved_addrs(&[private], false).is_err());
|
||||
assert!(validate_update_resolved_addrs(&[public, private], false).is_err());
|
||||
assert!(validate_update_resolved_addrs(&[private], true).is_ok());
|
||||
assert!(validate_update_resolved_addrs(&[], false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_proxy_host_supports_explicit_and_legacy_proxy_urls() {
|
||||
assert_eq!(
|
||||
update_proxy_host("http://user:[email protected]:8080").as_deref(),
|
||||
Some("proxy.example.test")
|
||||
);
|
||||
assert_eq!(
|
||||
update_proxy_host("127.0.0.1:7890").as_deref(),
|
||||
Some("127.0.0.1")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user