chore(proxy-contur): preserve legacy Contour compatibility service
This commit is contained in:
parent
7b55d887bc
commit
fedaf24098
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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"
|
||||||
|
```
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue