Compare commits

...

2 Commits

Author SHA1 Message Date
fengruixiang 232a571157 feat: S2 IP 白名单场景(token 端点 + send-txt,经真 IIS)
临时改 oauth_client(setsail).ip_whitelist 与 run\ixserver\Web.config 的 Http_TrustedProxyIps,
验证:白名单不含本机时 token 401 / send-txt 401、伪造 XFF 无效、真链路回复不漏发;
127.0.0.1 与 ::1 不互通;分隔符与 CIDR;可信代理下 XFF 取最右的非代理段。结束(含异常)还原。
S2 其余鉴权负例(错 secret / 没 scope / 过期 token / 别的服务号)未包含。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:56:14 +08:00
fengruixiang 327dee9f33 feat: epro_ig_demo 常开 IG 条款同意,闭环 ABP 补上 Agree 按钮
- db/ixserver/loop_patch.sql:epro_ig_demo 写 T_and_C_Setting(NeedSendAfterMinutes 10080),
  条款流程对第三方 chatbot 与人工状态都生效
- db/abp/loop_patch.sql 第 3 节:把模板「Agreement Button (Line IG)」追加到 epro_ig_demo
  条款组第二位。staging 的 IG 条款组漏了这条(Line 从问候模板按位置切、带着它),
  用户收到条款无按钮可点;闭环在这一处与 staging 不同。补丁可重复执行,末尾列出条款组
- scenarios/_lib.ps1:Complete-LoopTnC 替新用户走完条款 → 同意 → 选语言 → 问候,
  有 Agree 按钮就点按钮、没有退回裸 payload postback;默认选 English,保持 S5 的 Yes 问句
- S1 改为每次新 IGSID;S1 / S5 / S8 开头先走条款同意
- README 说明条款常开与 ABP 补丁

winbox 上 reset + reset-abp -Init 后 S1 / S5 / S8 均通过。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:08:13 +08:00
8 changed files with 417 additions and 3 deletions

View File

