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;
}
}