using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.WebSockets; using System.Security.Cryptography; using System.Text; using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; namespace JJServiceManager.Remote { // ═════════════════════════════════════════════════════════════════════ // 중계 서버 (JJServiceManagerRelay) 연결 // - /ws/agent : 대상 PC (등록, 온라인 유지, 연결 요청 받기, 감사 로그 보고) // - /ws/manager : 관리 PC (로그인, 기기 목록, 연결 요청, [서버 관리]) // - /relay/{세션}: 중계 통로 (안에서는 기존 TLS + JSON 줄 프로토콜 그대로) // ═════════════════════════════════════════════════════════════════════ /// 중계 서버 주소 처리 public static class RelayUrl { /// "https://jjsm.joynala.com" → "wss://jjsm.joynala.com" + path public static Uri Make(string baseUrl, string pathAndQuery) { var b = (baseUrl ?? "").Trim().TrimEnd('/'); if (b.Length == 0) throw new RemoteException("중계 서버 주소가 비어 있습니다."); if (b.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) b = "wss://" + b.Substring(8); else if (b.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) b = "ws://" + b.Substring(7); else if (!b.StartsWith("wss://", StringComparison.OrdinalIgnoreCase) && !b.StartsWith("ws://", StringComparison.OrdinalIgnoreCase)) b = "wss://" + b; if (!Uri.TryCreate(b + pathAndQuery, UriKind.Absolute, out var u)) throw new RemoteException("중계 서버 주소가 올바르지 않습니다: " + baseUrl); return u; } public static string Friendly(Exception ex) { ex = ex is AggregateException ae ? ae.GetBaseException() : ex; if (ex is WebSocketException we) { var inner = we.InnerException?.Message; return "중계 서버에 연결할 수 없습니다." + (string.IsNullOrEmpty(inner) ? "" : " (" + inner + ")"); } if (ex is OperationCanceledException) return "중계 서버 응답이 없습니다."; return RemoteClient.Friendly(ex); } } /// WebSocket 바이너리 프레임을 스트림처럼 (중계 통로 위에 TLS를 얹기 위해) public sealed class WebSocketStream : Stream { private readonly WebSocket _ws; private readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1); private byte[] _rbuf = new byte[64 * 1024]; private int _rpos, _rlen; private bool _eof; public WebSocketStream(WebSocket ws) => _ws = ws; public override bool CanRead => true; public override bool CanWrite => true; public override bool CanSeek => false; public override long Length => throw new NotSupportedException(); public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } public override async ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) { while (_rpos >= _rlen) { if (_eof) return 0; WebSocketReceiveResult r; try { r = await _ws.ReceiveAsync(new ArraySegment(_rbuf), ct).ConfigureAwait(false); } catch (WebSocketException) { _eof = true; return 0; } if (r.MessageType == WebSocketMessageType.Close) { _eof = true; return 0; } _rpos = 0; _rlen = r.Count; } int n = Math.Min(buffer.Length, _rlen - _rpos); _rbuf.AsMemory(_rpos, n).CopyTo(buffer); _rpos += n; return n; } public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) => ReadAsync(buffer.AsMemory(offset, count), ct).AsTask(); public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken ct = default) { await _sendLock.WaitAsync(ct).ConfigureAwait(false); try { // 큰 데이터는 64KB씩 (중계 서버 버퍼 크기) int off = 0; while (off < buffer.Length) { int n = Math.Min(64 * 1024, buffer.Length - off); await _ws.SendAsync(buffer.Slice(off, n), WebSocketMessageType.Binary, true, ct).ConfigureAwait(false); off += n; } } finally { _sendLock.Release(); } } public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken ct) => WriteAsync(buffer.AsMemory(offset, count), ct).AsTask(); public override int Read(byte[] buffer, int offset, int count) => ReadAsync(buffer, offset, count, CancellationToken.None).GetAwaiter().GetResult(); public override void Write(byte[] buffer, int offset, int count) => WriteAsync(buffer, offset, count, CancellationToken.None).GetAwaiter().GetResult(); public override void Flush() { } public override Task FlushAsync(CancellationToken ct) => Task.CompletedTask; public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); public override void SetLength(long value) => throw new NotSupportedException(); protected override void Dispose(bool disposing) { if (disposing) { try { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); if (_ws.State == WebSocketState.Open) _ws.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "bye", cts.Token).Wait(2000); } catch { } try { _ws.Dispose(); } catch { } } base.Dispose(disposing); } } /// 중계 서버의 권한 티켓 (서버 서명, 대상 PC가 확인) public sealed class RelayTicket { public string SessionId { get; private set; } = ""; public string DeviceId { get; private set; } = ""; public long UserId { get; private set; } public string Username { get; private set; } = ""; public string Role { get; private set; } = ""; public bool View { get; private set; } public bool Control { get; private set; } public bool ScreenView { get; private set; } public bool ScreenControl { get; private set; } /// 서명·기기·만료 확인. 실패하면 null + 사유 public static RelayTicket Verify(string ticket, string serverPublicKeyB64, string myDeviceId, out string error) { error = null; try { if (string.IsNullOrEmpty(serverPublicKeyB64)) { error = "이 PC가 중계 서버에 등록되어 있지 않습니다."; return null; } var parts = (ticket ?? "").Split('.'); if (parts.Length != 2) { error = "권한 티켓 형식이 올바르지 않습니다."; return null; } var body = FromB64Url(parts[0]); var sig = FromB64Url(parts[1]); using (var k = ECDsa.Create()) { k.ImportSubjectPublicKeyInfo(Convert.FromBase64String(serverPublicKeyB64), out _); if (!k.VerifyData(body, sig, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation)) { error = "권한 티켓 서명이 맞지 않습니다."; return null; } } var o = JsonNode.Parse(body) as JsonObject; var t = new RelayTicket { SessionId = Json.Str(o, "sid"), DeviceId = Json.Str(o, "deviceId"), UserId = o?["userId"]?.GetValue() ?? 0, Username = Json.Str(o, "username"), Role = Json.Str(o, "role") }; var p = o?["perms"] as JsonObject; t.View = Json.Bool(p, "view"); t.Control = Json.Bool(p, "control"); t.ScreenView = Json.Bool(p, "screenView"); t.ScreenControl = Json.Bool(p, "screenControl"); long exp = o?["exp"]?.GetValue() ?? 0; if (!string.Equals(t.DeviceId, myDeviceId, StringComparison.OrdinalIgnoreCase)) { error = "다른 기기용 권한 티켓입니다."; return null; } // 시계 차이 1분까지 허용 if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() > exp + 60) { error = "권한 티켓이 만료되었습니다. 다시 연결하세요."; return null; } return t; } catch (Exception ex) { error = "권한 티켓을 확인할 수 없습니다: " + ex.Message; return null; } } private static byte[] FromB64Url(string s) { s = s.Replace('-', '+').Replace('_', '/'); switch (s.Length % 4) { case 2: s += "=="; break; case 3: s += "="; break; } return Convert.FromBase64String(s); } } /// 중계 서버와 JSON 요청/응답 + 알림 (WebSocket 텍스트 프레임) public sealed class RelayChannel : IDisposable { private readonly ClientWebSocket _ws = new ClientWebSocket(); private readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1); private readonly ConcurrentDictionary> _pending = new ConcurrentDictionary>(); private readonly CancellationTokenSource _cts = new CancellationTokenSource(); private long _nextId; private int _closed; /// 서버가 먼저 보낸 알림 (백그라운드 스레드) public event Action EventReceived; /// 연결이 끊김 (한 번만) public event Action Closed; public bool IsOpen => _ws.State == WebSocketState.Open && Volatile.Read(ref _closed) == 0; public static async Task ConnectAsync(string baseUrl, string path, CancellationToken ct) { var c = new RelayChannel(); c._ws.Options.KeepAliveInterval = TimeSpan.FromSeconds(30); using (var t = CancellationTokenSource.CreateLinkedTokenSource(ct)) { t.CancelAfter(TimeSpan.FromSeconds(15)); try { await c._ws.ConnectAsync(RelayUrl.Make(baseUrl, path), t.Token).ConfigureAwait(false); } catch { c.Dispose(); throw; } } _ = Task.Run(c.ReadLoopAsync); return c; } private async Task ReadLoopAsync() { var buf = new byte[64 * 1024]; string reason = "중계 서버 연결이 끊겼습니다."; try { while (!_cts.IsCancellationRequested) { using var ms = new MemoryStream(); WebSocketReceiveResult r; do { r = await _ws.ReceiveAsync(new ArraySegment(buf), _cts.Token).ConfigureAwait(false); if (r.MessageType == WebSocketMessageType.Close) { if (!string.IsNullOrEmpty(_ws.CloseStatusDescription) && _ws.CloseStatusDescription != "bye") reason = "중계 서버가 연결을 닫았습니다: " + _ws.CloseStatusDescription; return; } ms.Write(buf, 0, r.Count); if (ms.Length > 8 * 1024 * 1024) throw new IOException("메시지가 너무 큽니다."); } while (!r.EndOfMessage); JsonObject msg; try { msg = JsonNode.Parse(ms.ToArray()) as JsonObject; } catch { continue; } if (msg == null) continue; if (msg.ContainsKey("event")) { try { EventReceived?.Invoke(msg); } catch { } continue; } long id = 0; try { id = msg["id"]?.GetValue() ?? 0; } catch { } if (_pending.TryRemove(id, out var tcs)) tcs.TrySetResult(msg); } } catch (OperationCanceledException) { } catch (Exception ex) { reason = RelayUrl.Friendly(ex); } finally { Close(reason); } } private void Close(string reason) { if (Interlocked.Exchange(ref _closed, 1) == 1) return; foreach (var kv in _pending.ToArray()) if (_pending.TryRemove(kv.Key, out var t)) t.TrySetException(new RemoteException(reason)); try { Closed?.Invoke(reason); } catch { } } /// 요청 → 응답 객체 (ok=false면 RemoteException, code는 Data["code"]) public async Task RequestAsync(string op, JsonObject args = null, TimeSpan? timeout = null) { if (!IsOpen) throw new RemoteException("중계 서버에 연결되어 있지 않습니다."); long id = Interlocked.Increment(ref _nextId); var msg = args != null ? (JsonObject)args.DeepClone() : new JsonObject(); msg["id"] = id; msg["op"] = op; var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _pending[id] = tcs; try { await SendAsync(msg).ConfigureAwait(false); } catch (Exception ex) { _pending.TryRemove(id, out _); throw new RemoteException("중계 서버로 보내기 실패: " + RelayUrl.Friendly(ex)); } var done = await Task.WhenAny(tcs.Task, Task.Delay(timeout ?? TimeSpan.FromSeconds(20))).ConfigureAwait(false); if (done != tcs.Task) { _pending.TryRemove(id, out _); throw new RemoteException("중계 서버 응답이 없습니다."); } var reply = await tcs.Task.ConfigureAwait(false); if (!Json.Bool(reply, "ok")) { var ex = new RemoteException(Json.Str(reply, "error")); ex.Data["code"] = Json.Str(reply, "code"); throw ex; } return reply; } private async Task SendAsync(JsonObject msg) { var bytes = Encoding.UTF8.GetBytes(msg.ToJsonString()); await _sendLock.WaitAsync().ConfigureAwait(false); try { using var t = new CancellationTokenSource(TimeSpan.FromSeconds(15)); await _ws.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, t.Token).ConfigureAwait(false); } finally { _sendLock.Release(); } } /// 응답을 기다리지 않음 (실패 무시) public void Post(string op, JsonObject args) { var msg = args != null ? (JsonObject)args.DeepClone() : new JsonObject(); msg["op"] = op; _ = SendAsync(msg).ContinueWith(t => { _ = t.Exception; }, TaskScheduler.Default); } public void Dispose() { try { _cts.Cancel(); } catch { } try { if (_ws.State == WebSocketState.Open) _ws.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None).Wait(1500); } catch { } try { _ws.Dispose(); } catch { } Close("연결을 닫았습니다."); } /// 중계 통로 열기 (/relay/{sid}?side=…&token=…) → 스트림 public static async Task OpenRelayAsync(string baseUrl, string sessionId, string side, string token, CancellationToken ct) { var ws = new ClientWebSocket(); ws.Options.KeepAliveInterval = TimeSpan.FromSeconds(30); using var t = CancellationTokenSource.CreateLinkedTokenSource(ct); t.CancelAfter(TimeSpan.FromSeconds(15)); try { await ws.ConnectAsync(RelayUrl.Make(baseUrl, $"/relay/{Uri.EscapeDataString(sessionId)}?side={side}&token={Uri.EscapeDataString(token)}"), t.Token).ConfigureAwait(false); return new WebSocketStream(ws); } catch { ws.Dispose(); throw; } } } // ═════════════════════════════════════════════════════════════════════ // 대상 PC: 중계 서버에 등록 + 온라인 유지 + 연결 요청 처리 // ═════════════════════════════════════════════════════════════════════ public sealed class RelayEnrollResult { public string DeviceId { get; init; } public string DeviceKey { get; init; } public string ServerPublicKey { get; init; } public string Name { get; init; } } public sealed class RelayAgent : IDisposable { private readonly RemoteServer _server; private readonly Action _log; private CancellationTokenSource _cts; private RelayChannel _ch; private string _url = "", _deviceId = "", _deviceKey = ""; private Func _activeListen = () => null; private readonly ConcurrentDictionary _incoming = new ConcurrentDictionary(); public bool IsOnline { get; private set; } public string StatusText { get; private set; } = ""; public string DeviceName { get; private set; } = ""; /// 상태가 바뀜 (백그라운드 스레드) public event Action StateChanged; /// 서버에서 기기를 사용 안 함/삭제 (다시 등록 필요) public event Action Revoked; /// 서버가 알려준 이 기기 이름이 바뀜 public event Action NameChanged; public RelayAgent(RemoteServer server, Action log) { _server = server; _log = log; _server.AuditRequested += OnAudit; } /// 등록 코드로 이 PC를 등록 (설정 화면에서). 연결은 바로 닫음 public static async Task EnrollAsync(string url, string code, JsonObject activeListen, CancellationToken ct) { using var ch = await RelayChannel.ConnectAsync(url, "/ws/agent", ct).ConfigureAwait(false); var r = await ch.RequestAsync("enroll", new JsonObject { ["code"] = code, ["hostname"] = Environment.MachineName, ["os"] = Environment.OSVersion.VersionString, ["appVersion"] = AppVersion, ["lanIps"] = LanIps(), ["activeListen"] = activeListen }).ConfigureAwait(false); return new RelayEnrollResult { DeviceId = Json.Str(r, "deviceId"), DeviceKey = Json.Str(r, "deviceKey"), ServerPublicKey = Json.Str(r, "serverPublicKey"), Name = Json.Str(r, "name") }; } public static string AppVersion => typeof(RelayAgent).Assembly.GetName().Version?.ToString(3) ?? ""; private static JsonArray LanIps() { var arr = new JsonArray(); foreach (var ip in NetInfo.LocalIPv4()) arr.Add(ip); return arr; } /// 접속 시작 (이미 같은 설정이면 그대로). activeListen: Active 수신 정보 {mode, port} 또는 null public void Start(string url, string deviceId, string deviceKey, string serverPublicKey, Func activeListen) { Stop(); _url = url; _deviceId = deviceId; _deviceKey = deviceKey; _activeListen = activeListen ?? (() => null); _server.SetRelayTrust(serverPublicKey, deviceId); _cts = new CancellationTokenSource(); var ct = _cts.Token; _ = Task.Run(() => LoopAsync(ct)); } public void Stop() { try { _cts?.Cancel(); } catch { } _cts = null; _ch?.Dispose(); _ch = null; foreach (var c in _incoming.Values) { try { c.Cancel(); } catch { } } _incoming.Clear(); if (IsOnline || StatusText != "") { IsOnline = false; StatusText = ""; StateChanged?.Invoke(this); } } /// Active 수신 정보 등이 바뀌었을 때 서버에 알림 public void SendUpdate() { var ch = _ch; if (ch == null || !ch.IsOpen) return; ch.Post("update", new JsonObject { ["hostname"] = Environment.MachineName, ["lanIps"] = LanIps(), ["activeListen"] = _activeListen(), ["appVersion"] = AppVersion }); } private void SetState(bool online, string text) { bool changed = IsOnline != online || StatusText != text; IsOnline = online; StatusText = text; if (changed) StateChanged?.Invoke(this); } private async Task LoopAsync(CancellationToken ct) { int failures = 0; string lastProblem = null; while (!ct.IsCancellationRequested) { RelayChannel ch = null; try { SetState(false, "중계 서버 연결 중…"); ch = await RelayChannel.ConnectAsync(_url, "/ws/agent", ct).ConfigureAwait(false); var closed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); ch.Closed += r => closed.TrySetResult(r); ch.EventReceived += OnEvent; var r = await ch.RequestAsync("auth", new JsonObject { ["deviceId"] = _deviceId, ["deviceKey"] = _deviceKey, ["hostname"] = Environment.MachineName, ["os"] = Environment.OSVersion.VersionString, ["appVersion"] = AppVersion, ["lanIps"] = LanIps(), ["activeListen"] = _activeListen() }).ConfigureAwait(false); _ch = ch; failures = 0; lastProblem = null; var name = Json.Str(r, "name"); if (!string.IsNullOrEmpty(name) && name != DeviceName) { DeviceName = name; NameChanged?.Invoke(name); } var pub = Json.Str(r, "serverPublicKey"); if (!string.IsNullOrEmpty(pub)) _server.SetRelayTrust(pub, _deviceId); SetState(true, "중계 서버 온라인"); _log($"중계 서버 접속: {DeviceName}", LogLevel.Success); // 30초마다 ping (서버는 2분 동안 아무것도 없으면 끊음) while (!ct.IsCancellationRequested && ch.IsOpen) { var wait = await Task.WhenAny(closed.Task, Task.Delay(TimeSpan.FromSeconds(30), ct)).ConfigureAwait(false); if (wait == closed.Task) break; try { await ch.RequestAsync("ping", null, TimeSpan.FromSeconds(20)).ConfigureAwait(false); } catch { break; } } if (!ct.IsCancellationRequested) { var why = closed.Task.IsCompleted ? closed.Task.Result : "중계 서버 응답이 없습니다."; _log("중계 서버 연결 끊김: " + why, LogLevel.Warning); } } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } catch (RemoteException ex) when (ex.Data["code"] as string is "notEnrolled" or "disabled") { var code = ex.Data["code"] as string; SetState(false, code == "disabled" ? "서버에서 사용 안 함" : "등록 정보가 없음 (다시 등록 필요)"); _log("중계 서버: " + ex.Message, LogLevel.Error); Revoked?.Invoke(ex.Message); ch?.Dispose(); return; } catch (Exception ex) { var msg = RelayUrl.Friendly(ex); if (msg != lastProblem) _log("중계 서버 접속 실패: " + msg, LogLevel.Warning); lastProblem = msg; } finally { if (ReferenceEquals(_ch, ch)) _ch = null; ch?.Dispose(); } if (ct.IsCancellationRequested) break; failures++; var delay = failures <= 1 ? 5 : failures == 2 ? 10 : 30; SetState(false, $"중계 서버 연결 안 됨 ({delay}초 뒤 다시 시도)"); try { await Task.Delay(TimeSpan.FromSeconds(delay), ct).ConfigureAwait(false); } catch { break; } } } private void OnEvent(JsonObject e) { switch (Json.Str(e, "event")) { case "incoming": { var sid = Json.Str(e, "sessionId"); var token = Json.Str(e, "relayToken"); var user = Json.Str(e, "username"); var cts = new CancellationTokenSource(); _incoming[sid] = cts; _ = Task.Run(() => ServeRelayAsync(sid, token, user, cts)); break; } case "cancel": { // 직접 연결 성공 → 미리 열어 둔 중계 통로는 필요 없음 if (_incoming.TryRemove(Json.Str(e, "sessionId"), out var c)) { try { c.Cancel(); } catch { } } break; } case "disabled": _log("중계 서버: " + Json.Str(e, "reason"), LogLevel.Error); SetState(false, "서버에서 사용 안 함"); Revoked?.Invoke(Json.Str(e, "reason")); try { _cts?.Cancel(); } catch { } break; case "replaced": _log("중계 서버: " + Json.Str(e, "reason"), LogLevel.Warning); break; case "revokeUser": _server.DropTicketUser(e["userId"]?.GetValue() ?? 0); break; } } /// 중계 통로를 열고 그 위에서 TLS 서버 → 기존 요청 처리 (티켓 필수) private async Task ServeRelayAsync(string sid, string token, string user, CancellationTokenSource cts) { var ct = cts.Token; WebSocketStream stream = null; try { stream = await RelayChannel.OpenRelayAsync(_url, sid, "agent", token, ct).ConfigureAwait(false); // 상대(관리 PC)가 붙거나 직접 연결로 취소될 때까지 TLS 시작을 기다림 var ssl = await RemoteListener.TlsServerAsync(stream, ct).ConfigureAwait(false); _incoming.TryRemove(sid, out _); await _server.ServeRelayAsync(new JsonLineConnection(ssl), $"{user}(중계)", sid, CancellationToken.None).ConfigureAwait(false); } catch { // 취소(직접 연결됨) 또는 상대가 붙지 않음 } finally { _incoming.TryRemove(sid, out _); try { stream?.Dispose(); } catch { } cts.Dispose(); } } /// 티켓으로 들어온 연결에서 실행한 작업 → 서버 감사 로그 private void OnAudit(RelayTicket t, string action, string target, string result, string detail) { var ch = _ch; if (ch == null || !ch.IsOpen || t == null) return; ch.Post("audit", new JsonObject { ["userId"] = t.UserId, ["username"] = t.Username, ["action"] = action, ["target"] = target ?? "", ["result"] = result ?? "ok", ["detail"] = detail ?? "" }); } public void Dispose() { _server.AuditRequested -= OnAudit; Stop(); } } // ═════════════════════════════════════════════════════════════════════ // 관리 PC: 중계 서버 로그인 + 기기 목록 + 연결 요청 + [서버 관리] // ═════════════════════════════════════════════════════════════════════ public sealed class RelayDevice { public string Id { get; init; } = ""; public string Name { get; init; } = ""; public string Hostname { get; init; } = ""; public bool Online { get; set; } public bool Enabled { get; init; } = true; public bool View { get; init; } public bool Control { get; init; } public bool ScreenView { get; init; } public bool ScreenControl { get; init; } public static RelayDevice From(JsonObject o) { var p = o?["perms"] as JsonObject; return new RelayDevice { Id = Json.Str(o, "id"), Name = Json.Str(o, "name"), Hostname = Json.Str(o, "hostname"), Online = Json.Bool(o, "online"), Enabled = o?["enabled"] == null || Json.Bool(o, "enabled"), View = Json.Bool(p, "view"), Control = Json.Bool(p, "control"), ScreenView = Json.Bool(p, "screenView"), ScreenControl = Json.Bool(p, "screenControl") }; } } /// 연결 요청 결과 public sealed class RelayConnectInfo { public string SessionId { get; init; } = ""; public string RelayToken { get; init; } = ""; public string Ticket { get; init; } = ""; public List<(string host, int port, RemoteMode mode)> Candidates { get; init; } = new List<(string, int, RemoteMode)>(); public string Hostname { get; init; } = ""; } public sealed class RelayManager : IDisposable { private readonly Action _log; private CancellationTokenSource _cts; private RelayChannel _ch; private string _url = "", _username = ""; private Func _token = () => "", _password = () => ""; private Action _saveToken = _ => { }; private readonly object _sync = new object(); private List _devices = new List(); private TaskCompletionSource _ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); public string Url => _url; public bool IsLoggedIn { get; private set; } public bool IsAdmin { get; private set; } public string Username { get; private set; } = ""; public long UserId { get; private set; } public bool MustChangePassword { get; private set; } public string StatusText { get; private set; } = ""; public IReadOnlyList Devices { get { lock (_sync) return _devices.ToList(); } } /// 로그인 상태가 바뀜 (백그라운드 스레드) public event Action StateChanged; /// 기기 목록을 새로 받음 (백그라운드 스레드) public event Action DevicesChanged; /// 기기 하나가 온라인/오프라인으로 바뀜 (기기 ID, 온라인) public event Action Presence; /// 로그인이 필요함 (토큰 만료 + 저장된 비밀번호 없음 / 비밀번호 변경 필요) public event Action LoginRequired; public RelayManager(Action log) => _log = log; /// 설정 화면의 [로그인]: 아이디·비밀번호로 로그인해서 토큰을 받음 (연결은 바로 닫음) public static async Task<(string token, bool mustChange, bool isAdmin)> LoginAsync(string url, string username, string password, CancellationToken ct) { using var ch = await RelayChannel.ConnectAsync(url, "/ws/manager", ct).ConfigureAwait(false); var r = await ch.RequestAsync("login", new JsonObject { ["username"] = username, ["password"] = password, ["host"] = Environment.MachineName }).ConfigureAwait(false); var u = r["user"] as JsonObject; return (Json.Str(r, "token"), Json.Bool(u, "mustChangePassword"), Json.Str(u, "role") == "admin"); } /// 로그인 직후 비밀번호 변경 (토큰으로 다시 붙어서) public static async Task ChangePasswordAsync(string url, string token, string oldPw, string newPw, CancellationToken ct) { using var ch = await RelayChannel.ConnectAsync(url, "/ws/manager", ct).ConfigureAwait(false); await ch.RequestAsync("resume", new JsonObject { ["token"] = token, ["host"] = Environment.MachineName }).ConfigureAwait(false); await ch.RequestAsync("changePassword", new JsonObject { ["old"] = oldPw, ["new"] = newPw }).ConfigureAwait(false); } /// 저장된 로그인 토큰 /// 저장된 비밀번호 (없으면 빈 값 → 토큰 만료 시 LoginRequired) /// 새 토큰을 받으면 저장 public void Start(string url, string username, Func token, Func password, Action saveToken) { Stop(); _url = url; _username = username; _token = token; _password = password; _saveToken = saveToken ?? (_ => { }); _cts = new CancellationTokenSource(); var ct = _cts.Token; _ = Task.Run(() => LoopAsync(ct)); } public void Stop() { try { _cts?.Cancel(); } catch { } _cts = null; _ch?.Dispose(); _ch = null; bool had; lock (_sync) { had = _devices.Count > 0; _devices = new List(); } if (IsLoggedIn || StatusText != "") { IsLoggedIn = false; IsAdmin = false; StatusText = ""; StateChanged?.Invoke(this); } if (had) DevicesChanged?.Invoke(this); _ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); } /// 로그아웃 (서버의 토큰도 지움) public async Task LogoutAsync() { var ch = _ch; if (ch != null && ch.IsOpen) { try { await ch.RequestAsync("logout", null, TimeSpan.FromSeconds(5)).ConfigureAwait(false); } catch { } } Stop(); } private void SetState(bool loggedIn, string text) { bool changed = IsLoggedIn != loggedIn || StatusText != text; IsLoggedIn = loggedIn; StatusText = text; if (changed) StateChanged?.Invoke(this); } private async Task LoopAsync(CancellationToken ct) { int failures = 0; string lastProblem = null; while (!ct.IsCancellationRequested) { RelayChannel ch = null; try { SetState(false, "중계 서버 연결 중…"); ch = await RelayChannel.ConnectAsync(_url, "/ws/manager", ct).ConfigureAwait(false); var closed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); ch.Closed += r => closed.TrySetResult(r); ch.EventReceived += e => OnEvent(ch, e); // 저장된 토큰 → 안 되면 저장된 비밀번호로 로그인 JsonObject r = null; var token = _token(); if (!string.IsNullOrEmpty(token)) { try { r = await ch.RequestAsync("resume", new JsonObject { ["token"] = token, ["host"] = Environment.MachineName }).ConfigureAwait(false); } catch (RemoteException ex) when (ex.Data["code"] as string == "loginRequired") { r = null; } } if (r == null) { var pw = _password(); if (string.IsNullOrEmpty(pw)) { SetState(false, "로그인 필요"); LoginRequired?.Invoke("중계 서버 로그인이 만료되었습니다. 설정 → 관리 대상 PC → 중계 서버 로그인에서 다시 로그인하세요."); return; } r = await ch.RequestAsync("login", new JsonObject { ["username"] = _username, ["password"] = pw, ["host"] = Environment.MachineName }).ConfigureAwait(false); _saveToken(Json.Str(r, "token")); } var u = r["user"] as JsonObject; Username = Json.Str(u, "username"); UserId = u?["id"]?.GetValue() ?? 0; IsAdmin = Json.Str(u, "role") == "admin"; MustChangePassword = Json.Bool(u, "mustChangePassword"); if (MustChangePassword) { SetState(false, "비밀번호 변경 필요"); LoginRequired?.Invoke("중계 서버 비밀번호를 바꿔야 합니다. 설정 → 관리 대상 PC → 중계 서버 로그인에서 다시 로그인하세요."); return; } _ch = ch; failures = 0; lastProblem = null; await RefreshDevicesAsync().ConfigureAwait(false); SetState(true, $"로그인됨: {Username}{(IsAdmin ? " (관리자)" : "")}"); _log($"중계 서버 로그인: {Username}{(IsAdmin ? " (관리자)" : "")}", LogLevel.Success); _ready.TrySetResult(true); while (!ct.IsCancellationRequested && ch.IsOpen) { var wait = await Task.WhenAny(closed.Task, Task.Delay(TimeSpan.FromSeconds(30), ct)).ConfigureAwait(false); if (wait == closed.Task) break; try { await ch.RequestAsync("ping", null, TimeSpan.FromSeconds(20)).ConfigureAwait(false); } catch { break; } } if (!ct.IsCancellationRequested) _log("중계 서버 연결 끊김: " + (closed.Task.IsCompleted ? closed.Task.Result : "응답 없음"), LogLevel.Warning); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } catch (RemoteException ex) when (ex.Message.Contains("아이디 또는 비밀번호") || ex.Message.Contains("사용 안 함")) { SetState(false, "로그인 실패"); LoginRequired?.Invoke("중계 서버 로그인 실패: " + ex.Message); return; } catch (Exception ex) { var msg = RelayUrl.Friendly(ex); if (msg != lastProblem) _log("중계 서버 연결 실패: " + msg, LogLevel.Warning); lastProblem = msg; } finally { if (ReferenceEquals(_ch, ch)) { _ch = null; _ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); // 연결이 끊기면 기기는 모두 "모름" → 오프라인 처리 lock (_sync) foreach (var d in _devices) d.Online = false; DevicesChanged?.Invoke(this); } ch?.Dispose(); } if (ct.IsCancellationRequested) break; failures++; var delay = failures <= 1 ? 5 : failures == 2 ? 10 : 30; SetState(false, $"중계 서버 연결 안 됨 ({delay}초 뒤 다시 시도)"); try { await Task.Delay(TimeSpan.FromSeconds(delay), ct).ConfigureAwait(false); } catch { break; } } } private void OnEvent(RelayChannel ch, JsonObject e) { switch (Json.Str(e, "event")) { case "presence": { var id = Json.Str(e, "deviceId"); bool online = Json.Bool(e, "online"); lock (_sync) { var d = _devices.FirstOrDefault(x => x.Id == id); if (d != null) d.Online = online; } Presence?.Invoke(id, online); break; } case "devicesChanged": _ = Task.Run(async () => { try { await RefreshDevicesAsync().ConfigureAwait(false); } catch { } }); break; case "loggedOut": _log("중계 서버: " + Json.Str(e, "reason"), LogLevel.Warning); _saveToken(""); LoginRequired?.Invoke("중계 서버: " + Json.Str(e, "reason")); _ = Task.Run(Stop); break; } } public async Task RefreshDevicesAsync() { var r = await RequestAsync("devices").ConfigureAwait(false); var list = (r["devices"] as JsonArray)?.OfType().Select(RelayDevice.From).ToList() ?? new List(); lock (_sync) _devices = list; DevicesChanged?.Invoke(this); } /// 로그인된 연결로 요청 ([서버 관리] 포함). 응답 전체 public async Task RequestAsync(string op, JsonObject args = null, TimeSpan? timeout = null) { var ch = _ch; if (ch == null || !ch.IsOpen) throw new RemoteException("중계 서버에 로그인되어 있지 않습니다."); return await ch.RequestAsync(op, args, timeout).ConfigureAwait(false); } /// 로그인될 때까지 기다림 (최대 timeout) public async Task WaitReadyAsync(TimeSpan timeout, CancellationToken ct) { var t = _ready.Task; if (t.IsCompleted) return true; var done = await Task.WhenAny(t, Task.Delay(timeout, ct)).ConfigureAwait(false); return done == t; } public bool IsDeviceOnline(string deviceId) { lock (_sync) return _devices.Any(d => d.Id == deviceId && d.Online); } /// 대상 PC 연결 요청 → 세션·티켓·직접 연결 후보 public async Task ConnectAsync(string deviceId) { var r = await RequestAsync("connect", new JsonObject { ["deviceId"] = deviceId }).ConfigureAwait(false); var cands = new List<(string, int, RemoteMode)>(); if (r["candidates"] is JsonArray arr) { foreach (var c in arr.OfType()) { int port = Json.Int(c, "port"); var host = Json.Str(c, "host"); if (port > 0 && host.Length > 0) cands.Add((host, port, Json.Str(c, "mode") == "Ssh" ? RemoteMode.Ssh : RemoteMode.Port)); } } return new RelayConnectInfo { SessionId = Json.Str(r, "sessionId"), RelayToken = Json.Str(r, "relayToken"), Ticket = Json.Str(r, "ticket"), Candidates = cands, Hostname = Json.Str(r, "hostname") }; } /// 연결 결과 알림 (direct / failed / ended) public void ReportStatus(string sessionId, string status, long bytesToDevice = 0, long bytesFromDevice = 0, string reason = "") { var ch = _ch; if (ch == null || !ch.IsOpen) return; ch.Post("connectStatus", new JsonObject { ["sessionId"] = sessionId, ["status"] = status, ["bytesToDevice"] = bytesToDevice, ["bytesFromDevice"] = bytesFromDevice, ["reason"] = reason ?? "" }); } public void Dispose() => Stop(); } }