feat(map): add cache-first Cesium gateway and AMD egress

This commit is contained in:
Codex 2026-07-16 02:23:56 +03:00
parent 59e9c92415
commit 7b55d887bc
31 changed files with 4956 additions and 167 deletions

View File

@ -0,0 +1,3 @@
# Stable LAN address of the AMD Windows host. This is not the OpenVPN address.
AMD_CONNECTOR_BIND_IP=172.22.0.183
AMD_CONNECTOR_PORT=8791

2
services/dc-amd-connector/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.env
runtime/

View File

@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
COPY server.mjs ./
USER node
EXPOSE 8791
CMD ["node", "server.mjs"]

View File

@ -0,0 +1,84 @@
# DC AMD Connector
DC AMD Connector runs on the adjacent AMD Windows machine. It is a restricted
HTTP `CONNECT` proxy for the NODE.DC map path:
```text
NAS DC AMD Proxy -> 172.22.0.183:8791 -> AMD VPN -> Cesium / Bing
```
It is not a general-purpose proxy:
- only authenticated `CONNECT` requests are accepted;
- only port `443` is accepted;
- only Cesium Ion and required Bing imagery hosts are accepted;
- ordinary HTTP proxy requests are rejected;
- the Ion bearer token stays inside the end-to-end TLS connection from NAS to
Cesium and is not processed or logged by this connector.
The Windows host owns the VPN. The NAS uses only the stable LAN address in
`.env`, never an OpenVPN adapter address or a changing public VPN exit IP.
## First run on the AMD machine
1. Start Docker Desktop and wait for its engine to be running.
2. Open **PowerShell as Administrator** and run `install.ps1` from this
package folder. It verifies source checksums, copies the package to
`C:\\NODEDC\\dc-amd-connector`, creates a local random access token without
printing it, binds port `8791` only to `172.22.0.183`, scopes Windows
Firewall to NAS `172.22.0.222`, constrains the container to one CPU, 256 MB,
64 processes and 4096 file descriptors, and starts the container. It also
adds a launcher to the current user's Windows Startup folder. At the next
user sign-in it uses the Docker Desktop CLI when available, otherwise its
installed application; the `unless-stopped` restart policy then restores
this container.
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\\install.ps1
```
3. Do not send the runtime secret in chat or put it into a source file. The
next NAS patch will provision the same value in a root-owned secret file
through an operator-only transfer.
Docker Desktop on the WSL 2 backend is a desktop application, not an
unattended Windows boot daemon. Thus this recovery guarantee starts after the
configured Windows user signs in. Before a sign-in, the map must use its
offline/tile-cache path. No Windows route, VPN, DNS, proxy setting or other
application's traffic is modified.
## Operations and migration
Run these commands from an elevated PowerShell window:
```powershell
cd C:\\NODEDC\\dc-amd-connector
docker compose ps
docker compose logs --tail 100
docker compose restart
```
`docker compose down` is an intentional stop and removes the container; restore
it with `docker compose up -d`. The auto-start launcher is removed by
`uninstall.ps1`.
To move to a different adjacent Windows host: install this same package there
with its stable LAN address (`-BindAddress`) and verify its VPN tunnel first.
It creates a fresh local access secret. Only then may the separate NAS pairing
patch be changed to the new host; never run two active pairings against the
same NAS endpoint.
## Verification
The connector health check uses the local runtime secret inside the container.
It is expected to become `healthy` without making any external request. The
following local check proves a permitted TLS tunnel through the current AMD VPN
without revealing the access token:
```powershell
docker exec dc-amd-connector node -e "const fs=require('fs'),net=require('net');const t=fs.readFileSync('/run/dc-amd-secrets/connector-access','utf8').trim();const s=net.connect(8791,'127.0.0.1',()=>s.write('CONNECT api.cesium.com:443 HTTP/1.1\r\nHost: api.cesium.com:443\r\nProxy-Authorization: Bearer '+t+'\r\n\r\n'));s.once('data',d=>{const v=d.toString('ascii');console.log(v.split('\r\n')[0]);s.destroy();process.exit(v.startsWith('HTTP/1.1 200')?0:1)});s.on('error',e=>{console.error(e.message);process.exit(1)})"
```
Expected output is `HTTP/1.1 200 Connection Established`. If the AMD VPN is
off, this test must fail; it must never silently move live Cesium traffic to
the NAS.

View File

@ -0,0 +1,10 @@
36283c8f926cd5a2fdfec0adda20e1d30fefb47e8fb6f4c6a41ebdfdb8034357 ./.env.example
7e4d35564197224edd4899aae9525c5b3c0c904371bb2e635dcef7950bc776cd ./.gitignore
3d7464d3a7b97b3b9be1036dcf77a9fd31cd841043a64069870e001a7f526a7b ./Dockerfile
c967a21fbb804a61e467fd207702f328ccc5ed933beaf98a476490f033f6dfa4 ./README.md
08708bd47ddf85dadda552eeb2ac1bc8f8d1e5d119305ef41bb0327b4dbac36b ./VERSION
b00bbf2ec6b56d834b04543d4e65bda7eff97296d4d255df48c28d71369a3f0e ./docker-compose.yml
608e3c75b69567376019b6f9fdf274a5cf9afecdde07348b6f02c89b688e672a ./install.ps1
33d4c94fdec3b47bba25039306423dd9dea50d4b36014a5a34768e0a6a7ba174 ./package.json
e08d0f33b4749289a5adf897b8a4ef876d3ebad9aa377d992e9d33b1060fe698 ./server.mjs
f9bb0a9d862e9f78fd533eeb2a0750108392d73f6173aec2ccc8053120638e01 ./uninstall.ps1

View File

@ -0,0 +1 @@
dc-amd-connector-20260715-004

View File

@ -0,0 +1,47 @@
services:
dc-amd-connector:
build:
context: .
dockerfile: Dockerfile
container_name: dc-amd-connector
image: nodedc/dc-amd-connector:local
restart: unless-stopped
# This is a byte-forwarding gateway, not a map renderer. These are circuit
# breakers for the shared workstation, not normal throughput limits.
cpus: "1.00"
mem_limit: 256m
pids_limit: 64
ulimits:
nofile:
soft: 4096
hard: 4096
env_file:
- .env
environment:
PORT: "8791"
AMD_CONNECTOR_ACCESS_TOKEN_FILE: /run/dc-amd-secrets/connector-access
# Bind only to the stable LAN address of the AMD host, never all Windows
# interfaces and never the OpenVPN adapter address.
ports:
- "${AMD_CONNECTOR_BIND_IP:?set AMD_CONNECTOR_BIND_IP}:${AMD_CONNECTOR_PORT:-8791}:8791"
volumes:
- type: bind
source: ./runtime/connector-access
target: /run/dc-amd-secrets/connector-access
read_only: true
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=8m
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test:
- CMD-SHELL
- >
node -e "const fs=require('fs'),http=require('http');const t=fs.readFileSync('/run/dc-amd-secrets/connector-access','utf8').trim();const r=http.get({host:'127.0.0.1',port:8791,path:'/healthz',headers:{'proxy-authorization':'Bearer '+t}},x=>process.exit(x.statusCode===200?0:1));r.on('error',()=>process.exit(1))"
interval: 30s
timeout: 5s
retries: 3
start_period: 10s

View File

@ -0,0 +1,170 @@
[CmdletBinding()]
param(
[string]$Destination = 'C:\NODEDC\dc-amd-connector',
[string]$NasAddress = '172.22.0.222',
[string]$BindAddress = '172.22.0.183',
[int]$Port = 8791,
[bool]$EnableAutoStart = $true
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Require-Administrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Run this installer from an elevated PowerShell window.'
}
}
function Test-PackageChecksums([string]$Root) {
$manifest = Join-Path $Root 'SHA256SUMS'
if (-not (Test-Path -LiteralPath $manifest -PathType Leaf)) {
throw "Checksum manifest not found: $manifest"
}
foreach ($line in Get-Content -LiteralPath $manifest) {
if ([string]::IsNullOrWhiteSpace($line)) { continue }
$parts = $line -split '\s{2,}', 2
if ($parts.Count -ne 2 -or $parts[0] -notmatch '^[a-f0-9]{64}$') {
throw "Invalid checksum line: $line"
}
$relative = $parts[1] -replace '^\./', ''
$file = Join-Path $Root $relative
$actual = (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actual -ne $parts[0]) { throw "Checksum mismatch: $relative" }
}
}
function Get-DockerDesktopStartCommand([string]$DockerCliPath) {
& $DockerCliPath desktop start --help 2>$null | Out-Null
if ($LASTEXITCODE -eq 0) {
return "`"$DockerCliPath`" desktop start --detach"
}
$dockerRoot = Split-Path (Split-Path (Split-Path $DockerCliPath -Parent) -Parent) -Parent
$candidates = @(
(Join-Path $dockerRoot 'Docker Desktop.exe'),
(Join-Path $env:ProgramFiles 'Docker\Docker\Docker Desktop.exe'),
(Join-Path $env:LOCALAPPDATA 'Programs\Docker\Docker\Docker Desktop.exe')
) | Select-Object -Unique
$desktopExe = $candidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1
if (-not $desktopExe) {
throw 'Unable to find Docker Desktop start command or Docker Desktop.exe for restart recovery.'
}
return "start `"`" `"$desktopExe`""
}
function New-DockerDesktopAutoStart([string]$StartCommand) {
$startup = [Environment]::GetFolderPath([Environment+SpecialFolder]::Startup)
if ([string]::IsNullOrWhiteSpace($startup)) { throw 'Unable to resolve the current user Startup folder.' }
$launcher = Join-Path $startup 'NODE.DC DC AMD Connector - Docker Desktop.cmd'
if (Test-Path -LiteralPath $launcher) {
throw "Docker Desktop auto-start launcher already exists: $launcher"
}
@(
'@echo off',
'rem NODE.DC DC AMD Connector - starts Docker Desktop after this user signs in.',
$StartCommand
) | Set-Content -LiteralPath $launcher -Encoding ascii
return $launcher
}
Require-Administrator
$source = Split-Path -Parent $PSCommandPath
if (-not (Test-Path -LiteralPath (Join-Path $source 'VERSION') -PathType Leaf)) {
throw 'Run install.ps1 from the DC AMD Connector package folder.'
}
Test-PackageChecksums $source
if (Test-Path -LiteralPath $Destination) {
throw "Destination already exists; refusing to overwrite: $Destination"
}
$null = [Net.IPAddress]::Parse($NasAddress)
$null = [Net.IPAddress]::Parse($BindAddress)
if ($Port -lt 1 -or $Port -gt 65535) {
throw "Port must be between 1 and 65535: $Port"
}
$dockerCli = (Get-Command docker -CommandType Application -ErrorAction Stop | Select-Object -First 1).Path
if ([string]::IsNullOrWhiteSpace($dockerCli) -or -not (Test-Path -LiteralPath $dockerCli -PathType Leaf)) {
throw 'Docker CLI executable could not be resolved.'
}
& $dockerCli version --format '{{.Server.Os}}/{{.Server.Arch}} {{.Server.Version}}' | Out-Null
if ($LASTEXITCODE -ne 0) {
throw 'Docker Desktop engine is not available. Start it and wait for Engine running.'
}
$dockerDesktopStartCommand = Get-DockerDesktopStartCommand $dockerCli
$destinationCreated = $false
$firewallCreated = $false
$autoStartLauncher = $null
$parent = Split-Path -Parent $Destination
try {
New-Item -ItemType Directory -Force $parent | Out-Null
Copy-Item -LiteralPath $source -Destination $Destination -Recurse
$destinationCreated = $true
Test-PackageChecksums $Destination
Set-Location $Destination
New-Item -ItemType Directory -Force runtime | Out-Null
$tokenPath = Join-Path $Destination 'runtime\connector-access'
if (Test-Path -LiteralPath $tokenPath) { throw "Runtime token already exists: $tokenPath" }
$bytes = New-Object byte[] 48
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
try { $rng.GetBytes($bytes) } finally { $rng.Dispose() }
$token = [Convert]::ToBase64String($bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')
[IO.File]::WriteAllText($tokenPath, $token, [Text.UTF8Encoding]::new($false))
Copy-Item -LiteralPath (Join-Path $Destination '.env.example') -Destination (Join-Path $Destination '.env')
@(
"AMD_CONNECTOR_BIND_IP=$BindAddress",
"AMD_CONNECTOR_PORT=$Port"
) | Set-Content -LiteralPath (Join-Path $Destination '.env') -Encoding ascii
$ruleName = 'NODE.DC DC AMD Connector (NAS only)'
if (Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue) {
throw "Firewall rule already exists; refusing to replace: $ruleName"
}
New-NetFirewallRule -DisplayName $ruleName -Direction Inbound -Action Allow -Protocol TCP -Profile Any -LocalAddress $BindAddress -LocalPort $Port -RemoteAddress $NasAddress | Out-Null
$firewallCreated = $true
if ($EnableAutoStart) {
$autoStartLauncher = New-DockerDesktopAutoStart $dockerDesktopStartCommand
}
& $dockerCli compose up -d --build
$state = ''
foreach ($attempt in 1..30) {
$state = & $dockerCli inspect dc-amd-connector --format '{{.State.Status}} health={{if .State.Health}}{{.State.Health.Status}}{{end}}'
if ($LASTEXITCODE -ne 0) { throw 'Container inspection failed after Docker Compose apply.' }
if ($state -eq 'running health=healthy') { break }
Start-Sleep -Seconds 2
}
if ($state -ne 'running health=healthy') {
throw "Connector did not become healthy: $state"
}
Write-Host "DC AMD Connector installed: $state"
Write-Host "Bound to $BindAddress`:$Port; inbound firewall scope is NAS $NasAddress."
if ($autoStartLauncher) {
Write-Host "Docker Desktop will start at the next sign-in through: $autoStartLauncher"
}
Write-Host 'The connector access token remains only in runtime\connector-access. Do not print or send it in chat.'
}
catch {
if ($autoStartLauncher -and (Test-Path -LiteralPath $autoStartLauncher)) {
Remove-Item -LiteralPath $autoStartLauncher -Force -ErrorAction SilentlyContinue
}
if ($firewallCreated) {
Remove-NetFirewallRule -DisplayName 'NODE.DC DC AMD Connector (NAS only)' -ErrorAction SilentlyContinue
}
if ($destinationCreated -and (Test-Path -LiteralPath $Destination)) {
Remove-Item -LiteralPath $Destination -Recurse -Force -ErrorAction SilentlyContinue
}
throw
}

View File

@ -0,0 +1,9 @@
{
"name": "dc-amd-connector",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "node server.mjs"
}
}

View File

@ -0,0 +1,130 @@
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { connect } from "node:net";
import { timingSafeEqual } from "node:crypto";
const allowedHosts = 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 config = await readConfig();
const server = createServer((request, response) => {
if (!isAuthorized(request.headers["proxy-authorization"])) return writeJson(response, 401, { ok: false, error: "connector_unauthorized" }, { "proxy-authenticate": "Bearer" });
if (request.method === "GET" && request.url === "/healthz") return writeJson(response, 200, { ok: true, service: "dc-amd-connector", mode: "restricted-connect", allowedHosts: allowedHosts.size });
return writeJson(response, 405, { ok: false, error: "connect_only" });
});
server.on("connect", (request, clientSocket, head) => {
if (!isAuthorized(request.headers["proxy-authorization"])) return rejectTunnel(clientSocket, 407, "Proxy Authentication Required", { "Proxy-Authenticate": "Bearer" });
let target;
try {
target = parseConnectTarget(request.url || "");
} catch (error) {
log("warn", "connect_rejected", { reason: error.message });
return rejectTunnel(clientSocket, 403, "Forbidden");
}
const upstream = connect({ host: target.host, port: target.port });
let settled = false;
const timeout = setTimeout(() => upstream.destroy(new Error("upstream_connect_timeout")), config.connectTimeoutMs);
clientSocket.setTimeout(config.idleTimeoutMs, () => clientSocket.destroy());
upstream.setTimeout(config.idleTimeoutMs, () => upstream.destroy());
upstream.once("connect", () => {
settled = true;
clearTimeout(timeout);
clientSocket.write("HTTP/1.1 200 Connection Established\r\nProxy-Agent: dc-amd-connector\r\n\r\n");
if (head?.length) upstream.write(head);
clientSocket.pipe(upstream);
upstream.pipe(clientSocket);
log("info", "connect_established", { host: target.host, port: target.port });
});
upstream.once("error", (error) => {
clearTimeout(timeout);
if (!settled) {
log("warn", "connect_failed", { host: target.host, port: target.port, reason: sanitizeError(error) });
rejectTunnel(clientSocket, 502, "Bad Gateway");
}
});
clientSocket.once("error", () => upstream.destroy());
clientSocket.once("close", () => upstream.destroy());
upstream.once("close", () => clientSocket.destroy());
});
server.on("clientError", (_error, socket) => rejectTunnel(socket, 400, "Bad Request"));
server.listen(config.port, "0.0.0.0", () => log("info", "connector_started", { port: config.port, allowedHosts: allowedHosts.size }));
for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, () => server.close(() => process.exit(0)));
async function readConfig() {
const port = parsePort(process.env.PORT, 8791);
const tokenFile = String(process.env.AMD_CONNECTOR_ACCESS_TOKEN_FILE || "/run/dc-amd-secrets/connector-access").trim();
const token = String(await readFile(tokenFile, "utf8")).trim();
if (!/^[A-Za-z0-9_-]{48,256}$/.test(token)) throw new Error("connector_access_token_invalid");
return {
port,
token: Buffer.from(token, "utf8"),
connectTimeoutMs: parseDuration(process.env.AMD_CONNECTOR_CONNECT_TIMEOUT_SECONDS, 20),
idleTimeoutMs: parseDuration(process.env.AMD_CONNECTOR_IDLE_TIMEOUT_SECONDS, 90),
};
}
function parsePort(raw, fallback) {
const value = Number(String(raw || fallback).trim());
if (!Number.isInteger(value) || value < 1024 || value > 65535) throw new Error("connector_port_invalid");
return value;
}
function parseDuration(raw, fallbackSeconds) {
const seconds = Number(String(raw || fallbackSeconds).trim());
if (!Number.isInteger(seconds) || seconds < 1 || seconds > 600) throw new Error("connector_timeout_invalid");
return seconds * 1000;
}
function isAuthorized(rawHeader) {
const match = /^Bearer\s+([A-Za-z0-9_-]{48,256})$/i.exec(String(rawHeader || "").trim());
if (!match) return false;
const candidate = Buffer.from(match[1], "utf8");
return candidate.length === config.token.length && timingSafeEqual(candidate, config.token);
}
function parseConnectTarget(raw) {
const match = /^([A-Za-z0-9.-]{1,253}):(443)$/.exec(String(raw || "").trim());
if (!match) throw new Error("connect_target_invalid");
const host = match[1].toLowerCase();
if (!allowedHosts.has(host)) throw new Error("connect_target_not_allowed");
return { host, port: Number(match[2]) };
}
function rejectTunnel(socket, status, message, headers = {}) {
if (!socket || socket.destroyed) return;
const extra = Object.entries(headers).map(([name, value]) => `${name}: ${value}\r\n`).join("");
socket.end(`HTTP/1.1 ${status} ${message}\r\n${extra}Connection: close\r\nContent-Length: 0\r\n\r\n`);
}
function writeJson(response, status, body, headers = {}) {
const payload = JSON.stringify(body);
response.writeHead(status, {
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(payload),
"cache-control": "no-store",
...headers,
});
response.end(payload);
}
function sanitizeError(error) {
const message = String(error?.message || "connector_error");
return message.replace(/[^A-Za-z0-9_.:-]/g, "_").slice(0, 120);
}
function log(level, event, fields = {}) {
console.log(JSON.stringify({ ts: new Date().toISOString(), level, event, ...fields }));
}

View File

@ -0,0 +1,30 @@
[CmdletBinding()]
param(
[string]$Destination = 'C:\NODEDC\dc-amd-connector'
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Require-Administrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Run this uninstaller from an elevated PowerShell window.'
}
}
Require-Administrator
$docker = Get-Command docker -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
if ($docker -and (Test-Path -LiteralPath (Join-Path $Destination 'docker-compose.yml'))) {
Push-Location $Destination
try { & $docker.Path compose down --remove-orphans } finally { Pop-Location }
}
Remove-NetFirewallRule -DisplayName 'NODE.DC DC AMD Connector (NAS only)' -ErrorAction SilentlyContinue
$startup = [Environment]::GetFolderPath([Environment+SpecialFolder]::Startup)
$launcher = Join-Path $startup 'NODE.DC DC AMD Connector - Docker Desktop.cmd'
Remove-Item -LiteralPath $launcher -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $Destination -Recurse -Force -ErrorAction SilentlyContinue
Write-Host 'DC AMD Connector removed. Docker Desktop and unrelated containers were not changed.'

View File

@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
COPY server.mjs ./
USER node
EXPOSE 8790
CMD ["node", "server.mjs"]

View File

