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

@@ -1,17 +1,35 @@
# ==================== 必须配置(启动前) ==================== # ==================== 必须配置(启动前) ====================
# 以下配置项必须在项目启动前设置 # 以下配置项必须在项目启动前设置
# 应用端口(默认 8084
APP_PORT=8084
# API Key 前缀(默认 sk
API_KEY_PREFIX=sk
# Rust 日志过滤(默认 aether_gateway=info
# 示例: aether_gateway=debug,sqlx=warn
RUST_LOG=aether_gateway=info
# CORS 配置(跨域带 Cookie 时不要写 *,必须显式列出前端源)
# 示例: http://localhost:5173,https://app.example.com
CORS_ORIGINS=http://localhost:5173
# CORS_ALLOW_CREDENTIALS=true
# 如果前后端跨站并依赖登录刷新 Cookie还要配合
# AUTH_REFRESH_COOKIE_SAMESITE=None
# AUTH_REFRESH_COOKIE_SECURE=true
# 数据库配置 # 数据库配置
DB_HOST=localhost DB_HOST=localhost
DB_PORT=5432 DB_PORT=5432
DB_USER=postgres DB_USER=postgres
DB_NAME=aether DB_NAME=aether
DB_PASSWORD=your_secure_password_here DB_PASSWORD=aether
# Redis 配置 # Redis 配置
REDIS_HOST=localhost REDIS_HOST=localhost
REDIS_PORT=6379 REDIS_PORT=6379
REDIS_PASSWORD=your_redis_password_here REDIS_PASSWORD=aether
# JWT密钥使用 python generate_keys.py 生成) # JWT密钥使用 python generate_keys.py 生成)
# 用于用户登录 token 签名,更换后所有用户需重新登录 # 用于用户登录 token 签名,更换后所有用户需重新登录
@@ -21,31 +39,13 @@ JWT_SECRET_KEY=change-this-to-a-secure-random-string
# 注意:更换此密钥后需要在管理面板重新配置所有 Provider API Key # 注意:更换此密钥后需要在管理面板重新配置所有 Provider API Key
ENCRYPTION_KEY=change-this-to-another-secure-random-string ENCRYPTION_KEY=change-this-to-another-secure-random-string
# 支付回调共享密钥(公开 /api/payment/callback/* 入口必须携带 x-payment-callback-token # 启动自举管理员(仅在当前库里还没有活动管理员时生效
# 建议使用 32+ 位随机字符串
PAYMENT_CALLBACK_SECRET=change-this-to-a-secure-callback-secret
# 管理员账号(仅首次初始化时使用, 创建完成后可在系统内修改密码)
ADMIN_EMAIL=admin@example.com ADMIN_EMAIL=admin@example.com
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123456 ADMIN_PASSWORD=admin123456
# ==================== 可选配置(有默认值) ==================== # ==================== 可选配置(有默认值) ====================
# 以下配置项有合理的默认值,可按需调整
# 应用端口(默认 8084 # 支付回调共享密钥(公开 /api/payment/callback/* 入口必须携带 x-payment-callback-token
# APP_PORT=8084 # 建议使用 32+ 位随机字符串
# PAYMENT_CALLBACK_SECRET=change-this-to-a-secure-callback-secret
# 生产部署镜像deploy.sh 会读取)
# APP_IMAGE=ghcr.io/fawney19/aether:latest
# API Key 前缀(默认 sk
# API_KEY_PREFIX=sk
# 日志级别(默认 INFO可选DEBUG, INFO, WARNING, ERROR
# LOG_LEVEL=INFO
# CORS 配置(允许跨域的源,多个源用逗号分隔)
# 示例: http://localhost:3000,https://example.com
# 默认: * (允许所有源)
# CORS_ORIGINS=*

View File

@@ -19,7 +19,7 @@ COPY dist/frontend/ /srv/frontend
WORKDIR /app WORKDIR /app
ENV RUST_LOG=aether_gateway=info \ ENV RUST_LOG=aether_gateway=info \
AETHER_GATEWAY_BIND=0.0.0.0:8084 \ APP_PORT=8084 \
AETHER_GATEWAY_STATIC_DIR=/srv/frontend AETHER_GATEWAY_STATIC_DIR=/srv/frontend
EXPOSE 8084 EXPOSE 8084

View File

@@ -120,7 +120,7 @@ WORKDIR /app
ENV LANG=C.UTF-8 \ ENV LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \ LC_ALL=C.UTF-8 \
RUST_LOG=aether_gateway=info \ RUST_LOG=aether_gateway=info \
AETHER_GATEWAY_BIND=0.0.0.0:8084 \ APP_PORT=8084 \
AETHER_GATEWAY_STATIC_DIR=/srv/frontend AETHER_GATEWAY_STATIC_DIR=/srv/frontend
EXPOSE 8084 EXPOSE 8084

View File

@@ -85,7 +85,7 @@ cd frontend && npm install && npm run dev
| 角色 | 本地地址 | 说明 | | 角色 | 本地地址 | 说明 |
|------|----------|------| |------|----------|------|
| Rust frontdoor | `http://localhost:8084` | `aether-gateway`,本地唯一公开入口;浏览器、CLI、SDK 都应优先连这里 | | Rust frontdoor | 默认 `http://localhost:8084` | `aether-gateway`,本地唯一公开入口;实际端口由 `APP_PORT` 控制 |
本地默认链路是: 本地默认链路是:
@@ -116,12 +116,16 @@ Aether Proxy 是配套的正向代理节点,部署在海外 VPS 上,为墙
当前主链路真正要关注的是这组变量: 当前主链路真正要关注的是这组变量:
- `APP_PORT``aether-gateway` 唯一监听端口,固定绑定 `0.0.0.0:${APP_PORT}`
- `DATABASE_URL` / `REDIS_URL``aether-gateway` 直接读取的共享后端连接串 - `DATABASE_URL` / `REDIS_URL``aether-gateway` 直接读取的共享后端连接串
- `JWT_SECRET_KEY` / `ENCRYPTION_KEY`:认证和敏感数据加密所需密钥 - `JWT_SECRET_KEY` / `ENCRYPTION_KEY`:认证和敏感数据加密所需密钥
- `API_KEY_PREFIX`:用户和管理员新建 API Key 时使用的前缀,默认 `sk`
- `PAYMENT_CALLBACK_SECRET`:支付回调公开入口的共享密钥;未配置时相关路由保持禁用 - `PAYMENT_CALLBACK_SECRET`:支付回调公开入口的共享密钥;未配置时相关路由保持禁用
- `ADMIN_USERNAME` / `ADMIN_PASSWORD` / `ADMIN_EMAIL`:首次启动时自举首个本地管理员
- `CORS_ORIGINS` / `CORS_ALLOW_CREDENTIALS`:前端跨域来源控制;如果要跨域带登录 Cookie`CORS_ORIGINS` 不能写 `*`
- `AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY=single-node|multi-node` - `AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY=single-node|multi-node`
- `AETHER_GATEWAY_NODE_ROLE=all|frontdoor|background` - `AETHER_GATEWAY_NODE_ROLE=all|frontdoor|background`
- `RUST_LOG`Rust 日志过滤,而不是旧的 `LOG_LEVEL` - `RUST_LOG`Rust 日志过滤,例如 `aether_gateway=info``aether_gateway=debug,sqlx=warn`
- 如果使用仓库内置的数据栈 compose再额外配置 `DB_PASSWORD` / `REDIS_PASSWORD` - 如果使用仓库内置的数据栈 compose再额外配置 `DB_PASSWORD` / `REDIS_PASSWORD`
systemd 的 `.env` 必须保持简单 `KEY=VALUE` 形式,不要写 `export``${VAR}` 或命令替换。 systemd 的 `.env` 必须保持简单 `KEY=VALUE` 形式,不要写 `export``${VAR}` 或命令替换。

View File

