feat(server): implement sealed share lifecycle
Signed-off-by: JustMe <mr.toing@outlook.com>
This commit is contained in:
1 parent
e02e024605
commit
42b93ae2d8
45 files changed
+6327
No files matched your search
@@ -0,0 +1,119 @@
|
||||
# 对 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.seats_used -eq 1) '创建频道成功'
|
||||
$cid = B64 $id
|
||||
|
||||
$memberH = Headers 'write' $writeTok $memberSecret
|
||||
$joined = Invoke-RestMethod "$base/api/v1/channels/$cid/seats" -Method Post -Headers $memberH
|
||||
Check ($joined.seats_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
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""对 NativeAOT 产物做跨平台冒烟(只用标准库,不含 WebSocket,那部分在 smoke.ps1 与集成测试里)。
|
||||
|
||||
用法: python3 scripts/smoke.py out/linux-x64/webdrop
|
||||
"""
|
||||
import base64, hashlib, json, os, shutil, struct, subprocess, sys, tempfile, time, urllib.error, urllib.request
|
||||
|
||||
exe = sys.argv[1]
|
||||
port = int(sys.argv[2]) if len(sys.argv) > 2 else 5199
|
||||
base = f"http://127.0.0.1:{port}"
|
||||
data_dir = tempfile.mkdtemp(prefix="webdrop-smoke-")
|
||||
|
||||
|
||||
def b64(b: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
|
||||
|
||||
|
||||
def sha(b: bytes) -> bytes:
|
||||
return hashlib.sha256(b).digest()
|
||||
|
||||
|
||||
def check(cond: bool, msg: str):
|
||||
if not cond:
|
||||
raise SystemExit(f"冒烟失败: {msg}")
|
||||
print(f" ok {msg}")
|
||||
|
||||
|
||||
def call(method, path, headers=None, body=None):
|
||||
req = urllib.request.Request(base + path, data=body, method=method, headers=headers or {})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
return r.status, r.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read()
|
||||
|
||||
|
||||
def hdr(role, token, secret):
|
||||
h = {"X-WD-Role": role, "X-WD-Endpoint": b64(secret)}
|
||||
if token:
|
||||
h["Authorization"] = "Bearer " + b64(token)
|
||||
return h
|
||||
|
||||
|
||||
def frame(item: bytes, index: int, flags: int, payload: int, fill: int) -> bytes:
|
||||
return bytes([1, flags]) + item + struct.pack(">II", index, payload) + bytes([fill]) * payload
|
||||
|
||||
|
||||
env = dict(os.environ, WebDrop__DataDir=data_dir, WebDrop__Captcha__Provider="none")
|
||||
proc = subprocess.Popen([exe, "--urls", base], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
try:
|
||||
for _ in range(50):
|
||||
try:
|
||||
call("GET", "/api/v1/info")
|
||||
break
|
||||
except Exception:
|
||||
time.sleep(0.2)
|
||||
|
||||
status, body = call("GET", "/api/v1/info")
|
||||
check(status == 200 and json.loads(body)["protocol"] == 1, "info 可访问")
|
||||
|
||||
cid = os.urandom(16)
|
||||
read_tok, write_tok, owner_tok = os.urandom(32), os.urandom(32), os.urandom(32)
|
||||
owner_secret, member_secret = os.urandom(32), os.urandom(32)
|
||||
owner_h, member_h = hdr("owner", owner_tok, owner_secret), hdr("write", write_tok, member_secret)
|
||||
|
||||
create = json.dumps({
|
||||
"id": b64(cid), "mode": "enc",
|
||||
"read_hash": b64(sha(read_tok)), "write_hash": b64(sha(write_tok)), "owner_hash": b64(sha(owner_tok)),
|
||||
}).encode()
|
||||
status, body = call("POST", "/api/v1/channels", {**owner_h, "Content-Type": "application/json"}, create)
|
||||
check(status == 201, "创建频道")
|
||||
ch = b64(cid)
|
||||
|
||||
status, body = call("POST", f"/api/v1/channels/{ch}/seats", member_h)
|
||||
check(status == 200 and json.loads(body)["seats_used"] == 2, "成员加入")
|
||||
|
||||
item = os.urandom(16)
|
||||
f = frame(item, 0, 9, 1040, 0x42)
|
||||
oct_h = {**owner_h, "Content-Type": "application/octet-stream"}
|
||||
status, _ = call("POST", f"/api/v1/channels/{ch}/frames", oct_h, f)
|
||||
check(status == 201, "追加帧 201")
|
||||
status, _ = call("POST", f"/api/v1/channels/{ch}/frames", oct_h, f)
|
||||
check(status == 200, "幂等重发 200")
|
||||
status, body = call("POST", f"/api/v1/channels/{ch}/frames", oct_h, frame(item, 0, 9, 1040, 0x43))
|
||||
check(status == 409 and json.loads(body)["error"] == "conflict", "内容不同的重复帧 409")
|
||||
|
||||
status, body = call("GET", f"/api/v1/channels/{ch}/log", member_h)
|
||||
check(status == 200 and body[9:] == f and body[8] == 1, "日志返回完整帧记录")
|
||||
|
||||
big = os.urandom(16)
|
||||
for i in range(3):
|
||||
flags = 13 if i == 2 else 12
|
||||
status, _ = call("POST", f"/api/v1/channels/{ch}/frames", {**oct_h, "X-WD-Max-Downloads": "1"},
|
||||
frame(big, i, flags, 1040, i + 1))
|
||||
check(status == 201, f"GATED 第 {i} 帧上传")
|
||||
dl = f"/api/v1/channels/{ch}/items/{b64(big)}/frames"
|
||||
status, body = call("GET", dl, member_h)
|
||||
check(status == 200 and len(body) == 3 * (26 + 1040), "GATED 下载得到三帧")
|
||||
status, body = call("GET", dl, member_h)
|
||||
check(status == 410 and json.loads(body)["error"] == "gone", "下载上限生效 410")
|
||||
|
||||
status, body = call("GET", f"/api/v1/channels/{ch}", hdr("owner", os.urandom(32), owner_secret))
|
||||
check(status == 401 and json.loads(body)["error"] == "unauthorized", "错误令牌 401")
|
||||
|
||||
status, _ = call("DELETE", f"/api/v1/channels/{ch}", owner_h)
|
||||
check(status == 204, "删除频道")
|
||||
|
||||
# 数据目录里不应出现令牌或终端密钥的原值
|
||||
leaked = False
|
||||
for root, _, files in os.walk(data_dir):
|
||||
for name in files:
|
||||
content = open(os.path.join(root, name), "rb").read()
|
||||
for secret in (owner_tok, write_tok, owner_secret, member_secret):
|
||||
if b64(secret).encode() in content or secret in content:
|
||||
leaked = True
|
||||
check(not leaked, "数据目录里没有令牌或终端密钥的原值")
|
||||
print("\n冒烟通过")
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
shutil.rmtree(data_dir, ignore_errors=True)
|
||||
Reference in new issue
Block a user