@ -0,0 +1,770 @@
# DC AMD Proxy — production operations and recovery canon
This document is the reproducible operations contract for the restricted
Cesium/Bing egress path through the neighbouring Windows AMD workstation. It
describes the implementation that is in source now. It is not a proposal for
turning the NAS or the workstation into a general-purpose proxy.
The two non-negotiable boundaries are:
1. the NAS keeps its own routes, DNS, VPN, Tailscale and Internet connectivity
unchanged;
2. only approved Cesium/Bing HTTPS traffic may leave through the AMD host.
Do not solve an incident by adding a system proxy, default route, transparent
NAT, browser-visible token, permissive CONNECT endpoint, `0.0.0.0` Windows
bind, or direct NAS provider fallback.
## 1. Canonical architecture
```text
Browser
-> Foundry BFF
-> Platform Map Gateway
|-- warm object: NAS live/offline cache -> browser
`-- cold/refresh object:
HTTP + x-proxy-token
-> NAS <NAS_LAN_IP>:<NAS_PROXY_PORT> dc-amd-proxy
-> authenticated HTTP CONNECT
-> AMD <AMD_LAN_IP>:<CONNECTOR_PORT> dc-amd-connector
-> AMD/Docker DNS
-> Windows VPN/default egress route
-> approved Cesium/Bing host:443
<- end-to-end provider TLS is terminated by dc-amd-proxy
<- streamed provider response
-> browser and, when enabled, atomic NAS cache fill
```
The AMD connector is a byte-forwarding CONNECT boundary. It does not parse an
Ion bearer and cannot read the provider TLS stream. `dc-amd-proxy` establishes
TLS through that tunnel, validates the provider certificate and SNI, follows
only approved redirects, and returns the response to Map Gateway.
This distinction is operationally important:
- a **warm cache hit does not traverse AMD, VPN or Cesium**;
- a **cold miss or refresh does traverse AMD and depends on the workstation's
Docker Desktop, DNS, routing and VPN**;
- objects already cached on NAS must remain usable when AMD or its VPN is
unavailable;
- `dc-amd-proxy` is not in the browser-to-NAS cache read path.
### Current components and ownership
| Component | Runtime | Canonical state | Responsibility |
| --- | --- | --- | --- |
| Map Gateway | Synology Docker | `/volume1/docker/nodedc-platform/platform/services/map-gateway` | Provider credential scope, cache-first reads, streaming cache fill and private egress calls |
| `dc-amd-proxy` | Synology Docker | `/volume1/docker/dc-amd-proxy` | Request authorization, target allowlist, CONNECT/TLS pool, redirect/retry policy and telemetry |
| Proxy pair state | Synology bind mount | `/volume1/docker/dc-amd-proxy/runtime/connector-access` | Paired connector credential; persistent across container recreation |
| Map egress credential | Synology root-owned file | `/volume1/docker/nodedc-platform/secrets/map-egress-proxy-token` | Authenticates Map Gateway to `dc-amd-proxy` |
| `dc-amd-connector` | AMD Windows Docker Desktop | `C:\NODEDC\dc-amd-connector` by default | Restricted authenticated CONNECT to approved hosts on port 443 |
| Connector credential | AMD local file | `C:\NODEDC\dc-amd-connector\runtime\connector-access` | Generated locally once; never printed or stored in source |
| Docker Desktop launcher | Current Windows user's Startup folder | `NODE.DC DC AMD Connector - Docker Desktop.cmd` | Starts Docker Desktop after that user signs in |
Current source defaults are NAS listener port `8790`, AMD connector port
`8791`, eight active sockets and four idle sockets per origin. IP addresses
are deployment values and must be treated as configuration, not universal
constants.
## 2. Trust, secrets and traffic boundaries
There are three different credentials. They are not interchangeable.
1. The Cesium Ion credential belongs to Map Gateway provider configuration.
It must not be placed in Foundry browser code, an artifact or this proxy's
Compose file.
2. The Map egress credential authenticates Map Gateway to `dc-amd-proxy` using
`x-proxy-token`. The canonical runner synchronizes it into the root-owned
file mounted read-only into the two services.
3. The connector credential authenticates `dc-amd-proxy` to the Windows
connector using `Proxy-Authorization: Bearer ...`. The Windows installer
generates it locally; the one-time pair request stores the same value in
the NAS runtime bind mount.
No command in this runbook prints, copies to chat, or accepts a secret as a
command-line argument.
### Current runner dependency that must not be mistaken for a traffic route
The deploy runner currently seeds the shared Map egress credential from the
root-owned `PROXY_TOKEN` in `/volume1/docker/proxy-contur/.env`. Therefore a
valid, safely permissioned historical `proxy-contur` environment file is a
**deploy-time precondition** for `dc-amd-proxy` and Map Gateway applies.
This does **not** mean Cesium traffic passes through `proxy-contur`. Runtime
traffic goes directly from Map Gateway to `dc-amd-proxy`, then to the AMD
connector. Removing this legacy secret-seeding dependency is an explicit
evolution item later in this document.
### NAS network boundary
`dc-amd-proxy` uses `network_mode: host` so the one-time pair endpoint can see
the real AMD source address. Synology's published-port bridge path would hide
that source behind Docker NAT. Host mode does not make the service broad:
- the process binds only `<NAS_LAN_IP>:<NAS_PROXY_PORT>`;
- `/api/pair` accepts only `<AMD_LAN_IP>` and only while unpaired;
- `/proxy/cesium/fetch` accepts only `GET`/`HEAD`, requires the private Map
egress credential and validates the complete target URL;
- `/healthz` and `/status` expose safe state/counters but no credentials or
target URLs;
- the container is read-only, drops all capabilities, enables
`no-new-privileges`, and has only a small no-exec tmpfs plus the two explicit
bind mounts.
The current Map Gateway URL is a NAS-LAN address, not a public hostname and
not a Docker service discovery name.
### Windows network boundary
The connector publishes only `<AMD_LAN_IP>:<CONNECTOR_PORT>`. The installer
creates one inbound Windows Firewall rule with all of these restrictions:
- TCP only;
- local address `<AMD_LAN_IP>`;
- local port `<CONNECTOR_PORT>`;
- remote address `<NAS_LAN_IP>`;
- no bind to the VPN adapter and no bind to all interfaces.
The connector accepts only authenticated `CONNECT <allowed-host>:443`.
Ordinary forward-proxy HTTP methods, other ports, IP literals and arbitrary
hosts are rejected. Its container is read-only, drops capabilities and is
bounded to one CPU, 256 MB memory, 64 processes and 4096 file descriptors.
These are circuit breakers for a shared production workstation, not bandwidth
throttles.
The installer does not change the Windows default route, DNS, system proxy,
VPN configuration or unrelated firewall rules. The connector's DNS lookup and
TCP connection occur inside Docker Desktop on the AMD host and therefore use
the egress path exposed to Docker by Windows/VPN.
### Exact provider allowlist
Both NAS proxy and Windows connector currently allow only:
- `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`
Adding a provider hostname requires one reviewed change to both allowlists,
updated smoke coverage, a new Windows connector package, a new NAS app-overlay
artifact and the normal plan/apply acceptance. Never add a wildcard such as
`*.cesium.com` or `*.virtualearth.net`.
## 3. Request lifecycle
For a cache miss, the sequence is:
1. Map Gateway checks the NAS cache before acquiring provider credentials or
contacting egress.
2. Map Gateway calls
`http://<NAS_LAN_IP>:<NAS_PROXY_PORT>/proxy/cesium/fetch?url=<encoded-url>`
and authenticates with the private Map egress credential.
3. `dc-amd-proxy` validates method, URL scheme, host, port and credentials.
4. A per-origin HTTP/1.1 keep-alive agent either reuses an existing TLS tunnel,
opens a new one, or queues behind the bounded active-socket limit.
5. For a new connection, the NAS opens an authenticated CONNECT request to
`<AMD_LAN_IP>:<CONNECTOR_PORT>`.
6. The connector validates its credential, exact hostname and port 443, then
resolves/connects through the AMD network/VPN.
7. The NAS completes TLS over the byte tunnel and validates the provider
certificate.
8. Response headers and body stream back to Map Gateway. Client cancellation
propagates through queue, CONNECT, TLS, header wait, redirect drain, retry
and response streaming.
9. Map Gateway can stream the first bytes to the caller while its independent
branch completes an atomic cache fill. A failed/partial fill must not become
a valid cache object.
Provider headers forwarded upstream are deliberately narrow: `Accept`,
`Range`, `If-None-Match`, `If-Modified-Since`, scoped provider authorization
and the Map referer. `Accept-Encoding` is set to `identity`. Hop-by-hop headers
are not forwarded back downstream.
### Credential scope across redirects
- same-origin redirects retain the provider `Authorization` header;
- cross-origin redirects remove `Authorization` before the next request;
- every redirect target is parsed again through the exact HTTPS/443/host
allowlist;
- redirect bodies are drained with abort and idle-timeout protection;
- the chain is bounded and terminates with
`amd_upstream_redirect_limit` rather than looping indefinitely.
This is why the Ion API bearer cannot leak when the API redirects to the
assets or Bing origin.
### Pool, retry and timeout policy
| Control | Current default | Meaning |
| --- | ---: | --- |
| Active sockets | 8 per origin | Maximum concurrent provider tunnels for each approved origin |
| Idle sockets | 4 per origin | Reusable warm TLS tunnels retained for each origin |
| TCP keep-alive initial delay | 30 seconds | Socket keep-alive probe setting; actual idle lifetime is also controlled by provider/server socket closure |
| Connector/TLS timeout | 20 seconds | Bounds NAS-to-AMD CONNECT and provider TLS setup |
| Upstream header timeout | 30 seconds | Bounds one attempt until provider response headers |
| Response/redirect body idle timeout | 30 seconds | Resets on body progress; does not cap a healthy long stream |
| Slow request threshold | 2 seconds | Emits safe slow-request telemetry; does not cancel the request |
| Windows connector upstream connect timeout | 20 seconds | Bounds AMD-to-provider TCP connect |
| Windows connector tunnel idle timeout | 90 seconds | Closes a tunnel with no traffic |
The NAS duration values accept 1120 seconds and pool sizes accept 132. Do
not increase them to hide a route/VPN problem: first interpret queue,
connection, TTFB and body timings separately.
There is at most one transport retry. It is allowed only when all conditions
are true:
- method is idempotent `GET` or `HEAD`;
- the failed attempt used a previously reused keep-alive socket;
- failure is `ECONNRESET`, `EPIPE`, `ETIMEDOUT` or `ECONNABORTED`;
- the request has not already retried and the client has not aborted.
A failure on a newly opened socket is not blindly replayed. A retry may open a
replacement socket after the stale reused socket is discarded. Both attempts
are included in telemetry and terminal accounting.
### Runtime configuration reference
All changes to these values require a reviewed Compose/source artifact; do not
inject an ad-hoc live container environment.
| NAS proxy variable | Default | Contract |
| --- | --- | --- |
| `PORT` | `8790` | Internal/listener port |
| `DC_AMD_PROXY_BIND_ADDRESS` | deployment NAS LAN IP | Exact listener address; IPv4 only |
| `DC_AMD_CONNECTOR_HOST` | deployment AMD LAN IP | Stable connector LAN address; IPv4 only |
| `DC_AMD_CONNECTOR_PORT` | `8791` | Windows published connector port |
| `DC_AMD_PAIR_ALLOWED_SOURCE` | deployment AMD LAN IP | Only source allowed to perform first pair |
| `DC_AMD_CONNECTOR_TOKEN_FILE` | `/var/lib/dc-amd-proxy/connector-access` | Persistent paired connector state |
| `DC_AMD_MAP_EGRESS_TOKEN_FILE` | `/run/nodedc-secrets/map-egress-proxy-token` | Read-only Map Gateway authentication file |
| `DC_AMD_CONNECT_TIMEOUT_SECONDS` | `20` | CONNECT and TLS setup timeout |
| `DC_AMD_UPSTREAM_TIMEOUT_SECONDS` | `30` | Per-attempt response-header timeout |
| `DC_AMD_BODY_IDLE_TIMEOUT_SECONDS` | `30` | Progress-based response/redirect body timeout |
| `DC_AMD_POOL_MAX_SOCKETS` | `8` | Active sockets per origin |
| `DC_AMD_POOL_MAX_FREE_SOCKETS` | `4` | Idle sockets per origin |
| `DC_AMD_SLOW_REQUEST_SECONDS` | `2` | Safe slow-event threshold |
The Windows Compose `.env` contains only `AMD_CONNECTOR_BIND_IP` and
`AMD_CONNECTOR_PORT`. The connector server also supports bounded
`AMD_CONNECTOR_CONNECT_TIMEOUT_SECONDS` (default 20) and
`AMD_CONNECTOR_IDLE_TIMEOUT_SECONDS` (default 90); changing them should be a
versioned connector-package change.
## 4. Reproducible initial installation
Replace every angle-bracket placeholder before executing a command. Do not
paste a credential into any placeholder.
### 4.1 Preconditions
- AMD workstation has a stable LAN address reserved in DHCP or statically
configured.
- NAS and AMD can reach each other on the trusted LAN.
- Docker Desktop is installed and its Linux engine is running.
- The intended Windows user can sign in after a reboot.
- The VPN is connected and exposes its route to Docker Desktop workloads.
- The reviewed Windows connector package contains a valid `SHA256SUMS`.
- The canonical NAS runner is installed at
`/usr/local/sbin/nodedc-deploy`.
- No other host is using the selected connector LAN address/port.
### 4.2 Install the Windows connector
Extract the reviewed connector package to a temporary local folder. Open
PowerShell **as Administrator**, enter that folder and run:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\install.ps1" `
-Destination "C:\NODEDC\dc-amd-connector" `
-NasAddress "<NAS_LAN_IP>" `
-BindAddress "<AMD_LAN_IP>" `
-Port <CONNECTOR_PORT> `
-EnableAutoStart $true
```
The installer is fail-closed. It verifies `SHA256SUMS`, refuses to overwrite an
existing destination or firewall rule, generates a random connector credential
without printing it, builds/starts the container, waits for `healthy`, creates
the NAS-only firewall rule and writes the current-user Docker Desktop Startup
launcher. On failure it removes only the state it created.
Verify locally without displaying the credential:
```powershell
Set-Location "C:\NODEDC\dc-amd-connector"
docker compose ps
docker inspect dc-amd-connector --format '{{.State.Status}} health={{if .State.Health}}{{.State.Health.Status}}{{end}} restart={{.HostConfig.RestartPolicy.Name}}'
docker compose logs --tail 100
```
Expected state is `running health=healthy` and restart policy
`unless-stopped`.
This safe CONNECT probe reads the credential only inside the container and
prints only the HTTP status line:
```powershell
docker exec dc-amd-connector node -e "const fs=require('fs'),net=require('net');const t=fs.readFileSync('/run/dc-amd-secrets/connector-access','utf8').trim();const s=net.connect(8791,'127.0.0.1',()=>s.write('CONNECT api.cesium.com:443 HTTP/1.1\r\nHost: api.cesium.com:443\r\nProxy-Authorization: Bearer '+t+'\r\n\r\n'));s.once('data',d=>{const v=d.toString('ascii');console.log(v.split('\r\n')[0]);s.destroy();process.exit(v.startsWith('HTTP/1.1 200')?0:1)});s.on('error',e=>{console.error(e.message);process.exit(1)})"
```
Expected output is `HTTP/1.1 200 Connection Established`. This proves that the
connector can currently reach the API host; it does not validate an Ion token
or prove the VPN exit country.
### 4.3 Build and transfer the NAS artifact
From a clean `platform` repository checkout on the trusted build Mac:
```bash
node infra/deploy-runner/build-dc-amd-proxy-artifact.mjs \
dc-amd-proxy-<CHANGE_SLUG>-<YYYYMMDD>-<NNN>
cd infra/deploy-artifacts
shasum -a 256 nodedc-dc-amd-proxy-<CHANGE_SLUG>-<YYYYMMDD>-<NNN>.tgz \
> nodedc-dc-amd-proxy-<CHANGE_SLUG>-<YYYYMMDD>-<NNN>.tgz.sha256
```
Copy exactly the `.tgz` and `.tgz.sha256` through SMB into:
```text
/volume1/docker/nodedc-deploy/inbox
```
The builder includes only `Dockerfile`, `README.md`, `docker-compose.yml`,
`package.json` and `server.mjs`. Runtime, `.env`, credentials, logs, Windows
packages and this operations document are not deploy payload members.
### 4.4 Canonical NAS plan/apply
On Synology, from the canonical inbox:
```bash
cd /volume1/docker/nodedc-deploy/inbox
sha256sum -c \
nodedc-dc-amd-proxy-<CHANGE_SLUG>-<YYYYMMDD>-<NNN>.tgz.sha256
sudo /usr/local/sbin/nodedc-deploy verify-install
sudo /usr/local/sbin/nodedc-deploy plan \
/volume1/docker/nodedc-deploy/inbox/nodedc-dc-amd-proxy-<CHANGE_SLUG>-<YYYYMMDD>-<NNN>.tgz
```
Accept the plan only when all are true:
- `component=dc-amd-proxy`
- `type=app-overlay`
- `payload_root=/volume1/docker/dc-amd-proxy`
- `services=dc-amd-proxy`
- `runtime_secret=runner-synced:.../map-egress-proxy-token`
- `runtime_state=runner-prepared:/volume1/docker/dc-amd-proxy/runtime`
- `state=new`
- the file list is exactly the five allowlisted source files.
Then apply the exact planned filename; never use “latest” discovery:
```bash
sudo /usr/local/sbin/nodedc-deploy apply \
/volume1/docker/nodedc-deploy/inbox/nodedc-dc-amd-proxy-<CHANGE_SLUG>-<YYYYMMDD>-<NNN>.tgz
```
The runner preserves/prepares the private pair-state directory, synchronizes
the Map egress credential, creates a source backup, builds with Compose,
force-recreates only `dc-amd-proxy`, waits for container health, moves the
artifact to `applied`, and records the result. A new service should report
`state=awaiting_pair`.
### 4.5 Pair Windows to NAS
Pair only after NAS status says `awaiting_pair`. Place the reviewed pairing
tool on the AMD machine and run it from an elevated PowerShell window:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass `
-File ".\pair-dc-amd-proxy.ps1" `
-NasAddress "<NAS_LAN_IP>" `
-Port <NAS_PROXY_PORT>
```
Expected output:
```text
AMD connector paired with NAS. No secret value was displayed.
```
The pair call is a one-time HTTP LAN handoff with the Windows proxy explicitly
disabled. Its security boundary is the trusted LAN plus exact AMD source IP.
After the first successful pair, later attempts return `409 already_paired`.
Verify on NAS:
```bash
curl -fsS http://<NAS_LAN_IP>:<NAS_PROXY_PORT>/status
sudo /usr/local/bin/docker inspect dc-amd-proxy \
--format 'status={{.State.Status}} health={{if .State.Health}}{{.State.Health.Status}}{{end}} network={{.HostConfig.NetworkMode}} read_only={{.HostConfig.ReadonlyRootfs}} restart={{.HostConfig.RestartPolicy.Name}}'
```
Expected properties are `state=paired`, `forwarding=amd_connector_only`,
`directEgress=false`, container `healthy`, network `host`, read-only `true`,
and restart `unless-stopped`.
Finally verify one real Map Gateway request using the product health workflow.
Do not construct a shell command that reads or prints either private runtime
credential.
For a first installation, switch Map Gateway to
`http://<NAS_LAN_IP>:<NAS_PROXY_PORT>` only through a separate reviewed
Platform artifact and only after the proxy is paired and its tunnel test
passes. This separation keeps a failed connector bootstrap from breaking an
already-working provider route.
## 5. Restart and reboot behavior
### Synology
The NAS container has `restart: unless-stopped`. A Docker daemon or NAS reboot
restarts it. Pair state survives because `./runtime` is a bind mount outside
the container. The Map egress credential is a separate root-owned bind mount.
If an operator intentionally runs `docker compose down`, the container is
removed and restart policy cannot recreate it. Restore it from the canonical
source directory:
```bash
cd /volume1/docker/dc-amd-proxy
sudo /usr/local/bin/docker compose -p dc-amd-proxy up -d --build --no-deps dc-amd-proxy
```
Use that command only for recovery of the already-deployed version. Source
changes still require a runner artifact.
### Windows AMD host
Docker Desktop on the WSL 2 backend is not guaranteed to be an unattended
Windows boot service. The installed launcher starts it when the configured
Windows user signs in. After Docker Engine starts, `restart: unless-stopped`
restores the existing connector container.
Therefore the actual recovery sequence is:
1. Windows boots;
2. the configured user signs in;
3. Startup launches Docker Desktop;
4. Docker Engine becomes ready;
5. the existing connector container restarts;
6. the VPN must be connected and usable from Docker;
7. cold Cesium requests recover.
Before user sign-in, or while Docker/VPN is unavailable, live misses fail and
Map Gateway must rely on existing NAS cache. If the connector container was
removed with `docker compose down`, restart policy alone cannot recreate it;
run from elevated PowerShell:
```powershell
Set-Location "C:\NODEDC\dc-amd-connector"
docker compose up -d --build
```
## 6. Routine operations and observability
### NAS commands
```bash
curl -fsS http://<NAS_LAN_IP>:<NAS_PROXY_PORT>/status
sudo /usr/local/bin/docker inspect dc-amd-proxy \
--format 'status={{.State.Status}} health={{if .State.Health}}{{.State.Health.Status}}{{end}} restart={{.HostConfig.RestartPolicy.Name}}'
sudo /usr/local/bin/docker logs --tail 200 dc-amd-proxy
cd /volume1/docker/dc-amd-proxy
sudo /usr/local/bin/docker compose -p dc-amd-proxy ps
```
### Windows commands
```powershell
Set-Location "C:\NODEDC\dc-amd-connector"
docker compose ps
docker compose logs --tail 200
docker inspect dc-amd-connector --format '{{.State.Status}} health={{if .State.Health}}{{.State.Health.Status}}{{end}} restart={{.HostConfig.RestartPolicy.Name}}'
```
### What `/status` means
`/healthz` and `/status` currently return the same safe operational body. They
prove process/local state, not end-to-end provider reachability.
Important fields:
- `state`: `awaiting_pair` or `paired`;
- `forwarding`: disabled until paired, then `amd_connector_only`;
- `directEgress`: must always be `false`;
- `pool.activeSockets`, `idleSockets`, `queuedRequests`, `maxQueuedRequests`;
- `pool.byOrigin`: safe active/idle/queued counts per allowlisted origin;
- `metrics.requests`, `terminalRequests`, `inFlightRequests`;
- `completedResponses`, `failedRequests`, `clientAborts`, `streamFailures`;
- `openedTunnels`, `tunnelFailures`, `reusedSocketRequests`, `retries`;
- complete and partial byte counts;
- average/max queue, connection, TTFB, attempt, retry and total duration;
- per-host counters and the last short transport error.
At all times:
```text
requests = terminalRequests + inFlightRequests
```
At quiescence, `inFlightRequests` should return to zero. A violation indicates
a lifecycle/accounting defect. Counters are process-local and reset after a
container restart; pair state does not reset.
Interpret latency by stage:
| Symptom | Likely stage |
| --- | --- |
| High `queuedRequests` / `queueMs` | Per-origin eight-socket pool saturated or requests not terminating |
| High `connectionMs` / tunnel failures | NAS-to-AMD LAN, Windows connector, DNS, CONNECT or TLS setup |
| Low connection but high `ttfbMs` | VPN/provider latency or provider throttling |
| Low TTFB but high total duration | Slow/stalled response body or downstream backpressure |
| Rising `partialBytes` / `streamFailures` | Provider/VPN body died after headers |
| Rising `clientAborts` | Browser/navigation cancellation or caller timeout; not automatically an upstream failure |
| Low `reusedSocketRequests` with high `openedTunnels` | Keep-alive churn, route instability or origin fan-out |
| Retry spikes | Reused sockets are being reset; one bounded recovery is working but transport is unstable |
JSON logs intentionally contain event names, approved hostname, short error
code and numeric timing only. They must not include a full URL, query string,
Ion bearer, connector credential or Map egress credential. Normal fast tile
requests are not logged individually. Useful events include:
- `cesium_egress_tunnel_opened`
- `cesium_egress_reused_socket_retry`
- `cesium_egress_retry_completed`
- `cesium_egress_retry_failed`
- `cesium_egress_slow_or_failed`
- `cesium_egress_stream_failed`
- `cesium_egress_request_failed`
- `amd_connector_paired`
A warm Map Gateway cache hit should not increase `dc-amd-proxy` request
counters. If it does, audit the cache-first ordering before tuning the proxy.
## 7. Failure matrix
| Observable result | Probable cause | Safe action |
| --- | --- | --- |
| NAS status unavailable | Container stopped/unhealthy or wrong NAS bind address | Inspect container/Compose state and logs; do not change NAS routes |
| `state=awaiting_pair` | No durable connector pair state | Verify intended AMD host, then run the one-time pairing tool |
| Pair HTTP 403 `pair_source_not_allowed` | Request did not originate from configured AMD LAN IP | Verify stable AMD IP, NAS host-mode listener and no proxy/NAT in the pair call |
| Pair HTTP 409 `already_paired` | Pair state already exists | Do not overwrite it; use the controlled migration/rotation procedure |
| `map_egress_unauthorized` | Map Gateway and NAS proxy do not share the runner-synced Map egress credential | Re-run canonical preflight/deploy investigation; never copy a token into `.env` by hand |
| `amd_connector_not_paired` | NAS runtime pair file absent/invalid | Pair from the allowed AMD source; inspect ownership only through canonical runner checks |
| Connector refused/timeout | Windows off, no user sign-in, Docker stopped, firewall/IP mismatch or connector unhealthy | Restore Windows/Docker/connector; warm cache should continue |
| `amd_connector_rejected` | Connector credential mismatch or CONNECT rejected | Stop; do not retry pairing blindly. Treat as pair-state/migration incident |
| `cesium_target_not_allowed` | Provider redirected/requested an unreviewed host or wrong scheme/port | Capture hostname safely, review necessity, update both allowlists through source |
| TLS timeout/certificate error | VPN path, DNS, time, CA or provider interception problem | Verify AMD route/VPN and NAS container time/CA; never disable TLS verification in production |
| `amd_upstream_timeout` | No provider headers within attempt timeout | Compare connection vs TTFB metrics; verify VPN/provider rather than increasing timeout first |
| Body/redirect idle timeout | Stream stopped making progress | Check VPN packet loss/provider; partial object must not enter Map cache |
| Provider HTTP 401/403 | Ion token, asset permission, referer restriction or provider account issue | Diagnose Map Gateway/provider configuration; transport is functioning if status is passed through |
| Provider HTTP 429 | Provider rate/throughput policy | Reduce cold fan-out and use cache; do not add unbounded sockets/retries |
| Rendering survives but cold areas fail | Expected cache-first degradation while AMD/VPN is down | Restore egress; preserve existing cache and avoid destructive cache resets |
| Queue grows and never drains | Saturation, hung lifecycle or insufficient abort propagation | Inspect in-flight/accounting invariant, body idle errors and client aborts before changing pool size |
| AMD host rebooted and does not recover | No Windows sign-in, Startup launcher missing, Docker not ready, container removed or VPN disconnected | Follow the explicit Windows reboot sequence |
### Important VPN caveat
The connector trusts the route Docker Desktop receives from Windows. It does
not cryptographically prove that a request used a particular VPN or country.
If Windows allows direct Internet egress when the VPN is off, the connector
may use that direct AMD-host route. It will still never fall back to direct NAS
egress, but that is a different guarantee.
If VPN-only provider egress is mandatory, enforce it on the AMD host/VPN with
an outbound kill switch scoped to the connector workload/approved destinations
and test it with VPN on and off. The current installer creates only an inbound
NAS-only firewall rule; it does not install this outbound policy.
## 8. Migration to another AMD machine
Migration has two independent identities: stable LAN endpoint and connector
credential. A clean install on another machine creates a new credential, while
the current NAS pair endpoint is intentionally write-once. Changing only the
IP in Compose is therefore insufficient.
### Required zero-guesswork migration sequence
1. Keep the old connector active while preparing the replacement.
2. Assign the replacement a reserved `<NEW_AMD_LAN_IP>` and verify VPN/Docker
routing.
3. Install the reviewed connector package on the replacement using
`<NEW_AMD_LAN_IP>` and the same NAS address/connector port.
4. Verify local connector health and the safe CONNECT probe.
5. Create a reviewed source change that updates NAS connector host and allowed
pair source to `<NEW_AMD_LAN_IP>`.
6. Add a runner-supported, audited pair-rotation transition that can retire the
old NAS pair state and admit exactly one new pair. The transition must back
up state, never print either credential, fail closed and be idempotent.
7. Build, checksum, SMB-transfer, `plan`, and `apply` the exact versioned
artifact/runner change through the deploy canon.
8. Pair from the replacement machine, verify an end-to-end cold request and
confirm `directEgress=false` plus clean accounting.
9. Disable the old connector, then uninstall it only after acceptance.
The current implementation does **not yet provide step 6 as a first-class
runner operation**. Until it exists, a fresh-secret migration is not fully
canonical. Do not work around this by deleting
`/volume1/docker/dc-amd-proxy/runtime/connector-access`, copying the credential
through chat/SMB, weakening `/api/pair`, or running two active pair sources.
If an urgent migration is required before controlled rotation exists, open an
Ops change and implement/review that transition first. This limitation is
preferable to an undocumented secret reset.
To remove a retired connector from Windows after the new path is accepted:
```powershell
powershell.exe -NoProfile -ExecutionPolicy Bypass `
-File "C:\NODEDC\dc-amd-connector\uninstall.ps1" `
-Destination "C:\NODEDC\dc-amd-connector"
```
The uninstaller removes only this container, its NAS-only firewall rule,
Startup launcher and installation directory. It does not remove Docker Desktop
or unrelated containers.
## 9. Deploy and rollback canon
### Source/change acceptance before packaging
Run from the service directory in a clean checkout:
```bash
node --check server.mjs
npm run test:connection-pool
docker build --no-cache -t nodedc/dc-amd-proxy:<VERIFY_TAG> .
git status --short
```
The smoke suite must cover pool reuse/saturation, aborts in each lifecycle
stage, exact terminal accounting, one safe retry, body idle timeout, redirect
credential boundaries and absence of secret markers in logs/status.
Commit source and documentation together. Build an artifact from the commit,
not from an unknown dirty tree. The normal deployment sequence is:
```text
source review -> tests -> commit -> artifact -> checksum -> SMB inbox
-> verify-install -> exact plan -> human plan review -> exact apply
-> container health -> proxy status -> end-to-end Map acceptance
```
Never put `.env`, `runtime`, tokens, logs, backup archives, Windows package
state or shell hooks inside a `dc-amd-proxy` app-overlay.
### What the runner records
Before applying, the runner creates:
```text
/volume1/docker/nodedc-deploy/backups/<BACKUP_ID>/
```
with manifest, file lists and `source-before.tgz`. Successful artifacts move
to `/volume1/docker/nodedc-deploy/applied`; failed artifacts move to
`/volume1/docker/nodedc-deploy/failed`. Applied and failed JSONL state lives
under `/volume1/docker/nodedc-deploy/state`.
For this component the current runner does **not** perform generic automatic
source rollback after a failed post-copy build/health check. The backup is
evidence/recovery input, not permission for an improvised live extraction.
### Canonical forward rollback
Use a new patch ID and package the known-good service source from a separate
clean worktree. Do not reset or overwrite the active developer worktree:
```bash
git worktree add "../NODEDC-rollback" <KNOWN_GOOD_COMMIT>
cd "../NODEDC-rollback"
node infra/deploy-runner/build-dc-amd-proxy-artifact.mjs \
dc-amd-proxy-rollback-<YYYYMMDD>-<NNN>
cd infra/deploy-artifacts
shasum -a 256 nodedc-dc-amd-proxy-rollback-<YYYYMMDD>-<NNN>.tgz \
> nodedc-dc-amd-proxy-rollback-<YYYYMMDD>-<NNN>.tgz.sha256
```
Transfer the exact pair to the inbox and run the same checksum,
`verify-install`, exact `plan`, review and exact `apply` sequence. The runtime
pair-state directory remains outside the source overlay and must be preserved.
If the failed version changed a runtime contract, Compose contract or secret
format, stop and design an explicit rollback transition instead of assuming a
source-only forward rollback is safe.
## 10. Capacity and product behavior
The default eight active sockets are **per approved origin**, not global. A
Cesium session can use more than one origin, but every origin remains bounded.
Twenty users do not overwrite one another's transport state; they share the
pool and may queue. Cache objects are owned by Map Gateway, not by this proxy.
The proxy has no tile index, no cache eviction and no “do not overwrite cache”
switch. Those belong to Map Gateway. It streams bytes and records transport
telemetry. Consequently:
- proxy/VPN speed affects only cold/refresh acquisition;
- NAS cache read speed should be independent from AMD/VPN speed;
- increasing pool size cannot fix slow warm cache reads;
- cache fill concurrency and capacity policy must be tuned in Map Gateway;
- a browser reload should reuse NAS cache even if local browser cache is empty.
## 11. Known limitations and evolution path
The current path is production-usable but not yet a general infrastructure
egress product. Track these changes explicitly rather than accumulating
one-off live fixes:
1. **Controlled pair rotation/migration.** Add runner-owned one-time rotation,
revocation and rollback state; optionally support overlapping old/new
credentials for a bounded cutover window.
2. **Remove the legacy `proxy-contur` secret source.** Generate/manage a
dedicated Map egress credential as first-class runner state.
3. **VPN-route enforcement.** Add a tested Windows outbound kill switch or a
dedicated egress appliance so VPN-off cannot become direct AMD egress.
4. **Unattended host recovery.** Replace user-sign-in-dependent Docker Desktop
with a managed Windows service, signed installer or dedicated Linux egress
node if 24/7 cold-cache availability is required.
5. **Pair-channel hardening.** Replace source-IP-only HTTP pairing with a
short-lived nonce plus authenticated/encrypted handoff or mutual TLS.
6. **Two-level health.** Keep local `/healthz`, add a rate-limited synthetic
egress readiness check that proves DNS/CONNECT/TLS without consuming or
exposing the master Ion credential.
7. **Durable metrics.** Export the safe counters to the platform metrics stack;
process-local `/status` currently resets on restart.
8. **Central allowlist contract.** Generate identical proxy/connector host
policies from one reviewed provider contract and test that they cannot
drift.
9. **Circuit breaking and backoff.** Add per-origin fail-fast state for a dead
AMD/VPN path so many cold misses do not occupy all queues until timeout.
10. **High availability.** Define an explicit active/standby connector model,
health-based selection and credential isolation before adding a second
workstation.
11. **Signed Windows distribution/update.** Provide a versioned package builder,
signature verification and in-place upgrade/rollback rather than manual
archive handling.
12. **SLOs and load tests.** Establish cache-hit, cold-TTFB, queue-depth,
abort-rate and recovery-time objectives using realistic concurrent Map
Page sessions.
Until those changes land, preserve the narrow boundary: official allowlisted
HTTPS only, no direct NAS fallback, no global workstation proxy, no secret in
source, and every NAS mutation through a reviewed data-only artifact plus the
canonical runner.

