<# Kong <-> Azure APIM migration diff tester Local UI server + HTTP proxy (PowerShell 5.1 / 7.x compatible) Usage: powershell -ExecutionPolicy Bypass -File .\run.ps1 powershell -ExecutionPolicy Bypass -File .\run.ps1 -Port 9000 -NoBrowser NOTE: Console messages are intentionally ASCII-only. PowerShell 5.1 mis-decodes non-ASCII characters in BOM-less UTF-8 scripts, so all Korean UI text lives in index.html instead. #> [CmdletBinding()] param( [int]$Port = 8080, [string]$Config = 'config.json', [switch]$NoBrowser ) $ErrorActionPreference = 'Stop' $ScriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Definition $IndexPath = Join-Path $ScriptRoot 'index.html' # --------------------------------------------------------------------------- # TLS setup: allow self-signed / expired certificates (equivalent to curl -k) # --------------------------------------------------------------------------- try { $protocols = [System.Net.SecurityProtocolType]::Tls12 foreach ($name in @('Tls13', 'Tls11', 'Tls')) { try { $protocols = $protocols -bor [System.Net.SecurityProtocolType]::$name } catch { } } [System.Net.ServicePointManager]::SecurityProtocol = $protocols } catch { Write-Warning "Could not set TLS protocols: $($_.Exception.Message)" } [System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true } [System.Net.ServicePointManager]::Expect100Continue = $false [System.Net.ServicePointManager]::DefaultConnectionLimit = 32 # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- function ConvertTo-HashtableSafe { param($InputObject) $result = @{} if ($null -eq $InputObject) { return $result } if ($InputObject -is [System.Collections.IDictionary]) { foreach ($key in $InputObject.Keys) { $result[[string]$key] = $InputObject[$key] } return $result } foreach ($prop in $InputObject.PSObject.Properties) { $result[$prop.Name] = $prop.Value } return $result } # Config files are every *.json sitting next to run.ps1, enumerated fresh on # each request so a newly dropped file shows up without a restart. function Get-ConfigFiles { $names = @(Get-ChildItem -Path $ScriptRoot -Filter '*.json' -File -ErrorAction SilentlyContinue | Sort-Object Name | ForEach-Object { $_.Name }) return , $names } function Resolve-DefaultConfig { param([string[]]$Available) if ($Available -contains $Config) { return $Config } if ($Available -contains 'config.json') { return 'config.json' } if ($Available.Count -gt 0) { return $Available[0] } return $null } # --- Config document assembly ------------------------------------------- # A config file may declare its APIs inline (root "apis" or "groups") and/or # pull in group files through "include". Everything is flattened into a single # "groups" array here so the browser only ever sees one shape. function Read-JsonFile { param([string]$FullPath, [string]$DisplayName) if (-not (Test-Path $FullPath)) { throw "Config file not found: $DisplayName" } $bytes = [System.IO.File]::ReadAllBytes($FullPath) $text = [System.Text.Encoding]::UTF8.GetString($bytes) if ($text.Length -gt 0 -and [int]$text[0] -eq 0xFEFF) { $text = $text.Substring(1) } try { return $text | ConvertFrom-Json } catch { throw "$DisplayName is not valid JSON: $($_.Exception.Message)" } } function Resolve-IncludePath { param([string]$Relative) if ([string]::IsNullOrWhiteSpace($Relative)) { throw 'include entry is empty' } if ([System.IO.Path]::IsPathRooted($Relative)) { throw "include path must be relative: $Relative" } $rootFull = [System.IO.Path]::GetFullPath($ScriptRoot).TrimEnd('\') + '\' $full = [System.IO.Path]::GetFullPath((Join-Path $ScriptRoot $Relative)) if (-not $full.StartsWith($rootFull, [System.StringComparison]::OrdinalIgnoreCase)) { throw "include path escapes the tool directory: $Relative" } return $full } function Get-NormalizedGroup { param($Group, [string]$Source, [string]$FallbackName) if ($null -eq $Group) { throw "Empty group in $Source" } $names = $Group.PSObject.Properties.Name $name = if ($names -contains 'name' -and $Group.name) { [string]$Group.name } else { $FallbackName } $label = if ($names -contains 'label' -and $Group.label) { [string]$Group.label } else { $name } if ($names -notcontains 'apis') { throw "Group '$name' in $Source has no 'apis' array" } return [ordered]@{ name = $name label = $label source = $Source apis = @($Group.apis) } } function Get-ConfigDocument { param([string]$FileName) $doc = Read-JsonFile (Join-Path $ScriptRoot $FileName) $FileName $names = $doc.PSObject.Properties.Name $groups = @() # Root-level "apis" stays supported and becomes an implicit single group. if ($names -contains 'apis' -and $doc.apis) { $groups += [ordered]@{ name = 'default'; label = 'default'; source = $FileName; apis = @($doc.apis) } } if ($names -contains 'groups' -and $doc.groups) { $i = 0 foreach ($g in @($doc.groups)) { $groups += Get-NormalizedGroup $g $FileName "group$i" $i++ } } # Includes are resolved one level deep only: a group file cannot include # further files, which keeps cycles impossible. if ($names -contains 'include' -and $doc.include) { foreach ($rel in @($doc.include)) { $relPath = [string]$rel $full = Resolve-IncludePath $relPath $sub = Read-JsonFile $full $relPath $subNames = $sub.PSObject.Properties.Name $fallback = [System.IO.Path]::GetFileNameWithoutExtension($relPath) if ($subNames -contains 'include') { throw "Nested include is not supported ($relPath)" } if ($subNames -contains 'groups' -and $sub.groups) { $i = 0 foreach ($g in @($sub.groups)) { $groups += Get-NormalizedGroup $g $relPath "$fallback$i" $i++ } } else { $groups += Get-NormalizedGroup $sub $relPath $fallback } } } $result = [ordered]@{ timeoutMs = if ($names -contains 'timeoutMs' -and $doc.timeoutMs) { [int]$doc.timeoutMs } else { 30000 } requestIntervalMs = if ($names -contains 'requestIntervalMs' -and $null -ne $doc.requestIntervalMs) { [int]$doc.requestIntervalMs } else { 0 } variables = if ($names -contains 'variables') { $doc.variables } else { $null } targets = if ($names -contains 'targets') { $doc.targets } else { $null } groups = @($groups) } return $result } function Get-CharsetEncoding { param([string]$ContentType) # HttpWebResponse.CharacterSet defaults to ISO-8859-1 when no charset is # present, so parse Content-Type ourselves and fall back to UTF-8. if ($ContentType -and $ContentType -match 'charset\s*=\s*"?([^;"\s]+)') { try { return [System.Text.Encoding]::GetEncoding($Matches[1]) } catch { } } return [System.Text.Encoding]::UTF8 } # Largest body still sent to the browser inline as base64. Bigger bodies are # compared by hash only, so a huge download cannot wedge the page. $MaxInlineBytes = 8MB function Test-TextContentType { param([string]$ContentType) # No Content-Type at all: treat as text so plain responses keep diffing. if ([string]::IsNullOrWhiteSpace($ContentType)) { return $true } $ct = $ContentType.ToLowerInvariant() if ($ct.StartsWith('text/')) { return $true } # image/svg+xml lands here on purpose: SVG is text, and a line diff says # far more about it than a byte hash would. foreach ($token in @('json', 'xml', 'javascript', 'ecmascript', 'x-www-form-urlencoded', 'csv', 'yaml', 'plain', 'html')) { if ($ct.Contains($token)) { return $true } } return $false } function Get-Sha256Hex { param([byte[]]$Bytes) $sha = [System.Security.Cryptography.SHA256]::Create() try { return ([System.BitConverter]::ToString($sha.ComputeHash($Bytes))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() } } # --------------------------------------------------------------------------- # The actual outbound HTTP call. # HttpWebRequest is used (not Invoke-WebRequest) because we need: # - the Host header on an IP-based URL -> $req.Host # - 4xx/5xx bodies without an exception -> WebException.Response # - identical behaviour on PS 5.1 and 7.x # --------------------------------------------------------------------------- function Invoke-ProxyRequest { param( [string]$Method, [string]$Url, $Headers, [string]$Body, [int]$TimeoutMs = 30000 ) $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() $result = [ordered]@{ status = 0 statusText = '' headers = @{} contentType = '' isBinary = $false body = '' bodyBase64 = $null bodyTooLarge = $false bodyBytes = 0 bodySha256 = '' elapsedMs = 0 error = $null } $response = $null try { $method = $Method.ToUpperInvariant() $req = [System.Net.HttpWebRequest]::Create($Url) $req.Method = $method $req.Timeout = $TimeoutMs $req.ReadWriteTimeout = $TimeoutMs $req.AllowAutoRedirect = $false $req.KeepAlive = $false $req.AutomaticDecompression = [System.Net.DecompressionMethods]::GZip -bor [System.Net.DecompressionMethods]::Deflate try { $req.ServicePoint.Expect100Continue = $false } catch { } $headerTable = ConvertTo-HashtableSafe $Headers foreach ($name in $headerTable.Keys) { $value = [string]$headerTable[$name] if ([string]::IsNullOrEmpty($value)) { continue } # Restricted headers must go through dedicated properties. switch ($name.ToLowerInvariant()) { 'host' { $req.Host = $value } 'content-type' { $req.ContentType = $value } 'accept' { $req.Accept = $value } 'user-agent' { $req.UserAgent = $value } 'referer' { $req.Referer = $value } 'content-length' { } 'connection' { } 'transfer-encoding' { } 'expect' { } 'date' { } 'range' { } 'if-modified-since' { } default { $req.Headers.Add($name, $value) } } } if ($method -eq 'POST' -or $method -eq 'PUT' -or $method -eq 'PATCH') { if ([string]::IsNullOrEmpty($Body)) { $req.ContentLength = 0 } else { if (-not $req.ContentType) { $req.ContentType = 'application/x-www-form-urlencoded' } $payload = [System.Text.Encoding]::UTF8.GetBytes($Body) $req.ContentLength = $payload.Length $requestStream = $req.GetRequestStream() try { $requestStream.Write($payload, 0, $payload.Length) } finally { $requestStream.Close() } } } try { $response = $req.GetResponse() } catch [System.Net.WebException] { # 4xx / 5xx still carry a usable response object. if ($_.Exception.Response) { $response = $_.Exception.Response } else { throw } } $httpResponse = [System.Net.HttpWebResponse]$response $result.status = [int]$httpResponse.StatusCode $result.statusText = [string]$httpResponse.StatusDescription $responseHeaders = @{} foreach ($key in $httpResponse.Headers.AllKeys) { $responseHeaders[$key] = $httpResponse.Headers[$key] } $result.headers = $responseHeaders # Always take the raw bytes first. Text-decoding an image would corrupt # it and make the comparison meaningless. $stream = $httpResponse.GetResponseStream() $buffer = New-Object System.IO.MemoryStream try { $stream.CopyTo($buffer) } finally { $stream.Close() } $bytes = $buffer.ToArray() $buffer.Close() $contentType = [string]$httpResponse.ContentType $result.contentType = $contentType $result.bodyBytes = $bytes.Length $result.bodySha256 = Get-Sha256Hex $bytes if (Test-TextContentType $contentType) { $result.isBinary = $false $result.body = (Get-CharsetEncoding $contentType).GetString($bytes) } else { $result.isBinary = $true if ($bytes.Length -le $MaxInlineBytes) { $result.bodyBase64 = [System.Convert]::ToBase64String($bytes) } else { $result.bodyTooLarge = $true } } } catch { $result.error = $_.Exception.Message $inner = $_.Exception.InnerException if ($inner) { $result.error = "$($result.error) ($($inner.Message))" } } finally { if ($response) { try { $response.Close() } catch { } } $stopwatch.Stop() $result.elapsedMs = [int]$stopwatch.ElapsedMilliseconds } return $result } # --------------------------------------------------------------------------- # Response writers # --------------------------------------------------------------------------- function Send-Bytes { param($Context, [int]$StatusCode, [string]$ContentType, [byte[]]$Payload) $Context.Response.StatusCode = $StatusCode $Context.Response.ContentType = $ContentType $Context.Response.ContentLength64 = $Payload.Length $Context.Response.Headers['Cache-Control'] = 'no-store' $Context.Response.OutputStream.Write($Payload, 0, $Payload.Length) } function Send-Text { param($Context, [int]$StatusCode, [string]$ContentType, [string]$Text) Send-Bytes $Context $StatusCode $ContentType ([System.Text.Encoding]::UTF8.GetBytes($Text)) } function Send-Json { param($Context, [int]$StatusCode, $Payload) $json = $Payload | ConvertTo-Json -Depth 12 -Compress Send-Text $Context $StatusCode 'application/json; charset=utf-8' $json } # --------------------------------------------------------------------------- # Bind the listener (localhost only -> no admin rights required) # --------------------------------------------------------------------------- $listener = $null for ($candidate = $Port; $candidate -lt $Port + 25; $candidate++) { $attempt = New-Object System.Net.HttpListener $attempt.Prefixes.Add("http://localhost:$candidate/") try { $attempt.Start() $listener = $attempt $Port = $candidate break } catch { try { $attempt.Close() } catch { } } } if (-not $listener) { Write-Error "Could not bind any port in range $Port..$($Port + 24). Another process may be using them." exit 1 } $baseUrl = "http://localhost:$Port/" Write-Host '' Write-Host ' Kong <-> Azure APIM diff tester' -ForegroundColor Cyan Write-Host ' -------------------------------' Write-Host " URL : $baseUrl" -ForegroundColor Green Write-Host " index : $IndexPath" $availableAtStart = Get-ConfigFiles if ($availableAtStart.Count -gt 0) { Write-Host " configs : $($availableAtStart -join ', ')" Write-Host " default : $(Resolve-DefaultConfig $availableAtStart)" } else { Write-Host ' WARNING : no *.json config file found next to run.ps1' -ForegroundColor Yellow } if (-not (Test-Path $IndexPath)) { Write-Host ' WARNING : index.html not found' -ForegroundColor Yellow } Write-Host ' Press Ctrl+C to stop.' -ForegroundColor DarkGray Write-Host '' if (-not $NoBrowser) { try { Start-Process $baseUrl | Out-Null } catch { Write-Host " (Could not open browser automatically: $baseUrl)" } } # --------------------------------------------------------------------------- # Request loop. # BeginGetContext + WaitOne polling keeps Ctrl+C responsive; a blocking # GetContext() call cannot be interrupted from the PowerShell host. # --------------------------------------------------------------------------- try { while ($listener.IsListening) { $async = $listener.BeginGetContext($null, $null) while (-not $async.AsyncWaitHandle.WaitOne(200)) { if (-not $listener.IsListening) { break } } if (-not $listener.IsListening) { break } $context = $listener.EndGetContext($async) try { $path = $context.Request.Url.AbsolutePath $method = $context.Request.HttpMethod if ($path -eq '/' -or $path -eq '/index.html') { if (Test-Path $IndexPath) { Send-Bytes $context 200 'text/html; charset=utf-8' ([System.IO.File]::ReadAllBytes($IndexPath)) } else { Send-Text $context 404 'text/plain; charset=utf-8' 'index.html not found next to run.ps1' } } elseif ($path -eq '/configs') { $available = Get-ConfigFiles Send-Json $context 200 @{ files = $available default = Resolve-DefaultConfig $available } } elseif ($path -eq '/config') { $available = Get-ConfigFiles $requested = [string]$context.Request.QueryString['file'] if ([string]::IsNullOrWhiteSpace($requested)) { $requested = Resolve-DefaultConfig $available } if (-not $requested) { Send-Json $context 500 @{ error = "No *.json config file found next to run.ps1 ($ScriptRoot)" } } # Membership in the enumerated list is the only accepted proof # of a legal name -- it rules out traversal and absolute paths. elseif ($available -notcontains $requested) { # The rejected name is deliberately not echoed back: it is # unvalidated input and may contain control characters. Send-Json $context 400 @{ error = 'Unknown config file'; files = $available } } else { try { Send-Json $context 200 (Get-ConfigDocument $requested) } catch { Send-Json $context 500 @{ error = $_.Exception.Message } } } } elseif ($path -eq '/proxy' -and $method -eq 'POST') { $reader = New-Object System.IO.StreamReader($context.Request.InputStream, [System.Text.Encoding]::UTF8) $requestBody = $reader.ReadToEnd() $reader.Close() try { $spec = $requestBody | ConvertFrom-Json } catch { Send-Json $context 400 @{ error = "Invalid proxy payload: $($_.Exception.Message)" } continue } $timeout = 30000 if ($spec.PSObject.Properties.Name -contains 'timeoutMs' -and $spec.timeoutMs) { $timeout = [int]$spec.timeoutMs } $bodyText = '' if ($spec.PSObject.Properties.Name -contains 'body' -and $null -ne $spec.body) { $bodyText = [string]$spec.body } $proxyResult = Invoke-ProxyRequest -Method $spec.method -Url $spec.url -Headers $spec.headers -Body $bodyText -TimeoutMs $timeout Send-Json $context 200 $proxyResult } elseif ($path -eq '/ping') { Send-Json $context 200 @{ ok = $true; port = $Port } } else { Send-Text $context 404 'text/plain; charset=utf-8' "Not found: $path" } } catch { try { Send-Json $context 500 @{ error = $_.Exception.Message } } catch { } } finally { try { $context.Response.OutputStream.Close() } catch { } try { $context.Response.Close() } catch { } } } } finally { Write-Host '' Write-Host ' Shutting down...' -ForegroundColor DarkGray try { $listener.Stop() } catch { } try { $listener.Close() } catch { } [System.Net.ServicePointManager]::ServerCertificateValidationCallback = $null Write-Host ' Stopped.' -ForegroundColor DarkGray }