RouterOS serialize превращает results в объект с ключами 0,1 — API отвечал 400. ros-6 собирает массив, ingest принимает оба формата. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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={
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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' }])
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>) }
|
||||
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<string, unknown>
|
||||
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])
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user