View File

@ -0,0 +1,84 @@
# DC AMD Proxy
Полный воспроизводимый install/recovery/migration-контракт находится в
[`OPERATIONS.md`](./OPERATIONS.md). Этот README фиксирует краткую runtime-модель;
операционные изменения должны одновременно обновлять runbook.
`dc-amd-proxy` is the dedicated, controllable boundary between NODE.DC Map
Gateway and the adjacent AMD VPN machine. It is deliberately independent from
the historic `proxy-contur` service.
After pairing it has no direct Cesium egress: every approved Cesium/Bing
request is tunnelled through the authenticated AMD connector
(`172.22.0.183:8791`). The NAS always addresses this stable LAN endpoint;
the AMD host alone owns its changing VPN exit.
This is a single restricted transport path, not a per-tile relay chain. The
NAS keeps a small, bounded pool of HTTP/1.1 TLS tunnels per approved upstream
host, so adjacent tile requests reuse the existing `NAS → AMD → VPN` channel.
The pool is limited to eight active and four idle sockets per host by default;
it cannot become a general-purpose proxy or consume an unbounded amount of the
workstation's network resources.
`/status` exposes only safe operational counters: active/idle/queued sockets
per approved origin, the retained peak queue, logical/in-flight/terminal
requests, tunnels opened, reused sockets, bounded retries, complete and partial
response bytes, separate queue/CONNECT+TLS/TTFB/retry/total timing, body-stream
failures, client aborts, and the last short error code. Every accepted egress
request is accounted exactly once even when the browser disconnects while it
is queued, connecting, following a redirect, waiting for headers, or retrying.
The status never contains an Ion token, connector token, full URL, or query
parameters.
An upstream `Authorization` header is retained only across same-origin
redirects. A redirect from the Cesium API origin to an assets or Bing origin
is followed only after that header is removed, so an API/master bearer cannot
cross provider credential boundaries.
Only an idempotent `GET`/`HEAD` that loses an already reused keep-alive socket
is retried, once. The failed reused-socket attempt and the terminal retry
attempt are logged separately with numeric transport timings and no URL or
credential fields. A response body that makes no progress for 30 seconds is
terminated and counted as a stream failure, so a stalled VPN connection cannot
occupy one of the eight per-origin sockets forever. These limits are local to
Cesium egress and do not modify routing or connectivity of the AMD workstation.
## Pairing and operational state
The active service has an explicitly narrow first-pair contract:
- `POST /api/pair` accepts exactly one connector access token and only from
the configured AMD LAN source address;
- the AMD operator invokes a supplied local script; it reads the token inside
the AMD installation and sends it without printing it or putting it in chat;
- the NAS writes the paired value only to its private runtime directory, never
to `.env`, SMB inbox, a deploy artifact, browser code, or logs;
- the Map Gateway fetch contract (`/proxy/cesium/fetch`) remains private to
the NAS LAN address and still requires the runner-synchronised map egress
token;
- the service follows only the fixed Cesium Ion/Bing host allowlist and cannot
fall back to direct NAS Internet egress.
## Deployment order
1. Deploy this service and confirm `/healthz` reports `awaiting_pair`.
Map Gateway remains on its existing egress during this step.
2. On AMD, run the supplied one-time pairing script. It sends the locally-held
connector secret directly to the NAS and reports no secret value.
3. Verify the private Map Gateway request through this service.
4. Apply the separate, minimal Map Gateway switch patch. It changes only its
private egress hostname from the historical service to `dc-amd-proxy`.
The initial 001 deployment was intentionally inert. It is superseded by this
active configuration; it did not alter NAS routing, the NAS VPN/Tailscale
configuration, or the Windows workstation's VPN configuration.
## Why host networking is narrow here
Synology's Docker published-port proxy replaces the caller address before it
reaches a bridged container. That makes a strict one-time AMD source-address
check impossible. This service therefore uses the host network namespace but
binds its own HTTP listener only to `172.22.0.222:8790`; it is not bound to all
NAS interfaces. No host route, VPN, DNS, Tailscale setting or firewall rule is
changed. The forwarding code still has one hard-coded outbound path only:
the authenticated AMD connector.

View File

@ -0,0 +1,47 @@
services:
dc-amd-proxy:
build:
context: .
dockerfile: Dockerfile
container_name: dc-amd-proxy
image: nodedc/dc-amd-proxy:local
restart: unless-stopped
# Preserve the real AMD source address for the one-time pairing request.
# The process itself binds only to the NAS LAN address below; this does not
# change NAS routes, VPN, DNS, Tailscale, or any other host service.
network_mode: host
environment:
PORT: "8790"
DC_AMD_PROXY_BIND_ADDRESS: ${DC_AMD_PROXY_BIND_ADDRESS:-172.22.0.222}
DC_AMD_CONNECTOR_HOST: ${DC_AMD_CONNECTOR_HOST:-172.22.0.183}
DC_AMD_CONNECTOR_PORT: ${DC_AMD_CONNECTOR_PORT:-8791}
DC_AMD_PAIR_ALLOWED_SOURCE: ${DC_AMD_PAIR_ALLOWED_SOURCE:-172.22.0.183}
DC_AMD_CONNECTOR_TOKEN_FILE: /var/lib/dc-amd-proxy/connector-access
DC_AMD_MAP_EGRESS_TOKEN_FILE: /run/nodedc-secrets/map-egress-proxy-token
DC_AMD_BODY_IDLE_TIMEOUT_SECONDS: ${DC_AMD_BODY_IDLE_TIMEOUT_SECONDS:-30}
volumes:
- type: bind
source: /volume1/docker/nodedc-platform/secrets/map-egress-proxy-token
target: /run/nodedc-secrets/map-egress-proxy-token
read_only: true
- type: bind
source: ./runtime
target: /var/lib/dc-amd-proxy
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=8m
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test:
- CMD-SHELL
- >
node -e "fetch('http://'+process.env.DC_AMD_PROXY_BIND_ADDRESS+':8790/healthz')
.then(r => process.exit(r.ok ? 0 : 1))
.catch(() => process.exit(1))"
interval: 30s
timeout: 5s
retries: 3
start_period: 10s

View File

@ -0,0 +1,10 @@
{
"name": "dc-amd-proxy",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "node server.mjs",
"test:connection-pool": "node scripts/smoke-connection-pool.mjs"
}
}

View File

