using System.Collections.Concurrent; using System.Net.WebSockets; using System.Security.Cryptography; using System.Text; using System.Text.Json.Nodes; var baseUrl = args.Length > 0 ? args[0] : "ws://127.0.0.1:21900"; var adminPw0 = args.Length > 1 ? args[1] : "firstpass1"; int pass = 0, fail = 0; void Check(bool ok, string name, string extra = "") { if (ok) { pass++; Console.WriteLine($" [OK] {name}"); } else { fail++; Console.WriteLine($" [FAIL] {name} {extra}"); } } if (args.Length > 2 && args[2] == "phase2") { // 서버 재시작 뒤: 저장해 둔 토큰·기기 키가 그대로 동작하는지 + 아무도 안 붙는 세션 정리 var saved = JsonNode.Parse(File.ReadAllText("saved.json"))!.AsObject(); Console.WriteLine("== 10. 서버 재시작 뒤"); var a2 = await Client.Open(baseUrl + "/ws/manager"); var rr = await a2.Req("resume", new() { ["token"] = saved["adminToken"]!.ToString() }); Check(rr["ok"]!.GetValue(), "재시작 뒤 로그인 토큰 유지", rr.ToJsonString()); var ag = await Client.Open(baseUrl + "/ws/agent"); rr = await ag.Req("auth", new() { ["deviceId"] = saved["deviceId"]!.ToString(), ["deviceKey"] = saved["deviceKey"]!.ToString(), ["activeListen"] = new JsonObject { ["mode"] = "Port", ["port"] = 9988 } }); Check(rr["ok"]!.GetValue() && rr["serverPublicKey"]!.ToString() == saved["pubKey"]!.ToString(), "재시작 뒤 기기 키·서버 공개키 유지"); rr = await a2.Req("connect", new() { ["deviceId"] = saved["deviceId"]!.ToString() }); var sidT = rr["sessionId"]!.ToString(); Console.WriteLine(" (아무도 중계에 붙지 않고 75초 기다림)"); for (int i = 0; i < 15; i++) { await a2.Req("ping"); await ag.Req("ping"); await Task.Delay(5000); } var rowT = Sql($"SELECT mode, end_reason FROM connections WHERE session_id = '{sidT}'"); Check(rowT.StartsWith("failed|"), "아무도 안 붙은 세션은 60초 뒤 실패로 정리", rowT); Console.WriteLine($"\n결과: 통과 {pass} / 실패 {fail}"); return fail == 0 ? 0 : 1; } Console.WriteLine("== 1. 관리자 로그인 / 비밀번호 변경 강제"); var admin = await Client.Open(baseUrl + "/ws/manager"); var r = await admin.Req("login", new() { ["username"] = "admin", ["password"] = "wrong" }); Check(r["ok"]!.GetValue() == false, "틀린 비밀번호 거부", r.ToJsonString()); r = await admin.Req("login", new() { ["username"] = "ADMIN", ["password"] = adminPw0, ["host"] = "TESTPC" }); Check(r["ok"]!.GetValue() && r["user"]!["mustChangePassword"]!.GetValue(), "첫 로그인 성공 + 비밀번호 변경 필요 (대소문자 무시)", r.ToJsonString()); var adminToken = r["token"]?.ToString(); r = await admin.Req("devices"); Check(!r["ok"]!.GetValue(), "비밀번호 바꾸기 전 다른 요청 거부", r.ToJsonString()); r = await admin.Req("changePassword", new() { ["old"] = adminPw0, ["new"] = "short" }); Check(!r["ok"]!.GetValue(), "짧은 새 비밀번호 거부"); r = await admin.Req("changePassword", new() { ["old"] = adminPw0, ["new"] = "AdminPass#2026" }); Check(r["ok"]!.GetValue() && !r["user"]!["mustChangePassword"]!.GetValue(), "비밀번호 변경", r.ToJsonString()); Console.WriteLine("== 2. 등록 코드 / 대상 PC 등록"); r = await admin.Req("admin.codes.create", new() { ["note"] = "테스트 PC" }); var code = r["code"]?.ToString(); Check(code != null && code.Length == 9, "등록 코드 발급: " + code); var agentA = await Client.Open(baseUrl + "/ws/agent"); r = await agentA.Req("enroll", new() { ["code"] = "AAAA-BBBB", ["hostname"] = "X" }); Check(!r["ok"]!.GetValue(), "틀린 등록 코드 거부", r["error"]?.ToString()); agentA = await Client.Open(baseUrl + "/ws/agent"); r = await agentA.Req("enroll", new() { ["code"] = code!.ToLower().Replace("-", " "), ["hostname"] = "HOME-PC", ["os"] = "Windows 11", ["appVersion"] = "2.1.0", ["lanIps"] = new JsonArray("192.168.0.10", "bad-ip"), ["activeListen"] = new JsonObject { ["mode"] = "Port", ["port"] = 9988 } }); Check(r["ok"]!.GetValue(), "등록 성공 (소문자·공백 코드도 허용)", r.ToJsonString()); var deviceId = r["deviceId"]?.ToString(); var deviceKey = r["deviceKey"]?.ToString(); var pubKey = r["serverPublicKey"]?.ToString(); var other = await Client.Open(baseUrl + "/ws/agent"); r = await other.Req("enroll", new() { ["code"] = code, ["hostname"] = "Y" }); Check(!r["ok"]!.GetValue() && r["error"]!.ToString().Contains("이미"), "같은 코드 두 번째 사용 거부", r["error"]?.ToString()); // 등록한 연결을 끊고 기기 키로 다시 접속 await agentA.CloseAsync(); await Task.Delay(300); var agent = await Client.Open(baseUrl + "/ws/agent"); r = await agent.Req("auth", new() { ["deviceId"] = deviceId, ["deviceKey"] = "wrongkey" }); Check(!r["ok"]!.GetValue() && r["code"]?.ToString() == "notEnrolled", "틀린 기기 키 거부"); agent = await Client.Open(baseUrl + "/ws/agent"); var presenceTask = admin.WaitEvent("presence", TimeSpan.FromSeconds(5)); r = await agent.Req("auth", new() { ["deviceId"] = deviceId, ["deviceKey"] = deviceKey, ["hostname"] = "HOME-PC", ["lanIps"] = new JsonArray("192.168.0.10"), ["activeListen"] = new JsonObject { ["mode"] = "Port", ["port"] = 9988 } }); Check(r["ok"]!.GetValue() && r["serverPublicKey"]?.ToString() == pubKey, "기기 키로 접속", r.ToJsonString()); var pres = await presenceTask; Check(pres != null && pres["online"]!.GetValue(), "관리 PC에 온라인 알림", pres?.ToJsonString()); r = await admin.Req("devices"); Check(r["devices"]!.AsArray().Count == 1 && r["devices"]![0]!["online"]!.GetValue(), "관리자 기기 목록 (온라인)", r.ToJsonString()); Console.WriteLine("== 3. 일반 사용자 권한"); r = await admin.Req("admin.users.create", new() { ["username"] = "bob", ["password"] = "BobPass#123", ["role"] = "user", ["mustChangePassword"] = false }); Check(r["ok"]!.GetValue(), "사용자 bob 생성", r.ToJsonString()); var bobId = r["userId"]!.GetValue(); r = await admin.Req("admin.users.create", new() { ["username"] = "Bob", ["password"] = "BobPass#123" }); Check(!r["ok"]!.GetValue(), "대소문자만 다른 같은 아이디 거부"); var bob = await Client.Open(baseUrl + "/ws/manager"); r = await bob.Req("login", new() { ["username"] = "bob", ["password"] = "BobPass#123" }); Check(r["ok"]!.GetValue(), "bob 로그인"); var bobToken = r["token"]!.ToString(); r = await bob.Req("devices"); Check(r["devices"]!.AsArray().Count == 0, "권한 없으면 기기 안 보임"); r = await bob.Req("connect", new() { ["deviceId"] = deviceId }); Check(!r["ok"]!.GetValue(), "권한 없으면 연결 거부", r["error"]?.ToString()); r = await bob.Req("admin.users.list"); Check(!r["ok"]!.GetValue(), "일반 사용자는 [서버 관리] 거부"); var changed = bob.WaitEvent("devicesChanged", TimeSpan.FromSeconds(5)); r = await admin.Req("admin.perms.set", new() { ["userId"] = bobId, ["deviceId"] = deviceId, ["control"] = true }); Check(r["ok"]!.GetValue() && r["view"]!.GetValue() && r["control"]!.GetValue() && !r["screenView"]!.GetValue(), "권한 설정: 서비스 제어 → 보기 자동 포함", r.ToJsonString()); Check(await changed != null, "bob에게 목록 변경 알림"); r = await bob.Req("devices"); Check(r["devices"]!.AsArray().Count == 1, "권한 생기면 기기 보임"); Console.WriteLine("== 4. 연결 요청 + 티켓 + 중계"); var incomingTask = agent.WaitEvent("incoming", TimeSpan.FromSeconds(5)); r = await bob.Req("connect", new() { ["deviceId"] = deviceId }); Check(r["ok"]!.GetValue(), "연결 요청", r.ToJsonString()); var sid = r["sessionId"]!.ToString(); var mTok = r["relayToken"]!.ToString(); var ticket = r["ticket"]!.ToString(); var cands = r["candidates"]!.AsArray(); Check(cands.Count >= 1 && cands.Any(c => c!["host"]!.ToString() == "192.168.0.10" && c["port"]!.GetValue() == 9988), "직접 연결 후보 (내부 IP:Active 포트)", cands.ToJsonString()); Check(VerifyTicket(ticket, pubKey!, out var tp) && tp!["sid"]!.ToString() == sid && tp["deviceId"]!.ToString() == deviceId && tp["username"]!.ToString() == "bob" && tp["perms"]!["control"]!.GetValue() && !tp["perms"]!["screenView"]!.GetValue(), "티켓 서명 확인 (서버 공개키) + 내용", tp?.ToJsonString()); Check(!VerifyTicket(Tamper(ticket), pubKey!, out _), "변조한 티켓은 서명 실패"); var inc = await incomingTask; Check(inc != null && inc["sessionId"]!.ToString() == sid && inc["username"]!.ToString() == "bob", "대상 PC에 incoming 알림"); var aTok = inc!["relayToken"]!.ToString(); var bad = await Raw.TryOpen($"{baseUrl}/relay/{sid}?side=manager&token=wrong"); Check(bad == null, "틀린 중계 토큰 거부"); var relayA = await Raw.TryOpen($"{baseUrl}/relay/{sid}?side=agent&token={aTok}"); var relayM = await Raw.TryOpen($"{baseUrl}/relay/{sid}?side=manager&token={mTok}"); Check(relayA != null && relayM != null, "양쪽 중계 통로 접속"); var reuse = await Raw.TryOpen($"{baseUrl}/relay/{sid}?side=manager&token={mTok}"); Check(reuse == null, "같은 중계 토큰 재사용 거부"); var big = RandomNumberGenerator.GetBytes(1_000_000); var echoTask = Task.Run(async () => { // 대상 PC 쪽: 받은 것을 그대로 돌려보냄 var got = await Raw.ReceiveMessage(relayA!); await relayA!.SendAsync(got, WebSocketMessageType.Binary, true, CancellationToken.None); return got.Length; }); await relayM!.SendAsync(big, WebSocketMessageType.Binary, true, CancellationToken.None); var back = await Raw.ReceiveMessage(relayM); Check(back.SequenceEqual(big) && await echoTask == big.Length, "1MB 데이터 왕복 (관리 PC → 서버 → 대상 PC → 서버 → 관리 PC)"); await relayM.CloseAsync(WebSocketCloseStatus.NormalClosure, "done", CancellationToken.None); var aEnd = await Raw.WaitClosed(relayA!, TimeSpan.FromSeconds(5)); Check(aEnd, "한쪽이 닫으면 다른 쪽도 닫힘"); string row = ""; for (int i = 0; i < 25; i++) { row = Sql($"SELECT mode, bytes_to_device, bytes_from_device, ended_at IS NOT NULL FROM connections WHERE session_id = '{sid}'"); if (row.EndsWith("|t")) break; await Task.Delay(200); } Check(row.StartsWith("relay|1000000|1000000|t"), "연결 기록: relay, 트래픽 1MB/1MB, 종료 시각", row); Console.WriteLine("== 5. 직접 연결 성공 → 중계 취소"); incomingTask = agent.WaitEvent("incoming", TimeSpan.FromSeconds(5)); r = await bob.Req("connect", new() { ["deviceId"] = deviceId }); var sid2 = r["sessionId"]!.ToString(); inc = await incomingTask; var relayA2 = await Raw.TryOpen($"{baseUrl}/relay/{sid2}?side=agent&token={inc!["relayToken"]}"); var cancelTask = agent.WaitEvent("cancel", TimeSpan.FromSeconds(5)); r = await bob.Req("connectStatus", new() { ["sessionId"] = sid2, ["status"] = "direct" }); Check(r["ok"]!.GetValue(), "직접 연결 성공 알림"); Check((await cancelTask)?["sessionId"]?.ToString() == sid2, "대상 PC에 cancel 알림"); Check(await Raw.WaitClosed(relayA2!, TimeSpan.FromSeconds(5)), "기다리던 중계 통로 닫힘"); var lateM = await Raw.TryOpen($"{baseUrl}/relay/{sid2}?side=manager&token={r["relayToken"]}"); Check(lateM == null, "직접 연결된 세션의 중계 접속 거부"); r = await bob.Req("connectStatus", new() { ["sessionId"] = sid2, ["status"] = "ended", ["bytesToDevice"] = 1234, ["bytesFromDevice"] = 5678, ["reason"] = "사용자가 닫음" }); await Task.Delay(300); row = Sql($"SELECT mode, bytes_to_device, bytes_from_device, end_reason FROM connections WHERE session_id = '{sid2}'"); Check(row == "direct|1234|5678|사용자가 닫음", "연결 기록: direct + 관리 PC가 보고한 트래픽", row); r = await admin.Req("connectStatus", new() { ["sessionId"] = sid2, ["status"] = "ended" }); Check(!r["ok"]!.GetValue(), "다른 사용자의 연결 상태 변경 거부"); Console.WriteLine("== 6. 감사 로그 (대상 PC 보고) / 조회"); r = await agent.Req("audit", new() { ["userId"] = bobId, ["username"] = "bob", ["action"] = "stop", ["target"] = "Spooler", ["result"] = "ok" }); Check(r["ok"]!.GetValue(), "대상 PC 감사 로그 보고"); r = await admin.Req("admin.audit.query", new() { ["deviceId"] = deviceId, ["action"] = "stop" }); var rows = r["rows"]!.AsArray(); Check(rows.Count == 1 && rows[0]!["target"]!.ToString() == "Spooler" && rows[0]!["deviceName"]!.ToString() == "HOME-PC", "감사 로그 조회 (기기·작업 필터)", r.ToJsonString()); r = await admin.Req("admin.audit.query", new() { ["limit"] = 100 }); var actions = r["rows"]!.AsArray().Select(x => x!["action"]!.ToString()).ToHashSet(); Check(new[] { "login", "loginFail", "passwordChange", "codeCreate", "deviceEnroll", "userCreate", "permChange", "connect" }.All(actions.Contains), "서버 작업도 감사 로그에 기록", string.Join(",", actions)); r = await admin.Req("admin.connections.query", new() { ["deviceId"] = deviceId }); Check(r["rows"]!.AsArray().Count == 2, "연결 기록 조회", r["rows"]!.AsArray().Count.ToString()); r = await admin.Req("admin.server.info"); Check(r["onlineDevices"]!.GetValue() == 1 && r["onlineManagers"]!.GetValue() == 2 && r["db"]!.GetValue(), "서버 상태", r.ToJsonString()); r = await admin.Req("admin.codes.list"); Check(r["codes"]![0]!["status"]!.ToString() == "used" && r["codes"]![0]!["usedDevice"]!.ToString() == "HOME-PC", "등록 코드 목록 (사용됨)", r.ToJsonString()); Console.WriteLine("== 7. 재접속 (토큰) / 로그아웃"); var bob2 = await Client.Open(baseUrl + "/ws/manager"); r = await bob2.Req("resume", new() { ["token"] = bobToken }); Check(r["ok"]!.GetValue(), "토큰으로 재접속"); r = await bob2.Req("logout"); var bob3 = await Client.Open(baseUrl + "/ws/manager"); r = await bob3.Req("resume", new() { ["token"] = bobToken }); Check(!r["ok"]!.GetValue() && r["code"]?.ToString() == "loginRequired", "로그아웃한 토큰 거부"); var adm2 = await Client.Open(baseUrl + "/ws/manager"); r = await adm2.Req("resume", new() { ["token"] = adminToken }); Check(r["ok"]!.GetValue(), "비밀번호를 바꾼 연결의 토큰은 계속 유효", r.ToJsonString()); Console.WriteLine("== 8. 관리자 보호 / 사용 안 함"); r = await admin.Req("admin.users.update", new() { ["userId"] = 1, ["role"] = "user" }); Check(!r["ok"]!.GetValue(), "마지막 관리자 역할 변경 거부", r["error"]?.ToString()); bob = await Client.Open(baseUrl + "/ws/manager"); await bob.Req("login", new() { ["username"] = "bob", ["password"] = "BobPass#123" }); var outTask = bob.WaitEvent("loggedOut", TimeSpan.FromSeconds(5)); r = await admin.Req("admin.users.update", new() { ["userId"] = bobId, ["enabled"] = false }); Check(r["ok"]!.GetValue() && await outTask != null, "사용자 사용 안 함 → 접속 중인 연결 끊김"); var bob4 = await Client.Open(baseUrl + "/ws/manager"); r = await bob4.Req("login", new() { ["username"] = "bob", ["password"] = "BobPass#123" }); Check(!r["ok"]!.GetValue(), "사용 안 함 계정 로그인 거부", r["error"]?.ToString()); var disTask = agent.WaitEvent("disabled", TimeSpan.FromSeconds(5)); var offTask = admin.WaitEvent("presence", TimeSpan.FromSeconds(5)); r = await admin.Req("admin.devices.disable", new() { ["deviceId"] = deviceId }); Check(r["ok"]!.GetValue() && await disTask != null, "기기 사용 안 함 → 대상 PC에 알림"); var off = await offTask; Check(off != null && !off["online"]!.GetValue(), "관리 PC에 오프라인 알림"); var agent2 = await Client.Open(baseUrl + "/ws/agent"); r = await agent2.Req("auth", new() { ["deviceId"] = deviceId, ["deviceKey"] = deviceKey }); Check(!r["ok"]!.GetValue() && r["code"]?.ToString() == "disabled", "사용 안 함 기기 접속 거부"); await admin.Req("admin.devices.enable", new() { ["deviceId"] = deviceId }); agent2 = await Client.Open(baseUrl + "/ws/agent"); r = await agent2.Req("auth", new() { ["deviceId"] = deviceId, ["deviceKey"] = deviceKey }); Check(r["ok"]!.GetValue(), "다시 사용 → 접속됨"); var agent3 = await Client.Open(baseUrl + "/ws/agent"); var replaced = agent2.WaitEvent("replaced", TimeSpan.FromSeconds(5)); r = await agent3.Req("auth", new() { ["deviceId"] = deviceId, ["deviceKey"] = deviceKey }); Check(r["ok"]!.GetValue() && await replaced != null, "같은 기기 두 번째 접속 → 이전 연결 교체"); Console.WriteLine("== 9. 로그인 실패 제한"); string last = ""; for (int i = 0; i < 12; i++) { var t = await Client.Open(baseUrl + "/ws/manager"); var x = await t.Req("login", new() { ["username"] = "nobody", ["password"] = "x" }); last = x["error"]?.ToString() ?? ""; await t.CloseAsync(); } Check(last.Contains("너무 많습니다"), "11번 넘게 틀리면 잠시 거부", last); File.WriteAllText("saved.json", new JsonObject { ["adminToken"] = adminToken, ["deviceId"] = deviceId, ["deviceKey"] = deviceKey, ["pubKey"] = pubKey }.ToJsonString()); Console.WriteLine($"\n결과: 통과 {pass} / 실패 {fail}"); return fail == 0 ? 0 : 1; static string Sql(string q) { var p = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("psql", $"-h 127.0.0.1 -p 55432 -U jjsm -d jjsm -At -c \"{q}\"") { RedirectStandardOutput = true })!; var s = p.StandardOutput.ReadToEnd().Trim(); p.WaitForExit(); return s; } static bool VerifyTicket(string ticket, string pubKeyB64, out JsonObject payload) { payload = null; try { var parts = ticket.Split('.'); var body = B64(parts[0]); var sig = B64(parts[1]); using var k = ECDsa.Create(); k.ImportSubjectPublicKeyInfo(Convert.FromBase64String(pubKeyB64), out _); if (!k.VerifyData(body, sig, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation)) return false; payload = JsonNode.Parse(body) as JsonObject; return true; } catch { return false; } } static string Tamper(string ticket) { var parts = ticket.Split('.'); var body = JsonNode.Parse(B64(parts[0]))!.AsObject(); body["perms"]!["screenControl"] = true; return Convert.ToBase64String(Encoding.UTF8.GetBytes(body.ToJsonString())).TrimEnd('=').Replace('+', '-').Replace('/', '_') + "." + parts[1]; } static byte[] B64(string s) { s = s.Replace('-', '+').Replace('_', '/'); switch (s.Length % 4) { case 2: s += "=="; break; case 3: s += "="; break; } return Convert.FromBase64String(s); } sealed class Client { private ClientWebSocket _ws = new(); private long _id; private readonly ConcurrentDictionary> _pending = new(); private readonly List<(string ev, TaskCompletionSource tcs)> _waiters = new(); private readonly List _events = new(); public static async Task Open(string url) { var c = new Client(); await c._ws.ConnectAsync(new Uri(url), CancellationToken.None); _ = c.ReadLoop(); return c; } private async Task ReadLoop() { var buf = new byte[1 << 20]; try { while (true) { using var ms = new MemoryStream(); WebSocketReceiveResult r; do { r = await _ws.ReceiveAsync(buf, CancellationToken.None); if (r.MessageType == WebSocketMessageType.Close) { Fail(); return; } ms.Write(buf, 0, r.Count); } while (!r.EndOfMessage); var o = JsonNode.Parse(ms.ToArray())!.AsObject(); if (o["event"] != null) { lock (_waiters) { var w = _waiters.FirstOrDefault(x => x.ev == o["event"]!.ToString()); if (w.tcs != null) { _waiters.Remove(w); w.tcs.TrySetResult(o); } else _events.Add(o); } } else if (o["id"] != null && _pending.TryRemove(o["id"]!.GetValue(), out var t)) t.TrySetResult(o); } } catch { Fail(); } } private void Fail() { foreach (var p in _pending.Values) p.TrySetResult(new JsonObject { ["ok"] = false, ["error"] = "연결 끊김" }); } public async Task Req(string op, JsonObject args = null) { var id = Interlocked.Increment(ref _id); var o = args ?? new JsonObject(); o["id"] = id; o["op"] = op; var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _pending[id] = tcs; try { await _ws.SendAsync(Encoding.UTF8.GetBytes(o.ToJsonString()), WebSocketMessageType.Text, true, CancellationToken.None); } catch { return new JsonObject { ["ok"] = false, ["error"] = "보내기 실패" }; } var done = await Task.WhenAny(tcs.Task, Task.Delay(10000)); return done == tcs.Task ? tcs.Task.Result : new JsonObject { ["ok"] = false, ["error"] = "시간 초과" }; } public async Task WaitEvent(string ev, TimeSpan timeout) { TaskCompletionSource tcs; lock (_waiters) { var e = _events.FirstOrDefault(x => x["event"]!.ToString() == ev); if (e != null) { _events.Remove(e); return e; } tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _waiters.Add((ev, tcs)); } var d = await Task.WhenAny(tcs.Task, Task.Delay(timeout)); return d == tcs.Task ? tcs.Task.Result : null; } public async Task CloseAsync() { try { await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None); } catch { } } } static class Raw { public static async Task TryOpen(string url) { var ws = new ClientWebSocket(); try { await ws.ConnectAsync(new Uri(url), CancellationToken.None); return ws; } catch { return null; } } public static async Task ReceiveMessage(ClientWebSocket ws) { var buf = new byte[64 * 1024]; using var ms = new MemoryStream(); WebSocketReceiveResult r; do { r = await ws.ReceiveAsync(buf, CancellationToken.None); ms.Write(buf, 0, r.Count); } while (!r.EndOfMessage); return ms.ToArray(); } public static async Task WaitClosed(ClientWebSocket ws, TimeSpan timeout) { try { using var cts = new CancellationTokenSource(timeout); var buf = new byte[1024]; while (true) { var r = await ws.ReceiveAsync(buf, cts.Token); if (r.MessageType == WebSocketMessageType.Close) return true; } } catch (OperationCanceledException) { return false; } catch { return true; } } }