@@ -22,12 +22,13 @@ use crate::constants::{
}; };
#[cfg(test)] #[cfg(test)]
use crate::execution_runtime::remote_compat::execute_sync_plan_via_remote_execution_runtime; 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}; use crate::{AppState, GatewayError};
const HUB_RELAY_CONTENT_TYPE: &str = "application/vnd.aether.tunnel-envelope"; const HUB_RELAY_CONTENT_TYPE: &str = "application/vnd.aether.tunnel-envelope";
const HUB_RELAY_ERROR_HEADER: &str = "x-aether-tunnel-error"; 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 TUNNEL_RELAY_PATH_PREFIX: &str = "/api/internal/tunnel/relay";
const CLAUDE_CODE_TLS_PROFILE: &str = "claude_code_nodejs"; 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 let base_url = proxy
.and_then(resolve_tunnel_base_url_from_proxy) .and_then(resolve_tunnel_base_url_from_proxy)
.or_else(|| std::env::var("AETHER_TUNNEL_BASE_URL").ok()) .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!( format!(
"{}{}/{}", "{}{}/{}",
base_url.trim_end_matches('/'), base_url.trim_end_matches('/'),
@@ -686,26 +687,26 @@ mod tests {
use super::DirectSyncExecutionRuntime; use super::DirectSyncExecutionRuntime;
use crate::frontdoor_loop_guard::{ use crate::frontdoor_loop_guard::{
frontdoor_self_loop_public_ai_path, gateway_frontdoor_self_loop_guard_error_with_bind, frontdoor_self_loop_public_ai_path, gateway_frontdoor_self_loop_guard_error_with_port,
gateway_frontdoor_self_loop_guard_matches_with_bind, gateway_frontdoor_self_loop_guard_matches_with_port,
}; };
#[test] #[test]
fn gateway_frontdoor_self_loop_guard_matches_loopback_public_ai_route() { fn gateway_frontdoor_self_loop_guard_matches_loopback_public_ai_route() {
assert!(gateway_frontdoor_self_loop_guard_matches_with_bind( assert!(gateway_frontdoor_self_loop_guard_matches_with_port(
"0.0.0.0:8084", 8084,
"http://127.0.0.1:8084/v1/messages" "http://127.0.0.1:8084/v1/messages"
)); ));
assert!(gateway_frontdoor_self_loop_guard_matches_with_bind( assert!(gateway_frontdoor_self_loop_guard_matches_with_port(
"0.0.0.0:8084", 8084,
"http://localhost:8084/v1/responses" "http://localhost:8084/v1/responses"
)); ));
} }
#[test] #[test]
fn gateway_frontdoor_self_loop_guard_ignores_non_ai_routes() { fn gateway_frontdoor_self_loop_guard_ignores_non_ai_routes() {
assert!(!gateway_frontdoor_self_loop_guard_matches_with_bind( assert!(!gateway_frontdoor_self_loop_guard_matches_with_port(
"0.0.0.0:8084", 8084,
"http://127.0.0.1:8084/_gateway/health" "http://127.0.0.1:8084/_gateway/health"
)); ));
assert!(!frontdoor_self_loop_public_ai_path("/_gateway/health")); assert!(!frontdoor_self_loop_public_ai_path("/_gateway/health"));
@@ -713,8 +714,8 @@ mod tests {
#[test] #[test]
fn gateway_frontdoor_self_loop_guard_ignores_different_ports() { fn gateway_frontdoor_self_loop_guard_ignores_different_ports() {
assert!(!gateway_frontdoor_self_loop_guard_matches_with_bind( assert!(!gateway_frontdoor_self_loop_guard_matches_with_port(
"0.0.0.0:8084", 8084,
"http://127.0.0.1:9999/v1/messages" "http://127.0.0.1:9999/v1/messages"
)); ));
} }
@@ -722,8 +723,8 @@ mod tests {
#[test] #[test]
fn gateway_frontdoor_self_loop_guard_reports_clear_error() { fn gateway_frontdoor_self_loop_guard_reports_clear_error() {
assert_eq!( assert_eq!(
gateway_frontdoor_self_loop_guard_error_with_bind( gateway_frontdoor_self_loop_guard_error_with_port(
"0.0.0.0:8084", 8084,
"http://localhost:8084/v1/responses" "http://localhost:8084/v1/responses"
), ),
Some( Some(

View File

@@ -1,3 +1,5 @@
use std::sync::OnceLock;
use axum::http::HeaderMap; use axum::http::HeaderMap;
use url::Url; use url::Url;
@@ -7,19 +9,9 @@ use crate::constants::{
}; };
use crate::headers::header_value_str; use crate::headers::header_value_str;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] const DEFAULT_APP_PORT: u16 = 8084;
enum GatewayBindHostKind {
AnyLocal,
Loopback,
Exact,
}
#[derive(Debug, Clone, PartialEq, Eq)] static GATEWAY_FRONTDOOR_APP_PORT: OnceLock<u16> = OnceLock::new();
struct GatewayBindTarget {
host_kind: GatewayBindHostKind,
host: String,
port: u16,
}
pub(crate) fn request_has_execution_runtime_loop_guard(headers: &HeaderMap) -> bool { pub(crate) fn request_has_execution_runtime_loop_guard(headers: &HeaderMap) -> bool {
header_value_str(headers, EXECUTION_RUNTIME_LOOP_GUARD_HEADER) 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) || is_gemini_generation_path(path)
} }
pub(crate) fn gateway_frontdoor_self_loop_guard_error(url: &str) -> Option<String> { pub fn set_gateway_frontdoor_app_port(app_port: u16) {
let Some(bind) = std::env::var("AETHER_GATEWAY_BIND") let _ = GATEWAY_FRONTDOOR_APP_PORT.set(app_port);
.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(crate) fn gateway_frontdoor_self_loop_guard_error_with_bind( pub(crate) fn configured_gateway_frontdoor_base_url() -> String {
bind: &str, 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, url: &str,
) -> Option<String> { ) -> 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!( format!(
"upstream execution target resolves back to the local aether-gateway frontdoor: {url}" "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 { pub(crate) fn gateway_frontdoor_self_loop_guard_matches_with_port(
let Some(bind_target) = parse_gateway_bind_target(bind) else { app_port: u16,
url: &str,
) -> bool {
if app_port == 0 {
return false; return false;
}; }
let Some(target_url) = Url::parse(url).ok() else { let Some(target_url) = Url::parse(url).ok() else {
return false; 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 { let Some(target_port) = target_url.port_or_known_default() else {
return false; return false;
}; };
if target_port != bind_target.port { if target_port != app_port {
return false; return false;
} }
let target_host = normalize_host_for_frontdoor_loop_guard(target_host); is_loopbackish_host(normalize_host_for_frontdoor_loop_guard(target_host).as_str())
match bind_target.host_kind {
GatewayBindHostKind::AnyLocal | GatewayBindHostKind::Loopback => {
is_loopbackish_host(target_host.as_str())
}
GatewayBindHostKind::Exact => target_host == bind_target.host,
}
} }
fn is_gemini_generation_path(path: &str) -> bool { 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> { fn configured_gateway_frontdoor_app_port() -> u16 {
let trimmed = bind.trim(); GATEWAY_FRONTDOOR_APP_PORT
if trimmed.is_empty() { .get()
return None; .copied()
} .or_else(|| {
std::env::var("APP_PORT")
if let Ok(socket_addr) = trimmed.parse::<std::net::SocketAddr>() { .ok()
let (host_kind, host) = match socket_addr.ip() { .map(|value| value.trim().to_string())
std::net::IpAddr::V4(ip) if ip.is_unspecified() => { .filter(|value| !value.is_empty())
(GatewayBindHostKind::AnyLocal, "0.0.0.0".to_string()) .and_then(|value| value.parse::<u16>().ok())
} .filter(|value| *value > 0)
std::net::IpAddr::V4(ip) if ip.is_loopback() => { })
(GatewayBindHostKind::Loopback, ip.to_string()) .unwrap_or(DEFAULT_APP_PORT)
}
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 normalize_host_for_frontdoor_loop_guard(host: &str) -> String { 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::{ use crate::handlers::admin::shared::{
attach_admin_audit_response, decrypt_catalog_secret_with_fallbacks, 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 axum::{body::Body, response::Response};
use serde_json::json; use serde_json::json;
use std::collections::BTreeSet; use std::collections::BTreeSet;
@@ -17,20 +20,13 @@ pub(crate) fn masked_user_api_key_display(
ciphertext: Option<&str>, ciphertext: Option<&str>,
) -> String { ) -> String {
let Some(ciphertext) = ciphertext.map(str::trim).filter(|value| !value.is_empty()) else { 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) let Some(full_key) = decrypt_catalog_secret_with_fallbacks(state.encryption_key(), ciphertext)
else { else {
return "sk-****".to_string(); return api_key_placeholder_display();
}; };
let prefix_len = full_key.len().min(10); masked_gateway_api_key_display(Some(full_key.as_str()))
let prefix = &full_key[..prefix_len];
let suffix = if full_key.len() >= 4 {
&full_key[full_key.len() - 4..]
} else {
""
};
format!("{prefix}...{suffix}")
} }
pub(super) fn build_admin_user_api_key_detail_payload( 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 { pub(crate) fn generate_admin_user_api_key_plaintext() -> String {
let first = uuid::Uuid::new_v4().simple().to_string(); generate_gateway_api_key_plaintext()
let second = uuid::Uuid::new_v4().simple().to_string();
format!("sk-{}{}", first, &second[..16])
} }
pub(crate) fn hash_admin_user_api_key(value: &str) -> String { pub(crate) fn hash_admin_user_api_key(value: &str) -> String {

View File

@@ -9,6 +9,10 @@ use axum::{
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
use crate::handlers::shared::{
api_key_placeholder_display, generate_gateway_api_key_plaintext, masked_gateway_api_key_display,
};
use super::{ use super::{
build_auth_error_response, decrypt_catalog_secret_with_fallbacks, build_auth_error_response, decrypt_catalog_secret_with_fallbacks,
encrypt_catalog_secret_with_fallbacks, format_users_me_optional_unix_secs_iso8601, 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 { 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 { 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) let Some(full_key) = decrypt_catalog_secret_with_fallbacks(state.encryption_key(), ciphertext)
else { else {
return "sk-****".to_string(); return api_key_placeholder_display();
}; };
let prefix_len = full_key.len().min(10); masked_gateway_api_key_display(Some(full_key.as_str()))
let prefix = &full_key[..prefix_len];
let suffix = if full_key.len() >= 4 {
&full_key[full_key.len() - 4..]
} else {
""
};
format!("{prefix}...{suffix}")
} }
fn build_users_me_api_key_writer_unavailable_response() -> Response<Body> { 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 { fn generate_users_me_api_key_plaintext() -> String {
let first = uuid::Uuid::new_v4().simple().to_string(); generate_gateway_api_key_plaintext()
let second = uuid::Uuid::new_v4().simple().to_string();
format!("sk-{}{}", first, &second[..16])
} }
fn hash_users_me_api_key(value: &str) -> String { 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 admin_proxy;
mod api_keys;
mod catalog; mod catalog;
mod email_templates; mod email_templates;
mod external_models; mod external_models;
@@ -12,6 +13,10 @@ pub(crate) use self::admin_proxy::{
attach_admin_audit_response, build_admin_proxy_auth_required_response, attach_admin_audit_response, build_admin_proxy_auth_required_response,
build_unhandled_admin_proxy_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::{ pub(crate) use self::catalog::{
build_admin_provider_key_response, decrypt_catalog_secret_with_fallbacks, build_admin_provider_key_response, decrypt_catalog_secret_with_fallbacks,
default_provider_key_status_snapshot, effective_catalog_encryption_key, default_provider_key_status_snapshot, effective_catalog_encryption_key,

View File

@@ -78,6 +78,7 @@ pub use self::execution_runtime::{
serve_execution_runtime_unix, serve_execution_runtime_unix,
}; };
pub(crate) use self::fallback_metrics::{GatewayFallbackMetricKind, GatewayFallbackReason}; 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::middleware::strip_cf_headers_middleware;
pub use self::rate_limit::FrontdoorUserRpmConfig; pub use self::rate_limit::FrontdoorUserRpmConfig;
pub(crate) use self::rate_limit::FrontdoorUserRpmOutcome; 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::postgres::PostgresPoolConfig;
use aether_data::redis::RedisClientConfig; use aether_data::redis::RedisClientConfig;
use aether_gateway::{ use aether_gateway::{
attach_static_frontend, build_router_with_state, AppState, FrontdoorCorsConfig, attach_static_frontend, build_router_with_state, set_gateway_frontdoor_app_port, AppState,
FrontdoorUserRpmConfig, GatewayDataConfig, UsageRuntimeConfig, VideoTaskTruthSourceMode, FrontdoorCorsConfig, FrontdoorUserRpmConfig, GatewayDataConfig, UsageRuntimeConfig,
VideoTaskTruthSourceMode,
}; };
use aether_runtime::{ use aether_runtime::{
init_service_runtime, DistributedConcurrencyGate, FileLoggingConfig, LogDestination, LogFormat, init_service_runtime, DistributedConcurrencyGate, FileLoggingConfig, LogDestination, LogFormat,
@@ -495,8 +496,8 @@ impl GatewayLoggingArgs {
about = "Phase 3a Rust ingress gateway for Aether" about = "Phase 3a Rust ingress gateway for Aether"
)] )]
struct Args { struct Args {
#[arg(long, env = "AETHER_GATEWAY_BIND", default_value = "0.0.0.0:8084")] #[arg(long, env = "APP_PORT", default_value_t = 8084)]
bind: String, app_port: u16,
/// 容器内健康检查入口:根据当前 bind 端口探测本地 /health。 /// 容器内健康检查入口:根据当前 bind 端口探测本地 /health。
#[arg(long, hide = true, default_value_t = false)] #[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()) .unwrap_or_else(|| "local".to_string())
} }
fn resolve_bind_http_base_url(bind: &str) -> Result<String, std::io::Error> { fn validate_app_port(app_port: u16) -> Result<u16, std::io::Error> {
let trimmed = bind.trim(); if app_port == 0 {
if trimmed.is_empty() {
return Err(std::io::Error::new( return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput, std::io::ErrorKind::InvalidInput,
"AETHER_GATEWAY_BIND cannot be empty", "APP_PORT must be between 1 and 65535",
)); ));
} }
Ok(app_port)
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}"))
} }
fn resolve_healthcheck_url(bind: &str) -> Result<String, std::io::Error> { fn gateway_bind_addr(app_port: u16) -> Result<std::net::SocketAddr, std::io::Error> {
Ok(format!("{}/health", resolve_bind_http_base_url(bind)?)) 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>> { fn resolve_local_http_base_url(app_port: u16) -> Result<String, std::io::Error> {
let url = resolve_healthcheck_url(&args.bind)?; 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() reqwest::Client::builder()
.timeout(std::time::Duration::from_millis( .timeout(std::time::Duration::from_millis(
args.healthcheck_timeout_ms.max(1), healthcheck_timeout_ms.max(1),
)) ))
.build()? .build()?
.get(url) .get(url)
@@ -767,8 +744,11 @@ fn validate_deployment_topology(
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Args::parse(); 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 { if args.healthcheck {
return run_healthcheck(&args).await; return run_healthcheck(app_port, args.healthcheck_timeout_ms).await;
} }
init_service_runtime(args.runtime_config()?)?; init_service_runtime(args.runtime_config()?)?;
let data_postgres_url = args.data.effective_postgres_url(); let data_postgres_url = args.data.effective_postgres_url();
@@ -793,7 +773,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
info!( info!(
event_name = "gateway_starting", event_name = "gateway_starting",
log_type = "ops", log_type = "ops",
bind = %args.bind, bind = %bind_addr,
app_port,
environment = %args.frontdoor.environment, environment = %args.frontdoor.environment,
deployment_topology = args.deployment_topology.as_str(), deployment_topology = args.deployment_topology.as_str(),
node_role = args.node_role.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? { if state.run_postgres_migrations().await? {
info!("database migrations complete"); info!("database migrations complete");
} }
state.bootstrap_admin_from_env().await?;
let background_tasks = if args.node_role.spawns_background_tasks() { let background_tasks = if args.node_role.spawns_background_tasks() {
state.spawn_background_tasks() state.spawn_background_tasks()
@@ -933,9 +915,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
); );
Vec::new() Vec::new()
}; };
let listener = tokio::net::TcpListener::bind(&args.bind).await?; let listener = tokio::net::TcpListener::bind(bind_addr).await?;
let public_base_url = resolve_bind_http_base_url(&args.bind) let public_base_url = resolve_local_http_base_url(app_port)?;
.unwrap_or_else(|_| format!("http://{}", args.bind.trim()));
let frontdoor_health_url = format!("{public_base_url}/_gateway/health"); let frontdoor_health_url = format!("{public_base_url}/_gateway/health");
let api_router = build_router_with_state(state); let api_router = build_router_with_state(state);
@@ -958,7 +939,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
info!( info!(
event_name = "gateway_ready", event_name = "gateway_ready",
log_type = "ops", log_type = "ops",
bind = %args.bind, bind = %bind_addr,
app_port,
public_url = %public_base_url, public_url = %public_base_url,
healthcheck_url = %frontdoor_health_url, healthcheck_url = %frontdoor_health_url,
legacy_route_policy = "fail_closed", legacy_route_policy = "fail_closed",
@@ -981,48 +963,16 @@ mod tests {
use super::resolve_healthcheck_url; use super::resolve_healthcheck_url;
#[test] #[test]
fn resolves_ipv4_healthcheck_url() { fn resolves_healthcheck_url_from_app_port() {
assert_eq!( assert_eq!(
resolve_healthcheck_url("0.0.0.0:80").unwrap(), resolve_healthcheck_url(8084).unwrap(),
"http://127.0.0.1:80/health" "http://127.0.0.1:8084/health"
); );
} }
#[test] #[test]
fn resolves_ipv6_healthcheck_url() { fn rejects_zero_app_port() {
assert_eq!( let error = resolve_healthcheck_url(0).unwrap_err();
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();
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); 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 admin_types;
mod app; mod app;
mod bootstrap_admin;
mod cache; mod cache;
mod catalog; mod catalog;
mod core; mod core;

View File

@@ -12,11 +12,12 @@ AETHER_LOG_RETENTION_DAYS=7
AETHER_LOG_MAX_FILES=30 AETHER_LOG_MAX_FILES=30
# aether-gateway 本体 # aether-gateway 本体
AETHER_GATEWAY_BIND=0.0.0.0:8084 APP_PORT=8084
AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY=single-node AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY=single-node
AETHER_GATEWAY_NODE_ROLE=all AETHER_GATEWAY_NODE_ROLE=all
AETHER_GATEWAY_STATIC_DIR=/opt/aether/current/frontend AETHER_GATEWAY_STATIC_DIR=/opt/aether/current/frontend
AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE=rust-authoritative AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE=rust-authoritative
API_KEY_PREFIX=sk
# 数据库 / Redis # 数据库 / Redis
DB_NAME=aether DB_NAME=aether
@@ -35,6 +36,18 @@ JWT_SECRET_KEY=change-this-to-a-secure-random-jwt-secret
ENCRYPTION_KEY=change-this-to-a-secure-random-encryption-key ENCRYPTION_KEY=change-this-to-a-secure-random-encryption-key
PAYMENT_CALLBACK_SECRET=change-this-to-a-secure-random-callback-secret PAYMENT_CALLBACK_SECRET=change-this-to-a-secure-random-callback-secret
# 可选: 首次启动时按环境变量自举本地管理员。
# 仅在 users 表里还没有活动管理员时生效;已有管理员后会自动跳过。
# ADMIN_EMAIL=admin@example.com
# ADMIN_USERNAME=admin
# ADMIN_PASSWORD=change-this-admin-password
# 可选: 跨域前端源。若要跨域带登录 Cookie不要写 *,必须显式列出源。
# CORS_ORIGINS=https://app.example.com
# CORS_ALLOW_CREDENTIALS=true
# AUTH_REFRESH_COOKIE_SAMESITE=None
# AUTH_REFRESH_COOKIE_SECURE=true
# 可选: 多实例/分布式并发时启用 # 可选: 多实例/分布式并发时启用
# 切到 multi-node 后,启动会拒绝本地 video task 文件状态,并要求 Postgres/Redis 共享后端可用。 # 切到 multi-node 后,启动会拒绝本地 video task 文件状态,并要求 Postgres/Redis 共享后端可用。
# AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY=multi-node # AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY=multi-node

View File

@@ -375,6 +375,10 @@ restart_service_if_requested() {
} }
print_next_steps() { print_next_steps() {
local gateway_port
gateway_port="$(awk -F= '/^[[:space:]]*APP_PORT=/{print $2}' "${ENV_TARGET}" | tail -n1 | tr -d '[:space:]')"
gateway_port="${gateway_port:-8084}"
cat <<EOF cat <<EOF
Install complete. Install complete.
@@ -389,8 +393,8 @@ Data services (Docker only for Postgres/Redis):
docker compose --env-file ${ENV_TARGET} -f ${INSTALL_ROOT}/shared/docker-compose.data.yml ps docker compose --env-file ${ENV_TARGET} -f ${INSTALL_ROOT}/shared/docker-compose.data.yml ps
Health checks: Health checks:
curl -fsS http://127.0.0.1:8084/_gateway/health curl -fsS http://127.0.0.1:${gateway_port}/_gateway/health
curl -fsS http://127.0.0.1:8084/readyz curl -fsS http://127.0.0.1:${gateway_port}/readyz
Current release: Current release:
${INSTALL_ROOT}/current ${INSTALL_ROOT}/current

7
dev.sh
View File

@@ -155,7 +155,7 @@ wait_for_startup() {
# 本地开发默认约定: # 本地开发默认约定:
# - Rust aether-gateway 绑定 APP_PORT作为唯一公开入口 # - Rust aether-gateway 绑定 APP_PORT作为唯一公开入口
# - ./dev.sh 不再启动 Python 宿主;本地默认只验证 Rust-owned 路径 # - ./dev.sh 不再启动 Python 宿主;本地默认只验证 Rust-owned 路径
APP_PORT=${APP_PORT:-8084} export APP_PORT=${APP_PORT:-8084}
RUST_SERVICE_STARTUP_TIMEOUT_SECONDS=${RUST_SERVICE_STARTUP_TIMEOUT_SECONDS:-120} RUST_SERVICE_STARTUP_TIMEOUT_SECONDS=${RUST_SERVICE_STARTUP_TIMEOUT_SECONDS:-120}
GATEWAY_STARTUP_TIMEOUT_SECONDS=${GATEWAY_STARTUP_TIMEOUT_SECONDS:-${RUST_SERVICE_STARTUP_TIMEOUT_SECONDS}} GATEWAY_STARTUP_TIMEOUT_SECONDS=${GATEWAY_STARTUP_TIMEOUT_SECONDS:-${RUST_SERVICE_STARTUP_TIMEOUT_SECONDS}}
@@ -164,15 +164,14 @@ if ! command -v cargo >/dev/null 2>&1; then
exit 1 exit 1
fi fi
export AETHER_GATEWAY_BIND=${AETHER_GATEWAY_BIND:-0.0.0.0:${APP_PORT}}
export AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE=${AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE:-rust-authoritative} export AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE=${AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE:-rust-authoritative}
if ! preflight_dev_infra; then if ! preflight_dev_infra; then
exit 1 exit 1
fi fi
GATEWAY_ARGS=(--bind "${AETHER_GATEWAY_BIND}") GATEWAY_ARGS=(--app-port "${APP_PORT}")
echo "=> 启动 aether-gateway (Rust frontdoor: ${AETHER_GATEWAY_BIND})..." echo "=> 启动 aether-gateway (Rust frontdoor: 0.0.0.0:${APP_PORT})..."
cargo run -q -p aether-gateway -- "${GATEWAY_ARGS[@]}" & cargo run -q -p aether-gateway -- "${GATEWAY_ARGS[@]}" &
GATEWAY_PID=$! GATEWAY_PID=$!

View File

@@ -50,7 +50,7 @@ services:
DATABASE_URL: postgresql://postgres:${DB_PASSWORD}@postgres:5432/aether DATABASE_URL: postgresql://postgres:${DB_PASSWORD}@postgres:5432/aether
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0 REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
TZ: Asia/Shanghai TZ: Asia/Shanghai
AETHER_GATEWAY_BIND: 0.0.0.0:${APP_PORT:-8084} APP_PORT: ${APP_PORT:-8084}
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy

View File

@@ -47,7 +47,7 @@ services:
AETHER_LOG_ROTATION: ${AETHER_LOG_ROTATION:-daily} AETHER_LOG_ROTATION: ${AETHER_LOG_ROTATION:-daily}
AETHER_LOG_RETENTION_DAYS: ${AETHER_LOG_RETENTION_DAYS:-7} AETHER_LOG_RETENTION_DAYS: ${AETHER_LOG_RETENTION_DAYS:-7}
AETHER_LOG_MAX_FILES: ${AETHER_LOG_MAX_FILES:-30} AETHER_LOG_MAX_FILES: ${AETHER_LOG_MAX_FILES:-30}
AETHER_GATEWAY_BIND: 0.0.0.0:${APP_PORT:-8084} APP_PORT: ${APP_PORT:-8084}
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy

View File

@@ -1,241 +0,0 @@
# Aether Logging Guide
目标:
- 明确 `stdout`、文件日志和 `journald` 的职责边界
-`aether-gateway` / `aether-proxy` 提供统一的查看与排障手册
- 避免重复轮转、重复采集和“日志写了但不知道去哪找”的问题
## 1. 组合策略
推荐组合不是“二选一”,而是按运行方式选一套固定策略:
| 场景 | 推荐配置 | 说明 |
|------|----------|------|
| 本地开发 | `stdout` | 直接看终端输出,最简单 |
| Docker Compose 开发 | `stdout` | 交给容器日志驱动,避免和文件日志重复 |
| systemd 单机生产 | `both` | `journald` 负责最近事件,文件日志负责留痕和 grep |
| 裸机临时排障 | `file``both` | 需要长时间保留时用文件日志 |
规则:
- `stdout` 进入容器日志驱动或 `journald`
- `file` 由应用自己轮转和清理
- `both` 适合宿主机生产
- 不要再给同一目录额外叠 `logrotate`
## 2. 关键配置
### aether-gateway
- `AETHER_LOG_DESTINATION=stdout|file|both`
- `AETHER_LOG_FORMAT=pretty|json`
- `AETHER_LOG_DIR=/var/log/aether`
- `AETHER_LOG_ROTATION=hourly|daily`
- `AETHER_LOG_RETENTION_DAYS=7`
- `AETHER_LOG_MAX_FILES=30`
### aether-proxy
- `AETHER_PROXY_LOG_DESTINATION=stdout|file|both`
- `AETHER_PROXY_LOG_JSON=false|true`
- `AETHER_PROXY_LOG_DIR=/var/log/aether-proxy`
- `AETHER_PROXY_LOG_ROTATION=hourly|daily`
- `AETHER_PROXY_LOG_RETENTION_DAYS=7`
- `AETHER_PROXY_LOG_MAX_FILES=30`
注意:
- `file` / `both` 必须同时给出 `*_LOG_DIR`
- Docker Compose 默认建议 `stdout`
- systemd 默认建议 `both`
## 3. 日志落点
### systemd
`aether-gateway`
- `journalctl -u aether-gateway`
- 文件日志目录默认建议 `/var/log/aether`
`aether-proxy`
- `journalctl -u aether-proxy`
- 文件日志目录默认建议 `/var/log/aether-proxy`
### Docker Compose
- `docker compose logs -f gateway`
- `docker compose logs -f aether-proxy`
- 如果显式启用了 `file/both`,再去看容器内挂载出来的日志目录
## 4. 常用排障命令
### gateway
```bash
sudo systemctl status aether-gateway --no-pager
sudo journalctl -u aether-gateway -n 200 --no-pager
sudo tail -n 200 /var/log/aether/aether-gateway.*.log
sudo rg "trace_id|request_id|error" /var/log/aether/aether-gateway.*.log
```
### proxy
```bash
sudo systemctl status aether-proxy --no-pager
sudo journalctl -u aether-proxy -n 200 --no-pager
sudo tail -n 200 /var/log/aether-proxy/aether-proxy.*.log
sudo rg "node_id|server|error" /var/log/aether-proxy/aether-proxy.*.log
```
### Docker
```bash
docker compose logs -f gateway
docker compose logs -f aether-proxy
```
## 5. 故障定位顺序
1. 先看 `systemctl status`,判断是不是进程本身没起来
2. 再看 `journalctl`,确认启动报错、权限报错、配置报错
3. 如果启用了 `both`,再查文件日志做结构化检索
4.`trace_id` / `request_id` 串联同一请求
5. 对长时间问题看文件日志,对最近故障先看 `journald`
## 6. 常见问题
### 没有生成文件日志
优先检查:
- `*_LOG_DESTINATION` 是否是 `file``both`
- `*_LOG_DIR` 是否已设置
- systemd 的 `LogsDirectory=` 是否生效
- 进程用户对日志目录是否有写权限
### 日志重复
通常是以下原因之一:
- 容器里开了 `both`,同时又在看 `docker logs`
- 宿主机上既开了应用文件日志,又配了外部 `logrotate` / 采集器重复收集同一路径
处理方式:
- Docker 开发环境保持 `stdout`
- systemd 生产环境用 `both`
- 同一文件目录不要再叠第二套轮转器
### 磁盘持续增长
先看:
- `*_LOG_RETENTION_DAYS`
- `*_LOG_MAX_FILES`
- 是否误开 `hourly` 且保留天数过大
- 是否有旧目录不再被应用清理
### grep 不到请求
优先用这些字段:
- `trace_id`
- `request_id`
- `node_id`
- `route_class`
- `execution_path`
如果 `stdout` 噪声太大,就切到文件日志查。
## 7. 脱敏边界
常规日志不应该直接出现:
- `Authorization`
- `x-api-key`
- `x-goog-api-key`
- 完整请求体
- 完整响应体
- 完整 `report_context`
当前约定是:
- body 只记录摘要,例如字节数和 hash
- 真正的原文只允许进入显式调试通道,且必须先过 redaction
## 8. 关键事件名
当前先固定这批事件名,后续新增事件优先复用同一命名风格:
| event_name | log_type | 用途 |
|------------|----------|------|
| `http_request_started` | `access` | 请求进入 gateway |
| `http_request_completed` | `access` | 请求正常完成 |
| `http_request_failed` | `access` | 请求以 5xx 结束 |
| `local_sync_candidate_retry_scheduled` | `event` | 本地 sync 候选命中可重试结果,调度下一个候选 |
| `local_stream_candidate_retry_scheduled` | `event` | 本地 stream 候选命中可重试状态,调度下一个候选 |
| `local_openai_chat_candidates_exhausted` | `event` | 本地 OpenAI Chat 路径耗尽全部候选 |
| `local_core_finalize_fallback_raw_response_body` | `event` | 本地核心 finalize 无法映射成结构化响应,退回原始 body |
| `local_core_finalize_missing_error_report_mapping` | `event` | 本地核心 finalize 缺少错误上报映射 |
| `local_core_finalize_missing_success_report_mapping` | `event` | 本地核心 finalize 缺少成功上报映射 |
| `usage_terminal_settlement_failed` | `event` | usage 终态直写后结算失败 |
| `admin_billing_preset_applied` | `audit` | 管理员应用计费预设 |
| `admin_system_settings_updated` | `audit` | 管理员更新系统设置 |
| `admin_system_config_updated` | `audit` | 管理员更新系统配置项 |
| `admin_system_config_deleted` | `audit` | 管理员删除系统配置项 |
| `admin_wallet_balance_adjusted` | `audit` | 管理员手动调整钱包余额 |
| `admin_wallet_manual_recharge_created` | `audit` | 管理员创建手动充值 |
| `admin_wallet_refund_processed` | `audit` | 管理员开始处理退款 |
| `admin_wallet_refund_completed` | `audit` | 管理员完成退款 |
| `admin_wallet_refund_failed` | `audit` | 管理员标记退款失败 |
| `video_task_status_updated` | `event` | 异步视频任务轮询后状态发生更新 |
| `video_task_finalize_settlement_failed` | `event` | 异步视频任务终态结算失败 |
| `maintenance_worker_failed` | `ops` | 定时任务启动、tick 或调度查找失败 |
| `usage_cleanup_completed` | `ops` | usage 清理批次完成 |
| `provider_checkin_completed` | `ops` | provider checkin 批次完成 |
| `log_retention_cleanup_failed` | `ops` | 日志保留清理失败,但不阻断服务启动 |
| `execution_runtime_stream_flush_skipped` | `debug` | 下游断开,跳过 stream flush |
| `execution_runtime_stream_report_skipped` | `debug` | 下游断开,跳过 stream report |
命名规则:
- 统一小写蛇形
- 前缀先写模块,再写动作,再写结果
- `access``event``debug``ops``audit` 分层不要混用
- 管理员高风险写操作优先落 `audit`
- 定时任务批次结果和失败统一落 `ops`
## 9. 字段字典
这些字段应该优先保持稳定,方便 grep、告警和日志采集
| 字段 | 含义 |
|------|------|
| `event_name` | 机器可依赖的稳定事件名 |
| `log_type` | 日志类别,例如 `access` / `event` / `debug` / `ops` / `audit` |
| `service` | 服务名,例如 `aether-gateway` / `aether-proxy` |
| `node_role` | 节点角色,例如 `gateway` / `proxy` |
| `instance_id` | 进程或节点实例标识 |
| `trace_id` | 跨链路关联键 |
| `request_id` | 业务请求标识 |
| `route_class` | 路由大类,例如 `passthrough` / `control` |
| `execution_path` | 实际执行路径,例如 `local` / `proxy` / `passthrough` |
| `status` | 事件状态,例如 `started` / `completed` / `failed` |
| `status_code` | HTTP 或上游状态码 |
| `elapsed_ms` | 整体耗时 |
| `error` | 错误摘要,不放原始敏感内容 |
| `candidate_id` | 候选执行槽位标识 |
| `candidate_count` | 候选数量或耗尽数量 |
| `provider_id` | 供应商标识 |
| `endpoint_id` | endpoint 标识 |
| `key_id` | provider key 标识 |
| `task_id` | 异步任务标识 |
约束:
- 没有稳定含义的临时字段不要进入长期事件模板
- 敏感原文不进入 `error`
- body 相关字段默认只允许摘要,不允许原文

View File

@@ -1,198 +0,0 @@
# Aether Gateway Systemd 部署
目标形态:
- `aether-gateway` 作为宿主机上的 `systemd` 服务运行
- Docker 只保留 `Postgres``Redis`
- 前端静态资源由 `aether-gateway` 直接服务
适用场景:
- 单机/单节点部署
- 允许短暂停机更新
- 未来需要网页一键更新
## 目录约定
安装脚本默认使用以下路径:
- Gateway release: `/opt/aether/releases/<release-id>`
- 当前版本软链接: `/opt/aether/current`
- systemd env 文件: `/etc/aether/aether-gateway.env`
- 数据栈 compose: `/opt/aether/shared/docker-compose.data.yml`
## 1. 构建二进制与前端
在目标机器或 CI 产物目录中准备:
```bash
cargo build --release -p aether-gateway
(cd frontend && npm ci && npm run build)
```
构建完成后需要存在:
- `target/release/aether-gateway`
- `frontend/dist`
## 2. 准备环境变量
复制示例文件并修改:
```bash
sudo mkdir -p /etc/aether
sudo cp deploy/systemd/aether-gateway.env.example /etc/aether/aether-gateway.env
sudo chmod 600 /etc/aether/aether-gateway.env
sudo vim /etc/aether/aether-gateway.env
```
注意:
- `systemd``EnvironmentFile` 只适合简单 `KEY=VALUE`
- 不要写 `export`
- 不要写 `${VAR}`、命令替换、shell 函数等复杂语法
- `DATABASE_URL` / `REDIS_URL` 请直接写完整值
- 文件日志建议直接配 `AETHER_LOG_DESTINATION=both``AETHER_LOG_DIR=/var/log/aether`
- 现在支持 `AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY=single-node|multi-node`
- 现在支持 `AETHER_GATEWAY_NODE_ROLE=all|frontdoor|background`
- 如果未来准备跑多实例,先把它切成 `multi-node`,让启动期校验替你拦掉单机残留配置
- 安装脚本会拒绝示例占位值,例如 `change-me-*``change-this-*`
三种推荐运行档位:
- `single-node + all + Postgres/Redis`:完整单机,功能最全
- `single-node + all + 无 Postgres/Redis`:轻量单机,本地文件/内存兜底,适合最小化运行
- `multi-node + frontdoor/background + Postgres/Redis`:集群预备形态
## 3. 启动数据服务
这套 compose 只负责 `Postgres``Redis`
```bash
docker compose \
--env-file /etc/aether/aether-gateway.env \
-f deploy/docker-compose.data.yml \
up -d
```
其中:
- `Postgres` 数据目录走 Docker volume
- `Redis` 默认开启 `AOF + everysec`,避免 usage stream、分布式限流和锁状态在容器重启时全部丢失
- 端口默认只绑定到 `127.0.0.1`
- 如果给 gateway 打开 `AETHER_LOG_DESTINATION=file|both`,建议把 `AETHER_LOG_DIR` 指到持久目录
如果你已经执行过安装脚本,也可以使用安装后的固定路径:
```bash
docker compose \
--env-file /etc/aether/aether-gateway.env \
-f /opt/aether/shared/docker-compose.data.yml \
up -d
```
## 4. 安装 systemd 服务
```bash
sudo deploy/systemd/install-systemd.sh --env-file /etc/aether/aether-gateway.env
```
这个脚本会完成:
- 创建 `aether` 系统用户/组
- 复制 release 到 `/opt/aether/releases/<release-id>`
- 更新 `/opt/aether/current` 软链接
- 安装 `aether-gateway.service`
- 安装数据栈 compose 到 `/opt/aether/shared/docker-compose.data.yml`
- 校验 env 文件语法、拓扑模式和关键密钥占位值
- 重载并启动 `aether-gateway`
日志约定:
- 默认 stdout 仍进入 `journald`
- 如果设置 `AETHER_LOG_DESTINATION=both`gateway 会同时把日志写到 `AETHER_LOG_DIR`
- 文件日志目前支持 `daily/hourly` 轮转
- 文件日志会按 `AETHER_LOG_RETENTION_DAYS``AETHER_LOG_MAX_FILES` 自动清理
- 不建议再叠加外部 `logrotate` 对同一目录做二次轮转
- 更完整的组合策略和排障命令见 `docs/deploy/logging.md`
## 5. 验证
```bash
sudo systemctl status aether-gateway --no-pager
sudo journalctl -u aether-gateway -n 100 --no-pager
sudo ls -lah /var/log/aether
sudo tail -n 100 /var/log/aether/aether-gateway.*.log
curl -fsS http://127.0.0.1:8084/_gateway/health
curl -fsS http://127.0.0.1:8084/readyz
curl -fsS http://127.0.0.1:8084/.well-known/aether/frontdoor.json
```
## 6. 升级前备份
如果你使用仓库内置的数据栈 compose升级前至少备份 `Postgres`
```bash
docker compose \
--env-file /etc/aether/aether-gateway.env \
-f /opt/aether/shared/docker-compose.data.yml \
exec -T postgres pg_dump -U postgres aether | gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz
```
如果你改过 `DB_USER` / `DB_NAME`,把命令里的 `postgres` / `aether` 替换成实际值。
## 7. 更新流程
重新构建新版本后,重复执行安装脚本即可:
```bash
cargo build --release -p aether-gateway
(cd frontend && npm ci && npm run build)
sudo deploy/systemd/install-systemd.sh \
--env-file /etc/aether/aether-gateway.env \
--release-id "$(date +%Y%m%d%H%M%S)"
```
这会:
- 安装新 release
- 切换 `/opt/aether/current`
- 重启 `aether-gateway`
## 8. 回滚
列出历史 release
```bash
ls -1 /opt/aether/releases
```
把软链接切回旧版本并重启:
```bash
sudo ln -sfn /opt/aether/releases/<old-release-id> /opt/aether/current
sudo systemctl restart aether-gateway
```
如果这次更新包含数据库结构变更,单纯切回旧 release 不一定够,还需要把 `Postgres` 恢复到升级前备份。当前这套 Rust 迁移链路不应该再按老的 `alembic downgrade` 方式处理。
## 9. 集群演进注意点
这套方式适合单机,但不会阻塞以后做集群。要提前避免的坑:
- 不要把长期业务状态继续写进本地文件
- `AETHER_GATEWAY_VIDEO_TASK_STORE_PATH` 只适合单机临时方案
- 共享状态优先放 `Postgres` / `Redis`
- 以后扩成多实例时,替换的是上层托管者,不是 `aether-gateway` 二进制形态本身
建议先把单机 env 收敛成未来可迁移的基线:
- 保留 `DATABASE_URL``REDIS_URL`,不要跑“无 Redis 的单机特例”
- 默认就使用 `AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE=rust-authoritative`
- 只有单机临时排障时才启用 `AETHER_GATEWAY_VIDEO_TASK_STORE_PATH`
- 如果未来要接 `aether-proxy` / tunnel owner relay多实例下要给每个节点单独设置 `AETHER_GATEWAY_INSTANCE_ID`
- 如果未来要跨节点转发 tunnel owner 请求,还要给每个节点设置外部可达的 `AETHER_TUNNEL_RELAY_BASE_URL`
- `AETHER_GATEWAY_DISTRIBUTED_REQUEST_LIMIT` 是可选项,但要开的话现在已经可以直接复用 `REDIS_URL`,不必再维护第二份 Redis 地址
- 多实例下不要再用 `AETHER_GATEWAY_NODE_ROLE=all`;前台节点用 `frontdoor`,后台节点用 `background`
- 多实例下前台限流不会再退回本地内存计数;单机模式才允许这种兜底

View File

@@ -1,28 +0,0 @@
### 1. 创建集群项目
### 2. 添加两个数据库服务
- redis (复制 Redis Connection String)
- postgresql (复制 Connection String)
### 3. 添加Docker容器镜像
1. 镜像: ghcr.io/fawney19/aether:latest
2. 环境变量
```
DATABASE_URL=(postgresql复制的内容)
REDIS_URL=(redis复制的内容)
JWT_SECRET_KEY=change-this-to-a-secure-random-string
ENCRYPTION_KEY=change-this-to-another-secure-random-string
ADMIN_EMAIL=admin@example.com
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123456
```
JWT_SECRET_KEY、ENCRYPTION_KEY: 可以运行项目中的python脚本自动生成 python generate_keys.py
ADMIN_EMAIL、ADMIN_USERNAME、ADMIN_PASSWORD: 管理员初始信息必须修改
3. 端口: 80

View File

@@ -179,8 +179,7 @@
<Input <Input
id="login-password" id="login-password"
v-model="form.password" v-model="form.password"
type="text" type="password"
masked
required required
placeholder="输入密码" placeholder="输入密码"
autocomplete="off" autocomplete="off"

View File

@@ -174,7 +174,7 @@
</div> </div>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<code class="text-xs font-mono text-muted-foreground"> <code class="text-xs font-mono text-muted-foreground">
{{ apiKey.key_display || 'sk-****' }} {{ apiKey.key_display || '****' }}
</code> </code>
<Button <Button
variant="ghost" variant="ghost"
@@ -367,7 +367,7 @@
<div class="min-w-0 flex-1 space-y-2"> <div class="min-w-0 flex-1 space-y-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<code class="inline-flex max-w-[190px] sm:max-w-[240px] truncate rounded-lg bg-muted px-3 py-1.5 text-[11px] font-mono font-semibold text-foreground/90"> <code class="inline-flex max-w-[190px] sm:max-w-[240px] truncate rounded-lg bg-muted px-3 py-1.5 text-[11px] font-mono font-semibold text-foreground/90">
{{ apiKey.key_display || 'sk-****' }} {{ apiKey.key_display || '****' }}
</code> </code>
<Button <Button
variant="ghost" variant="ghost"
@@ -846,7 +846,7 @@ async function toggleApiKey(apiKey: AdminApiKey) {
async function deleteApiKey(apiKey: AdminApiKey) { async function deleteApiKey(apiKey: AdminApiKey) {
const confirmed = await confirmDanger( const confirmed = await confirmDanger(
`确定要删除这个独立余额 Key 吗?\n\n${apiKey.name || apiKey.key_display || 'sk-****'}\n\n此操作无法撤销。`, `确定要删除这个独立余额 Key 吗?\n\n${apiKey.name || apiKey.key_display || '****'}\n\n此操作无法撤销。`,
'删除独立 Key' '删除独立 Key'
) )

View File

@@ -682,7 +682,7 @@
</div> </div>
<div class="flex items-center gap-1 mt-0.5"> <div class="flex items-center gap-1 mt-0.5">
<code class="text-xs font-mono text-muted-foreground"> <code class="text-xs font-mono text-muted-foreground">
{{ apiKey.key_display || 'sk-****' }} {{ apiKey.key_display || '****' }}
</code> </code>
<button <button
class="p-0.5 hover:bg-muted rounded transition-colors" class="p-0.5 hover:bg-muted rounded transition-colors"
@@ -1486,7 +1486,7 @@ async function closeNewApiKeyDialog() {
async function deleteApiKey(apiKey: ApiKey) { async function deleteApiKey(apiKey: ApiKey) {
const confirmed = await confirmDanger( const confirmed = await confirmDanger(
`确定要删除这个API Key吗\n\n${apiKey.key_display || 'sk-****'}\n\n此操作无法撤销。`, `确定要删除这个API Key吗\n\n${apiKey.key_display || '****'}\n\n此操作无法撤销。`,
'删除 API Key' '删除 API Key'
) )

View File

@@ -86,7 +86,7 @@ const developmentSteps = [
}, },
{ {
title: '前端', title: '前端',
note: '自动代理到 8084', note: '自动代理到 APP_PORT默认 8084',
code: 'cd frontend && npm install && npm run dev', code: 'cd frontend && npm install && npm run dev',
icon: Monitor icon: Monitor
} }

View File

@@ -1,4 +1,4 @@
import { defineConfig } from 'vite' import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import path from 'path' import path from 'path'
import { execSync } from 'child_process' import { execSync } from 'child_process'
@@ -12,67 +12,71 @@ function getGitVersion(): string {
} }
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig(({ mode }) => ({ export default defineConfig(({ mode }) => {
// GitHub Pages 部署时使用仓库名作为 base const rootEnv = loadEnv(mode, path.resolve(__dirname, '..'), '')
base: process.env.GITHUB_PAGES === 'true' ? '/Aether/' : '/', const appPort = rootEnv.APP_PORT || process.env.APP_PORT || '8084'
plugins: [vue()], const gatewayTarget = `http://127.0.0.1:${appPort}`
define: {
__APP_VERSION__: JSON.stringify(getGitVersion()), return {
}, // GitHub Pages 部署时使用仓库名作为 base
resolve: { base: process.env.GITHUB_PAGES === 'true' ? '/Aether/' : '/',
alias: { plugins: [vue()],
'@': path.resolve(__dirname, './src'), define: {
__APP_VERSION__: JSON.stringify(getGitVersion()),
}, },
}, resolve: {
build: { alias: {
// 使用 esbuild 进行压缩(默认) '@': path.resolve(__dirname, './src'),
minify: 'esbuild', },
rollupOptions: { },
output: { build: {
// 手动分块以优化加载性能 // 使用 esbuild 进行压缩(默认)
manualChunks: { minify: 'esbuild',
// Vue 核心库 rollupOptions: {
'vue-vendor': ['vue', 'vue-router', 'pinia'], output: {
// UI 组件库 // 手动分块以优化加载性能
'ui-vendor': ['radix-vue', 'lucide-vue-next'], manualChunks: {
// 工具 // Vue 核心
'utils-vendor': ['axios', 'marked', 'dompurify'], 'vue-vendor': ['vue', 'vue-router', 'pinia'],
// 图表 // UI 组件
'chart-vendor': ['chart.js', 'vue-chartjs'], 'ui-vendor': ['radix-vue', 'lucide-vue-next'],
// 工具库
'utils-vendor': ['axios', 'marked', 'dompurify'],
// 图表库
'chart-vendor': ['chart.js', 'vue-chartjs'],
},
},
},
// esbuild 配置用于移除 console
target: 'es2015',
},
esbuild: {
// 生产环境移除 console 和 debugger
drop: mode === 'production' ? ['console', 'debugger'] : [],
},
server: {
port: 5173,
proxy: {
// 只代理真正的 API 路径;目标端口由根目录 APP_PORT 控制
'/api/': {
target: gatewayTarget,
changeOrigin: true,
secure: false,
},
'/v1/': {
target: gatewayTarget,
changeOrigin: true,
secure: false,
},
'/health': {
target: gatewayTarget,
changeOrigin: true,
secure: false,
}, },
}, },
}, },
// esbuild 配置用于移除 console preview: {
target: 'es2015', port: 5173,
},
esbuild: {
// 生产环境移除 console 和 debugger
drop: mode === 'production' ? ['console', 'debugger'] : [],
},
server: {
port: 5173,
proxy: {
// 只代理真正的 API 路径
// 注意:本地开发时后端默认运行在 8084 端口(见 src/config/settings.py
// 如果使用 Docker则通过 APP_PORT 环境变量映射(默认 80
'/api/': {
target: 'http://localhost:8084', // 本地开发端口
changeOrigin: true,
secure: false,
},
'/v1/': {
target: 'http://localhost:8084', // 本地开发端口
changeOrigin: true,
secure: false,
},
'/health': {
target: 'http://localhost:8084', // 本地开发端口
changeOrigin: true,
secure: false,
},
}, },
},
preview: {
port: 5173,
} }
})) })

View File

@@ -7,7 +7,6 @@ urlsafe_rand() { openssl rand -base64 "$1" | tr '+/' '-_' | tr -d '='; }
jwt_key=$(urlsafe_rand 32) jwt_key=$(urlsafe_rand 32)
encryption_key=$(urlsafe_rand 32) encryption_key=$(urlsafe_rand 32)
redis_password=$(urlsafe_rand 32)
cat <<EOF cat <<EOF
@@ -15,12 +14,8 @@ cat <<EOF
JWT_SECRET_KEY=${jwt_key} JWT_SECRET_KEY=${jwt_key}
ENCRYPTION_KEY=${encryption_key} ENCRYPTION_KEY=${encryption_key}
REDIS_PASSWORD=${redis_password}
注意: 注意:
- JWT_SECRET_KEY 用于用户登录 token 签名 - JWT_SECRET_KEY 用于用户登录 token 签名
- ENCRYPTION_KEY 用于敏感数据加密 (如 Provider API Keys) - ENCRYPTION_KEY 用于敏感数据加密 (如 Provider API Keys)
- REDIS_PASSWORD 用于 Redis 连接认证 (并发控制)
- 这些密钥应该独立设置, 避免相互耦合
EOF EOF

View File

@@ -1,138 +0,0 @@
[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"
[project]
name = "aether"
dynamic = ["version"]
description = "Proxy server enabling Claude Code to work with OpenAI-compatible API providers"
readme = "README.md"
authors = [
{name = "Aether", email = "noreply@example.com"}
]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: Other/Proprietary License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
]
requires-python = ">=3.12"
dependencies = [
"fastapi[standard]>=0.128.0",
"uvicorn>=0.40.0",
"gunicorn>=24.1.1",
"pydantic>=2.12.5",
"python-dotenv>=1.2.1",
"openai>=2.16.0",
"httpx[socks,http2]>=0.28.1",
"brotli>=1.1.0",
"sqlalchemy>=2.0.46",
"alembic>=1.18.3",
"bcrypt>=5.0.0",
"pyjwt>=2.10.1",
"certifi>=2026.1.4",
"cryptography>=46.0.4",
"psycopg2-binary>=2.9.11",
"asyncpg>=0.31.0",
"aiosqlite>=0.22.1",
"loguru>=0.7.3",
"tiktoken>=0.12.0",
"regex>=2026.1.15", # 支持超时的正则库,用于 ReDoS 防护
"aiofiles>=25.1.0",
"aiohttp>=3.13.3",
"aiosmtplib>=5.1.0",
"redis>=7.1.0",
"prometheus-client>=0.24.1",
"apscheduler>=3.11.2",
"ldap3>=2.9.1",
"msgpack>=1.1.2",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0.0",
"pytest-asyncio>=0.21.0",
"httpx>=0.25.0",
]
tls = [
"tls-client>=1.0.1", # 可选:用于 Claude OAuth token 请求的 TLS 指纹伪装
"curl_cffi>=0.7.0", # 可选:用于上游请求的真实 TLS 指纹伪装 (Chrome/Firefox)
]
[project.urls]
Homepage = "https://github.com/fawney19/Aether"
Repository = "https://github.com/fawney19/Aether.git"
Issues = "https://github.com/fawney19/Aether/issues"
[project.scripts]
aether = "src.main:main"
[tool.uv]
dev-dependencies = [
"pytest>=7.0.0",
"pytest-asyncio>=0.21.0",
"pytest-cov>=4.0.0",
"pytest-mock>=3.10.0",
"factory-boy>=3.2.0",
"faker>=18.0.0",
"black>=23.0.0",
"isort>=5.12.0",
"mypy>=1.0.0",
"pyinstaller>=6.15.0",
"hatch-vcs>=0.5.0",
]
[tool.black]
line-length = 100
target-version = ['py313']
[tool.isort]
profile = "black"
line_length = 100
[tool.hatch.build.targets.wheel]
packages = ["src"]
[tool.hatch.version]
source = "vcs"
[tool.hatch.build.hooks.vcs]
version-file = "src/_version.py"
[tool.mypy]
python_version = "3.14"
warn_return_any = false # 禁用返回 Any 警告(许多第三方库返回 Any
warn_unused_configs = true
disallow_untyped_defs = true
exclude = "tools/debug"
# 忽略项目内部模块的 import-untyped 警告
ignore_missing_imports = true
# SQLAlchemy mypy 插件
plugins = ["sqlalchemy.ext.mypy.plugin"]
# SQLAlchemy 相关模块放宽类型检查(模型未使用 Mapped 注解)
[[tool.mypy.overrides]]
module = ["src.models.*"]
disable_error_code = ["arg-type", "assignment", "misc", "attr-defined", "return-value"]
# 渐进式类型检查:暂时禁用一些复杂的检查,后续逐步启用
[[tool.mypy.overrides]]
module = ["src.api.*", "src.services.*", "src.plugins.*", "src.utils.*", "src.clients.*", "src.core.*", "src.config.*"]
disable_error_code = ["union-attr", "index", "operator", "attr-defined", "no-any-return", "arg-type", "return-value", "assignment", "var-annotated", "misc", "override", "dict-item", "func-returns-value", "no-redef", "call-overload", "type-var", "return", "call-arg"]
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
asyncio_mode = "auto"
addopts = [
"--cov=src",
"--cov-report=term-missing",
"--cov-report=html",
"-v"
]
norecursedirs = ["tools/debug"]

2994
uv.lock generated

File diff suppressed because it is too large Load Diff