@ -0,0 +1,499 @@
import assert from "node:assert/strict";
import { execFile as execFileCallback, spawn } from "node:child_process";
import { once } from "node:events";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { createServer as createHttpServer, request as createHttpRequest } from "node:http";
import { createServer as createHttpsServer } from "node:https";
import { connect, createServer as createNetServer } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
const execFile = promisify(execFileCallback);
const root = await mkdtemp(join(tmpdir(), "nodedc-amd-pool-smoke-"));
const upstreamPort = await freePort();
const connectorPort = await freePort();
const proxyPort = await freePort();
const upstreamSockets = new Set();
const connectorSockets = new Set();
let upstream;
let connector;
let proxy;
let proxyOutput = "";
let tunnelCount = 0;
let upstreamRequests = 0;
let retryAttempts = 0;
let nonReusedResetAttempts = 0;
let retryLimitAttempts = 0;
let retryAbortAttempts = 0;
let queuedAbortAttempts = 0;
let nextConnectorDelayMs = 15;
const redirectCredentials = {};
let holdReleased = false;
let releaseHold;
let markHoldStarted;
let markPreHeaderSeen;
let markPreHeaderClosed;
let markIdleClosed;
let markDelayedConnectorSeen;
let markDelayedConnectorClosed;
let markRedirectSeen;
let markRedirectClosed;
let markRetryAbortSecondSeen;
let markRetryAbortSecondClosed;
const holdRelease = new Promise((resolve) => { releaseHold = resolve; });
const holdStarted = new Promise((resolve) => { markHoldStarted = resolve; });
const preHeaderSeen = new Promise((resolve) => { markPreHeaderSeen = resolve; });
const preHeaderClosed = new Promise((resolve) => { markPreHeaderClosed = resolve; });
const idleClosed = new Promise((resolve) => { markIdleClosed = resolve; });
const delayedConnectorSeen = new Promise((resolve) => { markDelayedConnectorSeen = resolve; });
const delayedConnectorClosed = new Promise((resolve) => { markDelayedConnectorClosed = resolve; });
const redirectSeen = new Promise((resolve) => { markRedirectSeen = resolve; });
const redirectClosed = new Promise((resolve) => { markRedirectClosed = resolve; });
const retryAbortSecondSeen = new Promise((resolve) => { markRetryAbortSecondSeen = resolve; });
const retryAbortSecondClosed = new Promise((resolve) => { markRetryAbortSecondClosed = resolve; });
try {
const keyPath = join(root, "key.pem");
const certificatePath = join(root, "certificate.pem");
await execFile("openssl", [
"req", "-x509", "-newkey", "rsa:2048", "-nodes",
"-keyout", keyPath,
"-out", certificatePath,
"-subj", "/CN=api.cesium.com",
"-days", "1",
]);
upstream = createHttpsServer({ key: await readFile(keyPath), cert: await readFile(certificatePath) }, (request, response) => {
upstreamRequests += 1;
if (request.url === "/retry") {
retryAttempts += 1;
if (retryAttempts === 1) {
setTimeout(() => request.socket.destroy(), 25);
return;
}
response.writeHead(200, { "content-type": "text/plain" });
response.end("retry-ok");
return;
}
if (request.url?.startsWith("/hold/")) {
markHoldStarted();
const finish = () => {
response.writeHead(200, { "content-type": "text/plain" });
response.end(`hold-${request.url.slice("/hold/".length)}`);
};
if (holdReleased) finish();
else void holdRelease.then(finish);
return;
}
if (request.url === "/preheader") {
markPreHeaderSeen();
request.socket.once("close", markPreHeaderClosed);
return;
}
if (request.url === "/redirect-hold") {
markRedirectSeen();
request.socket.once("close", markRedirectClosed);
response.writeHead(302, { location: "https://api.cesium.com/redirect-target", "content-type": "text/plain" });
response.write("redirect-partial");
return;
}
if (request.url === "/redirect-same-origin") {
redirectCredentials.sameSource = request.headers.authorization;
response.writeHead(302, { location: "/same-origin-target" });
response.end("same-origin-redirect");
return;
}
if (request.url === "/same-origin-target") {
redirectCredentials.sameTarget = request.headers.authorization;
response.writeHead(200, { "content-type": "text/plain" });
response.end("same-origin-ok");
return;
}
if (request.url === "/redirect-assets-origin") {
redirectCredentials.assetsSource = request.headers.authorization;
response.writeHead(302, { location: "https://assets.ion.cesium.com/assets-origin-target" });
response.end("assets-origin-redirect");
return;
}
if (request.url === "/assets-origin-target") {
redirectCredentials.assetsHost = request.headers.host;
redirectCredentials.assetsTarget = request.headers.authorization;
response.writeHead(200, { "content-type": "text/plain" });
response.end("assets-origin-ok");
return;
}
if (request.url === "/redirect-bing-origin") {
redirectCredentials.bingSource = request.headers.authorization;
response.writeHead(302, { location: "https://dev.virtualearth.net/bing-origin-target" });
response.end("bing-origin-redirect");
return;
}
if (request.url === "/bing-origin-target") {
redirectCredentials.bingHost = request.headers.host;
redirectCredentials.bingTarget = request.headers.authorization;
response.writeHead(200, { "content-type": "text/plain" });
response.end("bing-origin-ok");
return;
}
if (request.url === "/non-reused-reset") {
nonReusedResetAttempts += 1;
setTimeout(() => request.socket.destroy(), 25);
return;
}
if (request.url === "/queued-abort") {
queuedAbortAttempts += 1;
response.writeHead(200, { "content-type": "text/plain" });
response.end("queue-abort-reached-upstream");
return;
}
if (request.url === "/retry-limit") {
retryLimitAttempts += 1;
setTimeout(() => request.socket.destroy(), 25);
return;
}
if (request.url === "/retry-abort") {
retryAbortAttempts += 1;
if (retryAbortAttempts === 1) {
setTimeout(() => request.socket.destroy(), 25);
return;
}
markRetryAbortSecondSeen();
request.socket.once("close", markRetryAbortSecondClosed);
return;
}
if (request.url === "/idle") {
request.socket.once("close", markIdleClosed);
response.writeHead(200, { "content-type": "text/plain" });
response.write("partial");
return;
}
const responseNumber = upstreamRequests;
const finish = () => {
response.writeHead(200, { "content-type": "text/plain" });
response.end(`tile-${responseNumber}`);
};
if (request.url?.startsWith("/tile/0?")) setTimeout(finish, 25);
else finish();
});
upstream.on("connection", (socket) => {
upstreamSockets.add(socket);
socket.once("close", () => upstreamSockets.delete(socket));
});
upstream.listen(upstreamPort, "127.0.0.1");
await once(upstream, "listening");
connector = createHttpServer((_request, response) => {
response.writeHead(405);
response.end();
});
connector.on("connection", (socket) => {
connectorSockets.add(socket);
socket.once("close", () => connectorSockets.delete(socket));
});
connector.on("connect", (_request, clientSocket, head) => {
tunnelCount += 1;
// The fake connector deliberately ignores the requested hostname and
// connects to a local TLS fixture. Production still enforces the strict
// Cesium/Bing allowlist before opening this tunnel.
const connectorDelayMs = nextConnectorDelayMs;
nextConnectorDelayMs = 15;
if (connectorDelayMs > 100) {
markDelayedConnectorSeen();
clientSocket.once("close", markDelayedConnectorClosed);
}
const target = connect(upstreamPort, "127.0.0.1");
target.once("connect", () => {
setTimeout(() => {
if (clientSocket.destroyed || target.destroyed) return;
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
if (head.length) target.write(head);
clientSocket.pipe(target);
target.pipe(clientSocket);
}, connectorDelayMs);
});
target.once("error", () => clientSocket.destroy());
clientSocket.once("error", () => target.destroy());
clientSocket.once("close", () => target.destroy());
});
connector.listen(connectorPort, "127.0.0.1");
await once(connector, "listening");
const connectorToken = `connector-secret-${"a".repeat(48)}`;
const mapToken = "map-pool-smoke-secret";
const providerToken = "provider-secret-marker";
const connectorTokenFile = join(root, "connector-token");
const mapTokenFile = join(root, "map-token");
await writeFile(connectorTokenFile, `${connectorToken}\n`, { mode: 0o600 });
await writeFile(mapTokenFile, `${mapToken}\n`, { mode: 0o600 });
proxy = spawn(process.execPath, ["server.mjs"], {
cwd: new URL("..", import.meta.url),
env: {
...process.env,
NODE_ENV: "test",
PORT: String(proxyPort),
DC_AMD_PROXY_BIND_ADDRESS: "127.0.0.1",
DC_AMD_CONNECTOR_HOST: "127.0.0.1",
DC_AMD_CONNECTOR_PORT: String(connectorPort),
DC_AMD_PAIR_ALLOWED_SOURCE: "127.0.0.1",
DC_AMD_CONNECTOR_TOKEN_FILE: connectorTokenFile,
DC_AMD_MAP_EGRESS_TOKEN_FILE: mapTokenFile,
DC_AMD_PROXY_TEST_ALLOW_INSECURE_TLS: "true",
DC_AMD_POOL_MAX_SOCKETS: "1",
DC_AMD_POOL_MAX_FREE_SOCKETS: "1",
DC_AMD_SLOW_REQUEST_SECONDS: "30",
DC_AMD_UPSTREAM_TIMEOUT_SECONDS: "2",
DC_AMD_BODY_IDLE_TIMEOUT_SECONDS: "1",
},
stdio: ["ignore", "pipe", "pipe"],
});
proxy.stdout.on("data", (chunk) => { proxyOutput += String(chunk); });
proxy.stderr.on("data", (chunk) => { proxyOutput += String(chunk); });
await waitForProxy(proxyPort, proxy, () => proxyOutput);
for (let index = 0; index < 4; index += 1) {
const target = `https://api.cesium.com/tile/${index}${index === 0 ? `?access_token=${providerToken}` : ""}`;
const response = await proxyFetch(proxyPort, mapToken, target);
assert.equal(response.status, 200);
assert.equal(await response.text(), `tile-${index + 1}`);
}
assert.equal(upstreamRequests, 4);
assert.equal(tunnelCount, 1);
const retryResponse = await proxyFetch(proxyPort, mapToken, "https://api.cesium.com/retry");
assert.equal(retryResponse.status, 200);
assert.equal(await retryResponse.text(), "retry-ok");
assert.equal(retryAttempts, 2);
const redirectAuthorization = "Bearer master-api-secret-marker";
const redirectHeaders = { "x-nodedc-cesium-authorization": redirectAuthorization };
const sameOriginResponse = await proxyFetch(proxyPort, mapToken, "https://api.cesium.com/redirect-same-origin", undefined, redirectHeaders);
assert.equal(sameOriginResponse.status, 200);
assert.equal(await sameOriginResponse.text(), "same-origin-ok");
const assetsOriginResponse = await proxyFetch(proxyPort, mapToken, "https://api.cesium.com/redirect-assets-origin", undefined, redirectHeaders);
assert.equal(assetsOriginResponse.status, 200);
assert.equal(await assetsOriginResponse.text(), "assets-origin-ok");
const bingOriginResponse = await proxyFetch(proxyPort, mapToken, "https://api.cesium.com/redirect-bing-origin", undefined, redirectHeaders);
assert.equal(bingOriginResponse.status, 200);
assert.equal(await bingOriginResponse.text(), "bing-origin-ok");
assert.equal(redirectCredentials.sameSource, redirectAuthorization);
assert.equal(redirectCredentials.sameTarget, redirectAuthorization);
assert.equal(redirectCredentials.assetsSource, redirectAuthorization);
assert.equal(redirectCredentials.assetsTarget, undefined);
assert.equal(redirectCredentials.assetsHost, "assets.ion.cesium.com");
assert.equal(redirectCredentials.bingSource, redirectAuthorization);
assert.equal(redirectCredentials.bingTarget, undefined);
assert.equal(redirectCredentials.bingHost, "dev.virtualearth.net");
const held = Array.from({ length: 4 }, (_, index) => (
rawProxyRequest(proxyPort, mapToken, `https://api.cesium.com/hold/${index}`).promise
));
await withTimeout(holdStarted, 1_000, "first saturated request did not reach upstream");
const queuedAbortRequest = rawProxyRequest(proxyPort, mapToken, "https://api.cesium.com/queued-abort");
const saturatedStatus = await waitFor(async () => {
const status = await (await fetch(`http://127.0.0.1:${proxyPort}/status`)).json();
return status.transport.pool.queuedRequests >= 4 ? status : null;
}, 1_000, "agent queue never reached saturation");
assert.equal(saturatedStatus.transport.pool.maxQueuedRequests >= 4, true);
queuedAbortRequest.abort();
await assert.rejects(withTimeout(queuedAbortRequest.promise, 1_000, "queued abort did not reject client request"));
await delay(100);
holdReleased = true;
releaseHold();
const heldResults = await withTimeout(Promise.all(held), 2_000, "saturated requests did not drain");
for (const [index, result] of heldResults.entries()) {
assert.equal(result.status, 200);
assert.equal(result.body, `hold-${index}`);
}
assert.equal(queuedAbortAttempts, 0);
const redirectAbortRequest = rawProxyRequest(proxyPort, mapToken, "https://api.cesium.com/redirect-hold");
await withTimeout(redirectSeen, 1_000, "redirect request did not reach upstream");
redirectAbortRequest.abort();
await assert.rejects(withTimeout(redirectAbortRequest.promise, 1_000, "redirect abort did not reject client request"));
await withTimeout(redirectClosed, 1_000, "redirect abort did not close redirect body socket");
const preHeaderRequest = rawProxyRequest(proxyPort, mapToken, "https://api.cesium.com/preheader");
await withTimeout(preHeaderSeen, 1_000, "pre-header request did not reach upstream");
preHeaderRequest.abort();
await assert.rejects(withTimeout(preHeaderRequest.promise, 1_000, "pre-header abort did not reject client request"));
await withTimeout(preHeaderClosed, 1_000, "pre-header abort did not close upstream socket");
// With no pooled socket left, delay the next CONNECT response and abort the
// browser-side request while that tunnel is still being established.
nextConnectorDelayMs = 500;
const connectAbortRequest = rawProxyRequest(proxyPort, mapToken, "https://api.cesium.com/connect-abort");
await withTimeout(delayedConnectorSeen, 1_000, "CONNECT-abort request did not reach connector");
connectAbortRequest.abort();
await assert.rejects(withTimeout(connectAbortRequest.promise, 1_000, "CONNECT abort did not reject client request"));
await withTimeout(delayedConnectorClosed, 1_000, "CONNECT abort did not close connector socket");
await waitFor(async () => {
const status = await (await fetch(`http://127.0.0.1:${proxyPort}/status`)).json();
return status.transport.metrics.inFlightRequests === 0 ? status : null;
}, 1_000, "CONNECT abort was not terminally accounted");
// The abort above removed the pooled socket. A reset on the next freshly
// opened socket must fail directly, never trigger the safe reused-socket retry.
const nonReusedResponse = await proxyFetch(proxyPort, mapToken, "https://api.cesium.com/non-reused-reset");
assert.equal(nonReusedResponse.status, 502);
assert.equal(nonReusedResetAttempts, 1);
const warmResponse = await proxyFetch(proxyPort, mapToken, "https://api.cesium.com/warm");
assert.equal(warmResponse.status, 200);
await warmResponse.text();
const retryAbortRequest = rawProxyRequest(proxyPort, mapToken, "https://api.cesium.com/retry-abort");
await withTimeout(retryAbortSecondSeen, 1_000, "retry abort did not reach its second attempt");
retryAbortRequest.abort();
await assert.rejects(withTimeout(retryAbortRequest.promise, 1_000, "retry abort did not reject client request"));
await withTimeout(retryAbortSecondClosed, 1_000, "retry abort did not close second-attempt socket");
assert.equal(retryAbortAttempts, 2);
const retryLimitWarmResponse = await proxyFetch(proxyPort, mapToken, "https://api.cesium.com/warm-after-retry-abort");
assert.equal(retryLimitWarmResponse.status, 200);
await retryLimitWarmResponse.text();
// First failure is on a reused socket and is retried once. The second
// failure is terminal: retry remains strictly bounded to two attempts.
const retryLimitResponse = await proxyFetch(proxyPort, mapToken, "https://api.cesium.com/retry-limit");
assert.equal(retryLimitResponse.status, 502);
assert.equal(retryLimitAttempts, 2);
const idleResponse = await proxyFetch(proxyPort, mapToken, "https://api.cesium.com/idle");
assert.equal(idleResponse.status, 200);
await assert.rejects(withTimeout(idleResponse.text(), 2_000, "idle body timeout did not terminate downstream"));
await withTimeout(idleClosed, 1_000, "idle timeout did not close upstream socket");
const status = await (await fetch(`http://127.0.0.1:${proxyPort}/status`)).json();
const metrics = status.transport.metrics;
assert.equal(metrics.requests, 22);
assert.equal(metrics.terminalRequests, 22);
assert.equal(metrics.inFlightRequests, 0);
assert.equal(metrics.completedResponses, 14);
assert.equal(metrics.failedRequests, 3);
assert.equal(metrics.retries, 3);
assert.equal(metrics.streamFailures, 1);
assert.equal(metrics.clientAborts, 5);
assert.equal(metrics.responseBytes > 0, true);
assert.equal(metrics.partialBytes >= Buffer.byteLength("partial"), true);
assert.equal(metrics.reusedSocketRequests >= 8, true);
assert.equal(metrics.timing.queueMs.max >= 100, true);
assert.equal(metrics.timing.connectionMs.max > 0, true);
assert.equal(metrics.timing.ttfbMs.max >= 20, true);
assert.equal(metrics.timing.retryMs.max >= 25, true);
assert.equal(status.transport.pool.queuedRequests, 0);
assert.equal(status.transport.pool.maxQueuedRequests >= 4, true);
assert.equal(status.transport.pool.maxSockets, 1);
assert.equal(status.transport.metrics.byHost["api.cesium.com"].terminalRequests, 22);
assert.equal(proxyOutput.includes('"event":"cesium_egress_reused_socket_retry"'), true);
assert.equal(proxyOutput.includes('"event":"cesium_egress_retry_completed"'), true);
assert.equal(proxyOutput.includes('"event":"cesium_egress_retry_failed"'), true);
for (const secret of [connectorToken, mapToken, providerToken, redirectAuthorization]) assert.equal(proxyOutput.includes(secret), false);
console.log("ok: aborts propagate, queue peaks are retained, retries are bounded, timings are separated, and terminal accounting is exact");
} catch (error) {
if (proxyOutput) console.error(proxyOutput);
throw error;
} finally {
if (proxy && proxy.exitCode === null && proxy.signalCode === null) {
proxy.kill("SIGTERM");
try {
await withTimeout(once(proxy, "exit"), 2_000, "proxy did not stop after SIGTERM");
} catch {
proxy.kill("SIGKILL");
await withTimeout(once(proxy, "exit"), 2_000, "proxy did not stop after SIGKILL").catch(() => undefined);
}
}
await closeServer(connector, connectorSockets);
await closeServer(upstream, upstreamSockets);
await rm(root, { recursive: true, force: true });
}
function proxyFetch(port, token, target, signal, extraHeaders = {}) {
return fetch(`http://127.0.0.1:${port}/proxy/cesium/fetch?url=${encodeURIComponent(target)}`, {
headers: { "x-proxy-token": token, ...extraHeaders },
signal,
});
}
function rawProxyRequest(port, token, target) {
let request;
const promise = new Promise((resolve, reject) => {
request = createHttpRequest({
host: "127.0.0.1",
port,
method: "GET",
path: `/proxy/cesium/fetch?url=${encodeURIComponent(target)}`,
headers: { "x-proxy-token": token },
agent: false,
}, (response) => {
const chunks = [];
response.on("data", (chunk) => chunks.push(chunk));
response.once("end", () => resolve({ status: response.statusCode, body: Buffer.concat(chunks).toString("utf8") }));
response.once("error", reject);
});
request.once("error", reject);
request.end();
});
// Register a rejection observer immediately; the test asserts the same
// original promise after it deliberately destroys the request.
void promise.catch(() => undefined);
return {
promise,
abort() { request.destroy(new Error("fixture_client_abort")); },
};
}
async function freePort() {
const server = createNetServer();
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
assert(address && typeof address === "object");
const closed = once(server, "close");
server.close();
await closed;
return address.port;
}
async function waitForProxy(port, child, output) {
for (let attempt = 0; attempt < 80; attempt += 1) {
if (child.exitCode !== null) throw new Error(`proxy_exited:${child.exitCode}:${output()}`);
try {
const response = await fetch(`http://127.0.0.1:${port}/healthz`);
if (response.ok) return;
} catch { /* service is still starting */ }
await delay(50);
}
throw new Error(`proxy_start_timeout:${output()}`);
}
async function waitFor(read, timeoutMs, message) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const value = await read();
if (value) return value;
await delay(20);
}
throw new Error(message);
}
function withTimeout(promise, timeoutMs, message) {
let timeout;
const deadline = new Promise((_, reject) => {
timeout = setTimeout(() => reject(new Error(message)), timeoutMs);
});
return Promise.race([promise, deadline]).finally(() => clearTimeout(timeout));
}
function delay(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
async function closeServer(server, sockets) {
for (const socket of sockets) socket.destroy();
if (!server?.listening) return;
const closed = once(server, "close");
server.close();
await withTimeout(closed, 2_000, "fixture server did not close").catch(() => undefined);
}

View File

@ -0,0 +1,987 @@
import { timingSafeEqual } from "node:crypto";
import { link, mkdir, open, readFile, unlink } from "node:fs/promises";
import { createServer } from "node:http";
import https from "node:https";
import { connect as connectNet } from "node:net";
import { dirname } from "node:path";
import { connect as connectTls } from "node:tls";
const allowedHosts = 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 connectionTimingSymbol = Symbol("nodedc.connection-timing");
const config = await readConfig();
const transport = createTransport(config);
const server = createServer(async (request, response) => {
const url = new URL(request.url || "/", "http://localhost");
try {
if (request.method === "GET" && ["/healthz", "/status"].includes(url.pathname)) return writeJson(response, 200, await statusBody());
if (request.method === "POST" && url.pathname === "/api/pair") return await pairConnector(request, response);
if (["GET", "HEAD"].includes(request.method || "") && url.pathname === "/proxy/cesium/fetch") return await forwardCesiumRequest(request, response, url);
return writeJson(response, 404, { ok: false, error: "not_found" });
} catch (error) {
if (response.destroyed || response.headersSent || response.writableEnded) {
if (!response.writableEnded) response.destroy();
return;
}
const errorCode = safeError(error);
const status = Number(error?.statusCode || 502);
console.warn(JSON.stringify({ event: "cesium_egress_request_failed", error: errorCode, status, ...telemetryLogFields(error?.telemetry) }));
return writeJson(response, status, { ok: false, error: errorCode }, { "x-nodedc-egress-error": errorCode });
}
});
server.listen(config.port, config.bindAddress, () => console.log(JSON.stringify({ event: "dc_amd_proxy_started", bindAddress: config.bindAddress, port: config.port, connector: `${config.connectorHost}:${config.connectorPort}`, directEgress: false })));
for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, () => server.close(() => transport.close(() => process.exit(0))));
async function readConfig() {
const nodeEnv = String(process.env.NODE_ENV || "production").trim();
return {
port: parsePort(process.env.PORT, 8790),
bindAddress: parseIpv4(process.env.DC_AMD_PROXY_BIND_ADDRESS || "172.22.0.222", "bind_address"),
connectorHost: parseIpv4(process.env.DC_AMD_CONNECTOR_HOST || "172.22.0.183", "connector_host"),
connectorPort: parsePort(process.env.DC_AMD_CONNECTOR_PORT, 8791),
pairAllowedSource: parseIpv4(process.env.DC_AMD_PAIR_ALLOWED_SOURCE || "172.22.0.183", "pair_allowed_source"),
connectorTokenFile: String(process.env.DC_AMD_CONNECTOR_TOKEN_FILE || "/var/lib/dc-amd-proxy/connector-access").trim(),
mapToken: Buffer.from(await readSecretFile(process.env.DC_AMD_MAP_EGRESS_TOKEN_FILE || "/run/nodedc-secrets/map-egress-proxy-token", "map_egress_token"), "utf8"),
connectTimeoutMs: parseDuration(process.env.DC_AMD_CONNECT_TIMEOUT_SECONDS, 20),
upstreamTimeoutMs: parseDuration(process.env.DC_AMD_UPSTREAM_TIMEOUT_SECONDS, 30),
bodyIdleTimeoutMs: parseDuration(process.env.DC_AMD_BODY_IDLE_TIMEOUT_SECONDS, 30),
poolMaxSockets: parsePoolSize(process.env.DC_AMD_POOL_MAX_SOCKETS, 8),
poolMaxFreeSockets: parsePoolSize(process.env.DC_AMD_POOL_MAX_FREE_SOCKETS, 4),
slowRequestMs: parseDuration(process.env.DC_AMD_SLOW_REQUEST_SECONDS, 2),
allowInsecureTls: nodeEnv === "test" && parseBoolean(process.env.DC_AMD_PROXY_TEST_ALLOW_INSECURE_TLS, false),
};
}
async function statusBody() {
const paired = Boolean(await readConnectorToken());
return {
ok: true,
service: "dc-amd-proxy",
state: paired ? "paired" : "awaiting_pair",
forwarding: paired ? "amd_connector_only" : "disabled",
directEgress: false,
connector: `${config.connectorHost}:${config.connectorPort}`,
transport: transport.status(),
};
}
async function pairConnector(request, response) {
if (normalizeAddress(request.socket.remoteAddress) !== config.pairAllowedSource) return writeJson(response, 403, { ok: false, error: "pair_source_not_allowed" });
if (await readConnectorToken()) return writeJson(response, 409, { ok: false, error: "already_paired" });
const body = await readJsonBody(request, 1024);
const token = String(body?.connectorAccessToken || "").trim();
if (!isSecret(token)) return writeJson(response, 400, { ok: false, error: "connector_access_token_invalid" });
await persistConnectorToken(token);
console.log(JSON.stringify({ event: "amd_connector_paired" }));
return writeJson(response, 201, { ok: true, state: "paired" });
}
async function forwardCesiumRequest(request, response, requestUrl) {
if (!isMapAuthorized(request.headers["x-proxy-token"])) return writeJson(response, 401, { ok: false, error: "map_egress_unauthorized" });
const target = parseTarget(requestUrl.searchParams.get("url") || "");
const startedAt = Date.now();
const terminal = transport.beginRequest(target.hostname);
const controller = new AbortController();
let telemetry = emptyTelemetry();
let upstream;
let responseBytes = 0;
let upstreamEnded = false;
let downstreamFinished = false;
let clientAborted = false;
let terminalFinished = false;
let bodyIdleTimer;
let abortUpstream;
const clearBodyIdleTimer = () => {
if (bodyIdleTimer) clearTimeout(bodyIdleTimer);
bodyIdleTimer = undefined;
};
const cleanup = () => {
clearBodyIdleTimer();
request.off("aborted", onClientAbort);
response.off("close", onClientAbort);
if (abortUpstream) controller.signal.removeEventListener("abort", abortUpstream);
};
const finish = (outcome) => {
if (terminalFinished) return false;
terminalFinished = terminal.complete({
...outcome,
durationMs: Date.now() - startedAt,
bytes: responseBytes,
telemetry,
});
if (terminalFinished) cleanup();
return terminalFinished;
};
function onClientAbort() {
if (downstreamFinished || terminalFinished) return;
clientAborted = true;
if (!controller.signal.aborted) controller.abort(abortError());
// Before headers, requestOnce owns the active/queued ClientRequest and its
// signal. The await/catch below records its complete attempt telemetry.
if (upstream) finish({ type: "client-abort" });
}
const armBodyIdleTimer = () => {
clearBodyIdleTimer();
bodyIdleTimer = setTimeout(() => {
const error = typedError("amd_upstream_body_idle_timeout", 502);
error.code = "ETIMEDOUT";
upstream.destroy(error);
}, config.bodyIdleTimeoutMs);
bodyIdleTimer.unref?.();
};
request.once("aborted", onClientAbort);
response.once("close", onClientAbort);
try {
const connectorToken = await readConnectorToken();
if (clientAborted || controller.signal.aborted) {
finish({ type: "client-abort" });
return;
}
if (!connectorToken) {
finish({ type: "request-failure", errorCode: "amd_connector_not_paired" });
return writeJson(response, 503, { ok: false, error: "amd_connector_not_paired" }, { "x-nodedc-egress-error": "amd_connector_not_paired" });
}
const result = await requestThroughAmd(target, request.method || "GET", upstreamHeaders(request), controller.signal);
upstream = result.upstream;
telemetry = result.telemetry;
if (clientAborted || controller.signal.aborted) {
upstream.destroy();
finish({ type: "client-abort" });
return;
}
abortUpstream = () => {
if (!upstreamEnded && !upstream.destroyed) upstream.destroy(abortError());
};
controller.signal.addEventListener("abort", abortUpstream, { once: true });
if (controller.signal.aborted) abortUpstream();
upstream.on("data", (chunk) => {
responseBytes += chunk.length;
armBodyIdleTimer();
});
upstream.once("end", () => {
upstreamEnded = true;
clearBodyIdleTimer();
});
upstream.once("error", (error) => {
upstreamEnded = true;
clearBodyIdleTimer();
if (!finish({ type: clientAborted ? "client-abort" : "stream-failure", errorCode: safeTransportError(error) })) return;
if (!clientAborted) {
console.warn(JSON.stringify({ event: "cesium_egress_stream_failed", host: target.hostname, error: safeTransportError(error), bytes: responseBytes }));
}
if (!response.destroyed) response.destroy();
});
armBodyIdleTimer();
response.writeHead(upstream.statusCode || 502, safeResponseHeaders(upstream.headers));
response.once("finish", () => {
downstreamFinished = true;
clearBodyIdleTimer();
const durationMs = Date.now() - startedAt;
const status = Number(upstream.statusCode || 502);
if (!finish({ type: "response", status })) return;
// Normal tile traffic is intentionally not logged per object. Slow or
// failing responses are sufficient to diagnose VPN/transport regressions
// without creating a noisy, credential-bearing request log.
if (durationMs >= config.slowRequestMs || status >= 400) {
console.warn(JSON.stringify({
event: "cesium_egress_slow_or_failed",
host: target.hostname,
status,
durationMs,
queueMs: telemetry.queueMs,
connectionMs: telemetry.connectionMs,
ttfbMs: telemetry.ttfbMs,
retryMs: telemetry.retryMs,
attempts: telemetry.attempts,
bytes: responseBytes,
reusedSocket: telemetry.reusedSocket,
retries: telemetry.retries,
}));
}
});
upstream.pipe(response);
} catch (error) {
telemetry = mergeTelemetry(telemetry, error?.telemetry);
if (clientAborted || controller.signal.aborted) {
finish({ type: "client-abort" });
return;
}
if (upstream && !upstream.destroyed) upstream.destroy();
finish({ type: "request-failure", errorCode: safeTransportError(error) });
error.telemetry = telemetry;
throw error;
}
}
async function requestThroughAmd(initialTarget, method, headers, signal) {
let target = initialTarget;
let requestHeaders = { ...headers };
let aggregate = emptyTelemetry();
for (let redirects = 0; redirects <= 5; redirects += 1) {
let result;
try {
result = await requestWithSafeRetry(target, method, requestHeaders, signal);
} catch (error) {
error.telemetry = mergeTelemetry(aggregate, error?.telemetry);
throw error;
}
const { upstream } = result;
aggregate = mergeTelemetry(aggregate, result.telemetry);
const statusCode = Number(upstream.statusCode || 502);
const location = Array.isArray(upstream.headers.location) ? upstream.headers.location[0] : upstream.headers.location;
if ([301, 302, 303, 307, 308].includes(statusCode) && location) {
try {
aggregate.attemptMs += await drainRedirect(upstream, signal);
const redirectedTarget = parseTarget(new URL(location, target).toString());
if (redirectedTarget.origin !== target.origin) requestHeaders = withoutAuthorization(requestHeaders);
target = redirectedTarget;
} catch (error) {
aggregate.attemptMs += Math.max(0, Number(error?.redirectDrainMs || 0));
error.telemetry = aggregate;
throw error;
}
continue;
}
return { upstream, telemetry: aggregate };
}
const error = typedError("amd_upstream_redirect_limit", 502);
error.telemetry = aggregate;
throw error;
}
function withoutAuthorization(headers) {
const output = { ...headers };
delete output.authorization;
return output;
}
function drainRedirect(upstream, signal) {
return new Promise((resolve, reject) => {
const startedAt = Date.now();
let finished = false;
let idleTimer;
const cleanup = () => {
if (idleTimer) clearTimeout(idleTimer);
signal.removeEventListener("abort", onAbort);
upstream.off("data", armIdleTimer);
upstream.off("end", onEnd);
upstream.off("aborted", onUpstreamAbort);
upstream.off("error", onError);
};
const complete = () => {
if (finished) return;
finished = true;
cleanup();
resolve(Date.now() - startedAt);
};
const fail = (error) => {
if (finished) return;
finished = true;
cleanup();
error.redirectDrainMs = Date.now() - startedAt;
upstream.destroy();
reject(error);
};
const armIdleTimer = () => {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
const error = typedError("amd_upstream_redirect_body_idle_timeout", 502);
error.code = "ETIMEDOUT";
fail(error);
}, config.bodyIdleTimeoutMs);
idleTimer.unref?.();
};
const onAbort = () => fail(abortReason(signal));
const onEnd = () => complete();
const onUpstreamAbort = () => {
const error = typedError("amd_upstream_redirect_aborted", 502);
error.code = "ECONNRESET";
fail(error);
};
const onError = (error) => fail(error);
signal.addEventListener("abort", onAbort, { once: true });
upstream.on("data", armIdleTimer);
upstream.once("end", onEnd);
upstream.once("aborted", onUpstreamAbort);
upstream.once("error", onError);
if (signal.aborted) onAbort();
else {
armIdleTimer();
upstream.resume();
}
});
}
async function requestWithSafeRetry(target, method, headers, signal) {
let retries = 0;
let aggregate = emptyTelemetry();
while (true) {
try {
const result = await requestOnce(target, method, headers, signal);
aggregate = mergeTelemetry(aggregate, result.telemetry);
aggregate.retries = retries;
if (retries > 0) {
console.warn(JSON.stringify({
event: "cesium_egress_retry_completed",
host: target.hostname,
attempt: retries + 1,
status: Number(result.upstream.statusCode || 502),
...telemetryLogFields(result.telemetry),
totalAttemptMs: aggregate.attemptMs,
}));
}
return { upstream: result.upstream, telemetry: aggregate };
} catch (error) {
const failedAttemptTelemetry = error?.telemetry;
aggregate = mergeTelemetry(aggregate, failedAttemptTelemetry);
if (signal.aborted || retries >= 1 || !["GET", "HEAD"].includes(method) || error?.reusedSocket !== true || !isRetryableSocketError(error)) {
if (retries > 0) {
console.warn(JSON.stringify({
event: "cesium_egress_retry_failed",
host: target.hostname,
attempt: retries + 1,
error: safeTransportError(error),
...telemetryLogFields(failedAttemptTelemetry),
totalAttemptMs: aggregate.attemptMs,
}));
}
error.telemetry = aggregate;
throw error;
}
retries += 1;
aggregate.retryMs += Number(error?.telemetry?.attemptMs || 0);
aggregate.retries = retries;
const errorCode = safeTransportError(error);
transport.recordRetry(target.hostname, errorCode);
console.warn(JSON.stringify({
event: "cesium_egress_reused_socket_retry",
host: target.hostname,
attempt: retries,
error: errorCode,
...telemetryLogFields(failedAttemptTelemetry),
}));
}
}
}
function requestOnce(target, method, headers, externalSignal) {
return new Promise((resolve, reject) => {
const startedAt = Date.now();
let socketAssignedAt = null;
let socketTiming = null;
let reusedSocket = false;
let settled = false;
let timedOut = false;
const pendingConnectionTiming = { startedAt: null, readyAt: null };
const attemptController = new AbortController();
const abortAttempt = () => {
if (!attemptController.signal.aborted) attemptController.abort(abortReason(externalSignal));
};
if (externalSignal.aborted) abortAttempt();
else externalSignal.addEventListener("abort", abortAttempt, { once: true });
const client = https.request({
protocol: "https:",
hostname: target.hostname,
port: 443,
method,
path: `${target.pathname || "/"}${target.search || ""}`,
headers,
agent: transport.agent,
signal: attemptController.signal,
nodedcAbortSignal: attemptController.signal,
nodedcConnectionTiming: pendingConnectionTiming,
});
transport.observeQueuePeak();
queueMicrotask(() => transport.observeQueuePeak());
const timeoutError = typedError("amd_upstream_timeout", 502);
timeoutError.code = "ETIMEDOUT";
const timeout = setTimeout(() => {
timedOut = true;
if (!attemptController.signal.aborted) attemptController.abort(timeoutError);
}, config.upstreamTimeoutMs);
timeout.unref?.();
const cleanup = () => {
clearTimeout(timeout);
externalSignal.removeEventListener("abort", abortAttempt);
};
const attemptTelemetry = (endedAt, receivedHeaders, error) => {
const failedConnectionTiming = error?.[connectionTimingSymbol];
const timing = socketTiming || failedConnectionTiming || (pendingConnectionTiming.startedAt ? pendingConnectionTiming : null);
const newConnection = !reusedSocket && timing;
const queueEnd = newConnection?.startedAt || socketAssignedAt || endedAt;
const connectionMs = newConnection ? Math.max(0, Number(newConnection.readyAt || endedAt) - Number(newConnection.startedAt || endedAt)) : 0;
return {
queueMs: Math.max(0, Number(queueEnd) - startedAt),
connectionMs,
ttfbMs: receivedHeaders && socketAssignedAt ? Math.max(0, endedAt - socketAssignedAt) : 0,
attemptMs: Math.max(0, endedAt - startedAt),
retryMs: 0,
attempts: 1,
retries: 0,
reusedSocket,
};
};
client.once("socket", (socket) => {
socketAssignedAt = Date.now();
socketTiming = socket[connectionTimingSymbol] || null;
const priorAssignments = Number(socketTiming?.assignments || 0);
reusedSocket = client.reusedSocket === true || priorAssignments > 0;
if (socketTiming) socketTiming.assignments = priorAssignments + 1;
});
client.once("error", (rawError) => {
if (settled) return;
settled = true;
cleanup();
const error = timedOut && !externalSignal.aborted ? timeoutError : externalSignal.aborted ? abortReason(externalSignal) : rawError;
if (rawError?.[connectionTimingSymbol] && !error[connectionTimingSymbol]) error[connectionTimingSymbol] = rawError[connectionTimingSymbol];
error.reusedSocket = reusedSocket || client.reusedSocket === true;
error.telemetry = attemptTelemetry(Date.now(), false, error);
reject(error);
});
client.once("response", (upstream) => {
if (settled) {
upstream.destroy();
return;
}
settled = true;
cleanup();
const endedAt = Date.now();
resolve({
upstream,
telemetry: attemptTelemetry(endedAt, true),
});
});
client.end();
transport.observeQueuePeak();
});
}
function emptyTelemetry() {
return { queueMs: 0, connectionMs: 0, ttfbMs: 0, attemptMs: 0, retryMs: 0, attempts: 0, retries: 0, reusedSocket: false };
}
function telemetryLogFields(telemetry) {
if (!telemetry) return {};
return {
queueMs: Math.max(0, Number(telemetry.queueMs || 0)),
connectionMs: Math.max(0, Number(telemetry.connectionMs || 0)),
ttfbMs: Math.max(0, Number(telemetry.ttfbMs || 0)),
attemptMs: Math.max(0, Number(telemetry.attemptMs || 0)),
retryMs: Math.max(0, Number(telemetry.retryMs || 0)),
attempts: Math.max(0, Number(telemetry.attempts || 0)),
retries: Math.max(0, Number(telemetry.retries || 0)),
reusedSocket: telemetry.reusedSocket === true,
};
}
function mergeTelemetry(left, right) {
const merged = { ...emptyTelemetry(), ...(left || {}) };
if (!right) return merged;
for (const name of ["queueMs", "connectionMs", "ttfbMs", "attemptMs", "retryMs", "attempts", "retries"]) {
if (name === "retries") merged[name] = Math.max(Number(merged[name] || 0), Number(right[name] || 0));
else merged[name] += Math.max(0, Number(right[name] || 0));
}
merged.reusedSocket ||= right.reusedSocket === true;
return merged;
}
function createTransport(runtimeConfig) {
const metrics = {
openedTunnels: 0,
tunnelFailures: 0,
requests: 0,
terminalRequests: 0,
inFlightRequests: 0,
completedResponses: 0,
failedRequests: 0,
slowRequests: 0,
responseBytes: 0,
partialBytes: 0,
reusedSocketRequests: 0,
retries: 0,
streamFailures: 0,
clientAborts: 0,
maxQueuedRequests: 0,
timing: {
queueMs: { total: 0, max: 0 },
connectionMs: { total: 0, max: 0 },
ttfbMs: { total: 0, max: 0 },
attemptMs: { total: 0, max: 0 },
retryMs: { total: 0, max: 0 },
durationMs: { total: 0, max: 0 },
},
byHost: {},
lastFailure: null,
lastFailureAt: null,
};
const agent = new https.Agent({
keepAlive: true,
keepAliveMsecs: 30_000,
maxSockets: runtimeConfig.poolMaxSockets,
maxFreeSockets: runtimeConfig.poolMaxFreeSockets,
scheduling: "lifo",
});
// https.Agent maintains a separate pool per origin. A Cesium/Bing tile
// therefore reuses an already authenticated NAS -> AMD -> VPN -> TLS path
// for its host instead of paying a fresh CONNECT and TLS handshake.
agent.createConnection = (options, callback) => {
const hostname = String(options.servername || options.hostname || options.host || "").toLowerCase();
const signal = options.nodedcAbortSignal;
const pendingConnectionTiming = options.nodedcConnectionTiming;
void (async () => {
if (!allowedHosts.has(hostname)) throw typedError("cesium_target_not_allowed", 403);
const connectorToken = await readConnectorToken();
if (!connectorToken) throw typedError("amd_connector_not_paired", 503);
const startedAt = Date.now();
if (pendingConnectionTiming) pendingConnectionTiming.startedAt = startedAt;
let connectedAt = startedAt;
try {
const tunnel = await openConnectorTunnel({ hostname }, connectorToken, signal);
connectedAt = Date.now();
const secureSocket = await openTlsTunnel(tunnel, hostname, signal);
const readyAt = Date.now();
if (pendingConnectionTiming) pendingConnectionTiming.readyAt = readyAt;
secureSocket[connectionTimingSymbol] = { startedAt, readyAt, assignments: 0 };
metrics.openedTunnels += 1;
console.log(JSON.stringify({
event: "cesium_egress_tunnel_opened",
host: hostname,
connectorMs: connectedAt - startedAt,
tlsMs: readyAt - connectedAt,
}));
callback(null, secureSocket);
} catch (error) {
const readyAt = Date.now();
if (pendingConnectionTiming) pendingConnectionTiming.readyAt = readyAt;
error[connectionTimingSymbol] = { startedAt, readyAt, assignments: 0 };
throw error;
}
})().catch((error) => {
const errorCode = safeError(error);
if (error?.code !== "ABORT_ERR") {
metrics.tunnelFailures += 1;
metrics.lastFailure = errorCode;
metrics.lastFailureAt = new Date().toISOString();
}
callback(error);
});
return undefined;
};
return {
agent,
close(callback) { agent.destroy(); callback(); },
beginRequest(host) {
const hostMetrics = perHostMetrics(metrics, host);
metrics.requests += 1;
hostMetrics.requests += 1;
metrics.inFlightRequests += 1;
hostMetrics.inFlightRequests += 1;
let finished = false;
return {
complete({ type, status = 0, durationMs = 0, bytes = 0, telemetry = emptyTelemetry(), errorCode = "" }) {
if (finished) return false;
finished = true;
metrics.terminalRequests += 1;
metrics.inFlightRequests = Math.max(0, metrics.inFlightRequests - 1);
hostMetrics.terminalRequests += 1;
hostMetrics.inFlightRequests = Math.max(0, hostMetrics.inFlightRequests - 1);
const safeBytes = Math.max(0, Number(bytes || 0));
if (telemetry.reusedSocket) {
metrics.reusedSocketRequests += 1;
hostMetrics.reusedSocketRequests += 1;
}
observe(metrics.timing.queueMs, telemetry.queueMs);
observe(metrics.timing.connectionMs, telemetry.connectionMs);
observe(metrics.timing.ttfbMs, telemetry.ttfbMs);
observe(metrics.timing.attemptMs, telemetry.attemptMs);
observe(metrics.timing.retryMs, telemetry.retryMs);
observe(metrics.timing.durationMs, durationMs);
if (type === "response") {
metrics.completedResponses += 1;
hostMetrics.completedResponses += 1;
metrics.responseBytes += safeBytes;
hostMetrics.bytes += safeBytes;
} else {
metrics.partialBytes += safeBytes;
hostMetrics.partialBytes += safeBytes;
}
if (type === "client-abort") {
metrics.clientAborts += 1;
hostMetrics.clientAborts += 1;
}
const failure = type === "stream-failure" || type === "request-failure" || type === "response" && Number(status) >= 400;
if (failure) {
metrics.failedRequests += 1;
hostMetrics.failedRequests += 1;
if (type === "stream-failure") {
metrics.streamFailures += 1;
hostMetrics.streamFailures += 1;
}
metrics.lastFailure = errorCode || `upstream_http_${status}`;
metrics.lastFailureAt = new Date().toISOString();
hostMetrics.lastTransportError = errorCode || `upstream_http_${status}`;
}
if (type !== "client-abort" && durationMs >= runtimeConfig.slowRequestMs) metrics.slowRequests += 1;
return true;
},
};
},
recordRetry(host, errorCode) {
metrics.retries += 1;
const hostMetrics = perHostMetrics(metrics, host);
hostMetrics.retries += 1;
hostMetrics.lastTransportError = errorCode;
},
observeQueuePeak() {
const queuedRequests = socketCount(agent.requests);
metrics.maxQueuedRequests = Math.max(metrics.maxQueuedRequests, queuedRequests);
},
status() {
const queuedRequests = socketCount(agent.requests);
metrics.maxQueuedRequests = Math.max(metrics.maxQueuedRequests, queuedRequests);
return {
pool: {
activeSockets: socketCount(agent.sockets),
idleSockets: socketCount(agent.freeSockets),
queuedRequests,
maxQueuedRequests: metrics.maxQueuedRequests,
maxSockets: runtimeConfig.poolMaxSockets,
maxFreeSockets: runtimeConfig.poolMaxFreeSockets,
byOrigin: poolByOrigin(agent),
},
metrics: publicTransportMetrics(metrics),
};
},
};
}
function perHostMetrics(metrics, host) {
const safeHost = allowedHosts.has(String(host || "").toLowerCase()) ? String(host).toLowerCase() : "unknown";
metrics.byHost[safeHost] ||= {
requests: 0,
terminalRequests: 0,
inFlightRequests: 0,
completedResponses: 0,
failedRequests: 0,
reusedSocketRequests: 0,
retries: 0,
streamFailures: 0,
clientAborts: 0,
bytes: 0,
partialBytes: 0,
lastTransportError: null,
};
return metrics.byHost[safeHost];
}
function observe(bucket, rawValue) {
const value = Math.max(0, Number(rawValue || 0));
bucket.total += value;
bucket.max = Math.max(bucket.max, value);
}
function publicTransportMetrics(metrics) {
const average = (bucket) => metrics.terminalRequests ? Math.round(bucket.total / metrics.terminalRequests) : 0;
return {
openedTunnels: metrics.openedTunnels,
tunnelFailures: metrics.tunnelFailures,
requests: metrics.requests,
terminalRequests: metrics.terminalRequests,
inFlightRequests: metrics.inFlightRequests,
completedResponses: metrics.completedResponses,
failedRequests: metrics.failedRequests,
slowRequests: metrics.slowRequests,
responseBytes: metrics.responseBytes,
partialBytes: metrics.partialBytes,
reusedSocketRequests: metrics.reusedSocketRequests,
retries: metrics.retries,
streamFailures: metrics.streamFailures,
clientAborts: metrics.clientAborts,
timing: {
queueMs: { average: average(metrics.timing.queueMs), max: metrics.timing.queueMs.max },
connectionMs: { average: average(metrics.timing.connectionMs), max: metrics.timing.connectionMs.max },
ttfbMs: { average: average(metrics.timing.ttfbMs), max: metrics.timing.ttfbMs.max },
attemptMs: { average: average(metrics.timing.attemptMs), max: metrics.timing.attemptMs.max },
retryMs: { average: average(metrics.timing.retryMs), max: metrics.timing.retryMs.max },
durationMs: { average: average(metrics.timing.durationMs), max: metrics.timing.durationMs.max },
},
byHost: metrics.byHost,
lastFailure: metrics.lastFailure,
lastFailureAt: metrics.lastFailureAt,
};
}
function poolByOrigin(agent) {
const origins = {};
for (const [key, sockets] of Object.entries(agent.sockets)) poolOrigin(origins, key).active += sockets.length;
for (const [key, sockets] of Object.entries(agent.freeSockets)) poolOrigin(origins, key).idle += sockets.length;
for (const [key, requests] of Object.entries(agent.requests)) poolOrigin(origins, key).queued += requests.length;
return origins;
}
function poolOrigin(origins, agentKey) {
const host = String(agentKey || "").split(":")[0].toLowerCase();
const safeHost = allowedHosts.has(host) ? host : "unknown";
origins[safeHost] ||= { active: 0, idle: 0, queued: 0 };
return origins[safeHost];
}
function openConnectorTunnel(target, connectorToken, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(abortReason(signal));
return;
}
const socket = connectNet({ host: config.connectorHost, port: config.connectorPort });
let buffer = Buffer.alloc(0);
let finished = false;
const timeoutError = typedError("amd_connector_timeout", 502);
timeoutError.code = "ETIMEDOUT";
const timeout = setTimeout(() => fail(timeoutError), config.connectTimeoutMs);
timeout.unref?.();
const cleanup = () => {
clearTimeout(timeout);
signal?.removeEventListener("abort", onAbort);
socket.off("connect", onConnect);
socket.off("error", fail);
socket.off("data", onData);
};
const finish = (value) => {
if (finished) return;
finished = true;
cleanup();
resolve(value);
};
const fail = (error) => {
if (finished) return;
finished = true;
cleanup();
socket.destroy();
reject(error);
};
const onAbort = () => fail(abortReason(signal));
const onConnect = () => socket.write(`CONNECT ${target.hostname}:443 HTTP/1.1\r\nHost: ${target.hostname}:443\r\nProxy-Authorization: Bearer ${connectorToken}\r\n\r\n`);
const onData = (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
if (buffer.length > 16 * 1024) return fail(typedError("amd_connector_response_invalid", 502));
const end = buffer.indexOf("\r\n\r\n");
if (end < 0) return;
if (!/^HTTP\/1\.[01] 200\b/.test(buffer.subarray(0, end).toString("ascii"))) return fail(typedError("amd_connector_rejected", 502));
const remainder = buffer.subarray(end + 4);
if (remainder.length) socket.unshift(remainder);
finish(socket);
};
signal?.addEventListener("abort", onAbort, { once: true });
socket.once("connect", onConnect);
socket.on("error", fail);
socket.on("data", onData);
});
}
function openTlsTunnel(socket, hostname, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
socket.destroy();
reject(abortReason(signal));
return;
}
const secureSocket = connectTls({ socket, servername: hostname, ALPNProtocols: ["http/1.1"], rejectUnauthorized: !config.allowInsecureTls });
let finished = false;
const timeoutError = typedError("amd_upstream_tls_timeout", 502);
timeoutError.code = "ETIMEDOUT";
const timeout = setTimeout(() => fail(timeoutError), config.connectTimeoutMs);
timeout.unref?.();
const cleanup = () => {
clearTimeout(timeout);
signal?.removeEventListener("abort", onAbort);
secureSocket.off("secureConnect", finish);
secureSocket.off("error", fail);
};
const finish = () => {
if (finished) return;
finished = true;
cleanup();
resolve(secureSocket);
};
const fail = (error) => {
if (finished) return;
finished = true;
cleanup();
secureSocket.destroy();
reject(error);
};
const onAbort = () => fail(abortReason(signal));
signal?.addEventListener("abort", onAbort, { once: true });
secureSocket.once("secureConnect", finish);
secureSocket.once("error", fail);
});
}
function upstreamHeaders(request) {
// Do not force `Connection: close`: the shared Agent owns a deliberately
// bounded keep-alive pool for the approved upstream hosts.
const headers = { accept: safeHeader(request.headers.accept, "*/*"), "accept-encoding": "identity" };
for (const name of ["range", "if-none-match", "if-modified-since"]) {
const value = safeHeader(request.headers[name], "");
if (value) headers[name] = value;
}
const authorization = safeHeader(request.headers["x-nodedc-cesium-authorization"], "");
if (authorization) headers.authorization = authorization;
const referer = safeHeader(request.headers["x-nodedc-map-referer"], "");
if (referer) headers.referer = referer;
return headers;
}
function safeResponseHeaders(headers) {
const output = {};
const skipped = new Set(["connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade"]);
for (const [name, raw] of Object.entries(headers)) {
if (skipped.has(name.toLowerCase()) || raw === undefined) continue;
const value = Array.isArray(raw) ? raw.join(", ") : String(raw);
if (value.length <= 8192 && !/[\r\n\u0000]/.test(value)) output[name] = value;
}
return output;
}
function parseTarget(value) {
let target;
try { target = new URL(String(value)); } catch { throw typedError("cesium_target_invalid", 400); }
if (target.protocol !== "https:" || target.username || target.password || target.port && target.port !== "443" || !allowedHosts.has(target.hostname.toLowerCase())) throw typedError("cesium_target_not_allowed", 403);
target.hostname = target.hostname.toLowerCase();
return target;
}
async function readConnectorToken() {
try {
const token = (await readFile(config.connectorTokenFile, "utf8")).trim();
return isSecret(token) ? token : null;
} catch (error) {
if (error?.code === "ENOENT") return null;
throw typedError("connector_pair_state_unreadable", 500);
}
}
async function persistConnectorToken(token) {
await mkdir(dirname(config.connectorTokenFile), { recursive: true, mode: 0o700 });
const temporary = `${config.connectorTokenFile}.${process.pid}.${Date.now()}.tmp`;
let handle;
try {
handle = await open(temporary, "wx", 0o600);
await handle.writeFile(`${token}\n`, "ascii");
await handle.sync();
await handle.close();
handle = null;
// link(2) is the compare-and-set: unlike rename(), it cannot overwrite an
// existing pair state. Two concurrent pair attempts therefore produce one
// durable secret and one harmless 409 response.
try {
await link(temporary, config.connectorTokenFile);
} catch (error) {
if (error?.code === "EEXIST") throw typedError("already_paired", 409);
throw error;
}
} finally {
if (handle) await handle.close();
await unlink(temporary).catch(() => {});
}
}
async function readSecretFile(path, label) {
let value;
try { value = (await readFile(String(path), "utf8")).trim(); } catch { throw new Error(`${label}_unreadable`); }
if (!value || value.length > 4096 || /[\u0000-\u001f\u007f\s]/.test(value)) throw new Error(`${label}_invalid`);
return value;
}
function isMapAuthorized(raw) {
if (Array.isArray(raw)) return false;
const candidate = Buffer.from(String(raw || ""), "utf8");
return candidate.length === config.mapToken.length && timingSafeEqual(candidate, config.mapToken);
}
function readJsonBody(request, maxBytes) {
return new Promise((resolve, reject) => {
const chunks = [];
let bytes = 0;
request.on("data", (chunk) => {
bytes += chunk.length;
if (bytes > maxBytes) return reject(typedError("pair_payload_too_large", 413));
chunks.push(chunk);
});
request.once("error", reject);
request.once("end", () => {
try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); } catch { reject(typedError("pair_payload_invalid", 400)); }
});
});
}
function writeJson(response, status, body, extraHeaders = {}) {
const payload = JSON.stringify(body);
response.writeHead(status, {
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(payload),
"cache-control": "no-store",
...extraHeaders,
});
response.end(payload);
}
function parsePort(raw, fallback) {
const value = Number(String(raw || fallback).trim());
if (!Number.isInteger(value) || value < 1024 || value > 65535) throw new Error("invalid_port");
return value;
}
function parseDuration(raw, fallbackSeconds) {
const value = Number(String(raw || fallbackSeconds).trim());
if (!Number.isInteger(value) || value < 1 || value > 120) throw new Error("invalid_duration");
return value * 1000;
}
function parsePoolSize(raw, fallback) {
const value = Number(String(raw || fallback).trim());
if (!Number.isInteger(value) || value < 1 || value > 32) throw new Error("invalid_pool_size");
return value;
}
function socketCount(table) {
return Object.values(table).reduce((total, sockets) => total + (Array.isArray(sockets) ? sockets.length : 0), 0);
}
function parseIpv4(raw, label) {
const value = String(raw || "").trim();
const octets = value.split(".").map(Number);
if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) throw new Error(`${label}_invalid`);
return value;
}
function normalizeAddress(value) { return String(value || "").replace(/^::ffff:/, ""); }
function safeHeader(raw, fallback) {
if (Array.isArray(raw)) return fallback;
const value = String(raw || "").trim();
return value.length <= 4096 && !/[\r\n\u0000]/.test(value) ? value : fallback;
}
function parseBoolean(value, fallback) { return value === undefined || value === "" ? fallback : ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase()); }
function isSecret(value) { return /^[A-Za-z0-9_-]{48,256}$/.test(value); }
function typedError(message, statusCode) { const error = new Error(message); error.statusCode = statusCode; return error; }
function abortError() { const error = typedError("amd_client_aborted", 499); error.code = "ABORT_ERR"; return error; }
function abortReason(signal) { return signal?.reason instanceof Error ? signal.reason : abortError(); }
function safeError(error) { return String(error?.message || "dc_amd_proxy_error").replace(/[^A-Za-z0-9_.:-]/g, "_").slice(0, 120); }
function isRetryableSocketError(error) { return new Set(["ECONNRESET", "EPIPE", "ETIMEDOUT", "ECONNABORTED"]).has(String(error?.code || "").toUpperCase()); }
function safeTransportError(error) {
const code = String(error?.code || "").trim().toLowerCase();
if (/^[a-z0-9_]{1,48}$/.test(code)) return `amd_upstream_${code}`;
return safeError(error);
}

