From fff7289bde4537a13bb04df96afa840901e3971d Mon Sep 17 00:00:00 2001 From: datehoer <62844803+datehoer@users.noreply.github.com> Date: Sat, 11 Oct 2025 10:21:18 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=99=A8=E5=AF=86=E9=92=A5=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=9A=E5=AE=9E=E7=8E=B0=E9=A6=96=E6=AC=A1=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E5=92=8C=E9=AA=8C=E8=AF=81=E6=9C=8D=E5=8A=A1=E5=99=A8=E5=AF=86?= =?UTF-8?q?=E9=92=A5=E7=9A=84=E8=B7=AF=E7=94=B1=E5=8F=8A=E4=B8=AD=E9=97=B4?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + routes.js | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++ server-auth.js | 86 +++++++++++++++++++++++++++++++++++++++++++++++ server.js | 6 +++- 4 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 server-auth.js diff --git a/.gitignore b/.gitignore index 852f2a4..02fb6b0 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules/ .env .DS_Store *.txt +server-key.json diff --git a/routes.js b/routes.js index 0818b6b..bf21df6 100644 --- a/routes.js +++ b/routes.js @@ -9,6 +9,7 @@ import { AnthropicResponseTransformer } from './transformers/response-anthropic. import { OpenAIResponseTransformer } from './transformers/response-openai.js'; import { getApiKey, recordRequestResult } from './auth.js'; import { getKeyManager } from './key-manager.js'; +import { isServerKeySet, setServerKey } from './server-auth.js'; const router = express.Router(); @@ -523,6 +524,45 @@ router.get('/status', (req, res) => { logInfo('GET /status'); try { + // First-visit setup: if no server key yet, present setup form + if (!isServerKeySet()) { + return res.send(` + + + + + droid2api Status - Setup + + + +

droid2api v2.0.0 Status

+
+
+

First-time setup: Set a server access key. After saving, all endpoints require this key via header X-Server-Key or query ?key=. The /status page remains open.

+
+
+ + +
Remember this key. It will be stored locally on the server.
+ +
+
+ + + `); + } + const keyManager = getKeyManager(); if (!keyManager) { @@ -586,6 +626,7 @@ router.get('/status', (req, res) => { color: #333; text-align: center; } + .notice { margin: 10px 0 20px; padding: 10px; background: #e8f5e9; border-left: 4px solid #4CAF50; } .section { background: white; margin: 20px 0; @@ -734,6 +775,9 @@ router.get('/status', (req, res) => {

Configuration

+
+

Access Control: Server key is set. Provide header X-Server-Key or query ?key= for all endpoints except /status.

+

Round-Robin Algorithm: ${stats.algorithm}

Remove on 402: ${stats.removeOn402 ? 'Enabled' : 'Disabled'}

Active Keys: ${stats.keys.length}

@@ -840,6 +884,53 @@ router.get('/status', (req, res) => { } }); +// First-time key setup endpoint (only works before key is set) +router.post('/status/set-key', (req, res) => { + logInfo('POST /status/set-key'); + try { + if (isServerKeySet()) { + return res.status(400).send(` + + + Key Already Set + +

Server key has already been set. Go back to /status.

+ + + `); + } + const key = (req.body?.key || '').toString(); + if (!key || key.trim() === '') { + return res.status(400).send('Key is required'); + } + setServerKey(key); + return res.send(` + + + + + Key Saved + + + +
+

Server key saved successfully.

+

Use header X-Server-Key or query ?key= with this key for all API requests (except /status).

+

Go to Status

