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

@@ -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(

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View 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()
);
}
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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);
}
}

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;