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