From e41b6b799382a6ee3335d029f83d3fe11a2d1506 Mon Sep 17 00:00:00 2001 From: cita Date: Sun, 1 Mar 2026 15:39:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81=E5=BD=93=E5=89=8D?= =?UTF-8?q?=E8=AF=86=E5=88=AB=E7=9A=84=E7=AE=A1=E7=90=86=20IP=20=E5=92=8C?= =?UTF-8?q?=E7=99=BD=E5=90=8D=E5=8D=95=E6=9B=B4=E6=96=B0=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server/routes/api/settings.events.test.ts | 52 +++++++++++++++++++ src/server/routes/api/settings.ts | 18 +++++-- src/web/App.tsx | 18 ++++++- src/web/i18n.tsx | 2 + src/web/loginError.test.ts | 13 +++++ src/web/loginError.ts | 11 ++++ src/web/pages/Settings.tsx | 18 ++++++- .../pages/helpers/modelTesterSession.test.ts | 10 ++++ src/web/pages/helpers/modelTesterSession.ts | 2 +- 9 files changed, 135 insertions(+), 9 deletions(-) create mode 100644 src/web/loginError.test.ts create mode 100644 src/web/loginError.ts diff --git a/src/server/routes/api/settings.events.test.ts b/src/server/routes/api/settings.events.test.ts index e6be0da..8320950 100644 --- a/src/server/routes/api/settings.events.test.ts +++ b/src/server/routes/api/settings.events.test.ts @@ -73,6 +73,21 @@ describe('settings and auth events', () => { 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 () => { const response = await app.inject({ method: 'PUT', @@ -114,6 +129,43 @@ describe('settings and auth events', () => { 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 () => { const response = await app.inject({ method: 'POST', diff --git a/src/server/routes/api/settings.ts b/src/server/routes/api/settings.ts index 9ab8823..0d7968a 100644 --- a/src/server/routes/api/settings.ts +++ b/src/server/routes/api/settings.ts @@ -7,6 +7,7 @@ import { updateBalanceRefreshCron, updateCheckinCron } from '../../services/chec import { sendNotification } from '../../services/notifyService.js'; import { exportBackup, importBackup, type BackupExportType } from '../../services/backupService.js'; import { startBackgroundTask } from '../../services/backgroundTaskService.js'; +import { extractClientIp, isIpAllowed } from '../../middleware/auth.js'; type RoutingWeights = typeof config.routingWeights; @@ -215,7 +216,7 @@ function applyImportedSettingToRuntime(key: string, value: unknown) { } } -function getRuntimeSettingsResponse() { +function getRuntimeSettingsResponse(currentAdminIp = '') { return { checkinCron: config.checkinCron, balanceRefreshCron: config.balanceRefreshCron, @@ -237,18 +238,21 @@ function getRuntimeSettingsResponse() { smtpTo: config.smtpTo, notifyCooldownSec: config.notifyCooldownSec, adminIpAllowlist: config.adminIpAllowlist, + currentAdminIp, proxyTokenMasked: maskSecret(config.proxyToken), }; } export async function settingsRoutes(app: FastifyInstance) { - app.get('/api/settings/runtime', async () => { - return getRuntimeSettingsResponse(); + app.get('/api/settings/runtime', async (request) => { + const currentAdminIp = extractClientIp(request.ip, request.headers['x-forwarded-for']); + return getRuntimeSettingsResponse(currentAdminIp); }); app.put<{ Body: RuntimeSettingsBody }>('/api/settings/runtime', async (request, reply) => { const body = request.body || {}; const changedLabels: string[] = []; + const currentRequestIp = extractClientIp(request.ip, request.headers['x-forwarded-for']); if (body.checkinCron !== undefined) { if (!cron.validate(body.checkinCron)) { @@ -418,6 +422,12 @@ export async function settingsRoutes(app: FastifyInstance) { if (body.adminIpAllowlist !== undefined) { 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)) { changedLabels.push('管理端 IP 白名单'); } @@ -470,7 +480,7 @@ export async function settingsRoutes(app: FastifyInstance) { return { success: true, message: '运行时设置已更新', - ...getRuntimeSettingsResponse(), + ...getRuntimeSettingsResponse(currentRequestIp), }; }); diff --git a/src/web/App.tsx b/src/web/App.tsx index bdf29e4..f75859e 100644 --- a/src/web/App.tsx +++ b/src/web/App.tsx @@ -6,6 +6,7 @@ import NotificationPanel from './components/NotificationPanel.js'; import { api } from './api.js'; import { clearAuthSession, hasValidAuthSession, persistAuthSession } from './authSession.js'; import { I18nProvider, useI18n } from './i18n.js'; +import { resolveLoginErrorMessage } from './loginError.js'; const Dashboard = lazy(() => import('./pages/Dashboard.js')); const Sites = lazy(() => import('./pages/Sites.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) { onLogin(token); } 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); } } catch { @@ -709,4 +724,3 @@ export default function App() { ); } - diff --git a/src/web/i18n.tsx b/src/web/i18n.tsx index 58b3a9b..6de8a65 100644 --- a/src/web/i18n.tsx +++ b/src/web/i18n.tsx @@ -8,6 +8,8 @@ const LANGUAGE_STORAGE_KEY = 'app_language'; const zhToEn: Record = { '管理员': 'Admin', '登录令牌无效': 'Invalid admin token', + '当前 IP 不在管理白名单中': 'Current IP is not in admin allowlist', + '当前识别到的管理端 IP(由服务端判定):': 'Current recognized admin IP (server-side detected):', '无法连接到服务器': 'Unable to connect to server', '请输入管理员令牌后继续。': 'Enter admin token to continue.', '管理员令牌': 'Admin Token', diff --git a/src/web/loginError.test.ts b/src/web/loginError.test.ts new file mode 100644 index 0000000..39629fa --- /dev/null +++ b/src/web/loginError.test.ts @@ -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('登录令牌无效'); + }); +}); diff --git a/src/web/loginError.ts b/src/web/loginError.ts new file mode 100644 index 0000000..32025c5 --- /dev/null +++ b/src/web/loginError.ts @@ -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 '登录令牌无效'; +} diff --git a/src/web/pages/Settings.tsx b/src/web/pages/Settings.tsx index 7c26bcd..caa6301 100644 --- a/src/web/pages/Settings.tsx +++ b/src/web/pages/Settings.tsx @@ -19,6 +19,7 @@ type RuntimeSettings = { routingWeights: RoutingWeights; proxyTokenMasked?: string; adminIpAllowlist?: string[]; + currentAdminIp?: string; }; const defaultWeights: RoutingWeights = { @@ -88,6 +89,7 @@ export default function Settings() { adminIpAllowlist: Array.isArray(runtimeInfo.adminIpAllowlist) ? runtimeInfo.adminIpAllowlist.filter((item: unknown) => typeof item === 'string') : [], + currentAdminIp: typeof runtimeInfo.currentAdminIp === 'string' ? runtimeInfo.currentAdminIp : '', }); setAdminIpAllowlistText( Array.isArray(runtimeInfo.adminIpAllowlist) @@ -176,10 +178,16 @@ export default function Settings() { .split(/\r?\n|,/g) .map((item) => item.trim()) .filter((item) => item.length > 0); - await api.updateRuntimeSettings({ + const res = await api.updateRuntimeSettings({ adminIpAllowlist: allowlist, }); - setRuntime((prev) => ({ ...prev, adminIpAllowlist: allowlist })); + setRuntime((prev) => ({ + ...prev, + adminIpAllowlist: allowlist, + currentAdminIp: typeof res?.currentAdminIp === 'string' + ? res.currentAdminIp + : prev.currentAdminIp, + })); toast.success('安全设置已保存'); } catch (err: any) { toast.error(err?.message || '保存失败'); @@ -446,6 +454,12 @@ export default function Settings() {
登录会话默认 12 小时自动过期。可选配置管理端 IP 白名单(每行一个 IP)。
+
+ {tr('当前识别到的管理端 IP(由服务端判定):')} +
+ + {runtime.currentAdminIp || tr('未知')} +