@ -53,6 +53,7 @@
```powershell
.\scenarios\s1-ig-text-echo.ps1 # IG 文本 → iXserver → 第三方 chatbot echo → IG 收到
.\scenarios\s2-auth-ip-whitelist.ps1 # IP 白名单:token 端点 + send-txt 经真 IIS 放行 / 拒绝、可信代理的 XFF 规则;临时改 oauth_client 与 Web.config,结束还原,约 2~3 分钟
.\scenarios\s5-ig-handoff.ps1 # 机器人工单 → SCRM 开工 + 客服上线 → 下一条:echo 在前、问句在后 → 点 Yes → 新人工工单 → 客服回复 → 旧单迟到回复 409 → 下班
.\scenarios\s8-human-chain.ps1 # SCRM 开工 → 新 IG 用户 → 人工工单推送 → 指派 / 回复 → IG 收到 → 结束 → 下班
```
@ -62,6 +63,11 @@ S5 依赖 `epro_ig_demo` 的 `DisconnectChatbotFlowSetting` 已打开(`db\ixse
跑的时候开着观察台 <http://127.0.0.1:5090>,每一跳和状态变化会实时画成泳道时序图;`?ticket=<工单号>` 只看一张。
`epro_ig_demo` 常开条款同意(`db\ixserver\loop_patch.sql` 第 3 节的 `T_and_C_Setting`):
新 IG 用户的第一句话会被条款拦下,三个场景都先调 `_lib.ps1` 的 `Complete-LoopTnC` 走完「条款 → 同意 → 选语言 → 问候」。
staging 的 IG 条款组**缺 Agree 按钮**(漏了 Line 问候里的「Agreement Button (Line IG)」);闭环 ABP 在
`db\abp\loop_patch.sql` 第 3 节补上了,这一处与 staging 不同。改过这个补丁要 `.\loop.ps1 reset-abp -Init` 重新打并留底。
S5 / S8 结束时(失败也一样)会把服务号切回机器人、在线人数归零,所以几个场景先后跑互不影响;要停在人工状态看页面加 `-KeepShift`。
从 Mac 看几个页面用 SSH 本地端口转发:

View File

@ -9,6 +9,9 @@
-- FX_App 的 client secret → loop-fxapp-secret
-- admin 的密码(iXserver 用 FX_App + password 授权取模板)→ Loop@123456
-- 明文都在 config\loop.psd1 里,是闭环专用的假值。
--
-- 另外改一处模板配置(第 3 节):epro_ig_demo 的条款组补上「Agreement Button (Line IG)」。
-- 整段可重复执行,结果不变。
SET NOCOUNT ON;
SET XACT_ABORT ON;
@ -46,9 +49,41 @@ UPDATE dbo.AbpUsers
LockoutEnd = NULL
WHERE UserName = N'admin' AND TenantId IS NULL;
------------------------------------------------------------------------------------------------
-- 3. epro_ig_demo 的条款组补上 Agree 按钮
-- Line 从问候模板按位置切条款:[Agreement (Line IG), Agreement Button (Line IG), Agreed, Select Language, …];
-- IG 走条款专用分组,从 staging 拷来时 AgreementMessageTemplateIdsJson 只有 Agreement (Line IG),
-- 缺了第二条「Please click Agree」,用户收到条款没有按钮可点。这里照 Line 的顺序追加到第二位。
-- staging 本身没改,闭环在这一处与 staging 不同
------------------------------------------------------------------------------------------------
DECLARE @agreeButton NVARCHAR(36) = (SELECT LOWER(CONVERT(NVARCHAR(36), Id)) FROM dbo.iCON_MT_MessageTemplates
WHERE Name = N'Agreement Button (Line IG)' AND IsDeleted = 0);
IF @agreeButton IS NULL THROW 50004, N'iCONAbpFX 里没有模板 Agreement Button (Line IG)', 1;
DECLARE @igReply UNIQUEIDENTIFIER = (SELECT s.Id FROM dbo.iCON_Chat_AutoReplySettings s
JOIN dbo.iCON_Chat_ChatServiceAccounts a ON a.Id = s.ChatServiceAccountId
WHERE a.Code = N'epro_ig_demo' AND a.IsDeleted = 0 AND s.IsDeleted = 0 AND s.Name = N'Default');
IF @igReply IS NULL THROW 50005, N'iCONAbpFX 里没有 epro_ig_demo 的 Default 自动回复设置', 1;
UPDATE dbo.iCON_Chat_AutoReplySettings
SET AgreementMessageTemplateIdsJson = JSON_MODIFY(AgreementMessageTemplateIdsJson, 'append $', @agreeButton),
ConcurrencyStamp = REPLACE(CONVERT(NVARCHAR(36), NEWID()), '-', ''),
LastModificationTime = GETDATE()
WHERE Id = @igReply
AND ISJSON(AgreementMessageTemplateIdsJson) = 1
AND NOT EXISTS (SELECT 1 FROM OPENJSON(AgreementMessageTemplateIdsJson) WHERE LOWER(value) = @agreeButton);
COMMIT TRANSACTION;
SELECT c.ClientId, c.Enabled, c.AccessTokenLifetime,
(SELECT COUNT(*) FROM dbo.IdentityServerClientSecrets s WHERE s.ClientId = c.Id) AS secrets
FROM dbo.IdentityServerClients c
WHERE c.ClientId IN (N'setsail', N'FX_App');
SELECT a.Code, t.[key] AS position, m.Name AS agreement_template
FROM dbo.iCON_Chat_AutoReplySettings s
JOIN dbo.iCON_Chat_ChatServiceAccounts a ON a.Id = s.ChatServiceAccountId
CROSS APPLY OPENJSON(s.AgreementMessageTemplateIdsJson) t
LEFT JOIN dbo.iCON_MT_MessageTemplates m ON LOWER(CONVERT(NVARCHAR(36), m.Id)) = LOWER(t.value)
WHERE s.Id = @igReply
ORDER BY CONVERT(INT, t.[key]);

View File

@ -78,6 +78,14 @@ UPDATE dbo.service_account_config
"NeedReactiveAfterIgnore": false, "NeedGreetingForNewTicket": false }'))
WHERE code = N'epro_ig_demo';
-- 条款同意(docs\IG 条款同意流程方案.html):存了 T_and_C_Setting 且 ABP 模板里有条款才启用,两样缺一就静默不启用。
-- 模板用本地 ABP 的 epro_ig_demo,所以这里只开设置;间隔取代码默认的 10080 分钟(7 天)。
-- 对第三方 chatbot 服务号同样生效:新用户第一句话被条款拦下,场景先调 scenarios\_lib.ps1 的 Complete-LoopTnC 走完同意。
-- staging 的条款组缺「请点 Agree」那条、没按钮可点;闭环 ABP 已补上(db\abp\loop_patch.sql 第 3 节)
UPDATE dbo.service_account_config
SET all_setting = JSON_MODIFY(all_setting, '$.T_and_C_Setting', JSON_QUERY(N'{ "NeedSendAfterMinutes": 10080 }'))
WHERE code = N'epro_ig_demo';
------------------------------------------------------------------------------------------------
-- 4. 第三方 client 登记:两张表都要有 setsail(方案 §2 链路 c)。ip_whitelist 留空 = 不限来源
------------------------------------------------------------------------------------------------
@ -108,5 +116,6 @@ SELECT code,
JSON_VALUE(all_setting, '$.AISetting.AIProviderWebhook.Webhook') AS webhook,
JSON_VALUE(all_setting, '$.ChatMessageSetting.CurrentProviderKey') AS message_provider,
JSON_VALUE(all_setting, '$.InstagramSetting.InstagramAccount') AS ig_account,
JSON_VALUE(all_setting, '$.InstagramSetting.FanPageToken') AS ig_token
JSON_VALUE(all_setting, '$.InstagramSetting.FanPageToken') AS ig_token,
JSON_VALUE(all_setting, '$.T_and_C_Setting.NeedSendAfterMinutes') AS tnc_minutes
FROM dbo.service_account_config WHERE code = N'epro_ig_demo';

View File

@ -52,3 +52,80 @@ function Save-LoopReport([bool]$Passed, [string]$Reason = '') {
Write-Host "没有生成报告(观察台没在跑?.\loop.ps1 up -Only console):$($_.Exception.Message)" -ForegroundColor Yellow
}
}
# ============================================================================ 条款同意
# 以这位用户身份发一条入站;iXserver 没收下返回 $null
function Send-LoopIg([string]$Igsid, [hashtable]$Body) {
$Body.igsid = $Igsid
$bytes = [Text.Encoding]::UTF8.GetBytes(($Body | ConvertTo-Json -Compress))
try { $r = Invoke-RestMethod "http://127.0.0.1:$($LoopCfg.Ports.IgMock)/_api/send" -Method Post -ContentType 'application/json; charset=utf-8' -Body $bytes }
catch { return $null } # IG 模拟投递失败回 502
if ($r.event -and $r.event.response.status -eq 200) { return $r.event }
return $null
}
# 这位用户在某个入站事件之后收到的消息(IG 模拟的事件 id 全局递增)
function Get-LoopOutbound([string]$Igsid, [int]$AfterId) {
@((Invoke-RestMethod "http://127.0.0.1:$($LoopCfg.Ports.IgMock)/_api/state").events |
Where-Object { $_.kind -eq 'outbound' -and $_.igsid -eq $Igsid -and $_.id -gt $AfterId } | Sort-Object id)
}
function Wait-LoopOutbound([string]$Igsid, [int]$AfterId, [scriptblock]$Match, [int]$TimeoutSec) {
$deadline = (Get-Date).AddSeconds($TimeoutSec)
while ((Get-Date) -lt $deadline) {
$hit = @(Get-LoopOutbound $Igsid $AfterId | Where-Object -FilterScript $Match) | Select-Object -First 1
if ($hit) { return $hit }
Start-Sleep -Milliseconds 300
}
return $null
}
# 一条出站消息上用户能点的:按钮模板 / 卡片的 postback 按钮,或 quick reply
function Get-LoopChoices($Outbound) {
$m = $Outbound.body.message
if ($m.attachment -and $m.attachment.type -eq 'template') {
$p = $m.attachment.payload
$buttons = if ($p.buttons) { @($p.buttons) } else { @($p.elements | ForEach-Object { $_.buttons }) }
return @($buttons | Where-Object { $_ -and $_.type -eq 'postback' } | ForEach-Object { @{ kind = 'postback'; title = $_.title; payload = $_.payload } })
}
return @($m.quick_replies | Where-Object { $_ } | ForEach-Object { @{ kind = 'quick_reply'; title = $_.title; payload = $_.payload } })
}
<#
epro_ig_demo 常开条款流程(db\ixserver\loop_patch.sql 第 3 节):新 IG 用户的第一句话被条款拦下,
不进第三方也不进人工对话。场景先走完「发一句 → 收条款 → 同意 → 选语言 → 收问候」,再测自己的链路。
条款里有 Agree 按钮就照按钮的形态点(闭环 ABP 已补上,db\abp\loop_patch.sql 第 3 节);
没有时(库是 staging 原样、没打那一节)退回发裸 payload 的 postback(InstagramChatChannel.TnCFlow.IsTnCPayload 放行)。
每步记 Add-LoopStep。成功返回 $null,失败返回原因——收尾(下班、打印记录)各场景不同,交给调用方的 Fail:
$err = Complete-LoopTnC $igsid $TimeoutSec; if ($err) { Fail $err }
#>
# 默认选 English:没有条款流程时端用户语言就是英文,S5 的问句按钮(-YesTitle Yes)跟着模板语言走
function Complete-LoopTnC([string]$Igsid, [int]$TimeoutSec = 60, [string]$Language = 'en-us') {
$text = 'loop-tnc ' + [guid]::NewGuid().ToString('N').Substring(0, 6)
$e = Send-LoopIg $Igsid @{ kind = 'text'; text = $text }
if (-not $e) { return "条款前的第一句「$text」没送进 iXserver" }
# 条款第一条带 4 个外链,标题里认得出来
$tnc = Wait-LoopOutbound $Igsid $e.id { $_.summary -like '*Terms and Conditions*' } $TimeoutSec
if (-not $tnc) { return "$TimeoutSec 秒内没收到条款(T_and_C_Setting 没开?见 db\ixserver\loop_patch.sql 第 3 节;或 ABP 模板的 agreementMessages 为空)" }
Add-LoopStep '新用户第一句话被条款拦下' $true "$Igsid「$text」→ $($tnc.summary)"
Start-Sleep -Milliseconds 1500 # 条款组可能不止一条,等它发完再找 Agree 按钮
$agree = @(Get-LoopOutbound $Igsid $e.id | ForEach-Object { Get-LoopChoices $_ } | Where-Object { $_.payload -eq 'Agree' }) | Select-Object -First 1
$how = if ($agree) { "点模板里的「$($agree.title)」($($agree.kind))" } else { '条款里没有 Agree 按钮(ABP 没打 loop_patch 第 3 节?),发 postback Agree' }
if (-not $agree) { $agree = @{ kind = 'postback'; title = 'Agree'; payload = 'Agree' } }
$e = Send-LoopIg $Igsid @{ kind = $agree.kind; title = $agree.title; payload = $agree.payload; text = $agree.title }
if (-not $e) { return '同意没送进 iXserver' }
$switchPayload = "switch $Language"
$menu = Wait-LoopOutbound $Igsid $e.id { @(Get-LoopChoices $_ | Where-Object { $_.payload -eq $switchPayload }).Count } $TimeoutSec
if (-not $menu) { return "$TimeoutSec 秒内同意之后没收到带「$switchPayload」的选语言按钮" }
Add-LoopStep '同意条款,收到选语言' $true $how
$choice = @(Get-LoopChoices $menu | Where-Object { $_.payload -eq $switchPayload })[0]
$e = Send-LoopIg $Igsid @{ kind = $choice.kind; title = $choice.title; payload = $choice.payload; text = $choice.title }
if (-not $e) { return '选语言没送进 iXserver' }
$greeting = Wait-LoopOutbound $Igsid $e.id { $true } $TimeoutSec
if (-not $greeting) { return "$TimeoutSec 秒内选完语言没收到问候(条款模板第四组)" }
Add-LoopStep "选「$($choice.title)」,收到问候" $true $greeting.summary
return $null
}

View File

@ -1,5 +1,6 @@
<#
S1:IG 用户发一条文本 → iXserver → 第三方 chatbot(echo 自动回复)→ iXserver → IG 用户收到 echo。
每次用新的 IGSID,先走完条款同意(_lib.ps1 的 Complete-LoopTnC)。
前提:.\loop.ps1 up 各角色都健康,chatbot 以 --auto-reply echo 运行(config\loop.psd1 的 ChatbotAutoReply)。
通过退出 0,失败退出 1 并打印两侧的记录;两种情况都在 run\reports\ 生成场景报告(观察台在跑时)。
用法:.\scenarios\s1-ig-text-echo.ps1 [-TimeoutSec 60] [-NoReport]
@ -27,8 +28,11 @@ function Fail([string]$why) {
Start-LoopRun 'S1' 'IG 文本 → 第三方 echo → IG'
Invoke-RestMethod "$ig/_api/clear" -Method Post | Out-Null
$igsid = 'loop-s1-' + [guid]::NewGuid().ToString('N').Substring(0, 8)
$err = Complete-LoopTnC $igsid $TimeoutSec
if ($err) { Fail $err }
$text = 'loop-s1 ' + [guid]::NewGuid().ToString('N').Substring(0, 8)
$sent = Invoke-RestMethod "$ig/_api/send" -Method Post -ContentType 'application/json; charset=utf-8' -Body (@{ kind = 'text'; text = $text } | ConvertTo-Json)
$sent = Invoke-RestMethod "$ig/_api/send" -Method Post -ContentType 'application/json; charset=utf-8' -Body ([Text.Encoding]::UTF8.GetBytes((@{ kind = 'text'; igsid = $igsid; text = $text } | ConvertTo-Json)))
if ($sent.event.response.status -ne 200) { Fail "iXserver 收 webhook 返回 $($sent.event.response.status)" }
Add-LoopStep 'iXserver 收下 webhook' $true "「$text」HTTP 200"
@ -37,7 +41,7 @@ $expected = "echo: $text"
$deadline = (Get-Date).AddSeconds($TimeoutSec)
$hit = $null
while (-not $hit -and (Get-Date) -lt $deadline) {
$hit = @((Invoke-RestMethod "$ig/_api/state").events | Where-Object { $_.kind -eq 'outbound' -and $_.summary -eq $expected }) | Select-Object -First 1
$hit = @((Invoke-RestMethod "$ig/_api/state").events | Where-Object { $_.kind -eq 'outbound' -and $_.igsid -eq $igsid -and $_.summary -eq $expected }) | Select-Object -First 1
if (-not $hit) { Start-Sleep -Milliseconds 500 }
}
if (-not $hit) { Fail "$TimeoutSec 秒内 iXserver 没有向 IG 用户发出「$expected」" }

View File

@ -0,0 +1,277 @@
<#
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

View File

@ -112,6 +112,9 @@ Invoke-RestMethod "$ig/_api/clear" -Method Post | Out-Null
# ---- 1. 服务号在机器人状态时建单:AI 状态只在建单那一刻定(方案 §2.3)
Step 'SCRM 下班(服务号在机器人状态)' 'shift' @{ on = $false }
# 条款同意那一句就建出了机器人工单,之后的 text1 落在同一张单上
$err = Complete-LoopTnC $igsid $TimeoutSec
if ($err) { Fail $err }
$text1 = 'S5 机器人阶段 ' + (New-Tag)
$e1 = Send-Ig @{ kind = 'text'; text = $text1 }
Add-LoopStep '新 IG 用户发消息' $true "$igsid「$text1」HTTP 200"

View File

@ -68,6 +68,9 @@ Step 'SCRM 开工(切人工、在线 3)' 'shift' @{ on = $true } | Out-Null
Step '客服上线' 'agent-online' @{} | Out-Null
$igsid = 'loop-s8-' + [guid]::NewGuid().ToString('N').Substring(0, 8)
# 人工状态下条款同样先拦第一句话;同意那一句就建出了人工工单,下面按 IGSID 认的还是这张
$err = Complete-LoopTnC $igsid $TimeoutSec
if ($err) { Fail $err }
$sent = Post "$ig/_api/send" @{ kind = 'text'; igsid = $igsid; text = 'S8 想找人工' }
if ($sent.event.response.status -ne 200) { Fail "iXserver 收 webhook 返回 $($sent.event.response.status)" }
Add-LoopStep '新 IG 用户发消息' $true "$igsid HTTP 200"