iXserverLoopEnv/scenarios/s2-auth-ip-whitelist.ps1

278 lines
16 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<#
S2(IP 白名单部分):第三方 client 的 oauth_client.ip_whitelist 与 Http_TrustedProxyIps 经真 IIS Express 生效,
token 端点 /api/oauth/token 与回复 API send-txt 两条路都验。S2 其余鉴权负例(错 secret / 没 scope / 过期 token /
别的服务号)还没写进来。
组别:
0 白名单为空:不限来源,IG → 第三方 echo → IG 正常
A 白名单不含本机:token 401 invalid_client;未过期 token 调 send-txt 401(WWW-Authenticate + problem+json);
伪造 X-Forwarded-For 无效;真链路里第三方的回复被拒、IG 用户一条都收不到
B 白名单只有 127.0.0.1:127.0.0.1 放行,::1 被拒(IPv4 / IPv6 本机地址不互通;第三方客户端经 localhost 进来是 ::1)
C 混写「203.0.113.10; ::1 127.0.0.0/8」:分隔符与 CIDR 都认,真链路恢复
D 本机登记为可信代理:XFF 被采信,取最右边的非代理段,夹着解析不了的段就退回直连地址
会临时改两处,结束时(含失败、抛异常)都还原:
- iXserverLoop.oauth_client(setsail).ip_whitelist —— 还原成开跑前的值
- run\ixserver\Web.config 的 Http_TrustedProxyIps —— 先备份成 Web.config.s2-bak,改动会让站点重启一次;
上次中途被打断留下的备份,开跑时先还原
oauth_client 有 30 秒缓存,每次改表都要轮询到生效,加上一次站点重启,整个场景约 2~3 分钟。
直连 iXserver 用 127.0.0.1 / [::1]:IIS Express 以 /path 模式只绑了 localhost,Host 头必须是 localhost:<端口>。
前提:.\loop.ps1 up 各角色都健康,chatbot 以 --auto-reply echo 运行。
通过退出 0,失败退出 1;两种情况都在 run\reports\ 生成场景报告(观察台在跑时)。
用法:.\scenarios\s2-auth-ip-whitelist.ps1 [-TimeoutSec 60] [-NoReport]
#>
param([int]$TimeoutSec = 60, [switch]$NoReport)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$LoopNoReport = [bool]$NoReport
. (Join-Path $PSScriptRoot '_lib.ps1')
Add-Type -AssemblyName System.Net.Http
$Cfg = $LoopCfg
$port = $Cfg.Ports.Ixserver
$base4 = "http://127.0.0.1:$port"
$base6 = "http://[::1]:$port"
$ig = "http://127.0.0.1:$($Cfg.Ports.IgMock)"
$cb = "http://127.0.0.1:$($Cfg.Ports.Chatbot)"
$clientId = $Cfg.Secrets.SetsailClientId
$webConfig = Join-Path $PSScriptRoot '..\run\ixserver\Web.config'
$webConfigBackup = "$webConfig.s2-bak"
$http = New-Object System.Net.Http.HttpClient
$http.Timeout = [TimeSpan]::FromSeconds(30)
$script:failures = New-Object System.Collections.ArrayList
function Check([string]$name, [bool]$ok, [string]$detail = '') {
Add-LoopStep $name $ok $detail
if (-not $ok) { [void]$script:failures.Add($name) }
Write-Host ("[{0}] {1} {2}" -f $(if ($ok) { 'PASS' } else { 'FAIL' }), $name, $detail) -ForegroundColor $(if ($ok) { 'Green' } else { 'Red' })
}
# ============================================================================ 改环境
function Invoke-LoopSql([string]$sql, [switch]$Scalar) {
$s = $Cfg.Sql
$conn = New-Object System.Data.SqlClient.SqlConnection "Server=$($s.Server);Database=$($s.IxserverDb);User Id=$($s.User);Password=$($s.Password)"
$conn.Open()
try {
$cmd = $conn.CreateCommand(); $cmd.CommandText = $sql
if ($Scalar) { $v = $cmd.ExecuteScalar(); if ($v -is [DBNull]) { return $null }; return $v }
[void]$cmd.ExecuteNonQuery()
}
finally { $conn.Close() }
}
function Get-Whitelist { Invoke-LoopSql "SELECT ip_whitelist FROM dbo.oauth_client WHERE client_id = N'$clientId'" -Scalar }
function Set-Whitelist($value) {
$v = if ($null -eq $value) { 'NULL' } else { "N'" + $value.Replace("'", "''") + "'" }
Invoke-LoopSql "UPDATE dbo.oauth_client SET ip_whitelist = $v, update_datetime = GETDATE() WHERE client_id = N'$clientId'"
Write-Host "--- ip_whitelist = $(if ($null -eq $value) { 'NULL' } else { "'$value'" })" -ForegroundColor Cyan
}
# Web.config 改动让站点重启:首个请求才触发 Application_Start,内嵌的 WebSocket 也是那时起的,所以两样都等
function Wait-IxserverAlive([int]$timeoutSec = 180) {
$deadline = (Get-Date).AddSeconds($timeoutSec)
while ((Get-Date) -lt $deadline) {
try {
if ((Send-Http 'GET' "$base4/" $null $null).Status -lt 500) {
$tcp = New-Object System.Net.Sockets.TcpClient
try { $tcp.Connect('127.0.0.1', $Cfg.Ports.WebSocket); return $true } catch { } finally { $tcp.Close() }
}
}
catch { }
Start-Sleep -Seconds 2
}
throw "iXserver 在 $timeoutSec 秒内没恢复(HTTP :$port / WebSocket :$($Cfg.Ports.WebSocket))"
}
function Set-TrustedProxies([string]$value) {
[xml]$x = Get-Content $webConfig -Raw -Encoding UTF8
$node = $x.configuration.appSettings.add | Where-Object { $_.key -eq 'Http_TrustedProxyIps' }
if (-not $node) { throw 'run\ixserver\Web.config 里没有 Http_TrustedProxyIps 键' }
$node.value = $value
$x.Save((Resolve-Path $webConfig).Path)
Write-Host "--- Http_TrustedProxyIps = '$value'(站点重启)" -ForegroundColor Cyan
Start-Sleep -Seconds 3
[void](Wait-IxserverAlive)
}
function Restore-WebConfig {
if (-not (Test-Path $webConfigBackup)) { return }
$same = (Get-FileHash $webConfigBackup).Hash -eq (Get-FileHash $webConfig).Hash
if (-not $same) { Copy-Item $webConfigBackup $webConfig -Force }
Remove-Item $webConfigBackup -Force
if ($same) { return } # 没改过就不必让站点白重启一次
Write-Host '--- Web.config 已还原(站点重启)' -ForegroundColor Cyan
Start-Sleep -Seconds 3
[void](Wait-IxserverAlive)
}
# ============================================================================ 直连 iXserver
function Send-Http([string]$method, [string]$url, $content, [hashtable]$headers) {
$req = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::new($method), $url)
$req.Headers.Host = "localhost:$port"
if ($headers) { foreach ($k in $headers.Keys) { [void]$req.Headers.TryAddWithoutValidation($k, $headers[$k]) } }
if ($content) { $req.Content = $content }
$resp = $http.SendAsync($req).GetAwaiter().GetResult()
[pscustomobject]@{
Status = [int]$resp.StatusCode
Body = $resp.Content.ReadAsStringAsync().GetAwaiter().GetResult()
WwwAuth = ($resp.Headers.WwwAuthenticate | ForEach-Object { $_.Scheme }) -join ','
ContentType = if ($resp.Content.Headers.ContentType) { $resp.Content.Headers.ContentType.MediaType } else { '' }
}
}
function Get-Token([string]$base, [string]$xff = '') {
$form = New-Object 'System.Collections.Generic.Dictionary[string,string]'
$form['grant_type'] = 'client_credentials'; $form['client_id'] = $clientId; $form['client_secret'] = $Cfg.Secrets.SetsailClientSecret
$h = @{}; if ($xff) { $h['X-Forwarded-For'] = $xff }
$r = Send-Http 'POST' "$base/api/oauth/token" ([System.Net.Http.FormUrlEncodedContent]::new($form)) $h
$token = if ($r.Status -eq 200) { ($r.Body | ConvertFrom-Json).access_token } else { '' }
$r | Add-Member -NotePropertyName Token -NotePropertyValue $token -PassThru
}
function Send-Txt([string]$base, [string]$token, [string]$ticketId, [string]$message, [string]$xff = '') {
$json = @{ ticket_id = $ticketId; message = $message } | ConvertTo-Json -Compress
$h = @{ Authorization = "Bearer $token" }; if ($xff) { $h['X-Forwarded-For'] = $xff }
Send-Http 'POST' "$base/api/chatbot/ticket/send-txt" ([System.Net.Http.StringContent]::new($json, [Text.Encoding]::UTF8, 'application/json')) $h
}
# oauth_client 有 30 秒缓存:改表后轮询到预期状态,返回用了几秒,超时返回 -1
function Wait-TokenStatus([string]$base, [int]$expected, [int]$timeoutSec = 60) {
$sw = [Diagnostics.Stopwatch]::StartNew()
while ($sw.Elapsed.TotalSeconds -lt $timeoutSec) {
if ((Get-Token $base).Status -eq $expected) { return [int]$sw.Elapsed.TotalSeconds }
Start-Sleep -Seconds 2
}
return -1
}
# ============================================================================ 真链路
# 同一位 IG 用户发一句,等第三方客户端的 echo:它的 send-txt 结果,以及 IG 用户有没有真收到
function Invoke-EchoRoundTrip([string]$Igsid, [string]$Tag) {
$text = "loop-s2 $Tag " + [guid]::NewGuid().ToString('N').Substring(0, 6)
$e = Send-LoopIg $Igsid @{ kind = 'text'; text = $text }
if (-not $e) { throw "「$text」没送进 iXserver" }
$expected = "echo: $text"
$deadline = (Get-Date).AddSeconds($TimeoutSec)
$reply = $null; $out = $null
while ((Get-Date) -lt $deadline) {
$reply = @((Invoke-RestMethod "$cb/_api/state").events | Where-Object { $_.kind -eq 'reply' -and $_.message -eq $expected }) | Select-Object -First 1
$out = @(Get-LoopOutbound $Igsid $e.id | Where-Object { $_.summary -eq $expected }) | Select-Object -First 1
if ($reply -and $out) { break }
# 被拒的回复:再多等几秒,确认它确实没有漏发到 IG
if ($reply -and $reply.response.status -ne 201) {
$out = Wait-LoopOutbound $Igsid $e.id { $_.summary -eq $expected } 3
break
}
Start-Sleep -Milliseconds 500
}
$status = if ($reply) { [string]$reply.response.status } else { '(客户端没有回复记录)' }
@{ Delivered = [bool]$out; Reply = $reply; Status = $status; Text = $text }
}
# ============================================================================ 场景
Start-LoopRun 'S2' 'IP 白名单:token 端点 + send-txt(真 IIS)'
Restore-WebConfig # 上次中途被打断留下的备份
$originalWhitelist = Get-Whitelist
$crash = ''
try {
Copy-Item $webConfig $webConfigBackup -Force
# ---------------------------------------------------------------- 0. 白名单为空
Set-Whitelist $null
[void](Wait-TokenStatus $base4 200)
Invoke-RestMethod "$ig/_api/clear" -Method Post | Out-Null
$igsid = 'loop-s2-' + [guid]::NewGuid().ToString('N').Substring(0, 8)
$err = Complete-LoopTnC $igsid $TimeoutSec
if ($err) { throw $err }
$rt = Invoke-EchoRoundTrip $igsid '0'
$ticketId = if ($rt.Reply) { [string]$rt.Reply.ticketId } else { '' }
Set-LoopTicket $ticketId
Check '0 白名单为空:IG → 第三方 echo → IG' ($rt.Delivered -and $rt.Status -eq '201') "ticket $ticketId,send-txt $($rt.Status)"
if (-not $ticketId) { throw '拿不到工单号,后面的 send-txt 用例没法跑' }
$earlyToken = (Get-Token $base4).Token
Check '0 白名单为空:127.0.0.1 与 ::1 都能换 token' ([bool]$earlyToken -and (Get-Token $base6).Status -eq 200)
# ---------------------------------------------------------------- A. 白名单不含本机
Set-Whitelist '203.0.113.0/24'
$sec = Wait-TokenStatus $base4 401
Check 'A0 改表后生效(oauth_client 缓存 30 秒)' ($sec -ge 0) "$sec 秒后开始拒"
$r = Get-Token $base4
$error_ = try { ($r.Body | ConvertFrom-Json).error } catch { '' }
Check 'A1 token 端点 127.0.0.1 → 401 invalid_client' ($r.Status -eq 401 -and $error_ -eq 'invalid_client') "HTTP $($r.Status) $($r.Body)"
$r = Get-Token $base6
Check 'A2 token 端点 ::1 → 401' ($r.Status -eq 401) "HTTP $($r.Status)"
$r = Send-Txt $base4 $earlyToken $ticketId 'loop-s2 A3 must be rejected'
Check 'A3 未过期 token 调 send-txt → 401 + WWW-Authenticate: Bearer + problem+json' ($r.Status -eq 401 -and $r.WwwAuth -eq 'Bearer' -and $r.ContentType -eq 'application/problem+json') "HTTP $($r.Status) $($r.ContentType) WWW-Authenticate=$($r.WwwAuth)"
$t = Get-Token $base4 '203.0.113.10'
$s = Send-Txt $base4 $earlyToken $ticketId 'loop-s2 A4 must be rejected' '203.0.113.10'
Check 'A4 没有可信代理时伪造 X-Forwarded-For: 203.0.113.10 → 401' ($t.Status -eq 401 -and $s.Status -eq 401) "token $($t.Status) / send-txt $($s.Status)"
$rt = Invoke-EchoRoundTrip $igsid 'A5'
Check 'A5 真链路:第三方的回复被拒,IG 用户收不到' ((-not $rt.Delivered) -and $rt.Status -ne '201') "send-txt $($rt.Status),IG 收到=$($rt.Delivered)"
$leaked = @(Get-LoopOutbound $igsid 0 | Where-Object { $_.summary -like '*must be rejected*' })
Check 'A6 被拒的 send-txt 一条都没发到 IG' ($leaked.Count -eq 0) "漏发 $($leaked.Count) 条"
# ---------------------------------------------------------------- B. 只有 127.0.0.1
Set-Whitelist '127.0.0.1'
$sec = Wait-TokenStatus $base4 200
$t = Get-Token $base4
$s = Send-Txt $base4 $t.Token $ticketId 'loop-s2 B1 from 127.0.0.1'
Check 'B1 白名单 127.0.0.1:从 127.0.0.1 换 token 200、send-txt 201' ($sec -ge 0 -and $t.Status -eq 200 -and $s.Status -eq 201) "生效 $sec 秒;token $($t.Status) / send-txt $($s.Status)"
$t6 = Get-Token $base6
$s6 = Send-Txt $base6 $t.Token $ticketId 'loop-s2 B2 must be rejected'
Check 'B2 白名单 127.0.0.1:从 ::1 来 → 401(IPv4 / IPv6 本机地址不互通)' ($t6.Status -eq 401 -and $s6.Status -eq 401) "token $($t6.Status) / send-txt $($s6.Status)"
$rt = Invoke-EchoRoundTrip $igsid 'B3'
Check 'B3 真链路:第三方客户端经 localhost 进来是 ::1 → 被拒' ((-not $rt.Delivered) -and $rt.Status -ne '201') "send-txt $($rt.Status),IG 收到=$($rt.Delivered)"
# ---------------------------------------------------------------- C. 混写
Set-Whitelist '203.0.113.10; ::1 127.0.0.0/8'
$sec = Wait-TokenStatus $base6 200
$t = Get-Token $base4
Check 'C1 混写「203.0.113.10; ::1 127.0.0.0/8」:::1 命中单个地址、127.0.0.1 命中 /8' ($sec -ge 0 -and $t.Status -eq 200) "生效 $sec 秒;127.0.0.1 token $($t.Status)"
$rt = Invoke-EchoRoundTrip $igsid 'C2'
Check 'C2 真链路:白名单命中,IG → 第三方 echo → IG 恢复' ($rt.Delivered -and $rt.Status -eq '201') "send-txt $($rt.Status)"
# ---------------------------------------------------------------- D. 可信反向代理
Set-Whitelist '203.0.113.10'
Set-TrustedProxies '127.0.0.1,::1'
$t = Get-Token $base4
Check 'D1 本机是可信代理、白名单 203.0.113.10:不带 XFF → 401' ($t.Status -eq 401) "HTTP $($t.Status)"
$t = Get-Token $base4 '203.0.113.10'
$s = Send-Txt $base4 $t.Token $ticketId 'loop-s2 D2 via trusted proxy' '203.0.113.10'
Check 'D2 经可信代理带 XFF: 203.0.113.10 → token 200、send-txt 201' ($t.Status -eq 200 -and $s.Status -eq 201) "token $($t.Status) / send-txt $($s.Status)"
$t = Get-Token $base4 '203.0.113.10, 198.51.100.7'
Check 'D3 XFF「203.0.113.10, 198.51.100.7」取最右的非代理段 198.51.100.7 → 401' ($t.Status -eq 401) "HTTP $($t.Status)"
$t = Get-Token $base4 '198.51.100.7, 203.0.113.10, ::1'
Check 'D4 XFF 末尾的可信代理 ::1 跳过,取 203.0.113.10 → 200' ($t.Status -eq 200) "HTTP $($t.Status)"
$t = Get-Token $base4 '203.0.113.10, garbage'
Check 'D5 XFF 夹着解析不了的段 → 退回直连地址 → 401' ($t.Status -eq 401) "HTTP $($t.Status)"
}
catch {
$crash = "场景中断:$($_.Exception.Message)"
Check $crash $false
}
finally {
Restore-WebConfig
Set-Whitelist $originalWhitelist
}
if ($null -eq $originalWhitelist) {
$sec = Wait-TokenStatus $base6 200
Check 'E 还原后 token 端点恢复' ($sec -ge 0) "$sec 秒"
}
if ($script:failures.Count) {
Write-Host "S2 失败:$($script:failures.Count) 项 —— $($script:failures -join ';')" -ForegroundColor Red
Write-Host '--- chatbot 记录(最近 6 条)'
(Invoke-RestMethod "$cb/_api/state").events | Select-Object -First 6 | ForEach-Object { $_ | ConvertTo-Json -Depth 8 -Compress } | Out-Host
Save-LoopReport -Passed $false -Reason $script:failures[0]
exit 1
}
Write-Host 'S2 通过:IP 白名单在 token 端点与 send-txt 上按预期放行 / 拒绝,可信代理的 XFF 规则生效' -ForegroundColor Green
Save-LoopReport -Passed $true
exit 0