using System.Net.WebSockets; using System.Security.Cryptography; using System.Text.Json.Nodes; namespace JJServiceManagerRelay; /// /ws/agent — 대상 PC public sealed class AgentHandler { private readonly Hub _hub; public AgentHandler(Hub hub) => _hub = hub; public async Task RunAsync(HttpContext ctx) { var ip = _hub.Config.ClientIp(ctx); using var socket = await ctx.WebSockets.AcceptWebSocketAsync(); var ws = new WsJson(socket); AgentConn agent = null; try { agent = await AuthenticateAsync(ws, ip, ctx.RequestAborted); if (agent == null) return; // 같은 기기로 이미 접속해 있으면 이전 연결을 끊음 if (_hub.Agents.TryGetValue(agent.DeviceId, out var old)) { await old.Ws.TrySendAsync(new JsonObject { ["event"] = "replaced", ["reason"] = "같은 기기로 다른 곳에서 접속했습니다." }); old.Cts.Cancel(); } _hub.Agents[agent.DeviceId] = agent; _hub.Log.Info($"대상 PC 접속: {agent.Name} ({agent.DeviceId}) {ip}"); // 목록에 넣은 뒤에 응답 (응답 직후 들어오는 요청·교체가 이 연결을 보도록) await ws.SendAsync(agent.PendingReply, ctx.RequestAborted); agent.PendingReply = null; if (agent.NotifyDevicesChanged) await _hub.NotifyDevicesChangedAsync(); await _hub.NotifyPresenceAsync(agent.DeviceId, agent.Name, agent.Hostname, true); using var linked = CancellationTokenSource.CreateLinkedTokenSource(ctx.RequestAborted, agent.Cts.Token); var ct = linked.Token; var lastTouch = DateTime.UtcNow; while (!ct.IsCancellationRequested) { JsonObject msg; using (var idle = CancellationTokenSource.CreateLinkedTokenSource(ct)) { idle.CancelAfter(TimeSpan.FromSeconds(120)); // 2분 동안 아무것도 없으면 끊김으로 봄 msg = await ws.ReceiveAsync(idle.Token); } if (msg == null) break; var id = J.Long(msg, "id"); try { var res = await HandleAsync(agent, msg, ip); if (id != null) await ws.SendAsync(WsJson.Ok(id, res), ct); } catch (UserError ue) { if (id != null) await ws.SendAsync(WsJson.Error(id, ue.Message), ct); } if (DateTime.UtcNow - lastTouch > TimeSpan.FromMinutes(1)) { lastTouch = DateTime.UtcNow; await _hub.Db.TouchDeviceAsync(agent.DeviceId); } } } catch (OperationCanceledException) { } catch (WebSocketException) { } catch (InvalidDataException ex) { _hub.Log.Warn($"대상 PC 메시지 오류 {ip}: {ex.Message}"); } catch (Exception ex) { _hub.Log.Error($"대상 PC 처리 오류 {ip}: {ex}"); } finally { if (agent != null) { // 다른 연결로 바뀐 경우가 아니면 목록에서 뺌 if (_hub.Agents.TryGetValue(agent.DeviceId, out var cur) && ReferenceEquals(cur, agent)) { _hub.Agents.TryRemove(agent.DeviceId, out _); _hub.Log.Info($"대상 PC 연결 끊김: {agent.Name} ({agent.DeviceId})"); try { await _hub.Db.TouchDeviceAsync(agent.DeviceId); } catch { } await _hub.NotifyPresenceAsync(agent.DeviceId, agent.Name, agent.Hostname, false); } } await ws.CloseAsync("bye"); } } /// 첫 메시지: enroll(등록 코드) 또는 auth(기기 키). 15초 안에 와야 함 private async Task AuthenticateAsync(WsJson ws, string ip, CancellationToken ct) { using var first = CancellationTokenSource.CreateLinkedTokenSource(ct); first.CancelAfter(TimeSpan.FromSeconds(15)); var msg = await ws.ReceiveAsync(first.Token); if (msg == null) return null; var id = J.Long(msg, "id"); var op = J.Str(msg, "op"); var ipKey = "agent-ip:" + ip; if (_hub.Throttle.IsBlocked(ipKey)) { await ws.SendAsync(WsJson.Error(id, "실패가 너무 많습니다. 15분 뒤에 다시 시도하세요."), ct); return null; } if (op == "enroll") { var code = Secrets.NormalizeCode(J.Str(msg, "code")); var hostname = Clip(J.Str(msg, "hostname"), 100); if (hostname.Length == 0) hostname = "PC"; var key = RandomNumberGenerator.GetBytes(32); var keyText = Secrets.Base64Url(key); var (deviceId, createdBy, error) = await _hub.Db.EnrollAsync(Secrets.Sha256(code), Guid.NewGuid(), hostname, Secrets.Sha256(keyText), Clip(J.Str(msg, "os"), 200), Clip(J.Str(msg, "appVersion"), 50)); if (error != null) { _hub.Throttle.Fail(ipKey); await _hub.Db.AuditAsync(null, "", null, hostname, "deviceEnroll", code.Length > 4 ? code[..4] + "-****" : "", "fail", error, ip); await ws.SendAsync(WsJson.Error(id, error), ct); return null; } _hub.Throttle.Success(ipKey); var creator = createdBy == null ? null : await _hub.Db.GetUserAsync(createdBy.Value); await _hub.Db.AuditAsync(createdBy, creator?.Username ?? "", deviceId, hostname, "deviceEnroll", "", "ok", "등록 코드로 기기 등록", ip); _hub.Log.Info($"기기 등록: {hostname} ({deviceId}) {ip}"); var agent = new AgentConn { DeviceId = deviceId, Name = hostname, Hostname = hostname, Ws = ws, PublicIp = ip }; ApplyState(agent, msg); await SaveStateAsync(agent, msg); agent.PendingReply = WsJson.Ok(id, new JsonObject { ["deviceId"] = deviceId.ToString(), ["deviceKey"] = keyText, ["serverPublicKey"] = _hub.Signer.PublicKeyBase64, ["name"] = hostname, ["publicIp"] = ip }); agent.NotifyDevicesChanged = true; return agent; } if (op == "auth") { var deviceId = J.Guid(msg, "deviceId"); var keyText = J.Str(msg, "deviceKey"); var dev = deviceId == null ? null : await _hub.Db.GetDeviceAsync(deviceId.Value); bool ok = dev != null && CryptographicOperations.FixedTimeEquals(dev.KeyHash, Secrets.Sha256(keyText)); if (!ok) { _hub.Throttle.Fail(ipKey); await ws.SendAsync(WsJson.Error(id, "등록되지 않은 기기이거나 기기 키가 맞지 않습니다. 다시 등록하세요.") .Also(o => o["code"] = "notEnrolled"), ct); return null; } if (!dev.Enabled) { await ws.SendAsync(WsJson.Error(id, "서버에서 사용 안 함으로 설정된 기기입니다.").Also(o => o["code"] = "disabled"), ct); return null; } _hub.Throttle.Success(ipKey); var agent = new AgentConn { DeviceId = dev.Id, Name = dev.Name, Hostname = string.IsNullOrWhiteSpace(J.Str(msg, "hostname")) ? dev.Hostname : Clip(J.Str(msg, "hostname"), 100), Ws = ws, PublicIp = ip }; ApplyState(agent, msg); await SaveStateAsync(agent, msg); agent.PendingReply = WsJson.Ok(id, new JsonObject { ["serverPublicKey"] = _hub.Signer.PublicKeyBase64, ["name"] = dev.Name, ["publicIp"] = ip }); return agent; } await ws.SendAsync(WsJson.Error(id, "먼저 enroll 또는 auth를 보내야 합니다."), ct); return null; } private async Task HandleAsync(AgentConn agent, JsonObject msg, string ip) { switch (J.Str(msg, "op")) { case "ping": return new JsonObject { ["time"] = Db.Iso(DateTime.UtcNow) }; case "update": ApplyState(agent, msg); await SaveStateAsync(agent, msg); return new JsonObject(); case "audit": { // 대상 PC가 실제로 실행한 작업 (티켓에서 확인한 사용자) var action = Clip(J.Str(msg, "action"), 50); if (action.Length == 0) throw new UserError("action이 없습니다."); await _hub.Db.AuditAsync(J.Long(msg, "userId"), Clip(J.Str(msg, "username"), 100), agent.DeviceId, agent.Name, action, J.Str(msg, "target"), Clip(J.Str(msg, "result", "ok"), 20), J.Str(msg, "detail"), J.Str(msg, "sourceIp", "")); return new JsonObject(); } default: throw new UserError("알 수 없는 요청: " + J.Str(msg, "op")); } } private static void ApplyState(AgentConn a, JsonObject msg) { var host = Clip(J.Str(msg, "hostname"), 100); if (host.Length > 0) a.Hostname = host; if (msg["lanIps"] is JsonArray lan) { var clean = new JsonArray(); foreach (var n in lan.Take(16)) { var s = n?.ToString() ?? ""; if (System.Net.IPAddress.TryParse(s, out _)) clean.Add(s); } a.LanIps = clean; } if (msg.ContainsKey("activeListen")) { if (msg["activeListen"] is JsonObject al && J.Long(al, "port") is long p && p > 0 && p < 65536) { var mode = J.Str(al, "mode") == "Ssh" ? "Ssh" : "Port"; a.ActiveListen = new JsonObject { ["mode"] = mode, ["port"] = p }; } else { a.ActiveListen = null; } } var av = Clip(J.Str(msg, "appVersion"), 50); if (av.Length > 0) a.AppVersion = av; } private Task SaveStateAsync(AgentConn a, JsonObject msg) => _hub.Db.UpdateDeviceStateAsync(a.DeviceId, a.Hostname, a.PublicIp, a.LanIps.ToJsonString(), a.ActiveListen?.ToJsonString(), a.AppVersion, Clip(J.Str(msg, "os"), 200)); private static string Clip(string s, int max) { s = (s ?? "").Trim(); return s.Length <= max ? s : s[..max]; } } public static class JsonObjectExtensions { public static JsonObject Also(this JsonObject o, Action a) { a(o); return o; } }