refactor(gateway): 统一 AETHER_GATEWAY_BIND 为 APP_PORT,新增 API Key 前缀配置和启动自举管理员

- 绑定地址固定 0.0.0.0,仅通过 APP_PORT 控制端口,简化 CLI/Docker/systemd/dev.sh/前端代理全链路
- 新增 API_KEY_PREFIX 环境变量,抽取 handlers/shared/api_keys.rs 消除 admin/public 重复逻辑
- 新增 bootstrap_admin.rs,启动时通过 ADMIN_* 环境变量在无管理员时自动创建首个本地管理员
- 前端密码输入改用 type=password,API Key 占位符改为动态前缀
- 删除过时的 pyproject.toml/uv.lock 和旧部署文档
- 更新 .env.example/README 反映新配置项
This commit is contained in:
fawney19
2026-04-11 17:39:02 +08:00
parent a570a77cca
commit 801e16c988
30 changed files with 867 additions and 3929 deletions

View File

@@ -0,0 +1,509 @@
use crate::{AppState, GatewayError};
use aether_data::repository::wallet::WalletLookupKey;
use regex::Regex;
use tracing::{info, warn};
const BOOTSTRAP_ADMIN_EMAIL_ENVS: &[&str] = &["ADMIN_EMAIL"];
const BOOTSTRAP_ADMIN_USERNAME_ENVS: &[&str] = &["ADMIN_USERNAME"];
const BOOTSTRAP_ADMIN_PASSWORD_ENVS: &[&str] = &["ADMIN_PASSWORD"];
#[derive(Debug, Clone, PartialEq, Eq)]
struct BootstrapAdminConfig {
email: Option<String>,
username: String,
password: String,
}
impl BootstrapAdminConfig {
fn from_env() -> Result<Option<Self>, GatewayError> {
Self::from_lookup(|key| {
std::env::var(key)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
})
}
fn from_lookup<F>(lookup: F) -> Result<Option<Self>, GatewayError>
where
F: Fn(&str) -> Option<String>,
{
let email = first_present_env(&lookup, BOOTSTRAP_ADMIN_EMAIL_ENVS);
let username = first_present_env(&lookup, BOOTSTRAP_ADMIN_USERNAME_ENVS);
let password = first_present_env(&lookup, BOOTSTRAP_ADMIN_PASSWORD_ENVS);
if email.is_none() && username.is_none() && password.is_none() {
return Ok(None);
}
let username = username.ok_or_else(|| {
GatewayError::Internal(format!(
"bootstrap admin env is partially configured; set {}",
BOOTSTRAP_ADMIN_USERNAME_ENVS.join(" or ")
))
})?;
let password = password.ok_or_else(|| {
GatewayError::Internal(format!(
"bootstrap admin env is partially configured; set {}",
BOOTSTRAP_ADMIN_PASSWORD_ENVS.join(" or ")
))
})?;
Ok(Some(Self {
email,
username,
password,
}))
}
}
fn first_present_env<F>(lookup: &F, keys: &[&str]) -> Option<String>
where
F: Fn(&str) -> Option<String>,
{
keys.iter().find_map(|key| lookup(key))
}
fn normalize_bootstrap_admin_email(value: Option<&str>) -> Result<Option<String>, GatewayError> {
let Some(value) = value else {
return Ok(None);
};
let normalized = value.trim().to_ascii_lowercase();
if normalized.is_empty() {
return Ok(None);
}
let pattern = Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
.expect("bootstrap admin email regex should compile");
if !pattern.is_match(&normalized) {
return Err(GatewayError::Internal(
"bootstrap admin email format is invalid".to_string(),
));
}
Ok(Some(normalized))
}
fn normalize_bootstrap_admin_username(value: &str) -> Result<String, GatewayError> {
let value = value.trim();
if value.is_empty() {
return Err(GatewayError::Internal(
"bootstrap admin username cannot be empty".to_string(),
));
}
if value.len() < 3 {
return Err(GatewayError::Internal(
"bootstrap admin username must be at least 3 characters".to_string(),
));
}
if value.len() > 30 {
return Err(GatewayError::Internal(
"bootstrap admin username must not exceed 30 characters".to_string(),
));
}
let pattern =
Regex::new(r"^[a-zA-Z0-9_.-]+$").expect("bootstrap admin username regex should compile");
if !pattern.is_match(value) {
return Err(GatewayError::Internal(
"bootstrap admin username may only contain letters, numbers, underscores, hyphens, and dots".to_string(),
));
}
Ok(value.to_string())
}
fn validate_bootstrap_admin_password(password: &str, policy: &str) -> Result<(), GatewayError> {
if password.is_empty() {
return Err(GatewayError::Internal(
"bootstrap admin password cannot be empty".to_string(),
));
}
if password.as_bytes().len() > 72 {
return Err(GatewayError::Internal(
"bootstrap admin password must not exceed 72 bytes".to_string(),
));
}
let min_len = if matches!(policy, "medium" | "strong") {
8
} else {
6
};
if password.chars().count() < min_len {
return Err(GatewayError::Internal(format!(
"bootstrap admin password must be at least {min_len} characters"
)));
}
if policy == "medium" {
if !password.chars().any(|ch| ch.is_ascii_alphabetic()) {
return Err(GatewayError::Internal(
"bootstrap admin password must contain at least one letter".to_string(),
));
}
if !password.chars().any(|ch| ch.is_ascii_digit()) {
return Err(GatewayError::Internal(
"bootstrap admin password must contain at least one digit".to_string(),
));
}
} else if policy == "strong" {
if !password.chars().any(|ch| ch.is_ascii_uppercase()) {
return Err(GatewayError::Internal(
"bootstrap admin password must contain at least one uppercase letter".to_string(),
));
}
if !password.chars().any(|ch| ch.is_ascii_lowercase()) {
return Err(GatewayError::Internal(
"bootstrap admin password must contain at least one lowercase letter".to_string(),
));
}
if !password.chars().any(|ch| ch.is_ascii_digit()) {
return Err(GatewayError::Internal(
"bootstrap admin password must contain at least one digit".to_string(),
));
}
if !password.chars().any(|ch| !ch.is_ascii_alphanumeric()) {
return Err(GatewayError::Internal(
"bootstrap admin password must contain at least one special character".to_string(),
));
}
}
Ok(())
}
async fn resolve_bootstrap_admin_password_policy(state: &AppState) -> Result<String, GatewayError> {
let configured = state
.read_system_config_json_value("password_policy_level")
.await?;
Ok(match configured.as_ref() {
Some(serde_json::Value::String(value))
if matches!(value.trim(), "weak" | "medium" | "strong") =>
{
value.trim().to_string()
}
_ => "weak".to_string(),
})
}
async fn find_existing_bootstrap_user(
state: &AppState,
username: &str,
email: Option<&str>,
) -> Result<Option<aether_data::repository::users::StoredUserAuthRecord>, GatewayError> {
let by_username = state.find_user_auth_by_identifier(username).await?;
let by_email = match email {
Some(email) => state.find_user_auth_by_identifier(email).await?,
None => None,
};
match (by_username, by_email) {
(Some(username_user), Some(email_user)) if username_user.id != email_user.id => {
Err(GatewayError::Internal(
"bootstrap admin username and email point to different existing users".to_string(),
))
}
(Some(user), _) | (_, Some(user)) => Ok(Some(user)),
(None, None) => Ok(None),
}
}
async fn ensure_bootstrap_admin_wallet(
state: &AppState,
user_id: &str,
) -> Result<(), GatewayError> {
if state
.find_wallet(WalletLookupKey::UserId(user_id))
.await?
.is_some()
{
return Ok(());
}
let created = state
.initialize_auth_user_wallet(user_id, 0.0, true)
.await?;
if created.is_none() {
return Err(GatewayError::Internal(
"bootstrap admin wallet storage is unavailable".to_string(),
));
}
Ok(())
}
impl AppState {
pub async fn bootstrap_admin_from_env(&self) -> Result<(), std::io::Error> {
let Some(config) = BootstrapAdminConfig::from_env()
.map_err(|err| std::io::Error::other(format!("{err:?}")))?
else {
return Ok(());
};
self.bootstrap_admin_from_config(config)
.await
.map_err(|err| std::io::Error::other(format!("{err:?}")))
}
async fn bootstrap_admin_from_config(
&self,
config: BootstrapAdminConfig,
) -> Result<(), GatewayError> {
if !self.has_auth_user_write_capability() || !self.has_auth_wallet_write_capability() {
return Err(GatewayError::Internal(
"bootstrap admin requires Postgres-backed user and wallet write capability"
.to_string(),
));
}
let email = normalize_bootstrap_admin_email(config.email.as_deref())?;
let username = normalize_bootstrap_admin_username(&config.username)?;
let password_policy = resolve_bootstrap_admin_password_policy(self).await?;
validate_bootstrap_admin_password(&config.password, &password_policy)?;
if let Some(existing_user) =
find_existing_bootstrap_user(self, &username, email.as_deref()).await?
{
if !existing_user.role.eq_ignore_ascii_case("admin")
|| !existing_user.auth_source.eq_ignore_ascii_case("local")
|| !existing_user.is_active
|| existing_user.is_deleted
{
return Err(GatewayError::Internal(format!(
"bootstrap admin target already exists but is not an active local admin: {}",
existing_user.username
)));
}
ensure_bootstrap_admin_wallet(self, &existing_user.id).await?;
info!(
event_name = "bootstrap_admin_ready",
log_type = "ops",
user_id = %existing_user.id,
username = %existing_user.username,
email = existing_user.email.as_deref().unwrap_or("-"),
status = "existing",
"bootstrap admin already exists"
);
return Ok(());
}
if self.count_active_admin_users().await? > 0 {
info!(
event_name = "bootstrap_admin_skipped",
log_type = "ops",
username = %username,
email = email.as_deref().unwrap_or("-"),
status = "active_admin_exists",
"bootstrap admin skipped because another active admin already exists"
);
return Ok(());
}
let password_hash =
bcrypt::hash(&config.password, bcrypt::DEFAULT_COST).map_err(|err| {
GatewayError::Internal(format!("bootstrap admin password hash failed: {err}"))
})?;
match self
.create_local_auth_user_with_settings(
email.clone(),
true,
username.clone(),
password_hash,
"admin".to_string(),
None,
None,
None,
None,
)
.await
{
Ok(Some(user)) => {
ensure_bootstrap_admin_wallet(self, &user.id).await?;
info!(
event_name = "bootstrap_admin_created",
log_type = "ops",
user_id = %user.id,
username = %user.username,
email = user.email.as_deref().unwrap_or("-"),
status = "created",
"bootstrap admin created from environment"
);
Ok(())
}
Ok(None) => Err(GatewayError::Internal(
"bootstrap admin user storage is unavailable".to_string(),
)),
Err(err) => {
if let Some(user) =
find_existing_bootstrap_user(self, &username, email.as_deref()).await?
{
ensure_bootstrap_admin_wallet(self, &user.id).await?;
warn!(
event_name = "bootstrap_admin_race_resolved",
log_type = "ops",
username = %user.username,
user_id = %user.id,
error = ?err,
"bootstrap admin creation raced with another writer; continuing with existing admin"
);
return Ok(());
}
Err(err)
}
}
}
}
#[cfg(test)]
mod tests {
use super::BootstrapAdminConfig;
use crate::AppState;
use aether_data::repository::wallet::WalletLookupKey;
fn bootstrap_config() -> BootstrapAdminConfig {
BootstrapAdminConfig {
email: Some("admin@example.com".to_string()),
username: "admin".to_string(),
password: "Secret123!".to_string(),
}
}
fn sample_local_admin(
user_id: &str,
username: &str,
email: Option<&str>,
) -> aether_data::repository::users::StoredUserAuthRecord {
aether_data::repository::users::StoredUserAuthRecord::new(
user_id.to_string(),
email.map(|value| value.to_string()),
true,
username.to_string(),
Some(
bcrypt::hash("Secret123!", bcrypt::DEFAULT_COST)
.expect("sample admin password hash should build"),
),
"admin".to_string(),
"local".to_string(),
None,
None,
None,
true,
false,
Some(chrono::Utc::now()),
None,
)
.expect("sample admin should build")
}
#[tokio::test]
async fn bootstrap_admin_creates_missing_admin_and_wallet() {
let state = AppState::new().expect("state should build");
state
.bootstrap_admin_from_config(bootstrap_config())
.await
.expect("bootstrap should succeed");
let user = state
.find_user_auth_by_identifier("admin")
.await
.expect("lookup should succeed")
.expect("admin should exist");
assert_eq!(user.role, "admin");
assert_eq!(user.auth_source, "local");
assert_eq!(user.email.as_deref(), Some("admin@example.com"));
assert!(bcrypt::verify(
"Secret123!",
user.password_hash
.as_deref()
.expect("password hash should exist")
)
.expect("password hash should verify"));
let wallet = state
.find_wallet(WalletLookupKey::UserId(&user.id))
.await
.expect("wallet lookup should succeed")
.expect("wallet should exist");
assert_eq!(wallet.limit_mode, "unlimited");
}
#[tokio::test]
async fn bootstrap_admin_repairs_missing_wallet_for_existing_matching_admin() {
let existing = sample_local_admin("admin-user-1", "admin", Some("admin@example.com"));
let state = AppState::new()
.expect("state should build")
.with_auth_users_for_tests([existing.clone()]);
state
.bootstrap_admin_from_config(bootstrap_config())
.await
.expect("bootstrap should succeed");
let wallet = state
.find_wallet(WalletLookupKey::UserId(&existing.id))
.await
.expect("wallet lookup should succeed")
.expect("wallet should exist");
assert_eq!(wallet.limit_mode, "unlimited");
}
#[tokio::test]
async fn bootstrap_admin_rejects_existing_non_admin_collision() {
let existing = aether_data::repository::users::StoredUserAuthRecord::new(
"user-1".to_string(),
Some("admin@example.com".to_string()),
true,
"admin".to_string(),
Some(
bcrypt::hash("Secret123!", bcrypt::DEFAULT_COST)
.expect("sample password hash should build"),
),
"user".to_string(),
"local".to_string(),
None,
None,
None,
true,
false,
Some(chrono::Utc::now()),
None,
)
.expect("sample user should build");
let state = AppState::new()
.expect("state should build")
.with_auth_users_for_tests([existing]);
let err = state
.bootstrap_admin_from_config(bootstrap_config())
.await
.expect_err("bootstrap should fail");
let detail = format!("{err:?}");
assert!(detail.contains("not an active local admin"));
}
#[test]
fn bootstrap_admin_config_reads_admin_env_names() {
let vars = std::collections::BTreeMap::from([
("ADMIN_EMAIL".to_string(), "admin@example.com".to_string()),
("ADMIN_USERNAME".to_string(), "admin".to_string()),
("ADMIN_PASSWORD".to_string(), "Secret123!".to_string()),
]);
let config = BootstrapAdminConfig::from_lookup(|key| vars.get(key).cloned())
.expect("config parsing should succeed")
.expect("config should exist");
assert_eq!(
config,
BootstrapAdminConfig {
email: Some("admin@example.com".to_string()),
username: "admin".to_string(),
password: "Secret123!".to_string(),
}
);
}
#[test]
fn bootstrap_admin_config_rejects_partial_env() {
let vars =
std::collections::BTreeMap::from([("ADMIN_USERNAME".to_string(), "admin".to_string())]);
let err = BootstrapAdminConfig::from_lookup(|key| vars.get(key).cloned())
.expect_err("partial config should fail");
let detail = format!("{err:?}");
assert!(detail.contains("bootstrap admin env is partially configured"));
}
}

View File

@@ -3,6 +3,7 @@ use super::error::GatewayError;
mod admin_types;
mod app;
mod bootstrap_admin;
mod cache;
mod catalog;
mod core;