Contract 7.0.0 (breaking). Devices become an attribute of the account;
members are managed per user, and only unauthenticated guests keep a
per-terminal registration.
- rename seats -> max_members, seats_used -> members_used,
seats_full -> members_full, SeatJoined -> JoinResult, the `seats`
stream control message -> `members`, and POST /channels/{id}/seats ->
POST /channels/{id}/join (stored channel settings are migrated on start)
- drop per-terminal encrypted device labels and the seat list/release
endpoints; add GET/DELETE /channels/{id}/guests for guest terminals
- authenticate account members by account session only: the terminal key
is optional for logged-in accounts, no seat row is written for them,
and gated-download grants are counted per user (per terminal for guests)
- GET /channels/{id}/members now returns online, devices and
online_devices per account; presence is tracked in memory from active
WebSocket/SSE subscriptions and is not pushed over the stream
- owner invitation list no longer returns accounts that already joined
120 lines
6.8 KiB
PowerShell
120 lines
6.8 KiB
PowerShell
# 对 NativeAOT 发布产物做端到端冒烟:创建频道、加入、追加帧、读日志、WebSocket 推送、GATED 下载、删除。
|
|
# 用法: pwsh scripts/smoke.ps1 -Exe out/win-x64/webdrop.exe
|
|
param(
|
|
[Parameter(Mandatory)][string]$Exe,
|
|
[int]$Port = 5188
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
$base = "http://127.0.0.1:$Port"
|
|
$dataDir = Join-Path ([IO.Path]::GetTempPath()) ("webdrop-smoke-" + [guid]::NewGuid().ToString('N'))
|
|
|
|
function B64([byte[]]$b) { [Convert]::ToBase64String($b).TrimEnd('=').Replace('+', '-').Replace('/', '_') }
|
|
function Rand([int]$n) { $b = [byte[]]::new($n); [Security.Cryptography.RandomNumberGenerator]::Fill($b); $b }
|
|
function Sha([byte[]]$b) { [Security.Cryptography.SHA256]::HashData($b) }
|
|
function Check($cond, $msg) { if (-not $cond) { throw "冒烟失败: $msg" } else { Write-Host " ok $msg" } }
|
|
|
|
function Frame([byte[]]$item, [uint32]$index, [byte]$flags, [int]$payload, [byte]$fill) {
|
|
$f = [byte[]]::new(26 + $payload)
|
|
$f[0] = 1; $f[1] = $flags
|
|
[Array]::Copy($item, 0, $f, 2, 16)
|
|
$idx = [BitConverter]::GetBytes($index); [Array]::Reverse($idx); [Array]::Copy($idx, 0, $f, 18, 4)
|
|
$len = [BitConverter]::GetBytes([uint32]$payload); [Array]::Reverse($len); [Array]::Copy($len, 0, $f, 22, 4)
|
|
for ($i = 26; $i -lt $f.Length; $i++) { $f[$i] = $fill }
|
|
, $f
|
|
}
|
|
|
|
function Headers($role, $token, [byte[]]$secret) {
|
|
$h = @{ 'X-WD-Role' = $role; 'X-WD-Endpoint' = (B64 $secret) }
|
|
if ($token) { $h['Authorization'] = 'Bearer ' + (B64 $token) }
|
|
$h
|
|
}
|
|
|
|
$env:WebDrop__DataDir = $dataDir
|
|
$proc = Start-Process -FilePath $Exe -ArgumentList '--urls', $base -PassThru -WindowStyle Hidden
|
|
try {
|
|
for ($i = 0; $i -lt 50; $i++) {
|
|
try { $null = Invoke-WebRequest "$base/api/v1/info" -UseBasicParsing; break } catch { Start-Sleep -Milliseconds 200 }
|
|
}
|
|
$info = Invoke-RestMethod "$base/api/v1/info"
|
|
Check ($info.protocol -eq 1 -and -not $info.captcha.enabled) 'info 可访问,默认不启用验证码'
|
|
|
|
$id = Rand 16; $readTok = Rand 32; $writeTok = Rand 32; $ownerTok = Rand 32
|
|
$ownerSecret = Rand 32; $memberSecret = Rand 32
|
|
|
|
$body = @{ id = (B64 $id); mode = 'enc'; read_hash = (B64 (Sha $readTok)); write_hash = (B64 (Sha $writeTok)); owner_hash = (B64 (Sha $ownerTok)) } | ConvertTo-Json
|
|
$ownerH = Headers 'owner' $ownerTok $ownerSecret
|
|
|
|
$created = Invoke-RestMethod "$base/api/v1/channels" -Method Post -Body $body -ContentType 'application/json' -Headers $ownerH
|
|
Check ($created.id -eq (B64 $id) -and $created.members_used -eq 1) '创建频道成功'
|
|
$cid = B64 $id
|
|
|
|
$memberH = Headers 'write' $writeTok $memberSecret
|
|
$joined = Invoke-RestMethod "$base/api/v1/channels/$cid/join" -Method Post -Headers $memberH
|
|
Check ($joined.members_used -eq 2) '成员加入'
|
|
|
|
# enc 帧:负载 = 1024 档位 + 16 标签
|
|
$item = Rand 16
|
|
$frame = Frame $item 0 9 1040 0x42
|
|
$r1 = Invoke-WebRequest "$base/api/v1/channels/$cid/frames" -Method Post -Body $frame -ContentType 'application/octet-stream' -Headers $ownerH -UseBasicParsing
|
|
Check ($r1.StatusCode -eq 201) '追加帧返回 201'
|
|
$r2 = Invoke-WebRequest "$base/api/v1/channels/$cid/frames" -Method Post -Body $frame -ContentType 'application/octet-stream' -Headers $ownerH -UseBasicParsing
|
|
Check ($r2.StatusCode -eq 200) '幂等重发返回 200'
|
|
|
|
$log = Invoke-WebRequest "$base/api/v1/channels/$cid/log" -Headers $memberH -UseBasicParsing
|
|
$bytes = [byte[]]$log.Content
|
|
Check ($bytes.Length -eq 9 + $frame.Length -and $bytes[8] -eq 1) '日志返回完整的帧记录'
|
|
Check ((Compare-Object $bytes[9..($bytes.Length - 1)] $frame) -eq $null) '帧字节与上传一致'
|
|
|
|
# WebSocket:凭据走子协议;先收到历史,再收到实时追加
|
|
$ws = [Net.WebSockets.ClientWebSocket]::new()
|
|
foreach ($p in 'wd.v1', 'wd.role.write', ('wd.auth.' + (B64 $writeTok)), ('wd.ep.' + (B64 $memberSecret))) { $ws.Options.AddSubProtocol($p) }
|
|
$ws.ConnectAsync([Uri]("ws://127.0.0.1:$Port/api/v1/channels/$cid/ws?from=0"), [Threading.CancellationToken]::None).Wait()
|
|
$seg = [ArraySegment[byte]]::new([byte[]]::new(4096))
|
|
$r = $ws.ReceiveAsync($seg, [Threading.CancellationToken]::None).Result
|
|
Check ($r.Count -eq 9 + $frame.Length) 'WebSocket 收到历史记录'
|
|
$item2 = Rand 16
|
|
$null = Invoke-WebRequest "$base/api/v1/channels/$cid/frames" -Method Post -Body (Frame $item2 0 9 1040 0x43) -ContentType 'application/octet-stream' -Headers $ownerH -UseBasicParsing
|
|
$r = $ws.ReceiveAsync($seg, [Threading.CancellationToken]::None).Result
|
|
Check ($r.Count -eq 9 + 26 + 1040) 'WebSocket 实时收到新帧'
|
|
$ws.Dispose()
|
|
|
|
# GATED 条目:三帧,下载上限 1
|
|
$big = Rand 16
|
|
foreach ($i in 0..2) {
|
|
$flags = if ($i -eq 2) { 13 } else { 12 } # Gated|Enc (+Final)
|
|
$h = $ownerH + @{ 'X-WD-Max-Downloads' = '1' }
|
|
$null = Invoke-WebRequest "$base/api/v1/channels/$cid/frames" -Method Post -Body (Frame $big ([uint32]$i) $flags 1040 ($i + 1)) -ContentType 'application/octet-stream' -Headers $h -UseBasicParsing
|
|
}
|
|
$big64 = B64 $big
|
|
$dl = Invoke-WebRequest "$base/api/v1/channels/$cid/items/$big64/frames" -Headers $memberH -UseBasicParsing
|
|
Check (([byte[]]$dl.Content).Length -eq 3 * (26 + 1040)) 'GATED 条目下载得到全部三帧'
|
|
$gone = try { Invoke-WebRequest "$base/api/v1/channels/$cid/items/$big64/frames" -Headers $memberH -UseBasicParsing; 0 } catch { [int]$_.Exception.Response.StatusCode }
|
|
Check ($gone -eq 410) '下载上限生效,第二次返回 410'
|
|
|
|
$unauth = try { Invoke-WebRequest "$base/api/v1/channels/$cid" -Headers (Headers 'owner' (Rand 32) $ownerSecret) -UseBasicParsing; 0 } catch { [int]$_.Exception.Response.StatusCode }
|
|
Check ($unauth -eq 401) '错误令牌返回 401'
|
|
|
|
$del = Invoke-WebRequest "$base/api/v1/channels/$cid" -Method Delete -Headers $ownerH -UseBasicParsing
|
|
Check ($del.StatusCode -eq 204) '删除频道'
|
|
|
|
# 数据目录(含 SQLite 的 -wal 文件)里不应出现令牌或终端密钥的原值;服务仍在运行,用共享读取打开
|
|
$found = $false
|
|
foreach ($file in (Get-ChildItem $dataDir -Recurse -File)) {
|
|
$fs = [IO.File]::Open($file.FullName, 'Open', 'Read', 'ReadWrite')
|
|
try {
|
|
$buf = [byte[]]::new($fs.Length); $null = $fs.Read($buf, 0, $buf.Length)
|
|
} finally { $fs.Dispose() }
|
|
$content = [Text.Encoding]::Latin1.GetString($buf)
|
|
if ($content.Contains((B64 $ownerTok)) -or $content.Contains((B64 $ownerSecret)) -or $content.Contains((B64 $writeTok))) { $found = $true }
|
|
}
|
|
Check (-not $found) '数据目录里没有令牌或终端密钥的原值'
|
|
|
|
Write-Host "`n冒烟通过" -ForegroundColor Green
|
|
}
|
|
finally {
|
|
if (-not $proc.HasExited) { Stop-Process $proc -Force }
|
|
Start-Sleep -Milliseconds 300
|
|
Remove-Item -Recurse -Force $dataDir -ErrorAction SilentlyContinue
|
|
}
|