chore(proxy-contur): preserve legacy Contour compatibility service

This commit is contained in:
Codex 2026-07-16 02:24:06 +03:00
parent 7b55d887bc
commit fedaf24098
6 changed files with 1113 additions and 0 deletions

View File

@ -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 });
}

View File

@ -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"]

View File

@ -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=<encoded-url>`
6. `GET /proxy/cesium/fetch?url=<encoded-url>`
## Auth
Если задан `PROXY_TOKEN`, требуется:
1. `x-proxy-token: <token>`
или
2. `Authorization: Bearer <token>`
## 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"
```

View File

@ -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

View File

@ -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"
}
}

View File

@ -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, '<redacted>')
}
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,
})
})