+
+
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.
@@ -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) => {