mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
refactor(gateway): 统一 AETHER_GATEWAY_BIND 为 APP_PORT,新增 API Key 前缀配置和启动自举管理员
- 绑定地址固定 0.0.0.0,仅通过 APP_PORT 控制端口,简化 CLI/Docker/systemd/dev.sh/前端代理全链路 - 新增 API_KEY_PREFIX 环境变量,抽取 handlers/shared/api_keys.rs 消除 admin/public 重复逻辑 - 新增 bootstrap_admin.rs,启动时通过 ADMIN_* 环境变量在无管理员时自动创建首个本地管理员 - 前端密码输入改用 type=password,API Key 占位符改为动态前缀 - 删除过时的 pyproject.toml/uv.lock 和旧部署文档 - 更新 .env.example/README 反映新配置项
This commit is contained in:
48
.env.example
48
.env.example
@@ -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=*
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}` 或命令替换。
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
108
apps/aether-gateway/src/handlers/shared/api_keys.rs
Normal file
108
apps/aether-gateway/src/handlers/shared/api_keys.rs
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
const DEFAULT_API_KEY_PREFIX: &str = "sk";
|
||||||
|
|
||||||
|
fn configured_api_key_prefix_from_lookup<F>(lookup: F) -> String
|
||||||
|
where
|
||||||
|
F: Fn(&str) -> Option<String>,
|
||||||
|
{
|
||||||
|
lookup("API_KEY_PREFIX")
|
||||||
|
.as_deref()
|
||||||
|
.map(normalize_api_key_prefix)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or_else(|| DEFAULT_API_KEY_PREFIX.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_api_key_prefix(value: &str) -> String {
|
||||||
|
let normalized = value.trim().trim_end_matches('-').trim();
|
||||||
|
if normalized.is_empty() {
|
||||||
|
return DEFAULT_API_KEY_PREFIX.to_string();
|
||||||
|
}
|
||||||
|
normalized.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn api_key_placeholder_display_with_prefix(prefix: &str) -> String {
|
||||||
|
format!("{prefix}-****")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_gateway_api_key_plaintext_with_prefix(prefix: &str) -> String {
|
||||||
|
let first = uuid::Uuid::new_v4().simple().to_string();
|
||||||
|
let second = uuid::Uuid::new_v4().simple().to_string();
|
||||||
|
format!("{prefix}-{}{}", first, &second[..16])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn configured_api_key_prefix() -> String {
|
||||||
|
configured_api_key_prefix_from_lookup(|key| {
|
||||||
|
std::env::var(key)
|
||||||
|
.ok()
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn api_key_placeholder_display() -> String {
|
||||||
|
api_key_placeholder_display_with_prefix(&configured_api_key_prefix())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn generate_gateway_api_key_plaintext() -> String {
|
||||||
|
generate_gateway_api_key_plaintext_with_prefix(&configured_api_key_prefix())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn masked_gateway_api_key_display(full_key: Option<&str>) -> String {
|
||||||
|
let Some(full_key) = full_key.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||||
|
return api_key_placeholder_display();
|
||||||
|
};
|
||||||
|
let prefix_len = full_key.len().min(10);
|
||||||
|
let prefix = &full_key[..prefix_len];
|
||||||
|
let suffix = if full_key.len() >= 4 {
|
||||||
|
&full_key[full_key.len().saturating_sub(4)..]
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
format!("{prefix}...{suffix}")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{
|
||||||
|
api_key_placeholder_display_with_prefix, configured_api_key_prefix_from_lookup,
|
||||||
|
generate_gateway_api_key_plaintext_with_prefix, masked_gateway_api_key_display,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_api_key_prefix_to_sk() {
|
||||||
|
assert_eq!(
|
||||||
|
configured_api_key_prefix_from_lookup(|_| None),
|
||||||
|
"sk".to_string()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalizes_api_key_prefix_whitespace_and_trailing_dash() {
|
||||||
|
assert_eq!(
|
||||||
|
configured_api_key_prefix_from_lookup(|_| Some(" ak- ".to_string())),
|
||||||
|
"ak".to_string()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generates_plaintext_api_key_with_configured_prefix() {
|
||||||
|
let value = generate_gateway_api_key_plaintext_with_prefix("ak");
|
||||||
|
assert!(value.starts_with("ak-"));
|
||||||
|
assert_eq!(value.len(), 3 + 32 + 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uses_configured_prefix_in_placeholder_display() {
|
||||||
|
assert_eq!(
|
||||||
|
api_key_placeholder_display_with_prefix("ak"),
|
||||||
|
"ak-****".to_string()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn masks_plaintext_api_key_without_changing_prefix() {
|
||||||
|
assert_eq!(
|
||||||
|
masked_gateway_api_key_display(Some("ak-1234567890abcdef")),
|
||||||
|
"ak-1234567...cdef".to_string()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
mod admin_proxy;
|
mod 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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
509
apps/aether-gateway/src/state/bootstrap_admin.rs
Normal file
509
apps/aether-gateway/src/state/bootstrap_admin.rs
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
use crate::{AppState, GatewayError};
|
||||||
|
use aether_data::repository::wallet::WalletLookupKey;
|
||||||
|
use regex::Regex;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
const BOOTSTRAP_ADMIN_EMAIL_ENVS: &[&str] = &["ADMIN_EMAIL"];
|
||||||
|
const BOOTSTRAP_ADMIN_USERNAME_ENVS: &[&str] = &["ADMIN_USERNAME"];
|
||||||
|
const BOOTSTRAP_ADMIN_PASSWORD_ENVS: &[&str] = &["ADMIN_PASSWORD"];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct BootstrapAdminConfig {
|
||||||
|
email: Option<String>,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BootstrapAdminConfig {
|
||||||
|
fn from_env() -> Result<Option<Self>, GatewayError> {
|
||||||
|
Self::from_lookup(|key| {
|
||||||
|
std::env::var(key)
|
||||||
|
.ok()
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_lookup<F>(lookup: F) -> Result<Option<Self>, GatewayError>
|
||||||
|
where
|
||||||
|
F: Fn(&str) -> Option<String>,
|
||||||
|
{
|
||||||
|
let email = first_present_env(&lookup, BOOTSTRAP_ADMIN_EMAIL_ENVS);
|
||||||
|
let username = first_present_env(&lookup, BOOTSTRAP_ADMIN_USERNAME_ENVS);
|
||||||
|
let password = first_present_env(&lookup, BOOTSTRAP_ADMIN_PASSWORD_ENVS);
|
||||||
|
|
||||||
|
if email.is_none() && username.is_none() && password.is_none() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let username = username.ok_or_else(|| {
|
||||||
|
GatewayError::Internal(format!(
|
||||||
|
"bootstrap admin env is partially configured; set {}",
|
||||||
|
BOOTSTRAP_ADMIN_USERNAME_ENVS.join(" or ")
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let password = password.ok_or_else(|| {
|
||||||
|
GatewayError::Internal(format!(
|
||||||
|
"bootstrap admin env is partially configured; set {}",
|
||||||
|
BOOTSTRAP_ADMIN_PASSWORD_ENVS.join(" or ")
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Some(Self {
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_present_env<F>(lookup: &F, keys: &[&str]) -> Option<String>
|
||||||
|
where
|
||||||
|
F: Fn(&str) -> Option<String>,
|
||||||
|
{
|
||||||
|
keys.iter().find_map(|key| lookup(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_bootstrap_admin_email(value: Option<&str>) -> Result<Option<String>, GatewayError> {
|
||||||
|
let Some(value) = value else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let normalized = value.trim().to_ascii_lowercase();
|
||||||
|
if normalized.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let pattern = Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
|
||||||
|
.expect("bootstrap admin email regex should compile");
|
||||||
|
if !pattern.is_match(&normalized) {
|
||||||
|
return Err(GatewayError::Internal(
|
||||||
|
"bootstrap admin email format is invalid".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Some(normalized))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_bootstrap_admin_username(value: &str) -> Result<String, GatewayError> {
|
||||||
|
let value = value.trim();
|
||||||
|
if value.is_empty() {
|
||||||
|
return Err(GatewayError::Internal(
|
||||||
|
"bootstrap admin username cannot be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if value.len() < 3 {
|
||||||
|
return Err(GatewayError::Internal(
|
||||||
|
"bootstrap admin username must be at least 3 characters".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if value.len() > 30 {
|
||||||
|
return Err(GatewayError::Internal(
|
||||||
|
"bootstrap admin username must not exceed 30 characters".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let pattern =
|
||||||
|
Regex::new(r"^[a-zA-Z0-9_.-]+$").expect("bootstrap admin username regex should compile");
|
||||||
|
if !pattern.is_match(value) {
|
||||||
|
return Err(GatewayError::Internal(
|
||||||
|
"bootstrap admin username may only contain letters, numbers, underscores, hyphens, and dots".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_bootstrap_admin_password(password: &str, policy: &str) -> Result<(), GatewayError> {
|
||||||
|
if password.is_empty() {
|
||||||
|
return Err(GatewayError::Internal(
|
||||||
|
"bootstrap admin password cannot be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if password.as_bytes().len() > 72 {
|
||||||
|
return Err(GatewayError::Internal(
|
||||||
|
"bootstrap admin password must not exceed 72 bytes".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let min_len = if matches!(policy, "medium" | "strong") {
|
||||||
|
8
|
||||||
|
} else {
|
||||||
|
6
|
||||||
|
};
|
||||||
|
if password.chars().count() < min_len {
|
||||||
|
return Err(GatewayError::Internal(format!(
|
||||||
|
"bootstrap admin password must be at least {min_len} characters"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if policy == "medium" {
|
||||||
|
if !password.chars().any(|ch| ch.is_ascii_alphabetic()) {
|
||||||
|
return Err(GatewayError::Internal(
|
||||||
|
"bootstrap admin password must contain at least one letter".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !password.chars().any(|ch| ch.is_ascii_digit()) {
|
||||||
|
return Err(GatewayError::Internal(
|
||||||
|
"bootstrap admin password must contain at least one digit".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else if policy == "strong" {
|
||||||
|
if !password.chars().any(|ch| ch.is_ascii_uppercase()) {
|
||||||
|
return Err(GatewayError::Internal(
|
||||||
|
"bootstrap admin password must contain at least one uppercase letter".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !password.chars().any(|ch| ch.is_ascii_lowercase()) {
|
||||||
|
return Err(GatewayError::Internal(
|
||||||
|
"bootstrap admin password must contain at least one lowercase letter".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !password.chars().any(|ch| ch.is_ascii_digit()) {
|
||||||
|
return Err(GatewayError::Internal(
|
||||||
|
"bootstrap admin password must contain at least one digit".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !password.chars().any(|ch| !ch.is_ascii_alphanumeric()) {
|
||||||
|
return Err(GatewayError::Internal(
|
||||||
|
"bootstrap admin password must contain at least one special character".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_bootstrap_admin_password_policy(state: &AppState) -> Result<String, GatewayError> {
|
||||||
|
let configured = state
|
||||||
|
.read_system_config_json_value("password_policy_level")
|
||||||
|
.await?;
|
||||||
|
Ok(match configured.as_ref() {
|
||||||
|
Some(serde_json::Value::String(value))
|
||||||
|
if matches!(value.trim(), "weak" | "medium" | "strong") =>
|
||||||
|
{
|
||||||
|
value.trim().to_string()
|
||||||
|
}
|
||||||
|
_ => "weak".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_existing_bootstrap_user(
|
||||||
|
state: &AppState,
|
||||||
|
username: &str,
|
||||||
|
email: Option<&str>,
|
||||||
|
) -> Result<Option<aether_data::repository::users::StoredUserAuthRecord>, GatewayError> {
|
||||||
|
let by_username = state.find_user_auth_by_identifier(username).await?;
|
||||||
|
let by_email = match email {
|
||||||
|
Some(email) => state.find_user_auth_by_identifier(email).await?,
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
match (by_username, by_email) {
|
||||||
|
(Some(username_user), Some(email_user)) if username_user.id != email_user.id => {
|
||||||
|
Err(GatewayError::Internal(
|
||||||
|
"bootstrap admin username and email point to different existing users".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
(Some(user), _) | (_, Some(user)) => Ok(Some(user)),
|
||||||
|
(None, None) => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_bootstrap_admin_wallet(
|
||||||
|
state: &AppState,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<(), GatewayError> {
|
||||||
|
if state
|
||||||
|
.find_wallet(WalletLookupKey::UserId(user_id))
|
||||||
|
.await?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let created = state
|
||||||
|
.initialize_auth_user_wallet(user_id, 0.0, true)
|
||||||
|
.await?;
|
||||||
|
if created.is_none() {
|
||||||
|
return Err(GatewayError::Internal(
|
||||||
|
"bootstrap admin wallet storage is unavailable".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub async fn bootstrap_admin_from_env(&self) -> Result<(), std::io::Error> {
|
||||||
|
let Some(config) = BootstrapAdminConfig::from_env()
|
||||||
|
.map_err(|err| std::io::Error::other(format!("{err:?}")))?
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
self.bootstrap_admin_from_config(config)
|
||||||
|
.await
|
||||||
|
.map_err(|err| std::io::Error::other(format!("{err:?}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn bootstrap_admin_from_config(
|
||||||
|
&self,
|
||||||
|
config: BootstrapAdminConfig,
|
||||||
|
) -> Result<(), GatewayError> {
|
||||||
|
if !self.has_auth_user_write_capability() || !self.has_auth_wallet_write_capability() {
|
||||||
|
return Err(GatewayError::Internal(
|
||||||
|
"bootstrap admin requires Postgres-backed user and wallet write capability"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let email = normalize_bootstrap_admin_email(config.email.as_deref())?;
|
||||||
|
let username = normalize_bootstrap_admin_username(&config.username)?;
|
||||||
|
let password_policy = resolve_bootstrap_admin_password_policy(self).await?;
|
||||||
|
validate_bootstrap_admin_password(&config.password, &password_policy)?;
|
||||||
|
|
||||||
|
if let Some(existing_user) =
|
||||||
|
find_existing_bootstrap_user(self, &username, email.as_deref()).await?
|
||||||
|
{
|
||||||
|
if !existing_user.role.eq_ignore_ascii_case("admin")
|
||||||
|
|| !existing_user.auth_source.eq_ignore_ascii_case("local")
|
||||||
|
|| !existing_user.is_active
|
||||||
|
|| existing_user.is_deleted
|
||||||
|
{
|
||||||
|
return Err(GatewayError::Internal(format!(
|
||||||
|
"bootstrap admin target already exists but is not an active local admin: {}",
|
||||||
|
existing_user.username
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
ensure_bootstrap_admin_wallet(self, &existing_user.id).await?;
|
||||||
|
info!(
|
||||||
|
event_name = "bootstrap_admin_ready",
|
||||||
|
log_type = "ops",
|
||||||
|
user_id = %existing_user.id,
|
||||||
|
username = %existing_user.username,
|
||||||
|
email = existing_user.email.as_deref().unwrap_or("-"),
|
||||||
|
status = "existing",
|
||||||
|
"bootstrap admin already exists"
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.count_active_admin_users().await? > 0 {
|
||||||
|
info!(
|
||||||
|
event_name = "bootstrap_admin_skipped",
|
||||||
|
log_type = "ops",
|
||||||
|
username = %username,
|
||||||
|
email = email.as_deref().unwrap_or("-"),
|
||||||
|
status = "active_admin_exists",
|
||||||
|
"bootstrap admin skipped because another active admin already exists"
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let password_hash =
|
||||||
|
bcrypt::hash(&config.password, bcrypt::DEFAULT_COST).map_err(|err| {
|
||||||
|
GatewayError::Internal(format!("bootstrap admin password hash failed: {err}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
match self
|
||||||
|
.create_local_auth_user_with_settings(
|
||||||
|
email.clone(),
|
||||||
|
true,
|
||||||
|
username.clone(),
|
||||||
|
password_hash,
|
||||||
|
"admin".to_string(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(user)) => {
|
||||||
|
ensure_bootstrap_admin_wallet(self, &user.id).await?;
|
||||||
|
info!(
|
||||||
|
event_name = "bootstrap_admin_created",
|
||||||
|
log_type = "ops",
|
||||||
|
user_id = %user.id,
|
||||||
|
username = %user.username,
|
||||||
|
email = user.email.as_deref().unwrap_or("-"),
|
||||||
|
status = "created",
|
||||||
|
"bootstrap admin created from environment"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Ok(None) => Err(GatewayError::Internal(
|
||||||
|
"bootstrap admin user storage is unavailable".to_string(),
|
||||||
|
)),
|
||||||
|
Err(err) => {
|
||||||
|
if let Some(user) =
|
||||||
|
find_existing_bootstrap_user(self, &username, email.as_deref()).await?
|
||||||
|
{
|
||||||
|
ensure_bootstrap_admin_wallet(self, &user.id).await?;
|
||||||
|
warn!(
|
||||||
|
event_name = "bootstrap_admin_race_resolved",
|
||||||
|
log_type = "ops",
|
||||||
|
username = %user.username,
|
||||||
|
user_id = %user.id,
|
||||||
|
error = ?err,
|
||||||
|
"bootstrap admin creation raced with another writer; continuing with existing admin"
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::BootstrapAdminConfig;
|
||||||
|
use crate::AppState;
|
||||||
|
use aether_data::repository::wallet::WalletLookupKey;
|
||||||
|
|
||||||
|
fn bootstrap_config() -> BootstrapAdminConfig {
|
||||||
|
BootstrapAdminConfig {
|
||||||
|
email: Some("admin@example.com".to_string()),
|
||||||
|
username: "admin".to_string(),
|
||||||
|
password: "Secret123!".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_local_admin(
|
||||||
|
user_id: &str,
|
||||||
|
username: &str,
|
||||||
|
email: Option<&str>,
|
||||||
|
) -> aether_data::repository::users::StoredUserAuthRecord {
|
||||||
|
aether_data::repository::users::StoredUserAuthRecord::new(
|
||||||
|
user_id.to_string(),
|
||||||
|
email.map(|value| value.to_string()),
|
||||||
|
true,
|
||||||
|
username.to_string(),
|
||||||
|
Some(
|
||||||
|
bcrypt::hash("Secret123!", bcrypt::DEFAULT_COST)
|
||||||
|
.expect("sample admin password hash should build"),
|
||||||
|
),
|
||||||
|
"admin".to_string(),
|
||||||
|
"local".to_string(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
Some(chrono::Utc::now()),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("sample admin should build")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bootstrap_admin_creates_missing_admin_and_wallet() {
|
||||||
|
let state = AppState::new().expect("state should build");
|
||||||
|
|
||||||
|
state
|
||||||
|
.bootstrap_admin_from_config(bootstrap_config())
|
||||||
|
.await
|
||||||
|
.expect("bootstrap should succeed");
|
||||||
|
|
||||||
|
let user = state
|
||||||
|
.find_user_auth_by_identifier("admin")
|
||||||
|
.await
|
||||||
|
.expect("lookup should succeed")
|
||||||
|
.expect("admin should exist");
|
||||||
|
assert_eq!(user.role, "admin");
|
||||||
|
assert_eq!(user.auth_source, "local");
|
||||||
|
assert_eq!(user.email.as_deref(), Some("admin@example.com"));
|
||||||
|
assert!(bcrypt::verify(
|
||||||
|
"Secret123!",
|
||||||
|
user.password_hash
|
||||||
|
.as_deref()
|
||||||
|
.expect("password hash should exist")
|
||||||
|
)
|
||||||
|
.expect("password hash should verify"));
|
||||||
|
|
||||||
|
let wallet = state
|
||||||
|
.find_wallet(WalletLookupKey::UserId(&user.id))
|
||||||
|
.await
|
||||||
|
.expect("wallet lookup should succeed")
|
||||||
|
.expect("wallet should exist");
|
||||||
|
assert_eq!(wallet.limit_mode, "unlimited");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bootstrap_admin_repairs_missing_wallet_for_existing_matching_admin() {
|
||||||
|
let existing = sample_local_admin("admin-user-1", "admin", Some("admin@example.com"));
|
||||||
|
let state = AppState::new()
|
||||||
|
.expect("state should build")
|
||||||
|
.with_auth_users_for_tests([existing.clone()]);
|
||||||
|
|
||||||
|
state
|
||||||
|
.bootstrap_admin_from_config(bootstrap_config())
|
||||||
|
.await
|
||||||
|
.expect("bootstrap should succeed");
|
||||||
|
|
||||||
|
let wallet = state
|
||||||
|
.find_wallet(WalletLookupKey::UserId(&existing.id))
|
||||||
|
.await
|
||||||
|
.expect("wallet lookup should succeed")
|
||||||
|
.expect("wallet should exist");
|
||||||
|
assert_eq!(wallet.limit_mode, "unlimited");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bootstrap_admin_rejects_existing_non_admin_collision() {
|
||||||
|
let existing = aether_data::repository::users::StoredUserAuthRecord::new(
|
||||||
|
"user-1".to_string(),
|
||||||
|
Some("admin@example.com".to_string()),
|
||||||
|
true,
|
||||||
|
"admin".to_string(),
|
||||||
|
Some(
|
||||||
|
bcrypt::hash("Secret123!", bcrypt::DEFAULT_COST)
|
||||||
|
.expect("sample password hash should build"),
|
||||||
|
),
|
||||||
|
"user".to_string(),
|
||||||
|
"local".to_string(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
Some(chrono::Utc::now()),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("sample user should build");
|
||||||
|
let state = AppState::new()
|
||||||
|
.expect("state should build")
|
||||||
|
.with_auth_users_for_tests([existing]);
|
||||||
|
|
||||||
|
let err = state
|
||||||
|
.bootstrap_admin_from_config(bootstrap_config())
|
||||||
|
.await
|
||||||
|
.expect_err("bootstrap should fail");
|
||||||
|
let detail = format!("{err:?}");
|
||||||
|
assert!(detail.contains("not an active local admin"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bootstrap_admin_config_reads_admin_env_names() {
|
||||||
|
let vars = std::collections::BTreeMap::from([
|
||||||
|
("ADMIN_EMAIL".to_string(), "admin@example.com".to_string()),
|
||||||
|
("ADMIN_USERNAME".to_string(), "admin".to_string()),
|
||||||
|
("ADMIN_PASSWORD".to_string(), "Secret123!".to_string()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let config = BootstrapAdminConfig::from_lookup(|key| vars.get(key).cloned())
|
||||||
|
.expect("config parsing should succeed")
|
||||||
|
.expect("config should exist");
|
||||||
|
assert_eq!(
|
||||||
|
config,
|
||||||
|
BootstrapAdminConfig {
|
||||||
|
email: Some("admin@example.com".to_string()),
|
||||||
|
username: "admin".to_string(),
|
||||||
|
password: "Secret123!".to_string(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bootstrap_admin_config_rejects_partial_env() {
|
||||||
|
let vars =
|
||||||
|
std::collections::BTreeMap::from([("ADMIN_USERNAME".to_string(), "admin".to_string())]);
|
||||||
|
|
||||||
|
let err = BootstrapAdminConfig::from_lookup(|key| vars.get(key).cloned())
|
||||||
|
.expect_err("partial config should fail");
|
||||||
|
let detail = format!("{err:?}");
|
||||||
|
assert!(detail.contains("bootstrap admin env is partially configured"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ use super::error::GatewayError;
|
|||||||
|
|
||||||
mod admin_types;
|
mod admin_types;
|
||||||
mod app;
|
mod app;
|
||||||
|
mod bootstrap_admin;
|
||||||
mod cache;
|
mod cache;
|
||||||
mod catalog;
|
mod catalog;
|
||||||
mod core;
|
mod core;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
7
dev.sh
@@ -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=$!
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 相关字段默认只允许摘要,不允许原文
|
|
||||||
@@ -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`
|
|
||||||
- 多实例下前台限流不会再退回本地内存计数;单机模式才允许这种兜底
|
|
||||||
@@ -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
|
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
}
|
||||||
}))
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
138
pyproject.toml
138
pyproject.toml
@@ -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"]
|
|
||||||
Reference in New Issue
Block a user