feat(server): implement sealed share lifecycle

Signed-off-by: JustMe <mr.toing@outlook.com>
This commit is contained in:
justme committed 2026-10-02 00:59:56 +08:00
1 parent e02e024605
commit 42b93ae2d8
45 files changed
+6327

No files matched your search

+119
View File
@@ -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
}
+124
View File
@@ -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)