View File

@ -10,11 +10,9 @@ RUN apk add --no-cache su-exec
COPY package.json ./ COPY package.json ./
COPY src ./src COPY src ./src
COPY docker-entrypoint.sh /usr/local/bin/nodedc-map-gateway COPY runtime-entrypoint.mjs /app/runtime-entrypoint.mjs
RUN chmod +x /usr/local/bin/nodedc-map-gateway
EXPOSE 18103 EXPOSE 18103
ENTRYPOINT ["/usr/local/bin/nodedc-map-gateway"] ENTRYPOINT ["node", "/app/runtime-entrypoint.mjs"]
CMD ["node", "src/server.mjs"] CMD ["node", "src/server.mjs"]

View File

@ -4,14 +4,14 @@
## Что делает ## Что делает
- хранит master `CESIUM_ION_TOKEN` только в process environment; - хранит master Cesium Ion token только в private runtime storage: первоначально он может прийти из server environment, а после admin rotation — из закрытого файла в NAS volume;
- выдаёт браузеру только asset-scoped endpoint token для allowlisted ion assets; - никогда не выдаёт browser-у master, asset-scoped Cesium token или Bing key: endpoint response содержит только публичный provider URL, а Gateway подставляет credential в свой upstream request;
- проксирует и кэширует tile/3D Tiles/terrain resources через `GET /api/map/cache?url=<https-url>`; - проксирует и кэширует tile/3D Tiles/terrain resources через `GET /api/map/cache?url=<https-url>`;
- может отдать перенесённый локальный Engine snapshot через `MAP_GATEWAY_LEGACY_CACHE_HOSTS`, но никогда не догружает для него cache miss из сети; - может отдать перенесённый локальный Engine snapshot через `MAP_GATEWAY_LEGACY_CACHE_HOSTS`, но никогда не догружает для него cache miss из сети;
- работает с persistent volume, а не с Git или Docker image layer; - работает с persistent volume, а не с Git или Docker image layer;
- при upstream outage отдаёт ранее сохранённый cache object; offline режим включается только для providers, явно разрешённых их лицензией; - при upstream outage отдаёт ранее сохранённый cache object; offline режим включается только для providers, явно разрешённых их лицензией;
- защищает proxy allowlist-ом upstream hosts, HTTPS-only правилом, лимитами размера и token-free cache key; - защищает proxy allowlist-ом upstream hosts, HTTPS-only правилом, лимитами размера и token-free cache key;
- ведёт лёгкий persistent cache index для LRU eviction и health/stats. - ведёт лёгкий persistent cache index и health/stats без автоматического удаления уже записанных tiles.
## API ## API
@ -21,23 +21,31 @@ GET /api/map/ion/assets/:assetId/endpoint
GET|HEAD /api/map/cache?url=<encoded-upstream-url> GET|HEAD /api/map/cache?url=<encoded-upstream-url>
``` ```
`/api/map/ion/assets/:assetId/endpoint` разрешает только `CESIUM_ION_ASSET_ALLOWLIST`. Ответ содержит runtime asset token, а не master token. Browser использует endpoint вместе с Cesium `DefaultProxy`, направляющим resource requests в `/api/map/cache`. Внутренний admin route `GET|PUT /api/map/admin/cesium-ion` не является Browser API: он доступен только из Foundry по HMAC. Ключ подписи создаётся и хранится root-owned deploy runner в `/volume1/docker/nodedc-platform/secrets/map-gateway-admin-secret`, а оба сервиса получают только read-only file mount. Он не является `.env`-значением, настройкой Foundry или Cesium credential. Перед записью `PUT` проверяет candidate token через Cesium asset endpoints `1` (terrain), `2` (imagery) и `96188` (3D Buildings). Не прошедшее проверку значение не заменяет рабочее. Успешный token записывается атомарно в `${MAP_CACHE_DIR}/secrets/cesium-ion-token` с mode `0600`; ответ содержит только факт конфигурации, safe verification state и audit metadata — никогда само значение.
Ion endpoint metadata также сохраняется в persistent volume с файловыми правами `0600`. Это нужно для cold start в offline: Cesium получает сохранённый asset URL и asset-scoped token, а proxy отвечает только из ранее записанного cache. В metadata никогда не записывается master token. `/api/map/ion/assets/:assetId/endpoint` разрешает только `CESIUM_ION_ASSET_ALLOWLIST`. Ответ не содержит credential: Browser использует публичный provider URL вместе с Cesium `DefaultProxy`, направляющим resource requests в `/api/map/cache`. Gateway валидирует scope URL, удаляет любые credential query parameters из browser request и добавляет соответствующий server-side credential только перед обращением к provider.
Ion endpoint metadata с asset credential сохраняется только в private persistent storage с файловыми правами `0600`. Это нужно для cold start и offline: Browser по-прежнему получает только sanitised URL, а Gateway отвечает из ранее записанного cache. В metadata никогда не записывается master token; NAS backup этого каталога считается service-sensitive.
## Cache modes ## Cache modes
- `readwrite`свежий cache hit отдаётся сразу; stale object обновляется online; при upstream failure возвращается stale copy; - `readwrite`при включённом `Не перезаписывать cache` отдаёт уже записанный object прямо из общего TileCache; только miss идёт к официальному upstream и атомарно дописывается. Явный `refresh` viewport получает live response и заменяет объект;
- `readonly`использует cache, но не записывает новый; - `readonly`отдаёт ранее записанный object из TileCache; новый object через Gateway может быть показан live, но не записывается;
- `offline` — никогда не идёт наружу; cache miss возвращает `504`. - `offline` — никогда не идёт наружу; cache miss возвращает `504`.
`nodedc_cache_refresh=1` — единственный путь заменить уже записанный объект. Без него существующий object всегда отдаётся прямо с NAS и вообще не обращается к Ion/AMD/VPN. Foundry создаёт refresh либо когда Application явно разрешил обновление cache, либо для одноразового действия «Обновить текущий viewport». При достижении `MAP_CACHE_MAX_MB` Gateway не удаляет LRU-объекты: новый miss продолжает live-маршрут без записи с `x-nodedc-map-cache: live-pass-through-cache-full`.
Первый cold miss начинает стримиться клиенту сразу после provider headers и одновременно записывается в private temp-файл. Только полностью полученный object публикуется через atomic rename и durable `index.json`; partial/error никогда не становится cache entry. Одинаковые параллельные misses делят один upstream download, а отмена одного browser request не прерывает server-owned fill для других инстансов. Warm hit не меняет index. Параллельные новые объекты объединяются в минимальное число полных index snapshots вместо одного O(N) rewrite на каждый tile.
`offline` дополнительно требует, чтобы hostname был явно указан в `MAP_GATEWAY_OFFLINE_PROVIDER_ALLOWLIST`. По умолчанию список пуст: Gateway не превращает Cesium ion или public OSM tile service в offline distribution. Это осознанная provider policy, а не техническая ошибка cache. `offline` дополнительно требует, чтобы hostname был явно указан в `MAP_GATEWAY_OFFLINE_PROVIDER_ALLOWLIST`. По умолчанию список пуст: Gateway не превращает Cesium ion или public OSM tile service в offline distribution. Это осознанная provider policy, а не техническая ошибка cache.
Range requests пока transparently проксируются и не записываются как partial cache object. Полные GET responses кэшируются. Это безопасная начальная граница для terrain/3D Tiles; отдельным следующим шагом добавляется range-aware chunk cache после замеров реальных GOS/3D Tiles assets. Range requests пока transparently проксируются и не записываются как partial cache object. Полные GET responses кэшируются. Это безопасная начальная граница для terrain/3D Tiles; отдельным следующим шагом добавляется range-aware chunk cache после замеров реальных GOS/3D Tiles assets.
## Persistent storage ## Persistent storage
Platform Compose использует два внешних named volume: mutable `nodedc-platform_map-live-tile-cache` монтируется в `/var/lib/nodedc-map-live-cache`, read-only snapshot `nodedc-platform_map-offline-snapshot` — в `/var/lib/nodedc-map-offline-snapshot`. Их содержимое намеренно исключено из Git. Внешний volume не удаляется через `docker compose down -v`; очистка требует явного `docker volume rm` при остановленном Gateway. При необходимости offline seed доставляется отдельным export/import artifact, а не коммитом runtime cache. Production Platform Compose использует два NAS host directories: mutable `/volume1/docker/nodedc-platform/map-gateway/live-tile-cache` монтируется в `/var/lib/nodedc-map-live-cache`, read-only `/volume1/docker/nodedc-platform/map-gateway/offline-snapshot` — в `/var/lib/nodedc-map-offline-snapshot`. Их создаёт root-owned deploy runner при первом Map Gateway rollout. Они намеренно исключены из Git и artifact; Compose teardown их не удаляет. При необходимости offline seed доставляется отдельным export/import artifact, а не коммитом runtime cache.
Внутри mutable volume имеется service-sensitive подкаталог `secrets/`: там лежит только текущий Cesium Ion master token и metadata последнего изменения. Это не TileCache, не часть offline export и не объект SMB-операций. Gateway создаёт его с directory mode `0700`, files `0600`; не включать его в backup, export или diagnostic archive без отдельной процедуры секретов.
Кэш не способен создать участок карты, который никогда не был скачан. Для целевых офлайн-регионов нужен отдельный licensed dataset/provider и его `seed/prefetch` job: он заранее проходит разрешённые assets, zoom-диапазоны и географические области, записывает их в тот же persistent volume и формирует проверяемый export/import artifact. Эта задача не смешивается с runtime gateway и не помещает binary tiles в историю репозитория. Кэш не способен создать участок карты, который никогда не был скачан. Для целевых офлайн-регионов нужен отдельный licensed dataset/provider и его `seed/prefetch` job: он заранее проходит разрешённые assets, zoom-диапазоны и географические области, записывает их в тот же persistent volume и формирует проверяемый export/import artifact. Эта задача не смешивается с runtime gateway и не помещает binary tiles в историю репозитория.
@ -66,9 +74,15 @@ npm run import:engine-cache -- /absolute/path/to/nodedc-data/api/cesium/manifest
## Required environment ## Required environment
```text ```text
# Optional migration bootstrap; the private token file is preferred after an
# admin has configured it through Foundry.
CESIUM_ION_TOKEN= CESIUM_ION_TOKEN=
CESIUM_ION_ASSET_ALLOWLIST=1,96188 # Production: runner-owned NODEDC_MAP_GATEWAY_ADMIN_SECRET_FILE is mounted
MAP_GATEWAY_UPSTREAM_ALLOWLIST=api.cesium.com,assets.ion.cesium.com,tile.openstreetmap.org # automatically. Do not put the secret into an env file.
CESIUM_ION_ASSET_ALLOWLIST=1,2,96188
CESIUM_ION_ENDPOINT_TTL_SECONDS=300
CESIUM_ION_ENDPOINT_REFRESH_AHEAD_SECONDS=60
MAP_GATEWAY_UPSTREAM_ALLOWLIST=api.cesium.com,assets.ion.cesium.com,tile.openstreetmap.org,dev.virtualearth.net,ecn.t0.tiles.virtualearth.net,ecn.t1.tiles.virtualearth.net,ecn.t2.tiles.virtualearth.net,ecn.t3.tiles.virtualearth.net
MAP_GATEWAY_LEGACY_CACHE_HOSTS=ecn.t0.tiles.virtualearth.net,ecn.t1.tiles.virtualearth.net,ecn.t2.tiles.virtualearth.net,ecn.t3.tiles.virtualearth.net MAP_GATEWAY_LEGACY_CACHE_HOSTS=ecn.t0.tiles.virtualearth.net,ecn.t1.tiles.virtualearth.net,ecn.t2.tiles.virtualearth.net,ecn.t3.tiles.virtualearth.net
MAP_GATEWAY_OFFLINE_PROVIDER_ALLOWLIST= MAP_GATEWAY_OFFLINE_PROVIDER_ALLOWLIST=
MAP_CACHE_DIR=/var/lib/nodedc-map-cache MAP_CACHE_DIR=/var/lib/nodedc-map-cache
@ -76,12 +90,13 @@ MAP_CACHE_MODE=readwrite
``` ```
# Cache topology # Cache topology
`MAP_CACHE_DIR` is the mutable **live** cache. In the Platform compose profile it `MAP_CACHE_DIR` is the mutable **live** cache. In the production Platform
is mounted as `map-live-tile-cache` and is the only store that can receive new compose profile it is bind-mounted from the NAS path
upstream objects. `/volume1/docker/nodedc-platform/map-gateway/live-tile-cache` and is the only
store that can receive new upstream objects.
`MAP_OFFLINE_SNAPSHOT_DIR` is optional and mounted read-only as `MAP_OFFLINE_SNAPSHOT_DIR` is optional and mounted read-only as
`map-offline-snapshot`. It is populated only by the controlled Engine cache `/volume1/docker/nodedc-platform/map-gateway/offline-snapshot`. It is
import. Requests marked with the `offline` cache profile are served exclusively populated only by the controlled Engine cache import. Requests marked with the
from this snapshot; a missing tile returns a cache miss and never reaches an `offline` cache profile are served exclusively from this snapshot; a missing
upstream provider. tile returns a cache miss and never reaches an upstream provider.

