feat: 增强设置功能,支持当前识别的管理 IP 和白名单更新逻辑
This commit is contained in:
@@ -73,6 +73,21 @@ describe('settings and auth events', () => {
|
|||||||
expect(events[0].message || '').toContain('签到 Cron');
|
expect(events[0].message || '').toContain('签到 Cron');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns current recognized admin IP in runtime settings response', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/settings/runtime',
|
||||||
|
remoteAddress: '10.0.0.8',
|
||||||
|
headers: {
|
||||||
|
'x-forwarded-for': '203.0.113.5, 10.0.0.8',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json() as { currentAdminIp?: string };
|
||||||
|
expect(body.currentAdminIp).toBe('203.0.113.5');
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects proxy token that does not start with sk-', async () => {
|
it('rejects proxy token that does not start with sk-', async () => {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -114,6 +129,43 @@ describe('settings and auth events', () => {
|
|||||||
expect(runtime.routingFallbackUnitCost).toBe(0.25);
|
expect(runtime.routingFallbackUnitCost).toBe(0.25);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects allowlist update that does not include current request IP', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/settings/runtime',
|
||||||
|
remoteAddress: '198.51.100.10',
|
||||||
|
payload: {
|
||||||
|
adminIpAllowlist: ['198.51.100.11'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
const body = response.json() as { message?: string };
|
||||||
|
expect(body.message).toContain('白名单');
|
||||||
|
expect(body.message).toContain('198.51.100.10');
|
||||||
|
|
||||||
|
const saved = db.select().from(schema.settings).where(eq(schema.settings.key, 'admin_ip_allowlist')).get();
|
||||||
|
expect(saved).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows allowlist update when current request IP is included', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/settings/runtime',
|
||||||
|
remoteAddress: '198.51.100.10',
|
||||||
|
payload: {
|
||||||
|
adminIpAllowlist: ['198.51.100.10', '198.51.100.11'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json() as { adminIpAllowlist?: string[] };
|
||||||
|
expect(body.adminIpAllowlist).toEqual(['198.51.100.10', '198.51.100.11']);
|
||||||
|
|
||||||
|
const saved = db.select().from(schema.settings).where(eq(schema.settings.key, 'admin_ip_allowlist')).get();
|
||||||
|
expect(saved?.value).toBe(JSON.stringify(['198.51.100.10', '198.51.100.11']));
|
||||||
|
});
|
||||||
|
|
||||||
it('appends event when admin auth token changes', async () => {
|
it('appends event when admin auth token changes', async () => {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { updateBalanceRefreshCron, updateCheckinCron } from '../../services/chec
|
|||||||
import { sendNotification } from '../../services/notifyService.js';
|
import { sendNotification } from '../../services/notifyService.js';
|
||||||
import { exportBackup, importBackup, type BackupExportType } from '../../services/backupService.js';
|
import { exportBackup, importBackup, type BackupExportType } from '../../services/backupService.js';
|
||||||
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
|
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
|
||||||
|
import { extractClientIp, isIpAllowed } from '../../middleware/auth.js';
|
||||||
|
|
||||||
type RoutingWeights = typeof config.routingWeights;
|
type RoutingWeights = typeof config.routingWeights;
|
||||||
|
|
||||||
@@ -215,7 +216,7 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRuntimeSettingsResponse() {
|
function getRuntimeSettingsResponse(currentAdminIp = '') {
|
||||||
return {
|
return {
|
||||||
checkinCron: config.checkinCron,
|
checkinCron: config.checkinCron,
|
||||||
balanceRefreshCron: config.balanceRefreshCron,
|
balanceRefreshCron: config.balanceRefreshCron,
|
||||||
@@ -237,18 +238,21 @@ function getRuntimeSettingsResponse() {
|
|||||||
smtpTo: config.smtpTo,
|
smtpTo: config.smtpTo,
|
||||||
notifyCooldownSec: config.notifyCooldownSec,
|
notifyCooldownSec: config.notifyCooldownSec,
|
||||||
adminIpAllowlist: config.adminIpAllowlist,
|
adminIpAllowlist: config.adminIpAllowlist,
|
||||||
|
currentAdminIp,
|
||||||
proxyTokenMasked: maskSecret(config.proxyToken),
|
proxyTokenMasked: maskSecret(config.proxyToken),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function settingsRoutes(app: FastifyInstance) {
|
export async function settingsRoutes(app: FastifyInstance) {
|
||||||
app.get('/api/settings/runtime', async () => {
|
app.get('/api/settings/runtime', async (request) => {
|
||||||
return getRuntimeSettingsResponse();
|
const currentAdminIp = extractClientIp(request.ip, request.headers['x-forwarded-for']);
|
||||||
|
return getRuntimeSettingsResponse(currentAdminIp);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put<{ Body: RuntimeSettingsBody }>('/api/settings/runtime', async (request, reply) => {
|
app.put<{ Body: RuntimeSettingsBody }>('/api/settings/runtime', async (request, reply) => {
|
||||||
const body = request.body || {};
|
const body = request.body || {};
|
||||||
const changedLabels: string[] = [];
|
const changedLabels: string[] = [];
|
||||||
|
const currentRequestIp = extractClientIp(request.ip, request.headers['x-forwarded-for']);
|
||||||
|
|
||||||
if (body.checkinCron !== undefined) {
|
if (body.checkinCron !== undefined) {
|
||||||
if (!cron.validate(body.checkinCron)) {
|
if (!cron.validate(body.checkinCron)) {
|
||||||
@@ -418,6 +422,12 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
if (body.adminIpAllowlist !== undefined) {
|
if (body.adminIpAllowlist !== undefined) {
|
||||||
const nextAllowlist = toStringList(body.adminIpAllowlist);
|
const nextAllowlist = toStringList(body.adminIpAllowlist);
|
||||||
|
if (nextAllowlist.length > 0 && !isIpAllowed(currentRequestIp, nextAllowlist)) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
success: false,
|
||||||
|
message: `保存失败:当前请求 IP(${currentRequestIp || 'unknown'})不在新白名单中。请至少保留当前 IP,避免把自己锁出后台。`,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (JSON.stringify(nextAllowlist) !== JSON.stringify(config.adminIpAllowlist)) {
|
if (JSON.stringify(nextAllowlist) !== JSON.stringify(config.adminIpAllowlist)) {
|
||||||
changedLabels.push('管理端 IP 白名单');
|
changedLabels.push('管理端 IP 白名单');
|
||||||
}
|
}
|
||||||
@@ -470,7 +480,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: '运行时设置已更新',
|
message: '运行时设置已更新',
|
||||||
...getRuntimeSettingsResponse(),
|
...getRuntimeSettingsResponse(currentRequestIp),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+16
-2
@@ -6,6 +6,7 @@ import NotificationPanel from './components/NotificationPanel.js';
|
|||||||
import { api } from './api.js';
|
import { api } from './api.js';
|
||||||
import { clearAuthSession, hasValidAuthSession, persistAuthSession } from './authSession.js';
|
import { clearAuthSession, hasValidAuthSession, persistAuthSession } from './authSession.js';
|
||||||
import { I18nProvider, useI18n } from './i18n.js';
|
import { I18nProvider, useI18n } from './i18n.js';
|
||||||
|
import { resolveLoginErrorMessage } from './loginError.js';
|
||||||
const Dashboard = lazy(() => import('./pages/Dashboard.js'));
|
const Dashboard = lazy(() => import('./pages/Dashboard.js'));
|
||||||
const Sites = lazy(() => import('./pages/Sites.js'));
|
const Sites = lazy(() => import('./pages/Sites.js'));
|
||||||
const Accounts = lazy(() => import('./pages/Accounts.js'));
|
const Accounts = lazy(() => import('./pages/Accounts.js'));
|
||||||
@@ -131,7 +132,21 @@ function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (text: str
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
onLogin(token);
|
onLogin(token);
|
||||||
} else {
|
} else {
|
||||||
setError(t('登录令牌无效'));
|
let reason = '';
|
||||||
|
try {
|
||||||
|
const text = await res.text();
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text) as { message?: unknown; error?: unknown };
|
||||||
|
if (typeof parsed.message === 'string') reason = parsed.message;
|
||||||
|
else if (typeof parsed.error === 'string') reason = parsed.error;
|
||||||
|
else reason = text;
|
||||||
|
} catch {
|
||||||
|
reason = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
setError(t(resolveLoginErrorMessage(res.status, reason)));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -709,4 +724,3 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ const LANGUAGE_STORAGE_KEY = 'app_language';
|
|||||||
const zhToEn: Record<string, string> = {
|
const zhToEn: Record<string, string> = {
|
||||||
'管理员': 'Admin',
|
'管理员': 'Admin',
|
||||||
'登录令牌无效': 'Invalid admin token',
|
'登录令牌无效': 'Invalid admin token',
|
||||||
|
'当前 IP 不在管理白名单中': 'Current IP is not in admin allowlist',
|
||||||
|
'当前识别到的管理端 IP(由服务端判定):': 'Current recognized admin IP (server-side detected):',
|
||||||
'无法连接到服务器': 'Unable to connect to server',
|
'无法连接到服务器': 'Unable to connect to server',
|
||||||
'请输入管理员令牌后继续。': 'Enter admin token to continue.',
|
'请输入管理员令牌后继续。': 'Enter admin token to continue.',
|
||||||
'管理员令牌': 'Admin Token',
|
'管理员令牌': 'Admin Token',
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { resolveLoginErrorMessage } from './loginError.js';
|
||||||
|
|
||||||
|
describe('resolveLoginErrorMessage', () => {
|
||||||
|
it('returns IP allowlist hint when backend rejects by IP', () => {
|
||||||
|
expect(resolveLoginErrorMessage(403, 'IP not allowed')).toBe('当前 IP 不在管理白名单中');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns invalid token message for auth failures', () => {
|
||||||
|
expect(resolveLoginErrorMessage(403, 'Invalid token')).toBe('登录令牌无效');
|
||||||
|
expect(resolveLoginErrorMessage(401, 'Missing Authorization header')).toBe('登录令牌无效');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
function normalizeReason(reason: string): string {
|
||||||
|
return (reason || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveLoginErrorMessage(status: number, reason: string): string {
|
||||||
|
const normalizedReason = normalizeReason(reason);
|
||||||
|
if (status === 403 && normalizedReason.includes('ip not allowed')) {
|
||||||
|
return '当前 IP 不在管理白名单中';
|
||||||
|
}
|
||||||
|
return '登录令牌无效';
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ type RuntimeSettings = {
|
|||||||
routingWeights: RoutingWeights;
|
routingWeights: RoutingWeights;
|
||||||
proxyTokenMasked?: string;
|
proxyTokenMasked?: string;
|
||||||
adminIpAllowlist?: string[];
|
adminIpAllowlist?: string[];
|
||||||
|
currentAdminIp?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultWeights: RoutingWeights = {
|
const defaultWeights: RoutingWeights = {
|
||||||
@@ -88,6 +89,7 @@ export default function Settings() {
|
|||||||
adminIpAllowlist: Array.isArray(runtimeInfo.adminIpAllowlist)
|
adminIpAllowlist: Array.isArray(runtimeInfo.adminIpAllowlist)
|
||||||
? runtimeInfo.adminIpAllowlist.filter((item: unknown) => typeof item === 'string')
|
? runtimeInfo.adminIpAllowlist.filter((item: unknown) => typeof item === 'string')
|
||||||
: [],
|
: [],
|
||||||
|
currentAdminIp: typeof runtimeInfo.currentAdminIp === 'string' ? runtimeInfo.currentAdminIp : '',
|
||||||
});
|
});
|
||||||
setAdminIpAllowlistText(
|
setAdminIpAllowlistText(
|
||||||
Array.isArray(runtimeInfo.adminIpAllowlist)
|
Array.isArray(runtimeInfo.adminIpAllowlist)
|
||||||
@@ -176,10 +178,16 @@ export default function Settings() {
|
|||||||
.split(/\r?\n|,/g)
|
.split(/\r?\n|,/g)
|
||||||
.map((item) => item.trim())
|
.map((item) => item.trim())
|
||||||
.filter((item) => item.length > 0);
|
.filter((item) => item.length > 0);
|
||||||
await api.updateRuntimeSettings({
|
const res = await api.updateRuntimeSettings({
|
||||||
adminIpAllowlist: allowlist,
|
adminIpAllowlist: allowlist,
|
||||||
});
|
});
|
||||||
setRuntime((prev) => ({ ...prev, adminIpAllowlist: allowlist }));
|
setRuntime((prev) => ({
|
||||||
|
...prev,
|
||||||
|
adminIpAllowlist: allowlist,
|
||||||
|
currentAdminIp: typeof res?.currentAdminIp === 'string'
|
||||||
|
? res.currentAdminIp
|
||||||
|
: prev.currentAdminIp,
|
||||||
|
}));
|
||||||
toast.success('安全设置已保存');
|
toast.success('安全设置已保存');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast.error(err?.message || '保存失败');
|
toast.error(err?.message || '保存失败');
|
||||||
@@ -446,6 +454,12 @@ export default function Settings() {
|
|||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
||||||
登录会话默认 12 小时自动过期。可选配置管理端 IP 白名单(每行一个 IP)。
|
登录会话默认 12 小时自动过期。可选配置管理端 IP 白名单(每行一个 IP)。
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
|
||||||
|
{tr('当前识别到的管理端 IP(由服务端判定):')}
|
||||||
|
</div>
|
||||||
|
<code style={{ display: 'block', padding: '10px 14px', background: 'var(--color-bg)', borderRadius: 'var(--radius-sm)', fontSize: 13, fontFamily: 'var(--font-mono)', color: 'var(--color-text-secondary)', border: '1px solid var(--color-border-light)', marginBottom: 10 }}>
|
||||||
|
{runtime.currentAdminIp || tr('未知')}
|
||||||
|
</code>
|
||||||
<textarea
|
<textarea
|
||||||
value={adminIpAllowlistText}
|
value={adminIpAllowlistText}
|
||||||
onChange={(e) => setAdminIpAllowlistText(e.target.value)}
|
onChange={(e) => setAdminIpAllowlistText(e.target.value)}
|
||||||
|
|||||||
@@ -84,6 +84,16 @@ describe('modelTesterSession', () => {
|
|||||||
expect(restored?.parameterEnabled).toEqual(DEFAULT_PARAMETER_ENABLED);
|
expect(restored?.parameterEnabled).toEqual(DEFAULT_PARAMETER_ENABLED);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('defaults top_p to disabled when parameter switches are missing', () => {
|
||||||
|
const restored = parseModelTesterSession(JSON.stringify({
|
||||||
|
model: 'gpt-4o',
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
pendingPayload: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(restored?.parameterEnabled.top_p).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns null for malformed or missing session payload', () => {
|
it('returns null for malformed or missing session payload', () => {
|
||||||
expect(parseModelTesterSession(null)).toBeNull();
|
expect(parseModelTesterSession(null)).toBeNull();
|
||||||
expect(parseModelTesterSession('not-json')).toBeNull();
|
expect(parseModelTesterSession('not-json')).toBeNull();
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export const DEFAULT_INPUTS: ModelTesterInputs = {
|
|||||||
|
|
||||||
export const DEFAULT_PARAMETER_ENABLED: ParameterEnabled = {
|
export const DEFAULT_PARAMETER_ENABLED: ParameterEnabled = {
|
||||||
temperature: true,
|
temperature: true,
|
||||||
top_p: true,
|
top_p: false,
|
||||||
max_tokens: false,
|
max_tokens: false,
|
||||||
frequency_penalty: true,
|
frequency_penalty: true,
|
||||||
presence_penalty: true,
|
presence_penalty: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user