feat(autotest): 新增双连接接入测试与链路密钥探测

- AutoTest:大厅 Hash Request → 取 digest/空间 id → 空间连接 EnterSpace → 收空间帧
- StreamDecoder:首条密文即穷举单字节链路密钥(K),之后 s2c 自动解密
- 首条空间帧到达后再加密发送 G.q1wd,服务端才会继续下发容器数据
- RunAutoTest.ps1:自动拉起大厅/空间两条中继,跑完自动回收
- 实测通过:握手成功、K 唯一命中、容器 1 条、回编码一致
This commit is contained in:
TermiNexus committed 2026-10-21 00:38:59 +08:00
1 parent 2ba8df0245
commit 0ca28ff9b2
7 files changed
+566

No files matched your search

+2
View File
@@ -14,6 +14,8 @@ kotlin {
dependencies { dependencies {
implementation(project(":core")) implementation(project(":core"))
implementation(project(":net")) implementation(project(":net"))
// 自动测试用 ws 客户端:需要自定义 User-Agent(真实服务器会拒绝 Node 默认 UA)
implementation(npm("ws", "8.18.0"))
} }
} }
} }
@@ -0,0 +1,230 @@
// AutoTest.kt —— 接服务器自动化测试(Node 下运行)
//
// 真实链路是**两段连接**(与抓包 plain/space-* 的 c2s_0000 = EnterSpace 一致):
// ① 大厅连接(c2):Hash Request → Hash Response(digest + 分配的空间 id)
// ② 空间连接(c1):首帧直接 Auth/EnterSpace(digest, spaceId) → 开始收空间帧
//
// 环境变量:
// TANKIOFFLINE_AUTOTEST=1
// TANKIOFFLINE_AUTOTEST_URL 大厅中继,默认 ws://localhost:9091/
// TANKIOFFLINE_AUTOTEST_SPACE_URL 空间中继,默认 ws://localhost:9092/
// TANKIOFFLINE_AUTOTEST_SPACE 期望空间 id,默认 100073070(服务端会回实际分配值)
// TANKIOFFLINE_AUTOTEST_TIMEOUT 默认 20000(毫秒)
package App
import Core.ByteReader
import Core.Nullmap
import Net.Generated.DownlinkRegistry
import Net.Protocol.Entry
import Net.Protocol.EntryEvent
import Net.Protocol.S2cDecoder
import Net.Protocol.SpaceFrame
import Net.Protocol.XorCipher
// ws 包的客户端(可自定义握手头;Node 内置 WebSocket 不能改 User-Agent)
// 用 module.require 动态取构造器,避开 CommonJS/UMD 的 import 形态差异
private val CreateWs: (String, dynamic) -> dynamic =
js("(function (U, O) { var W = module.require('ws'); return new W(U, O); })")
private val MakeWsOptions: (String) -> dynamic = js(
"(function (Ua) { return { headers: { 'User-Agent': Ua }, perMessageDeflate: false }; })"
)
private val SetTimeout: (() -> Unit, Int) -> Unit = js("(function (Fn, Ms) { setTimeout(Fn, Ms); })")
private const val BrowserUserAgent =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36 Edg/153.0.0.0"
private fun Exit(Code: Int) {
js("process.exit(Code)")
}
object AutoTest {
fun Wanted(): Boolean = js("process.env.TANKIOFFLINE_AUTOTEST === '1'") as Boolean
private class Stats {
var Messages = 0
var Frames = 0
var Pings = 0
var Records = 0
var RoundTripOk = 0
var RoundTripBad = 0
var HeaderErrors = 0
var DecodeErrors = 0
var ShortCommands = 0
var UnknownMethods = 0
var Containers = 0
var HashReceived = false
var EnterSent = false
var OpenSpace = false
var SpaceConnected = false
}
private val Containers = setOf(
3216143066888387731L,
7640916300855664666L,
9038756763225879447L,
8179162314198150518L,
)
fun Run() {
val LobbyUrl = Env("TANKIOFFLINE_AUTOTEST_URL", "ws://localhost:9091/")
val SpaceUrl = Env("TANKIOFFLINE_AUTOTEST_SPACE_URL", "ws://localhost:9092/")
val WantSpace = Env("TANKIOFFLINE_AUTOTEST_SPACE", "100073070").toLong()
val TimeoutMs = Env("TANKIOFFLINE_AUTOTEST_TIMEOUT", "20000").toInt()
val S = Stats()
val LobbyDecoder = S2cDecoder()
var SpaceDecoder: S2cDecoder? = null
var Socket: dynamic = null
var PendingDigest: ByteArray? = null
var AssignedSpace: Long? = null
var SpaceTarget = 0L
var C2sCipher: XorCipher? = null
var ObjectDataSent = false
val SpaceAliases = HashMap<Int, Pair<Long, Long>>()
println("TankiOffline 自动测试:大厅=$LobbyUrl 空间=$SpaceUrl(请求空间 $WantSpace,超时 ${TimeoutMs}ms)")
fun SendBytes(Bytes: ByteArray) {
Socket.send(Bytes)
}
fun HandleSpaceBody(Body: ByteArray) {
val Nm = Nullmap.Size(Body, 0)
if (Nm >= Body.size) {
S.Pings++
return
}
val R = SpaceFrame.DecodeBody(Body, DownlinkRegistry.ByMethodId, SpaceAliases)
S.Records += R.Records.size
S.ShortCommands += R.ShortCommandCount
S.UnknownMethods += R.UnknownMethodIds.size
val Err = R.Error
if (Err != null) {
if (Err.startsWith("记录头截断")) S.HeaderErrors++ else S.DecodeErrors++
println(" ⚠ $Err")
}
for (Rec in R.Records) {
if (Rec.Payload.contentEquals(Rec.Reencoded)) S.RoundTripOk++ else {
S.RoundTripBad++
println(" ⚠ methodId=${Rec.MethodId} 回编码不一致")
}
if (Rec.MethodId in Containers) S.Containers++
}
}
fun Finish() {
val Ok = S.HashReceived && S.EnterSent && S.Records > 0 && S.RoundTripBad == 0
println("—— 自动测试结果 ——")
println("消息 ${S.Messages}|帧 ${S.Frames}|保活 ${S.Pings}|空间记录 ${S.Records}")
println("回编码一致 ${S.RoundTripOk}|不一致 ${S.RoundTripBad}")
println("容器记录 ${S.Containers}|短命令 ${S.ShortCommands}|未知 methodId ${S.UnknownMethods}")
println("记录头截断 ${S.HeaderErrors}|其它解码错误 ${S.DecodeErrors}")
println("握手:Hash=${S.HashReceived} Enter=${S.EnterSent} OpenSpace=${S.OpenSpace} 空间连接=${S.SpaceConnected} 密钥=${SpaceDecoder?.Key}")
println(if (Ok) "结果:通过" else "结果:失败")
try {
Socket.close()
} catch (_: Throwable) {
}
Exit(if (Ok) 0 else 1)
}
// 打开一条连接;IsSpace=true 时首帧发 EnterSpace
fun Open(Url: String, IsSpace: Boolean) {
val Decoder = if (IsSpace) S2cDecoder().also { SpaceDecoder = it } else LobbyDecoder
val Ws: dynamic = CreateWs(Url, MakeWsOptions(BrowserUserAgent))
Socket = Ws
if (IsSpace) S.SpaceConnected = true
val OnOpen: (dynamic, dynamic) -> Unit = { _, _ ->
if (IsSpace) {
val Digest = PendingDigest
val Target = AssignedSpace ?: WantSpace
if (Digest == null) {
println("✗ 空间连接缺少 digest")
Exit(1)
}
S.EnterSent = true
SpaceTarget = Target
SendBytes(Entry.BuildEnterSpace(Digest!!, Target))
println("→ [空间] Auth/EnterSpace($Target)")
} else {
println("→ [大厅] 已连接,发 Hash Request")
SendBytes(Entry.BuildHashRequest(Entry.HashKeys, Entry.HashValues))
}
}
val OnMessage: (dynamic, dynamic) -> Unit = { Data, _ ->
S.Messages++
val Bytes = js("new Int8Array(Data)") as ByteArray
val Bodies = Decoder.Feed(Bytes) { Line -> println(" $Line") }
// 第一条空间帧到达(此时密钥已知)后再发 G.q1wd;太早发服务端会直接关连接。
// 之后的上行必须用 c2s 方向的同一密钥(密钥表再 ^87)。
val K = Decoder.Key
if (IsSpace && !ObjectDataSent && K != null) {
val C = XorCipher(K, true)
C2sCipher = C
val Req = C.Apply(Entry.BuildObjectDataRequest(SpaceTarget))
SendBytes(Req)
ObjectDataSent = true
println("→ [空间] G.q1wd(1)(已加密,K=$K)")
}
for (B in Bodies) {
S.Frames++
if (IsSpace) {
HandleSpaceBody(B)
continue
}
// 大厅:只处理入口命令
val Nm = Nullmap.Size(B, 0)
if (Nm >= B.size) {
S.Pings++
continue
}
var Reconnect = false
for (E in Entry.ParseServerBody(B)) {
when (E) {
is EntryEvent.HashResponse -> {
S.HashReceived = true
PendingDigest = E.Digest
println("← [大厅] Hash Response(加密标志=${E.Encrypted})")
}
is EntryEvent.OpenSpace -> {
S.OpenSpace = true
AssignedSpace = E.SpaceId
println("← [大厅] Open Space spaceId=${E.SpaceId}")
Reconnect = true
}
is EntryEvent.Unknown -> println("← [大厅] 未识别入口命令 id=${E.Id}")
}
}
if (Reconnect && PendingDigest != null) {
println("→ 关闭大厅连接,改连空间服务器")
try {
Ws.close()
} catch (_: Throwable) {
}
Open(SpaceUrl, true)
}
}
}
val OnError: (dynamic, dynamic) -> Unit = { _, _ -> println("✗ WebSocket 错误($Url)") }
val OnClose: (dynamic, dynamic) -> Unit = { Code, _ -> println("连接关闭 code=$Code($Url)") }
Ws.on("open", OnOpen)
Ws.on("message", OnMessage)
Ws.on("error", OnError)
Ws.on("close", OnClose)
}
Open(LobbyUrl, false)
SetTimeout({ Finish() }, TimeoutMs)
}
private val EnvImpl: (String, String) -> String =
js("(function (N, D) { return process.env[N] || D; })")
private fun Env(Name: String, Def: String): String = EnvImpl(Name, Def)
}
@@ -122,6 +122,10 @@ private fun RunOne(Path: String, Registry: Map<Long, ErasedCodec>, S: Stats) {
fun main() { fun main() {
if (!IsNode()) return if (!IsNode()) return
if (AutoTest.Wanted()) {
AutoTest.Run()
return
}
// 默认抓包目录:从 cwd 逐级向上找 3DTank/packets(Gradle 运行 node 的 cwd 在 build/js/packages 下) // 默认抓包目录:从 cwd 逐级向上找 3DTank/packets(Gradle 运行 node 的 cwd 在 build/js/packages 下)
val Root = js( val Root = js(
+5
View File
@@ -42,6 +42,11 @@
"Module": "94965", "Module": "94965",
"Evidence": "module_94965.js:305 var e = \"1.13.6\"", "Evidence": "module_94965.js:305 var e = \"1.13.6\"",
"Confidence": "已证实" "Confidence": "已证实"
},
"ws": {
"Version": "8.18.0",
"Evidence": "仅自动测试客户端使用:Node 内置 WebSocket 无法自定义 User-Agent,真实服务器会拒绝 node UA",
"Confidence": "测试依赖"
} }
}, },
"KotlinEcosystem": { "KotlinEcosystem": {
@@ -0,0 +1,117 @@
// Entry.kt —— 入口层(握手)命令
//
// 依据:3DTank/tools/server/entry.js 与 module_61860 的入口帧处理。
// c2s 1 Hash Request 体 = [1][varint N][N × 字符串][varint M][M × 字符串]
// s2c 2 Hash Response 体 = [2][32 字节 hash][1 字节 是否启用链路加密]
// c2s 3 Auth/EnterSpace 体 = [3][32 字节 digest][i64 spaceId]
// s2c 32 Open Space 体 = [32][i64 spaceId]
package Net.Protocol
import Core.ByteReader
import Core.ByteWriter
import Core.Frame
import Core.Nullmap
import Core.ProtocolException
sealed class EntryEvent {
class HashResponse(val Digest: ByteArray, val Encrypted: Boolean) : EntryEvent()
class OpenSpace(val SpaceId: Long) : EntryEvent()
class Unknown(val Id: Int, val Remaining: ByteArray) : EntryEvent()
}
object Entry {
const val C2S_HASH_REQUEST = 1
const val S2C_HASH_RESPONSE = 2
const val C2S_ENTER_SPACE = 3
const val S2C_OPEN_SPACE = 32
fun BuildHashRequest(Keys: List<String>, Values: List<String>): ByteArray {
val W = ByteWriter()
W.WriteByte(C2S_HASH_REQUEST)
W.WriteVarint(Keys.size)
for (K in Keys) W.WriteString(K)
W.WriteVarint(Values.size)
for (V in Values) W.WriteString(V)
return Frame.EncodeFrame(W.ToByteArray())
}
fun BuildEnterSpace(Digest: ByteArray, SpaceId: Long): ByteArray {
if (Digest.size != 32) throw ProtocolException("EnterSpace 的 digest 必须是 32 字节,实际 ${Digest.size}")
val W = ByteWriter()
W.WriteByte(C2S_ENTER_SPACE)
W.WriteRaw(Digest)
W.WriteLong(SpaceId)
return Frame.EncodeFrame(W.ToByteArray())
}
// 空间连接建立后客户端要立即发的一条请求(G.q1wd):
// 抓包 space-100045260/c2s_0001 = [nullmap 00][objectId i64][methodId i64][i32 1]
// 不发这条,服务端只推一帧就正常关闭(code=1000)。
fun BuildObjectDataRequest(SpaceId: Long, Index: Int = 1): ByteArray {
val W = ByteWriter()
W.WriteLong(SpaceId)
W.WriteLong(1816792453857564692L)
W.WriteInt(Index)
return Frame.EncodeFrame(W.ToByteArray())
}
// 一个入口帧体(nullmap + 若干入口命令)→ 事件列表
fun ParseServerBody(Body: ByteArray): List<EntryEvent> {
val Out = ArrayList<EntryEvent>()
val Start = Nullmap.Size(Body, 0)
if (Start <= 0 || Start >= Body.size) return Out
val R = ByteReader(Body, Start)
while (R.Remaining > 0) {
val Id = R.ReadByte().toInt() and 0xff
when (Id) {
S2C_HASH_RESPONSE -> {
if (R.Remaining < 33) {
Out.add(EntryEvent.Unknown(Id, ByteArray(0)))
break
}
val Digest = R.ReadRaw(32)
val Flag = R.ReadByte().toInt() and 0xff
Out.add(EntryEvent.HashResponse(Digest, Flag != 0))
}
S2C_OPEN_SPACE -> {
if (R.Remaining < 8) {
Out.add(EntryEvent.Unknown(Id, ByteArray(0)))
break
}
Out.add(EntryEvent.OpenSpace(R.ReadLong()))
}
else -> {
Out.add(EntryEvent.Unknown(Id, ByteArray(0)))
break
}
}
}
return Out
}
// 与真实抓包一致的握手键/值;client_version 用当前基线 e98d0216
val HashKeys = listOf(
"relay", "resources", "config-template", "lang", "browser_user_agent", "os", "device_type",
"client_type", "hardware_concurrency", "time_zone", "video_card", "mobile_device",
"touchscreen_support", "client_version", "ram_size",
)
val HashValues = listOf(
"0",
"http://localhost:8080/resources",
"http://localhost:8080/config.xml",
"cn",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36 Edg/153.0.0.0",
"Win32",
"Desktop",
"BROWSER",
"16",
"Asia/Shanghai",
"ANGLE (AMD, AMD Radeon(TM) 860M Graphics (0x00001114) Direct3D11 vs_5_0 ps_5_0, D3D11)",
"false",
"false",
"e98d02161a563e043eabb96c6e4db5fedcf165e7",
"32768",
)
}
@@ -0,0 +1,121 @@
// StreamDecoder.kt —— s2c 方向的链路解码:明文段 → 密钥探测 → 自变体 XOR 流密码
//
// 依据:3DTank/tools/wss_server.js 的 StreamDecoder/Cipher 与 module_61860 的链路编码器:
// 解密:p = c ^ key[idx];key[idx] = p;idx ^= (p & 7)
// key[i] = K ^ (i << 3)(s2c);c2s 再 ^ 87
// Open Space 之后服务端切加密;密钥是单字节,用「解密后每条消息都必须是完整帧」来穷举。
package Net.Protocol
import Core.Frame
class XorCipher(K: Int, Encoded: Boolean) {
private val Slot = IntArray(8) { I -> ((K xor (I shl 3)) xor (if (Encoded) 87 else 0)) and 0xff }
private var Index = 0
fun Apply(Buf: ByteArray): ByteArray {
val Out = ByteArray(Buf.size)
for (I in Buf.indices) {
val Plain = (Buf[I].toInt() xor Slot[Index]) and 0xff
Out[I] = Plain.toByte()
Slot[Index] = Plain
Index = Index xor (Plain and 7)
}
return Out
}
}
class S2cDecoder {
private enum class Stage { Plain, Probing, Cipher }
private var Current: Stage = Stage.Plain
private var Cipher: XorCipher? = null
private val Window = ArrayList<ByteArray>()
val Key: Int? get() = if (Current == Stage.Cipher) LastKey else null
private var LastKey: Int? = null
// 返回解压后的帧体;处于密钥探测窗口内时返回空
fun Feed(Buf: ByteArray, Log: (String) -> Unit): List<ByteArray> {
when (Current) {
Stage.Plain -> {
val Frames = Frame.ParseFrames(Buf)
if (Frames != null) return DecodeFrames(Frames, Log)
Current = Stage.Probing
Window.clear()
Window.add(Buf)
Log("明文段结束,开始探测链路密钥(首条密文 ${Buf.size}B)")
// 关键:首条密文就要立刻穷举,否则服务端在等回复时会直接关连接
return Probe(Log)
}
Stage.Probing -> {
Window.add(Buf)
return Probe(Log)
}
Stage.Cipher -> {
val Plain = Cipher!!.Apply(Buf)
val Frames = Frame.ParseFrames(Plain)
if (Frames == null) {
Log("密文帧解析失败(${Buf.size}B)")
return emptyList()
}
return DecodeFrames(Frames, Log)
}
}
return emptyList()
}
// 在探测窗口上穷举单字节密钥;唯一命中即切到密文态并吐出窗口内所有帧
private fun Probe(Log: (String) -> Unit): List<ByteArray> {
val Hits = (0..255).filter { SeriesOk(Window, it) }
if (Hits.size == 1) {
val K = Hits[0]
LastKey = K
Cipher = XorCipher(K, false)
Current = Stage.Cipher
Log("链路密钥 K=$K(窗口 ${Window.size} 条)")
val Buffered = ArrayList(Window)
Window.clear()
val Out = ArrayList<ByteArray>()
for (B in Buffered) Out.addAll(DecodeFrames(Frame.ParseFrames(Cipher!!.Apply(B)) ?: emptyList(), Log))
return Out
}
val Bytes = Window.sumOf { it.size }
if (Window.size >= 8 || Bytes > 262144) {
Log("密钥探测失败(候选 ${Hits.size} 个),后续按非帧处理")
Current = Stage.Plain
val Buffered = ArrayList(Window)
Window.clear()
val Out = ArrayList<ByteArray>()
for (B in Buffered) {
val F = Frame.ParseFrames(B) ?: continue
Out.addAll(DecodeFrames(F, Log))
}
return Out
}
Log("密钥候选 ${Hits.size} 个,继续等待下一条")
return emptyList()
}
// 同一密钥连续作用于窗口内所有消息(流密码状态连续)
private fun SeriesOk(Window: List<ByteArray>, K: Int): Boolean {
val C = XorCipher(K, false)
for (B in Window) {
if (Frame.ParseFrames(C.Apply(B)) == null) return false
}
return true
}
private fun DecodeFrames(Frames: List<Core.WireFrame>, Log: (String) -> Unit): List<ByteArray> {
val Out = ArrayList<ByteArray>(Frames.size)
for (F in Frames) {
try {
Out.add(Frame.DecodeFrame(F))
} catch (E: Throwable) {
Log("帧解压失败:${E.message}")
}
}
return Out
}
}
+87
View File
@@ -0,0 +1,87 @@
# RunAutoTest.ps1 —— 启动两条中继并跑 TankiOffline 接入测试
#
# 真实链路是两段连接:
# 大厅:9091 → wss://c2.3dtank.com:9090/(Hash Request / Hash Response)
# 空间:9092 → wss://c2.3dtank.com:9090/(首帧 EnterSpace → 空间帧;实测 c2 才会回空间数据)
#
# 用法:
# powershell -ExecutionPolicy Bypass -File .\tools\RunAutoTest.ps1
# powershell -ExecutionPolicy Bypass -File .\tools\RunAutoTest.ps1 -TimeoutSec 30 -SpaceId 100073070
param(
[long]$SpaceId = 100073070,
[int]$TimeoutSec = 25,
[int]$LobbyPort = 9091,
[int]$SpacePort = 9092,
[string]$LobbyUpstream = 'wss://c2.3dtank.com:9090/',
[string]$SpaceUpstream = 'wss://c2.3dtank.com:9090/'
)
$ErrorActionPreference = 'Stop'
$Root = Split-Path -Parent $PSScriptRoot
$Tools = Join-Path (Split-Path -Parent $Root) '3DTank\tools'
$RelayLobbyOut = Join-Path $env:TEMP 'tankioffline-relay-lobby.out.log'
$RelayLobbyErr = Join-Path $env:TEMP 'tankioffline-relay-lobby.err.log'
$RelaySpaceOut = Join-Path $env:TEMP 'tankioffline-relay-space.out.log'
$RelaySpaceErr = Join-Path $env:TEMP 'tankioffline-relay-space.err.log'
if (-not (Test-Path (Join-Path $Tools 'wss_server.js'))) {
throw "找不到中继脚本:$Tools\wss_server.js"
}
function Start-Relay([int]$Port, [string]$Upstream, [string]$Out, [string]$Err, [string]$Label) {
$Args = @(
'wss_server.js',
'--port', "$Port",
'--upstream', $Upstream,
'--dump-every', '0',
'--log', '0',
'--quiet', '1',
'--tunnel-reconnect', '0',
'--label', $Label
)
Write-Host "启动中继 $Label:$Upstream → localhost:$Port"
return Start-Process -FilePath 'node' -ArgumentList $Args -WorkingDirectory $Tools -PassThru -WindowStyle Hidden `
-RedirectStandardOutput $Out -RedirectStandardError $Err
}
function Wait-Port([int]$Port, [string]$Name) {
for ($I = 0; $I -lt 50; $I++) {
Start-Sleep -Milliseconds 200
try {
$Client = New-Object System.Net.Sockets.TcpClient
$Client.Connect('127.0.0.1', $Port)
$Client.Close()
return
} catch {
}
}
throw "$Name 端口 $Port 未启动"
}
$RelayLobby = Start-Relay $LobbyPort $LobbyUpstream $RelayLobbyOut $RelayLobbyErr 'autotest-lobby'
$RelaySpace = Start-Relay $SpacePort $SpaceUpstream $RelaySpaceOut $RelaySpaceErr 'autotest-space'
try {
Wait-Port $LobbyPort '大厅中继'
Wait-Port $SpacePort '空间中继'
$env:TANKIOFFLINE_AUTOTEST = '1'
$env:TANKIOFFLINE_AUTOTEST_URL = "ws://localhost:$LobbyPort/"
$env:TANKIOFFLINE_AUTOTEST_SPACE_URL = "ws://localhost:$SpacePort/"
$env:TANKIOFFLINE_AUTOTEST_SPACE = "$SpaceId"
$env:TANKIOFFLINE_AUTOTEST_TIMEOUT = "$($TimeoutSec * 1000)"
Write-Host "运行客户端:大厅 $($env:TANKIOFFLINE_AUTOTEST_URL) / 空间 $($env:TANKIOFFLINE_AUTOTEST_SPACE_URL)"
& (Join-Path $Root 'gradlew.bat') ':app:jsNodeDevelopmentRun' '--console=plain'
$Code = $LASTEXITCODE
Write-Host "客户端退出码:$Code"
exit $Code
} finally {
foreach ($P in @($RelayLobby, $RelaySpace)) {
if ($P -and -not $P.HasExited) {
Stop-Process -Id $P.Id -Force -ErrorAction SilentlyContinue
}
}
Write-Host '中继已停止'
}