View File

@ -1,11 +0,0 @@
#!/bin/sh
set -eu
# Docker creates a fresh named volume as root. Prepare only the mutable live
# cache before dropping privileges; the imported offline snapshot stays read-only.
if [ -d "${MAP_CACHE_DIR:-}" ]; then
mkdir -p "$MAP_CACHE_DIR"
chown -R node:node "$MAP_CACHE_DIR"
fi
exec su-exec node "$@"

View File

@ -6,6 +6,10 @@
"scripts": { "scripts": {
"start": "node src/server.mjs", "start": "node src/server.mjs",
"dev": "node --watch src/server.mjs", "dev": "node --watch src/server.mjs",
"test:credential-boundary": "node scripts/smoke-no-browser-credentials.mjs",
"test:admin-token-boundary": "node scripts/smoke-admin-boundary.mjs",
"test:live-cache-fallback": "node scripts/smoke-live-cache-fallback.mjs",
"test:streaming-cache-fill": "node scripts/smoke-streaming-cache-fill.mjs",
"audit:engine-cache": "node scripts/audit-engine-cache.mjs", "audit:engine-cache": "node scripts/audit-engine-cache.mjs",
"import:engine-cache": "node scripts/import-engine-cache.mjs" "import:engine-cache": "node scripts/import-engine-cache.mjs"
}, },

