From fedaf240982fe916bdbe3b4f1ec3325d46258d7d Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 16 Jul 2026 02:24:06 +0300 Subject: [PATCH] chore(proxy-contur): preserve legacy Contour compatibility service --- .../build-proxy-contur-artifact.mjs | 44 + services/proxy-contur/Dockerfile | 12 + services/proxy-contur/README.md | 123 +++ services/proxy-contur/docker-compose.yml | 33 + services/proxy-contur/package.json | 13 + services/proxy-contur/server.js | 888 ++++++++++++++++++ 6 files changed, 1113 insertions(+) create mode 100644 infra/deploy-runner/build-proxy-contur-artifact.mjs create mode 100644 services/proxy-contur/Dockerfile create mode 100644 services/proxy-contur/README.md create mode 100644 services/proxy-contur/docker-compose.yml create mode 100644 services/proxy-contur/package.json create mode 100644 services/proxy-contur/server.js diff --git a/infra/deploy-runner/build-proxy-contur-artifact.mjs b/infra/deploy-runner/build-proxy-contur-artifact.mjs new file mode 100644 index 0000000..18887bf --- /dev/null +++ b/infra/deploy-runner/build-proxy-contur-artifact.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const platformRoot = resolve(scriptDir, "../.."); +const artifactDir = resolve(scriptDir, "../deploy-artifacts"); +const [patchId = "cesium-egress-20260715-012", ...extra] = process.argv.slice(2); +if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) throw new Error("usage: build-proxy-contur-artifact.mjs [patch-id]"); + +const files = [ + ["services/proxy-contur/Dockerfile", "Dockerfile"], + ["services/proxy-contur/README.md", "README.md"], + ["services/proxy-contur/docker-compose.yml", "docker-compose.yml"], + ["services/proxy-contur/package.json", "package.json"], + ["services/proxy-contur/server.js", "server.js"], +]; +const stage = await mkdtemp(join(tmpdir(), "nodedc-proxy-contur-artifact-")); +const payload = join(stage, "payload"); +const target = join(artifactDir, `nodedc-proxy-contur-${patchId}.tgz`); + +try { + await mkdir(payload, { recursive: true }); + for (const [source, destination] of files) await copySafe(resolve(platformRoot, source), join(payload, destination)); + await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=proxy-contur\ntype=app-overlay\n`, "utf8"); + await writeFile(join(stage, "files.txt"), `${files.map(([, destination]) => destination).join("\n")}\n`, "utf8"); + await mkdir(artifactDir, { recursive: true }); + const tar = spawnSync("python3", ["-c", "import sys,tarfile\nwith tarfile.open(sys.argv[1],'w:gz',format=tarfile.PAX_FORMAT) as a:\n [a.add(n,arcname=n,recursive=True) for n in ('manifest.env','files.txt','payload')]", target], { cwd: stage, encoding: "utf8" }); + if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`); + console.log(JSON.stringify({ ok: true, patchId, artifact: target, sha256: createHash("sha256").update(await readFile(target)).digest("hex") }, null, 2)); +} finally { + await rm(stage, { recursive: true, force: true }); +} + +async function copySafe(source, destination) { + const info = await lstat(source); + if (info.isSymbolicLink() || !info.isFile()) throw new Error(`source_file_rejected:${source}`); + await mkdir(dirname(destination), { recursive: true }); + await cp(source, destination, { force: true }); +} diff --git a/services/proxy-contur/Dockerfile b/services/proxy-contur/Dockerfile new file mode 100644 index 0000000..acb9f93 --- /dev/null +++ b/services/proxy-contur/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package.json ./ +RUN npm install --omit=dev + +COPY server.js ./ + +EXPOSE 8787 + +CMD ["node", "server.js"] diff --git a/services/proxy-contur/README.md b/services/proxy-contur/README.md new file mode 100644 index 0000000..882c27b --- /dev/null +++ b/services/proxy-contur/README.md @@ -0,0 +1,123 @@ +# proxy-contur + +Прокси-сервис для тендерного контура (Synology upstream). + +> **Legacy Cesium note.** Контурные endpoints этого сервиса остаются +> поддерживаемым назначением. Его `/proxy/cesium/fetch` сохраняется только как +> историческая совместимость и не является production-маршрутом Foundry Map. +> Канонический Cesium egress: `Map Gateway → dc-amd-proxy → dc-amd-connector → +> Windows VPN`; см. `services/dc-amd-proxy/OPERATIONS.md`. + +## Назначение + +Сервис принимает запросы от локального backend NodeDC и делает внешний трафик: + +1. Поиск тендеров (Contour search). +2. Карточка закупки (Contour purchases). +3. Fetch документов по `url` с stream passthrough (без base64). +4. Ограниченный VPN egress для Platform Map Gateway / Cesium. + +## Endpoints + +1. `GET /healthz` +2. `POST /proxy/contour/search` +3. `GET /proxy/contour/purchases/:purchaseId` +4. `GET /proxy/contour/limit-groups` +5. `GET /proxy/fetch?url=` +6. `GET /proxy/cesium/fetch?url=` + +## Auth + +Если задан `PROXY_TOKEN`, требуется: + +1. `x-proxy-token: ` +или +2. `Authorization: Bearer ` + +## Contour API key + +Рекомендуемый режим: + +1. Задать `CONTOUR_API_KEY` в `.env` на стороне `proxy-contur`. +2. После этого ключ Контура не нужно передавать в каждом запросе. + +Совместимость: + +1. Если `CONTOUR_API_KEY` не задан, сервис возьмёт ключ из запроса (`x-kontur-apikey` / query / body), как раньше. + +## Security + +По умолчанию включено: + +1. Только `http/https`. +2. Блок private/local/loopback сетей. +3. Проверка каждого redirect. +4. Лимиты timeout/size/redirect count. + +`/proxy/cesium/fetch` дополнительно принимает только канонические hosts +Cesium Ion и Bing, проверяет каждый redirect и не допускает произвольные +outbound headers. Внутренние `x-nodedc-cesium-authorization` и +`x-nodedc-map-referer` передаёт только Map Gateway через закрытую сеть +`nodedc-map-egress`; в логах query credentials маскируются. + +## Quick Start + +```bash +cd proxy-contur +cp .env.example .env +npm install +npm run start +``` + +## Docker Compose Start + +```bash +cd proxy-contur +cp .env.example .env +docker compose up -d --build +``` + +Проверка: + +```bash +docker compose ps +curl -sS http://172.22.0.222:8787/healthz | jq +``` + +Остановка: + +```bash +docker compose down +``` + +## Пример запросов + +Проверка: + +```bash +curl -sS http://172.22.0.222:8787/healthz | jq +``` + +Contour search: + +```bash +curl -sS -X POST "http://172.22.0.222:8787/proxy/contour/search" \ + -H "x-proxy-token: change-me" \ + -H "content-type: application/json" \ + -d '{"PageNumber":0,"PageSize":50}' | jq +``` + +Contour purchase: + +```bash +curl -sS "http://172.22.0.222:8787/proxy/contour/purchases/12345" \ + -H "x-proxy-token: change-me" | jq +``` + +Fetch file stream: + +```bash +curl -L -o out.bin \ + "http://172.22.0.222:8787/proxy/fetch?url=https%3A%2F%2Fexample.com%2Ffile.pdf" \ + -H "x-proxy-token: change-me" +``` diff --git a/services/proxy-contur/docker-compose.yml b/services/proxy-contur/docker-compose.yml new file mode 100644 index 0000000..94d8c71 --- /dev/null +++ b/services/proxy-contur/docker-compose.yml @@ -0,0 +1,33 @@ +services: + proxy-contur: + build: + context: . + dockerfile: Dockerfile + container_name: proxy-contur + restart: unless-stopped + env_file: + - .env + ports: + - "${PORT:-8787}:${PORT:-8787}" + # This is the only private link from Platform Map Gateway to the VPN + # egress. The service remains reachable through its existing host port for + # the legacy Contour integration. + networks: + map-egress: + aliases: + - nodedc-map-egress + healthcheck: + test: + - CMD-SHELL + - > + node -e "fetch('http://127.0.0.1:' + (process.env.PORT || '8787') + '/healthz', { headers: { 'x-proxy-token': process.env.PROXY_TOKEN || '' } }) + .then(r => process.exit(r.ok ? 0 : 1)) + .catch(() => process.exit(1))" + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + +networks: + map-egress: + name: nodedc-map-egress diff --git a/services/proxy-contur/package.json b/services/proxy-contur/package.json new file mode 100644 index 0000000..8350385 --- /dev/null +++ b/services/proxy-contur/package.json @@ -0,0 +1,13 @@ +{ + "name": "proxy-contur", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js" + }, + "dependencies": { + "express": "^5.1.0" + } +} diff --git a/services/proxy-contur/server.js b/services/proxy-contur/server.js new file mode 100644 index 0000000..a44fd8f --- /dev/null +++ b/services/proxy-contur/server.js @@ -0,0 +1,888 @@ +import crypto from 'node:crypto' +import dns from 'node:dns/promises' +import net from 'node:net' +import { URL } from 'node:url' +import express from 'express' + +const app = express() + +const PORT = Math.max(1, Number(process.env.PORT || 8787) || 8787) +const PROXY_TOKEN = String(process.env.PROXY_TOKEN || '').trim() +const CONTOUR_API_BASE_URL = String( + process.env.CONTOUR_API_BASE_URL || 'https://api-zakupki.kontur.ru/external/v1', +).trim().replace(/\/+$/, '') +const CONTOUR_API_KEY = String(process.env.CONTOUR_API_KEY || '').trim() +const REQUEST_TIMEOUT_MS = Math.max(1000, Number(process.env.REQUEST_TIMEOUT_MS || 30000) || 30000) +const MAX_REDIRECTS = Math.max(0, Number(process.env.MAX_REDIRECTS || 5) || 5) +const MAX_JSON_RESPONSE_BYTES = Math.max( + 1024 * 64, + Number(process.env.MAX_JSON_RESPONSE_BYTES || 10 * 1024 * 1024) || (10 * 1024 * 1024), +) +const MAX_FETCH_BYTES = Math.max( + 1024 * 256, + Number(process.env.MAX_FETCH_BYTES || 50 * 1024 * 1024) || (50 * 1024 * 1024), +) +const MAX_CESIUM_FETCH_BYTES = Math.max( + 1024 * 256, + Number(process.env.MAX_CESIUM_FETCH_BYTES || 128 * 1024 * 1024) || (128 * 1024 * 1024), +) +const BLOCK_PRIVATE_NETWORKS = String(process.env.BLOCK_PRIVATE_NETWORKS || '1').trim() !== '0' +const LOG_LEVEL = String(process.env.LOG_LEVEL || 'info').trim().toLowerCase() || 'info' +const DEBUG_FETCH = ['1', 'true', 'yes', 'on'].includes(String(process.env.DEBUG_FETCH || '').trim().toLowerCase()) +// Map Gateway has a dedicated outbound route. It is deliberately separate +// from /proxy/fetch: only the provider hosts required by the canonical Map +// Page can use it, and Cesium credentials never become generic proxy headers. +const CESIUM_EGRESS_HOSTS = new Set([ + 'api.cesium.com', + 'assets.ion.cesium.com', + 'dev.virtualearth.net', + 'ecn.t0.tiles.virtualearth.net', + 'ecn.t1.tiles.virtualearth.net', + 'ecn.t2.tiles.virtualearth.net', + 'ecn.t3.tiles.virtualearth.net', +]) +const CREDENTIAL_QUERY_KEYS = new Set([ + 'access_token', + 'accesstoken', + 'iontoken', + 'token', + 'key', + 'apikey', + 'api_key', + 'signature', + 'sig', +]) +const LOG_LEVEL_WEIGHT = { + debug: 10, + info: 20, + warn: 30, + error: 40, +} + +app.set('trust proxy', true) +app.disable('x-powered-by') +app.use(express.json({ limit: '2mb', type: ['application/json', 'application/*+json'] })) + +function shouldLog(level = 'info') { + const wanted = LOG_LEVEL_WEIGHT[String(LOG_LEVEL || '').toLowerCase()] || LOG_LEVEL_WEIGHT.info + const current = LOG_LEVEL_WEIGHT[String(level || '').toLowerCase()] || LOG_LEVEL_WEIGHT.info + return current >= wanted +} + +function log(level, message, fields = {}) { + if (!shouldLog(level)) return + const line = { + ts: new Date().toISOString(), + level, + message, + ...fields, + } + // eslint-disable-next-line no-console + console.log(JSON.stringify(line)) +} + +function getRequestId(req) { + const fromHeader = String(req.headers['x-request-id'] || '').trim() + return fromHeader || crypto.randomUUID() +} + +function sanitizeErrorMessage(error, fallback = 'proxy_request_failed') { + const text = String(error?.message || '').trim() + if (!text) return fallback + return text.slice(0, 500) +} + +function parseBoolean(raw, fallback = false) { + const text = String(raw ?? '').trim().toLowerCase() + if (!text) return fallback + if (['1', 'true', 'yes', 'on'].includes(text)) return true + if (['0', 'false', 'no', 'off'].includes(text)) return false + return fallback +} + +function isIpv4Private(address) { + const parts = String(address || '').split('.').map((s) => Number(s)) + if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false + const [a, b] = parts + if (a === 10) return true + if (a === 127) return true + if (a === 0) return true + if (a === 169 && b === 254) return true + if (a === 172 && b >= 16 && b <= 31) return true + if (a === 192 && b === 168) return true + if (a === 100 && b >= 64 && b <= 127) return true + if (a === 198 && (b === 18 || b === 19)) return true + if (a >= 224) return true + return false +} + +function ipv6ToBigInt(address) { + const input = String(address || '').toLowerCase() + if (!input) return null + const percent = input.indexOf('%') + const src = percent >= 0 ? input.slice(0, percent) : input + if (!src.includes(':')) return null + + const hasDouble = src.includes('::') + if (hasDouble && src.indexOf('::') !== src.lastIndexOf('::')) return null + + const [headRaw, tailRaw] = hasDouble ? src.split('::') : [src, ''] + const head = headRaw ? headRaw.split(':').filter(Boolean) : [] + const tail = tailRaw ? tailRaw.split(':').filter(Boolean) : [] + const missing = 8 - (head.length + tail.length) + if (missing < 0) return null + + const chunks = hasDouble + ? [...head, ...new Array(missing).fill('0'), ...tail] + : src.split(':') + if (chunks.length !== 8) return null + + let out = 0n + for (const chunk of chunks) { + if (!/^[0-9a-f]{1,4}$/i.test(chunk)) return null + const value = BigInt(parseInt(chunk, 16)) + out = (out << 16n) + value + } + return out +} + +function isIpv6Private(address) { + const normalized = String(address || '').toLowerCase() + if (!normalized) return false + if (normalized === '::1' || normalized === '::') return true + const value = ipv6ToBigInt(normalized) + if (value === null) return false + + // fc00::/7 + if ((value & 0xfe00_0000_0000_0000_0000_0000_0000_0000n) === 0xfc00_0000_0000_0000_0000_0000_0000_0000n) return true + // fe80::/10 + if ((value & 0xffc0_0000_0000_0000_0000_0000_0000_0000n) === 0xfe80_0000_0000_0000_0000_0000_0000_0000n) return true + // ff00::/8 multicast + if ((value & 0xff00_0000_0000_0000_0000_0000_0000_0000n) === 0xff00_0000_0000_0000_0000_0000_0000_0000n) return true + return false +} + +function isPrivateAddress(address) { + const kind = net.isIP(address) + if (kind === 4) return isIpv4Private(address) + if (kind === 6) return isIpv6Private(address) + return false +} + +async function assertUrlIsSafe(targetUrl) { + let parsed = null + try { + parsed = new URL(String(targetUrl || '')) + } catch { + const err = new Error('invalid_target_url') + err.statusCode = 400 + throw err + } + + const protocol = String(parsed.protocol || '').toLowerCase() + if (protocol !== 'http:' && protocol !== 'https:') { + const err = new Error('unsupported_target_protocol') + err.statusCode = 400 + throw err + } + + const hostname = String(parsed.hostname || '').trim() + if (!hostname) { + const err = new Error('target_hostname_required') + err.statusCode = 400 + throw err + } + + if (!BLOCK_PRIVATE_NETWORKS) return parsed + + const lowerHost = hostname.toLowerCase() + if (lowerHost === 'localhost' || lowerHost.endsWith('.localhost')) { + const err = new Error('target_host_not_allowed') + err.statusCode = 403 + throw err + } + + const literalIpKind = net.isIP(hostname) + if (literalIpKind && isPrivateAddress(hostname)) { + const err = new Error('target_private_ip_not_allowed') + err.statusCode = 403 + throw err + } + + let resolved = [] + try { + resolved = await dns.lookup(hostname, { all: true, verbatim: true }) + } catch { + // If DNS cannot resolve now, let fetch handle it later. + return parsed + } + + for (const entry of resolved) { + const address = String(entry?.address || '').trim() + if (!address) continue + if (isPrivateAddress(address)) { + const err = new Error('target_resolves_to_private_network') + err.statusCode = 403 + throw err + } + } + + return parsed +} + +function assertCesiumEgressTarget(target) { + const hostname = String(target?.hostname || '').trim().toLowerCase() + if (CESIUM_EGRESS_HOSTS.has(hostname)) return target + const err = new Error('cesium_egress_host_not_allowed') + err.statusCode = 403 + throw err +} + +function redactCredentialUrl(rawUrl) { + try { + const parsed = new URL(String(rawUrl || '')) + for (const key of [...parsed.searchParams.keys()]) { + if (CREDENTIAL_QUERY_KEYS.has(key.toLowerCase())) parsed.searchParams.set(key, '') + } + return parsed.toString() + } catch { + return '[invalid-target-url]' + } +} + +function mapEgressReferer(rawReferer) { + const value = String(rawReferer || '').trim() + if (!value) return '' + let parsed + try { + parsed = new URL(value) + } catch { + const err = new Error('invalid_map_egress_referer') + err.statusCode = 400 + throw err + } + if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) { + const err = new Error('invalid_map_egress_referer') + err.statusCode = 400 + throw err + } + parsed.search = '' + parsed.hash = '' + return parsed.toString() +} + +function mapEgressHeaders(req, target, requestId) { + const authorization = String(req.headers['x-nodedc-cesium-authorization'] || '').trim() + if (authorization && !/^Bearer\s+[A-Za-z0-9._~+/=-]{16,4096}$/i.test(authorization)) { + const err = new Error('invalid_cesium_egress_authorization') + err.statusCode = 400 + throw err + } + const range = String(req.headers.range || '').trim() + if (range && !/^bytes=\d*-\d*(?:,\d*-\d*)*$/i.test(range)) { + const err = new Error('invalid_map_egress_range') + err.statusCode = 400 + throw err + } + const accept = String(req.headers.accept || 'application/json, application/octet-stream, image/*, */*;q=0.5').trim().slice(0, 512) + const referer = mapEgressReferer(req.headers['x-nodedc-map-referer']) + const headers = { + accept: accept || '*/*', + 'user-agent': 'nodedc-map-egress/1.0', + 'x-request-id': requestId, + ...(referer ? { referer } : {}), + ...(range ? { range } : {}), + } + // The master Ion token may only be sent to the Ion API. Redirects are + // validated per hop and never carry it to an asset/Bing host. + if (String(target?.hostname || '').toLowerCase() === 'api.cesium.com' && authorization) { + headers.authorization = authorization + } + return headers +} + +function getProxyTokenFromReq(req) { + const fromHeader = String(req.headers['x-proxy-token'] || '').trim() + if (fromHeader) return fromHeader + const auth = String(req.headers.authorization || '').trim() + if (!auth) return '' + const m = /^Bearer\s+(.+)$/i.exec(auth) + return m ? String(m[1] || '').trim() : '' +} + +function setAccessLogExtra(req, fields = {}) { + if (!req || typeof req !== 'object') return + const extra = req.accessLogExtra && typeof req.accessLogExtra === 'object' + ? req.accessLogExtra + : {} + req.accessLogExtra = { + ...extra, + ...(fields && typeof fields === 'object' && !Array.isArray(fields) ? fields : {}), + } +} + +function authMiddleware(req, res, next) { + if (!PROXY_TOKEN) return next() + const token = getProxyTokenFromReq(req) + if (!token || token !== PROXY_TOKEN) { + return res.status(401).json({ + ok: false, + error: 'unauthorized', + message: 'Invalid proxy token', + }) + } + return next() +} + +app.use((req, res, next) => { + const requestId = getRequestId(req) + req.requestId = requestId + res.setHeader('x-request-id', requestId) + next() +}) + +app.use((req, res, next) => { + const startedAt = process.hrtime.bigint() + req.accessLogExtra = {} + res.on('finish', () => { + const durationMs = Number(process.hrtime.bigint() - startedAt) / 1e6 + const payload = { + requestId: req.requestId || '', + method: req.method, + path: req.path, + status: Number(res.statusCode || 0), + durationMs: Math.round(durationMs * 1000) / 1000, + ...(req.accessLogExtra && typeof req.accessLogExtra === 'object' ? req.accessLogExtra : {}), + } + const level = Number(res.statusCode || 0) >= 500 + ? 'error' + : (Number(res.statusCode || 0) >= 400 ? 'warn' : 'info') + log(level, 'proxy access', payload) + }) + next() +}) + +app.use(authMiddleware) + +app.get('/healthz', (req, res) => { + res.json({ + ok: true, + service: 'proxy-contur', + time: new Date().toISOString(), + mode: { + blockPrivateNetworks: BLOCK_PRIVATE_NETWORKS, + timeoutMs: REQUEST_TIMEOUT_MS, + maxRedirects: MAX_REDIRECTS, + maxFetchBytes: MAX_FETCH_BYTES, + hasContourApiKey: Boolean(CONTOUR_API_KEY), + }, + }) +}) + +function pickApiKey(req) { + if (CONTOUR_API_KEY) return CONTOUR_API_KEY + const headerApiKey = String(req.headers['x-kontur-apikey'] || '').trim() + if (headerApiKey) return headerApiKey + const queryApiKey = String(req.query?.apiKey || req.query?.api_key || '').trim() + if (queryApiKey) return queryApiKey + const bodyApiKey = String(req.body?.apiKey || req.body?.api_key || '').trim() + return bodyApiKey +} + +async function fetchWithTimeout(url, { + method = 'GET', + headers = {}, + body = undefined, + timeoutMs = REQUEST_TIMEOUT_MS, + redirect = 'manual', +} = {}) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), Math.max(1000, timeoutMs)) + try { + return await fetch(url, { + method, + headers, + body, + redirect, + signal: controller.signal, + }) + } finally { + clearTimeout(timer) + } +} + +async function readResponseTextLimited(response, maxBytes = MAX_JSON_RESPONSE_BYTES) { + if (!response?.body) return '' + const reader = response.body.getReader() + const chunks = [] + let total = 0 + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + const buf = Buffer.from(value) + total += buf.length + if (total > maxBytes) { + const err = new Error('upstream_response_too_large') + err.statusCode = 413 + throw err + } + chunks.push(buf) + } + } finally { + reader.releaseLock() + } + return Buffer.concat(chunks).toString('utf8') +} + +function setJsonPassthroughHeaders(res, upstream) { + const contentType = upstream.headers.get('content-type') || 'application/json; charset=utf-8' + res.setHeader('content-type', contentType) +} + +app.post('/proxy/contour/search', async (req, res) => { + const requestId = req.requestId + const apiKey = pickApiKey(req) + if (!apiKey) { + return res.status(400).json({ + ok: false, + error: 'contour_apikey_required', + message: 'X-Kontur-Apikey is required', + requestId, + }) + } + + const targetUrl = `${CONTOUR_API_BASE_URL}/search` + const timeoutMs = Math.max(1000, Number(req.query?.timeoutMs || REQUEST_TIMEOUT_MS) || REQUEST_TIMEOUT_MS) + try { + await assertUrlIsSafe(targetUrl) + const upstream = await fetchWithTimeout(targetUrl, { + method: 'POST', + timeoutMs, + headers: { + accept: 'application/json, text/plain, */*', + 'content-type': 'application/json', + 'x-kontur-apikey': apiKey, + 'x-request-id': requestId, + }, + body: JSON.stringify(req.body && typeof req.body === 'object' ? req.body : {}), + }) + const bodyText = await readResponseTextLimited(upstream) + setAccessLogExtra(req, { + route: 'proxy_contour_search', + upstreamStatus: Number(upstream.status || 0), + responseBytes: Buffer.byteLength(bodyText || '', 'utf8'), + }) + setJsonPassthroughHeaders(res, upstream) + return res.status(upstream.status).send(bodyText) + } catch (error) { + const statusCode = Number(error?.statusCode || 502) || 502 + log('error', 'proxy contour search failed', { + requestId, + statusCode, + message: sanitizeErrorMessage(error), + }) + return res.status(statusCode).json({ + ok: false, + error: 'proxy_contour_search_failed', + message: sanitizeErrorMessage(error), + requestId, + }) + } +}) + +app.get('/proxy/contour/purchases/:purchaseId', async (req, res) => { + const requestId = req.requestId + const purchaseId = String(req.params?.purchaseId || '').trim() + if (!purchaseId) { + return res.status(400).json({ + ok: false, + error: 'purchase_id_required', + message: 'purchaseId is required', + requestId, + }) + } + const apiKey = pickApiKey(req) + if (!apiKey) { + return res.status(400).json({ + ok: false, + error: 'contour_apikey_required', + message: 'X-Kontur-Apikey is required', + requestId, + }) + } + const targetUrl = `${CONTOUR_API_BASE_URL}/purchases/${encodeURIComponent(purchaseId)}` + const timeoutMs = Math.max(1000, Number(req.query?.timeoutMs || REQUEST_TIMEOUT_MS) || REQUEST_TIMEOUT_MS) + try { + await assertUrlIsSafe(targetUrl) + const upstream = await fetchWithTimeout(targetUrl, { + method: 'GET', + timeoutMs, + headers: { + accept: 'application/json, text/plain, */*', + 'x-kontur-apikey': apiKey, + 'x-request-id': requestId, + }, + }) + const bodyText = await readResponseTextLimited(upstream) + setAccessLogExtra(req, { + route: 'proxy_contour_purchase', + purchaseId, + upstreamStatus: Number(upstream.status || 0), + responseBytes: Buffer.byteLength(bodyText || '', 'utf8'), + }) + setJsonPassthroughHeaders(res, upstream) + return res.status(upstream.status).send(bodyText) + } catch (error) { + const statusCode = Number(error?.statusCode || 502) || 502 + log('error', 'proxy contour purchase failed', { + requestId, + statusCode, + purchaseId, + message: sanitizeErrorMessage(error), + }) + return res.status(statusCode).json({ + ok: false, + error: 'proxy_contour_purchase_failed', + message: sanitizeErrorMessage(error), + requestId, + }) + } +}) + +app.get('/proxy/contour/limit-groups', async (req, res) => { + const requestId = req.requestId + const apiKey = pickApiKey(req) + if (!apiKey) { + return res.status(400).json({ + ok: false, + error: 'contour_apikey_required', + message: 'X-Kontur-Apikey is required', + requestId, + }) + } + const targetUrl = `${CONTOUR_API_BASE_URL}/limitGroups` + const timeoutMs = Math.max(1000, Number(req.query?.timeoutMs || REQUEST_TIMEOUT_MS) || REQUEST_TIMEOUT_MS) + try { + await assertUrlIsSafe(targetUrl) + const upstream = await fetchWithTimeout(targetUrl, { + method: 'GET', + timeoutMs, + headers: { + accept: 'application/json, text/plain, */*', + 'x-kontur-apikey': apiKey, + 'x-request-id': requestId, + }, + }) + const bodyText = await readResponseTextLimited(upstream) + setAccessLogExtra(req, { + route: 'proxy_contour_limit_groups', + upstreamStatus: Number(upstream.status || 0), + responseBytes: Buffer.byteLength(bodyText || '', 'utf8'), + }) + setJsonPassthroughHeaders(res, upstream) + return res.status(upstream.status).send(bodyText) + } catch (error) { + const statusCode = Number(error?.statusCode || 502) || 502 + log('error', 'proxy contour limit groups failed', { + requestId, + statusCode, + message: sanitizeErrorMessage(error), + }) + return res.status(statusCode).json({ + ok: false, + error: 'proxy_contour_limit_groups_failed', + message: sanitizeErrorMessage(error), + requestId, + }) + } +}) + +function copyFetchHeaders(res, upstreamHeaders, { omitContentLength = false } = {}) { + const passHeaders = [ + 'content-type', + 'content-length', + 'content-disposition', + 'cache-control', + 'etag', + 'last-modified', + 'accept-ranges', + ] + for (const name of passHeaders) { + if (omitContentLength && name === 'content-length') continue + const value = upstreamHeaders.get(name) + if (!value) continue + res.setHeader(name, value) + } +} + +async function fetchWithSafeRedirects(initialUrl, { + timeoutMs = REQUEST_TIMEOUT_MS, + maxRedirects = MAX_REDIRECTS, + headers = {}, + headersForTarget = null, + validateTarget = null, + redactForLog = null, + requestId = '', +} = {}) { + let currentUrl = String(initialUrl || '') + let redirects = 0 + + while (true) { + const parsed = await assertUrlIsSafe(currentUrl) + if (typeof validateTarget === 'function') validateTarget(parsed) + const requestHeaders = typeof headersForTarget === 'function' + ? headersForTarget(parsed) + : headers + const upstream = await fetchWithTimeout(parsed.toString(), { + method: 'GET', + timeoutMs, + redirect: 'manual', + headers: requestHeaders, + }) + const status = Number(upstream.status || 0) + const location = String(upstream.headers.get('location') || '').trim() + const isRedirect = status >= 300 && status < 400 && location + if (!isRedirect) { + return { upstream, finalUrl: parsed.toString(), redirects } + } + + if (redirects >= maxRedirects) { + const err = new Error('too_many_redirects') + err.statusCode = 508 + throw err + } + let nextUrl = '' + try { + nextUrl = new URL(location, parsed.toString()).toString() + } catch { + const err = new Error('invalid_redirect_url') + err.statusCode = 502 + throw err + } + if (DEBUG_FETCH) { + const safe = typeof redactForLog === 'function' ? redactForLog : (value) => value + log('debug', 'proxy fetch redirect', { + requestId: String(requestId || ''), + from: String(safe(parsed.toString())).slice(0, 512), + to: String(safe(nextUrl)).slice(0, 512), + status, + redirects: redirects + 1, + }) + } + currentUrl = nextUrl + redirects += 1 + } +} + +function createFetchRoute({ + route, + errorCode, + buildHeaders, + validateTarget = null, + redactTarget = false, + allowRequestTuning = true, + omitContentLength = false, + fixedMaxBytes = MAX_FETCH_BYTES, +}) { + return async (req, res) => { + const requestId = req.requestId + const rawUrl = String(req.query?.url || '').trim() + const safeTarget = redactTarget ? redactCredentialUrl(rawUrl) : rawUrl.slice(0, 400) + if (!rawUrl) { + return res.status(400).json({ + ok: false, + error: 'url_required', + message: 'query parameter "url" is required', + requestId, + }) + } + + const timeoutMs = allowRequestTuning + ? Math.max(1000, Number(req.query?.timeoutMs || REQUEST_TIMEOUT_MS) || REQUEST_TIMEOUT_MS) + : REQUEST_TIMEOUT_MS + const maxBytes = allowRequestTuning + ? Math.max(1024 * 64, Number(req.query?.maxBytes || MAX_FETCH_BYTES) || MAX_FETCH_BYTES) + : fixedMaxBytes + + try { + const { upstream, finalUrl, redirects } = await fetchWithSafeRedirects(rawUrl, { + timeoutMs, + maxRedirects: MAX_REDIRECTS, + headersForTarget: (target) => buildHeaders(req, target, requestId), + validateTarget, + redactForLog: redactTarget ? redactCredentialUrl : null, + requestId, + }) + const safeFinalUrl = redactTarget ? redactCredentialUrl(finalUrl) : finalUrl + const contentType = String(upstream.headers.get('content-type') || '').split(';')[0].trim().toLowerCase() + + const contentLength = Number(upstream.headers.get('content-length') || 0) + if (Number.isFinite(contentLength) && contentLength > 0 && contentLength > maxBytes) { + setAccessLogExtra(req, { + route, + upstreamStatus: Number(upstream.status || 0), + finalUrl: String(safeFinalUrl || '').slice(0, 512), + redirects, + contentType, + bytes: contentLength, + target: safeTarget, + error: 'fetch_too_large', + }) + return res.status(413).json({ + ok: false, + error: 'fetch_too_large', + message: 'Content-Length exceeds maxBytes', + requestId, + finalUrl: safeFinalUrl, + contentLength, + maxBytes, + }) + } + + copyFetchHeaders(res, upstream.headers, { omitContentLength }) + res.setHeader('x-proxy-final-url', safeFinalUrl) + res.setHeader('x-proxy-redirects', String(redirects)) + res.status(upstream.status) + + if (!upstream.body) { + setAccessLogExtra(req, { + route, + upstreamStatus: Number(upstream.status || 0), + finalUrl: String(safeFinalUrl || '').slice(0, 512), + redirects, + contentType, + bytes: 0, + target: safeTarget, + }) + return res.end() + } + + const reader = upstream.body.getReader() + let total = 0 + const onClose = () => { + reader.cancel().catch(() => {}) + } + req.on('close', onClose) + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + const chunk = Buffer.from(value) + total += chunk.length + if (total > maxBytes) { + setAccessLogExtra(req, { + route, + upstreamStatus: Number(upstream.status || 0), + finalUrl: String(safeFinalUrl || '').slice(0, 512), + redirects, + contentType, + bytes: total, + target: safeTarget, + error: 'fetch_too_large_stream', + }) + return res.status(413).end() + } + if (!res.write(chunk)) { + await new Promise((resolve) => res.once('drain', resolve)) + } + } + setAccessLogExtra(req, { + route, + upstreamStatus: Number(upstream.status || 0), + finalUrl: String(safeFinalUrl || '').slice(0, 512), + redirects, + contentType, + bytes: total, + target: safeTarget, + }) + return res.end() + } finally { + req.off('close', onClose) + reader.releaseLock() + } + } catch (error) { + const statusCode = Number(error?.statusCode || 502) || 502 + setAccessLogExtra(req, { + route, + target: safeTarget, + error: sanitizeErrorMessage(error), + }) + log('error', 'proxy fetch failed', { + requestId, + statusCode, + message: sanitizeErrorMessage(error), + target: safeTarget, + }) + if (res.headersSent) return res.end() + return res.status(statusCode).json({ + ok: false, + error: errorCode, + message: sanitizeErrorMessage(error), + requestId, + }) + } + } +} + +app.get('/proxy/fetch', createFetchRoute({ + route: 'proxy_fetch', + errorCode: 'proxy_fetch_failed', + buildHeaders: (req, _target, requestId) => { + const includeApiKey = parseBoolean(req.query?.includeApiKey, true) + const apiKey = includeApiKey ? pickApiKey(req) : '' + return { + accept: String(req.headers.accept || '*/*'), + 'user-agent': String(req.headers['user-agent'] || 'proxy-contur/0.1'), + ...(apiKey ? { 'x-kontur-apikey': apiKey } : {}), + 'x-request-id': requestId, + } + }, +})) + +app.get('/proxy/cesium/fetch', createFetchRoute({ + route: 'proxy_cesium_fetch', + errorCode: 'proxy_cesium_fetch_failed', + buildHeaders: mapEgressHeaders, + validateTarget: assertCesiumEgressTarget, + redactTarget: true, + allowRequestTuning: false, + omitContentLength: true, + fixedMaxBytes: MAX_CESIUM_FETCH_BYTES, +})) + +app.use((req, res) => { + res.status(404).json({ + ok: false, + error: 'not_found', + message: 'Route is not defined', + }) +}) + +app.use((error, req, res, _next) => { + const requestId = req?.requestId || '' + const statusCode = Number(error?.statusCode || 500) || 500 + log('error', 'unhandled error', { + requestId, + statusCode, + message: sanitizeErrorMessage(error, 'internal_error'), + }) + res.status(statusCode).json({ + ok: false, + error: 'internal_error', + message: sanitizeErrorMessage(error, 'internal_error'), + requestId, + }) +}) + +app.listen(PORT, () => { + log('info', 'proxy-contur started', { + port: PORT, + contourApiBaseUrl: CONTOUR_API_BASE_URL, + blockPrivateNetworks: BLOCK_PRIVATE_NETWORKS, + hasProxyToken: Boolean(PROXY_TOKEN), + hasContourApiKey: Boolean(CONTOUR_API_KEY), + logLevel: LOG_LEVEL, + debugFetch: DEBUG_FETCH, + }) +})