using System.Collections.Concurrent; using System.Net.WebSockets; using System.Text.Json.Nodes; namespace JJServiceManagerRelay; /// 접속 중인 대상 PC public sealed class AgentConn { public Guid DeviceId { get; init; } public string Name { get; set; } public string Hostname { get; set; } public WsJson Ws { get; init; } public string PublicIp { get; init; } public JsonArray LanIps { get; set; } = new(); public JsonObject ActiveListen { get; set; } public string AppVersion { get; set; } = ""; public DateTime ConnectedAt { get; } = DateTime.UtcNow; public CancellationTokenSource Cts { get; } = new(); public JsonObject PendingReply { get; set; } public bool NotifyDevicesChanged { get; set; } } /// 로그인한 관리 PC public sealed class ManagerConn { public Guid Id { get; } = Guid.NewGuid(); public UserRow User { get; set; } public byte[] TokenHash { get; set; } public WsJson Ws { get; init; } public string Ip { get; init; } public string Host { get; set; } = ""; public Dictionary Perms { get; set; } = new(); public DateTime LastExtend { get; set; } = DateTime.UtcNow; public CancellationTokenSource Cts { get; } = new(); public JsonObject PendingReply { get; set; } public Perm PermFor(Guid deviceId) => User.IsAdmin ? Perm.All : Perms.TryGetValue(deviceId, out var p) ? p : Perm.None; } /// 관리 PC ↔ 대상 PC 연결 하나 (중개 → 직접 또는 중계) public sealed class RelaySession { public Guid Id { get; } = Guid.NewGuid(); public Guid DeviceId { get; init; } public string DeviceName { get; init; } public long UserId { get; init; } public string Username { get; init; } public Guid ManagerConnId { get; init; } public string ManagerToken { get; } = Secrets.NewToken(); public string AgentToken { get; } = Secrets.NewToken(); public DateTime CreatedAt { get; } = DateTime.UtcNow; public readonly object Lock = new(); public WebSocket ManagerWs; public WebSocket AgentWs; public bool ManagerUsed, AgentUsed; public bool Paired; public bool Direct; // 관리 PC가 직접 연결에 성공했다고 알림 public bool Finished; public long BytesToDevice, BytesFromDevice; public readonly TaskCompletionSource Done = new(TaskCreationOptions.RunContinuationsAsynchronously); /// 직접 연결 성공 → 중계 통로에서 기다리던 쪽을 깨움 public readonly TaskCompletionSource Wake = new(TaskCreationOptions.RunContinuationsAsynchronously); } /// 서버 전체 상태 public sealed class Hub { public Db Db { get; } public ServerConfig Config { get; } public ServerLog Log { get; } public TicketSigner Signer { get; set; } public Throttle Throttle { get; } = new(); public DateTime StartedAt { get; } = DateTime.UtcNow; public string Version { get; } = typeof(Hub).Assembly.GetName().Version?.ToString(3) ?? "1.0.0"; public ConcurrentDictionary Agents { get; } = new(); public ConcurrentDictionary Managers { get; } = new(); public ConcurrentDictionary Sessions { get; } = new(); public Hub(Db db, ServerConfig config, ServerLog log) { Db = db; Config = config; Log = log; } public bool IsOnline(Guid deviceId) => Agents.ContainsKey(deviceId); // ───────────────────────── 알림 ───────────────────────── /// 기기 온라인/오프라인을 볼 수 있는 관리 PC에 알림 public async Task NotifyPresenceAsync(Guid deviceId, string name, string hostname, bool online) { var msg = new JsonObject { ["event"] = "presence", ["deviceId"] = deviceId.ToString(), ["name"] = name, ["hostname"] = hostname, ["online"] = online, ["at"] = Db.Iso(DateTime.UtcNow) }; foreach (var m in Managers.Values) { if (m.User.MustChangePassword) continue; if (!m.PermFor(deviceId).Any) continue; await m.Ws.TrySendAsync((JsonObject)msg.DeepClone()); } } /// 기기 목록·권한이 바뀜 → 관리 PC들이 목록을 다시 받게 public async Task NotifyDevicesChangedAsync() { foreach (var m in Managers.Values) { try { if (!m.User.IsAdmin) m.Perms = await Db.GetPermsForUserAsync(m.User.Id); } catch { } await m.Ws.TrySendAsync(new JsonObject { ["event"] = "devicesChanged" }); } } /// 사용자 계정이 바뀜 (사용 안 함·역할 변경) → 그 사용자의 연결을 다시 확인 public async Task RefreshUserAsync(long userId, string kickReason = null) { var user = await Db.GetUserAsync(userId); foreach (var m in Managers.Values.Where(x => x.User.Id == userId).ToList()) { if (user == null || !user.Enabled || kickReason != null) { await m.Ws.TrySendAsync(new JsonObject { ["event"] = "loggedOut", ["reason"] = kickReason ?? "계정을 사용할 수 없습니다." }); m.Cts.Cancel(); continue; } m.User = user; m.Perms = user.IsAdmin ? new() : await Db.GetPermsForUserAsync(user.Id); await m.Ws.TrySendAsync(new JsonObject { ["event"] = "devicesChanged" }); } } /// 기기를 사용 안 함/삭제 → 접속 중이면 끊음 public async Task KickAgentAsync(Guid deviceId, string reason) { if (Agents.TryGetValue(deviceId, out var a)) { await a.Ws.TrySendAsync(new JsonObject { ["event"] = "disabled", ["reason"] = reason }); a.Cts.Cancel(); } } // ───────────────────────── 연결 세션 정리 ───────────────────────── public async Task FinishSessionAsync(RelaySession s, string mode, string reason) { lock (s.Lock) { if (s.Finished) return; s.Finished = true; } Sessions.TryRemove(s.Id, out _); s.Done.TrySetResult(); s.Wake.TrySetResult(); await Db.ConnectionEndAsync(s.Id, mode, s.BytesToDevice, s.BytesFromDevice, reason); } /// 30초마다: 아무도 붙지 않은 세션 정리, 만료 토큰 정리 public async Task HousekeepingLoopAsync(CancellationToken ct) { var lastPurge = DateTime.MinValue; while (!ct.IsCancellationRequested) { try { await Task.Delay(TimeSpan.FromSeconds(10), ct); var now = DateTime.UtcNow; foreach (var s in Sessions.Values) { bool stale; lock (s.Lock) stale = !s.Paired && !s.Direct && now - s.CreatedAt > TimeSpan.FromSeconds(60); if (stale) await FinishSessionAsync(s, "failed", "시간 초과 (양쪽이 연결되지 않음)"); } if (now - lastPurge > TimeSpan.FromHours(1)) { lastPurge = now; await Db.PurgeExpiredSessionsAsync(); } } catch (OperationCanceledException) { break; } catch (Exception ex) { Log.Error("정리 작업 오류: " + ex.Message); } } } }