View File

@ -0,0 +1,17 @@
import { spawn } from "node:child_process";
import { mkdir, chown } from "node:fs/promises";
const cacheDir = String(process.env.MAP_CACHE_DIR || "").trim();
if (cacheDir) {
await mkdir(cacheDir, { recursive: true });
await chown(cacheDir, 1000, 1000);
}
if (process.getuid?.() === 0) {
process.setgid(1000);
process.setuid(1000);
}
const [command, ...args] = process.argv.slice(2);
if (!command) throw new Error("map_gateway_runtime_command_required");
const child = spawn(command, args, { stdio: "inherit" });
child.on("exit", (code, signal) => process.exitCode = code ?? (signal ? 1 : 0));
child.on("error", (error) => { throw error; });

View File

@ -0,0 +1,366 @@
import assert from "node:assert/strict";
import { createHash, createHmac } from "node:crypto";
import { once } from "node:events";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { createServer as createHttpServer } from "node:http";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawn } from "node:child_process";
const root = await mkdtemp(join(tmpdir(), "nodedc-map-gateway-admin-smoke-"));
const cacheDir = join(root, "cache");
const port = await freePort();
const egressPort = await freePort();
const adminSecret = "gateway-admin-smoke-secret";
const providerToken = "cesium-private-token-should-never-be-returned";
const rotatedProviderToken = "rotated-cesium-private-token-should-never-be-returned";
const egressProxyToken = "map-egress-private-token-should-never-be-returned";
const ionReferer = "https://foundry.example.test/page-library/";
const providerState = createProviderState();
let child;
let egress;
let childOutput = "";
try {
const egressTokenFile = join(root, "map-egress-proxy-token");
await writeFile(egressTokenFile, `${egressProxyToken}\n`, { mode: 0o600 });
egress = createCesiumEgressMock(providerState, egressProxyToken);
egress.listen(egressPort, "127.0.0.1");
await once(egress, "listening");
child = spawn(process.execPath, ["src/server.mjs"], {
cwd: new URL("..", import.meta.url),
env: {
...process.env,
PORT: String(port),
MAP_CACHE_DIR: cacheDir,
MAP_GATEWAY_ALLOW_ANONYMOUS: "true",
MAP_CACHE_MODE: "readwrite",
NODE_ENV: "test",
CESIUM_ION_TOKEN: "",
// A malformed legacy override may add an approved asset, but must never
// remove the three canonical Foundry map assets.
CESIUM_ION_ASSET_ALLOWLIST: "999",
CESIUM_ION_API_BASE_URL: "https://api.cesium.com/",
MAP_GATEWAY_UPSTREAM_ALLOWLIST: "api.cesium.com,assets.ion.cesium.com,dev.virtualearth.net",
MAP_GATEWAY_TEST_ALLOW_HTTP_LOOPBACK: "true",
MAP_GATEWAY_EGRESS_URL: `http://127.0.0.1:${egressPort}/`,
MAP_GATEWAY_EGRESS_PROXY_TOKEN_FILE: egressTokenFile,
NODEDC_MAP_GATEWAY_ADMIN_SECRET: adminSecret,
CESIUM_ION_ENDPOINT_TTL_SECONDS: "2",
CESIUM_ION_ENDPOINT_REFRESH_AHEAD_SECONDS: "1",
},
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout.on("data", (chunk) => { childOutput += String(chunk); });
child.stderr.on("data", (chunk) => { childOutput += String(chunk); });
await waitForGateway(port, child);
const path = "/api/map/admin/cesium-ion";
const unsigned = await fetch(`http://127.0.0.1:${port}${path}`);
assert.equal(unsigned.status, 401);
const initial = await requestAdmin(port, adminSecret, "GET", path, "user_root", "", ionReferer);
assert.equal(initial.response.status, 200);
assert.equal(initial.body.configured, false);
const saved = await requestAdmin(port, adminSecret, "PUT", path, "user_root", JSON.stringify({ token: providerToken }), ionReferer);
assert.equal(saved.response.status, 200);
assert.equal(saved.body.configured, true);
assert.equal(saved.body.verification, "verified");
assert.equal(saved.raw.includes(providerToken), false);
const terrainEndpoint = await fetch(`http://127.0.0.1:${port}/api/map/ion/assets/1/endpoint`, { headers: { "x-nodedc-ion-referer": ionReferer } });
assert.equal(terrainEndpoint.status, 200);
assert.equal((await terrainEndpoint.json()).credentialMode, "gateway");
const buildingsEndpoint = await fetch(`http://127.0.0.1:${port}/api/map/ion/assets/96188/endpoint`, { headers: { "x-nodedc-ion-referer": ionReferer } });
assert.equal(buildingsEndpoint.status, 200);
const buildingsEndpointRaw = await buildingsEndpoint.text();
const buildingsEndpointBody = JSON.parse(buildingsEndpointRaw);
assert.equal(buildingsEndpointBody.credentialMode, "gateway");
assert.equal(buildingsEndpointRaw.includes(providerState.credentials.v1.buildings), false);
assert.equal(buildingsEndpointBody.url.endsWith("/tileset.json?v=production-shaped"), true);
const endpointCallsBeforeCacheHit = providerState.endpointRequests.get("v1:96188");
const cachedBuildingsEndpoint = await fetch(`http://127.0.0.1:${port}/api/map/ion/assets/96188/endpoint`, { headers: { "x-nodedc-ion-referer": ionReferer } });
assert.equal(cachedBuildingsEndpoint.status, 200);
assert.equal(providerState.endpointRequests.get("v1:96188"), endpointCallsBeforeCacheHit);
const terrainTile = encodeURIComponent("https://assets.ion.cesium.com/us-east-1/asset_depot/1/CesiumWorldTerrain/v1.2/0/0/0.terrain");
const proxiedTerrain = await fetch(`http://127.0.0.1:${port}/api/map/cache?url=${terrainTile}`, { headers: { "x-nodedc-ion-referer": ionReferer } });
assert.equal(proxiedTerrain.status, 200);
assert.equal(await proxiedTerrain.text(), "terrain-bytes");
const buildingsRoot = await fetch(`http://127.0.0.1:${port}/api/map/cache?url=${encodeURIComponent(buildingsEndpointBody.url)}`, { headers: { "x-nodedc-ion-referer": ionReferer } });
assert.equal(buildingsRoot.status, 200);
assert.equal((await buildingsRoot.json()).root.content.uri, "root.b3dm");
const buildingsChildUrl = new URL("14/31780/12975.b3dm", buildingsEndpointBody.url).toString();
const buildingsChild = await fetch(`http://127.0.0.1:${port}/api/map/cache?url=${encodeURIComponent(buildingsChildUrl)}`, { headers: { "x-nodedc-ion-referer": ionReferer } });
assert.equal(buildingsChild.status, 200);
assert.equal(await buildingsChild.text(), "building-bytes-v1");
const nestedEndpoint = await fetch(`http://127.0.0.1:${port}/api/map/ion/assets/999/endpoint`, { headers: { "x-nodedc-ion-referer": ionReferer } });
const nestedEndpointRaw = await nestedEndpoint.text();
const nestedEndpointBody = JSON.parse(nestedEndpointRaw);
assert.equal(nestedEndpoint.status, 200);
assert.equal(nestedEndpointRaw.includes(providerState.credentials.v1.nested), false);
const nestedChildUrl = new URL("nested-child.b3dm", nestedEndpointBody.url).toString();
const nestedChild = await fetch(`http://127.0.0.1:${port}/api/map/cache?url=${encodeURIComponent(nestedChildUrl)}`, { headers: { "x-nodedc-ion-referer": ionReferer } });
assert.equal(nestedChild.status, 200);
assert.equal(await nestedChild.text(), "nested-building-bytes-v1");
const attackerToken = "browser-supplied-token-must-be-removed";
const siblingUrl = `https://assets.ion.cesium.com/us-east-1/asset_depot/999/OtherAsset/root.b3dm?access_token=${attackerToken}`;
const sibling = await fetch(`http://127.0.0.1:${port}/api/map/cache?url=${encodeURIComponent(siblingUrl)}`, { headers: { "x-nodedc-ion-referer": ionReferer } });
assert.equal(sibling.status, 401);
assert.equal(providerState.siblingCredentialObserved, "");
const rejected = await requestAdmin(port, adminSecret, "PUT", path, "user_root", JSON.stringify({ token: "invalid-private-token-should-not-replace-valid-value" }), ionReferer);
assert.equal(rejected.response.status, 422);
assert.equal(rejected.body.error, "cesium_ion_token_verification_failed");
// Simulate metadata persisted by an older Gateway transport. A false failure
// must be rechecked exactly once after the v4 scope/cache transport upgrade.
await writeFile(join(cacheDir, "secrets", "cesium-ion-token.metadata.json"), JSON.stringify({
updatedAt: "2026-07-15T00:00:00.000Z",
updatedBy: "user_root",
verification: "failed",
}));
const status = await requestAdmin(port, adminSecret, "GET", path, "user_root", "", ionReferer);
assert.equal(status.response.status, 200);
assert.equal(status.body.configured, true);
assert.equal(status.body.verification, "verified");
assert.equal(status.raw.includes(providerToken), false);
const metadata = JSON.parse(await readFile(join(cacheDir, "secrets", "cesium-ion-token.metadata.json"), "utf8"));
assert.equal(metadata.verificationTransportVersion, 4);
// Refresh-ahead is cache-first and single-flight: concurrent callers receive
// the still-valid cached endpoint while exactly one Ion request refreshes it.
await delay(1_100);
const callsBeforeRefreshAhead = providerState.endpointRequests.get("v1:96188");
const refreshAheadResponses = await Promise.all(Array.from({ length: 8 }, () => fetch(
`http://127.0.0.1:${port}/api/map/ion/assets/96188/endpoint`,
{ headers: { "x-nodedc-ion-referer": ionReferer } },
)));
assert.equal(refreshAheadResponses.every((response) => response.status === 200), true);
await waitFor(() => providerState.endpointRequests.get("v1:96188") >= callsBeforeRefreshAhead + 1);
await delay(100);
assert.equal(providerState.endpointRequests.get("v1:96188"), callsBeforeRefreshAhead + 1);
const rotated = await requestAdmin(port, adminSecret, "PUT", path, "user_root", JSON.stringify({ token: rotatedProviderToken }), ionReferer);
assert.equal(rotated.response.status, 200);
assert.equal(rotated.raw.includes(rotatedProviderToken), false);
const rotatedEndpoint = await fetch(`http://127.0.0.1:${port}/api/map/ion/assets/96188/endpoint`, { headers: { "x-nodedc-ion-referer": ionReferer } });
const rotatedEndpointRaw = await rotatedEndpoint.text();
const rotatedEndpointBody = JSON.parse(rotatedEndpointRaw);
assert.equal(rotatedEndpoint.status, 200);
assert.equal(rotatedEndpointRaw.includes(providerState.credentials.v1.buildings), false);
assert.equal(rotatedEndpointRaw.includes(providerState.credentials.v2.buildings), false);
const rotatedChildUrl = new URL("rotated.b3dm", rotatedEndpointBody.url).toString();
const rotatedChild = await fetch(`http://127.0.0.1:${port}/api/map/cache?url=${encodeURIComponent(rotatedChildUrl)}`, { headers: { "x-nodedc-ion-referer": ionReferer } });
assert.equal(rotatedChild.status, 200);
assert.equal(await rotatedChild.text(), "building-bytes-v2");
const indexRaw = await readFile(join(cacheDir, "index.json"), "utf8");
for (const secret of [providerToken, rotatedProviderToken, ...Object.values(providerState.credentials.v1), ...Object.values(providerState.credentials.v2), attackerToken]) {
assert.equal(indexRaw.includes(secret), false);
assert.equal(childOutput.includes(secret), false);
}
const health = await fetch(`http://127.0.0.1:${port}/healthz`);
const healthRaw = await health.text();
assert.equal(health.ok, true);
assert.equal(healthRaw.includes(providerToken), false);
assert.equal(JSON.parse(healthRaw).ionConfigured, true);
const stored = await readFile(join(cacheDir, "secrets", "cesium-ion-token"), "utf8");
assert.equal(stored.trim(), rotatedProviderToken);
console.log("ok: Ion scopes, refresh single-flight, token rotation, and secret boundaries hold");
} finally {
if (child && !child.killed) {
child.kill("SIGTERM");
await once(child, "exit").catch(() => undefined);
}
if (egress?.listening) {
egress.close();
await once(egress, "close");
}
await rm(root, { recursive: true, force: true });
}
function createCesiumEgressMock(state, expectedProxyToken) {
return createHttpServer((request, response) => {
const requestUrl = new URL(request.url || "/", "http://127.0.0.1");
if (requestUrl.pathname !== "/proxy/cesium/fetch") {
response.writeHead(404, { "content-type": "application/json" });
response.end(JSON.stringify({ error: "not_found" }));
return;
}
if (request.headers["x-proxy-token"] !== expectedProxyToken || request.headers.authorization) {
response.writeHead(401, { "content-type": "application/json" });
response.end(JSON.stringify({ error: "proxy_authorization_required" }));
return;
}
const target = new URL(requestUrl.searchParams.get("url") || "https://invalid.example/");
if (request.headers["x-nodedc-map-referer"] !== ionReferer) {
response.writeHead(401, { "content-type": "application/json" });
response.end(JSON.stringify({ error: "referer_required" }));
return;
}
if (target.hostname === "assets.ion.cesium.com" && target.pathname.startsWith("/us-east-1/asset_depot/1/CesiumWorldTerrain/v1.2/")) {
if (![state.credentials.v1.terrain, state.credentials.v2.terrain].includes(target.searchParams.get("access_token"))) {
response.writeHead(401, { "content-type": "application/json" });
response.end(JSON.stringify({ error: "terrain_authorization_required" }));
return;
}
response.writeHead(200, { "content-type": "application/octet-stream" });
response.end("terrain-bytes");
return;
}
if (target.hostname === "assets.ion.cesium.com" && target.pathname.startsWith("/us-east-1/asset_depot/96188/OpenStreetMap/CWT/2025-04-01/nested/")) {
const generation = target.searchParams.get("access_token") === state.credentials.v2.nested ? "v2"
: target.searchParams.get("access_token") === state.credentials.v1.nested ? "v1" : "";
if (!generation) {
response.writeHead(401, { "content-type": "application/json" });
response.end(JSON.stringify({ error: "nested_authorization_required" }));
return;
}
response.writeHead(200, { "content-type": "application/octet-stream" });
response.end(`nested-building-bytes-${generation}`);
return;
}
if (target.hostname === "assets.ion.cesium.com" && target.pathname.startsWith("/us-east-1/asset_depot/96188/OpenStreetMap/CWT/2025-04-01/")) {
const generation = target.searchParams.get("access_token") === state.credentials.v2.buildings ? "v2"
: target.searchParams.get("access_token") === state.credentials.v1.buildings ? "v1" : "";
if (!generation) {
response.writeHead(401, { "content-type": "application/json" });
response.end(JSON.stringify({ error: "buildings_authorization_required" }));
return;
}
if (target.pathname.endsWith("/tileset.json")) {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ asset: { version: "1.1" }, geometricError: 1, root: { geometricError: 0, content: { uri: "root.b3dm" } } }));
return;
}
response.writeHead(200, { "content-type": "application/octet-stream" });
response.end(`building-bytes-${generation}`);
return;
}
if (target.hostname === "assets.ion.cesium.com" && target.pathname.startsWith("/us-east-1/asset_depot/999/")) {
state.siblingCredentialObserved = target.searchParams.get("access_token") || "";
response.writeHead(401, { "content-type": "application/json" });
response.end(JSON.stringify({ error: "scope_isolation" }));
return;
}
const authorization = String(request.headers["x-nodedc-cesium-authorization"] || "");
const generation = authorization === `Bearer ${providerToken}` ? "v1" : authorization === `Bearer ${rotatedProviderToken}` ? "v2" : "";
if (target.hostname !== "api.cesium.com" || !generation) {
response.writeHead(401, { "content-type": "application/json" });
response.end(JSON.stringify({ error: "unauthorized" }));
return;
}
const assetId = target.pathname.match(/^\/v1\/assets\/(\d+)\/endpoint$/)?.[1];
state.endpointRequests.set(`${generation}:${assetId}`, (state.endpointRequests.get(`${generation}:${assetId}`) || 0) + 1);
const body = assetId === "1"
? { type: "TERRAIN", url: "https://assets.ion.cesium.com/us-east-1/asset_depot/1/CesiumWorldTerrain/v1.2/", accessToken: state.credentials[generation].terrain }
: assetId === "2"
? { type: "IMAGERY", externalType: "BING", options: { url: "https://dev.virtualearth.net/REST/v1/Imagery/Metadata/Aerial", key: state.credentials[generation].imagery, mapStyle: "Aerial" } }
: assetId === "96188"
? { type: "3DTILES", url: "https://assets.ion.cesium.com/us-east-1/asset_depot/96188/OpenStreetMap/CWT/2025-04-01/tileset.json?v=production-shaped", accessToken: state.credentials[generation].buildings }
: assetId === "999"
? { type: "3DTILES", url: "https://assets.ion.cesium.com/us-east-1/asset_depot/96188/OpenStreetMap/CWT/2025-04-01/nested/tileset.json?v=nested", accessToken: state.credentials[generation].nested }
: null;
if (!body) {
response.writeHead(404, { "content-type": "application/json" });
response.end(JSON.stringify({ error: "not_found" }));
return;
}
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify(body));
});
}
function createProviderState() {
const expiresAt = Math.floor(Date.now() / 1000) + 3600;
return {
credentials: {
v1: {
terrain: fakeJwt("terrain-v1", expiresAt),
imagery: "bing-private-endpoint-key-v1",
buildings: fakeJwt("buildings-v1", expiresAt),
nested: fakeJwt("nested-v1", expiresAt),
},
v2: {
terrain: fakeJwt("terrain-v2", expiresAt),
imagery: "bing-private-endpoint-key-v2",
buildings: fakeJwt("buildings-v2", expiresAt),
nested: fakeJwt("nested-v2", expiresAt),
},
},
endpointRequests: new Map(),
siblingCredentialObserved: null,
};
}
function fakeJwt(subject, exp) {
const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
return `${encode({ alg: "HS256", typ: "JWT" })}.${encode({ sub: subject, exp })}.test-signature`;
}
async function requestAdmin(port, secret, method, pathname, actorId, body = "", referer = "") {
const timestamp = String(Date.now());
const bodyHash = createHash("sha256").update(body).digest("hex");
const message = `nodedc.map-gateway.admin.v2\n${method}\n${pathname}\n${timestamp}\n${actorId}\n${bodyHash}\n${referer}`;
const signature = createHmac("sha256", secret).update(message).digest("hex");
const response = await fetch(`http://127.0.0.1:${port}${pathname}`, {
method,
headers: {
...(body ? { "content-type": "application/json" } : {}),
"x-nodedc-admin-timestamp": timestamp,
"x-nodedc-admin-actor": actorId,
"x-nodedc-admin-signature": signature,
...(referer ? { "x-nodedc-ion-referer": referer } : {}),
},
body: body || undefined,
});
const raw = await response.text();
return { response, raw, body: JSON.parse(raw) };
}
async function freePort() {
const server = createServer();
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
assert(address && typeof address === "object");
const port = address.port;
server.close();
await once(server, "close");
return port;
}
async function waitForGateway(port, child) {
let output = "";
child.stderr.on("data", (chunk) => { output += String(chunk); });
for (let attempt = 0; attempt < 60; attempt += 1) {
if (child.exitCode !== null) throw new Error(`gateway_exited:${child.exitCode}:${output}`);
try {
const response = await fetch(`http://127.0.0.1:${port}/healthz`);
if (response.ok) return;
} catch { /* service is still starting */ }
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`gateway_start_timeout:${output}`);
}
async function waitFor(predicate) {
for (let attempt = 0; attempt < 100; attempt += 1) {
if (predicate()) return;
await delay(20);
}
throw new Error("condition_timeout");
}
function delay(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}

View File

