mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat: support api key ip restriction rules
This commit is contained in:
@@ -799,7 +799,7 @@ mod tests {
|
||||
api_key_allowed_providers: None,
|
||||
api_key_allowed_api_formats: None,
|
||||
api_key_allowed_models: None,
|
||||
api_key_allowed_ips: None,
|
||||
api_key_ip_rules: None,
|
||||
currently_usable: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -633,7 +633,7 @@ mod tests {
|
||||
api_key_allowed_providers: None,
|
||||
api_key_allowed_api_formats: None,
|
||||
api_key_allowed_models: None,
|
||||
api_key_allowed_ips: None,
|
||||
api_key_ip_rules: None,
|
||||
currently_usable: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,7 +336,7 @@ mod tests {
|
||||
api_key_allowed_providers: None,
|
||||
api_key_allowed_api_formats: None,
|
||||
api_key_allowed_models: None,
|
||||
api_key_allowed_ips: None,
|
||||
api_key_ip_rules: None,
|
||||
currently_usable: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -575,7 +575,7 @@ mod tests {
|
||||
admin_bypass_limits: false,
|
||||
local_rejection: None,
|
||||
allowed_models: Some(allowed_models),
|
||||
allowed_ips: None,
|
||||
ip_rules: None,
|
||||
});
|
||||
decision
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ pub(crate) struct GatewayControlAuthContext {
|
||||
#[serde(skip)]
|
||||
pub(crate) allowed_models: Option<Vec<String>>,
|
||||
#[serde(skip)]
|
||||
pub(crate) allowed_ips: Option<Vec<String>>,
|
||||
pub(crate) ip_rules: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -586,7 +586,7 @@ pub(super) async fn resolve_data_backed_auth_context(
|
||||
admin_bypass_limits: false,
|
||||
local_rejection: Some(GatewayLocalAuthRejection::InvalidApiKey),
|
||||
allowed_models: None,
|
||||
allowed_ips: None,
|
||||
ip_rules: None,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -644,7 +644,7 @@ async fn resolve_trusted_auth_context(
|
||||
admin_bypass_limits: false,
|
||||
local_rejection: Some(GatewayLocalAuthRejection::InvalidApiKey),
|
||||
allowed_models: None,
|
||||
allowed_ips: None,
|
||||
ip_rules: None,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -735,7 +735,7 @@ async fn build_data_backed_auth_context(
|
||||
&& !snapshot.api_key_is_standalone,
|
||||
local_rejection,
|
||||
allowed_models,
|
||||
allowed_ips: snapshot.api_key_allowed_ips,
|
||||
ip_rules: snapshot.api_key_ip_rules,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ use crate::handlers::admin::users::{
|
||||
default_admin_user_api_key_name, format_optional_unix_secs_iso8601,
|
||||
generate_admin_user_api_key_plaintext, hash_admin_user_api_key, masked_user_api_key_display,
|
||||
normalize_admin_feature_settings, normalize_admin_optional_api_key_name,
|
||||
normalize_admin_user_api_formats, normalize_admin_user_string_list,
|
||||
normalize_admin_user_api_formats, normalize_admin_user_ip_rules,
|
||||
normalize_admin_user_string_list,
|
||||
};
|
||||
use crate::handlers::shared::normalize_optional_api_key_concurrent_limit;
|
||||
use crate::GatewayError;
|
||||
@@ -109,6 +110,10 @@ pub(super) async fn build_admin_create_api_key_response(
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(build_admin_api_keys_bad_request_response(detail)),
|
||||
};
|
||||
let ip_rules = match normalize_admin_user_ip_rules(payload.ip_rules) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(build_admin_api_keys_bad_request_response(detail)),
|
||||
};
|
||||
if payload.rate_limit.is_some_and(|value| value < 0) {
|
||||
return Ok(build_admin_api_keys_bad_request_response(
|
||||
"rate_limit 必须大于等于 0",
|
||||
@@ -162,7 +167,7 @@ pub(super) async fn build_admin_create_api_key_response(
|
||||
allowed_providers,
|
||||
allowed_api_formats,
|
||||
allowed_models,
|
||||
allowed_ips: None,
|
||||
ip_rules,
|
||||
rate_limit: payload.rate_limit,
|
||||
concurrent_limit,
|
||||
force_capabilities: None,
|
||||
@@ -328,6 +333,19 @@ pub(super) async fn build_admin_update_api_key_response(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let ip_rules_present =
|
||||
field_presence.contains("ip_rules") || field_presence.contains("allowed_ips");
|
||||
let ip_rules = if ip_rules_present {
|
||||
match payload.ip_rules {
|
||||
Some(value) => match normalize_admin_user_ip_rules(value) {
|
||||
Ok(value) => Some(value),
|
||||
Err(detail) => return Ok(build_admin_api_keys_bad_request_response(detail)),
|
||||
},
|
||||
None => Some(None),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let effective_expires_at_unix_secs = if field_presence.contains("expires_at") {
|
||||
match parse_standalone_api_key_expires_at(payload.expires_at.as_deref()) {
|
||||
Ok(value) => value,
|
||||
@@ -395,7 +413,7 @@ pub(super) async fn build_admin_update_api_key_response(
|
||||
allowed_providers,
|
||||
allowed_api_formats,
|
||||
allowed_models,
|
||||
allowed_ips: None,
|
||||
ip_rules,
|
||||
expires_at_present: field_presence.contains("expires_at"),
|
||||
expires_at_unix_secs: if field_presence.contains("expires_at") {
|
||||
effective_expires_at_unix_secs
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::handlers::admin::shared::{query_param_value, AdminTypedObjectPatch};
|
||||
use crate::handlers::admin::users::{
|
||||
format_optional_unix_secs_iso8601, masked_user_api_key_display,
|
||||
};
|
||||
use crate::handlers::shared::deserialize_optional_string_list_patch;
|
||||
use aether_admin::system::serialize_admin_system_users_export_wallet;
|
||||
use axum::{
|
||||
body::Body,
|
||||
@@ -20,6 +21,8 @@ pub(super) struct AdminStandaloneApiKeyCreateRequest {
|
||||
pub(super) allowed_providers: Option<Vec<String>>,
|
||||
pub(super) allowed_api_formats: Option<Vec<String>>,
|
||||
pub(super) allowed_models: Option<Vec<String>>,
|
||||
#[serde(default, alias = "allowed_ips")]
|
||||
pub(super) ip_rules: Option<Vec<String>>,
|
||||
pub(super) rate_limit: Option<i32>,
|
||||
pub(super) concurrent_limit: Option<i32>,
|
||||
pub(super) initial_balance_usd: Option<f64>,
|
||||
@@ -36,6 +39,12 @@ pub(super) struct AdminStandaloneApiKeyUpdateRequest {
|
||||
pub(super) allowed_providers: Option<Vec<String>>,
|
||||
pub(super) allowed_api_formats: Option<Vec<String>>,
|
||||
pub(super) allowed_models: Option<Vec<String>>,
|
||||
#[serde(
|
||||
default,
|
||||
alias = "allowed_ips",
|
||||
deserialize_with = "deserialize_optional_string_list_patch"
|
||||
)]
|
||||
pub(super) ip_rules: Option<Option<Vec<String>>>,
|
||||
pub(super) rate_limit: Option<i32>,
|
||||
pub(super) concurrent_limit: Option<i32>,
|
||||
pub(super) initial_balance_usd: Option<f64>,
|
||||
@@ -161,6 +170,7 @@ pub(super) fn build_admin_api_key_list_item_payload(
|
||||
"allowed_providers": record.allowed_providers,
|
||||
"allowed_api_formats": record.allowed_api_formats,
|
||||
"allowed_models": record.allowed_models,
|
||||
"ip_rules": record.ip_rules,
|
||||
"last_used_at": format_optional_unix_secs_iso8601(record.last_used_at_unix_secs),
|
||||
"expires_at": format_optional_unix_secs_iso8601(record.expires_at_unix_secs),
|
||||
"created_at": format_optional_unix_secs_iso8601(record.created_at_unix_secs),
|
||||
@@ -191,6 +201,7 @@ pub(super) fn build_admin_api_key_detail_payload(
|
||||
"allowed_providers": record.allowed_providers,
|
||||
"allowed_api_formats": record.allowed_api_formats,
|
||||
"allowed_models": record.allowed_models,
|
||||
"ip_rules": record.ip_rules,
|
||||
"last_used_at": format_optional_unix_secs_iso8601(record.last_used_at_unix_secs),
|
||||
"expires_at": format_optional_unix_secs_iso8601(record.expires_at_unix_secs),
|
||||
"created_at": format_optional_unix_secs_iso8601(record.created_at_unix_secs),
|
||||
|
||||
@@ -344,6 +344,7 @@ impl<'a> AdminAppState<'a> {
|
||||
"allowed_models".to_string(),
|
||||
json!(key.allowed_models.clone()),
|
||||
),
|
||||
("ip_rules".to_string(), json!(key.ip_rules.clone())),
|
||||
("rate_limit".to_string(), json!(key.rate_limit)),
|
||||
("concurrent_limit".to_string(), json!(key.concurrent_limit)),
|
||||
(
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::handlers::admin::system::shared::configs::apply_admin_system_config_u
|
||||
use crate::handlers::admin::users::{
|
||||
hash_admin_user_api_key, normalize_admin_feature_settings, normalize_admin_list_policy_mode,
|
||||
normalize_admin_rate_limit_policy_mode, normalize_admin_user_api_formats,
|
||||
normalize_admin_user_string_list,
|
||||
normalize_admin_user_ip_rules, normalize_admin_user_string_list,
|
||||
};
|
||||
use crate::handlers::public::normalize_admin_base_url;
|
||||
use crate::GatewayError;
|
||||
@@ -705,6 +705,27 @@ fn normalize_imported_user_api_formats(
|
||||
)?)
|
||||
}
|
||||
|
||||
fn imported_ip_rules_field<'a>(
|
||||
object: &'a Map<String, Value>,
|
||||
) -> (&'static str, Option<&'a Value>) {
|
||||
if let Some(value) = object.get("ip_rules") {
|
||||
("ip_rules", Some(value))
|
||||
} else {
|
||||
("allowed_ips", object.get("allowed_ips"))
|
||||
}
|
||||
}
|
||||
|
||||
fn imported_ip_rules_present(object: &Map<String, Value>) -> bool {
|
||||
object.contains_key("ip_rules") || object.contains_key("allowed_ips")
|
||||
}
|
||||
|
||||
fn normalize_imported_user_ip_rules(
|
||||
object: &Map<String, Value>,
|
||||
) -> Result<Option<Vec<String>>, String> {
|
||||
let (field_name, value) = imported_ip_rules_field(object);
|
||||
normalize_admin_user_ip_rules(imported_string_list_from_value(value, field_name)?)
|
||||
}
|
||||
|
||||
fn build_imported_user_group_record(
|
||||
group: &Map<String, Value>,
|
||||
field_name: &str,
|
||||
@@ -2493,6 +2514,7 @@ impl<'a> AdminAppState<'a> {
|
||||
));
|
||||
let allowed_models =
|
||||
invalid_value!(normalize_imported_user_string_list(key, "allowed_models"));
|
||||
let ip_rules = invalid_value!(normalize_imported_user_ip_rules(key));
|
||||
let rate_limit =
|
||||
invalid_value!(imported_optional_i32(key.get("rate_limit"), "rate_limit"))
|
||||
.unwrap_or(0);
|
||||
@@ -2558,7 +2580,8 @@ impl<'a> AdminAppState<'a> {
|
||||
} else {
|
||||
None
|
||||
},
|
||||
allowed_ips: None,
|
||||
ip_rules: imported_ip_rules_present(key)
|
||||
.then(|| ip_rules.clone()),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -2627,7 +2650,7 @@ impl<'a> AdminAppState<'a> {
|
||||
allowed_providers,
|
||||
allowed_api_formats,
|
||||
allowed_models,
|
||||
allowed_ips: None,
|
||||
ip_rules,
|
||||
rate_limit,
|
||||
concurrent_limit,
|
||||
force_capabilities,
|
||||
@@ -2712,6 +2735,7 @@ impl<'a> AdminAppState<'a> {
|
||||
));
|
||||
let allowed_models =
|
||||
invalid_value!(normalize_imported_user_string_list(key, "allowed_models"));
|
||||
let ip_rules = invalid_value!(normalize_imported_user_ip_rules(key));
|
||||
let rate_limit =
|
||||
invalid_value!(imported_optional_i32(key.get("rate_limit"), "rate_limit"))
|
||||
.unwrap_or(0);
|
||||
@@ -2783,7 +2807,8 @@ impl<'a> AdminAppState<'a> {
|
||||
allowed_providers: Some(allowed_providers.clone()),
|
||||
allowed_api_formats: Some(allowed_api_formats.clone()),
|
||||
allowed_models: Some(allowed_models.clone()),
|
||||
allowed_ips: None,
|
||||
ip_rules: imported_ip_rules_present(key)
|
||||
.then(|| ip_rules.clone()),
|
||||
expires_at_present: false,
|
||||
expires_at_unix_secs: None,
|
||||
auto_delete_on_expiry_present: false,
|
||||
@@ -2844,7 +2869,7 @@ impl<'a> AdminAppState<'a> {
|
||||
allowed_providers,
|
||||
allowed_api_formats,
|
||||
allowed_models,
|
||||
allowed_ips: None,
|
||||
ip_rules,
|
||||
rate_limit: Some(rate_limit),
|
||||
concurrent_limit,
|
||||
force_capabilities,
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::handlers::admin::system::shared::paths::{
|
||||
admin_management_token_status_id_from_path, is_admin_management_tokens_root,
|
||||
};
|
||||
use crate::handlers::internal::build_management_token_payload;
|
||||
use crate::handlers::shared::generate_gateway_secret_plaintext;
|
||||
use crate::handlers::shared::{generate_gateway_secret_plaintext, parse_json_ip_rules};
|
||||
use crate::{GatewayError, LocalMutationOutcome};
|
||||
use aether_data::repository::management_tokens::{
|
||||
CreateManagementTokenRecord, ManagementTokenListQuery, RegenerateManagementTokenSecret,
|
||||
@@ -97,59 +97,10 @@ fn admin_management_token_prefix(value: &str) -> Option<String> {
|
||||
.then(|| value[..value.len().min(ADMIN_MANAGEMENT_TOKEN_DISPLAY_PREFIX_LEN)].to_string())
|
||||
}
|
||||
|
||||
fn admin_validate_ip_or_cidr(value: &str) -> bool {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if value.parse::<std::net::IpAddr>().is_ok() {
|
||||
return true;
|
||||
}
|
||||
let Some((host, prefix)) = value.split_once('/') else {
|
||||
return false;
|
||||
};
|
||||
let Ok(ip) = host.trim().parse::<std::net::IpAddr>() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(prefix) = prefix.trim().parse::<u8>() else {
|
||||
return false;
|
||||
};
|
||||
match ip {
|
||||
std::net::IpAddr::V4(_) => prefix <= 32,
|
||||
std::net::IpAddr::V6(_) => prefix <= 128,
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_parse_management_token_allowed_ips(
|
||||
value: Option<&serde_json::Value>,
|
||||
) -> Result<Option<serde_json::Value>, String> {
|
||||
let Some(value) = value else {
|
||||
return Ok(None);
|
||||
};
|
||||
match value {
|
||||
serde_json::Value::Null => Ok(None),
|
||||
serde_json::Value::Array(items) => {
|
||||
if items.is_empty() {
|
||||
return Err("IP 白名单不能为空列表,如需取消限制请不提供此字段".to_string());
|
||||
}
|
||||
let mut normalized = Vec::with_capacity(items.len());
|
||||
for (index, item) in items.iter().enumerate() {
|
||||
let Some(raw) = item.as_str() else {
|
||||
return Err("IP 白名单必须是字符串数组".to_string());
|
||||
};
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(format!("IP 白名单第 {} 项为空", index + 1));
|
||||
}
|
||||
if !admin_validate_ip_or_cidr(trimmed) {
|
||||
return Err(format!("无效的 IP 地址或 CIDR: {raw}"));
|
||||
}
|
||||
normalized.push(trimmed.to_string());
|
||||
}
|
||||
Ok(Some(json!(normalized)))
|
||||
}
|
||||
_ => Err("IP 白名单必须是字符串数组".to_string()),
|
||||
}
|
||||
parse_json_ip_rules(value)
|
||||
}
|
||||
|
||||
fn admin_parse_management_token_expires_at(
|
||||
|
||||
@@ -46,7 +46,7 @@ pub(crate) const ADMIN_MODULE_DEFINITIONS: &[AdminModuleDefinition] = &[
|
||||
AdminModuleDefinition {
|
||||
name: "management_tokens",
|
||||
display_name: "访问令牌",
|
||||
description: "管理 API 访问令牌,支持细粒度权限控制和 IP 白名单",
|
||||
description: "管理 API 访问令牌,支持细粒度权限控制和 IP 限制",
|
||||
category: "security",
|
||||
env_key: "MANAGEMENT_TOKENS_AVAILABLE",
|
||||
default_available: true,
|
||||
|
||||
@@ -44,7 +44,7 @@ pub(super) fn build_admin_user_api_key_detail_payload(
|
||||
"total_cost_usd": record.total_cost_usd,
|
||||
"rate_limit": record.rate_limit,
|
||||
"concurrent_limit": record.concurrent_limit,
|
||||
"allowed_ips": record.allowed_ips,
|
||||
"ip_rules": record.ip_rules,
|
||||
"feature_settings": record.feature_settings,
|
||||
"expires_at": format_optional_unix_secs_iso8601(record.expires_at_unix_secs),
|
||||
"last_used_at": format_optional_unix_secs_iso8601(record.last_used_at_unix_secs),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::super::super::{
|
||||
build_admin_users_bad_request_response, build_admin_users_data_unavailable_response,
|
||||
build_admin_users_read_only_response, normalize_admin_feature_settings,
|
||||
normalize_admin_user_allowed_ips, AdminCreateUserApiKeyRequest,
|
||||
normalize_admin_user_ip_rules, AdminCreateUserApiKeyRequest,
|
||||
};
|
||||
use super::super::helpers::{
|
||||
attach_audit_response, default_admin_user_api_key_name, format_optional_unix_secs_iso8601,
|
||||
@@ -71,7 +71,7 @@ pub(crate) async fn build_admin_create_user_api_key_response(
|
||||
{
|
||||
return Ok((
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "detail": "当前仅支持 name、rate_limit、concurrent_limit、allowed_providers、allowed_ips 字段" })),
|
||||
Json(json!({ "detail": "当前仅支持 name、rate_limit、concurrent_limit、allowed_providers、ip_rules 字段" })),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
@@ -107,7 +107,7 @@ pub(crate) async fn build_admin_create_user_api_key_response(
|
||||
.into_response());
|
||||
}
|
||||
};
|
||||
let allowed_ips = match normalize_admin_user_allowed_ips(payload.allowed_ips) {
|
||||
let ip_rules = match normalize_admin_user_ip_rules(payload.ip_rules) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => {
|
||||
return Ok((
|
||||
@@ -156,7 +156,7 @@ pub(crate) async fn build_admin_create_user_api_key_response(
|
||||
allowed_providers: None,
|
||||
allowed_api_formats: None,
|
||||
allowed_models: None,
|
||||
allowed_ips,
|
||||
ip_rules,
|
||||
rate_limit,
|
||||
concurrent_limit,
|
||||
force_capabilities: None,
|
||||
@@ -207,7 +207,7 @@ pub(crate) async fn build_admin_create_user_api_key_response(
|
||||
"key_display": masked_user_api_key_display(state, created.key_encrypted.as_deref()),
|
||||
"rate_limit": created.rate_limit,
|
||||
"concurrent_limit": created.concurrent_limit,
|
||||
"allowed_ips": created.allowed_ips,
|
||||
"ip_rules": created.ip_rules,
|
||||
"expires_at": format_optional_unix_secs_iso8601(created.expires_at_unix_secs),
|
||||
"last_used_at": format_optional_unix_secs_iso8601(created.last_used_at_unix_secs),
|
||||
"created_at": format_optional_unix_secs_iso8601(created.created_at_unix_secs),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use super::super::super::{
|
||||
build_admin_users_bad_request_response, build_admin_users_read_only_response,
|
||||
normalize_admin_feature_settings, normalize_admin_user_allowed_ips,
|
||||
AdminUpdateUserApiKeyRequest,
|
||||
normalize_admin_feature_settings, normalize_admin_user_ip_rules, AdminUpdateUserApiKeyRequest,
|
||||
};
|
||||
use super::super::helpers::{
|
||||
attach_audit_response, build_admin_user_api_key_detail_payload,
|
||||
@@ -95,8 +94,8 @@ pub(crate) async fn build_admin_update_user_api_key_response(
|
||||
.into_response());
|
||||
}
|
||||
};
|
||||
let allowed_ips = match payload.allowed_ips {
|
||||
Some(value) => match normalize_admin_user_allowed_ips(value) {
|
||||
let ip_rules = match payload.ip_rules {
|
||||
Some(value) => match normalize_admin_user_ip_rules(value) {
|
||||
Ok(value) => Some(value),
|
||||
Err(detail) => {
|
||||
return Ok((
|
||||
@@ -116,7 +115,7 @@ pub(crate) async fn build_admin_update_user_api_key_response(
|
||||
name,
|
||||
rate_limit: payload.rate_limit,
|
||||
concurrent_limit,
|
||||
allowed_ips,
|
||||
ip_rules,
|
||||
})
|
||||
.await?
|
||||
else {
|
||||
|
||||
@@ -56,7 +56,7 @@ use self::shared::{
|
||||
};
|
||||
pub(crate) use self::shared::{
|
||||
normalize_admin_list_policy_mode, normalize_admin_rate_limit_policy_mode,
|
||||
normalize_admin_user_allowed_ips, normalize_admin_user_api_formats,
|
||||
normalize_admin_user_api_formats, normalize_admin_user_ip_rules,
|
||||
normalize_admin_user_string_list,
|
||||
};
|
||||
pub(crate) use crate::handlers::shared::normalize_feature_settings as normalize_admin_feature_settings;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::ADMIN_USERS_DATA_UNAVAILABLE_DETAIL;
|
||||
use crate::handlers::admin::shared::AdminTypedObjectPatch;
|
||||
use crate::handlers::shared::deserialize_optional_string_list_patch;
|
||||
use crate::handlers::shared::{deserialize_optional_string_list_patch, normalize_ip_rules};
|
||||
use axum::{
|
||||
body::Body,
|
||||
http,
|
||||
@@ -20,8 +20,8 @@ pub(super) struct AdminCreateUserApiKeyRequest {
|
||||
pub(super) allowed_api_formats: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub(super) allowed_models: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub(super) allowed_ips: Option<Vec<String>>,
|
||||
#[serde(default, alias = "allowed_ips")]
|
||||
pub(super) ip_rules: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub(super) rate_limit: Option<i32>,
|
||||
#[serde(default)]
|
||||
@@ -52,8 +52,12 @@ pub(super) struct AdminUpdateUserApiKeyRequest {
|
||||
pub(super) concurrent_limit: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub(super) feature_settings: Option<Option<Value>>,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_string_list_patch")]
|
||||
pub(super) allowed_ips: Option<Option<Vec<String>>>,
|
||||
#[serde(
|
||||
default,
|
||||
alias = "allowed_ips",
|
||||
deserialize_with = "deserialize_optional_string_list_patch"
|
||||
)]
|
||||
pub(super) ip_rules: Option<Option<Vec<String>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
@@ -285,50 +289,10 @@ pub(crate) fn normalize_admin_user_api_formats(
|
||||
Ok(Some(normalized))
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_admin_user_allowed_ips(
|
||||
pub(crate) fn normalize_admin_user_ip_rules(
|
||||
value: Option<Vec<String>>,
|
||||
) -> Result<Option<Vec<String>>, String> {
|
||||
let Some(values) = value else {
|
||||
return Ok(None);
|
||||
};
|
||||
if values.is_empty() {
|
||||
return Err("IP 白名单不能为空列表,如需取消限制请不提供此字段".to_string());
|
||||
}
|
||||
let mut normalized = Vec::with_capacity(values.len());
|
||||
for (index, raw) in values.into_iter().enumerate() {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(format!("IP 白名单第 {} 项为空", index + 1));
|
||||
}
|
||||
if !validate_admin_user_ip_or_cidr(trimmed) {
|
||||
return Err(format!("无效的 IP 地址或 CIDR: {raw}"));
|
||||
}
|
||||
normalized.push(trimmed.to_string());
|
||||
}
|
||||
Ok(Some(normalized))
|
||||
}
|
||||
|
||||
fn validate_admin_user_ip_or_cidr(value: &str) -> bool {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if value.parse::<std::net::IpAddr>().is_ok() {
|
||||
return true;
|
||||
}
|
||||
let Some((host, prefix)) = value.split_once('/') else {
|
||||
return false;
|
||||
};
|
||||
let Ok(ip) = host.trim().parse::<std::net::IpAddr>() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(prefix) = prefix.trim().parse::<u8>() else {
|
||||
return false;
|
||||
};
|
||||
match ip {
|
||||
std::net::IpAddr::V4(_) => prefix <= 32,
|
||||
std::net::IpAddr::V6(_) => prefix <= 128,
|
||||
}
|
||||
normalize_ip_rules(value)
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_admin_list_policy_mode(value: &str) -> Result<String, String> {
|
||||
@@ -429,25 +393,25 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_update_api_key_distinguishes_missing_null_and_present_allowed_ips() {
|
||||
fn admin_update_api_key_distinguishes_missing_null_and_present_ip_rules() {
|
||||
let missing = serde_json::from_value::<AdminUpdateUserApiKeyRequest>(json!({
|
||||
"name": "unchanged-whitelist",
|
||||
"name": "unchanged-ip-rules",
|
||||
}))
|
||||
.expect("missing allowed_ips should deserialize");
|
||||
assert_eq!(missing.allowed_ips, None);
|
||||
.expect("missing ip_rules should deserialize");
|
||||
assert_eq!(missing.ip_rules, None);
|
||||
|
||||
let cleared = serde_json::from_value::<AdminUpdateUserApiKeyRequest>(json!({
|
||||
"allowed_ips": null,
|
||||
"ip_rules": null,
|
||||
}))
|
||||
.expect("null allowed_ips should deserialize");
|
||||
assert_eq!(cleared.allowed_ips, Some(None));
|
||||
.expect("null ip_rules should deserialize");
|
||||
assert_eq!(cleared.ip_rules, Some(None));
|
||||
|
||||
let updated = serde_json::from_value::<AdminUpdateUserApiKeyRequest>(json!({
|
||||
"allowed_ips": ["203.0.113.10", "10.0.0.0/24"],
|
||||
"ip_rules": ["203.0.113.10", "10.0.0.0/24"],
|
||||
}))
|
||||
.expect("present allowed_ips should deserialize");
|
||||
.expect("present ip_rules should deserialize");
|
||||
assert_eq!(
|
||||
updated.allowed_ips,
|
||||
updated.ip_rules,
|
||||
Some(Some(vec![
|
||||
"203.0.113.10".to_string(),
|
||||
"10.0.0.0/24".to_string(),
|
||||
|
||||
@@ -46,8 +46,8 @@ use crate::frontdoor_loop_guard::{
|
||||
frontdoor_self_loop_public_ai_path, request_has_execution_runtime_loop_guard,
|
||||
};
|
||||
use crate::handlers::shared::{
|
||||
build_admin_proxy_auth_required_response, build_unhandled_admin_proxy_response,
|
||||
local_proxy_route_requires_buffered_body, request_enables_control_execute,
|
||||
build_admin_proxy_auth_required_response, build_unhandled_admin_proxy_response, ip_rules_allow,
|
||||
json_ip_rules_allow, local_proxy_route_requires_buffered_body, request_enables_control_execute,
|
||||
should_strip_forwarded_provider_credential_header, should_strip_forwarded_trusted_admin_header,
|
||||
};
|
||||
use crate::headers::{
|
||||
@@ -197,69 +197,11 @@ fn hash_management_token(value: &str) -> String {
|
||||
}
|
||||
|
||||
fn remote_ip_allowed(allowed_ips: Option<&serde_json::Value>, remote_ip: std::net::IpAddr) -> bool {
|
||||
let Some(allowed_ips) = allowed_ips else {
|
||||
return true;
|
||||
};
|
||||
if allowed_ips.is_null() {
|
||||
return true;
|
||||
}
|
||||
let Some(items) = allowed_ips.as_array() else {
|
||||
return false;
|
||||
};
|
||||
if items.is_empty() {
|
||||
return false;
|
||||
}
|
||||
items
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.any(|value| ip_or_cidr_matches(value, remote_ip))
|
||||
json_ip_rules_allow(allowed_ips, remote_ip)
|
||||
}
|
||||
|
||||
fn api_key_remote_ip_allowed(allowed_ips: Option<&[String]>, remote_ip: std::net::IpAddr) -> bool {
|
||||
let Some(allowed_ips) = allowed_ips else {
|
||||
return true;
|
||||
};
|
||||
if allowed_ips.is_empty() {
|
||||
return false;
|
||||
}
|
||||
allowed_ips
|
||||
.iter()
|
||||
.any(|value| ip_or_cidr_matches(value, remote_ip))
|
||||
}
|
||||
|
||||
fn ip_or_cidr_matches(pattern: &str, remote_ip: std::net::IpAddr) -> bool {
|
||||
let pattern = pattern.trim();
|
||||
if pattern.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if let Ok(ip) = pattern.parse::<std::net::IpAddr>() {
|
||||
return ip == remote_ip;
|
||||
}
|
||||
let Some((network, prefix)) = pattern.split_once('/') else {
|
||||
return false;
|
||||
};
|
||||
let Ok(prefix) = prefix.trim().parse::<u8>() else {
|
||||
return false;
|
||||
};
|
||||
match (network.trim().parse::<std::net::IpAddr>(), remote_ip) {
|
||||
(Ok(std::net::IpAddr::V4(network)), std::net::IpAddr::V4(remote)) if prefix <= 32 => {
|
||||
let mask = if prefix == 0 {
|
||||
0
|
||||
} else {
|
||||
u32::MAX << (32 - prefix)
|
||||
};
|
||||
(u32::from(network) & mask) == (u32::from(remote) & mask)
|
||||
}
|
||||
(Ok(std::net::IpAddr::V6(network)), std::net::IpAddr::V6(remote)) if prefix <= 128 => {
|
||||
let mask = if prefix == 0 {
|
||||
0
|
||||
} else {
|
||||
u128::MAX << (128 - prefix)
|
||||
};
|
||||
(u128::from(network) & mask) == (u128::from(remote) & mask)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
fn api_key_remote_ip_allowed(ip_rules: Option<&[String]>, remote_ip: std::net::IpAddr) -> bool {
|
||||
ip_rules_allow(ip_rules, remote_ip)
|
||||
}
|
||||
|
||||
async fn maybe_promote_management_token_admin_principal(
|
||||
@@ -1003,7 +945,7 @@ pub(crate) async fn proxy_request(
|
||||
.as_ref()
|
||||
.and_then(|decision| decision.auth_context.as_ref())
|
||||
{
|
||||
if !api_key_remote_ip_allowed(auth_context.allowed_ips.as_deref(), remote_addr.ip()) {
|
||||
if !api_key_remote_ip_allowed(auth_context.ip_rules.as_deref(), remote_addr.ip()) {
|
||||
let rejection = crate::control::GatewayLocalAuthRejection::IpNotAllowed {
|
||||
remote_ip: remote_addr.ip().to_string(),
|
||||
};
|
||||
@@ -2052,20 +1994,24 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_remote_ip_matches_exact_ip_and_cidr() {
|
||||
let allowed_ips = vec!["198.51.100.1".to_string(), "203.0.113.0/24".to_string()];
|
||||
fn api_key_remote_ip_applies_ip_rules() {
|
||||
let ip_rules = vec![
|
||||
"198.51.100.1".to_string(),
|
||||
"203.0.113.*".to_string(),
|
||||
"!203.0.113.13".to_string(),
|
||||
];
|
||||
|
||||
assert!(api_key_remote_ip_allowed(
|
||||
Some(&allowed_ips),
|
||||
Some(&ip_rules),
|
||||
"198.51.100.1".parse().expect("valid ip"),
|
||||
));
|
||||
assert!(api_key_remote_ip_allowed(
|
||||
Some(&allowed_ips),
|
||||
Some(&ip_rules),
|
||||
"203.0.113.42".parse().expect("valid ip"),
|
||||
));
|
||||
assert!(!api_key_remote_ip_allowed(
|
||||
Some(&allowed_ips),
|
||||
"203.0.114.42".parse().expect("valid ip"),
|
||||
Some(&ip_rules),
|
||||
"203.0.113.13".parse().expect("valid ip"),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ use serde_json::json;
|
||||
use crate::handlers::shared::{
|
||||
api_key_placeholder_display, deserialize_optional_json_patch,
|
||||
deserialize_optional_string_list_patch, generate_gateway_api_key_plaintext,
|
||||
masked_gateway_api_key_display, normalize_feature_settings,
|
||||
masked_gateway_api_key_display, normalize_feature_settings, normalize_ip_rules,
|
||||
normalize_optional_api_key_concurrent_limit,
|
||||
};
|
||||
|
||||
@@ -35,8 +35,8 @@ struct UsersMeCreateApiKeyRequest {
|
||||
concurrent_limit: Option<i32>,
|
||||
#[serde(default)]
|
||||
feature_settings: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
allowed_ips: Option<Vec<String>>,
|
||||
#[serde(default, alias = "allowed_ips")]
|
||||
ip_rules: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -49,8 +49,12 @@ struct UsersMeUpdateApiKeyRequest {
|
||||
concurrent_limit: Option<i32>,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_json_patch")]
|
||||
feature_settings: Option<Option<serde_json::Value>>,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_string_list_patch")]
|
||||
allowed_ips: Option<Option<Vec<String>>>,
|
||||
#[serde(
|
||||
default,
|
||||
alias = "allowed_ips",
|
||||
deserialize_with = "deserialize_optional_string_list_patch"
|
||||
)]
|
||||
ip_rules: Option<Option<Vec<String>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -165,7 +169,7 @@ fn build_users_me_api_key_list_payload(
|
||||
"rate_limit": record.rate_limit,
|
||||
"concurrent_limit": record.concurrent_limit,
|
||||
"allowed_providers": record.allowed_providers,
|
||||
"allowed_ips": record.allowed_ips,
|
||||
"ip_rules": record.ip_rules,
|
||||
"force_capabilities": record.force_capabilities,
|
||||
"feature_settings": record.feature_settings,
|
||||
})
|
||||
@@ -183,7 +187,7 @@ fn build_users_me_api_key_detail_payload(
|
||||
"is_active": record.is_active,
|
||||
"is_locked": is_locked,
|
||||
"allowed_providers": record.allowed_providers,
|
||||
"allowed_ips": record.allowed_ips,
|
||||
"ip_rules": record.ip_rules,
|
||||
"force_capabilities": record.force_capabilities,
|
||||
"feature_settings": record.feature_settings,
|
||||
"rate_limit": record.rate_limit,
|
||||
@@ -206,50 +210,8 @@ fn generate_users_me_api_key_plaintext() -> String {
|
||||
generate_gateway_api_key_plaintext()
|
||||
}
|
||||
|
||||
fn users_me_validate_ip_or_cidr(value: &str) -> bool {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if value.parse::<std::net::IpAddr>().is_ok() {
|
||||
return true;
|
||||
}
|
||||
let Some((host, prefix)) = value.split_once('/') else {
|
||||
return false;
|
||||
};
|
||||
let Ok(ip) = host.trim().parse::<std::net::IpAddr>() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(prefix) = prefix.trim().parse::<u8>() else {
|
||||
return false;
|
||||
};
|
||||
match ip {
|
||||
std::net::IpAddr::V4(_) => prefix <= 32,
|
||||
std::net::IpAddr::V6(_) => prefix <= 128,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_users_me_allowed_ips(
|
||||
values: Option<Vec<String>>,
|
||||
) -> Result<Option<Vec<String>>, String> {
|
||||
let Some(values) = values else {
|
||||
return Ok(None);
|
||||
};
|
||||
if values.is_empty() {
|
||||
return Err("IP 白名单不能为空列表,如需取消限制请不提供此字段".to_string());
|
||||
}
|
||||
let mut normalized = Vec::with_capacity(values.len());
|
||||
for (index, raw) in values.into_iter().enumerate() {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(format!("IP 白名单第 {} 项为空", index + 1));
|
||||
}
|
||||
if !users_me_validate_ip_or_cidr(trimmed) {
|
||||
return Err(format!("无效的 IP 地址或 CIDR: {raw}"));
|
||||
}
|
||||
normalized.push(trimmed.to_string());
|
||||
}
|
||||
Ok(Some(normalized))
|
||||
fn normalize_users_me_ip_rules(values: Option<Vec<String>>) -> Result<Option<Vec<String>>, String> {
|
||||
normalize_ip_rules(values)
|
||||
}
|
||||
|
||||
fn hash_users_me_api_key(value: &str) -> String {
|
||||
@@ -595,7 +557,7 @@ pub(super) async fn handle_users_me_api_key_create(
|
||||
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false);
|
||||
}
|
||||
};
|
||||
let allowed_ips = match normalize_users_me_allowed_ips(payload.allowed_ips) {
|
||||
let ip_rules = match normalize_users_me_ip_rules(payload.ip_rules) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => {
|
||||
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false);
|
||||
@@ -619,7 +581,7 @@ pub(super) async fn handle_users_me_api_key_create(
|
||||
allowed_providers: None,
|
||||
allowed_api_formats: None,
|
||||
allowed_models: None,
|
||||
allowed_ips,
|
||||
ip_rules,
|
||||
rate_limit,
|
||||
concurrent_limit,
|
||||
force_capabilities: None,
|
||||
@@ -674,7 +636,7 @@ pub(super) async fn handle_users_me_api_key_create(
|
||||
"is_locked": false,
|
||||
"rate_limit": created.rate_limit,
|
||||
"concurrent_limit": created.concurrent_limit,
|
||||
"allowed_ips": created.allowed_ips,
|
||||
"ip_rules": created.ip_rules,
|
||||
"feature_settings": created.feature_settings,
|
||||
"last_used_at": format_users_me_optional_unix_secs_iso8601(created.last_used_at_unix_secs),
|
||||
"created_at": format_users_me_optional_unix_secs_iso8601(created.created_at_unix_secs),
|
||||
@@ -756,8 +718,8 @@ pub(super) async fn handle_users_me_api_key_update(
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let allowed_ips = match payload.allowed_ips {
|
||||
Some(value) => match normalize_users_me_allowed_ips(value) {
|
||||
let ip_rules = match payload.ip_rules {
|
||||
Some(value) => match normalize_users_me_ip_rules(value) {
|
||||
Ok(value) => Some(value),
|
||||
Err(detail) => {
|
||||
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false);
|
||||
@@ -773,7 +735,7 @@ pub(super) async fn handle_users_me_api_key_update(
|
||||
name,
|
||||
rate_limit,
|
||||
concurrent_limit,
|
||||
allowed_ips,
|
||||
ip_rules,
|
||||
})
|
||||
.await
|
||||
{
|
||||
@@ -1131,16 +1093,16 @@ pub(super) async fn handle_users_me_api_key_capabilities_put(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{normalize_users_me_allowed_ips, UsersMeUpdateApiKeyRequest};
|
||||
use super::{normalize_users_me_ip_rules, UsersMeUpdateApiKeyRequest};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn normalize_allowed_ips_trims_ip_and_cidr_values() {
|
||||
let values = normalize_users_me_allowed_ips(Some(vec![
|
||||
fn normalize_ip_rules_trims_ip_and_cidr_values() {
|
||||
let values = normalize_users_me_ip_rules(Some(vec![
|
||||
" 203.0.113.10 ".to_string(),
|
||||
"10.0.0.0/24".to_string(),
|
||||
]))
|
||||
.expect("valid whitelist should normalize");
|
||||
.expect("valid IP rules should normalize");
|
||||
|
||||
assert_eq!(
|
||||
values,
|
||||
@@ -1149,33 +1111,33 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_allowed_ips_rejects_invalid_cidr() {
|
||||
let err = normalize_users_me_allowed_ips(Some(vec!["10.0.0.0/99".to_string()]))
|
||||
fn normalize_ip_rules_rejects_invalid_cidr() {
|
||||
let err = normalize_users_me_ip_rules(Some(vec!["10.0.0.0/99".to_string()]))
|
||||
.expect_err("invalid cidr should fail");
|
||||
|
||||
assert_eq!(err, "无效的 IP 地址或 CIDR: 10.0.0.0/99");
|
||||
assert_eq!(err, "无效的 IP 限制规则: 10.0.0.0/99(第 1 项)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_payload_distinguishes_missing_null_and_present_allowed_ips() {
|
||||
fn update_payload_distinguishes_missing_null_and_present_ip_rules() {
|
||||
let missing = serde_json::from_value::<UsersMeUpdateApiKeyRequest>(json!({
|
||||
"name": "unchanged-whitelist",
|
||||
"name": "unchanged-ip-rules",
|
||||
}))
|
||||
.expect("missing allowed_ips should deserialize");
|
||||
assert_eq!(missing.allowed_ips, None);
|
||||
.expect("missing ip_rules should deserialize");
|
||||
assert_eq!(missing.ip_rules, None);
|
||||
|
||||
let cleared = serde_json::from_value::<UsersMeUpdateApiKeyRequest>(json!({
|
||||
"allowed_ips": null,
|
||||
"ip_rules": null,
|
||||
}))
|
||||
.expect("null allowed_ips should deserialize");
|
||||
assert_eq!(cleared.allowed_ips, Some(None));
|
||||
.expect("null ip_rules should deserialize");
|
||||
assert_eq!(cleared.ip_rules, Some(None));
|
||||
|
||||
let updated = serde_json::from_value::<UsersMeUpdateApiKeyRequest>(json!({
|
||||
"allowed_ips": ["203.0.113.10", "10.0.0.0/24"],
|
||||
"ip_rules": ["203.0.113.10", "10.0.0.0/24"],
|
||||
}))
|
||||
.expect("present allowed_ips should deserialize");
|
||||
.expect("present ip_rules should deserialize");
|
||||
assert_eq!(
|
||||
updated.allowed_ips,
|
||||
updated.ip_rules,
|
||||
Some(Some(vec![
|
||||
"203.0.113.10".to_string(),
|
||||
"10.0.0.0/24".to_string(),
|
||||
|
||||
@@ -20,7 +20,7 @@ use super::{
|
||||
GatewayPublicRequestContext,
|
||||
};
|
||||
use crate::control::normalize_assignable_management_token_permissions;
|
||||
use crate::handlers::shared::generate_gateway_secret_plaintext;
|
||||
use crate::handlers::shared::{generate_gateway_secret_plaintext, parse_json_ip_rules};
|
||||
use crate::LocalMutationOutcome;
|
||||
|
||||
const USERS_ME_MANAGEMENT_TOKEN_PREFIX: &str = "ae";
|
||||
@@ -177,59 +177,10 @@ fn users_me_management_token_skip(query: Option<&str>) -> usize {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn users_me_validate_ip_or_cidr(value: &str) -> bool {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if value.parse::<std::net::IpAddr>().is_ok() {
|
||||
return true;
|
||||
}
|
||||
let Some((host, prefix)) = value.split_once('/') else {
|
||||
return false;
|
||||
};
|
||||
let Ok(ip) = host.trim().parse::<std::net::IpAddr>() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(prefix) = prefix.trim().parse::<u8>() else {
|
||||
return false;
|
||||
};
|
||||
match ip {
|
||||
std::net::IpAddr::V4(_) => prefix <= 32,
|
||||
std::net::IpAddr::V6(_) => prefix <= 128,
|
||||
}
|
||||
}
|
||||
|
||||
fn users_me_parse_management_token_allowed_ips(
|
||||
value: Option<&serde_json::Value>,
|
||||
) -> Result<Option<serde_json::Value>, String> {
|
||||
let Some(value) = value else {
|
||||
return Ok(None);
|
||||
};
|
||||
match value {
|
||||
serde_json::Value::Null => Ok(None),
|
||||
serde_json::Value::Array(items) => {
|
||||
if items.is_empty() {
|
||||
return Err("IP 白名单不能为空列表,如需取消限制请不提供此字段".to_string());
|
||||
}
|
||||
let mut normalized = Vec::with_capacity(items.len());
|
||||
for (index, item) in items.iter().enumerate() {
|
||||
let Some(raw) = item.as_str() else {
|
||||
return Err("IP 白名单必须是字符串数组".to_string());
|
||||
};
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(format!("IP 白名单第 {} 项为空", index + 1));
|
||||
}
|
||||
if !users_me_validate_ip_or_cidr(trimmed) {
|
||||
return Err(format!("无效的 IP 地址或 CIDR: {raw}"));
|
||||
}
|
||||
normalized.push(trimmed.to_string());
|
||||
}
|
||||
Ok(Some(json!(normalized)))
|
||||
}
|
||||
_ => Err("IP 白名单必须是字符串数组".to_string()),
|
||||
}
|
||||
parse_json_ip_rules(value)
|
||||
}
|
||||
|
||||
fn users_me_parse_management_token_expires_at(
|
||||
|
||||
@@ -36,8 +36,9 @@ pub(crate) use self::email_templates::{
|
||||
};
|
||||
pub(crate) use self::external_models::OFFICIAL_EXTERNAL_MODEL_PROVIDERS;
|
||||
pub(crate) use self::normalize::{
|
||||
deserialize_optional_json_patch, deserialize_optional_string_list_patch,
|
||||
normalize_feature_settings, normalize_json_array, normalize_json_object, normalize_string_list,
|
||||
deserialize_optional_json_patch, deserialize_optional_string_list_patch, ip_rules_allow,
|
||||
json_ip_rules_allow, normalize_feature_settings, normalize_ip_rules, normalize_json_array,
|
||||
normalize_json_object, normalize_string_list, parse_json_ip_rules,
|
||||
};
|
||||
pub(crate) use self::payloads::{
|
||||
InternalGatewayAuthContextRequest, InternalGatewayExecuteRequest,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::net::IpAddr;
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
@@ -63,6 +64,215 @@ pub(crate) fn normalize_feature_settings(value: Option<Value>) -> Result<Option<
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_ip_rules(
|
||||
values: Option<Vec<String>>,
|
||||
) -> Result<Option<Vec<String>>, String> {
|
||||
let Some(values) = values else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut normalized = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
for (index, raw) in values.into_iter().enumerate() {
|
||||
let rule = normalize_ip_rule(raw.trim())
|
||||
.map_err(|detail| format!("{detail}(第 {} 项)", index + 1))?;
|
||||
if seen.insert(rule.clone()) {
|
||||
normalized.push(rule);
|
||||
}
|
||||
}
|
||||
Ok((!normalized.is_empty()).then_some(normalized))
|
||||
}
|
||||
|
||||
pub(crate) fn parse_json_ip_rules(value: Option<&Value>) -> Result<Option<Value>, String> {
|
||||
let Some(value) = value else {
|
||||
return Ok(None);
|
||||
};
|
||||
match value {
|
||||
Value::Null => Ok(None),
|
||||
Value::Array(items) => {
|
||||
let mut values = Vec::with_capacity(items.len());
|
||||
for item in items {
|
||||
let Some(value) = item.as_str() else {
|
||||
return Err("IP 限制规则必须是字符串数组".to_string());
|
||||
};
|
||||
values.push(value.to_string());
|
||||
}
|
||||
Ok(normalize_ip_rules(Some(values))?.map(|rules| serde_json::json!(rules)))
|
||||
}
|
||||
_ => Err("IP 限制规则必须是字符串数组".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ip_rules_allow(rules: Option<&[String]>, remote_ip: IpAddr) -> bool {
|
||||
let Some(rules) = rules else {
|
||||
return true;
|
||||
};
|
||||
if rules.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let mut has_allow_rule = false;
|
||||
let mut matched_allow_rule = false;
|
||||
for raw in rules {
|
||||
let rule = raw.trim();
|
||||
if rule.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (deny, pattern) = match rule.strip_prefix('!') {
|
||||
Some(pattern) => (true, pattern.trim()),
|
||||
None => (false, rule),
|
||||
};
|
||||
let matched = ip_rule_pattern_matches(pattern, remote_ip);
|
||||
if deny && matched {
|
||||
return false;
|
||||
}
|
||||
if !deny {
|
||||
has_allow_rule = true;
|
||||
if matched {
|
||||
matched_allow_rule = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if has_allow_rule {
|
||||
matched_allow_rule
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn json_ip_rules_allow(value: Option<&Value>, remote_ip: IpAddr) -> bool {
|
||||
let Some(value) = value else {
|
||||
return true;
|
||||
};
|
||||
if value.is_null() {
|
||||
return true;
|
||||
}
|
||||
let Some(items) = value.as_array() else {
|
||||
return false;
|
||||
};
|
||||
let mut rules = Vec::with_capacity(items.len());
|
||||
for item in items {
|
||||
let Some(rule) = item.as_str() else {
|
||||
return false;
|
||||
};
|
||||
rules.push(rule.to_string());
|
||||
}
|
||||
ip_rules_allow(Some(&rules), remote_ip)
|
||||
}
|
||||
|
||||
fn normalize_ip_rule(raw: &str) -> Result<String, String> {
|
||||
if raw.is_empty() {
|
||||
return Err("IP 限制规则不能为空".to_string());
|
||||
}
|
||||
let (deny, pattern) = match raw.strip_prefix('!') {
|
||||
Some(pattern) => (true, pattern.trim()),
|
||||
None => (false, raw),
|
||||
};
|
||||
if pattern.is_empty() {
|
||||
return Err("IP 限制规则不能为空".to_string());
|
||||
}
|
||||
if !valid_ip_rule_pattern(pattern) {
|
||||
return Err(format!("无效的 IP 限制规则: {raw}"));
|
||||
}
|
||||
if deny {
|
||||
Ok(format!("!{pattern}"))
|
||||
} else {
|
||||
Ok(pattern.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_ip_rule_pattern(pattern: &str) -> bool {
|
||||
if pattern == "*" {
|
||||
return true;
|
||||
}
|
||||
if pattern.parse::<IpAddr>().is_ok() {
|
||||
return true;
|
||||
}
|
||||
if valid_cidr_pattern(pattern) {
|
||||
return true;
|
||||
}
|
||||
valid_ipv4_wildcard_pattern(pattern)
|
||||
}
|
||||
|
||||
fn valid_cidr_pattern(pattern: &str) -> bool {
|
||||
let Some((host, prefix)) = pattern.split_once('/') else {
|
||||
return false;
|
||||
};
|
||||
let Ok(ip) = host.trim().parse::<IpAddr>() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(prefix) = prefix.trim().parse::<u8>() else {
|
||||
return false;
|
||||
};
|
||||
match ip {
|
||||
IpAddr::V4(_) => prefix <= 32,
|
||||
IpAddr::V6(_) => prefix <= 128,
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_ipv4_wildcard_pattern(pattern: &str) -> bool {
|
||||
if !pattern.contains('*') {
|
||||
return false;
|
||||
}
|
||||
let parts = pattern.split('.').collect::<Vec<_>>();
|
||||
parts.len() == 4
|
||||
&& parts
|
||||
.iter()
|
||||
.all(|part| *part == "*" || part.parse::<u8>().is_ok())
|
||||
}
|
||||
|
||||
fn ip_rule_pattern_matches(pattern: &str, remote_ip: IpAddr) -> bool {
|
||||
if pattern == "*" {
|
||||
return true;
|
||||
}
|
||||
if let Ok(ip) = pattern.parse::<IpAddr>() {
|
||||
return ip == remote_ip;
|
||||
}
|
||||
if ipv4_wildcard_matches(pattern, remote_ip) {
|
||||
return true;
|
||||
}
|
||||
let Some((network, prefix)) = pattern.split_once('/') else {
|
||||
return false;
|
||||
};
|
||||
let Ok(prefix) = prefix.trim().parse::<u8>() else {
|
||||
return false;
|
||||
};
|
||||
match (network.trim().parse::<IpAddr>(), remote_ip) {
|
||||
(Ok(IpAddr::V4(network)), IpAddr::V4(remote)) if prefix <= 32 => {
|
||||
let mask = if prefix == 0 {
|
||||
0
|
||||
} else {
|
||||
u32::MAX << (32 - prefix)
|
||||
};
|
||||
(u32::from(network) & mask) == (u32::from(remote) & mask)
|
||||
}
|
||||
(Ok(IpAddr::V6(network)), IpAddr::V6(remote)) if prefix <= 128 => {
|
||||
let mask = if prefix == 0 {
|
||||
0
|
||||
} else {
|
||||
u128::MAX << (128 - prefix)
|
||||
};
|
||||
(u128::from(network) & mask) == (u128::from(remote) & mask)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn ipv4_wildcard_matches(pattern: &str, remote_ip: IpAddr) -> bool {
|
||||
let IpAddr::V4(remote_ip) = remote_ip else {
|
||||
return false;
|
||||
};
|
||||
if !valid_ipv4_wildcard_pattern(pattern) {
|
||||
return false;
|
||||
}
|
||||
pattern
|
||||
.split('.')
|
||||
.zip(remote_ip.octets())
|
||||
.all(|(pattern_part, remote_part)| {
|
||||
pattern_part == "*" || pattern_part.parse::<u8>() == Ok(remote_part)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn deserialize_optional_json_patch<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<Option<Value>>, D::Error>
|
||||
@@ -115,3 +325,80 @@ fn normalize_chat_pii_redaction_feature_object(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ip_rules_allow, json_ip_rules_allow, normalize_ip_rules, parse_json_ip_rules};
|
||||
use serde_json::json;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
|
||||
fn v4(a: u8, b: u8, c: u8, d: u8) -> IpAddr {
|
||||
IpAddr::V4(Ipv4Addr::new(a, b, c, d))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_ip_rules_accepts_ip_cidr_wildcard_and_deny_rules() {
|
||||
let rules = normalize_ip_rules(Some(vec![
|
||||
" 203.0.113.10 ".to_string(),
|
||||
"10.0.0.0/24".to_string(),
|
||||
"192.168.*.*".to_string(),
|
||||
"! 10.0.0.13 ".to_string(),
|
||||
"203.0.113.10".to_string(),
|
||||
]))
|
||||
.expect("valid IP rules should normalize");
|
||||
|
||||
assert_eq!(
|
||||
rules,
|
||||
Some(vec![
|
||||
"203.0.113.10".to_string(),
|
||||
"10.0.0.0/24".to_string(),
|
||||
"192.168.*.*".to_string(),
|
||||
"!10.0.0.13".to_string(),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ip_rules_allow_applies_allow_rules_and_deny_overrides() {
|
||||
let rules = vec![
|
||||
"10.0.0.0/24".to_string(),
|
||||
"192.168.*.*".to_string(),
|
||||
"!10.0.0.13".to_string(),
|
||||
];
|
||||
|
||||
assert!(ip_rules_allow(Some(&rules), v4(10, 0, 0, 12)));
|
||||
assert!(ip_rules_allow(Some(&rules), v4(192, 168, 2, 3)));
|
||||
assert!(!ip_rules_allow(Some(&rules), v4(10, 0, 0, 13)));
|
||||
assert!(!ip_rules_allow(Some(&rules), v4(203, 0, 113, 10)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ip_rules_allow_defaults_to_allow_when_only_deny_rules_exist() {
|
||||
let rules = vec!["!10.0.*.*".to_string()];
|
||||
|
||||
assert!(!ip_rules_allow(Some(&rules), v4(10, 0, 0, 13)));
|
||||
assert!(ip_rules_allow(Some(&rules), v4(203, 0, 113, 10)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_json_ip_rules_normalizes_empty_and_string_arrays() {
|
||||
assert_eq!(
|
||||
parse_json_ip_rules(Some(&json!([" 203.0.113.10 ", "!10.0.0.13"])))
|
||||
.expect("valid JSON IP rules should parse"),
|
||||
Some(json!(["203.0.113.10", "!10.0.0.13"])),
|
||||
);
|
||||
assert_eq!(
|
||||
parse_json_ip_rules(Some(&json!([]))).expect("empty rules should parse"),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_ip_rules_allow_rejects_invalid_stored_shape() {
|
||||
assert!(!json_ip_rules_allow(
|
||||
Some(&json!({"bad": true})),
|
||||
v4(10, 0, 0, 1)
|
||||
));
|
||||
assert!(!json_ip_rules_allow(Some(&json!([123])), v4(10, 0, 0, 1)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,7 +498,7 @@ mod tests {
|
||||
admin_bypass_limits: false,
|
||||
local_rejection: None,
|
||||
allowed_models: None,
|
||||
allowed_ips: None,
|
||||
ip_rules: None,
|
||||
});
|
||||
let state = AppState::new().expect("state should build for tests");
|
||||
|
||||
@@ -538,7 +538,7 @@ mod tests {
|
||||
admin_bypass_limits: false,
|
||||
local_rejection: None,
|
||||
allowed_models: None,
|
||||
allowed_ips: None,
|
||||
ip_rules: None,
|
||||
});
|
||||
let state = AppState::new().expect("state should build for tests");
|
||||
|
||||
@@ -578,7 +578,7 @@ mod tests {
|
||||
admin_bypass_limits: true,
|
||||
local_rejection: None,
|
||||
allowed_models: None,
|
||||
allowed_ips: None,
|
||||
ip_rules: None,
|
||||
});
|
||||
let state = AppState::new().expect("state should build for tests");
|
||||
|
||||
@@ -622,7 +622,7 @@ mod tests {
|
||||
admin_bypass_limits: false,
|
||||
local_rejection: None,
|
||||
allowed_models: None,
|
||||
allowed_ips: None,
|
||||
ip_rules: None,
|
||||
});
|
||||
let state = AppState::new().expect("state should build for tests");
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ pub(super) fn sample_auth_snapshot(api_key_id: &str) -> GatewayAuthApiKeySnapsho
|
||||
api_key_allowed_providers: None,
|
||||
api_key_allowed_api_formats: None,
|
||||
api_key_allowed_models: None,
|
||||
api_key_allowed_ips: None,
|
||||
api_key_ip_rules: None,
|
||||
currently_usable: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -927,7 +927,7 @@ mod tests {
|
||||
admin_bypass_limits: false,
|
||||
local_rejection: None,
|
||||
allowed_models: Some(vec!["gpt-4.1".to_string()]),
|
||||
allowed_ips: None,
|
||||
ip_rules: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ pub(super) fn sample_auth_context() -> GatewayControlAuthContext {
|
||||
admin_bypass_limits: false,
|
||||
local_rejection: None,
|
||||
allowed_models: None,
|
||||
allowed_ips: None,
|
||||
ip_rules: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -307,7 +307,7 @@ mod tests {
|
||||
api_key_allowed_providers: None,
|
||||
api_key_allowed_api_formats: None,
|
||||
api_key_allowed_models: None,
|
||||
api_key_allowed_ips: None,
|
||||
api_key_ip_rules: None,
|
||||
currently_usable: true,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user