+
+ + + `); + } catch (error) { + logError('Error in POST /status/set-key', error); + res.status(500).send('Failed to set key'); + } +}); + // 注册路由 router.post('/v1/chat/completions', handleChatCompletions); router.post('/v1/responses', handleDirectResponses); diff --git a/server-auth.js b/server-auth.js new file mode 100644 index 0000000..a114b1d --- /dev/null +++ b/server-auth.js @@ -0,0 +1,86 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { logInfo, logError } from './logger.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const KEY_FILE = path.join(__dirname, 'server-key.json'); + +let cachedKey = null; + +function loadKeyFromDisk() { + try { + if (fs.existsSync(KEY_FILE)) { + const raw = fs.readFileSync(KEY_FILE, 'utf-8'); + const data = JSON.parse(raw); + if (data && typeof data.key === 'string' && data.key.trim() !== '') { + cachedKey = data.key.trim(); + return cachedKey; + } + } + } catch (err) { + logError('Failed to read server key from disk', err); + } + return null; +} + +export function isServerKeySet() { + if (cachedKey && cachedKey.length > 0) return true; + const key = loadKeyFromDisk(); + return !!(key && key.length > 0); +} + +export function setServerKey(key) { + if (isServerKeySet()) { + throw new Error('Server key already set'); + } + if (!key || typeof key !== 'string' || key.trim() === '') { + throw new Error('Invalid key'); + } + const normalized = key.trim(); + try { + fs.writeFileSync(KEY_FILE, JSON.stringify({ key: normalized }, null, 2), 'utf-8'); + cachedKey = normalized; + logInfo('Server key has been set successfully'); + } catch (err) { + logError('Failed to write server key to disk', err); + throw err; + } +} + +export function verifyServerKey(provided) { + if (!provided || typeof provided !== 'string') return false; + const expected = cachedKey || loadKeyFromDisk(); + if (!expected) return false; + return expected === provided.trim(); +} + +export function serverAuthMiddleware(req, res, next) { + // Allow status and its subpaths without key + const path = req.path || req.originalUrl || ''; + if (path === '/status' || path.startsWith('/status/')) { + return next(); + } + + // If key is not set yet, block all other routes and instruct to visit /status + if (!isServerKeySet()) { + return res.status(503).json({ + error: 'Server key not set', + message: 'Visit /status to set the initial access key.' + }); + } + + // Accept key via header or query + const provided = req.headers['x-server-key'] || req.query.key || req.query.server_key; + if (!verifyServerKey(typeof provided === 'string' ? provided : '')) { + return res.status(401).json({ + error: 'Unauthorized', + message: 'Missing or invalid X-Server-Key (or ?key=)' + }); + } + + return next(); +} + diff --git a/server.js b/server.js index 90e84d2..c724ccd 100644 --- a/server.js +++ b/server.js @@ -2,6 +2,7 @@ import express from 'express'; import { loadConfig, isDevMode, getPort, getRoundRobin, getRemoveOn402 } from './config.js'; import { logInfo, logError } from './logger.js'; import router from './routes.js'; +import { serverAuthMiddleware } from './server-auth.js'; import { initializeAuth } from './auth.js'; const app = express(); @@ -12,7 +13,7 @@ app.use(express.urlencoded({ extended: true, limit: '50mb' })); app.use((req, res, next) => { res.header('Access-Control-Allow-Origin', '*'); 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'); + res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key, X-Server-Key, anthropic-version'); if (req.method === 'OPTIONS') { return res.sendStatus(200); @@ -20,6 +21,9 @@ app.use((req, res, next) => { next(); }); +// Enforce server-wide access key after first-time setup via /status +app.use(serverAuthMiddleware); + app.use(router); app.get('/', (req, res) => { From 2bfddc00e404d9ca1c761cf0c32df0b359974496 Mon Sep 17 00:00:00 2001 From: datehoer <62844803+datehoer@users.noreply.github.com> Date: Sat, 11 Oct 2025 10:34:52 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E6=9C=BA=E5=88=B6=EF=BC=9A=E5=B0=86=E6=9C=8D=E5=8A=A1=E5=99=A8?= =?UTF-8?q?=E5=AF=86=E9=92=A5=E7=9A=84=E6=8E=A5=E6=94=B6=E6=96=B9=E5=BC=8F?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E9=80=9A=E8=BF=87Authorization=E5=A4=B4?= =?UTF-8?q?=E9=83=A8=EF=BC=8C=E8=B0=83=E6=95=B4=E7=9B=B8=E5=85=B3=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=92=8C=E7=8A=B6=E6=80=81=E9=A1=B5=E9=9D=A2=E4=BF=A1?= =?UTF-8?q?=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- routes.js | 18 ++++++++++++------ server-auth.js | 14 ++++++++++---- server.js | 2 +- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/routes.js b/routes.js index bf21df6..977697c 100644 --- a/routes.js +++ b/routes.js @@ -104,9 +104,12 @@ async function handleChatCompletions(req, res) { logInfo(`Routing to ${model.type} endpoint: ${endpoint.base_url}`); // Get API key (will auto-refresh if needed) + // When server access key is enabled, Authorization header is reserved for server auth. + // To pass a client-provided upstream token (client mode), use X-Endpoint-Authorization. let authHeader; try { - authHeader = await getApiKey(req.headers.authorization); + const clientUpstreamAuth = req.headers['x-endpoint-authorization'] || null; + authHeader = await getApiKey(clientUpstreamAuth); } catch (error) { logError('Failed to get API key', error); return res.status(500).json({ @@ -269,7 +272,8 @@ async function handleDirectResponses(req, res) { const clientAuthFromXApiKey = req.headers['x-api-key'] ? `Bearer ${req.headers['x-api-key']}` : null; - authHeader = await getApiKey(req.headers.authorization || clientAuthFromXApiKey); + const clientUpstreamAuth = req.headers['x-endpoint-authorization'] || null; + authHeader = await getApiKey(clientUpstreamAuth || clientAuthFromXApiKey); } catch (error) { logError('Failed to get API key', error); return res.status(500).json({ @@ -408,7 +412,8 @@ async function handleDirectMessages(req, res) { const clientAuthFromXApiKey = req.headers['x-api-key'] ? `Bearer ${req.headers['x-api-key']}` : null; - authHeader = await getApiKey(req.headers.authorization || clientAuthFromXApiKey); + const clientUpstreamAuth = req.headers['x-endpoint-authorization'] || null; + authHeader = await getApiKey(clientUpstreamAuth || clientAuthFromXApiKey); } catch (error) { logError('Failed to get API key', error); return res.status(500).json({ @@ -549,7 +554,7 @@ router.get('/status', (req, res) => {

droid2api v2.0.0 Status

-

First-time setup: Set a server access key. After saving, all endpoints require this key via header X-Server-Key or query ?key=. The /status page remains open.

+

First-time setup: Set a server access key. After saving, all endpoints require this key via header Authorization: Bearer <key>. The /status page remains open.

@@ -776,12 +781,13 @@ router.get('/status', (req, res) => {

Configuration

-

Access Control: Server key is set. Provide header X-Server-Key or query ?key= for all endpoints except /status.

+

Access Control: Server key is set. Provide header Authorization: Bearer <key> for all endpoints except /status.

Round-Robin Algorithm: ${stats.algorithm}

Remove on 402: ${stats.removeOn402 ? 'Enabled' : 'Disabled'}

Active Keys: ${stats.keys.length}

Deprecated Keys: ${stats.deprecatedKeys ? stats.deprecatedKeys.length : 0}

+

Client-Auth (optional): If you rely on client-provided upstream tokens, send them via header X-Endpoint-Authorization: Bearer <upstream-token>. The Authorization header is reserved for the server access key.

@@ -919,7 +925,7 @@ router.post('/status/set-key', (req, res) => {

Server key saved successfully.

-

Use header X-Server-Key or query ?key= with this key for all API requests (except /status).

+

Use header Authorization: Bearer <key> for all API requests (except /status).

Go to Status

diff --git a/server-auth.js b/server-auth.js index a114b1d..2a39abf 100644 --- a/server-auth.js +++ b/server-auth.js @@ -72,15 +72,21 @@ export function serverAuthMiddleware(req, res, next) { }); } - // Accept key via header or query - const provided = req.headers['x-server-key'] || req.query.key || req.query.server_key; + // Accept key via Authorization: Bearer + const authHeader = req.headers['authorization']; + let provided = null; + if (typeof authHeader === 'string') { + const parts = authHeader.split(' '); + if (parts.length === 2 && /^Bearer$/i.test(parts[0])) { + provided = parts[1]; + } + } if (!verifyServerKey(typeof provided === 'string' ? provided : '')) { return res.status(401).json({ error: 'Unauthorized', - message: 'Missing or invalid X-Server-Key (or ?key=)' + message: 'Missing or invalid Authorization: Bearer ' }); } return next(); } - diff --git a/server.js b/server.js index c724ccd..ed0516d 100644 --- a/server.js +++ b/server.js @@ -13,7 +13,7 @@ app.use(express.urlencoded({ extended: true, limit: '50mb' })); app.use((req, res, next) => { res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); - res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key, X-Server-Key, anthropic-version'); + res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key, anthropic-version, X-Endpoint-Authorization'); if (req.method === 'OPTIONS') { return res.sendStatus(200); From d584276739af9db9535c96788ff522c87e6ea590 Mon Sep 17 00:00:00 2001 From: datehoer <62844803+datehoer@users.noreply.github.com> Date: Sat, 11 Oct 2025 18:35:30 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=BD=99=E9=A2=9D?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=EF=BC=9A=E6=9B=B4=E6=96=B0?= =?UTF-8?q?KeyManager=E4=BB=A5=E6=94=AF=E6=8C=81=E4=BD=99=E9=A2=9D?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E5=92=8C=E7=94=A8=E5=B0=BD=E7=8A=B6=E6=80=81?= =?UTF-8?q?=EF=BC=8C=E5=A2=9E=E5=BC=BA=E7=8A=B6=E6=80=81=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E4=BB=A5=E6=98=BE=E7=A4=BA=E4=BD=99=E9=A2=9D=E5=92=8C=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E6=83=85=E5=86=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- key-manager.js | 53 +++++++- routes.js | 351 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 392 insertions(+), 12 deletions(-) diff --git a/key-manager.js b/key-manager.js index ae552ab..9031527 100644 --- a/key-manager.js +++ b/key-manager.js @@ -10,7 +10,9 @@ class KeyManager { key: key, success: 0, fail: 0, - deprecated: false // 是否被废弃 + deprecated: false, // 是否被废弃 + depleted: false, // 是否余额用尽 + balance: null // 最近一次余额信息 { totalAllowance, used, remaining, fetchedAt } })); this.algorithm = algorithm; // 'weighted' or 'simple' @@ -18,6 +20,7 @@ class KeyManager { this.endpointStats = {}; // 端点统计 { endpoint: { success: 0, fail: 0 } } this.removeOn402 = removeOn402; // 是否在402时移除key this.deprecatedKeys = []; // 已废弃的key列表 + this.skipThreshold = 0; // 剩余≤阈值则视为用尽 logInfo(`KeyManager initialized with ${this.keys.length} keys, algorithm: ${this.algorithm}, removeOn402: ${this.removeOn402}`); } @@ -27,7 +30,7 @@ class KeyManager { */ selectKey() { // 获取未废弃的key - const activeKeys = this.keys.filter(k => !k.deprecated); + const activeKeys = this.keys.filter(k => !k.deprecated && !k.depleted); if (activeKeys.length === 0) { throw new Error('No active keys available - all keys have been deprecated'); @@ -126,6 +129,38 @@ class KeyManager { logDebug(`Endpoint stats updated: ${endpoint}, success=${this.endpointStats[endpoint].success}, fail=${this.endpointStats[endpoint].fail}`); } + /** + * 更新余额与用尽状态(按索引) + * @param {number} index + * @param {object} balanceInfo { totalAllowance, used, remaining, fetchedAt } + */ + setBalanceByIndex(index, balanceInfo) { + if (typeof index !== 'number' || index < 0 || index >= this.keys.length) return; + const k = this.keys[index]; + if (!k) return; + k.balance = balanceInfo || null; + if (balanceInfo && typeof balanceInfo.remaining === 'number') { + k.depleted = balanceInfo.remaining <= this.skipThreshold; + } + } + + /** + * 设置跳过阈值(剩余≤n 视为用尽)并重算状态 + */ + setSkipThreshold(n) { + const val = Number(n); + this.skipThreshold = Number.isFinite(val) && val >= 0 ? val : 0; + for (const k of this.keys) { + if (k.balance && typeof k.balance.remaining === 'number') { + k.depleted = k.balance.remaining <= this.skipThreshold; + } + } + } + + getSkipThreshold() { + return this.skipThreshold; + } + /** * 废弃一个key * @param {string} key - 要废弃的key @@ -167,9 +202,10 @@ class KeyManager { * 获取统计信息 */ getStats() { - // 分离活跃的key和废弃的key - const activeKeys = this.keys.filter(k => !k.deprecated); - const deprecatedKeys = this.keys.filter(k => k.deprecated); + // 分离活跃的key和废弃的key(保留原始索引以便在UI中定位) + const indexedKeys = this.keys.map((k, idx) => ({ ...k, __index: idx })); + const activeKeys = indexedKeys.filter(k => !k.deprecated); + const deprecatedKeys = indexedKeys.filter(k => k.deprecated); return { algorithm: this.algorithm, @@ -181,7 +217,9 @@ class KeyManager { total: keyObj.success + keyObj.fail, successRate: keyObj.success + keyObj.fail > 0 ? ((keyObj.success / (keyObj.success + keyObj.fail)) * 100).toFixed(2) + '%' - : 'N/A' + : 'N/A', + index: keyObj.__index, + depleted: !!keyObj.depleted })), deprecatedKeys: deprecatedKeys.map(keyObj => ({ key: this.maskKey(keyObj.key), @@ -191,7 +229,8 @@ class KeyManager { successRate: keyObj.success + keyObj.fail > 0 ? ((keyObj.success / (keyObj.success + keyObj.fail)) * 100).toFixed(2) + '%' : 'N/A', - deprecatedAt: this.deprecatedKeys.find(dk => dk.key === keyObj.key)?.deprecatedAt || 'Unknown' + deprecatedAt: this.deprecatedKeys.find(dk => dk.key === keyObj.key)?.deprecatedAt || 'Unknown', + index: keyObj.__index })), endpoints: Object.entries(this.endpointStats) .filter(([_, stats]) => stats.success > 0 || stats.fail > 0) diff --git a/routes.js b/routes.js index 977697c..2fab60e 100644 --- a/routes.js +++ b/routes.js @@ -581,7 +581,7 @@ router.get('/status', (req, res) => {