@ -0,0 +1,177 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { once } from "node:events";
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
import { createServer } from "node:http";
import { createServer as createNetServer } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { gzipSync } from "node:zlib";
const root = await mkdtemp(join(tmpdir(), "nodedc-map-gateway-live-cache-smoke-"));
const upstreamPort = await freePort();
const gatewayPort = await freePort();
let upstream;
let gateway;
let upstreamState = "live-1";
let gzipRevision = 1;
let upstreamRequests = 0;
try {
upstream = createServer((request, response) => {
upstreamRequests += 1;
if (request.url?.startsWith("/gzip/")) {
const compressed = gzipSync(JSON.stringify({ revision: gzipRevision, payload: "x".repeat(8_192) }));
response.writeHead(200, {
"content-type": "application/json",
"content-encoding": "gzip",
"content-length": String(compressed.byteLength),
"cache-control": "max-age=3600",
});
response.end(compressed);
return;
}
if (upstreamState === "unavailable") {
response.writeHead(503, { "content-type": "text/plain" });
response.end("unavailable");
return;
}
response.writeHead(200, { "content-type": "text/plain", "cache-control": "max-age=3600" });
response.end(upstreamState);
});
upstream.listen(upstreamPort, "127.0.0.1");
await once(upstream, "listening");
gateway = spawn(process.execPath, ["src/server.mjs"], {
cwd: new URL("..", import.meta.url),
env: {
...process.env,
NODE_ENV: "test",
PORT: String(gatewayPort),
MAP_CACHE_DIR: join(root, "cache"),
MAP_CACHE_MODE: "readwrite",
MAP_GATEWAY_ALLOW_ANONYMOUS: "true",
MAP_GATEWAY_UPSTREAM_ALLOWLIST: "127.0.0.1",
MAP_GATEWAY_TEST_ALLOW_HTTP_LOOPBACK: "true",
},
stdio: ["ignore", "pipe", "pipe"],
});
await waitForGateway(gatewayPort, gateway);
const source = `http://127.0.0.1:${upstreamPort}/terrain/layer.json`;
const first = await requestGateway(gatewayPort, source);
assert.equal(first.body, "live-1");
assert.equal(first.response.headers.get("x-nodedc-map-cache"), "live-record");
upstreamState = "live-2";
const second = await requestGateway(gatewayPort, source);
assert.equal(second.body, "live-1");
assert.equal(["live-record", "live-cache-hit"].includes(second.response.headers.get("x-nodedc-map-cache")), true);
assert.equal(upstreamRequests, 1);
const cachedEtag = second.response.headers.get("etag");
assert.match(cachedEtag || "", /^(?:W\/)?"[^"]+"$/);
const indexPath = join(root, "cache", "index.json");
const indexBeforeHits = await stat(indexPath);
const indexRawBeforeHits = await readFile(indexPath, "utf8");
const revalidated = await requestGateway(gatewayPort, source, { headers: { "if-none-match": cachedEtag } });
assert.equal(revalidated.response.status, 304);
assert.equal(revalidated.body, "");
assert.equal(revalidated.response.headers.get("etag"), cachedEtag);
assert.equal(upstreamRequests, 1);
// Browser route revisions bypass only the browser HTTP cache. They must not
// create a duplicate upstream URL or TileCache entry.
const versioned = await requestGateway(gatewayPort, `${source}?nodedc_client_revision=2`);
assert.equal(versioned.body, "live-1");
assert.equal(versioned.response.headers.get("x-nodedc-map-cache"), "live-cache-hit");
assert.equal(upstreamRequests, 1);
for (let attempt = 0; attempt < 4; attempt += 1) {
const repeated = await requestGateway(gatewayPort, source);
assert.equal(repeated.response.headers.get("x-nodedc-map-cache"), "live-cache-hit");
}
const indexAfterHits = await stat(indexPath);
const indexRawAfterHits = await readFile(indexPath, "utf8");
assert.equal(indexAfterHits.ino, indexBeforeHits.ino);
assert.equal(indexAfterHits.mtimeMs, indexBeforeHits.mtimeMs);
assert.equal(indexRawAfterHits, indexRawBeforeHits);
// `nodedc_cache_refresh=1` is the explicit opposite of "Не
// перезаписывать cache": it goes live and replaces the stored object.
const refreshed = await requestGateway(gatewayPort, `${source}?nodedc_cache_refresh=1`);
assert.equal(refreshed.body, "live-2");
assert.equal(refreshed.response.headers.get("x-nodedc-map-cache"), "live-refresh-record");
assert.equal(upstreamRequests, 2);
upstreamState = "unavailable";
const retained = await requestGateway(gatewayPort, source);
assert.equal(retained.body, "live-2");
assert.equal(retained.response.headers.get("x-nodedc-map-cache"), "live-cache-hit");
assert.equal(upstreamRequests, 2);
// The second response takes the live-network-first path with a cache entry
// already present. The Gateway must not forward the compressed upstream
// Content-Length after Node has decoded the body.
const gzipSource = `http://127.0.0.1:${upstreamPort}/gzip/layer.json`;
const gzipFirst = await requestGateway(gatewayPort, gzipSource);
assert.equal(JSON.parse(gzipFirst.body).revision, 1);
gzipRevision = 2;
const gzipSecond = await requestGateway(gatewayPort, gzipSource);
assert.equal(JSON.parse(gzipSecond.body).revision, 1);
assert.equal(
["live-record", "live-cache-hit"].includes(gzipSecond.response.headers.get("x-nodedc-map-cache")),
true,
);
const gzipRefresh = await requestGateway(gatewayPort, `${gzipSource}?nodedc_cache_refresh=1`);
assert.equal(JSON.parse(gzipRefresh.body).revision, 2);
// Node fetch decodes gzip while retaining the encoded upstream length. A
// streaming response cannot know the decoded length before completion, so
// it must use chunking instead of forwarding the unsafe encoded value.
assert.equal(gzipRefresh.response.headers.get("content-length"), null);
console.log("ok: TileCache hits do zero index writes, support ETag/304, and only explicit refresh replaces an object");
} finally {
await stop(gateway);
if (upstream?.listening) {
upstream.close();
await once(upstream, "close");
}
await rm(root, { recursive: true, force: true });
}
async function requestGateway(port, source, options = {}) {
const response = await fetch(`http://127.0.0.1:${port}/api/map/cache?url=${encodeURIComponent(source)}`, options);
return { response, body: await response.text() };
}
async function freePort() {
const server = createNetServer();
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
assert(address && typeof address === "object");
const port = address.port;
server.close();
await once(server, "close");
return port;
}
async function waitForGateway(port, child) {
let output = "";
child.stderr.on("data", (chunk) => { output += String(chunk); });
for (let attempt = 0; attempt < 80; attempt += 1) {
if (child.exitCode !== null) throw new Error(`gateway_exited:${child.exitCode}:${output}`);
try {
const response = await fetch(`http://127.0.0.1:${port}/healthz`);
if (response.ok) return;
} catch { /* service is still starting */ }
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`gateway_start_timeout:${output}`);
}
async function stop(child) {
if (child && !child.killed) {
child.kill("SIGTERM");
await once(child, "exit").catch(() => undefined);
}
}

View File

@ -0,0 +1,117 @@
import assert from "node:assert/strict";
import { once } from "node:events";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawn } from "node:child_process";
const root = await mkdtemp(join(tmpdir(), "nodedc-map-gateway-credential-smoke-"));
const cacheDir = join(root, "cache");
const port = await freePort();
let child;
try {
await mkdir(join(cacheDir, "ion-endpoints"), { recursive: true });
await writeFile(join(cacheDir, "index.json"), `${JSON.stringify({ version: 1, entries: {} })}\n`);
await writeFile(join(cacheDir, "ion-endpoints", "1.json"), `${JSON.stringify({
assetId: "1",
type: "TERRAIN",
url: "https://assets.ion.cesium.com/asset/1/",
accessToken: "terrain-private-token",
attributions: [],
savedAt: Date.now(),
})}\n`);
await writeFile(join(cacheDir, "ion-endpoints", "2.json"), `${JSON.stringify({
assetId: "2",
type: "IMAGERY",
externalType: "BING",
options: {
url: "https://dev.virtualearth.net/",
key: "bing-private-key",
mapStyle: "Aerial",
},
attributions: [],
savedAt: Date.now(),
})}\n`);
const expiredBuildingsToken = fakeJwt("expired-buildings", Math.floor(Date.now() / 1000) - 60);
await writeFile(join(cacheDir, "ion-endpoints", "96188.json"), `${JSON.stringify({
assetId: "96188",
type: "3DTILES",
url: "https://assets.ion.cesium.com/us-east-1/asset_depot/96188/OpenStreetMap/CWT/2025-04-01/tileset.json?v=expired",
accessToken: expiredBuildingsToken,
attributions: [],
savedAt: Date.now(),
})}\n`);
child = spawn(process.execPath, ["src/server.mjs"], {
cwd: new URL("..", import.meta.url),
env: {
...process.env,
PORT: String(port),
MAP_CACHE_DIR: cacheDir,
MAP_GATEWAY_ALLOW_ANONYMOUS: "true",
MAP_CACHE_MODE: "readwrite",
CESIUM_ION_TOKEN: "",
CESIUM_ION_ASSET_ALLOWLIST: "1,2,96188",
MAP_GATEWAY_UPSTREAM_ALLOWLIST: "assets.ion.cesium.com,dev.virtualearth.net",
},
stdio: ["ignore", "pipe", "pipe"],
});
await waitForGateway(port, child);
for (const assetId of ["1", "2"]) {
const response = await fetch(`http://127.0.0.1:${port}/api/map/ion/assets/${assetId}/endpoint`);
assert.equal(response.status, 200);
const body = await response.text();
assert.equal(body.includes("terrain-private-token"), false);
assert.equal(body.includes("bing-private-key"), false);
assert.equal(body.includes("accessToken"), false);
assert.equal(body.includes('"key"'), false);
const endpoint = JSON.parse(body);
assert.equal(endpoint.credentialMode, "gateway");
}
const expired = await fetch(`http://127.0.0.1:${port}/api/map/ion/assets/96188/endpoint`);
const expiredRaw = await expired.text();
assert.equal(expired.status, 503);
assert.equal(expiredRaw.includes(expiredBuildingsToken), false);
assert.equal(expiredRaw.includes("accessToken"), false);
console.log("ok: endpoint responses contain no credentials and a known-expired Ion JWT is never served");
} finally {
if (child && !child.killed) {
child.kill("SIGTERM");
await once(child, "exit").catch(() => undefined);
}
await rm(root, { recursive: true, force: true });
}
async function freePort() {
const server = createServer();
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
assert(address && typeof address === "object");
const port = address.port;
server.close();
await once(server, "close");
return port;
}
async function waitForGateway(port, child) {
let output = "";
child.stderr.on("data", (chunk) => { output += String(chunk); });
for (let attempt = 0; attempt < 60; attempt += 1) {
if (child.exitCode !== null) throw new Error(`gateway_exited:${child.exitCode}:${output}`);
try {
const response = await fetch(`http://127.0.0.1:${port}/healthz`);
if (response.ok) return;
} catch { /* service is still starting */ }
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`gateway_start_timeout:${output}`);
}
function fakeJwt(subject, exp) {
const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
return `${encode({ alg: "HS256", typ: "JWT" })}.${encode({ sub: subject, exp })}.test-signature`;
}

View File

@ -0,0 +1,246 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { once } from "node:events";
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
import { createServer, get as httpGet } from "node:http";
import { createServer as createNetServer } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
const root = await mkdtemp(join(tmpdir(), "nodedc-map-gateway-streaming-smoke-"));
const cacheDir = join(root, "cache");
const upstreamPort = await freePort();
const gatewayPort = await freePort();
const distinctCount = 12;
const state = {
sharedRequests: 0,
sharedEnded: false,
abortRequests: 0,
abortEnded: false,
brokenRequests: 0,
distinctRequests: 0,
distinctResponses: [],
};
let upstream;
let gateway;
try {
upstream = createServer((request, response) => {
const requestUrl = new URL(request.url || "/", `http://127.0.0.1:${upstreamPort}`);
if (requestUrl.pathname === "/slow/shared") {
state.sharedRequests += 1;
response.writeHead(200, { "content-type": "application/octet-stream", "cache-control": "max-age=3600" });
response.write("first-");
setTimeout(() => {
state.sharedEnded = true;
response.end("second");
}, 250);
return;
}
if (requestUrl.pathname === "/slow/abort") {
state.abortRequests += 1;
response.writeHead(200, { "content-type": "application/octet-stream", "cache-control": "max-age=3600" });
response.write("abort-first-");
setTimeout(() => {
state.abortEnded = true;
response.end("abort-second");
}, 250);
return;
}
if (requestUrl.pathname === "/broken") {
state.brokenRequests += 1;
response.writeHead(200, { "content-type": "application/octet-stream", "cache-control": "max-age=3600" });
response.write("partial-must-not-commit");
setTimeout(() => response.destroy(new Error("intentional_upstream_truncation")), 50);
return;
}
if (requestUrl.pathname.startsWith("/distinct/")) {
state.distinctRequests += 1;
state.distinctResponses.push({ id: requestUrl.pathname.split("/").pop(), response });
if (state.distinctResponses.length === distinctCount) {
for (const item of state.distinctResponses) {
item.response.writeHead(200, { "content-type": "text/plain", "cache-control": "max-age=3600" });
item.response.end(`distinct-${item.id}`);
}
}
return;
}
response.writeHead(404).end();
});
upstream.listen(upstreamPort, "127.0.0.1");
await once(upstream, "listening");
gateway = spawn(process.execPath, ["src/server.mjs"], {
cwd: new URL("..", import.meta.url),
env: {
...process.env,
NODE_ENV: "test",
PORT: String(gatewayPort),
MAP_CACHE_DIR: cacheDir,
MAP_CACHE_MODE: "readwrite",
MAP_GATEWAY_ALLOW_ANONYMOUS: "true",
MAP_GATEWAY_UPSTREAM_ALLOWLIST: "127.0.0.1",
MAP_GATEWAY_TEST_ALLOW_HTTP_LOOPBACK: "true",
},
stdio: ["ignore", "pipe", "pipe"],
});
await waitForGateway(gatewayPort, gateway);
const sharedSource = `http://127.0.0.1:${upstreamPort}/slow/shared`;
const sharedUrl = gatewayUrl(gatewayPort, sharedSource);
const leader = await fetch(sharedUrl);
assert.equal(leader.status, 200);
const reader = leader.body.getReader();
const first = await reader.read();
assert.equal(Buffer.from(first.value).toString("utf8"), "first-");
assert.equal(state.sharedEnded, false, "first byte must reach the leader before upstream end");
assert.equal(await indexEntryCount(cacheDir), 0, "partial body must not be published in index");
const followerPromise = fetch(sharedUrl).then(async (response) => ({ status: response.status, body: await response.text() }));
const leaderBody = `first-${await readRemaining(reader)}`;
const follower = await followerPromise;
assert.equal(leaderBody, "first-second");
assert.deepEqual(follower, { status: 200, body: "first-second" });
assert.equal(state.sharedRequests, 1, "same-key followers must share one upstream");
assert.equal(await indexEntryCount(cacheDir), 1, "complete body must be committed");
const abortSource = `http://127.0.0.1:${upstreamPort}/slow/abort`;
const abortUrl = gatewayUrl(gatewayPort, abortSource);
assert.equal(await abortAfterFirstChunk(abortUrl), "abort-first-");
const afterAbort = await fetch(abortUrl);
assert.equal(await afterAbort.text(), "abort-first-abort-second");
assert.equal(state.abortEnded, true);
assert.equal(state.abortRequests, 1, "client abort must not cancel the server-owned cache fill");
assert.equal(await indexEntryCount(cacheDir), 2);
const beforeBroken = await indexEntryCount(cacheDir);
const brokenSource = `http://127.0.0.1:${upstreamPort}/broken`;
let brokenRejected = false;
try {
const broken = await fetch(gatewayUrl(gatewayPort, brokenSource));
await broken.arrayBuffer();
} catch {
brokenRejected = true;
}
assert.equal(brokenRejected, true);
await waitFor(async () => (await tempFiles(cacheDir)).length === 0);
assert.equal(await indexEntryCount(cacheDir), beforeBroken, "truncated upstream must not publish an index entry");
assert.equal((await tempFiles(cacheDir)).length, 0, "truncated upstream must remove temp files");
assert.equal((await fetch(`http://127.0.0.1:${gatewayPort}/healthz`)).status, 200, "stream failure must not stop Gateway");
const diagnosticsBefore = (await (await fetch(`http://127.0.0.1:${gatewayPort}/healthz`)).json()).diagnostics;
const distinctBodies = await Promise.all(Array.from({ length: distinctCount }, async (_, index) => {
const response = await fetch(gatewayUrl(gatewayPort, `http://127.0.0.1:${upstreamPort}/distinct/${index}`));
return response.text();
}));
assert.deepEqual(distinctBodies.sort(), Array.from({ length: distinctCount }, (_, index) => `distinct-${index}`).sort());
await waitFor(async () => await indexEntryCount(cacheDir) === beforeBroken + distinctCount);
const diagnosticsAfter = (await (await fetch(`http://127.0.0.1:${gatewayPort}/healthz`)).json()).diagnostics;
const snapshotDelta = diagnosticsAfter.indexSnapshots - diagnosticsBefore.indexSnapshots;
assert.equal(state.distinctRequests, distinctCount);
assert.equal(snapshotDelta >= 1 && snapshotDelta <= 2, true, `expected <=2 coalesced snapshots, got ${snapshotDelta}`);
console.log("ok: cold miss streams immediately, singleflight survives abort, partial bodies stay private, and index snapshots coalesce");
} finally {
await stop(gateway);
if (upstream?.listening) {
upstream.close();
await once(upstream, "close");
}
await rm(root, { recursive: true, force: true });
}
function gatewayUrl(port, source) {
return `http://127.0.0.1:${port}/api/map/cache?url=${encodeURIComponent(source)}`;
}
async function readRemaining(reader) {
const chunks = [];
while (true) {
const next = await reader.read();
if (next.done) break;
chunks.push(Buffer.from(next.value));
}
return Buffer.concat(chunks).toString("utf8");
}
function abortAfterFirstChunk(url) {
return new Promise((resolve, reject) => {
let resolved = false;
const request = httpGet(url, (response) => {
response.once("data", (chunk) => {
resolved = true;
resolve(String(chunk));
response.destroy();
});
response.once("error", (error) => { if (!resolved) reject(error); });
});
request.once("error", (error) => { if (!resolved) reject(error); });
});
}
async function indexEntryCount(dir) {
try {
const index = JSON.parse(await readFile(join(dir, "index.json"), "utf8"));
return Object.keys(index.entries || {}).length;
} catch (error) {
if (error?.code === "ENOENT") return 0;
throw error;
}
}
async function tempFiles(dir) {
const found = [];
async function walk(current) {
let entries;
try { entries = await readdir(current, { withFileTypes: true }); }
catch (error) { if (error?.code === "ENOENT") return; throw error; }
for (const entry of entries) {
const path = join(current, entry.name);
if (entry.isDirectory()) await walk(path);
else if (entry.name.includes(".tmp")) found.push(path);
}
}
await walk(dir);
return found;
}
async function waitFor(predicate) {
for (let attempt = 0; attempt < 200; attempt += 1) {
if (await predicate()) return;
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error("condition_timeout");
}
async function freePort() {
const server = createNetServer();
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
assert(address && typeof address === "object");
const port = address.port;
server.close();
await once(server, "close");
return port;
}
async function waitForGateway(port, child) {
let output = "";
child.stderr.on("data", (chunk) => { output += String(chunk); });
for (let attempt = 0; attempt < 80; attempt += 1) {
if (child.exitCode !== null) throw new Error(`gateway_exited:${child.exitCode}:${output}`);
try {
const response = await fetch(`http://127.0.0.1:${port}/healthz`);
if (response.ok) return;
} catch { /* service is still starting */ }
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`gateway_start_timeout:${output}`);
}
async function stop(child) {
if (child && !child.killed) {
child.kill("SIGTERM");
await once(child, "exit").catch(() => undefined);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,11 @@
DC AMD Proxy pairing
Use only after the NAS dc-amd-proxy service is deployed and reports
state=awaiting_pair.
Open PowerShell on the AMD machine and run:
powershell -NoProfile -ExecutionPolicy Bypass -File .\pair-dc-amd-proxy.ps1
The script reads the connector credential only inside the local Docker
container, sends it once to the NAS LAN address, and does not print it.

View File

@ -0,0 +1,2 @@
b42a43ad743cf72f98fcea360a605dd6634f99fc4c238f118b96968e3b848866 pair-dc-amd-proxy.ps1
c626a016f74ddb821bfb919afa0a463589d9adbba5715ba5577db4b64cd05a29 README.txt

View File

@ -0,0 +1,71 @@
[CmdletBinding()]
param(
[string]$NasAddress = '172.22.0.222',
[int]$Port = 8790
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Windows PowerShell 5.1 does not necessarily autoload this assembly, unlike
# newer PowerShell editions. Load it before resolving HttpClientHandler.
Add-Type -AssemblyName System.Net.Http
if ($Port -lt 1 -or $Port -gt 65535) {
throw "Port must be between 1 and 65535: $Port"
}
$null = [Net.IPAddress]::Parse($NasAddress)
$docker = (Get-Command docker -CommandType Application -ErrorAction Stop | Select-Object -First 1).Path
if ([string]::IsNullOrWhiteSpace($docker)) {
throw 'Docker CLI executable could not be resolved.'
}
# The token is read inside the already-running connector installation. It is
# never written to disk by this script and never printed to the console.
$token = $null
$body = $null
$handler = $null
$client = $null
$content = $null
$response = $null
try {
$token = (& $docker exec dc-amd-connector node -e "process.stdout.write(require('fs').readFileSync('/run/dc-amd-secrets/connector-access','utf8').trim())").Trim()
if ($LASTEXITCODE -ne 0) {
throw 'Unable to read the local connector access value. Check that dc-amd-connector is running.'
}
if ($token -notmatch '^[A-Za-z0-9_-]{48,256}$') {
throw 'The local connector access value has an invalid format.'
}
$body = '{"connectorAccessToken":"' + $token + '"}'
$handler = [System.Net.Http.HttpClientHandler]::new()
# Pair only with the NAS LAN address. Do not use a Windows or VPN proxy for
# this one-time local handoff.
$handler.UseProxy = $false
$client = [System.Net.Http.HttpClient]::new($handler)
$client.Timeout = [TimeSpan]::FromSeconds(20)
$content = [System.Net.Http.StringContent]::new($body, [System.Text.Encoding]::UTF8, 'application/json')
$response = $client.PostAsync("http://$NasAddress`:$Port/api/pair", $content).GetAwaiter().GetResult()
$responseBody = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
if ([int]$response.StatusCode -ne 201) {
throw "NAS pairing was rejected (HTTP $([int]$response.StatusCode))."
}
$result = $responseBody | ConvertFrom-Json
if ($result.ok -ne $true -or $result.state -ne 'paired') {
throw 'NAS pairing returned an unexpected result.'
}
Write-Host 'AMD connector paired with NAS. No secret value was displayed.'
}
finally {
if ($response) { $response.Dispose() }
if ($content) { $content.Dispose() }
if ($client) { $client.Dispose() }
if ($handler) { $handler.Dispose() }
$body = $null
$token = $null
Remove-Variable -Name body -ErrorAction SilentlyContinue
Remove-Variable -Name token -ErrorAction SilentlyContinue
}