fix(api): закрыть SSRF в rates-proxy, проверять роль в space, убрать гонки синка
- rates-proxy запрашивает только сохранённый settings.ratesUrl - viewer в space не может писать в платежи, баланс, провайдеров, аккаунты, проекты и запускать синк - /api/sync/status показывает журнал только текущего space - повторный синк аккаунта во время текущего отвечает 409 - тики планировщика не накладываются, каждый space идёт по своему интервалу Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
78f413324a
commit
273883f657
7 files changed
+307
-22
No files matched your search
@@ -8,13 +8,37 @@ export interface RunAccountSyncResult extends SyncResult {
|
||||
logId: string
|
||||
}
|
||||
|
||||
/** Manual and scheduled syncs of one account must not interleave (duplicate inserts, tariff wipe). */
|
||||
const inFlightAccounts = new Set<string>()
|
||||
|
||||
export class SyncInProgressError extends Error {
|
||||
constructor() {
|
||||
super('Синхронизация этого аккаунта уже выполняется')
|
||||
}
|
||||
}
|
||||
|
||||
export async function runAccountSync(
|
||||
adapter: ProviderAdapter,
|
||||
account: unknown,
|
||||
opts: { skipTariffs?: boolean; skipVpsPayments?: boolean } = {},
|
||||
): Promise<RunAccountSyncResult> {
|
||||
const db = getDb()
|
||||
const accountRow = account as { id: string; spaceId?: string }
|
||||
if (inFlightAccounts.has(accountRow.id)) throw new SyncInProgressError()
|
||||
inFlightAccounts.add(accountRow.id)
|
||||
try {
|
||||
return await runAccountSyncLocked(adapter, account, accountRow, opts)
|
||||
} finally {
|
||||
inFlightAccounts.delete(accountRow.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function runAccountSyncLocked(
|
||||
adapter: ProviderAdapter,
|
||||
account: unknown,
|
||||
accountRow: { id: string; spaceId?: string },
|
||||
opts: { skipTariffs?: boolean; skipVpsPayments?: boolean },
|
||||
): Promise<RunAccountSyncResult> {
|
||||
const db = getDb()
|
||||
const logId = `sync-${accountRow.id}-${Date.now()}`
|
||||
const spaceId = accountRow.spaceId ?? getCurrentSpaceId()
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { closeDb } from '@cfdm/db'
|
||||
import { resetTestDb } from '@cfdm/db/test-setup'
|
||||
|
||||
vi.mock('./uptime-check.js', () => ({ runVpsUptimeChecks: vi.fn() }))
|
||||
|
||||
import { runVpsUptimeChecks } from './uptime-check.js'
|
||||
import { resetSchedulerState, runScheduledUptimeChecks } from './scheduler.js'
|
||||
|
||||
const uptimeMock = vi.mocked(runVpsUptimeChecks)
|
||||
|
||||
describe('scheduler guards', () => {
|
||||
beforeEach(() => {
|
||||
resetTestDb()
|
||||
resetSchedulerState()
|
||||
uptimeMock.mockReset()
|
||||
})
|
||||
afterEach(() => closeDb())
|
||||
|
||||
it('skips a tick while the previous one runs and respects the space interval', async () => {
|
||||
let finish!: () => void
|
||||
uptimeMock.mockImplementation(
|
||||
() => new Promise((resolve) => (finish = () => resolve({ checked: 0, down: 0, newlyDown: [], newlyUp: [] }))),
|
||||
)
|
||||
const first = runScheduledUptimeChecks()
|
||||
await runScheduledUptimeChecks() // overlapping tick → skipped
|
||||
await vi.waitFor(() => expect(finish).toBeTypeOf('function'))
|
||||
finish()
|
||||
await first
|
||||
expect(uptimeMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
uptimeMock.mockResolvedValue({ checked: 0, down: 0, newlyDown: [], newlyUp: [] })
|
||||
await runScheduledUptimeChecks() // space interval (5 min) not elapsed
|
||||
expect(uptimeMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
resetSchedulerState()
|
||||
await runScheduledUptimeChecks()
|
||||
expect(uptimeMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -63,9 +63,57 @@ function allSettings(): SettingsRow[] {
|
||||
return settingsRepository.listAllSpaces()
|
||||
}
|
||||
|
||||
export async function runNotificationTick(): Promise<void> {
|
||||
function spaceIntervals(s: SettingsRow) {
|
||||
return {
|
||||
notify: Math.max(15, Number(s.notifyIntervalMinutes) || 60),
|
||||
uptime: Math.max(1, Number(s.uptimeCheckIntervalMinutes) || 5),
|
||||
sync: Math.max(15, Number(s.syncIntervalMinutes) || 60),
|
||||
tariffs: Math.max(60, Number(s.syncTariffsIntervalMinutes) || 1440),
|
||||
}
|
||||
}
|
||||
|
||||
const runningJobs = new Set<string>()
|
||||
|
||||
/** A tick that finds the previous run of the same job still going is skipped, not stacked. */
|
||||
async function exclusive(job: string, fn: () => Promise<void>): Promise<void> {
|
||||
if (runningJobs.has(job)) {
|
||||
console.warn(`Scheduler: ${job} ещё выполняется — тик пропущен`)
|
||||
return
|
||||
}
|
||||
runningJobs.add(job)
|
||||
try {
|
||||
await fn()
|
||||
} finally {
|
||||
runningJobs.delete(job)
|
||||
}
|
||||
}
|
||||
|
||||
const lastRunAt = new Map<string, number>()
|
||||
|
||||
/** Timers tick at the smallest interval of all spaces; each space still runs at its own. */
|
||||
function dueForSpace(job: string, spaceId: string, minutes: number): boolean {
|
||||
const key = `${job}:${spaceId}`
|
||||
const now = Date.now()
|
||||
const last = lastRunAt.get(key)
|
||||
if (last != null && now - last < minutes * 60_000 - 30_000) return false
|
||||
lastRunAt.set(key, now)
|
||||
return true
|
||||
}
|
||||
|
||||
export function resetSchedulerState(): void {
|
||||
runningJobs.clear()
|
||||
lastRunAt.clear()
|
||||
}
|
||||
|
||||
export const runNotificationTick = () => exclusive('notify', notificationTick)
|
||||
export const runScheduledSync = () => exclusive('sync', scheduledSync)
|
||||
export const runScheduledSyncTariffs = () => exclusive('tariffs', scheduledSyncTariffs)
|
||||
export const runScheduledUptimeChecks = () => exclusive('uptime', scheduledUptimeChecks)
|
||||
|
||||
async function notificationTick(): Promise<void> {
|
||||
for (const settings of allSettings()) {
|
||||
const spaceId = settings.spaceId || MAIN_SPACE_ID
|
||||
if (!dueForSpace('notify', spaceId, spaceIntervals(settings).notify)) continue
|
||||
try {
|
||||
await runWithSpaceAsync(spaceId, async () => {
|
||||
const payload = buildPaymentExpiryNotification()
|
||||
@@ -80,10 +128,11 @@ export async function runNotificationTick(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function runScheduledSync(): Promise<void> {
|
||||
async function scheduledSync(): Promise<void> {
|
||||
for (const settings of allSettings()) {
|
||||
if (!settings.syncEnabled) continue
|
||||
const spaceId = settings.spaceId || MAIN_SPACE_ID
|
||||
if (!dueForSpace('sync', spaceId, spaceIntervals(settings).sync)) continue
|
||||
try {
|
||||
await runWithSpaceAsync(spaceId, async () => {
|
||||
const entries = getSyncableAccounts(spaceId)
|
||||
@@ -134,10 +183,11 @@ export async function runScheduledSync(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function runScheduledSyncTariffs(): Promise<void> {
|
||||
async function scheduledSyncTariffs(): Promise<void> {
|
||||
for (const settings of allSettings()) {
|
||||
if (!settings.syncEnabled) continue
|
||||
const spaceId = settings.spaceId || MAIN_SPACE_ID
|
||||
if (!dueForSpace('tariffs', spaceId, spaceIntervals(settings).tariffs)) continue
|
||||
try {
|
||||
await runWithSpaceAsync(spaceId, async () => {
|
||||
const entries = getSyncableAccounts(spaceId)
|
||||
@@ -178,9 +228,10 @@ export async function runScheduledSyncTariffs(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function runScheduledUptimeChecks(): Promise<void> {
|
||||
async function scheduledUptimeChecks(): Promise<void> {
|
||||
for (const settings of allSettings()) {
|
||||
const spaceId = settings.spaceId || MAIN_SPACE_ID
|
||||
if (!dueForSpace('uptime', spaceId, spaceIntervals(settings).uptime)) continue
|
||||
try {
|
||||
await runWithSpaceAsync(spaceId, async () => {
|
||||
const { newlyDown, newlyUp } = await runVpsUptimeChecks()
|
||||
@@ -261,19 +312,12 @@ function pickSchedulerIntervals(rows: SettingsRow[]): {
|
||||
let tariffsInterval: number | null = null
|
||||
|
||||
for (const s of rows) {
|
||||
notifyInterval = Math.min(
|
||||
notifyInterval,
|
||||
Math.max(15, Number(s.notifyIntervalMinutes) || 60),
|
||||
)
|
||||
uptimeInterval = Math.min(
|
||||
uptimeInterval,
|
||||
Math.max(1, Number(s.uptimeCheckIntervalMinutes) || 5),
|
||||
)
|
||||
const i = spaceIntervals(s)
|
||||
notifyInterval = Math.min(notifyInterval, i.notify)
|
||||
uptimeInterval = Math.min(uptimeInterval, i.uptime)
|
||||
if (s.syncEnabled) {
|
||||
const si = Math.max(15, Number(s.syncIntervalMinutes) || 60)
|
||||
const ti = Math.max(60, Number(s.syncTariffsIntervalMinutes) || 1440)
|
||||
syncInterval = syncInterval == null ? si : Math.min(syncInterval, si)
|
||||
tariffsInterval = tariffsInterval == null ? ti : Math.min(tariffsInterval, ti)
|
||||
syncInterval = syncInterval == null ? i.sync : Math.min(syncInterval, i.sync)
|
||||
tariffsInterval = tariffsInterval == null ? i.tariffs : Math.min(tariffsInterval, i.tariffs)
|
||||
}
|
||||
}
|
||||
return { notifyInterval, uptimeInterval, syncInterval, tariffsInterval }
|
||||
|
||||
Reference in new issue
Block a user