426 lines
23 KiB
C#
426 lines
23 KiB
C#
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<bool>(), "재시작 뒤 로그인 토큰 유지", 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<bool>() && 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<bool>() == false, "틀린 비밀번호 거부", r.ToJsonString());
|
|
r = await admin.Req("login", new() { ["username"] = "ADMIN", ["password"] = adminPw0, ["host"] = "TESTPC" });
|
|
Check(r["ok"]!.GetValue<bool>() && r["user"]!["mustChangePassword"]!.GetValue<bool>(), "첫 로그인 성공 + 비밀번호 변경 필요 (대소문자 무시)", r.ToJsonString());
|
|
var adminToken = r["token"]?.ToString();
|
|
r = await admin.Req("devices");
|
|
Check(!r["ok"]!.GetValue<bool>(), "비밀번호 바꾸기 전 다른 요청 거부", r.ToJsonString());
|
|
r = await admin.Req("changePassword", new() { ["old"] = adminPw0, ["new"] = "short" });
|
|
Check(!r["ok"]!.GetValue<bool>(), "짧은 새 비밀번호 거부");
|
|
r = await admin.Req("changePassword", new() { ["old"] = adminPw0, ["new"] = "AdminPass#2026" });
|
|
Check(r["ok"]!.GetValue<bool>() && !r["user"]!["mustChangePassword"]!.GetValue<bool>(), "비밀번호 변경", 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<bool>(), "틀린 등록 코드 거부", 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<bool>(), "등록 성공 (소문자·공백 코드도 허용)", 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<bool>() && 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<bool>() && 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<bool>() && r["serverPublicKey"]?.ToString() == pubKey, "기기 키로 접속", r.ToJsonString());
|
|
var pres = await presenceTask;
|
|
Check(pres != null && pres["online"]!.GetValue<bool>(), "관리 PC에 온라인 알림", pres?.ToJsonString());
|
|
|
|
r = await admin.Req("devices");
|
|
Check(r["devices"]!.AsArray().Count == 1 && r["devices"]![0]!["online"]!.GetValue<bool>(), "관리자 기기 목록 (온라인)", 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<bool>(), "사용자 bob 생성", r.ToJsonString());
|
|
var bobId = r["userId"]!.GetValue<long>();
|
|
r = await admin.Req("admin.users.create", new() { ["username"] = "Bob", ["password"] = "BobPass#123" });
|
|
Check(!r["ok"]!.GetValue<bool>(), "대소문자만 다른 같은 아이디 거부");
|
|
var bob = await Client.Open(baseUrl + "/ws/manager");
|
|
r = await bob.Req("login", new() { ["username"] = "bob", ["password"] = "BobPass#123" });
|
|
Check(r["ok"]!.GetValue<bool>(), "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<bool>(), "권한 없으면 연결 거부", r["error"]?.ToString());
|
|
r = await bob.Req("admin.users.list");
|
|
Check(!r["ok"]!.GetValue<bool>(), "일반 사용자는 [서버 관리] 거부");
|
|
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<bool>() && r["view"]!.GetValue<bool>() && r["control"]!.GetValue<bool>() && !r["screenView"]!.GetValue<bool>(),
|
|
"권한 설정: 서비스 제어 → 보기 자동 포함", 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<bool>(), "연결 요청", 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<long>() == 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<bool>() && !tp["perms"]!["screenView"]!.GetValue<bool>(),
|
|
"티켓 서명 확인 (서버 공개키) + 내용", 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<bool>(), "직접 연결 성공 알림");
|
|
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<bool>(), "다른 사용자의 연결 상태 변경 거부");
|
|
|
|
Console.WriteLine("== 6. 감사 로그 (대상 PC 보고) / 조회");
|
|
r = await agent.Req("audit", new() { ["userId"] = bobId, ["username"] = "bob", ["action"] = "stop", ["target"] = "Spooler", ["result"] = "ok" });
|
|
Check(r["ok"]!.GetValue<bool>(), "대상 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<int>() == 1 && r["onlineManagers"]!.GetValue<int>() == 2 && r["db"]!.GetValue<bool>(), "서버 상태", 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<bool>(), "토큰으로 재접속");
|
|
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<bool>() && r["code"]?.ToString() == "loginRequired", "로그아웃한 토큰 거부");
|
|
var adm2 = await Client.Open(baseUrl + "/ws/manager");
|
|
r = await adm2.Req("resume", new() { ["token"] = adminToken });
|
|
Check(r["ok"]!.GetValue<bool>(), "비밀번호를 바꾼 연결의 토큰은 계속 유효", r.ToJsonString());
|
|
|
|
Console.WriteLine("== 8. 관리자 보호 / 사용 안 함");
|
|
r = await admin.Req("admin.users.update", new() { ["userId"] = 1, ["role"] = "user" });
|
|
Check(!r["ok"]!.GetValue<bool>(), "마지막 관리자 역할 변경 거부", 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<bool>() && 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<bool>(), "사용 안 함 계정 로그인 거부", 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<bool>() && await disTask != null, "기기 사용 안 함 → 대상 PC에 알림");
|
|
var off = await offTask;
|
|
Check(off != null && !off["online"]!.GetValue<bool>(), "관리 PC에 오프라인 알림");
|
|
var agent2 = await Client.Open(baseUrl + "/ws/agent");
|
|
r = await agent2.Req("auth", new() { ["deviceId"] = deviceId, ["deviceKey"] = deviceKey });
|
|
Check(!r["ok"]!.GetValue<bool>() && 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<bool>(), "다시 사용 → 접속됨");
|
|
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<bool>() && 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<long, TaskCompletionSource<JsonObject>> _pending = new();
|
|
private readonly List<(string ev, TaskCompletionSource<JsonObject> tcs)> _waiters = new();
|
|
private readonly List<JsonObject> _events = new();
|
|
|
|
public static async Task<Client> 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<long>(), 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<JsonObject> 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<JsonObject>(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<JsonObject> WaitEvent(string ev, TimeSpan timeout)
|
|
{
|
|
TaskCompletionSource<JsonObject> tcs;
|
|
lock (_waiters)
|
|
{
|
|
var e = _events.FirstOrDefault(x => x["event"]!.ToString() == ev);
|
|
if (e != null) { _events.Remove(e); return e; }
|
|
tcs = new TaskCompletionSource<JsonObject>(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<ClientWebSocket> 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<byte[]> 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<bool> 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; }
|
|
}
|
|
}
|