mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
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:
@@ -22,12 +22,13 @@ use crate::constants::{
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::execution_runtime::remote_compat::execute_sync_plan_via_remote_execution_runtime;
|
||||
use crate::frontdoor_loop_guard::gateway_frontdoor_self_loop_guard_error;
|
||||
use crate::frontdoor_loop_guard::{
|
||||
configured_gateway_frontdoor_base_url, gateway_frontdoor_self_loop_guard_error,
|
||||
};
|
||||
use crate::{AppState, GatewayError};
|
||||
|
||||
const HUB_RELAY_CONTENT_TYPE: &str = "application/vnd.aether.tunnel-envelope";
|
||||
const HUB_RELAY_ERROR_HEADER: &str = "x-aether-tunnel-error";
|
||||
const DEFAULT_TUNNEL_BASE_URL: &str = "http://127.0.0.1:8084";
|
||||
const TUNNEL_RELAY_PATH_PREFIX: &str = "/api/internal/tunnel/relay";
|
||||
const CLAUDE_CODE_TLS_PROFILE: &str = "claude_code_nodejs";
|
||||
|
||||
@@ -436,7 +437,7 @@ fn build_relay_url(proxy: Option<&ProxySnapshot>, node_id: &str) -> String {
|
||||
let base_url = proxy
|
||||
.and_then(resolve_tunnel_base_url_from_proxy)
|
||||
.or_else(|| std::env::var("AETHER_TUNNEL_BASE_URL").ok())
|
||||
.unwrap_or_else(|| DEFAULT_TUNNEL_BASE_URL.to_string());
|
||||
.unwrap_or_else(configured_gateway_frontdoor_base_url);
|
||||
format!(
|
||||
"{}{}/{}",
|
||||
base_url.trim_end_matches('/'),
|
||||
@@ -686,26 +687,26 @@ mod tests {
|
||||
|
||||
use super::DirectSyncExecutionRuntime;
|
||||
use crate::frontdoor_loop_guard::{
|
||||
frontdoor_self_loop_public_ai_path, gateway_frontdoor_self_loop_guard_error_with_bind,
|
||||
gateway_frontdoor_self_loop_guard_matches_with_bind,
|
||||
frontdoor_self_loop_public_ai_path, gateway_frontdoor_self_loop_guard_error_with_port,
|
||||
gateway_frontdoor_self_loop_guard_matches_with_port,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn gateway_frontdoor_self_loop_guard_matches_loopback_public_ai_route() {
|
||||
assert!(gateway_frontdoor_self_loop_guard_matches_with_bind(
|
||||
"0.0.0.0:8084",
|
||||
assert!(gateway_frontdoor_self_loop_guard_matches_with_port(
|
||||
8084,
|
||||
"http://127.0.0.1:8084/v1/messages"
|
||||
));
|
||||
assert!(gateway_frontdoor_self_loop_guard_matches_with_bind(
|
||||
"0.0.0.0:8084",
|
||||
assert!(gateway_frontdoor_self_loop_guard_matches_with_port(
|
||||
8084,
|
||||
"http://localhost:8084/v1/responses"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_frontdoor_self_loop_guard_ignores_non_ai_routes() {
|
||||
assert!(!gateway_frontdoor_self_loop_guard_matches_with_bind(
|
||||
"0.0.0.0:8084",
|
||||
assert!(!gateway_frontdoor_self_loop_guard_matches_with_port(
|
||||
8084,
|
||||
"http://127.0.0.1:8084/_gateway/health"
|
||||
));
|
||||
assert!(!frontdoor_self_loop_public_ai_path("/_gateway/health"));
|
||||
@@ -713,8 +714,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn gateway_frontdoor_self_loop_guard_ignores_different_ports() {
|
||||
assert!(!gateway_frontdoor_self_loop_guard_matches_with_bind(
|
||||
"0.0.0.0:8084",
|
||||
assert!(!gateway_frontdoor_self_loop_guard_matches_with_port(
|
||||
8084,
|
||||
"http://127.0.0.1:9999/v1/messages"
|
||||
));
|
||||
}
|
||||
@@ -722,8 +723,8 @@ mod tests {
|
||||
#[test]
|
||||
fn gateway_frontdoor_self_loop_guard_reports_clear_error() {
|
||||
assert_eq!(
|
||||
gateway_frontdoor_self_loop_guard_error_with_bind(
|
||||
"0.0.0.0:8084",
|
||||
gateway_frontdoor_self_loop_guard_error_with_port(
|
||||
8084,
|
||||
"http://localhost:8084/v1/responses"
|
||||
),
|
||||
Some(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
use url::Url;
|
||||
|
||||
@@ -7,19 +9,9 @@ use crate::constants::{
|
||||
};
|
||||
use crate::headers::header_value_str;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum GatewayBindHostKind {
|
||||
AnyLocal,
|
||||
Loopback,
|
||||
Exact,
|
||||
}
|
||||
const DEFAULT_APP_PORT: u16 = 8084;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct GatewayBindTarget {
|
||||
host_kind: GatewayBindHostKind,
|
||||
host: String,
|
||||
port: u16,
|
||||
}
|
||||
static GATEWAY_FRONTDOOR_APP_PORT: OnceLock<u16> = OnceLock::new();
|
||||
|
||||
pub(crate) fn request_has_execution_runtime_loop_guard(headers: &HeaderMap) -> bool {
|
||||
header_value_str(headers, EXECUTION_RUNTIME_LOOP_GUARD_HEADER)
|
||||
@@ -57,32 +49,39 @@ pub(crate) fn frontdoor_self_loop_public_ai_path(path: &str) -> bool {
|
||||
|| is_gemini_generation_path(path)
|
||||
}
|
||||
|
||||
pub(crate) fn gateway_frontdoor_self_loop_guard_error(url: &str) -> Option<String> {
|
||||
let Some(bind) = std::env::var("AETHER_GATEWAY_BIND")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
gateway_frontdoor_self_loop_guard_error_with_bind(bind.as_str(), url)
|
||||
pub fn set_gateway_frontdoor_app_port(app_port: u16) {
|
||||
let _ = GATEWAY_FRONTDOOR_APP_PORT.set(app_port);
|
||||
}
|
||||
|
||||
pub(crate) fn gateway_frontdoor_self_loop_guard_error_with_bind(
|
||||
bind: &str,
|
||||
pub(crate) fn configured_gateway_frontdoor_base_url() -> String {
|
||||
format!(
|
||||
"http://127.0.0.1:{}",
|
||||
configured_gateway_frontdoor_app_port()
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn gateway_frontdoor_self_loop_guard_error(url: &str) -> Option<String> {
|
||||
gateway_frontdoor_self_loop_guard_error_with_port(configured_gateway_frontdoor_app_port(), url)
|
||||
}
|
||||
|
||||
pub(crate) fn gateway_frontdoor_self_loop_guard_error_with_port(
|
||||
app_port: u16,
|
||||
url: &str,
|
||||
) -> Option<String> {
|
||||
gateway_frontdoor_self_loop_guard_matches_with_bind(bind, url).then(|| {
|
||||
gateway_frontdoor_self_loop_guard_matches_with_port(app_port, url).then(|| {
|
||||
format!(
|
||||
"upstream execution target resolves back to the local aether-gateway frontdoor: {url}"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn gateway_frontdoor_self_loop_guard_matches_with_bind(bind: &str, url: &str) -> bool {
|
||||
let Some(bind_target) = parse_gateway_bind_target(bind) else {
|
||||
pub(crate) fn gateway_frontdoor_self_loop_guard_matches_with_port(
|
||||
app_port: u16,
|
||||
url: &str,
|
||||
) -> bool {
|
||||
if app_port == 0 {
|
||||
return false;
|
||||
};
|
||||
}
|
||||
let Some(target_url) = Url::parse(url).ok() else {
|
||||
return false;
|
||||
};
|
||||
@@ -96,17 +95,11 @@ pub(crate) fn gateway_frontdoor_self_loop_guard_matches_with_bind(bind: &str, ur
|
||||
let Some(target_port) = target_url.port_or_known_default() else {
|
||||
return false;
|
||||
};
|
||||
if target_port != bind_target.port {
|
||||
if target_port != app_port {
|
||||
return false;
|
||||
}
|
||||
|
||||
let target_host = normalize_host_for_frontdoor_loop_guard(target_host);
|
||||
match bind_target.host_kind {
|
||||
GatewayBindHostKind::AnyLocal | GatewayBindHostKind::Loopback => {
|
||||
is_loopbackish_host(target_host.as_str())
|
||||
}
|
||||
GatewayBindHostKind::Exact => target_host == bind_target.host,
|
||||
}
|
||||
is_loopbackish_host(normalize_host_for_frontdoor_loop_guard(target_host).as_str())
|
||||
}
|
||||
|
||||
fn is_gemini_generation_path(path: &str) -> bool {
|
||||
@@ -119,57 +112,19 @@ fn is_gemini_generation_path(path: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_gateway_bind_target(bind: &str) -> Option<GatewayBindTarget> {
|
||||
let trimmed = bind.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Ok(socket_addr) = trimmed.parse::<std::net::SocketAddr>() {
|
||||
let (host_kind, host) = match socket_addr.ip() {
|
||||
std::net::IpAddr::V4(ip) if ip.is_unspecified() => {
|
||||
(GatewayBindHostKind::AnyLocal, "0.0.0.0".to_string())
|
||||
}
|
||||
std::net::IpAddr::V4(ip) if ip.is_loopback() => {
|
||||
(GatewayBindHostKind::Loopback, ip.to_string())
|
||||
}
|
||||
std::net::IpAddr::V4(ip) => (GatewayBindHostKind::Exact, ip.to_string()),
|
||||
std::net::IpAddr::V6(ip) if ip.is_unspecified() => {
|
||||
(GatewayBindHostKind::AnyLocal, "::".to_string())
|
||||
}
|
||||
std::net::IpAddr::V6(ip) if ip.is_loopback() => {
|
||||
(GatewayBindHostKind::Loopback, ip.to_string())
|
||||
}
|
||||
std::net::IpAddr::V6(ip) => (GatewayBindHostKind::Exact, ip.to_string()),
|
||||
};
|
||||
return Some(GatewayBindTarget {
|
||||
host_kind,
|
||||
host,
|
||||
port: socket_addr.port(),
|
||||
});
|
||||
}
|
||||
|
||||
let (host, port) = trimmed.rsplit_once(':')?;
|
||||
let port = port.parse::<u16>().ok()?;
|
||||
let host = host.trim().trim_start_matches('[').trim_end_matches(']');
|
||||
if host.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let normalized_host = normalize_host_for_frontdoor_loop_guard(host);
|
||||
let host_kind = if matches!(normalized_host.as_str(), "0.0.0.0" | "::") {
|
||||
GatewayBindHostKind::AnyLocal
|
||||
} else if is_loopbackish_host(normalized_host.as_str()) {
|
||||
GatewayBindHostKind::Loopback
|
||||
} else {
|
||||
GatewayBindHostKind::Exact
|
||||
};
|
||||
|
||||
Some(GatewayBindTarget {
|
||||
host_kind,
|
||||
host: normalized_host,
|
||||
port,
|
||||
})
|
||||
fn configured_gateway_frontdoor_app_port() -> u16 {
|
||||
GATEWAY_FRONTDOOR_APP_PORT
|
||||
.get()
|
||||
.copied()
|
||||
.or_else(|| {
|
||||
std::env::var("APP_PORT")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.and_then(|value| value.parse::<u16>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
})
|
||||
.unwrap_or(DEFAULT_APP_PORT)
|
||||
}
|
||||
|
||||
fn normalize_host_for_frontdoor_loop_guard(host: &str) -> String {
|
||||
|
||||
@@ -2,6 +2,9 @@ use crate::handlers::admin::request::AdminAppState;
|
||||
use crate::handlers::admin::shared::{
|
||||
attach_admin_audit_response, decrypt_catalog_secret_with_fallbacks,
|
||||
};
|
||||
use crate::handlers::shared::{
|
||||
api_key_placeholder_display, generate_gateway_api_key_plaintext, masked_gateway_api_key_display,
|
||||
};
|
||||
use axum::{body::Body, response::Response};
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeSet;
|
||||
@@ -17,20 +20,13 @@ pub(crate) fn masked_user_api_key_display(
|
||||
ciphertext: Option<&str>,
|
||||
) -> String {
|
||||
let Some(ciphertext) = ciphertext.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return "sk-****".to_string();
|
||||
return api_key_placeholder_display();
|
||||
};
|
||||
let Some(full_key) = decrypt_catalog_secret_with_fallbacks(state.encryption_key(), ciphertext)
|
||||
else {
|
||||
return "sk-****".to_string();
|
||||
return api_key_placeholder_display();
|
||||
};
|
||||
let prefix_len = full_key.len().min(10);
|
||||
let prefix = &full_key[..prefix_len];
|
||||
let suffix = if full_key.len() >= 4 {
|
||||
&full_key[full_key.len() - 4..]
|
||||
} else {
|
||||
""
|
||||
};
|
||||
format!("{prefix}...{suffix}")
|
||||
masked_gateway_api_key_display(Some(full_key.as_str()))
|
||||
}
|
||||
|
||||
pub(super) fn build_admin_user_api_key_detail_payload(
|
||||
@@ -89,9 +85,7 @@ pub(super) fn normalize_admin_api_key_providers(
|
||||
}
|
||||
|
||||
pub(crate) fn generate_admin_user_api_key_plaintext() -> String {
|
||||
let first = uuid::Uuid::new_v4().simple().to_string();
|
||||
let second = uuid::Uuid::new_v4().simple().to_string();
|
||||
format!("sk-{}{}", first, &second[..16])
|
||||
generate_gateway_api_key_plaintext()
|
||||
}
|
||||
|
||||
pub(crate) fn hash_admin_user_api_key(value: &str) -> String {
|
||||
|
||||
@@ -9,6 +9,10 @@ use axum::{
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::handlers::shared::{
|
||||
api_key_placeholder_display, generate_gateway_api_key_plaintext, masked_gateway_api_key_display,
|
||||
};
|
||||
|
||||
use super::{
|
||||
build_auth_error_response, decrypt_catalog_secret_with_fallbacks,
|
||||
encrypt_catalog_secret_with_fallbacks, format_users_me_optional_unix_secs_iso8601,
|
||||
@@ -111,20 +115,13 @@ pub(super) fn users_me_api_key_capabilities_path_matches(request_path: &str) ->
|
||||
|
||||
fn users_me_masked_api_key_display(state: &AppState, ciphertext: Option<&str>) -> String {
|
||||
let Some(ciphertext) = ciphertext.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return "sk-****".to_string();
|
||||
return api_key_placeholder_display();
|
||||
};
|
||||
let Some(full_key) = decrypt_catalog_secret_with_fallbacks(state.encryption_key(), ciphertext)
|
||||
else {
|
||||
return "sk-****".to_string();
|
||||
return api_key_placeholder_display();
|
||||
};
|
||||
let prefix_len = full_key.len().min(10);
|
||||
let prefix = &full_key[..prefix_len];
|
||||
let suffix = if full_key.len() >= 4 {
|
||||
&full_key[full_key.len() - 4..]
|
||||
} else {
|
||||
""
|
||||
};
|
||||
format!("{prefix}...{suffix}")
|
||||
masked_gateway_api_key_display(Some(full_key.as_str()))
|
||||
}
|
||||
|
||||
fn build_users_me_api_key_writer_unavailable_response() -> Response<Body> {
|
||||
@@ -185,9 +182,7 @@ fn normalize_users_me_required_api_key_name(value: &str) -> Result<String, Strin
|
||||
}
|
||||
|
||||
fn generate_users_me_api_key_plaintext() -> String {
|
||||
let first = uuid::Uuid::new_v4().simple().to_string();
|
||||
let second = uuid::Uuid::new_v4().simple().to_string();
|
||||
format!("sk-{}{}", first, &second[..16])
|
||||
generate_gateway_api_key_plaintext()
|
||||
}
|
||||
|
||||
fn hash_users_me_api_key(value: &str) -> String {
|
||||
|
||||
108
apps/aether-gateway/src/handlers/shared/api_keys.rs
Normal file
108
apps/aether-gateway/src/handlers/shared/api_keys.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
const DEFAULT_API_KEY_PREFIX: &str = "sk";
|
||||
|
||||
fn configured_api_key_prefix_from_lookup<F>(lookup: F) -> String
|
||||
where
|
||||
F: Fn(&str) -> Option<String>,
|
||||
{
|
||||
lookup("API_KEY_PREFIX")
|
||||
.as_deref()
|
||||
.map(normalize_api_key_prefix)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| DEFAULT_API_KEY_PREFIX.to_string())
|
||||
}
|
||||
|
||||
fn normalize_api_key_prefix(value: &str) -> String {
|
||||
let normalized = value.trim().trim_end_matches('-').trim();
|
||||
if normalized.is_empty() {
|
||||
return DEFAULT_API_KEY_PREFIX.to_string();
|
||||
}
|
||||
normalized.to_string()
|
||||
}
|
||||
|
||||
fn api_key_placeholder_display_with_prefix(prefix: &str) -> String {
|
||||
format!("{prefix}-****")
|
||||
}
|
||||
|
||||
fn generate_gateway_api_key_plaintext_with_prefix(prefix: &str) -> String {
|
||||
let first = uuid::Uuid::new_v4().simple().to_string();
|
||||
let second = uuid::Uuid::new_v4().simple().to_string();
|
||||
format!("{prefix}-{}{}", first, &second[..16])
|
||||
}
|
||||
|
||||
pub(crate) fn configured_api_key_prefix() -> String {
|
||||
configured_api_key_prefix_from_lookup(|key| {
|
||||
std::env::var(key)
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn api_key_placeholder_display() -> String {
|
||||
api_key_placeholder_display_with_prefix(&configured_api_key_prefix())
|
||||
}
|
||||
|
||||
pub(crate) fn generate_gateway_api_key_plaintext() -> String {
|
||||
generate_gateway_api_key_plaintext_with_prefix(&configured_api_key_prefix())
|
||||
}
|
||||
|
||||
pub(crate) fn masked_gateway_api_key_display(full_key: Option<&str>) -> String {
|
||||
let Some(full_key) = full_key.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return api_key_placeholder_display();
|
||||
};
|
||||
let prefix_len = full_key.len().min(10);
|
||||
let prefix = &full_key[..prefix_len];
|
||||
let suffix = if full_key.len() >= 4 {
|
||||
&full_key[full_key.len().saturating_sub(4)..]
|
||||
} else {
|
||||
""
|
||||
};
|
||||
format!("{prefix}...{suffix}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
api_key_placeholder_display_with_prefix, configured_api_key_prefix_from_lookup,
|
||||
generate_gateway_api_key_plaintext_with_prefix, masked_gateway_api_key_display,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn defaults_api_key_prefix_to_sk() {
|
||||
assert_eq!(
|
||||
configured_api_key_prefix_from_lookup(|_| None),
|
||||
"sk".to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_api_key_prefix_whitespace_and_trailing_dash() {
|
||||
assert_eq!(
|
||||
configured_api_key_prefix_from_lookup(|_| Some(" ak- ".to_string())),
|
||||
"ak".to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generates_plaintext_api_key_with_configured_prefix() {
|
||||
let value = generate_gateway_api_key_plaintext_with_prefix("ak");
|
||||
assert!(value.starts_with("ak-"));
|
||||
assert_eq!(value.len(), 3 + 32 + 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_configured_prefix_in_placeholder_display() {
|
||||
assert_eq!(
|
||||
api_key_placeholder_display_with_prefix("ak"),
|
||||
"ak-****".to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_plaintext_api_key_without_changing_prefix() {
|
||||
assert_eq!(
|
||||
masked_gateway_api_key_display(Some("ak-1234567890abcdef")),
|
||||
"ak-1234567...cdef".to_string()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
mod admin_proxy;
|
||||
mod api_keys;
|
||||
mod catalog;
|
||||
mod email_templates;
|
||||
mod external_models;
|
||||
@@ -12,6 +13,10 @@ pub(crate) use self::admin_proxy::{
|
||||
attach_admin_audit_response, build_admin_proxy_auth_required_response,
|
||||
build_unhandled_admin_proxy_response,
|
||||
};
|
||||
pub(crate) use self::api_keys::{
|
||||
api_key_placeholder_display, configured_api_key_prefix, generate_gateway_api_key_plaintext,
|
||||
masked_gateway_api_key_display,
|
||||
};
|
||||
pub(crate) use self::catalog::{
|
||||
build_admin_provider_key_response, decrypt_catalog_secret_with_fallbacks,
|
||||
default_provider_key_status_snapshot, effective_catalog_encryption_key,
|
||||
|
||||
@@ -78,6 +78,7 @@ pub use self::execution_runtime::{
|
||||
serve_execution_runtime_unix,
|
||||
};
|
||||
pub(crate) use self::fallback_metrics::{GatewayFallbackMetricKind, GatewayFallbackReason};
|
||||
pub use self::frontdoor_loop_guard::set_gateway_frontdoor_app_port;
|
||||
pub use self::middleware::strip_cf_headers_middleware;
|
||||
pub use self::rate_limit::FrontdoorUserRpmConfig;
|
||||
pub(crate) use self::rate_limit::FrontdoorUserRpmOutcome;
|
||||
|
||||
@@ -9,8 +9,9 @@ use aether_crypto::warm_python_fernet_secret;
|
||||
use aether_data::postgres::PostgresPoolConfig;
|
||||
use aether_data::redis::RedisClientConfig;
|
||||
use aether_gateway::{
|
||||
attach_static_frontend, build_router_with_state, AppState, FrontdoorCorsConfig,
|
||||
FrontdoorUserRpmConfig, GatewayDataConfig, UsageRuntimeConfig, VideoTaskTruthSourceMode,
|
||||
attach_static_frontend, build_router_with_state, set_gateway_frontdoor_app_port, AppState,
|
||||
FrontdoorCorsConfig, FrontdoorUserRpmConfig, GatewayDataConfig, UsageRuntimeConfig,
|
||||
VideoTaskTruthSourceMode,
|
||||
};
|
||||
use aether_runtime::{
|
||||
init_service_runtime, DistributedConcurrencyGate, FileLoggingConfig, LogDestination, LogFormat,
|
||||
@@ -495,8 +496,8 @@ impl GatewayLoggingArgs {
|
||||
about = "Phase 3a Rust ingress gateway for Aether"
|
||||
)]
|
||||
struct Args {
|
||||
#[arg(long, env = "AETHER_GATEWAY_BIND", default_value = "0.0.0.0:8084")]
|
||||
bind: String,
|
||||
#[arg(long, env = "APP_PORT", default_value_t = 8084)]
|
||||
app_port: u16,
|
||||
|
||||
/// 容器内健康检查入口:根据当前 bind 端口探测本地 /health。
|
||||
#[arg(long, hide = true, default_value_t = false)]
|
||||
@@ -625,63 +626,39 @@ fn resolve_gateway_log_instance_id() -> String {
|
||||
.unwrap_or_else(|| "local".to_string())
|
||||
}
|
||||
|
||||
fn resolve_bind_http_base_url(bind: &str) -> Result<String, std::io::Error> {
|
||||
let trimmed = bind.trim();
|
||||
if trimmed.is_empty() {
|
||||
fn validate_app_port(app_port: u16) -> Result<u16, std::io::Error> {
|
||||
if app_port == 0 {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"AETHER_GATEWAY_BIND cannot be empty",
|
||||
"APP_PORT must be between 1 and 65535",
|
||||
));
|
||||
}
|
||||
|
||||
if let Ok(socket_addr) = trimmed.parse::<std::net::SocketAddr>() {
|
||||
let host = match socket_addr.ip() {
|
||||
std::net::IpAddr::V4(ip) if ip.is_unspecified() => "127.0.0.1".to_string(),
|
||||
std::net::IpAddr::V4(ip) => ip.to_string(),
|
||||
std::net::IpAddr::V6(ip) if ip.is_unspecified() => "[::1]".to_string(),
|
||||
std::net::IpAddr::V6(ip) => format!("[{ip}]"),
|
||||
};
|
||||
return Ok(format!("http://{host}:{}", socket_addr.port()));
|
||||
}
|
||||
|
||||
let (host, port) = trimmed.rsplit_once(':').ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("AETHER_GATEWAY_BIND must include a port: {trimmed}"),
|
||||
)
|
||||
})?;
|
||||
let port = port.parse::<u16>().map_err(|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("invalid bind port in AETHER_GATEWAY_BIND={trimmed}: {error}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let host = host.trim();
|
||||
if host.is_empty() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("invalid host in AETHER_GATEWAY_BIND={trimmed}"),
|
||||
));
|
||||
}
|
||||
let host = if host.contains(':') && !host.starts_with('[') {
|
||||
format!("[{host}]")
|
||||
} else {
|
||||
host.to_string()
|
||||
};
|
||||
|
||||
Ok(format!("http://{host}:{port}"))
|
||||
Ok(app_port)
|
||||
}
|
||||
|
||||
fn resolve_healthcheck_url(bind: &str) -> Result<String, std::io::Error> {
|
||||
Ok(format!("{}/health", resolve_bind_http_base_url(bind)?))
|
||||
fn gateway_bind_addr(app_port: u16) -> Result<std::net::SocketAddr, std::io::Error> {
|
||||
Ok(std::net::SocketAddr::from((
|
||||
[0, 0, 0, 0],
|
||||
validate_app_port(app_port)?,
|
||||
)))
|
||||
}
|
||||
|
||||
async fn run_healthcheck(args: &Args) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = resolve_healthcheck_url(&args.bind)?;
|
||||
fn resolve_local_http_base_url(app_port: u16) -> Result<String, std::io::Error> {
|
||||
Ok(format!("http://127.0.0.1:{}", validate_app_port(app_port)?))
|
||||
}
|
||||
|
||||
fn resolve_healthcheck_url(app_port: u16) -> Result<String, std::io::Error> {
|
||||
Ok(format!("{}/health", resolve_local_http_base_url(app_port)?))
|
||||
}
|
||||
|
||||
async fn run_healthcheck(
|
||||
app_port: u16,
|
||||
healthcheck_timeout_ms: u64,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = resolve_healthcheck_url(app_port)?;
|
||||
reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_millis(
|
||||
args.healthcheck_timeout_ms.max(1),
|
||||
healthcheck_timeout_ms.max(1),
|
||||
))
|
||||
.build()?
|
||||
.get(url)
|
||||
@@ -767,8 +744,11 @@ fn validate_deployment_topology(
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args = Args::parse();
|
||||
let app_port = validate_app_port(args.app_port)?;
|
||||
let bind_addr = gateway_bind_addr(app_port)?;
|
||||
set_gateway_frontdoor_app_port(app_port);
|
||||
if args.healthcheck {
|
||||
return run_healthcheck(&args).await;
|
||||
return run_healthcheck(app_port, args.healthcheck_timeout_ms).await;
|
||||
}
|
||||
init_service_runtime(args.runtime_config()?)?;
|
||||
let data_postgres_url = args.data.effective_postgres_url();
|
||||
@@ -793,7 +773,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
info!(
|
||||
event_name = "gateway_starting",
|
||||
log_type = "ops",
|
||||
bind = %args.bind,
|
||||
bind = %bind_addr,
|
||||
app_port,
|
||||
environment = %args.frontdoor.environment,
|
||||
deployment_topology = args.deployment_topology.as_str(),
|
||||
node_role = args.node_role.as_str(),
|
||||
@@ -923,6 +904,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if state.run_postgres_migrations().await? {
|
||||
info!("database migrations complete");
|
||||
}
|
||||
state.bootstrap_admin_from_env().await?;
|
||||
|
||||
let background_tasks = if args.node_role.spawns_background_tasks() {
|
||||
state.spawn_background_tasks()
|
||||
@@ -933,9 +915,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
);
|
||||
Vec::new()
|
||||
};
|
||||
let listener = tokio::net::TcpListener::bind(&args.bind).await?;
|
||||
let public_base_url = resolve_bind_http_base_url(&args.bind)
|
||||
.unwrap_or_else(|_| format!("http://{}", args.bind.trim()));
|
||||
let listener = tokio::net::TcpListener::bind(bind_addr).await?;
|
||||
let public_base_url = resolve_local_http_base_url(app_port)?;
|
||||
let frontdoor_health_url = format!("{public_base_url}/_gateway/health");
|
||||
let api_router = build_router_with_state(state);
|
||||
|
||||
@@ -958,7 +939,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
info!(
|
||||
event_name = "gateway_ready",
|
||||
log_type = "ops",
|
||||
bind = %args.bind,
|
||||
bind = %bind_addr,
|
||||
app_port,
|
||||
public_url = %public_base_url,
|
||||
healthcheck_url = %frontdoor_health_url,
|
||||
legacy_route_policy = "fail_closed",
|
||||
@@ -981,48 +963,16 @@ mod tests {
|
||||
use super::resolve_healthcheck_url;
|
||||
|
||||
#[test]
|
||||
fn resolves_ipv4_healthcheck_url() {
|
||||
fn resolves_healthcheck_url_from_app_port() {
|
||||
assert_eq!(
|
||||
resolve_healthcheck_url("0.0.0.0:80").unwrap(),
|
||||
"http://127.0.0.1:80/health"
|
||||
resolve_healthcheck_url(8084).unwrap(),
|
||||
"http://127.0.0.1:8084/health"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_ipv6_healthcheck_url() {
|
||||
assert_eq!(
|
||||
resolve_healthcheck_url("[::]:8080").unwrap(),
|
||||
"http://[::1]:8080/health"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_explicit_ipv4_bind_for_healthcheck_url() {
|
||||
assert_eq!(
|
||||
resolve_healthcheck_url("172.18.0.2:9000").unwrap(),
|
||||
"http://172.18.0.2:9000/health"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_explicit_ipv6_bind_for_healthcheck_url() {
|
||||
assert_eq!(
|
||||
resolve_healthcheck_url("[2001:db8::2]:9000").unwrap(),
|
||||
"http://[2001:db8::2]:9000/health"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_hostname_healthcheck_url() {
|
||||
assert_eq!(
|
||||
resolve_healthcheck_url("gateway.internal:9000").unwrap(),
|
||||
"http://gateway.internal:9000/health"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bind_without_port() {
|
||||
let error = resolve_healthcheck_url("not-a-socket").unwrap_err();
|
||||
fn rejects_zero_app_port() {
|
||||
let error = resolve_healthcheck_url(0).unwrap_err();
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
|
||||
}
|
||||
}
|
||||
|
||||
509
apps/aether-gateway/src/state/bootstrap_admin.rs
Normal file
509
apps/aether-gateway/src/state/bootstrap_admin.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ use super::error::GatewayError;
|
||||
|
||||
mod admin_types;
|
||||
mod app;
|
||||
mod bootstrap_admin;
|
||||
mod cache;
|
||||
mod catalog;
|
||||
mod core;
|
||||
|
||||
Reference in New Issue
Block a user