Compare commits
12 Commits
42fc3f2cf3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
906ac686a2 | ||
|
|
3670aceb4a | ||
|
|
0504029e47 | ||
|
|
51e4b3a839 | ||
|
|
d1dc095cb1 | ||
|
|
17ddd815a9 | ||
|
|
df1ac40d41 | ||
|
|
cc4e4cea94 | ||
|
|
8068475d6e | ||
|
|
eef909c5dd | ||
|
|
3dccbcfed1 | ||
|
|
ed888edfc9 |
14
.env.example
14
.env.example
@@ -29,3 +29,17 @@ PM2_APP_NAME=droid2api
|
|||||||
# Cloudflare Tunnel Configuration (Optional)
|
# Cloudflare Tunnel Configuration (Optional)
|
||||||
# Get token from: https://one.dash.cloudflare.com/ -> Networks -> Tunnels
|
# Get token from: https://one.dash.cloudflare.com/ -> Networks -> Tunnels
|
||||||
TUNNEL_TOKEN=
|
TUNNEL_TOKEN=
|
||||||
|
|
||||||
|
# CORS Configuration (Optional, overrides config.json)
|
||||||
|
# CORS_ENABLED=true
|
||||||
|
# CORS_ALLOW_ALL=false
|
||||||
|
# CORS_ORIGINS=https://app1.com,https://app2.com
|
||||||
|
|
||||||
|
# API Authentication - Protect your API endpoints
|
||||||
|
# Recommended for production or when used as backend for new-api/one-api
|
||||||
|
#
|
||||||
|
# Security flow: User -> [new-api验证] -> [droid2api验证] -> Factory API
|
||||||
|
#
|
||||||
|
AUTH_ENABLED=false # Set to true to enable authentication
|
||||||
|
API_KEYS=sk-internal-secret-key # Internal key shared with new-api (comma-separated for multiple)
|
||||||
|
AUTH_PUBLIC_MODELS=true # Allow /v1/models without auth
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ node_modules/
|
|||||||
*.txt
|
*.txt
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
accounts.json
|
accounts.json
|
||||||
|
.serena/
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# 使用官方 Node.js 运行时作为基础镜像
|
# 使用官方 Node.js 运行时作为基础镜像
|
||||||
FROM node:24-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
# 设置工作目录
|
# 设置工作目录
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -8,7 +8,7 @@ WORKDIR /app
|
|||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# 安装项目依赖
|
# 安装项目依赖
|
||||||
RUN npm ci --only=production
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
# 复制项目文件
|
# 复制项目文件
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
146
auth-middleware.js
Normal file
146
auth-middleware.js
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* 请求认证中间件
|
||||||
|
* 验证客户端请求的 API Key,保护 API 端点
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getConfig } from './config.js';
|
||||||
|
import { logInfo, logError } from './logger.js';
|
||||||
|
|
||||||
|
// 不需要认证的路径(精确匹配)
|
||||||
|
const PUBLIC_PATHS = new Set([
|
||||||
|
'/',
|
||||||
|
'/health',
|
||||||
|
'/status'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 可配置是否公开的路径
|
||||||
|
const OPTIONAL_PUBLIC_PATHS = new Set([
|
||||||
|
'/v1/models'
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取认证配置
|
||||||
|
* API Keys 只从环境变量读取(安全考虑)
|
||||||
|
* enabled/public_models 可从 config.json 读取默认值,环境变量可覆盖
|
||||||
|
*/
|
||||||
|
export function getAuthConfig() {
|
||||||
|
const cfg = getConfig();
|
||||||
|
const configAuth = cfg.auth || {};
|
||||||
|
|
||||||
|
// 环境变量
|
||||||
|
const envEnabled = process.env.AUTH_ENABLED;
|
||||||
|
const envApiKeys = process.env.API_KEYS;
|
||||||
|
const envPublicModels = process.env.AUTH_PUBLIC_MODELS;
|
||||||
|
|
||||||
|
// 解析 enabled(环境变量 > config.json)
|
||||||
|
let enabled = configAuth.enabled ?? false;
|
||||||
|
if (envEnabled !== undefined) {
|
||||||
|
enabled = ['true', '1', 'yes'].includes(envEnabled.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Keys 只从环境变量读取(敏感信息不应存储在配置文件中)
|
||||||
|
let apiKeys = [];
|
||||||
|
if (envApiKeys) {
|
||||||
|
apiKeys = envApiKeys.split(',').map(k => k.trim()).filter(k => k);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 public_models(环境变量 > config.json)
|
||||||
|
let publicModels = configAuth.public_models ?? true;
|
||||||
|
if (envPublicModels !== undefined) {
|
||||||
|
publicModels = ['true', '1', 'yes'].includes(envPublicModels.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
apiKeys: new Set(apiKeys),
|
||||||
|
publicModels
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从请求头中提取 API Key
|
||||||
|
* 支持: Authorization: Bearer <key> 或 x-api-key: <key>
|
||||||
|
*/
|
||||||
|
function extractApiKey(req) {
|
||||||
|
// 优先检查 Authorization header
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (authHeader) {
|
||||||
|
if (authHeader.startsWith('Bearer ')) {
|
||||||
|
return authHeader.slice(7).trim();
|
||||||
|
}
|
||||||
|
// 也支持直接传 key(不带 Bearer 前缀)
|
||||||
|
return authHeader.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其次检查 x-api-key header
|
||||||
|
const xApiKey = req.headers['x-api-key'];
|
||||||
|
if (xApiKey) {
|
||||||
|
return xApiKey.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证中间件
|
||||||
|
*/
|
||||||
|
export function authMiddleware(req, res, next) {
|
||||||
|
const authConfig = getAuthConfig();
|
||||||
|
|
||||||
|
// 如果认证未启用,直接放行
|
||||||
|
if (!authConfig.enabled) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否是公开路径
|
||||||
|
if (PUBLIC_PATHS.has(req.path)) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查可选公开路径
|
||||||
|
if (authConfig.publicModels && OPTIONAL_PUBLIC_PATHS.has(req.path)) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 API Keys 是否配置
|
||||||
|
if (authConfig.apiKeys.size === 0) {
|
||||||
|
logError('Auth enabled but no API keys configured');
|
||||||
|
return res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: 'Server configuration error: authentication enabled but no API keys configured',
|
||||||
|
type: 'server_error',
|
||||||
|
code: 'auth_not_configured'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取并验证 API Key
|
||||||
|
const clientKey = extractApiKey(req);
|
||||||
|
|
||||||
|
if (!clientKey) {
|
||||||
|
logInfo(`Auth failed: No API key provided for ${req.method} ${req.path}`);
|
||||||
|
return res.status(401).json({
|
||||||
|
error: {
|
||||||
|
message: 'Missing API key. Please include your API key in the Authorization header using Bearer auth (Authorization: Bearer YOUR_API_KEY) or as x-api-key header.',
|
||||||
|
type: 'authentication_error',
|
||||||
|
code: 'missing_api_key'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authConfig.apiKeys.has(clientKey)) {
|
||||||
|
logInfo(`Auth failed: Invalid API key for ${req.method} ${req.path}`);
|
||||||
|
return res.status(401).json({
|
||||||
|
error: {
|
||||||
|
message: 'Invalid API key provided.',
|
||||||
|
type: 'authentication_error',
|
||||||
|
code: 'invalid_api_key'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 认证通过
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default authMiddleware;
|
||||||
72
auth.js
72
auth.js
@@ -10,6 +10,7 @@ import { getRefreshConfig, requestRefreshToken } from './refresh-client.js';
|
|||||||
let currentApiKey = null;
|
let currentApiKey = null;
|
||||||
let currentRefreshToken = null;
|
let currentRefreshToken = null;
|
||||||
let lastRefreshTime = null;
|
let lastRefreshTime = null;
|
||||||
|
let tokenExpiresAt = null; // Token 过期时间戳 (ms)
|
||||||
let clientId = null;
|
let clientId = null;
|
||||||
let authSource = null; // 'env' or 'file' or 'factory_key' or 'client' or 'multi_account'
|
let authSource = null; // 'env' or 'file' or 'factory_key' or 'client' or 'multi_account'
|
||||||
let authFilePath = null;
|
let authFilePath = null;
|
||||||
@@ -21,6 +22,7 @@ let refreshInFlight = null; // 刷新锁,避免并发刷新
|
|||||||
const REFRESH_URL = 'https://api.workos.com/user_management/authenticate';
|
const REFRESH_URL = 'https://api.workos.com/user_management/authenticate';
|
||||||
const REFRESH_INTERVAL_HOURS = 6; // Refresh every 6 hours
|
const REFRESH_INTERVAL_HOURS = 6; // Refresh every 6 hours
|
||||||
const TOKEN_VALID_HOURS = 8; // Token valid for 8 hours
|
const TOKEN_VALID_HOURS = 8; // Token valid for 8 hours
|
||||||
|
const REFRESH_BUFFER_MS = 30 * 60 * 1000; // 提前 30 分钟刷新
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a ULID (Universally Unique Lexicographically Sortable Identifier)
|
* Generate a ULID (Universally Unique Lexicographically Sortable Identifier)
|
||||||
@@ -123,9 +125,25 @@ function loadAuthConfig() {
|
|||||||
authSource = 'file';
|
authSource = 'file';
|
||||||
authFilePath = factoryAuthPath;
|
authFilePath = factoryAuthPath;
|
||||||
|
|
||||||
// Also load access_token if available
|
// Also load access_token if available and not expired
|
||||||
if (authData.access_token) {
|
if (authData.access_token) {
|
||||||
currentApiKey = authData.access_token.trim();
|
const expiresAt = authData.expires_at ? new Date(authData.expires_at).getTime() : null;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (expiresAt && expiresAt > now + REFRESH_BUFFER_MS) {
|
||||||
|
// Token 还有效(且距离过期超过30分钟)
|
||||||
|
currentApiKey = authData.access_token.trim();
|
||||||
|
tokenExpiresAt = expiresAt;
|
||||||
|
lastRefreshTime = authData.last_updated ? new Date(authData.last_updated).getTime() : now;
|
||||||
|
logInfo(`Loaded valid token from file, expires at: ${new Date(expiresAt).toISOString()}`);
|
||||||
|
} else if (expiresAt) {
|
||||||
|
// Token 已过期或即将过期
|
||||||
|
logInfo(`Stored token expired or expiring soon (expires_at: ${authData.expires_at}), will refresh`);
|
||||||
|
} else {
|
||||||
|
// 没有过期时间记录,按旧逻辑处理
|
||||||
|
currentApiKey = authData.access_token.trim();
|
||||||
|
logInfo('Loaded token from file (no expiry info, will check on first use)');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { type: 'refresh', value: authData.refresh_token.trim() };
|
return { type: 'refresh', value: authData.refresh_token.trim() };
|
||||||
@@ -162,20 +180,22 @@ async function refreshApiKey() {
|
|||||||
logInfo('Refreshing API key...');
|
logInfo('Refreshing API key...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const proxyAgentInfo = getNextProxyAgent(REFRESH_URL);
|
const proxyAgentInfo = getNextProxyAgent(REFRESH_URL);
|
||||||
const refreshConfig = getRefreshConfig();
|
const refreshConfig = getRefreshConfig();
|
||||||
const data = await requestRefreshToken({
|
const data = await requestRefreshToken({
|
||||||
refreshUrl: REFRESH_URL,
|
refreshUrl: REFRESH_URL,
|
||||||
refreshToken: currentRefreshToken,
|
refreshToken: currentRefreshToken,
|
||||||
clientId,
|
clientId,
|
||||||
proxyAgentInfo,
|
proxyAgentInfo,
|
||||||
...refreshConfig
|
...refreshConfig
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update tokens
|
// Update tokens
|
||||||
currentApiKey = data.access_token;
|
currentApiKey = data.access_token;
|
||||||
currentRefreshToken = data.refresh_token;
|
currentRefreshToken = data.refresh_token;
|
||||||
lastRefreshTime = Date.now();
|
lastRefreshTime = Date.now();
|
||||||
|
// 设置过期时间(默认8小时)
|
||||||
|
tokenExpiresAt = lastRefreshTime + TOKEN_VALID_HOURS * 60 * 60 * 1000;
|
||||||
|
|
||||||
// Log user info
|
// Log user info
|
||||||
if (data.user) {
|
if (data.user) {
|
||||||
@@ -188,6 +208,7 @@ async function refreshApiKey() {
|
|||||||
saveTokens(data.access_token, data.refresh_token);
|
saveTokens(data.access_token, data.refresh_token);
|
||||||
|
|
||||||
logInfo(`New Refresh-Key: ${currentRefreshToken}`);
|
logInfo(`New Refresh-Key: ${currentRefreshToken}`);
|
||||||
|
logInfo(`Token expires at: ${new Date(tokenExpiresAt).toISOString()}`);
|
||||||
logInfo('API key refreshed successfully');
|
logInfo('API key refreshed successfully');
|
||||||
return data.access_token;
|
return data.access_token;
|
||||||
|
|
||||||
@@ -206,13 +227,20 @@ async function refreshApiKey() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Save tokens to appropriate file
|
* Save tokens to appropriate file
|
||||||
|
* @param {string} accessToken - Access token to save
|
||||||
|
* @param {string} refreshToken - Refresh token to save
|
||||||
|
* @param {number} expiresInMs - Token validity duration in milliseconds (default: TOKEN_VALID_HOURS)
|
||||||
*/
|
*/
|
||||||
function saveTokens(accessToken, refreshToken) {
|
function saveTokens(accessToken, refreshToken, expiresInMs = TOKEN_VALID_HOURS * 60 * 60 * 1000) {
|
||||||
try {
|
try {
|
||||||
|
const now = Date.now();
|
||||||
|
const expiresAt = new Date(now + expiresInMs).toISOString();
|
||||||
|
|
||||||
const authData = {
|
const authData = {
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
last_updated: new Date().toISOString()
|
expires_at: expiresAt,
|
||||||
|
last_updated: new Date(now).toISOString()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ensure directory exists
|
// Ensure directory exists
|
||||||
@@ -228,6 +256,7 @@ function saveTokens(accessToken, refreshToken) {
|
|||||||
Object.assign(authData, existingData, {
|
Object.assign(authData, existingData, {
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
|
expires_at: expiresAt,
|
||||||
last_updated: authData.last_updated
|
last_updated: authData.last_updated
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -244,14 +273,27 @@ function saveTokens(accessToken, refreshToken) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if API key needs refresh (older than 6 hours)
|
* Check if API key needs refresh
|
||||||
|
* Uses actual expiration time if available, falls back to time-based check
|
||||||
*/
|
*/
|
||||||
function shouldRefresh() {
|
function shouldRefresh() {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// 如果有过期时间,使用过期时间判断(提前30分钟刷新)
|
||||||
|
if (tokenExpiresAt) {
|
||||||
|
const shouldRefreshByExpiry = now + REFRESH_BUFFER_MS >= tokenExpiresAt;
|
||||||
|
if (shouldRefreshByExpiry) {
|
||||||
|
logDebug(`Token expiring soon (expires_at: ${new Date(tokenExpiresAt).toISOString()})`);
|
||||||
|
}
|
||||||
|
return shouldRefreshByExpiry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退到基于刷新时间的判断
|
||||||
if (!lastRefreshTime) {
|
if (!lastRefreshTime) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hoursSinceRefresh = (Date.now() - lastRefreshTime) / (1000 * 60 * 60);
|
const hoursSinceRefresh = (now - lastRefreshTime) / (1000 * 60 * 60);
|
||||||
return hoursSinceRefresh >= REFRESH_INTERVAL_HOURS;
|
return hoursSinceRefresh >= REFRESH_INTERVAL_HOURS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
40
config.js
40
config.js
@@ -91,3 +91,43 @@ export function getRedirectedModelId(modelId) {
|
|||||||
}
|
}
|
||||||
return modelId;
|
return modelId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 CORS 配置
|
||||||
|
* 优先级: 环境变量 > config.json > 默认值
|
||||||
|
* @returns {Object} CORS 配置对象
|
||||||
|
*/
|
||||||
|
export function getCorsConfig() {
|
||||||
|
const cfg = getConfig();
|
||||||
|
const configCors = cfg.cors || {};
|
||||||
|
|
||||||
|
// 环境变量覆盖
|
||||||
|
const envAllowAll = process.env.CORS_ALLOW_ALL;
|
||||||
|
const envOrigins = process.env.CORS_ORIGINS;
|
||||||
|
|
||||||
|
// 解析 allow_all
|
||||||
|
let allowAll = configCors.allow_all ?? false;
|
||||||
|
if (envAllowAll !== undefined) {
|
||||||
|
allowAll = ['true', '1', 'yes'].includes(envAllowAll.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 origins
|
||||||
|
let origins = configCors.origins || [];
|
||||||
|
if (envOrigins) {
|
||||||
|
origins = envOrigins.split(',').map(o => o.trim()).filter(o => o);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 enabled
|
||||||
|
let enabled = configCors.enabled ?? true;
|
||||||
|
if (process.env.CORS_ENABLED !== undefined) {
|
||||||
|
enabled = ['true', '1', 'yes'].includes(process.env.CORS_ENABLED.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
allowAll,
|
||||||
|
origins,
|
||||||
|
methods: configCors.methods || ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||||
|
headers: configCors.headers || ['Content-Type', 'Authorization', 'X-API-Key', 'anthropic-version']
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
13
config.json
13
config.json
@@ -91,6 +91,19 @@
|
|||||||
"provider": "google"
|
"provider": "google"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"cors": {
|
||||||
|
"enabled": true,
|
||||||
|
"allow_all": false,
|
||||||
|
"origins": [
|
||||||
|
"http://localhost:3000",
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://127.0.0.1:3000"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"enabled": false,
|
||||||
|
"public_models": true
|
||||||
|
},
|
||||||
"dev_mode": false,
|
"dev_mode": false,
|
||||||
"user_agent": "factory-cli/0.40.2",
|
"user_agent": "factory-cli/0.40.2",
|
||||||
"system_prompt": "You are Droid, an AI software engineering agent built by Factory.\n\n"
|
"system_prompt": "You are Droid, an AI software engineering agent built by Factory.\n\n"
|
||||||
|
|||||||
61
docker-compose.prod.yml
Normal file
61
docker-compose.prod.yml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# 生产环境 Docker Compose 配置
|
||||||
|
# 使用预构建镜像,适用于离线部署
|
||||||
|
#
|
||||||
|
# 部署步骤:
|
||||||
|
# 1. docker network create api-network (首次)
|
||||||
|
# 2. gunzip -c droid2api.tar.gz | docker load
|
||||||
|
# 3. docker compose -f docker-compose.prod.yml up -d
|
||||||
|
|
||||||
|
services:
|
||||||
|
droid2api:
|
||||||
|
image: droid2api:latest # 使用预构建镜像(非 build)
|
||||||
|
container_name: droid2api
|
||||||
|
ports:
|
||||||
|
- "3001:3000"
|
||||||
|
environment:
|
||||||
|
# 认证配置(按优先级选择其一):
|
||||||
|
- FACTORY_API_KEY=${FACTORY_API_KEY}
|
||||||
|
- DROID_REFRESH_KEY=${DROID_REFRESH_KEY}
|
||||||
|
# 阿里云日志服务配置
|
||||||
|
- ALIYUN_ACCESS_KEY_ID=${ALIYUN_ACCESS_KEY_ID}
|
||||||
|
- ALIYUN_ACCESS_KEY_SECRET=${ALIYUN_ACCESS_KEY_SECRET}
|
||||||
|
- ALIYUN_SLS_ENDPOINT=${ALIYUN_SLS_ENDPOINT}
|
||||||
|
- ALIYUN_SLS_PROJECT=${ALIYUN_SLS_PROJECT}
|
||||||
|
- ALIYUN_SLS_LOGSTORE=${ALIYUN_SLS_LOGSTORE}
|
||||||
|
# API 认证中间件(new-api 接入时启用)
|
||||||
|
- AUTH_ENABLED=${AUTH_ENABLED:-false}
|
||||||
|
- API_KEYS=${API_KEYS}
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./accounts.json:/app/accounts.json:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
networks:
|
||||||
|
- api-network
|
||||||
|
|
||||||
|
# Cloudflare Tunnel (可选)
|
||||||
|
# 启用方式: docker compose -f docker-compose.prod.yml --profile tunnel up -d
|
||||||
|
tunnel:
|
||||||
|
image: cloudflare/cloudflared:latest
|
||||||
|
container_name: droid2api_tunnel
|
||||||
|
restart: unless-stopped
|
||||||
|
command: tunnel run
|
||||||
|
environment:
|
||||||
|
- TUNNEL_TOKEN=${TUNNEL_TOKEN}
|
||||||
|
depends_on:
|
||||||
|
- droid2api
|
||||||
|
profiles:
|
||||||
|
- tunnel
|
||||||
|
networks:
|
||||||
|
- api-network
|
||||||
|
|
||||||
|
# 共享网络 - 与 new-api 等服务互通
|
||||||
|
# 首次使用: docker network create api-network
|
||||||
|
networks:
|
||||||
|
api-network:
|
||||||
|
external: true
|
||||||
@@ -17,6 +17,9 @@ services:
|
|||||||
- ALIYUN_SLS_ENDPOINT=${ALIYUN_SLS_ENDPOINT}
|
- ALIYUN_SLS_ENDPOINT=${ALIYUN_SLS_ENDPOINT}
|
||||||
- ALIYUN_SLS_PROJECT=${ALIYUN_SLS_PROJECT}
|
- ALIYUN_SLS_PROJECT=${ALIYUN_SLS_PROJECT}
|
||||||
- ALIYUN_SLS_LOGSTORE=${ALIYUN_SLS_LOGSTORE}
|
- ALIYUN_SLS_LOGSTORE=${ALIYUN_SLS_LOGSTORE}
|
||||||
|
# API 认证中间件
|
||||||
|
- AUTH_ENABLED=${AUTH_ENABLED:-false}
|
||||||
|
- API_KEYS=${API_KEYS}
|
||||||
volumes:
|
volumes:
|
||||||
# 可选:持久化auth.json以保存刷新的tokens
|
# 可选:持久化auth.json以保存刷新的tokens
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
@@ -29,8 +32,11 @@ services:
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
|
networks:
|
||||||
|
- api-network
|
||||||
|
|
||||||
# Cloudflare Tunnel (可选)
|
# Cloudflare Tunnel (可选)
|
||||||
|
# 启用方式: docker compose --profile tunnel up -d
|
||||||
# 需要在 .env 中设置 TUNNEL_TOKEN
|
# 需要在 .env 中设置 TUNNEL_TOKEN
|
||||||
tunnel:
|
tunnel:
|
||||||
image: cloudflare/cloudflared:latest
|
image: cloudflare/cloudflared:latest
|
||||||
@@ -41,3 +47,13 @@ services:
|
|||||||
- TUNNEL_TOKEN=${TUNNEL_TOKEN}
|
- TUNNEL_TOKEN=${TUNNEL_TOKEN}
|
||||||
depends_on:
|
depends_on:
|
||||||
- droid2api
|
- droid2api
|
||||||
|
profiles:
|
||||||
|
- tunnel
|
||||||
|
networks:
|
||||||
|
- api-network
|
||||||
|
|
||||||
|
# 共享网络配置 - 支持与 new-api 等其他服务独立部署但可互通
|
||||||
|
# 首次使用需先创建网络: docker network create api-network
|
||||||
|
networks:
|
||||||
|
api-network:
|
||||||
|
external: true
|
||||||
|
|||||||
146
server.js
146
server.js
@@ -1,28 +1,156 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { loadConfig, isDevMode, getPort } from './config.js';
|
import { loadConfig, isDevMode, getPort, getCorsConfig } from './config.js';
|
||||||
import { logInfo, logError } from './logger.js';
|
import { logInfo, logError } from './logger.js';
|
||||||
import router from './routes.js';
|
import router from './routes.js';
|
||||||
import { initializeAuth } from './auth.js';
|
import { initializeAuth } from './auth.js';
|
||||||
import { initializeUserAgentUpdater } from './user-agent-updater.js';
|
import { initializeUserAgentUpdater } from './user-agent-updater.js';
|
||||||
import './sls-logger.js'; // 初始化阿里云日志服务
|
import './sls-logger.js'; // 初始化阿里云日志服务
|
||||||
import { sanitizeForLog } from './log-sanitizer.js';
|
import { sanitizeForLog } from './log-sanitizer.js';
|
||||||
|
import { authMiddleware, getAuthConfig } from './auth-middleware.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 全局错误处理 - 必须在应用启动前注册
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
let isShuttingDown = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 优雅关闭服务器
|
||||||
|
*/
|
||||||
|
function gracefulShutdown(reason, exitCode = 1) {
|
||||||
|
if (isShuttingDown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isShuttingDown = true;
|
||||||
|
|
||||||
|
console.error(`\n${'='.repeat(80)}`);
|
||||||
|
console.error(`🛑 Server shutting down: ${reason}`);
|
||||||
|
console.error(`${'='.repeat(80)}\n`);
|
||||||
|
|
||||||
|
// 给正在处理的请求一些时间完成
|
||||||
|
setTimeout(() => {
|
||||||
|
process.exit(exitCode);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理未捕获的 Promise Rejection
|
||||||
|
*/
|
||||||
|
process.on('unhandledRejection', (reason, promise) => {
|
||||||
|
const errorInfo = {
|
||||||
|
type: 'unhandledRejection',
|
||||||
|
reason: reason instanceof Error ? reason.message : String(reason),
|
||||||
|
stack: reason instanceof Error ? reason.stack : undefined,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
console.error(`\n${'='.repeat(80)}`);
|
||||||
|
console.error('⚠️ Unhandled Promise Rejection');
|
||||||
|
console.error('='.repeat(80));
|
||||||
|
console.error(`Time: ${errorInfo.timestamp}`);
|
||||||
|
console.error(`Reason: ${errorInfo.reason}`);
|
||||||
|
if (errorInfo.stack && isDevMode()) {
|
||||||
|
console.error(`Stack: ${errorInfo.stack}`);
|
||||||
|
}
|
||||||
|
console.error('='.repeat(80) + '\n');
|
||||||
|
|
||||||
|
logError('Unhandled Promise Rejection', sanitizeForLog(errorInfo));
|
||||||
|
|
||||||
|
// 在生产环境中,unhandledRejection 可能表示严重问题,考虑退出
|
||||||
|
// 但为了服务稳定性,这里只记录不退出
|
||||||
|
// 如需更严格的处理,可取消下面的注释:
|
||||||
|
// gracefulShutdown('Unhandled Promise Rejection', 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理未捕获的异常
|
||||||
|
*/
|
||||||
|
process.on('uncaughtException', (error) => {
|
||||||
|
const errorInfo = {
|
||||||
|
type: 'uncaughtException',
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
console.error(`\n${'='.repeat(80)}`);
|
||||||
|
console.error('💥 Uncaught Exception');
|
||||||
|
console.error('='.repeat(80));
|
||||||
|
console.error(`Time: ${errorInfo.timestamp}`);
|
||||||
|
console.error(`Error: ${errorInfo.message}`);
|
||||||
|
if (errorInfo.stack) {
|
||||||
|
console.error(`Stack: ${errorInfo.stack}`);
|
||||||
|
}
|
||||||
|
console.error('='.repeat(80) + '\n');
|
||||||
|
|
||||||
|
logError('Uncaught Exception', sanitizeForLog(errorInfo));
|
||||||
|
|
||||||
|
// uncaughtException 后进程状态不确定,必须退出
|
||||||
|
gracefulShutdown('Uncaught Exception', 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理进程信号
|
||||||
|
*/
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
logInfo('Received SIGTERM signal');
|
||||||
|
gracefulShutdown('SIGTERM', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
logInfo('Received SIGINT signal');
|
||||||
|
gracefulShutdown('SIGINT', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(express.json({ limit: '50mb' }));
|
app.use(express.json({ limit: '50mb' }));
|
||||||
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CORS 中间件 - 根据配置动态处理跨域请求
|
||||||
|
*/
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
res.header('Access-Control-Allow-Origin', '*');
|
const corsConfig = getCorsConfig();
|
||||||
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
||||||
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key, anthropic-version');
|
// 如果 CORS 完全禁用,直接跳过
|
||||||
|
if (!corsConfig.enabled) {
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
return res.sendStatus(204);
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const origin = req.headers.origin;
|
||||||
|
|
||||||
|
// 设置允许的方法和头
|
||||||
|
res.header('Access-Control-Allow-Methods', corsConfig.methods.join(', '));
|
||||||
|
res.header('Access-Control-Allow-Headers', corsConfig.headers.join(', '));
|
||||||
|
|
||||||
|
if (corsConfig.allowAll) {
|
||||||
|
// 允许所有来源(仅用于开发环境)
|
||||||
|
res.header('Access-Control-Allow-Origin', '*');
|
||||||
|
} else if (origin && corsConfig.origins.length > 0) {
|
||||||
|
// 白名单模式:验证请求来源
|
||||||
|
if (corsConfig.origins.includes(origin)) {
|
||||||
|
res.header('Access-Control-Allow-Origin', origin);
|
||||||
|
res.header('Vary', 'Origin');
|
||||||
|
}
|
||||||
|
// 不在白名单中的请求不设置 CORS 头,浏览器会拒绝
|
||||||
|
}
|
||||||
|
// 如果没有配置 origins 且不是 allowAll,则不设置任何 CORS 头
|
||||||
|
|
||||||
if (req.method === 'OPTIONS') {
|
if (req.method === 'OPTIONS') {
|
||||||
return res.sendStatus(200);
|
return res.sendStatus(204);
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 请求认证中间件
|
||||||
|
app.use(authMiddleware);
|
||||||
|
|
||||||
app.use(router);
|
app.use(router);
|
||||||
|
|
||||||
app.get('/', (req, res) => {
|
app.get('/', (req, res) => {
|
||||||
@@ -120,6 +248,14 @@ app.use((err, req, res, next) => {
|
|||||||
logInfo('Configuration loaded successfully');
|
logInfo('Configuration loaded successfully');
|
||||||
logInfo(`Dev mode: ${isDevMode()}`);
|
logInfo(`Dev mode: ${isDevMode()}`);
|
||||||
|
|
||||||
|
// Log auth status
|
||||||
|
const authConfig = getAuthConfig();
|
||||||
|
if (authConfig.enabled) {
|
||||||
|
logInfo(`Auth enabled with ${authConfig.apiKeys.size} API key(s)`);
|
||||||
|
} else {
|
||||||
|
logInfo('Auth disabled - API endpoints are publicly accessible');
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize User-Agent version updater
|
// Initialize User-Agent version updater
|
||||||
initializeUserAgentUpdater();
|
initializeUserAgentUpdater();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user