1035 lines
47 KiB
C#
1035 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=…&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;
|
|
}
|
|
}
|
|
|
|
/// <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();
|
|
}
|
|
}
|