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 @@
+
diff --git a/Views/Settings.xaml b/Views/Settings.xaml
index 16cf9dc..9ab6f05 100644
--- a/Views/Settings.xaml
+++ b/Views/Settings.xaml
@@ -172,6 +172,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -218,6 +241,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Windows/MainWindow.cs b/Windows/MainWindow.cs
index 0eb4b62..faf6a66 100644
--- a/Windows/MainWindow.cs
+++ b/Windows/MainWindow.cs
@@ -5,6 +5,7 @@ using System.ComponentModel;
using System.Diagnostics;
using System.Linq;
using System.Security.Principal;
+using System.Text.Json.Nodes;
using System.ServiceProcess;
using System.Threading.Tasks;
using System.Windows;
@@ -34,6 +35,14 @@ namespace JJServiceManager
private readonly DispatcherTimer _statusTimer;
private readonly RemoteServer _remoteServer;
private readonly PassiveHub _passiveHub;
+ private readonly RelayAgent _relayAgent;
+ private readonly RelayManager _relayManager;
+ private string _relayAgentKey = "", _relayManageKey = "";
+ private string _relayLoginNotice;
+ /// 중계 기기 그룹의 표시용 대상 정보 (그룹 Id → 대상)
+ private readonly Dictionary _relayTargets = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ /// 시작할 때 열려 있던 중계 기기 탭 (로그인 뒤 기기 목록을 받으면 다시 엶)
+ private readonly HashSet _pendingRelayTabs = new HashSet(StringComparer.OrdinalIgnoreCase);
private RemoteIndicatorWindow _indicator;
private readonly Dictionary _viewers = new Dictionary(StringComparer.OrdinalIgnoreCase);
private string _passiveKey = "";
@@ -170,6 +179,31 @@ namespace JJServiceManager
// 관리 PC: Passive 대상 PC의 접속을 받는 곳
_passiveHub = new PassiveHub((m, l) => Log(m, l));
+ // 중계 서버: 대상 PC 역할 (이 PC를 서버에 등록해 두고 연결 요청을 받음)
+ _relayAgent = new RelayAgent(_remoteServer, (m, l) => Log(m, l));
+ _relayAgent.StateChanged += a => Dispatcher.BeginInvoke(new Action(UpdateLocalStatus));
+ _relayAgent.NameChanged += name => Dispatcher.BeginInvoke(new Action(() =>
+ {
+ if (_settings.RelayDeviceName != name) { _settings.RelayDeviceName = name; SaveSettings(); }
+ }));
+ _relayAgent.Revoked += why => Dispatcher.BeginInvoke(new Action(() =>
+ {
+ _relayAgentKey = "revoked";
+ UpdateLocalStatus();
+ }));
+
+ // 중계 서버: 관리 PC 역할 (로그인 → 권한 있는 기기를 자동으로 표시)
+ _relayManager = new RelayManager((m, l) => Log(m, l));
+ _relayManager.DevicesChanged += m => Dispatcher.BeginInvoke(new Action(SyncRelayGroups));
+ _relayManager.Presence += (id, online) => Dispatcher.BeginInvoke(new Action(() => OnRelayPresence(id, online)));
+ _relayManager.StateChanged += m => Dispatcher.BeginInvoke(new Action(SyncRelayGroups));
+ _relayManager.LoginRequired += msg => Dispatcher.BeginInvoke(new Action(() =>
+ {
+ if (_relayLoginNotice == msg) return;
+ _relayLoginNotice = msg;
+ Log(msg, LogLevel.Warning);
+ }));
+
CreateTray();
Log("프로그램 시작" + (IsAdmin() ? " (관리자 권한)" : "") + AppInstance.TitleTag, LogLevel.Notice);
@@ -283,15 +317,21 @@ namespace JJServiceManager
private void SaveOpenTabs()
{
- _settings.OpenHostTabs = _hostTabs.Keys.ToList();
+ _settings.OpenHostTabs = _hostTabs.Keys.Concat(_pendingRelayTabs).Distinct().ToList();
SaveSettings();
}
private void RestoreHostTabs()
{
- foreach (var id in _settings.OpenHostTabs.ToList()) OpenHostTab(id, select: false);
- // 없어진 PC 탭은 정리
- _settings.OpenHostTabs = _hostTabs.Keys.ToList();
+ foreach (var id in _settings.OpenHostTabs.ToList())
+ {
+ if (id.StartsWith(RelayGroupPrefix, StringComparison.OrdinalIgnoreCase) && !_groups.Any(g => g.Id == id))
+ _pendingRelayTabs.Add(id); // 로그인 뒤 기기 목록을 받으면 엶
+ else
+ OpenHostTab(id, select: false);
+ }
+ // 없어진 PC 탭은 정리 (중계 기기 탭은 유지)
+ _settings.OpenHostTabs = _hostTabs.Keys.Concat(_pendingRelayTabs).Distinct().ToList();
}
// ───────────────────────── 내 PC 서비스 추가 / 삭제 ─────────────────────────
@@ -397,6 +437,12 @@ namespace JJServiceManager
OpenRemoteDesktop(hg);
return;
}
+ if (e.OriginalSource is Button pb && pb.DataContext is HostGroup pg && pb.Tag as string == "relayPassword")
+ {
+ e.Handled = true;
+ AskRelayPassword(pg);
+ return;
+ }
if (e.OriginalSource is not Button b || b.DataContext is not ServiceRow row) return;
switch (b.Tag as string)
{
@@ -773,39 +819,19 @@ namespace JJServiceManager
// 기존 원격 PC 정리
_passiveHub.Clear();
foreach (var v in _viewers.Values.ToList()) v.Close();
- foreach (var g in _groups.Where(g => !g.IsLocal).ToList())
- {
- g.Client?.Dispose();
- _logWindows.Close($"remote|{g.Id}|", prefix: true);
- _groups.Remove(g);
- }
+ foreach (var g in _groups.Where(g => !g.IsLocal && !IsRelayGroup(g)).ToList())
+ RemoveRemoteGroup(g);
if (_settings.RemoteManageEnabled)
{
+ // 중계 기기 그룹보다 앞에 넣음
+ int index = 1;
foreach (var t in _settings.Targets)
{
var client = new RemoteClient(t);
- var g = new HostGroup
- {
- Id = t.Id,
- Client = client,
- Title = t.Label,
- StatusText = "연결 중…",
- StatusBrush = Brushes2.Pending,
- EmptyText = "연결 중…"
- };
- client.StateChanged += c =>
- {
- // 상태는 이벤트 시점 값으로 넘김 (UI에서 나중에 읽으면 연속 변경이 모두 같은 값으로 보임)
- var st = c.State;
- var stMsg = c.StateMessage;
- Dispatcher.BeginInvoke(new Action(() => OnRemoteState(g, st, stMsg)));
- };
- client.ServicesChanged += c => Dispatcher.BeginInvoke(new Action(() => OnRemoteServices(g, c)));
- client.HostnameDiscovered += (c, host) => Dispatcher.BeginInvoke(new Action(() => OnHostname(g, c, host)));
+ var g = CreateRemoteGroup(t.Id, t.Label, client, index++);
client.PeerAddressDiscovered += (c, addr) => Dispatcher.BeginInvoke(new Action(() => OnPeerAddress(g, addr)));
if (t.Direction == ConnectDirection.Passive) _passiveHub.Register(client);
- _groups.Add(g);
client.Start();
}
}
@@ -820,6 +846,213 @@ namespace JJServiceManager
UpdateCounts();
}
+ /// 원격 PC 그룹 만들기 (Active / Passive / 중계 공통)
+ private HostGroup CreateRemoteGroup(string id, string title, RemoteClient client, int index = -1)
+ {
+ var g = new HostGroup
+ {
+ Id = id,
+ Client = client,
+ Title = title,
+ StatusText = "연결 중…",
+ StatusBrush = Brushes2.Pending,
+ EmptyText = "연결 중…"
+ };
+ client.StateChanged += c =>
+ {
+ // 상태는 이벤트 시점 값으로 넘김 (UI에서 나중에 읽으면 연속 변경이 모두 같은 값으로 보임)
+ var st = c.State;
+ var stMsg = c.StateMessage;
+ Dispatcher.BeginInvoke(new Action(() => OnRemoteState(g, st, stMsg)));
+ };
+ client.ServicesChanged += c => Dispatcher.BeginInvoke(new Action(() => OnRemoteServices(g, c)));
+ client.HostnameDiscovered += (c, host) => Dispatcher.BeginInvoke(new Action(() => OnHostname(g, c, host)));
+ if (index >= 0 && index <= _groups.Count) _groups.Insert(index, g);
+ else _groups.Add(g);
+ return g;
+ }
+
+ private void RemoveRemoteGroup(HostGroup g)
+ {
+ if (_viewers.TryGetValue(g.Id, out var v)) v.Close();
+ g.Client?.Dispose();
+ _logWindows.Close($"remote|{g.Id}|", prefix: true);
+ _groups.Remove(g);
+ }
+
+ // ───────────────────────── 중계 서버 ─────────────────────────
+
+ private const string RelayGroupPrefix = "relay-";
+ private static bool IsRelayGroup(HostGroup g) => g.Id.StartsWith(RelayGroupPrefix, StringComparison.OrdinalIgnoreCase);
+
+ /// 대상 PC 역할: 이 PC를 중계 서버에 등록해 두고 온라인 유지
+ private void ApplyRelayAgent()
+ {
+ var s = _settings;
+ bool hasPw = !string.IsNullOrEmpty(s.RemotePasswordHash);
+ bool need = s.RemoteServerEnabled && hasPw && s.RelayAgentEnabled && !string.IsNullOrEmpty(s.RelayDeviceId);
+ var key = need ? $"{s.RelayAgentUrl}|{s.RelayDeviceId}|{s.RelayDeviceKeyProtected}" : "off";
+ if (key != _relayAgentKey)
+ {
+ _relayAgentKey = key;
+ if (need)
+ {
+ _relayAgent.Start(s.RelayAgentUrl, s.RelayDeviceId, RemoteSecurity.Unprotect(s.RelayDeviceKeyProtected),
+ s.RelayServerPublicKey, RelayActiveListen);
+ }
+ else
+ {
+ _relayAgent.Stop();
+ }
+ }
+ else if (need)
+ {
+ _relayAgent.SendUpdate(); // Active 수신 정보가 바뀌었을 수 있음
+ }
+ if (!need && !string.IsNullOrEmpty(s.RelayServerPublicKey)) _remoteServer.SetRelayTrust(s.RelayServerPublicKey, s.RelayDeviceId);
+ }
+
+ /// 중계 서버에 알릴 Active 수신 정보 (직접 연결 후보)
+ private JsonObject RelayActiveListen()
+ {
+ if (!_remoteServer.IsRunning) return null;
+ return new JsonObject { ["mode"] = _remoteServer.Mode == RemoteMode.Ssh ? "Ssh" : "Port", ["port"] = _remoteServer.Port };
+ }
+
+ /// 관리 역할: 중계 서버 로그인 유지
+ private void ApplyRelayManage()
+ {
+ var s = _settings;
+ bool need = s.RemoteManageEnabled && s.RelayManageEnabled
+ && (!string.IsNullOrEmpty(s.RelayTokenProtected) || !string.IsNullOrEmpty(s.RelayPasswordProtected));
+ var key = need ? $"{s.RelayManageUrl}|{s.RelayUsername}|{s.RelayTokenProtected}|{s.RelayPasswordProtected}" : "off";
+ if (key == _relayManageKey) return;
+ _relayManageKey = key;
+ _relayLoginNotice = null;
+ if (need)
+ {
+ _relayManager.Start(s.RelayManageUrl, s.RelayUsername,
+ () => RemoteSecurity.Unprotect(_settings.RelayTokenProtected),
+ () => RemoteSecurity.Unprotect(_settings.RelayPasswordProtected),
+ token => Dispatcher.BeginInvoke(new Action(() =>
+ {
+ _settings.RelayTokenProtected = RemoteSecurity.Protect(token);
+ _relayManageKey = $"{_settings.RelayManageUrl}|{_settings.RelayUsername}|{_settings.RelayTokenProtected}|{_settings.RelayPasswordProtected}";
+ SaveSettings();
+ })));
+ }
+ else
+ {
+ _relayManager.Stop();
+ }
+ SyncRelayGroups();
+ }
+
+ /// 중계 서버의 기기 목록 → 초기화면 그룹 (권한 있는 기기만)
+ private void SyncRelayGroups()
+ {
+ bool active = _settings.RemoteManageEnabled && _settings.RelayManageEnabled && _relayManageKey != "off" && _relayManageKey != "";
+ var devices = active ? _relayManager.Devices : Array.Empty();
+ var wanted = devices.Where(d => d.Enabled).ToDictionary(d => RelayGroupPrefix + d.Id, StringComparer.OrdinalIgnoreCase);
+
+ // 없어진 기기
+ foreach (var g in _groups.Where(IsRelayGroup).ToList())
+ {
+ if (wanted.ContainsKey(g.Id)) continue;
+ // 서버 연결이 잠깐 끊긴 경우(목록이 비어 있음)에는 그대로 둠
+ if (active && devices.Count == 0 && !_relayManager.IsLoggedIn) continue;
+ RemoveRemoteGroup(g);
+ _relayTargets.Remove(g.Id);
+ if (_hostTabs.ContainsKey(g.Id)) CloseHostTab(g.Id, save: false);
+ }
+
+ foreach (var kv in wanted)
+ {
+ var d = kv.Value;
+ var gid = kv.Key;
+ var existing = _groups.FirstOrDefault(x => x.Id == gid);
+ if (existing != null)
+ {
+ // 이름이 바뀌었으면 제목 갱신
+ if (_relayTargets.TryGetValue(gid, out var t0) && (t0.RelayName != d.Name))
+ {
+ t0.RelayName = d.Name;
+ existing.Title = t0.Label;
+ existing.TitleChanged();
+ }
+ if (d.Online && existing.Client?.State != RemoteState.Connected) existing.Client?.Nudge();
+ continue;
+ }
+
+ var t = new RemoteTarget
+ {
+ Id = gid,
+ Direction = ConnectDirection.Relay,
+ RelayDeviceId = d.Id,
+ RelayName = d.Name,
+ Hostname = d.Hostname
+ };
+ _relayTargets[gid] = t;
+ var deviceId = d.Id;
+ var client = new RemoteClient(t, () =>
+ _settings.RelayDevicePasswords.TryGetValue(deviceId, out var p) ? RemoteSecurity.Unprotect(p) : "", _relayManager);
+ var g = CreateRemoteGroup(gid, t.Label, client);
+ client.RelayModeChanged += c => Dispatcher.BeginInvoke(new Action(() =>
+ {
+ t.RelayMode = c.RelayMode;
+ g.Title = t.Label;
+ g.TitleChanged();
+ _logWindows.ChoicesChanged();
+ }));
+ client.Start();
+ if (_pendingRelayTabs.Remove(gid)) OpenHostTab(gid, select: false);
+ }
+
+ _logWindows.ChoicesChanged();
+ UpdateCounts();
+ }
+
+ private void OnRelayPresence(string deviceId, bool online)
+ {
+ var g = _groups.FirstOrDefault(x => x.Id == RelayGroupPrefix + deviceId);
+ if (g == null) return;
+ if (online) g.Client?.Nudge();
+ }
+
+ /// 중계 기기의 대상 PC 접속 비밀번호 입력 (기기마다 저장)
+ private void AskRelayPassword(HostGroup g)
+ {
+ if (!_relayTargets.TryGetValue(g.Id, out var t) || _modalOpen) return;
+ string pw;
+ _modalOpen = true;
+ try
+ {
+ pw = TextPrompt.Ask(this, "대상 PC 접속 비밀번호",
+ $"{g.Title}\n\n대상 PC의 [원격 접속 허용] 접속 비밀번호를 입력하세요.\n(이 PC에 Windows 계정으로 암호화해 저장합니다)", "", password: true);
+ }
+ finally { _modalOpen = false; }
+ if (string.IsNullOrEmpty(pw)) return;
+ _settings.RelayDevicePasswords[t.RelayDeviceId] = RemoteSecurity.Protect(pw);
+ SaveSettings();
+ Log($"[{g.Prefix}] 접속 비밀번호 저장");
+ g.Client?.Nudge();
+ }
+
+ /// 내 PC 제목 줄의 원격 접속 허용 상태
+ private void UpdateLocalStatus()
+ {
+ var s = _settings;
+ var parts = new List();
+ bool passive = s.RemoteServerEnabled && !string.IsNullOrEmpty(s.RemotePasswordHash) && s.PassiveManagers.Count > 0;
+ if (_remoteServer.IsRunning) parts.Add($"Active {(s.RemoteServerMode == RemoteMode.Ssh ? "SSH" : "지정 포트")} {s.RemoteServerPort}");
+ if (passive) parts.Add($"Passive {s.PassiveManagers.Count}곳");
+ if (_relayAgentKey == "revoked") parts.Add("중계 서버: 다시 등록 필요");
+ else if (_relayAgentKey != "off" && _relayAgentKey != "") parts.Add(_relayAgent.IsOnline ? "중계 서버 온라인" : "중계 서버 연결 안 됨");
+ if (s.RemoteServerEnabled && s.RemoteAllowView) parts.Add(s.RemoteAllowControl ? "화면 보기·제어 허용" : "화면 보기 허용");
+ _local.StatusText = parts.Count > 0 ? "원격 접속 허용 중 · " + string.Join(" · ", parts) : "";
+ _local.StatusBrush = _relayAgentKey == "revoked" ? Brushes2.Stopped : Brushes2.Running;
+ }
+
/// Passive 대상이 있으면 수신 포트를 엶 (방화벽 포함)
private void ApplyPassiveListen(bool startup)
{
@@ -934,6 +1167,14 @@ namespace JJServiceManager
/// hostname 자동 등록
private void OnHostname(HostGroup g, RemoteClient c, string host)
{
+ if (_relayTargets.TryGetValue(g.Id, out var rt))
+ {
+ rt.Hostname = host;
+ g.Title = rt.Label;
+ g.TitleChanged();
+ _logWindows.ChoicesChanged();
+ return;
+ }
var t = _settings.Targets.FirstOrDefault(x => x.Id == g.Id);
if (t == null) return;
if (t.Hostname != host)
@@ -996,13 +1237,11 @@ namespace JJServiceManager
else _remoteServer.StopPassive();
}
+ // ── 중계 서버 (이 PC를 등록해 두고 연결 요청 받기)
+ ApplyRelayAgent();
+
// ── 원격관리 허용을 끄면 보고 있던 연결은 다음 장면에서 자동으로 끊김
- var parts = new List();
- if (_remoteServer.IsRunning) parts.Add($"Active {(s.RemoteServerMode == RemoteMode.Ssh ? "SSH" : "지정 포트")} {s.RemoteServerPort}");
- if (passive) parts.Add($"Passive {s.PassiveManagers.Count}곳");
- if (s.RemoteServerEnabled && s.RemoteAllowView) parts.Add(s.RemoteAllowControl ? "화면 보기·제어 허용" : "화면 보기 허용");
- _local.StatusText = parts.Count > 0 ? "원격 접속 허용 중 · " + string.Join(" · ", parts) : "";
- _local.StatusBrush = Brushes2.Running;
+ UpdateLocalStatus();
}
// ───────────────────────── Windows 종료 ─────────────────────────
@@ -1043,7 +1282,7 @@ namespace JJServiceManager
ShowFromTray();
if (_modalOpen) return;
bool wasStartup = _settings.RunAtStartup;
- var dlg = new SettingsWindow(_settings) { Owner = this };
+ var dlg = new SettingsWindow(_settings, _relayManager) { Owner = this };
// 설정 창에서 단축키를 누를 때 전역 단축키가 먼저 받지 않도록 잠시 해제
_hotkey.Unregister();
@@ -1081,6 +1320,7 @@ namespace JJServiceManager
ApplyHotkey(quiet: startup);
ApplyRemoteServer(startup);
ApplyRemoteManage();
+ ApplyRelayManage();
ApplyPassiveListen(startup);
}
@@ -1256,6 +1496,8 @@ namespace JJServiceManager
_logWindows.CloseAll();
foreach (var v in _viewers.Values.ToList()) v.Close();
foreach (var g in _groups) g.Client?.Dispose();
+ _relayManager.Dispose();
+ _relayAgent.Dispose();
_passiveHub.Dispose();
_remoteServer.Dispose();
_indicator?.Close();
diff --git a/Windows/RelayDialogs.cs b/Windows/RelayDialogs.cs
new file mode 100644
index 0000000..974b5f5
--- /dev/null
+++ b/Windows/RelayDialogs.cs
@@ -0,0 +1,145 @@
+using System;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+
+namespace JJServiceManager
+{
+ /// 한 줄 입력 (글자 또는 비밀번호)
+ public sealed class TextPrompt : Window
+ {
+ private readonly TextBox _text;
+ private readonly PasswordBox _pw;
+ private readonly bool _password;
+
+ private TextPrompt(string title, string message, string initial, bool password, bool upper)
+ {
+ _password = password;
+ Title = title + AppInstance.TitleTag;
+ Width = 460;
+ SizeToContent = SizeToContent.Height;
+ ResizeMode = ResizeMode.NoResize;
+ WindowStartupLocation = WindowStartupLocation.CenterOwner;
+ ShowInTaskbar = false;
+ FontFamily = new FontFamily("Malgun Gothic");
+ FontSize = 13;
+ AppIcon.Apply(this);
+
+ var root = new StackPanel { Margin = new Thickness(18) };
+ root.Children.Add(new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 10) });
+ if (password)
+ {
+ _pw = new PasswordBox { Height = 30, VerticalContentAlignment = VerticalAlignment.Center };
+ root.Children.Add(_pw);
+ }
+ else
+ {
+ _text = new TextBox
+ {
+ Height = 30,
+ VerticalContentAlignment = VerticalAlignment.Center,
+ Text = initial ?? "",
+ CharacterCasing = upper ? CharacterCasing.Upper : CharacterCasing.Normal,
+ FontFamily = upper ? new FontFamily("Consolas") : FontFamily,
+ FontWeight = upper ? FontWeights.Bold : FontWeights.Normal
+ };
+ root.Children.Add(_text);
+ }
+ var buttons = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 14, 0, 0) };
+ var ok = new Button { Content = "확인", MinWidth = 80, IsDefault = true, FontWeight = FontWeights.Bold };
+ var cancel = new Button { Content = "취소", MinWidth = 80, IsCancel = true, Margin = new Thickness(6, 0, 0, 0) };
+ ok.Click += (s, e) => DialogResult = true;
+ buttons.Children.Add(ok);
+ buttons.Children.Add(cancel);
+ root.Children.Add(buttons);
+ Content = root;
+ Loaded += (s, e) =>
+ {
+ if (_pw != null) _pw.Focus();
+ else { _text.Focus(); _text.SelectAll(); }
+ };
+ }
+
+ private string Value => _password ? _pw.Password : _text.Text.Trim();
+
+ /// 입력값 (취소하면 null)
+ public static string Ask(Window owner, string title, string message, string initial = "", bool password = false, bool upper = false)
+ {
+ var w = new TextPrompt(title, message, initial, password, upper);
+ if (owner != null && owner.IsVisible) w.Owner = owner;
+ return w.ShowDialog() == true ? w.Value : null;
+ }
+ }
+
+ /// 중계 서버 비밀번호 변경 (지금 비밀번호 + 새 비밀번호 두 번)
+ public sealed class ChangePasswordWindow : Window
+ {
+ private readonly PasswordBox _old, _new1, _new2;
+ private readonly TextBlock _msg;
+
+ public string OldPassword => _old.Password;
+ public string NewPassword => _new1.Password;
+
+ private ChangePasswordWindow(string message, bool askOld)
+ {
+ Title = "중계 서버 비밀번호 변경" + AppInstance.TitleTag;
+ Width = 440;
+ SizeToContent = SizeToContent.Height;
+ ResizeMode = ResizeMode.NoResize;
+ WindowStartupLocation = WindowStartupLocation.CenterOwner;
+ ShowInTaskbar = false;
+ FontFamily = new FontFamily("Malgun Gothic");
+ FontSize = 13;
+ AppIcon.Apply(this);
+
+ var root = new StackPanel { Margin = new Thickness(18) };
+ root.Children.Add(new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 10) });
+ var grid = new Grid();
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(120) });
+ grid.ColumnDefinitions.Add(new ColumnDefinition());
+ _old = new PasswordBox { Height = 28, Margin = new Thickness(0, 3, 0, 3) };
+ _new1 = new PasswordBox { Height = 28, Margin = new Thickness(0, 3, 0, 3) };
+ _new2 = new PasswordBox { Height = 28, Margin = new Thickness(0, 3, 0, 3) };
+ int row = 0;
+ void Add(string label, Control c)
+ {
+ grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
+ var t = new TextBlock { Text = label, VerticalAlignment = VerticalAlignment.Center };
+ Grid.SetRow(t, row); Grid.SetRow(c, row); Grid.SetColumn(c, 1);
+ grid.Children.Add(t); grid.Children.Add(c);
+ row++;
+ }
+ if (askOld) Add("지금 비밀번호", _old);
+ Add("새 비밀번호", _new1);
+ Add("새 비밀번호 확인", _new2);
+ root.Children.Add(grid);
+ _msg = new TextBlock { Foreground = Brushes2.Stopped, Margin = new Thickness(0, 8, 0, 0), TextWrapping = TextWrapping.Wrap, Text = "8자 이상" };
+ root.Children.Add(_msg);
+ var buttons = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 12, 0, 0) };
+ var ok = new Button { Content = "변경", MinWidth = 80, IsDefault = true, FontWeight = FontWeights.Bold };
+ var cancel = new Button { Content = "취소", MinWidth = 80, IsCancel = true, Margin = new Thickness(6, 0, 0, 0) };
+ ok.Click += (s, e) =>
+ {
+ if (askOld && _old.Password.Length == 0) { _msg.Text = "지금 비밀번호를 입력하세요."; return; }
+ if (_new1.Password.Length < 8) { _msg.Text = "새 비밀번호는 8자 이상이어야 합니다."; return; }
+ if (_new1.Password != _new2.Password) { _msg.Text = "새 비밀번호와 확인이 다릅니다."; return; }
+ if (askOld && _new1.Password == _old.Password) { _msg.Text = "지금과 다른 비밀번호를 입력하세요."; return; }
+ DialogResult = true;
+ };
+ buttons.Children.Add(ok);
+ buttons.Children.Add(cancel);
+ root.Children.Add(buttons);
+ Content = root;
+ Loaded += (s, e) => (askOld ? _old : _new1).Focus();
+ }
+
+ /// (지금 비밀번호, 새 비밀번호) 또는 취소하면 null. askOld=false면 지금 비밀번호는 빈 값
+ public static (string oldPw, string newPw)? Ask(Window owner, string message, bool askOld = true)
+ {
+ var w = new ChangePasswordWindow(message, askOld);
+ if (owner != null && owner.IsVisible) w.Owner = owner;
+ return w.ShowDialog() == true ? (w.OldPassword, w.NewPassword) : null;
+ }
+ }
+}
diff --git a/Windows/ServerAdminPanel.cs b/Windows/ServerAdminPanel.cs
new file mode 100644
index 0000000..4da4a2f
--- /dev/null
+++ b/Windows/ServerAdminPanel.cs
@@ -0,0 +1,890 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Text;
+using System.Text.Json.Nodes;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
+using System.Windows.Data;
+using System.Windows.Media;
+using JJServiceManager.Remote;
+
+namespace JJServiceManager
+{
+ ///
+ /// 설정 → [서버 관리] 탭 (중계 서버 관리자 계정으로 로그인했을 때만).
+ /// 계정 / 권한 / 기기 / 등록 코드 / 감사 로그 / 연결 기록
+ ///
+ public sealed class ServerAdminPanel : DockPanel
+ {
+ private readonly RelayManager _relay;
+ private readonly Window _owner;
+ private readonly TextBlock _info, _status;
+
+ // 계정
+ private readonly ObservableCollection _users = new ObservableCollection();
+ private readonly ListView _userList;
+ private readonly TextBox _newUser, _newNote;
+ private readonly PasswordBox _newPw;
+ private readonly ComboBox _newRole;
+
+ // 권한
+ private readonly ComboBox _permUser;
+ private readonly ObservableCollection _perms = new ObservableCollection();
+ private readonly TextBlock _permNote;
+ private bool _permLoading;
+
+ // 기기
+ private readonly ObservableCollection _devices = new ObservableCollection();
+ private readonly ListView _deviceList;
+
+ // 등록 코드
+ private readonly ObservableCollection _codes = new ObservableCollection();
+ private readonly ListView _codeList;
+ private readonly ComboBox _codeHours;
+ private readonly TextBox _codeNote, _codeShown;
+
+ // 감사 로그 / 연결 기록
+ private readonly ObservableCollection _audit = new ObservableCollection();
+ private readonly ObservableCollection _conns = new ObservableCollection();
+ private readonly DatePicker _aFrom, _aTo, _cFrom, _cTo;
+ private readonly ComboBox _aUser, _aDevice, _aAction, _cUser, _cDevice;
+ private long? _auditLastId, _connLastId;
+
+ public ServerAdminPanel(RelayManager relay, Window owner)
+ {
+ _relay = relay;
+ _owner = owner;
+ Background = Brushes.White;
+
+ // 위: 서버 정보 + 상태
+ var top = new DockPanel { Margin = new Thickness(10, 8, 10, 4) };
+ var refreshAll = Btn("모두 새로고침", async () => await LoadAllAsync());
+ DockPanel.SetDock(refreshAll, Dock.Right);
+ top.Children.Add(refreshAll);
+ _info = new TextBlock { Foreground = Brushes2.Make("#37474F"), VerticalAlignment = VerticalAlignment.Center, TextWrapping = TextWrapping.Wrap };
+ top.Children.Add(_info);
+ DockPanel.SetDock(top, Dock.Top);
+ Children.Add(top);
+
+ _status = new TextBlock { Margin = new Thickness(10, 4, 10, 6), TextWrapping = TextWrapping.Wrap };
+ DockPanel.SetDock(_status, Dock.Bottom);
+ Children.Add(_status);
+
+ var tabs = new TabControl { Margin = new Thickness(6, 0, 6, 0) };
+ Children.Add(tabs);
+
+ // ───────── 계정 ─────────
+ {
+ var p = new DockPanel { Margin = new Thickness(8) };
+ var form = new WrapPanel { Margin = new Thickness(0, 8, 0, 0) };
+ _newUser = new TextBox { Width = 130, Height = 26, VerticalContentAlignment = VerticalAlignment.Center };
+ _newPw = new PasswordBox { Width = 130, Height = 26, VerticalContentAlignment = VerticalAlignment.Center };
+ _newRole = new ComboBox { Width = 100, Height = 26, ItemsSource = new[] { "일반 사용자", "관리자" }, SelectedIndex = 0 };
+ _newNote = new TextBox { Width = 140, Height = 26, VerticalContentAlignment = VerticalAlignment.Center };
+ form.Children.Add(Label("아이디")); form.Children.Add(_newUser);
+ form.Children.Add(Label("처음 비밀번호")); form.Children.Add(_newPw);
+ form.Children.Add(Label("역할")); form.Children.Add(_newRole);
+ form.Children.Add(Label("메모")); form.Children.Add(_newNote);
+ form.Children.Add(Btn("추가", AddUserAsync, bold: true));
+ DockPanel.SetDock(form, Dock.Bottom);
+ p.Children.Add(form);
+
+ var buttons = new WrapPanel { Margin = new Thickness(0, 6, 0, 0) };
+ buttons.Children.Add(Btn("새로고침", LoadUsersAsync));
+ buttons.Children.Add(Btn("비밀번호 초기화", ResetPasswordAsync));
+ buttons.Children.Add(Btn("역할 바꾸기", ToggleRoleAsync));
+ buttons.Children.Add(Btn("사용 / 사용 안 함", ToggleUserAsync));
+ buttons.Children.Add(Btn("메모", EditUserNoteAsync));
+ buttons.Children.Add(Note("새 계정은 첫 로그인 때 비밀번호를 바꿔야 합니다. 계정은 지우지 않고 '사용 안 함'으로 합니다 (감사 로그 보존)."));
+ DockPanel.SetDock(buttons, Dock.Bottom);
+ p.Children.Add(buttons);
+
+ _userList = Table(_users,
+ ("아이디", nameof(UserRowItem.Username), 130),
+ ("역할", nameof(UserRowItem.RoleText), 80),
+ ("상태", nameof(UserRowItem.StateText), 110),
+ ("마지막 로그인", nameof(UserRowItem.LastLogin), 140),
+ ("만든 때", nameof(UserRowItem.Created), 140),
+ ("메모", nameof(UserRowItem.Note), 160));
+ p.Children.Add(_userList);
+ tabs.Items.Add(new TabItem { Header = " 계정 ", Content = p });
+ }
+
+ // ───────── 권한 ─────────
+ {
+ var p = new DockPanel { Margin = new Thickness(8) };
+ var head = new DockPanel { Margin = new Thickness(0, 0, 0, 6) };
+ head.Children.Add(Label("사용자"));
+ _permUser = new ComboBox { Width = 200, Height = 26, DisplayMemberPath = nameof(UserRowItem.Username) };
+ _permUser.SelectionChanged += async (s, e) => await LoadPermsAsync();
+ head.Children.Add(_permUser);
+ _permNote = Note("체크하면 바로 저장됩니다. 서비스 제어·화면 권한을 주면 보기 권한은 자동으로 함께 켜집니다.");
+ _permNote.Margin = new Thickness(12, 0, 0, 0);
+ head.Children.Add(_permNote);
+ DockPanel.SetDock(head, Dock.Top);
+ p.Children.Add(head);
+
+ var list = new ListView { ItemsSource = _perms };
+ var gv = new GridView();
+ gv.Columns.Add(new GridViewColumn { Header = "기기", Width = 170, DisplayMemberBinding = new Binding(nameof(PermRowItem.Name)) });
+ gv.Columns.Add(new GridViewColumn { Header = "hostname", Width = 130, DisplayMemberBinding = new Binding(nameof(PermRowItem.Hostname)) });
+ gv.Columns.Add(CheckColumn("보기", nameof(PermRowItem.View)));
+ gv.Columns.Add(CheckColumn("서비스 제어", nameof(PermRowItem.Control)));
+ gv.Columns.Add(CheckColumn("화면 보기", nameof(PermRowItem.ScreenView)));
+ gv.Columns.Add(CheckColumn("화면 제어", nameof(PermRowItem.ScreenControl)));
+ list.View = gv;
+ list.AddHandler(ButtonBase.ClickEvent, new RoutedEventHandler(async (s, e) =>
+ {
+ if (e.OriginalSource is CheckBox cb && cb.DataContext is PermRowItem row) await SavePermAsync(row);
+ }));
+ p.Children.Add(list);
+ tabs.Items.Add(new TabItem { Header = " 권한 ", Content = p });
+ }
+
+ // ───────── 기기 ─────────
+ {
+ var p = new DockPanel { Margin = new Thickness(8) };
+ var buttons = new WrapPanel { Margin = new Thickness(0, 6, 0, 0) };
+ buttons.Children.Add(Btn("새로고침", LoadDevicesAsync));
+ buttons.Children.Add(Btn("이름 바꾸기", RenameDeviceAsync));
+ buttons.Children.Add(Btn("메모", EditDeviceNoteAsync));
+ buttons.Children.Add(Btn("사용 안 함", () => SetDeviceEnabledAsync(false)));
+ buttons.Children.Add(Btn("사용", () => SetDeviceEnabledAsync(true)));
+ buttons.Children.Add(Btn("삭제", DeleteDeviceAsync));
+ buttons.Children.Add(Note("'사용 안 함'은 바로 접속이 끊기고, 다시 '사용'하면 그대로 접속됩니다. '삭제'하면 그 PC는 새 등록 코드로 다시 등록해야 합니다."));
+ DockPanel.SetDock(buttons, Dock.Bottom);
+ p.Children.Add(buttons);
+ _deviceList = Table(_devices,
+ ("이름", nameof(DeviceRowItem.Name), 140),
+ ("hostname", nameof(DeviceRowItem.Hostname), 110),
+ ("상태", nameof(DeviceRowItem.StateText), 90),
+ ("외부 IP", nameof(DeviceRowItem.PublicIp), 110),
+ ("내부 IP", nameof(DeviceRowItem.LanIps), 130),
+ ("Active 수신", nameof(DeviceRowItem.ActiveText), 100),
+ ("버전", nameof(DeviceRowItem.Version), 60),
+ ("마지막 접속", nameof(DeviceRowItem.LastSeen), 140),
+ ("등록", nameof(DeviceRowItem.Enrolled), 140),
+ ("메모", nameof(DeviceRowItem.Note), 140));
+ p.Children.Add(_deviceList);
+ tabs.Items.Add(new TabItem { Header = " 기기 ", Content = p });
+ }
+
+ // ───────── 등록 코드 ─────────
+ {
+ var p = new DockPanel { Margin = new Thickness(8) };
+ var form = new WrapPanel { Margin = new Thickness(0, 0, 0, 6) };
+ _codeHours = new ComboBox { Width = 90, Height = 26, ItemsSource = new[] { "1시간", "24시간", "7일", "30일" }, SelectedIndex = 2 };
+ _codeNote = new TextBox { Width = 200, Height = 26, VerticalContentAlignment = VerticalAlignment.Center };
+ form.Children.Add(Label("유효 시간")); form.Children.Add(_codeHours);
+ form.Children.Add(Label("메모 (어느 PC용)")); form.Children.Add(_codeNote);
+ form.Children.Add(Btn("새 코드 발급", CreateCodeAsync, bold: true));
+ DockPanel.SetDock(form, Dock.Top);
+ p.Children.Add(form);
+
+ _codeShown = new TextBox
+ {
+ IsReadOnly = true, FontFamily = new FontFamily("Consolas"), FontSize = 22, FontWeight = FontWeights.Bold,
+ Height = 40, VerticalContentAlignment = VerticalAlignment.Center, HorizontalContentAlignment = HorizontalAlignment.Center,
+ Background = Brushes2.Make("#FFF8E1"), Visibility = Visibility.Collapsed, Margin = new Thickness(0, 0, 0, 6),
+ ToolTip = "대상 PC의 설정 → 원격 접속 허용 → 중계 서버 접속 → [등록 코드로 등록]에 입력하세요. 이 코드는 다시 볼 수 없습니다."
+ };
+ DockPanel.SetDock(_codeShown, Dock.Top);
+ p.Children.Add(_codeShown);
+
+ var buttons = new WrapPanel { Margin = new Thickness(0, 6, 0, 0) };
+ buttons.Children.Add(Btn("새로고침", LoadCodesAsync));
+ buttons.Children.Add(Btn("선택한 코드 취소", RevokeCodeAsync));
+ buttons.Children.Add(Note("코드는 한 번만 쓸 수 있고, 발급할 때만 보입니다."));
+ DockPanel.SetDock(buttons, Dock.Bottom);
+ p.Children.Add(buttons);
+ _codeList = Table(_codes,
+ ("상태", nameof(CodeRowItem.StateText), 80),
+ ("만든 사람", nameof(CodeRowItem.CreatedBy), 100),
+ ("만든 때", nameof(CodeRowItem.Created), 140),
+ ("만료", nameof(CodeRowItem.Expires), 140),
+ ("사용한 기기", nameof(CodeRowItem.UsedDevice), 130),
+ ("사용한 때", nameof(CodeRowItem.Used), 140),
+ ("메모", nameof(CodeRowItem.Note), 160));
+ p.Children.Add(_codeList);
+ tabs.Items.Add(new TabItem { Header = " 등록 코드 ", Content = p });
+ }
+
+ // ───────── 감사 로그 ─────────
+ {
+ var p = new DockPanel { Margin = new Thickness(8) };
+ var f = new WrapPanel { Margin = new Thickness(0, 0, 0, 6) };
+ _aFrom = new DatePicker { Width = 120, SelectedDate = DateTime.Today.AddDays(-7) };
+ _aTo = new DatePicker { Width = 120, SelectedDate = DateTime.Today };
+ _aUser = new ComboBox { Width = 120, Height = 26, DisplayMemberPath = "Text" };
+ _aDevice = new ComboBox { Width = 140, Height = 26, DisplayMemberPath = "Text" };
+ _aAction = new ComboBox { Width = 130, Height = 26, DisplayMemberPath = "Text", ItemsSource = AuditActions(), SelectedIndex = 0 };
+ f.Children.Add(Label("기간")); f.Children.Add(_aFrom); f.Children.Add(Label("~")); f.Children.Add(_aTo);
+ f.Children.Add(Label("사용자")); f.Children.Add(_aUser);
+ f.Children.Add(Label("기기")); f.Children.Add(_aDevice);
+ f.Children.Add(Label("작업")); f.Children.Add(_aAction);
+ f.Children.Add(Btn("조회", () => QueryAuditAsync(false), bold: true));
+ f.Children.Add(Btn("더 보기", () => QueryAuditAsync(true)));
+ f.Children.Add(Btn("복사", () => { Copy(_audit.Select(a => a.ToLine())); return Task.CompletedTask; }));
+ DockPanel.SetDock(f, Dock.Top);
+ p.Children.Add(f);
+ p.Children.Add(Table(_audit,
+ ("시각", nameof(AuditRowItem.At), 140),
+ ("사용자", nameof(AuditRowItem.User), 90),
+ ("기기", nameof(AuditRowItem.Device), 120),
+ ("작업", nameof(AuditRowItem.ActionText), 110),
+ ("대상", nameof(AuditRowItem.Target), 120),
+ ("결과", nameof(AuditRowItem.ResultText), 60),
+ ("내용", nameof(AuditRowItem.Detail), 220),
+ ("IP", nameof(AuditRowItem.Ip), 110)));
+ tabs.Items.Add(new TabItem { Header = " 감사 로그 ", Content = p });
+ }
+
+ // ───────── 연결 기록 ─────────
+ {
+ var p = new DockPanel { Margin = new Thickness(8) };
+ var f = new WrapPanel { Margin = new Thickness(0, 0, 0, 6) };
+ _cFrom = new DatePicker { Width = 120, SelectedDate = DateTime.Today.AddDays(-7) };
+ _cTo = new DatePicker { Width = 120, SelectedDate = DateTime.Today };
+ _cUser = new ComboBox { Width = 120, Height = 26, DisplayMemberPath = "Text" };
+ _cDevice = new ComboBox { Width = 140, Height = 26, DisplayMemberPath = "Text" };
+ f.Children.Add(Label("기간")); f.Children.Add(_cFrom); f.Children.Add(Label("~")); f.Children.Add(_cTo);
+ f.Children.Add(Label("사용자")); f.Children.Add(_cUser);
+ f.Children.Add(Label("기기")); f.Children.Add(_cDevice);
+ f.Children.Add(Btn("조회", () => QueryConnsAsync(false), bold: true));
+ f.Children.Add(Btn("더 보기", () => QueryConnsAsync(true)));
+ f.Children.Add(Btn("복사", () => { Copy(_conns.Select(c => c.ToLine())); return Task.CompletedTask; }));
+ DockPanel.SetDock(f, Dock.Top);
+ p.Children.Add(f);
+ p.Children.Add(Table(_conns,
+ ("시작", nameof(ConnRowItem.Started), 140),
+ ("끝", nameof(ConnRowItem.Ended), 140),
+ ("사용자", nameof(ConnRowItem.User), 90),
+ ("기기", nameof(ConnRowItem.Device), 120),
+ ("방식", nameof(ConnRowItem.ModeText), 60),
+ ("보냄", nameof(ConnRowItem.ToDevice), 80),
+ ("받음", nameof(ConnRowItem.FromDevice), 80),
+ ("끝난 이유", nameof(ConnRowItem.Reason), 200),
+ ("관리 PC IP", nameof(ConnRowItem.Ip), 110)));
+ tabs.Items.Add(new TabItem { Header = " 연결 기록 ", Content = p });
+ }
+
+ Loaded += async (s, e) => { if (_users.Count == 0) await LoadAllAsync(); };
+ }
+
+ // ───────────────────────── 불러오기 ─────────────────────────
+
+ private async Task LoadAllAsync()
+ {
+ await Run(async () =>
+ {
+ var i = await _relay.RequestAsync("admin.server.info");
+ _info.Text = $"중계 서버 {Json.Str(i, "version")} · DB {(Json.Bool(i, "db") ? "정상" : "오류")} · 접속 중: 기기 {Json.Int(i, "onlineDevices")}대, 관리 PC {Json.Int(i, "onlineManagers")}곳"
+ + $" · 시작 {Local(Json.Str(i, "startedAt"))} · 로그인: {_relay.Username}";
+ });
+ await LoadUsersAsync();
+ await LoadDevicesAsync();
+ await LoadCodesAsync();
+ await QueryAuditAsync(false);
+ await QueryConnsAsync(false);
+ }
+
+ private async Task LoadUsersAsync()
+ {
+ await Run(async () =>
+ {
+ var r = await _relay.RequestAsync("admin.users.list");
+ var keep = (_permUser.SelectedItem as UserRowItem)?.Id;
+ _users.Clear();
+ foreach (var o in (r["users"] as JsonArray).OfType()) _users.Add(UserRowItem.From(o));
+ _permUser.ItemsSource = _users.Where(u => !u.IsAdmin).ToList();
+ var sel = ((IEnumerable)_permUser.ItemsSource).FirstOrDefault(u => u.Id == keep)
+ ?? ((IEnumerable)_permUser.ItemsSource).FirstOrDefault();
+ _permUser.SelectedItem = sel;
+ if (sel == null) { _perms.Clear(); _permNote.Text = "일반 사용자가 없습니다. [계정]에서 추가하세요. (관리자는 모든 기기에 모든 권한)"; }
+ FillFilters();
+ });
+ }
+
+ private async Task LoadDevicesAsync()
+ {
+ await Run(async () =>
+ {
+ var r = await _relay.RequestAsync("admin.devices.list");
+ _devices.Clear();
+ foreach (var o in (r["devices"] as JsonArray).OfType()) _devices.Add(DeviceRowItem.From(o));
+ FillFilters();
+ });
+ }
+
+ private async Task LoadCodesAsync()
+ {
+ await Run(async () =>
+ {
+ var r = await _relay.RequestAsync("admin.codes.list");
+ _codes.Clear();
+ foreach (var o in (r["codes"] as JsonArray).OfType()) _codes.Add(CodeRowItem.From(o));
+ });
+ }
+
+ private void FillFilters()
+ {
+ var users = new List { new Pick("(전체)", null) };
+ users.AddRange(_users.Select(u => new Pick(u.Username, u.Id.ToString())));
+ var devices = new List { new Pick("(전체)", null) };
+ devices.AddRange(_devices.Select(d => new Pick(d.Name, d.Id)));
+ foreach (var cb in new[] { _aUser, _cUser }) { var k = (cb.SelectedItem as Pick)?.Value; cb.ItemsSource = users; cb.SelectedItem = users.FirstOrDefault(x => x.Value == k) ?? users[0]; }
+ foreach (var cb in new[] { _aDevice, _cDevice }) { var k = (cb.SelectedItem as Pick)?.Value; cb.ItemsSource = devices; cb.SelectedItem = devices.FirstOrDefault(x => x.Value == k) ?? devices[0]; }
+ }
+
+ // ───────────────────────── 계정 ─────────────────────────
+
+ private UserRowItem SelectedUser()
+ {
+ if (_userList.SelectedItem is UserRowItem u) return u;
+ Say("목록에서 계정을 고르세요.", false);
+ return null;
+ }
+
+ private async Task AddUserAsync()
+ {
+ var name = _newUser.Text.Trim();
+ if (name.Length == 0) { Say("아이디를 입력하세요.", false); return; }
+ if (_newPw.Password.Length < 8) { Say("처음 비밀번호는 8자 이상이어야 합니다.", false); return; }
+ bool asAdmin = _newRole.SelectedIndex == 1;
+ await Run(async () =>
+ {
+ await _relay.RequestAsync("admin.users.create", new JsonObject
+ {
+ ["username"] = name,
+ ["password"] = _newPw.Password,
+ ["role"] = asAdmin ? "admin" : "user",
+ ["note"] = _newNote.Text.Trim(),
+ ["mustChangePassword"] = true
+ });
+ _newUser.Text = ""; _newPw.Password = ""; _newNote.Text = ""; _newRole.SelectedIndex = 0;
+ Say($"계정 {name} 을(를) 만들었습니다. 첫 로그인 때 비밀번호를 바꾸게 됩니다." +
+ (asAdmin ? "" : " [권한] 탭에서 볼 수 있는 기기를 정하세요."), true);
+ await LoadUsersAsync();
+ });
+ }
+
+ private async Task ResetPasswordAsync()
+ {
+ var u = SelectedUser();
+ if (u == null) return;
+ var pw = TextPrompt.Ask(_owner, "비밀번호 초기화", $"{u.Username} 의 새 비밀번호 (8자 이상)\n다음 로그인 때 다시 바꾸게 되며, 접속 중인 연결은 끊깁니다.", "", password: true);
+ if (string.IsNullOrEmpty(pw)) return;
+ await Run(async () =>
+ {
+ await _relay.RequestAsync("admin.users.resetPassword", new JsonObject { ["userId"] = u.Id, ["password"] = pw });
+ Say($"{u.Username} 의 비밀번호를 초기화했습니다.", true);
+ await LoadUsersAsync();
+ });
+ }
+
+ private async Task ToggleRoleAsync()
+ {
+ var u = SelectedUser();
+ if (u == null) return;
+ var to = u.IsAdmin ? "user" : "admin";
+ if (!Dialogs.Confirm(_owner, $"{u.Username} 을(를) {(to == "admin" ? "관리자" : "일반 사용자")}로 바꿀까요?", "역할 바꾸기")) return;
+ await Run(async () =>
+ {
+ await _relay.RequestAsync("admin.users.update", new JsonObject { ["userId"] = u.Id, ["role"] = to });
+ Say("역할을 바꿨습니다.", true);
+ await LoadUsersAsync();
+ });
+ }
+
+ private async Task ToggleUserAsync()
+ {
+ var u = SelectedUser();
+ if (u == null) return;
+ bool to = !u.Enabled;
+ if (!to && !Dialogs.Confirm(_owner, $"{u.Username} 을(를) 사용 안 함으로 할까요?\n접속 중인 연결은 바로 끊깁니다.", "사용 안 함")) return;
+ await Run(async () =>
+ {
+ await _relay.RequestAsync("admin.users.update", new JsonObject { ["userId"] = u.Id, ["enabled"] = to });
+ Say(to ? "다시 사용하도록 했습니다." : "사용 안 함으로 했습니다.", true);
+ await LoadUsersAsync();
+ });
+ }
+
+ private async Task EditUserNoteAsync()
+ {
+ var u = SelectedUser();
+ if (u == null) return;
+ var note = TextPrompt.Ask(_owner, "메모", $"{u.Username} 메모", u.Note);
+ if (note == null) return;
+ await Run(async () =>
+ {
+ await _relay.RequestAsync("admin.users.update", new JsonObject { ["userId"] = u.Id, ["note"] = note });
+ await LoadUsersAsync();
+ });
+ }
+
+ // ───────────────────────── 권한 ─────────────────────────
+
+ private async Task LoadPermsAsync()
+ {
+ if (_permUser.SelectedItem is not UserRowItem u) { _perms.Clear(); return; }
+ await Run(async () =>
+ {
+ var r = await _relay.RequestAsync("admin.perms.get", new JsonObject { ["userId"] = u.Id });
+ _permLoading = true;
+ _perms.Clear();
+ foreach (var o in (r["devices"] as JsonArray).OfType()) _perms.Add(PermRowItem.From(o));
+ _permLoading = false;
+ _permNote.Text = _perms.Count == 0
+ ? "등록된 기기가 없습니다. [등록 코드]에서 코드를 발급해 대상 PC를 등록하세요."
+ : "체크하면 바로 저장됩니다. 서비스 제어·화면 권한을 주면 보기 권한은 자동으로 함께 켜집니다.";
+ });
+ }
+
+ private async Task SavePermAsync(PermRowItem row)
+ {
+ if (_permLoading || _permUser.SelectedItem is not UserRowItem u) return;
+ await Run(async () =>
+ {
+ var r = await _relay.RequestAsync("admin.perms.set", new JsonObject
+ {
+ ["userId"] = u.Id,
+ ["deviceId"] = row.DeviceId,
+ ["view"] = row.View,
+ ["control"] = row.Control,
+ ["screenView"] = row.ScreenView,
+ ["screenControl"] = row.ScreenControl
+ });
+ // 서버가 정리한 값으로 (예: 제어 → 보기 자동)
+ row.Set(Json.Bool(r, "view"), Json.Bool(r, "control"), Json.Bool(r, "screenView"), Json.Bool(r, "screenControl"));
+ Say($"{u.Username} → {row.Name}: {row.Summary}", true);
+ });
+ }
+
+ // ───────────────────────── 기기 ─────────────────────────
+
+ private DeviceRowItem SelectedDevice()
+ {
+ if (_deviceList.SelectedItem is DeviceRowItem d) return d;
+ Say("목록에서 기기를 고르세요.", false);
+ return null;
+ }
+
+ private async Task RenameDeviceAsync()
+ {
+ var d = SelectedDevice();
+ if (d == null) return;
+ var name = TextPrompt.Ask(_owner, "기기 이름 바꾸기", $"{d.Name} ({d.Hostname}) 의 새 이름", d.Name);
+ if (string.IsNullOrWhiteSpace(name)) return;
+ await Run(async () =>
+ {
+ await _relay.RequestAsync("admin.devices.update", new JsonObject { ["deviceId"] = d.Id, ["name"] = name.Trim() });
+ Say("이름을 바꿨습니다.", true);
+ await LoadDevicesAsync();
+ });
+ }
+
+ private async Task EditDeviceNoteAsync()
+ {
+ var d = SelectedDevice();
+ if (d == null) return;
+ var note = TextPrompt.Ask(_owner, "메모", $"{d.Name} 메모", d.Note);
+ if (note == null) return;
+ await Run(async () =>
+ {
+ await _relay.RequestAsync("admin.devices.update", new JsonObject { ["deviceId"] = d.Id, ["note"] = note });
+ await LoadDevicesAsync();
+ });
+ }
+
+ private async Task SetDeviceEnabledAsync(bool enable)
+ {
+ var d = SelectedDevice();
+ if (d == null) return;
+ if (!enable && !Dialogs.Confirm(_owner, $"{d.Name} 을(를) 사용 안 함으로 할까요?\n접속이 바로 끊기고, 다시 '사용'할 때까지 연결할 수 없습니다.", "사용 안 함")) return;
+ await Run(async () =>
+ {
+ await _relay.RequestAsync(enable ? "admin.devices.enable" : "admin.devices.disable", new JsonObject { ["deviceId"] = d.Id });
+ Say(enable ? "다시 사용하도록 했습니다." : "사용 안 함으로 했습니다.", true);
+ await LoadDevicesAsync();
+ });
+ }
+
+ private async Task DeleteDeviceAsync()
+ {
+ var d = SelectedDevice();
+ if (d == null) return;
+ if (!Dialogs.Confirm(_owner, $"{d.Name} 을(를) 서버에서 삭제할까요?\n\n그 PC는 새 등록 코드로 다시 등록해야 합니다.\n감사 로그·연결 기록은 그대로 남습니다.", "기기 삭제")) return;
+ await Run(async () =>
+ {
+ await _relay.RequestAsync("admin.devices.delete", new JsonObject { ["deviceId"] = d.Id });
+ Say("삭제했습니다.", true);
+ await LoadDevicesAsync();
+ });
+ }
+
+ // ───────────────────────── 등록 코드 ─────────────────────────
+
+ private async Task CreateCodeAsync()
+ {
+ int hours = _codeHours.SelectedIndex switch { 0 => 1, 1 => 24, 3 => 24 * 30, _ => 24 * 7 };
+ await Run(async () =>
+ {
+ var r = await _relay.RequestAsync("admin.codes.create", new JsonObject { ["validHours"] = hours, ["note"] = _codeNote.Text.Trim() });
+ _codeShown.Text = Json.Str(r, "code");
+ _codeShown.Visibility = Visibility.Visible;
+ _codeNote.Text = "";
+ Say($"등록 코드 {Json.Str(r, "code")} (만료 {Local(Json.Str(r, "expiresAt"))}). 대상 PC의 [원격 접속 허용 → 중계 서버 접속 → 등록 코드로 등록]에 입력하세요. 이 코드는 다시 볼 수 없습니다.", true);
+ await LoadCodesAsync();
+ });
+ }
+
+ private async Task RevokeCodeAsync()
+ {
+ if (_codeList.SelectedItem is not CodeRowItem c) { Say("목록에서 코드를 고르세요.", false); return; }
+ if (c.Status != "valid") { Say("사용 가능한 코드만 취소할 수 있습니다.", false); return; }
+ await Run(async () =>
+ {
+ await _relay.RequestAsync("admin.codes.revoke", new JsonObject { ["codeId"] = c.Id });
+ Say("코드를 취소했습니다.", true);
+ await LoadCodesAsync();
+ });
+ }
+
+ // ───────────────────────── 감사 로그 / 연결 기록 ─────────────────────────
+
+ private static List AuditActions() => new List
+ {
+ new Pick("(전체)", null),
+ new Pick("서비스 시작", "start"), new Pick("서비스 중지", "stop"),
+ new Pick("원격 화면 시작", "screenStart"), new Pick("원격 화면 끝", "screenEnd"),
+ new Pick("연결 요청", "connect"), new Pick("대상 PC 인증 실패", "authFail"),
+ new Pick("로그인", "login"), new Pick("로그인 실패", "loginFail"), new Pick("로그아웃", "logout"),
+ new Pick("비밀번호 변경", "passwordChange"), new Pick("비밀번호 초기화", "passwordReset"),
+ new Pick("계정 만들기", "userCreate"), new Pick("계정 변경", "userUpdate"), new Pick("권한 변경", "permChange"),
+ new Pick("등록 코드 발급", "codeCreate"), new Pick("등록 코드 취소", "codeRevoke"),
+ new Pick("기기 등록", "deviceEnroll"), new Pick("기기 변경", "deviceUpdate"),
+ new Pick("기기 사용 안 함", "deviceDisable"), new Pick("기기 사용", "deviceEnable"), new Pick("기기 삭제", "deviceDelete")
+ };
+
+ private static JsonObject Filter(DatePicker from, DatePicker to, ComboBox user, ComboBox device)
+ {
+ var o = new JsonObject { ["limit"] = 200 };
+ if (from.SelectedDate is DateTime f) o["from"] = f.Date.ToUniversalTime().ToString("o");
+ if (to.SelectedDate is DateTime t) o["to"] = t.Date.AddDays(1).ToUniversalTime().ToString("o");
+ if ((user.SelectedItem as Pick)?.Value is string u && long.TryParse(u, out var uid)) o["userId"] = uid;
+ if ((device.SelectedItem as Pick)?.Value is string d) o["deviceId"] = d;
+ return o;
+ }
+
+ private async Task QueryAuditAsync(bool more)
+ {
+ var q = Filter(_aFrom, _aTo, _aUser, _aDevice);
+ if ((_aAction.SelectedItem as Pick)?.Value is string a) q["action"] = a;
+ if (more)
+ {
+ if (_auditLastId == null) return;
+ q["beforeId"] = _auditLastId;
+ }
+ await Run(async () =>
+ {
+ var r = await _relay.RequestAsync("admin.audit.query", q);
+ if (!more) _audit.Clear();
+ var rows = (r["rows"] as JsonArray).OfType().Select(AuditRowItem.From).ToList();
+ foreach (var x in rows) _audit.Add(x);
+ _auditLastId = rows.Count > 0 ? rows[^1].Id : _auditLastId;
+ if (more || rows.Count == 0) Say(rows.Count == 0 ? "더 없습니다." : $"{rows.Count}건 더 불러옴", true);
+ });
+ }
+
+ private async Task QueryConnsAsync(bool more)
+ {
+ var q = Filter(_cFrom, _cTo, _cUser, _cDevice);
+ if (more)
+ {
+ if (_connLastId == null) return;
+ q["beforeId"] = _connLastId;
+ }
+ await Run(async () =>
+ {
+ var r = await _relay.RequestAsync("admin.connections.query", q);
+ if (!more) _conns.Clear();
+ var rows = (r["rows"] as JsonArray).OfType().Select(ConnRowItem.From).ToList();
+ foreach (var x in rows) _conns.Add(x);
+ _connLastId = rows.Count > 0 ? rows[^1].Id : _connLastId;
+ if (more) Say(rows.Count == 0 ? "더 없습니다." : $"{rows.Count}건 더 불러옴", true);
+ });
+ }
+
+ private void Copy(IEnumerable lines)
+ {
+ var text = string.Join(Environment.NewLine, lines);
+ if (text.Length == 0) { Say("복사할 내용이 없습니다.", false); return; }
+ try { Clipboard.SetText(text); Say("복사했습니다.", true); }
+ catch (Exception ex) { Say("복사 실패: " + ex.Message, false); }
+ }
+
+ // ───────────────────────── 공통 ─────────────────────────
+
+ private async Task Run(Func work)
+ {
+ try
+ {
+ await work();
+ }
+ catch (Exception ex)
+ {
+ Say(RelayUrl.Friendly(ex), false);
+ }
+ }
+
+ private void Say(string msg, bool ok)
+ {
+ _status.Text = msg;
+ _status.Foreground = ok ? Brushes2.Running : Brushes2.Stopped;
+ }
+
+ public static string Local(string iso)
+ {
+ if (string.IsNullOrEmpty(iso)) return "";
+ return DateTime.TryParse(iso, null, System.Globalization.DateTimeStyles.RoundtripKind, out var t)
+ ? t.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss") : iso;
+ }
+
+ public static string Bytes(long b) => b < 1024 ? $"{b}B" : b < 1024 * 1024 ? $"{b / 1024.0:0.0}KB" : b < 1024L * 1024 * 1024 ? $"{b / 1048576.0:0.0}MB" : $"{b / 1073741824.0:0.00}GB";
+
+ private static TextBlock Label(string text) => new TextBlock { Text = text, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(8, 0, 4, 0) };
+ private static TextBlock Note(string text) => new TextBlock { Text = text, Foreground = Brushes2.Make("#78909C"), VerticalAlignment = VerticalAlignment.Center, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(8, 0, 0, 0) };
+
+ private static Button Btn(string text, Func click, bool bold = false)
+ {
+ var b = new Button { Content = text, FontWeight = bold ? FontWeights.Bold : FontWeights.Normal, Margin = new Thickness(4, 2, 0, 2), Padding = new Thickness(10, 2, 10, 2) };
+ b.Click += async (s, e) =>
+ {
+ b.IsEnabled = false;
+ try { await click(); }
+ finally { b.IsEnabled = true; }
+ };
+ return b;
+ }
+
+ private static ListView Table(ObservableCollection items, params (string header, string path, double width)[] cols)
+ {
+ var gv = new GridView();
+ foreach (var (h, p, w) in cols)
+ gv.Columns.Add(new GridViewColumn { Header = h, Width = w, DisplayMemberBinding = new Binding(p) });
+ return new ListView { ItemsSource = items, View = gv, SelectionMode = SelectionMode.Single };
+ }
+
+ private static GridViewColumn CheckColumn(string header, string path)
+ {
+ var f = new FrameworkElementFactory(typeof(CheckBox));
+ f.SetBinding(ToggleButton.IsCheckedProperty, new Binding(path) { Mode = BindingMode.TwoWay, UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged });
+ f.SetValue(FrameworkElement.HorizontalAlignmentProperty, HorizontalAlignment.Center);
+ return new GridViewColumn { Header = header, Width = 90, CellTemplate = new DataTemplate { VisualTree = f } };
+ }
+
+ private sealed class Pick
+ {
+ public string Text { get; }
+ public string Value { get; }
+ public Pick(string text, string value) { Text = text; Value = value; }
+ }
+
+ // ───────────────────────── 표 한 줄 ─────────────────────────
+
+ private sealed class UserRowItem
+ {
+ public long Id { get; init; }
+ public string Username { get; init; }
+ public bool IsAdmin { get; init; }
+ public bool Enabled { get; init; }
+ public string RoleText => IsAdmin ? "관리자" : "일반";
+ public string StateText { get; init; }
+ public string LastLogin { get; init; }
+ public string Created { get; init; }
+ public string Note { get; init; }
+
+ public static UserRowItem From(JsonObject o)
+ {
+ bool enabled = Json.Bool(o, "enabled");
+ var state = !enabled ? "사용 안 함" : Json.Bool(o, "mustChangePassword") ? "비밀번호 변경 대기" : "사용";
+ if (Json.Bool(o, "online")) state += " · 접속 중";
+ return new UserRowItem
+ {
+ Id = o["id"]?.GetValue() ?? 0,
+ Username = Json.Str(o, "username"),
+ IsAdmin = Json.Str(o, "role") == "admin",
+ Enabled = enabled,
+ StateText = state,
+ LastLogin = Local(Json.Str(o, "lastLoginAt")),
+ Created = Local(Json.Str(o, "createdAt")),
+ Note = Json.Str(o, "note")
+ };
+ }
+ }
+
+ private sealed class PermRowItem : ObservableObject
+ {
+ private bool _view, _control, _sv, _sc;
+ public string DeviceId { get; init; }
+ public string Name { get; init; }
+ public string Hostname { get; init; }
+ public bool View { get => _view; set => Set(ref _view, value); }
+ public bool Control { get => _control; set => Set(ref _control, value); }
+ public bool ScreenView { get => _sv; set => Set(ref _sv, value); }
+ public bool ScreenControl { get => _sc; set => Set(ref _sc, value); }
+
+ public void Set(bool v, bool c, bool sv, bool sc) { View = v; Control = c; ScreenView = sv; ScreenControl = sc; }
+
+ public string Summary
+ {
+ get
+ {
+ var p = new List();
+ if (View) p.Add("보기");
+ if (Control) p.Add("서비스 제어");
+ if (ScreenView) p.Add("화면 보기");
+ if (ScreenControl) p.Add("화면 제어");
+ return p.Count == 0 ? "권한 없음 (목록에 안 보임)" : string.Join(", ", p);
+ }
+ }
+
+ public static PermRowItem From(JsonObject o) => new PermRowItem
+ {
+ DeviceId = Json.Str(o, "deviceId"),
+ Name = Json.Str(o, "name") + (Json.Bool(o, "enabled") ? "" : " (사용 안 함)"),
+ Hostname = Json.Str(o, "hostname"),
+ _view = Json.Bool(o, "view"),
+ _control = Json.Bool(o, "control"),
+ _sv = Json.Bool(o, "screenView"),
+ _sc = Json.Bool(o, "screenControl")
+ };
+ }
+
+ private sealed class DeviceRowItem
+ {
+ public string Id { get; init; }
+ public string Name { get; init; }
+ public string Hostname { get; init; }
+ public string StateText { get; init; }
+ public string PublicIp { get; init; }
+ public string LanIps { get; init; }
+ public string ActiveText { get; init; }
+ public string Version { get; init; }
+ public string LastSeen { get; init; }
+ public string Enrolled { get; init; }
+ public string Note { get; init; }
+
+ public static DeviceRowItem From(JsonObject o)
+ {
+ var al = o["activeListen"] as JsonObject;
+ var lan = (o["lanIps"] as JsonArray)?.Select(x => x?.ToString()).Where(x => !string.IsNullOrEmpty(x)) ?? Enumerable.Empty();
+ return new DeviceRowItem
+ {
+ Id = Json.Str(o, "id"),
+ Name = Json.Str(o, "name"),
+ Hostname = Json.Str(o, "hostname"),
+ StateText = !Json.Bool(o, "enabled") ? "사용 안 함" : Json.Bool(o, "online") ? "● 온라인" : "오프라인",
+ PublicIp = Json.Str(o, "publicIp"),
+ LanIps = string.Join(", ", lan),
+ ActiveText = al == null ? "꺼짐" : $"{(Json.Str(al, "mode") == "Ssh" ? "SSH" : "TLS")} {Json.Int(al, "port")}",
+ Version = Json.Str(o, "appVersion"),
+ LastSeen = Local(Json.Str(o, "lastSeenAt")),
+ Enrolled = Local(Json.Str(o, "enrolledAt")),
+ Note = Json.Str(o, "note")
+ };
+ }
+ }
+
+ private sealed class CodeRowItem
+ {
+ public long Id { get; init; }
+ public string Status { get; init; }
+ public string StateText => Status switch { "valid" => "사용 가능", "used" => "사용됨", "revoked" => "취소됨", _ => "만료됨" };
+ public string CreatedBy { get; init; }
+ public string Created { get; init; }
+ public string Expires { get; init; }
+ public string Used { get; init; }
+ public string UsedDevice { get; init; }
+ public string Note { get; init; }
+
+ public static CodeRowItem From(JsonObject o) => new CodeRowItem
+ {
+ Id = o["id"]?.GetValue() ?? 0,
+ Status = Json.Str(o, "status"),
+ CreatedBy = Json.Str(o, "createdBy"),
+ Created = Local(Json.Str(o, "createdAt")),
+ Expires = Local(Json.Str(o, "expiresAt")),
+ Used = Local(Json.Str(o, "usedAt")),
+ UsedDevice = Json.Str(o, "usedDevice"),
+ Note = Json.Str(o, "note")
+ };
+ }
+
+ private sealed class AuditRowItem
+ {
+ public long Id { get; init; }
+ public string At { get; init; }
+ public string User { get; init; }
+ public string Device { get; init; }
+ public string Action { get; init; }
+ public string ActionText => AuditActions().FirstOrDefault(a => a.Value == Action)?.Text ?? Action;
+ public string Target { get; init; }
+ public string Result { get; init; }
+ public string ResultText => Result switch { "ok" => "성공", "fail" => "실패", "denied" => "거부", _ => Result };
+ public string Detail { get; init; }
+ public string Ip { get; init; }
+
+ public string ToLine() => $"{At}\t{User}\t{Device}\t{ActionText}\t{Target}\t{ResultText}\t{Detail}\t{Ip}";
+
+ public static AuditRowItem From(JsonObject o) => new AuditRowItem
+ {
+ Id = o["id"]?.GetValue() ?? 0,
+ At = Local(Json.Str(o, "at")),
+ User = Json.Str(o, "username"),
+ Device = Json.Str(o, "deviceName"),
+ Action = Json.Str(o, "action"),
+ Target = Json.Str(o, "target"),
+ Result = Json.Str(o, "result"),
+ Detail = Json.Str(o, "detail"),
+ Ip = Json.Str(o, "ip")
+ };
+ }
+
+ private sealed class ConnRowItem
+ {
+ public long Id { get; init; }
+ public string Started { get; init; }
+ public string Ended { get; init; }
+ public string User { get; init; }
+ public string Device { get; init; }
+ public string Mode { get; init; }
+ public string ModeText => Mode switch { "direct" => "직접", "relay" => "중계", "failed" => "실패", "" => "진행 중", _ => Mode };
+ public string ToDevice { get; init; }
+ public string FromDevice { get; init; }
+ public string Reason { get; init; }
+ public string Ip { get; init; }
+
+ public string ToLine() => $"{Started}\t{Ended}\t{User}\t{Device}\t{ModeText}\t{ToDevice}\t{FromDevice}\t{Reason}\t{Ip}";
+
+ public static ConnRowItem From(JsonObject o) => new ConnRowItem
+ {
+ Id = o["id"]?.GetValue() ?? 0,
+ Started = Local(Json.Str(o, "startedAt")),
+ Ended = Local(Json.Str(o, "endedAt")),
+ User = Json.Str(o, "username"),
+ Device = Json.Str(o, "deviceName"),
+ Mode = Json.Str(o, "mode"),
+ ToDevice = Bytes(o["bytesToDevice"]?.GetValue() ?? 0),
+ FromDevice = Bytes(o["bytesFromDevice"]?.GetValue() ?? 0),
+ Reason = Json.Str(o, "endReason"),
+ Ip = Json.Str(o, "managerIp")
+ };
+ }
+ }
+}
diff --git a/Windows/ServiceRow.cs b/Windows/ServiceRow.cs
index 1d52230..dac5fdb 100644
--- a/Windows/ServiceRow.cs
+++ b/Windows/ServiceRow.cs
@@ -52,7 +52,11 @@ namespace JJServiceManager
/// 원격 PC 제목 줄의 [원격관리] 버튼
public Visibility RemoteButtonVisibility => IsLocal ? Visibility.Collapsed : Visibility.Visible;
- public Brush HeaderBrush => IsLocal ? Brushes2.Make("#E3F2FD") : Brushes2.Make("#F3E5F5");
+ /// 중계 기기 제목 줄의 [비밀번호] 버튼 (대상 PC 접속 비밀번호는 기기마다 입력)
+ public bool IsRelay => Id.StartsWith("relay-", System.StringComparison.OrdinalIgnoreCase);
+ public Visibility PasswordButtonVisibility => IsRelay ? Visibility.Visible : Visibility.Collapsed;
+
+ public Brush HeaderBrush => IsLocal ? Brushes2.Make("#E3F2FD") : IsRelay ? Brushes2.Make("#E8F5E9") : Brushes2.Make("#F3E5F5");
public HostGroup()
{
diff --git a/Windows/SettingsWindow.cs b/Windows/SettingsWindow.cs
index 7a167ec..19b88b0 100644
--- a/Windows/SettingsWindow.cs
+++ b/Windows/SettingsWindow.cs
@@ -52,6 +52,22 @@ namespace JJServiceManager
private readonly Button _btnTest;
private readonly ObservableCollection _items = new ObservableCollection();
+ // 중계 서버 (대상 PC: 등록)
+ private readonly CheckBox _relayAgent;
+ private readonly TextBox _relayAgentUrl;
+ private readonly TextBlock _relayAgentState;
+ private string _relayDeviceId, _relayKeyProt, _relayPub, _relayDeviceName;
+
+ // 중계 서버 (관리 PC: 로그인)
+ private readonly RelayManager _relay;
+ private readonly CheckBox _relayManage, _relaySavePw;
+ private readonly TextBox _relayManageUrl, _relayUser;
+ private readonly PasswordBox _relayPw;
+ private readonly TextBlock _relayLoginState;
+ private readonly Button _btnRelayLogin;
+ private string _relayTokenProt, _relayPwProt;
+ private string _loggedUrl, _loggedUser; // 이 토큰을 받은 주소·아이디
+
/// 목록 표시용
private sealed class TargetItem : ObservableObject
{
@@ -68,9 +84,10 @@ namespace JJServiceManager
}
}
- public SettingsWindow(AppSettings current)
+ public SettingsWindow(AppSettings current, RelayManager relay = null)
{
_s = current;
+ _relay = relay;
Title = "설정" + AppInstance.TitleTag;
Width = 800;
@@ -223,6 +240,48 @@ namespace JJServiceManager
root.Find