From d2cd87526f34be0e3adc2ce012c42845263f806d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 07:07:18 +0000 Subject: [PATCH] =?UTF-8?q?=EC=A4=91=EA=B3=84=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0:=20=EB=8C=80=EC=83=81=20PC=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=C2=B7=EC=98=A8=EB=9D=BC=EC=9D=B8=20=EC=9C=A0=EC=A7=80?= =?UTF-8?q?,=20=EA=B4=80=EB=A6=AC=20PC=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=C2=B7?= =?UTF-8?q?=EA=B8=B0=EA=B8=B0=20=EC=9E=90=EB=8F=99=20=ED=91=9C=EC=8B=9C,?= =?UTF-8?q?=20=EC=A7=81=EC=A0=91=E2=86=92=EC=A4=91=EA=B3=84,=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=ED=8B=B0=EC=BC=93,=20[=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC]=20=ED=83=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Core/Remote/Relay.cs: WebSocket 스트림, 중계 서버 채널, 권한 티켓 확인, RelayAgent(대상 PC), RelayManager(관리 PC) - RemoteServer: 티켓 서명·기기·만료 확인, 티켓 권한 밖 요청 거부, 실행한 작업을 서버 감사 로그로 보고 - RemoteClient: 중계 기기 연결 (직접 연결 3초 시도 → 중계 통로 위 TLS), 직접 연결 트래픽 보고 - 초기화면: 권한 있는 중계 기기를 자동으로 묶음 표시, 기기별 [비밀번호] - 설정: 원격 접속 허용 → 중계 서버 접속(등록 코드), 관리 대상 PC → 중계 서버 로그인(첫 로그인 비밀번호 변경), [서버 관리] 탭 - tests/RelayIntegration: 통합 점검 32개 항목 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013U497kUNL8ArhV7jkzCpKa --- Core/Models.cs | 48 +- Core/Remote/Relay.cs | 1034 +++++++++++++++++ Core/Remote/RemoteClient.cs | 127 +- Core/Remote/RemoteCommon.cs | 8 + Core/Remote/RemoteServer.cs | 155 ++- Core/Remote/RemoteTransport.cs | 30 +- JJServiceManager.csproj | 4 + README.md | 35 + Views/MainWindow.xaml | 3 + Views/Settings.xaml | 43 + Windows/MainWindow.cs | 314 ++++- Windows/RelayDialogs.cs | 145 +++ Windows/ServerAdminPanel.cs | 890 ++++++++++++++ Windows/ServiceRow.cs | 6 +- Windows/SettingsWindow.cs | 233 +++- tests/RelayIntegration/Main.cs | 160 +++ tests/RelayIntegration/README.md | 7 + .../RelayIntegration/RelayIntegration.csproj | 19 + tests/RelayIntegration/Stubs.cs | 14 + 19 files changed, 3217 insertions(+), 58 deletions(-) create mode 100644 Core/Remote/Relay.cs create mode 100644 Windows/RelayDialogs.cs create mode 100644 Windows/ServerAdminPanel.cs create mode 100644 tests/RelayIntegration/Main.cs create mode 100644 tests/RelayIntegration/README.md create mode 100644 tests/RelayIntegration/RelayIntegration.csproj create mode 100644 tests/RelayIntegration/Stubs.cs diff --git a/Core/Models.cs b/Core/Models.cs index f168ebd..9af1a5e 100644 --- a/Core/Models.cs +++ b/Core/Models.cs @@ -65,7 +65,8 @@ namespace JJServiceManager public enum ConnectDirection { Active = 0, // 관리 PC → 대상 PC 로 접속 (대상 PC가 포트를 엶) - Passive = 1 // 대상 PC → 관리 PC 로 접속 (관리 PC가 포트를 엶) + Passive = 1, // 대상 PC → 관리 PC 로 접속 (관리 PC가 포트를 엶) + Relay = 2 // 중계 서버를 거쳐 연결 (먼저 직접, 안 되면 중계). 설정 목록에는 저장하지 않음 } /// 관리 대상 PC @@ -90,11 +91,28 @@ namespace JJServiceManager /// Passive로 접속해 온 대상 PC의 IP (표시용, 자동) public string LastAddress { get; set; } = ""; + /// 중계 서버의 기기 ID (Relay일 때) + public string RelayDeviceId { get; set; } = ""; + + /// 중계 서버에서 정한 기기 이름 (Relay일 때 표시용) + public string RelayName { get; set; } = ""; + + /// Relay: 실제로 연결된 방식 ("direct" / "relay", 표시용) + [JsonIgnore] + public string RelayMode { get; set; } = ""; + [JsonIgnore] public string Label { get { + if (Direction == ConnectDirection.Relay) + { + var name = string.IsNullOrWhiteSpace(RelayName) ? Hostname : RelayName; + if (!string.IsNullOrWhiteSpace(Hostname) && !string.Equals(name, Hostname, StringComparison.OrdinalIgnoreCase)) + name = $"{name} / {Hostname}"; + return $"{name} ({(RelayMode == "direct" ? "직접" : "중계")})"; + } if (Direction == ConnectDirection.Passive) { // 아직 한 번도 접속하지 않았으면 코드로 표시 @@ -248,6 +266,31 @@ namespace JJServiceManager [JsonIgnore] public int PassiveListenPort => PassiveListenMode == RemoteMode.Ssh ? PassiveListenSshPort : PassiveListenTcpPort; + // ───────── 중계 서버 (대상 역할: 이 PC를 서버에 등록) ───────── + + /// 중계 서버 접속 사용 (원격 접속 허용이 켜져 있어야 동작) + public bool RelayAgentEnabled { get; set; } + public string RelayAgentUrl { get; set; } = "https://jjsm.joynala.com"; + /// 등록할 때 서버가 준 기기 ID + public string RelayDeviceId { get; set; } = ""; + /// 기기 키 (Windows 계정으로 암호화) + public string RelayDeviceKeyProtected { get; set; } = ""; + /// 권한 티켓 확인용 서버 공개키 (등록할 때 받음) + public string RelayServerPublicKey { get; set; } = ""; + public string RelayDeviceName { get; set; } = ""; + + // ───────── 중계 서버 (관리 역할: 로그인) ───────── + + public bool RelayManageEnabled { get; set; } + public string RelayManageUrl { get; set; } = "https://jjsm.joynala.com"; + public string RelayUsername { get; set; } = ""; + /// 로그인 토큰 (Windows 계정으로 암호화) + public string RelayTokenProtected { get; set; } = ""; + /// 자동 로그인용 비밀번호 (선택, Windows 계정으로 암호화) + public string RelayPasswordProtected { get; set; } = ""; + /// 중계 기기별 대상 PC 접속 비밀번호 (기기 ID → 암호화된 비밀번호) + public Dictionary RelayDevicePasswords { get; set; } = new Dictionary(); + /// 초기화면에 열어둔 PC 탭 ("local" 또는 대상 Id) public List OpenHostTabs { get; set; } = new List(); @@ -288,6 +331,9 @@ namespace JJServiceManager s.Targets ??= new List(); s.PassiveManagers ??= new List(); s.OpenHostTabs ??= new List(); + s.RelayDevicePasswords ??= new Dictionary(); + // 중계 기기는 서버에서 받아오므로 목록에 저장하지 않음 + s.Targets.RemoveAll(t => t.Direction == ConnectDirection.Relay); return s; } } diff --git a/Core/Remote/Relay.cs b/Core/Remote/Relay.cs new file mode 100644 index 0000000..8c7d521 --- /dev/null +++ b/Core/Remote/Relay.cs @@ -0,0 +1,1034 @@ +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; + } + } + + /// 중계 통로를 열고 그 위에서 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(); + } +} diff --git a/Core/Remote/RemoteClient.cs b/Core/Remote/RemoteClient.cs index a04e09a..8c486a0 100644 --- a/Core/Remote/RemoteClient.cs +++ b/Core/Remote/RemoteClient.cs @@ -32,6 +32,9 @@ namespace JJServiceManager.Remote private TaskCompletionSource<(JsonLineConnection conn, string peer)> _incoming; private JsonLineConnection _conn; private int _nextId; + private readonly RelayManager _relay; + private TaskCompletionSource _wake = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + private string _relaySession = ""; private sealed class LogSubscriber { @@ -41,6 +44,13 @@ namespace JJServiceManager.Remote public RemoteTarget Target => _target; public bool IsPassive => _target.Direction == ConnectDirection.Passive; + public bool IsRelay => _target.Direction == ConnectDirection.Relay; + + /// Relay: 실제 연결 방식 ("direct" / "relay", 연결 전에는 빈 값) + public string RelayMode { get; private set; } = ""; + + /// Relay: 연결 방식이 정해짐 (백그라운드 스레드) + public event Action RelayModeChanged; public RemoteState State { get; private set; } = RemoteState.Connecting; public string StateMessage { get; private set; } = "연결 중…"; public string Hostname { get; private set; } = ""; @@ -61,8 +71,9 @@ namespace JJServiceManager.Remote /// 원격 화면 이벤트 (frame / screenEnd) public event Action ScreenEvent; - public RemoteClient(RemoteTarget target, Func passwordProvider = null) + public RemoteClient(RemoteTarget target, Func passwordProvider = null, RelayManager relay = null) { + _relay = relay; _target = target.Clone(); Hostname = target.Hostname ?? ""; var prot = _target.ProtectedPassword; @@ -71,6 +82,9 @@ namespace JJServiceManager.Remote public void Start() => _ = Task.Run(RunAsync); + /// 다시 시도 대기 중이면 바로 다시 시도 (대상 PC가 온라인이 됨 등) + public void Nudge() => _wake.TrySetResult(true); + public void Dispose() { try { _cts.Cancel(); } catch { } @@ -118,6 +132,10 @@ namespace JJServiceManager.Remote SetState(RemoteState.Connecting, "대상 PC가 접속함, 인증 중…"); hello = await HelloAsync(conn, _password(), sendPassword: true, ct).ConfigureAwait(false); } + else if (IsRelay) + { + (conn, hello) = await ConnectRelayAsync(ct).ConfigureAwait(false); + } else { SetState(RemoteState.Connecting, "연결 중…"); @@ -157,27 +175,129 @@ namespace JJServiceManager.Remote { SetState(RemoteState.AuthFailed, ex.Message); retry = IsPassive ? TimeSpan.FromSeconds(1) : TimeSpan.FromSeconds(30); + // 중계: 비밀번호를 바꿀 때까지(Nudge) 다시 시도하지 않음 + if (IsRelay) retry = TimeSpan.FromMinutes(10); } catch (Exception ex) { SetState(RemoteState.Disconnected, Friendly(ex)); if (IsPassive) retry = TimeSpan.FromSeconds(1); + if (IsRelay) retry = TimeSpan.FromSeconds(15); } finally { var c = _conn; _conn = null; + // 직접 연결이었으면 끝 정보를 중계 서버에 보고 (중계는 서버가 직접 셈) + if (IsRelay && RelayMode == "direct" && _relaySession.Length > 0 && c != null) + _relay?.ReportStatus(_relaySession, "ended", c.BytesSent, c.BytesReceived, ct.IsCancellationRequested ? "관리 PC에서 닫음" : "연결 끊김"); + _relaySession = ""; c?.Dispose(); FailPending("연결이 끊겼습니다."); _logs.Clear(); ScreenEvent?.Invoke(new JsonObject { ["event"] = "screenEnd", ["sid"] = 0, ["reason"] = "대상 PC와 연결이 끊겼습니다." }); } - try { await Task.Delay(retry, ct).ConfigureAwait(false); } + try + { + var wake = _wake.Task; + await Task.WhenAny(Task.Delay(retry, ct), wake).ConfigureAwait(false); + if (wake.IsCompleted) _wake = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + ct.ThrowIfCancellationRequested(); + } catch { break; } } } + // ───────────────────────── 중계 서버를 거친 연결 ───────────────────────── + + /// 중계 서버에 연결 요청 → 직접 연결 시도(최대 3초) → 안 되면 중계 통로. 둘 다 같은 TLS + hello(비밀번호 + 티켓) + private async Task<(JsonLineConnection conn, JsonObject hello)> ConnectRelayAsync(CancellationToken ct) + { + var relay = _relay ?? throw new RemoteException("중계 서버 설정이 없습니다."); + var deviceId = _target.RelayDeviceId; + if (!relay.IsLoggedIn) + { + SetState(RemoteState.Connecting, "중계 서버 로그인 기다리는 중…"); + if (!await relay.WaitReadyAsync(TimeSpan.FromSeconds(30), ct).ConfigureAwait(false)) + throw new RemoteException("중계 서버에 로그인되어 있지 않습니다."); + } + if (!relay.IsDeviceOnline(deviceId)) + throw new RemoteException("대상 PC가 중계 서버에 접속해 있지 않습니다 (오프라인)."); + var password = _password(); + if (string.IsNullOrEmpty(password)) + throw new RemoteAuthException("대상 PC 접속 비밀번호가 없습니다. 제목 줄의 [비밀번호]를 눌러 입력하세요."); + + SetState(RemoteState.Connecting, "중계 서버에 연결 요청 중…"); + var info = await relay.ConnectAsync(deviceId).ConfigureAwait(false); + _relaySession = info.SessionId; + + // 1) 직접 연결 (대상 PC가 Active 수신 중일 때만 후보가 있음) + var started = DateTime.UtcNow; + foreach (var (host, port, mode) in info.Candidates) + { + var left = TimeSpan.FromSeconds(3) - (DateTime.UtcNow - started); + if (left <= TimeSpan.Zero) break; + SetState(RemoteState.Connecting, $"직접 연결 시도 중… ({host}:{port})"); + JsonLineConnection conn = null; + try + { + using (var t = CancellationTokenSource.CreateLinkedTokenSource(ct)) + { + t.CancelAfter(left); + conn = await RemoteDialer.DialAsync(mode, host, port, RemoteServer.SshUser, password, t.Token).ConfigureAwait(false); + } + var hello = await HelloAsync(conn, password, sendPassword: mode == RemoteMode.Port, ct, info.Ticket).ConfigureAwait(false); + relay.ReportStatus(info.SessionId, "direct"); + SetRelayMode("direct"); + return (conn, hello); + } + catch (RemoteAuthException ex) + { + // 비밀번호·티켓 문제는 중계로 바꿔도 같으므로 바로 실패 + relay.ReportStatus(info.SessionId, "failed", reason: ex.Message); + throw; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch + { + conn?.Dispose(); + } + } + + // 2) 중계 통로 + SetState(RemoteState.Connecting, "중계 서버를 거쳐 연결 중…"); + WebSocketStream stream = null; + try + { + stream = await RelayChannel.OpenRelayAsync(relay.Url, info.SessionId, "manager", info.RelayToken, ct).ConfigureAwait(false); + var ssl = await RemoteDialer.TlsClientAsync(stream, ct).ConfigureAwait(false); + var conn = new JsonLineConnection(ssl); + var hello = await HelloAsync(conn, password, sendPassword: true, ct, info.Ticket).ConfigureAwait(false); + SetRelayMode("relay"); + return (conn, hello); + } + catch (RemoteAuthException) + { + throw; + } + catch (Exception ex) when (!ct.IsCancellationRequested) + { + try { stream?.Dispose(); } catch { } + throw new RemoteException("중계 연결 실패: " + RelayUrl.Friendly(ex)); + } + } + + private void SetRelayMode(string mode) + { + if (RelayMode == mode) return; + RelayMode = mode; + RelayModeChanged?.Invoke(this); + } + private async Task ReadLoopAsync(JsonLineConnection conn, CancellationToken ct) { while (!ct.IsCancellationRequested) @@ -332,7 +452,7 @@ namespace JJServiceManager.Remote } /// 연결 위에서 hello (비밀번호 확인). 실패하면 연결을 닫고 예외 - public static async Task HelloAsync(JsonLineConnection conn, string password, bool sendPassword, CancellationToken ct) + public static async Task HelloAsync(JsonLineConnection conn, string password, bool sendPassword, CancellationToken ct, string ticket = null) { using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); timeout.CancelAfter(ConnectTimeout); @@ -346,6 +466,7 @@ namespace JJServiceManager.Remote ["version"] = RemoteServer.Version }; if (sendPassword) hello["password"] = password; + if (!string.IsNullOrEmpty(ticket)) hello["ticket"] = ticket; await conn.SendAsync(hello, timeout.Token).ConfigureAwait(false); var reply = await conn.ReadAsync(timeout.Token).ConfigureAwait(false); diff --git a/Core/Remote/RemoteCommon.cs b/Core/Remote/RemoteCommon.cs index 3094922..648aeed 100644 --- a/Core/Remote/RemoteCommon.cs +++ b/Core/Remote/RemoteCommon.cs @@ -89,6 +89,12 @@ namespace JJServiceManager.Remote public bool IsDisposed => Volatile.Read(ref _disposed) == 1; + private long _bytesSent, _charsReceived; + /// 보낸 바이트 수 (연결 기록 통계용) + public long BytesSent => Interlocked.Read(ref _bytesSent); + /// 받은 양 (문자 수 ≈ 바이트, 연결 기록 통계용) + public long BytesReceived => Interlocked.Read(ref _charsReceived); + private readonly char[] _buf = new char[64 * 1024]; private int _bufPos, _bufLen; private readonly StringBuilder _line = new StringBuilder(); @@ -104,6 +110,7 @@ namespace JJServiceManager.Remote _bufLen = await _reader.ReadAsync(_buf.AsMemory(), ct).ConfigureAwait(false); _bufPos = 0; if (_bufLen <= 0) return null; + Interlocked.Add(ref _charsReceived, _bufLen); } int idx = Array.IndexOf(_buf, '\n', _bufPos, _bufLen - _bufPos); @@ -139,6 +146,7 @@ namespace JJServiceManager.Remote { await _stream.WriteAsync(bytes, ct).ConfigureAwait(false); await _stream.FlushAsync(ct).ConfigureAwait(false); + Interlocked.Add(ref _bytesSent, bytes.Length); } finally { diff --git a/Core/Remote/RemoteServer.cs b/Core/Remote/RemoteServer.cs index fa8c33b..28a33d7 100644 --- a/Core/Remote/RemoteServer.cs +++ b/Core/Remote/RemoteServer.cs @@ -56,6 +56,28 @@ namespace JJServiceManager.Remote /// 화면을 보는 관리 PC 목록이 바뀜 (백그라운드 스레드) public event Action> ScreenViewersChanged; + /// 중계 서버 권한 티켓으로 들어온 연결에서 실행한 작업 (티켓, 작업, 대상, 결과, 상세) → 서버 감사 로그 + public event Action AuditRequested; + + // 중계 서버 티켓 확인용 + private string _relayPublicKey = "", _relayDeviceId = ""; + + /// 중계 서버 공개키·이 PC의 기기 ID (중계 서버에 등록되어 있을 때) + public void SetRelayTrust(string serverPublicKey, string deviceId) + { + lock (_sync) + { + _relayPublicKey = serverPublicKey ?? ""; + _relayDeviceId = deviceId ?? ""; + } + } + + private void Audit(RelayTicket t, string action, string target, string result, string detail = "") + { + if (t == null) return; + try { AuditRequested?.Invoke(t, action, target, result, detail); } catch { } + } + /// 등록된 서비스 목록 (스레드 안전한 복사본을 돌려줘야 함) /// 작업 로그 기록 /// 원격에서 Start/Stop 했을 때 (상태 갱신용) @@ -227,10 +249,15 @@ namespace JJServiceManager.Remote // ───────────────────────── 요청 처리 ───────────────────────── - private async Task ServeAsync(JsonLineConnection conn, string peer, bool requireHelloPassword, CancellationToken ct) + /// 중계 통로로 들어온 연결 (권한 티켓 필수, 세션 ID 일치) + public Task ServeRelayAsync(JsonLineConnection conn, string peer, string sessionId, CancellationToken ct) + => ServeAsync(conn, peer, requireHelloPassword: true, ct, relaySessionId: sessionId); + + private async Task ServeAsync(JsonLineConnection conn, string peer, bool requireHelloPassword, CancellationToken ct, string relaySessionId = null) { bool authed = !requireHelloPassword; string who = peer; + RelayTicket ticket = null; // 중계 서버 권한 (없으면 비밀번호만으로 모든 권한 — 기존 Active/Passive) var streams = new Dictionary(); int nextStream = 1; ScreenSession screen = null; @@ -249,6 +276,25 @@ namespace JJServiceManager.Remote { var client = Json.Str(msg, "client"); who = string.IsNullOrEmpty(client) ? peer : $"{client}({peer})"; + + // 중계 서버 권한 티켓: 중계 통로면 필수, 직접 연결이면 있을 때만 확인 + var ticketText = Json.Str(msg, "ticket"); + if (relaySessionId != null || ticketText.Length > 0) + { + string pub, dev; + lock (_sync) { pub = _relayPublicKey; dev = _relayDeviceId; } + var t = RelayTicket.Verify(ticketText, pub, dev, out var terr); + if (t == null || (relaySessionId != null && !string.Equals(t.SessionId, relaySessionId, StringComparison.OrdinalIgnoreCase))) + { + var why = t == null ? terr : "중계 세션이 맞지 않습니다."; + _log($"원격 접속 거부: {who} {why}", LogLevel.Warning); + await Reply(conn, id, error: why, extra: new JsonObject { ["authFailed"] = true }); + break; + } + ticket = t; + who = $"{t.Username}@{(string.IsNullOrEmpty(client) ? "" : client)}({(relaySessionId != null ? "중계" : peer)})"; + } + if (!authed) { if (IsThrottled()) @@ -261,21 +307,33 @@ namespace JJServiceManager.Remote RecordFailure(); await Task.Delay(1000, ct).ConfigureAwait(false); _log($"원격 접속 거부: {who} 비밀번호가 맞지 않습니다.", LogLevel.Warning); + Audit(ticket, "authFail", "", "denied", "대상 PC 접속 비밀번호가 맞지 않음"); await Reply(conn, id, error: "접속 비밀번호가 맞지 않습니다.", extra: new JsonObject { ["authFailed"] = true }); break; } authed = true; } _log($"원격 접속: {who}", LogLevel.Notice); - var pol = _policy(); - await Reply(conn, id, result: new JsonObject + var pol = Policy(ticket); + var helloResult = new JsonObject { ["hostname"] = Environment.MachineName, ["version"] = Version, ["instance"] = AppInstance.Number, ["allowView"] = pol.AllowView, ["allowControl"] = pol.AllowView && pol.AllowControl - }); + }; + if (ticket != null) + { + helloResult["perms"] = new JsonObject + { + ["view"] = ticket.View, + ["control"] = ticket.Control, + ["screenView"] = ticket.ScreenView, + ["screenControl"] = ticket.ScreenControl + }; + } + await Reply(conn, id, result: helloResult); continue; } @@ -285,6 +343,16 @@ namespace JJServiceManager.Remote break; } + // 중계 서버 권한 (티켓) 확인 + var denied = Denied(ticket, op); + if (denied != null) + { + if (op == "start" || op == "stop" || op == "screenStart") + Audit(ticket, op, Json.Str(msg, "service"), "denied", denied); + if (op != "input") await Reply(conn, id, error: denied); + continue; + } + switch (op) { case "ping": @@ -292,13 +360,34 @@ namespace JJServiceManager.Remote break; case "list": - _ = Task.Run(() => SafeReply(conn, id, () => ListServices())); + { + var tk = ticket; + _ = Task.Run(() => SafeReply(conn, id, () => ListServices(tk))); break; + } case "start": case "stop": - _ = Task.Run(() => SafeReply(conn, id, () => StartStop(op == "start", Json.Str(msg, "service"), who))); + { + var tk = ticket; + var svc = Json.Str(msg, "service"); + var whoNow = who; + _ = Task.Run(() => SafeReply(conn, id, () => + { + try + { + var res = StartStop(op == "start", svc, whoNow); + Audit(tk, op, svc, "ok", $"{Json.Str(res, "seconds")}초"); + return res; + } + catch (RemoteException ex) + { + Audit(tk, op, svc, ex.IsInfo ? "ok" : "fail", ex.Message); + throw; + } + })); break; + } case "details": _ = Task.Run(() => SafeReply(conn, id, () => @@ -363,7 +452,7 @@ namespace JJServiceManager.Remote case "screenInfo": { - var pol = _policy(); + var pol = Policy(ticket); var arr = new JsonArray(); foreach (var m in ScreenCapture.Monitors()) arr.Add(new JsonObject { ["index"] = m.Index, ["width"] = m.Width, ["height"] = m.Height, ["primary"] = m.Primary }); @@ -379,13 +468,14 @@ namespace JJServiceManager.Remote case "screenStart": { var whoNow = who; + var tk = ticket; int monitor = Json.Int(msg, "monitor", 1); // 승인 창을 기다리는 동안 다른 요청은 계속 처리되도록 따로 실행 _ = Task.Run(async () => { try { - var pol = _policy(); + var pol = Policy(tk); if (!pol.AllowView) throw new RemoteException("대상 PC에서 화면 보기를 허용하지 않았습니다."); bool control = pol.AllowControl; if (pol.RequireApproval) @@ -395,12 +485,14 @@ namespace JJServiceManager.Remote if (!ok) { _log($"[원격 화면] {whoNow} 연결 거부", LogLevel.Warning); + Audit(tk, "screenStart", "", "denied", "대상 PC에서 거부"); throw new RemoteException("대상 PC에서 연결을 거부했습니다."); } } var old = Interlocked.Exchange(ref screen, null); EndScreen(old); - var s = new ScreenSession(Interlocked.Increment(ref _nextScreenId), whoNow, control, monitor); + var s = new ScreenSession(Interlocked.Increment(ref _nextScreenId), whoNow, control, monitor) { Ticket = tk }; + Audit(tk, "screenStart", "", "ok", control ? "보기 + 제어" : "보기"); screen = s; _screens[s.Id] = s; _log($"[원격 화면] {whoNow} {(control ? "보기 + 제어" : "보기")} 시작", LogLevel.Notice); @@ -435,7 +527,7 @@ namespace JJServiceManager.Remote // 응답 없이 바로 실행 (빠르게) var s = screen; if (s == null || s.Stopped || !s.Control) break; - var pol = _policy(); + var pol = Policy(ticket); if (!pol.AllowView || !pol.AllowControl) break; try { @@ -487,6 +579,7 @@ namespace JJServiceManager.Remote public int Id { get; } public string Who { get; } public bool Control { get; } + public RelayTicket Ticket { get; init; } public volatile int Monitor; public bool Stopped => _cts.IsCancellationRequested; public CancellationToken Token => _cts.Token; @@ -517,7 +610,7 @@ namespace JJServiceManager.Remote while (!s.Stopped && !ct.IsCancellationRequested && !conn.IsDisposed) { var sw = Stopwatch.StartNew(); - var pol = _policy(); + var pol = Policy(s.Ticket); if (!pol.AllowView) { endReason = "대상 PC에서 화면 보기 허용을 껐습니다."; @@ -572,6 +665,7 @@ namespace JJServiceManager.Remote catch { } } _log($"[원격 화면] {s.Who} 연결 끝", LogLevel.Info); + Audit(s.Ticket, "screenEnd", "", "ok", endReason); NotifyViewers(); } } @@ -596,6 +690,41 @@ namespace JJServiceManager.Remote ScreenViewersChanged?.Invoke(list); } + // ───────────────────────── 중계 서버 권한 ───────────────────────── + + /// 이 PC 설정 ∩ 중계 서버 권한 + private ScreenPolicy Policy(RelayTicket t) + { + var p = _policy(); + if (t == null) return p; + bool view = p.AllowView && t.ScreenView; + return new ScreenPolicy(view, view && p.AllowControl && t.ScreenControl, p.RequireApproval); + } + + /// 티켓 권한으로 할 수 없는 요청이면 사유 + private static string Denied(RelayTicket t, string op) + { + if (t == null) return null; + switch (op) + { + case "list": + case "details": + case "logOpen": + return t.View ? null : "중계 서버에서 이 기기 [보기] 권한이 없습니다."; + case "start": + case "stop": + return t.Control ? null : "중계 서버에서 이 기기 [서비스 제어] 권한이 없습니다."; + case "screenInfo": + case "screenStart": + case "screenMonitor": + return t.ScreenView ? null : "중계 서버에서 이 기기 [화면 보기] 권한이 없습니다."; + case "input": + return t.ScreenControl ? null : "중계 서버에서 이 기기 [화면 제어] 권한이 없습니다."; + default: + return null; + } + } + // ───────────────────────── 서비스 ───────────────────────── private ServiceEntry Registered(string serviceName) @@ -605,7 +734,7 @@ namespace JJServiceManager.Remote return e; } - private JsonObject ListServices() + private JsonObject ListServices(RelayTicket ticket = null) { var arr = new JsonArray(); foreach (var e in _services()) @@ -620,7 +749,7 @@ namespace JJServiceManager.Remote ["logIsFolder"] = e.LogIsFolder }); } - var pol = _policy(); + var pol = Policy(ticket); return new JsonObject { ["services"] = arr, diff --git a/Core/Remote/RemoteTransport.cs b/Core/Remote/RemoteTransport.cs index c1861d8..ddadf7d 100644 --- a/Core/Remote/RemoteTransport.cs +++ b/Core/Remote/RemoteTransport.cs @@ -152,8 +152,22 @@ namespace JJServiceManager.Remote try { _ssh?.Dispose(); } catch { } } + /// 임의의 스트림(중계 통로) 위에서 TLS 서버 쪽 연결 + public static async Task TlsServerAsync(System.IO.Stream inner, CancellationToken ct) + { + var ssl = new SslStream(inner, false); + using var hs = CancellationTokenSource.CreateLinkedTokenSource(ct); + hs.CancelAfter(TimeSpan.FromSeconds(20)); + await ssl.AuthenticateAsServerAsync(new SslServerAuthenticationOptions + { + ServerCertificate = Certificate(), + ClientCertificateRequired = false + }, hs.Token).ConfigureAwait(false); + return ssl; + } + /// TLS용 자체 서명 인증서 (실행할 때마다 새로 만듦, 지문 확인은 하지 않는 설정) - private static X509Certificate2 Certificate() + internal static X509Certificate2 Certificate() { lock (CertLock) { @@ -173,6 +187,20 @@ namespace JJServiceManager.Remote /// 접속하는 쪽 (지정 포트 TLS 또는 내장 SSH) public static class RemoteDialer { + /// 임의의 스트림(중계 통로) 위에서 TLS 클라이언트 쪽 연결 (암호화만, 지문 확인 안 함) + public static async Task TlsClientAsync(System.IO.Stream inner, CancellationToken ct) + { + var ssl = new SslStream(inner, false); + using var hs = CancellationTokenSource.CreateLinkedTokenSource(ct); + hs.CancelAfter(TimeSpan.FromSeconds(20)); + await ssl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions + { + TargetHost = "JJServiceManager", + RemoteCertificateValidationCallback = (a, b, c, d) => true + }, hs.Token).ConfigureAwait(false); + return ssl; + } + /// /// 접속. SSH면 sshUser/sshPassword로 인증까지 함 (실패 시 RemoteAuthException). /// TLS는 암호화만 하고 서버 지문은 확인하지 않음. diff --git a/JJServiceManager.csproj b/JJServiceManager.csproj index 51dcc8d..f3c157b 100644 --- a/JJServiceManager.csproj +++ b/JJServiceManager.csproj @@ -37,4 +37,8 @@ + + + + diff --git a/README.md b/README.md index 2601429..c6b3bca 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ Windows 서비스 기동/종료 관리 프로그램 (C# WPF, .NET 8) - v1.1부터 **다른 PC의 등록된 서비스를 원격으로 모니터링·기동·종료**할 수 있습니다. - v2.0부터 **원격관리(화면 보기 / 키보드·마우스 제어)** 와 **Active / Passive 접속 방향 선택**이 추가되었습니다. +- (개발 중) **중계 서버** ([JJServiceManagerRelay](https://gitea.joynala.com/diadmin/JJServiceManagerRelay)): 모든 PC가 서버로만 접속하면 서로 연결 (먼저 직접, 안 되면 중계), 계정·권한·감사 로그. ## 실행 파일 @@ -102,6 +103,40 @@ Windows 서비스 기동/종료 관리 프로그램 (C# WPF, .NET 8) 설정 → 일반에서 **내 PC** / **원격** Start/Stop 각각 켜고 끔 (기본: 내 PC 끔, 원격 켬). 여러 서비스를 한꺼번에 Start/Stop할 때는 한 번만 입력합니다. +## 중계 서버 (JJServiceManagerRelay) + +NAS 등에 띄운 중계 서버(`https://jjsm.joynala.com`)를 거치면 대상 PC·관리 PC 모두 **포트를 열 필요가 없습니다**. +같은 내부망이거나 대상 PC의 Active 포트가 열려 있으면 **직접 연결**하고, 안 되면 **서버가 중계**합니다. +서버 설치: [JJServiceManagerRelay/docs/NAS설치.md](https://gitea.joynala.com/diadmin/JJServiceManagerRelay/src/branch/main/docs/NAS설치.md) + +### 대상 PC +1. 설정 → 원격 접속 허용 → 켜기, 접속 비밀번호 +2. **중계 서버 접속**: 서버 주소 확인 → **[등록 코드로 등록]** → 서버 관리자에게 받은 코드 입력 → 중계 서버 사용 켜기 → 확인 +3. 내 PC 제목 줄에 `중계 서버 온라인` 표시 + +### 관리 PC +1. 설정 → 관리 대상 PC → 다른 PC 관리 켜기 +2. **중계 서버 로그인**: 서버 주소, 아이디, 비밀번호 → **[로그인]** (첫 로그인이면 새 비밀번호를 정함) → 확인 +3. 권한이 있는 대상 PC가 초기화면에 `이름 (중계)` / `이름 (직접)` 묶음(초록색)으로 자동으로 나타남 +4. 묶음 제목 줄의 **[비밀번호]** 에 그 대상 PC의 접속 비밀번호를 입력 (기기마다 한 번, 암호화 저장) + +### 권한과 보안 +- 서버 권한 4가지: **보기 / 서비스 제어 / 화면 보기 / 화면 제어** (관리자는 모두) +- 서버는 연결마다 **권한 티켓**에 서명하고, **대상 PC가 직접 서명을 확인**해 그 권한을 넘는 요청은 거부 +- 실제 권한 = 서버 권한 ∩ 대상 PC 설정 (화면 보기 허용 등) +- 중계 통로 안에서도 관리 PC ↔ 대상 PC가 TLS를 다시 맺으므로 **서버는 내용·비밀번호를 볼 수 없음** +- 대상 PC가 실제로 실행한 Start/Stop·원격 화면은 서버 **감사 로그**에 남음 (지우는 기능 없음) + +### [서버 관리] 탭 (관리자 계정으로 로그인했을 때 설정에 나타남) +| 화면 | 기능 | +|---|---| +| 계정 | 추가 / 비밀번호 초기화 / 역할 / 사용·사용 안 함 / 메모 | +| 권한 | 사용자 선택 → 기기별 4가지 권한 체크 (바로 저장) | +| 기기 | 온라인·IP·Active 수신·버전, 이름 바꾸기, 사용 안 함, 삭제 | +| 등록 코드 | 새 코드 발급 (1시간 / 24시간 / 7일 / 30일, 메모), 취소 | +| 감사 로그 | 기간·사용자·기기·작업으로 조회, 더 보기, 복사 | +| 연결 기록 | 직접/중계, 시간, 트래픽 | + ## 원격관리 (화면 보기 / 키보드·마우스 제어) ### 대상 PC 설정 (원격 접속 허용 탭 → 원격관리) diff --git a/Views/MainWindow.xaml b/Views/MainWindow.xaml index d8c3f78..51c757a 100644 --- a/Views/MainWindow.xaml +++ b/Views/MainWindow.xaml @@ -45,6 +45,9 @@