198 lines
7.7 KiB
C#
198 lines
7.7 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Net.WebSockets;
|
|
using System.Text.Json.Nodes;
|
|
|
|
namespace JJServiceManagerRelay;
|
|
|
|
/// <summary>접속 중인 대상 PC</summary>
|
|
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; }
|
|
}
|
|
|
|
/// <summary>로그인한 관리 PC</summary>
|
|
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<Guid, Perm> 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;
|
|
}
|
|
|
|
/// <summary>관리 PC ↔ 대상 PC 연결 하나 (중개 → 직접 또는 중계)</summary>
|
|
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);
|
|
/// <summary>직접 연결 성공 → 중계 통로에서 기다리던 쪽을 깨움</summary>
|
|
public readonly TaskCompletionSource Wake = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
}
|
|
|
|
/// <summary>서버 전체 상태</summary>
|
|
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<Guid, AgentConn> Agents { get; } = new();
|
|
public ConcurrentDictionary<Guid, ManagerConn> Managers { get; } = new();
|
|
public ConcurrentDictionary<Guid, RelaySession> 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);
|
|
|
|
// ───────────────────────── 알림 ─────────────────────────
|
|
|
|
/// <summary>기기 온라인/오프라인을 볼 수 있는 관리 PC에 알림</summary>
|
|
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());
|
|
}
|
|
}
|
|
|
|
/// <summary>기기 목록·권한이 바뀜 → 관리 PC들이 목록을 다시 받게</summary>
|
|
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" });
|
|
}
|
|
}
|
|
|
|
/// <summary>사용자 계정이 바뀜 (사용 안 함·역할 변경) → 그 사용자의 연결을 다시 확인</summary>
|
|
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" });
|
|
}
|
|
}
|
|
|
|
/// <summary>기기를 사용 안 함/삭제 → 접속 중이면 끊음</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>30초마다: 아무도 붙지 않은 세션 정리, 만료 토큰 정리</summary>
|
|
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); }
|
|
}
|
|
}
|
|
}
|