diff --git a/apps/api/scripts/censorcheck/launcher.rsc b/apps/api/scripts/censorcheck/launcher.rsc index f762784..f411d3e 100644 --- a/apps/api/scripts/censorcheck/launcher.rsc +++ b/apps/api/scripts/censorcheck/launcher.rsc @@ -6,7 +6,7 @@ :local vtToken "__VT_INGEST_TOKEN__" :local vtDaily "__VT_DAILY__" :local vtRemove "__VT_REMOVE_DAILY__" -:local launcherVer "ros-5" +:local launcherVer "ros-6" :local schedName "vt-cc" :local dstFile "vt-cc.rsc" @@ -100,7 +100,8 @@ "autodesk.com";"graylog.org";"redis.io";"copilot.microsoft.com"\ } -:local results ({}) +:local resultJson "" +:local itemJson "" :local code 0 :local data :local line @@ -142,22 +143,24 @@ } :put ($host . " " . $code) :set item { service=$host; raw={ https={ ipv4={ status=$code } } } } - :set ($results->[:len $results]) $item + :set itemJson [:serialize to=json value=$item] + :if ([:len $resultJson] > 0) do={ :set resultJson ($resultJson . ",") } + :set resultJson ($resultJson . $itemJson) } :local probe { publicIp=$publicIp } :if ([:len $hoster] > 0) do={ :set ($probe->"hoster") $hoster } -:local payload {\ +:local meta {\ schemaVersion=1;\ runId=$runId;\ probe=$probe;\ launcherVersion=$launcherVer;\ - censorcheck={ version="ros"; mode="https" };\ - results=$results\ + censorcheck={ version="ros"; mode="https" }\ } -:local json [:serialize to=json value=$payload] -:local hdrs ("Content-Type:application/json,Authorization:Bearer " . $vtToken) +:local metaJson [:serialize to=json value=$meta] +:local json ([:pick $metaJson 0 ([:len $metaJson] - 1)] . ",\"results\":[" . $resultJson . "]}") +:local hdrs {"Content-Type: application/json"; ("Authorization: Bearer " . $vtToken)} :put ("Отправляю ingest (" . [:len $json] . " B)...") :set ingestOk false :onerror err,attr in={ diff --git a/apps/api/scripts/ipregion/launcher.rsc b/apps/api/scripts/ipregion/launcher.rsc index febf186..5bc2ea9 100644 --- a/apps/api/scripts/ipregion/launcher.rsc +++ b/apps/api/scripts/ipregion/launcher.rsc @@ -6,7 +6,7 @@ :local vtToken "__VT_INGEST_TOKEN__" :local vtDaily "__VT_DAILY__" :local vtRemove "__VT_REMOVE_DAILY__" -:local launcherVer "ros-5" +:local launcherVer "ros-6" :local schedName "vt-ic" :local dstFile "vt-ic.rsc" @@ -102,7 +102,8 @@ "ip-api.com";"cloudflare cdn"\ } -:local results ({}) +:local resultJson "" +:local itemJson "" :local url "" :local method "get" :local postData "" @@ -259,22 +260,24 @@ :put ($name . " " . $iso) :set item { service=$name; ipv4=$iso } - :set ($results->[:len $results]) $item + :set itemJson [:serialize to=json value=$item] + :if ([:len $resultJson] > 0) do={ :set resultJson ($resultJson . ",") } + :set resultJson ($resultJson . $itemJson) } :local probe { publicIp=$publicIp } :if ([:len $hoster] > 0) do={ :set ($probe->"hoster") $hoster } -:local payload {\ +:local meta {\ schemaVersion=1;\ runId=$runId;\ probe=$probe;\ launcherVersion=$launcherVer;\ - ipregion={ version="ros" };\ - results=$results\ + ipregion={ version="ros" }\ } -:local json [:serialize to=json value=$payload] -:local hdrs ("Content-Type:application/json,Authorization:Bearer " . $vtToken) +:local metaJson [:serialize to=json value=$meta] +:local json ([:pick $metaJson 0 ([:len $metaJson] - 1)] . ",\"results\":[" . $resultJson . "]}") +:local hdrs {"Content-Type: application/json"; ("Authorization: Bearer " . $vtToken)} :put ("Отправляю ingest (" . [:len $json] . " B)...") :set ingestOk false :onerror err,attr in={ diff --git a/apps/api/src/lib/ros-ingest.test.ts b/apps/api/src/lib/ros-ingest.test.ts new file mode 100644 index 0000000..9a5f455 --- /dev/null +++ b/apps/api/src/lib/ros-ingest.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { coerceRouterosIngestBody } from './ros-ingest.js' + +describe('coerceRouterosIngestBody', () => { + it('превращает results с числовыми ключами в массив', () => { + const out = coerceRouterosIngestBody({ + schemaVersion: '1', + runId: 'mt-abcdefghijklmnop', + probe: { publicIp: '185.246.117.91' }, + results: { + '0': { service: 'maxmind.com', ipv4: 'N/A' }, + '1': { service: 'ipinfo.io', ipv4: 'RU' }, + }, + }) as { schemaVersion: number; results: Array<{ service: string }> } + + expect(out.schemaVersion).toBe(1) + expect(out.results).toEqual([ + { service: 'maxmind.com', ipv4: 'N/A' }, + { service: 'ipinfo.io', ipv4: 'RU' }, + ]) + }) + + it('оставляет обычный массив без изменений', () => { + const results = [{ service: 'ipinfo.io', ipv4: 'RU' }] + const out = coerceRouterosIngestBody({ + schemaVersion: 1, + results, + }) as { results: unknown } + expect(out.results).toBe(results) + }) + + it('парсит JSON-строку', () => { + const out = coerceRouterosIngestBody( + '{"schemaVersion":1,"results":{"0":{"service":"a"}}}', + ) as { results: Array<{ service: string }> } + expect(out.results).toEqual([{ service: 'a' }]) + }) +}) diff --git a/apps/api/src/lib/ros-ingest.ts b/apps/api/src/lib/ros-ingest.ts new file mode 100644 index 0000000..4e6223e --- /dev/null +++ b/apps/api/src/lib/ros-ingest.ts @@ -0,0 +1,30 @@ +/** + * RouterOS `:serialize to=json` of `$arr->0 = $item` yields + * `{"0": {...}, "1": {...}}` instead of a JSON array. + * Numbers may also come as strings (`schemaVersion: "1"`). + */ +export function coerceRouterosIngestBody(body: unknown): unknown { + let value: unknown = body + if (typeof value === 'string') { + try { + value = JSON.parse(value) + } catch { + return body + } + } + if (!value || typeof value !== 'object' || Array.isArray(value)) return value + + const rec = { ...(value as Record) } + if (rec.schemaVersion === '1') rec.schemaVersion = 1 + rec.results = coerceIndexedObjectToArray(rec.results) + return rec +} + +function coerceIndexedObjectToArray(value: unknown): unknown { + if (Array.isArray(value) || value == null || typeof value !== 'object') return value + const obj = value as Record + const keys = Object.keys(obj) + if (keys.length === 0) return value + if (!keys.every((key) => /^\d+$/.test(key))) return value + return keys.sort((a, b) => Number(a) - Number(b)).map((key) => obj[key]) +} diff --git a/apps/api/src/routes/censorcheck.test.ts b/apps/api/src/routes/censorcheck.test.ts index 8cdadc8..5d3465f 100644 --- a/apps/api/src/routes/censorcheck.test.ts +++ b/apps/api/src/routes/censorcheck.test.ts @@ -201,4 +201,21 @@ describe('censorcheck ingest + reads', () => { expect(item.vps.providerName).toBe('Test Host') expect(item.detectedHoster).toBe('Hetzner') }) + + it('принимает results как объект с числовыми ключами (RouterOS serialize)', async () => { + const res = await post( + ingestPayload({ + schemaVersion: '1', + runId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + results: { + '0': { + service: 'youtube.com', + raw: { https: { ipv4: { status: 200 } } }, + }, + }, + }), + ) + expect(res.statusCode).toBe(200) + expect(res.json().summary.available).toBe(1) + }) }) diff --git a/apps/api/src/routes/censorcheck.ts b/apps/api/src/routes/censorcheck.ts index 59a38d2..274d73e 100644 --- a/apps/api/src/routes/censorcheck.ts +++ b/apps/api/src/routes/censorcheck.ts @@ -7,6 +7,7 @@ import { ingestSecret, verifyIngestToken, } from '../services/censorcheck/ingest-token.js' +import { coerceRouterosIngestBody } from '../lib/ros-ingest.js' import { matchVpsByPublicIp, resolveProbeIp } from '../services/censorcheck/match-ip.js' import { normalizeIngestResult, summarizeResults } from '../services/censorcheck/normalize.js' @@ -44,7 +45,7 @@ export const censorcheckRoutes: FastifyPluginAsync = async (app) => { async (request, reply) => { if (!requireIngestToken(request, reply)) return - const parsed = censorcheckIngestBodySchema.safeParse(request.body) + const parsed = censorcheckIngestBodySchema.safeParse(coerceRouterosIngestBody(request.body)) if (!parsed.success) { return sendError(reply, 400, 'VALIDATION', parsed.error.message) } diff --git a/apps/api/src/routes/ipregion.test.ts b/apps/api/src/routes/ipregion.test.ts index cc8f630..97693e6 100644 --- a/apps/api/src/routes/ipregion.test.ts +++ b/apps/api/src/routes/ipregion.test.ts @@ -140,4 +140,19 @@ describe('ipregion ingest + reads', () => { const current = await app.inject({ method: 'GET', url: '/api/ipregion/current' }) expect(current.json().items[0].detectedHoster).toBe('DigitalOcean') }) + + it('принимает results как объект с числовыми ключами (RouterOS serialize)', async () => { + const res = await post( + ingestPayload({ + schemaVersion: '1', + runId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + results: { + '0': { service: 'maxmind.com', ipv4: 'NL' }, + '1': { service: 'ipinfo.io', ipv4: 'RU' }, + }, + }), + ) + expect(res.statusCode).toBe(200) + expect(res.json().summary.ok).toBe(2) + }) }) diff --git a/apps/api/src/routes/ipregion.ts b/apps/api/src/routes/ipregion.ts index d99ef06..d48a13f 100644 --- a/apps/api/src/routes/ipregion.ts +++ b/apps/api/src/routes/ipregion.ts @@ -9,6 +9,7 @@ import { verifyIngestToken, } from '../services/censorcheck/ingest-token.js' import { matchVpsByPublicIp, resolveProbeIp } from '../services/censorcheck/match-ip.js' +import { coerceRouterosIngestBody } from '../lib/ros-ingest.js' import { normalizeIngestResult, summarizeResults } from '../services/ipregion/normalize.js' const BODY_LIMIT = 512 * 1024 @@ -45,7 +46,7 @@ export const ipregionRoutes: FastifyPluginAsync = async (app) => { async (request, reply) => { if (!requireIngestToken(request, reply)) return - const parsed = ipregionIngestBodySchema.safeParse(request.body) + const parsed = ipregionIngestBodySchema.safeParse(coerceRouterosIngestBody(request.body)) if (!parsed.success) { return sendError(reply, 400, 'VALIDATION', parsed.error.message) } diff --git a/apps/api/src/routes/launcher.test.ts b/apps/api/src/routes/launcher.test.ts index 07df917..96a311c 100644 --- a/apps/api/src/routes/launcher.test.ts +++ b/apps/api/src/routes/launcher.test.ts @@ -128,7 +128,7 @@ describe('GET /cc.rsc RouterOS launcher', () => { expect(res.body).toContain('https://vt.shnt.top') expect(res.body).toContain('/tool fetch') expect(res.body).toContain('/api/integrations/censorcheck/runs') - expect(res.body).toContain('ros-5') + expect(res.body).toContain('ros-6') expect(res.body).toContain(':local vtDaily "no"') expect(res.body).toContain(':local vtRemove "no"') expect(res.body).toContain('/system scheduler') @@ -138,6 +138,7 @@ describe('GET /cc.rsc RouterOS launcher', () => { expect(res.body).toContain('src-address=$srcIp') expect(res.body).toContain(':onerror') expect(res.body).toContain('повтор ingest без src-address') + expect(res.body).toContain('\\"results\\":[') expect(res.body).not.toContain('mark-routing') expect(res.body).not.toContain('vtIface') expect(res.body).not.toContain('\r') @@ -188,7 +189,7 @@ describe('GET /ic.rsc RouterOS launcher', () => { expect(res.body).toContain('https://vt.shnt.top') expect(res.body).toContain('/tool fetch') expect(res.body).toContain('/api/integrations/ipregion/runs') - expect(res.body).toContain('ros-5') + expect(res.body).toContain('ros-6') expect(res.body).toContain(':local vtDaily "no"') expect(res.body).toContain('ipinfo.io') expect(res.body).toContain('cloudflare cdn') @@ -197,6 +198,7 @@ describe('GET /ic.rsc RouterOS launcher', () => { expect(res.body).toContain('src-address=$srcIp') expect(res.body).toContain(':onerror') expect(res.body).toContain('повтор ingest без src-address') + expect(res.body).toContain('\\"results\\":[') expect(res.body).not.toContain('mark-routing') expect(res.body).not.toContain('vtIface') expect(res.body).not.toContain('\r')