JJServiceManagerEnt/Core/Remote/Relay.cs

1038 lines
47 KiB
C#

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 줄 프로토콜 그대로)
// ═════════════════════════════════════════════════════════════════════
/// <summary>중계 서버 주소 처리</summary>
public static class RelayUrl
{
/// <summary>"https://jjsm.joynala.com" → "wss://jjsm.joynala.com" + path</summary>
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);
}
}
/// <summary>WebSocket 바이너리 프레임을 스트림처럼 (중계 통로 위에 TLS를 얹기 위해)</summary>
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<int> ReadAsync(Memory<byte> buffer, CancellationToken ct = default)
{
while (_rpos >= _rlen)
{
if (_eof) return 0;
WebSocketReceiveResult r;
try { r = await _ws.ReceiveAsync(new ArraySegment<byte>(_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<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct)
=> ReadAsync(buffer.AsMemory(offset, count), ct).AsTask();
public override async ValueTask WriteAsync(ReadOnlyMemory<byte> 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);
}
}
/// <summary>중계 서버의 권한 티켓 (서버 서명, 대상 PC가 확인)</summary>
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; }
/// <summary>서명·기기·만료 확인. 실패하면 null + 사유</summary>
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<long>() ?? 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<long>() ?? 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);
}
}
/// <summary>중계 서버와 JSON 요청/응답 + 알림 (WebSocket 텍스트 프레임)</summary>
public sealed class RelayChannel : IDisposable
{
private readonly ClientWebSocket _ws = new ClientWebSocket();
private readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1);
private readonly ConcurrentDictionary<long, TaskCompletionSource<JsonObject>> _pending = new ConcurrentDictionary<long, TaskCompletionSource<JsonObject>>();
private readonly CancellationTokenSource _cts = new CancellationTokenSource();
private long _nextId;
private int _closed;
/// <summary>서버가 먼저 보낸 알림 (백그라운드 스레드)</summary>
public event Action<JsonObject> EventReceived;
/// <summary>연결이 끊김 (한 번만)</summary>
public event Action<string> Closed;
public bool IsOpen => _ws.State == WebSocketState.Open && Volatile.Read(ref _closed) == 0;
public static async Task<RelayChannel> 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<byte>(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<long>() ?? 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 { }
}
/// <summary>요청 → 응답 객체 (ok=false면 RemoteException, code는 Data["code"])</summary>
public async Task<JsonObject> 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<JsonObject>(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<byte>(bytes), WebSocketMessageType.Text, true, t.Token).ConfigureAwait(false);
}
finally
{
_sendLock.Release();
}
}
/// <summary>응답을 기다리지 않음 (실패 무시)</summary>
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("연결을 닫았습니다.");
}
/// <summary>중계 통로 열기 (/relay/{sid}?side=…&amp;token=…) → 스트림</summary>
public static async Task<WebSocketStream> 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<string, LogLevel> _log;
private CancellationTokenSource _cts;
private RelayChannel _ch;
private string _url = "", _deviceId = "", _deviceKey = "";
private Func<JsonObject> _activeListen = () => null;
private readonly ConcurrentDictionary<string, CancellationTokenSource> _incoming = new ConcurrentDictionary<string, CancellationTokenSource>();
public bool IsOnline { get; private set; }
public string StatusText { get; private set; } = "";
public string DeviceName { get; private set; } = "";
/// <summary>상태가 바뀜 (백그라운드 스레드)</summary>
public event Action<RelayAgent> StateChanged;
/// <summary>서버에서 기기를 사용 안 함/삭제 (다시 등록 필요)</summary>
public event Action<string> Revoked;
/// <summary>서버가 알려준 이 기기 이름이 바뀜</summary>
public event Action<string> NameChanged;
public RelayAgent(RemoteServer server, Action<string, LogLevel> log)
{
_server = server;
_log = log;
_server.AuditRequested += OnAudit;
}
/// <summary>등록 코드로 이 PC를 등록 (설정 화면에서). 연결은 바로 닫음</summary>
public static async Task<RelayEnrollResult> 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;
}
/// <summary>접속 시작 (이미 같은 설정이면 그대로). activeListen: Active 수신 정보 {mode, port} 또는 null</summary>
public void Start(string url, string deviceId, string deviceKey, string serverPublicKey, Func<JsonObject> 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);
}
}
/// <summary>Active 수신 정보 등이 바뀌었을 때 서버에 알림</summary>
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<string>(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<long>() ?? 0);
break;
}
}
/// <summary>중계 통로를 열고 그 위에서 TLS 서버 → 기존 요청 처리 (티켓 필수)</summary>
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();
}
}
/// <summary>티켓으로 들어온 연결에서 실행한 작업 → 서버 감사 로그</summary>
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")
};
}
}
/// <summary>연결 요청 결과</summary>
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<string, LogLevel> _log;
private CancellationTokenSource _cts;
private RelayChannel _ch;
private string _url = "", _username = "";
private Func<string> _token = () => "", _password = () => "";
private Action<string> _saveToken = _ => { };
private readonly object _sync = new object();
private List<RelayDevice> _devices = new List<RelayDevice>();
private TaskCompletionSource<bool> _ready = new TaskCompletionSource<bool>(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<RelayDevice> Devices { get { lock (_sync) return _devices.ToList(); } }
/// <summary>로그인 상태가 바뀜 (백그라운드 스레드)</summary>
public event Action<RelayManager> StateChanged;
/// <summary>기기 목록을 새로 받음 (백그라운드 스레드)</summary>
public event Action<RelayManager> DevicesChanged;
/// <summary>기기 하나가 온라인/오프라인으로 바뀜 (기기 ID, 온라인)</summary>
public event Action<string, bool> Presence;
/// <summary>로그인이 필요함 (토큰 만료 + 저장된 비밀번호 없음 / 비밀번호 변경 필요)</summary>
public event Action<string> LoginRequired;
public RelayManager(Action<string, LogLevel> log) => _log = log;
/// <summary>설정 화면의 [로그인]: 아이디·비밀번호로 로그인해서 토큰을 받음 (연결은 바로 닫음)</summary>
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");
}
/// <summary>로그인 직후 비밀번호 변경 (토큰으로 다시 붙어서)</summary>
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);
}
/// <param name="token">저장된 로그인 토큰</param>
/// <param name="password">저장된 비밀번호 (없으면 빈 값 → 토큰 만료 시 LoginRequired)</param>
/// <param name="saveToken">새 토큰을 받으면 저장</param>
public void Start(string url, string username, Func<string> token, Func<string> password, Action<string> 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<RelayDevice>();
}
if (IsLoggedIn || StatusText != "")
{
IsLoggedIn = false;
IsAdmin = false;
StatusText = "";
StateChanged?.Invoke(this);
}
if (had) DevicesChanged?.Invoke(this);
_ready = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
}
/// <summary>로그아웃 (서버의 토큰도 지움)</summary>
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<string>(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<long>() ?? 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<bool>(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<JsonObject>().Select(RelayDevice.From).ToList() ?? new List<RelayDevice>();
lock (_sync) _devices = list;
DevicesChanged?.Invoke(this);
}
/// <summary>로그인된 연결로 요청 ([서버 관리] 포함). 응답 전체</summary>
public async Task<JsonObject> 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);
}
/// <summary>로그인될 때까지 기다림 (최대 timeout)</summary>
public async Task<bool> 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);
}
/// <summary>대상 PC 연결 요청 → 세션·티켓·직접 연결 후보</summary>
public async Task<RelayConnectInfo> 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<JsonObject>())
{
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")
};
}
/// <summary>연결 결과 알림 (direct / failed / ended)</summary>
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();
}
}