feat(map): add cache-first Cesium gateway and AMD egress
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
.env
|
||||
runtime/
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
COPY server.mjs ./
|
||||
|
||||
USER node
|
||||
|
||||
EXPOSE 8791
|
||||
|
||||
CMD ["node", "server.mjs"]
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
dc-amd-connector-20260715-004
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "dc-amd-connector",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.mjs"
|
||||
}
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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.'
|
||||
Reference in New Issue
Block a user