diff --git a/Core/ConsoleRunner.cs b/Core/ConsoleRunner.cs new file mode 100644 index 0000000..5405cfe --- /dev/null +++ b/Core/ConsoleRunner.cs @@ -0,0 +1,78 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading.Tasks; + +namespace JJServiceManager +{ + /// + /// Windows 명령(schtasks, netsh 등)을 실행하고 출력을 읽음. + /// 시스템 설정에 따라 출력이 UTF-8 또는 CP949(OEM)일 수 있어, + /// 바이트로 받은 뒤 UTF-8로 올바르게 읽히면 UTF-8, 아니면 시스템 OEM 코드페이지로 해석함. + /// + public static class ConsoleRunner + { + [DllImport("kernel32.dll")] + private static extern uint GetOEMCP(); + + public static (int exitCode, string output) Run(string file, string args, int timeoutMs = 15000) + { + try + { + var psi = new ProcessStartInfo(file, args) + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + using var p = Process.Start(psi); + var outTask = ReadAllAsync(p.StandardOutput.BaseStream); + var errTask = ReadAllAsync(p.StandardError.BaseStream); + if (!p.WaitForExit(timeoutMs)) + { + try { p.Kill(); } catch { } + return (-1, "시간 초과"); + } + var bytes = Combine(outTask.Result, errTask.Result); + return (p.ExitCode, Decode(bytes).Trim()); + } + catch (Exception ex) + { + return (-1, ex.Message); + } + } + + private static async Task ReadAllAsync(Stream s) + { + using var ms = new MemoryStream(); + await s.CopyToAsync(ms).ConfigureAwait(false); + return ms.ToArray(); + } + + private static byte[] Combine(byte[] a, byte[] b) + { + var r = new byte[a.Length + b.Length]; + Buffer.BlockCopy(a, 0, r, 0, a.Length); + Buffer.BlockCopy(b, 0, r, a.Length, b.Length); + return r; + } + + public static string Decode(byte[] bytes) + { + if (bytes.Length == 0) return ""; + try + { + var strict = new UTF8Encoding(false, throwOnInvalidBytes: true); + return strict.GetString(bytes); + } + catch { } + try { return Encoding.GetEncoding((int)GetOEMCP()).GetString(bytes); } + catch { } + try { return Encoding.GetEncoding(949).GetString(bytes); } + catch { return Encoding.UTF8.GetString(bytes); } + } + } +} diff --git a/Core/Models.cs b/Core/Models.cs index bd9abb6..f83cc44 100644 --- a/Core/Models.cs +++ b/Core/Models.cs @@ -61,10 +61,24 @@ namespace JJServiceManager Port = 1 // 지정 포트 + TLS (기본 9988) } + /// 접속 방향 + public enum ConnectDirection + { + Active = 0, // 관리 PC → 대상 PC 로 접속 (대상 PC가 포트를 엶) + Passive = 1 // 대상 PC → 관리 PC 로 접속 (관리 PC가 포트를 엶) + } + /// 관리 대상 PC public class RemoteTarget { public string Id { get; set; } = Guid.NewGuid().ToString("N"); + + /// Active: 관리 PC가 접속 / Passive: 대상 PC가 접속해 옴 + public ConnectDirection Direction { get; set; } = ConnectDirection.Active; + + /// Passive일 때 대상 PC가 알려주는 접속 코드 (예: AB12-CD34) + public string AccessCode { get; set; } = ""; + public string Address { get; set; } = ""; /// 접속할 때 자동으로 채워짐 public string Hostname { get; set; } = ""; @@ -73,10 +87,50 @@ namespace JJServiceManager /// 접속 비밀번호 (Windows 사용자 계정으로 암호화, Base64) public string ProtectedPassword { get; set; } = ""; + /// Passive로 접속해 온 대상 PC의 IP (표시용, 자동) + public string LastAddress { get; set; } = ""; + [JsonIgnore] - public string Label => string.IsNullOrWhiteSpace(Hostname) ? Address : $"{Address} / {Hostname}"; + public string Label + { + get + { + var addr = Direction == ConnectDirection.Passive + ? (string.IsNullOrWhiteSpace(LastAddress) ? "Passive " + AccessCode : LastAddress) + : Address; + return string.IsNullOrWhiteSpace(Hostname) ? addr : $"{addr} / {Hostname}"; + } + } public RemoteTarget Clone() => (RemoteTarget)MemberwiseClone(); + + /// 새 접속 코드 (헷갈리는 글자 0/O, 1/I 제외) + public static string NewAccessCode() + { + const string chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + var sb = new System.Text.StringBuilder(); + for (int i = 0; i < 8; i++) + { + if (i == 4) sb.Append('-'); + sb.Append(chars[System.Security.Cryptography.RandomNumberGenerator.GetInt32(chars.Length)]); + } + return sb.ToString(); + } + } + + /// 대상 PC 쪽: Passive로 접속할 관리 PC + public class PassiveManager + { + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public string Address { get; set; } = ""; + public RemoteMode Mode { get; set; } = RemoteMode.Port; + public int Port { get; set; } = 9990; + public string AccessCode { get; set; } = ""; + + [JsonIgnore] + public string Label => $"{Address} ({(Mode == RemoteMode.Ssh ? "SSH" : "지정 포트")} {Port}) · 코드 {AccessCode}"; + + public PassiveManager Clone() => (PassiveManager)MemberwiseClone(); } /// 로그창 띄우기 방식 @@ -160,11 +214,36 @@ namespace JJServiceManager [JsonIgnore] public int RemoteServerPort => RemoteServerMode == RemoteMode.Ssh ? RemoteServerSshPort : RemoteServerTcpPort; + /// Active 수신: 이 PC가 포트를 열고 관리 PC의 접속을 받음 + public bool RemoteServerActiveListen { get; set; } = true; + + /// Passive 접속: 이 PC가 관리 PC로 접속 + public List PassiveManagers { get; set; } = new List(); + + // ───────── 원격관리 (화면 보기 / 제어) ───────── + + /// 원격에서 이 PC 화면 보기 허용 + public bool RemoteAllowView { get; set; } + + /// 원격에서 이 PC 키보드·마우스 제어 허용 (화면 보기가 켜져 있어야 함) + public bool RemoteAllowControl { get; set; } + + /// 원격 화면 연결 시 이 PC에서 승인해야 함 + public bool RemoteRequireApproval { get; set; } = true; + // ───────── 다른 PC 관리 (관리 역할) ───────── public bool RemoteManageEnabled { get; set; } public List Targets { get; set; } = new List(); + /// Passive 대상 PC의 접속을 기다리는 방식/포트 + public RemoteMode PassiveListenMode { get; set; } = RemoteMode.Port; + public int PassiveListenTcpPort { get; set; } = 9990 + AppInstance.Number - 1; + public int PassiveListenSshPort { get; set; } = 2224 + AppInstance.Number - 1; + + [JsonIgnore] + public int PassiveListenPort => PassiveListenMode == RemoteMode.Ssh ? PassiveListenSshPort : PassiveListenTcpPort; + /// 초기화면에 열어둔 PC 탭 ("local" 또는 대상 Id) public List OpenHostTabs { get; set; } = new List(); @@ -203,6 +282,7 @@ namespace JJServiceManager { s.Services ??= new List(); s.Targets ??= new List(); + s.PassiveManagers ??= new List(); s.OpenHostTabs ??= new List(); return s; } diff --git a/Core/Remote/RemoteClient.cs b/Core/Remote/RemoteClient.cs index e82d893..03c1e28 100644 --- a/Core/Remote/RemoteClient.cs +++ b/Core/Remote/RemoteClient.cs @@ -1,24 +1,22 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; -using System.Net.Security; using System.Net.Sockets; -using System.Security.Claims; using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; using Microsoft.DevTunnels.Ssh; -using SshClientTcp = Microsoft.DevTunnels.Ssh.Tcp.SshClient; namespace JJServiceManager.Remote { public enum RemoteState { Connecting, Connected, Disconnected, AuthFailed } /// - /// 관리 역할: 대상 PC 하나에 접속을 유지하며 서비스 목록을 주기적으로 받아옴. - /// 끊기면 자동으로 다시 접속함. + /// 관리 역할: 대상 PC 하나와 연결을 유지하며 서비스 목록을 주기적으로 받아옴. + /// - Active: 이쪽에서 대상 PC로 접속 (끊기면 자동 재접속) + /// - Passive: 대상 PC가 접속해 오기를 기다림 (PassiveHub가 연결을 넘겨줌) + /// 연결 하나로 목록 / Start·Stop / 로그 / 원격 화면을 모두 주고받음. /// public sealed class RemoteClient : IDisposable { @@ -26,9 +24,12 @@ namespace JJServiceManager.Remote private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(2); private readonly RemoteTarget _target; + private readonly Func _password; private readonly CancellationTokenSource _cts = new CancellationTokenSource(); private readonly ConcurrentDictionary> _pending = new ConcurrentDictionary>(); private readonly ConcurrentDictionary _logs = new ConcurrentDictionary(); + private readonly object _incomingLock = new object(); + private TaskCompletionSource<(JsonLineConnection conn, string peer)> _incoming; private JsonLineConnection _conn; private int _nextId; @@ -39,19 +40,26 @@ namespace JJServiceManager.Remote } public RemoteTarget Target => _target; + public bool IsPassive => _target.Direction == ConnectDirection.Passive; public RemoteState State { get; private set; } = RemoteState.Connecting; public string StateMessage { get; private set; } = "연결 중…"; public string Hostname { get; private set; } = ""; public List Services { get; private set; } = new List(); + /// 대상 PC가 화면 보기 / 제어를 허용했는지 (목록 받을 때 갱신) + public bool AllowView { get; private set; } + public bool AllowControl { get; private set; } + /// 연결 상태가 바뀜 (백그라운드 스레드에서 발생) public event Action StateChanged; /// 서비스 목록을 새로 받음 (백그라운드 스레드에서 발생) public event Action ServicesChanged; /// 대상 PC의 hostname을 처음 알게 되었거나 바뀜 public event Action HostnameDiscovered; - - private readonly Func _password; + /// Passive: 대상 PC가 접속해 온 주소 + public event Action PeerAddressDiscovered; + /// 원격 화면 이벤트 (frame / screenEnd) + public event Action ScreenEvent; public RemoteClient(RemoteTarget target, Func passwordProvider = null) { @@ -67,21 +75,54 @@ namespace JJServiceManager.Remote { try { _cts.Cancel(); } catch { } _conn?.Dispose(); + lock (_incomingLock) _incoming?.TrySetCanceled(); } - // ───────────────────────── 접속 유지 ───────────────────────── + /// Passive: 대상 PC가 접속해 온 연결을 넘겨받음. 받아들였으면 true + public bool Offer(JsonLineConnection conn, string peer) + { + lock (_incomingLock) + { + if (_incoming == null || _incoming.Task.IsCompleted) return false; + return _incoming.TrySetResult((conn, peer)); + } + } + + // ───────────────────────── 연결 유지 ───────────────────────── private async Task RunAsync() { var ct = _cts.Token; while (!ct.IsCancellationRequested) { - SetState(RemoteState.Connecting, "연결 중…"); TimeSpan retry = TimeSpan.FromSeconds(5); try { - var password = _password(); - var (conn, hello) = await ConnectAsync(_target, password, ct).ConfigureAwait(false); + JsonLineConnection conn; + JsonObject hello; + if (IsPassive) + { + SetState(RemoteState.Connecting, $"대상 PC의 접속을 기다리는 중 (코드 {_target.AccessCode})"); + TaskCompletionSource<(JsonLineConnection, string)> tcs; + lock (_incomingLock) + { + _incoming = new TaskCompletionSource<(JsonLineConnection, string)>(TaskCreationOptions.RunContinuationsAsynchronously); + tcs = _incoming; + } + using (ct.Register(() => tcs.TrySetCanceled())) + { + var (c, peer) = await tcs.Task.ConfigureAwait(false); + conn = c; + if (!string.IsNullOrEmpty(peer)) PeerAddressDiscovered?.Invoke(this, peer); + } + SetState(RemoteState.Connecting, "대상 PC가 접속함, 인증 중…"); + hello = await HelloAsync(conn, _password(), sendPassword: true, ct).ConfigureAwait(false); + } + else + { + SetState(RemoteState.Connecting, "연결 중…"); + (conn, hello) = await ConnectAsync(_target, _password(), ct).ConfigureAwait(false); + } _conn = conn; var host = Json.Str(hello, "hostname"); @@ -90,14 +131,18 @@ namespace JJServiceManager.Remote Hostname = host; HostnameDiscovered?.Invoke(this, host); } + AllowView = Json.Bool(hello, "allowView"); + AllowControl = Json.Bool(hello, "allowControl"); SetState(RemoteState.Connected, "연결됨"); var reader = Task.Run(() => ReadLoopAsync(conn, ct)); // 목록 주기적으로 받기 while (!ct.IsCancellationRequested && !reader.IsCompleted) { - var res = await RequestAsync("list", null, TimeSpan.FromSeconds(15)).ConfigureAwait(false); + var res = await RequestAsync("list", null, TimeSpan.FromSeconds(20)).ConfigureAwait(false); Services = ParseServices(res); + AllowView = Json.Bool(res, "allowView"); + AllowControl = Json.Bool(res, "allowControl"); ServicesChanged?.Invoke(this); await Task.WhenAny(reader, Task.Delay(PollInterval, ct)).ConfigureAwait(false); } @@ -111,11 +156,12 @@ namespace JJServiceManager.Remote catch (RemoteAuthException ex) { SetState(RemoteState.AuthFailed, ex.Message); - retry = TimeSpan.FromSeconds(30); + retry = IsPassive ? TimeSpan.FromSeconds(1) : TimeSpan.FromSeconds(30); } catch (Exception ex) { SetState(RemoteState.Disconnected, Friendly(ex)); + if (IsPassive) retry = TimeSpan.FromSeconds(1); } finally { @@ -124,6 +170,7 @@ namespace JJServiceManager.Remote c?.Dispose(); FailPending("연결이 끊겼습니다."); _logs.Clear(); + ScreenEvent?.Invoke(new JsonObject { ["event"] = "screenEnd", ["sid"] = 0, ["reason"] = "대상 PC와 연결이 끊겼습니다." }); } try { await Task.Delay(retry, ct).ConfigureAwait(false); } @@ -141,6 +188,11 @@ namespace JJServiceManager.Remote if (msg.ContainsKey("event")) { var ev = Json.Str(msg, "event"); + if (ev == "frame" || ev == "screenEnd") + { + ScreenEvent?.Invoke(msg); + continue; + } int sid = Json.Int(msg, "stream"); if (!_logs.TryGetValue(sid, out var sub)) continue; if (ev == "log") @@ -215,6 +267,15 @@ namespace JJServiceManager.Remote return reply["result"] as JsonObject ?? new JsonObject(); } + /// 응답을 기다리지 않고 보냄 (원격 화면 입력) + public void SendNoReply(JsonObject msg) + { + var conn = _conn; + if (conn == null || State != RemoteState.Connected) return; + msg["id"] = 0; + _ = conn.SendAsync(msg).ContinueWith(t => { _ = t.Exception; }, TaskScheduler.Default); + } + public Task StartServiceAsync(string service) => RequestAsync("start", new JsonObject { ["service"] = service }, TimeSpan.FromSeconds(90)); @@ -246,72 +307,35 @@ namespace JJServiceManager.Remote // ───────────────────────── 연결 ───────────────────────── - /// 접속 + 인증 + hello. 성공하면 연결과 hello 결과 + /// Active: 접속 + 인증 + hello. 성공하면 연결과 hello 결과 public static async Task<(JsonLineConnection conn, JsonObject hello)> ConnectAsync(RemoteTarget t, string password, CancellationToken ct) { if (string.IsNullOrWhiteSpace(t.Address)) throw new RemoteException("대상 IP가 비어 있습니다."); if (string.IsNullOrEmpty(password)) throw new RemoteAuthException("접속 비밀번호가 저장되어 있지 않습니다."); + JsonLineConnection conn; + using (var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct)) + { + timeout.CancelAfter(ConnectTimeout); + try + { + conn = await RemoteDialer.DialAsync(t.Mode, t.Address, t.Port, RemoteServer.SshUser, password, timeout.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + throw new RemoteException("연결 시간이 초과되었습니다. (IP, 포트, 방화벽, 원격 접속 허용 여부 확인)"); + } + } + // SSH는 SSH 인증에서 비밀번호를 확인했으므로 hello에는 보내지 않음 + var hello = await HelloAsync(conn, password, sendPassword: t.Mode == RemoteMode.Port, ct).ConfigureAwait(false); + return (conn, hello); + } + + /// 연결 위에서 hello (비밀번호 확인). 실패하면 연결을 닫고 예외 + public static async Task HelloAsync(JsonLineConnection conn, string password, bool sendPassword, CancellationToken ct) + { using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); timeout.CancelAfter(ConnectTimeout); - var tok = timeout.Token; - - JsonLineConnection conn; - bool sendPassword; - - try - { - if (t.Mode == RemoteMode.Port) - { - var tcp = new TcpClient { NoDelay = true }; - try - { - await tcp.ConnectAsync(t.Address.Trim(), t.Port, tok).ConfigureAwait(false); - var ssl = new SslStream(tcp.GetStream(), false); // 암호화만, 서버 지문 확인 안 함 (아래 옵션) - await ssl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions - { - TargetHost = "JJServiceManager", - RemoteCertificateValidationCallback = (a, b, c, d) => true - }, tok).ConfigureAwait(false); - conn = new JsonLineConnection(ssl, tcp); - } - catch - { - tcp.Dispose(); - throw; - } - sendPassword = true; - } - else - { - var client = new SshClientTcp(SshSessionConfiguration.Default, new TraceSource("JJServiceManager.SshClient")); - try - { - var session = await client.OpenSessionAsync(t.Address.Trim(), t.Port, tok).ConfigureAwait(false); - // 서버 지문 확인 안 함 (설정: 확인 없이 암호화만) - session.Authenticating += (s, e) => e.AuthenticationTask = Task.FromResult(new ClaimsPrincipal()); - bool ok = await session.AuthenticateAsync(new SshClientCredentials(RemoteServer.SshUser, password), tok).ConfigureAwait(false); - if (!ok) - { - session.Dispose(); - throw new RemoteAuthException("접속 비밀번호가 맞지 않습니다."); - } - var channel = await session.OpenChannelAsync(tok).ConfigureAwait(false); - conn = new JsonLineConnection(new SshStream(channel), session, client); - } - catch - { - client.Dispose(); - throw; - } - sendPassword = false; - } - } - catch (OperationCanceledException) when (!ct.IsCancellationRequested) - { - throw new RemoteException("연결 시간이 초과되었습니다. (IP, 포트, 방화벽, 원격 접속 허용 여부 확인)"); - } - try { var hello = new JsonObject @@ -322,9 +346,9 @@ namespace JJServiceManager.Remote ["version"] = RemoteServer.Version }; if (sendPassword) hello["password"] = password; - await conn.SendAsync(hello, tok).ConfigureAwait(false); + await conn.SendAsync(hello, timeout.Token).ConfigureAwait(false); - var reply = await conn.ReadAsync(tok).ConfigureAwait(false); + var reply = await conn.ReadAsync(timeout.Token).ConfigureAwait(false); if (reply == null) throw new RemoteException("대상 PC가 연결을 끊었습니다."); if (!Json.Bool(reply, "ok")) { @@ -332,7 +356,7 @@ namespace JJServiceManager.Remote if (Json.Bool(reply, "authFailed")) throw new RemoteAuthException(err); throw new RemoteException(err); } - return (conn, reply["result"] as JsonObject ?? new JsonObject()); + return reply["result"] as JsonObject ?? new JsonObject(); } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { @@ -380,12 +404,14 @@ namespace JJServiceManager.Remote return ex switch { RemoteException re => re.Message, + RemoteAuthException ra => ra.Message, SocketException se when se.SocketErrorCode == SocketError.ConnectionRefused - => "연결이 거부되었습니다. (대상 PC에서 원격 접속 허용이 꺼져 있거나 포트가 다름)", + => "연결이 거부되었습니다. (상대 PC에서 기능이 꺼져 있거나 포트가 다름)", SocketException se when se.SocketErrorCode == SocketError.HostNotFound - => "대상 PC를 찾을 수 없습니다. (IP 확인)", + => "상대 PC를 찾을 수 없습니다. (주소 확인)", SocketException se when se.SocketErrorCode == SocketError.TimedOut || se.SocketErrorCode == SocketError.HostUnreachable || se.SocketErrorCode == SocketError.NetworkUnreachable - => "대상 PC에 닿지 않습니다. (네트워크, 방화벽 확인)", + => "상대 PC에 닿지 않습니다. (네트워크, 방화벽 확인)", + OperationCanceledException => "연결 시간이 초과되었습니다. (주소, 포트, 방화벽 확인)", System.Security.Authentication.AuthenticationException => "암호화 연결에 실패했습니다. (방식(SSH/지정 포트)이 맞는지 확인)", SshConnectionException @@ -402,6 +428,104 @@ namespace JJServiceManager.Remote public RemoteAuthException(string message) : base(message) { } } + /// + /// 관리 PC: Passive 대상 PC들의 접속을 받는 곳. + /// 접속해 온 연결의 첫 메시지(announce)의 접속 코드로 어느 대상인지 찾아 RemoteClient에 넘김. + /// + public sealed class PassiveHub : IDisposable + { + private readonly ConcurrentDictionary _byCode = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + private readonly Action _log; + private RemoteListener _listener; + private string _key = ""; + + public PassiveHub(Action log) => _log = log; + + public bool IsRunning => _listener != null; + + public void Register(RemoteClient c) + { + if (!string.IsNullOrWhiteSpace(c.Target.AccessCode)) _byCode[c.Target.AccessCode.Trim()] = c; + } + + public void Clear() => _byCode.Clear(); + + /// 수신 시작/중지 (방식·포트가 같으면 그대로) + public void Apply(bool enabled, RemoteMode mode, int port) + { + var key = enabled ? $"{mode}|{port}" : "off"; + if (key == _key) return; + _key = key; + _listener?.Dispose(); + _listener = null; + if (!enabled) + { + _log("Passive 수신 중지", LogLevel.Info); + return; + } + try + { + _listener = RemoteListener.Start(mode, port, CheckSshAsync, OnConnectionAsync); + _listener.Failed += msg => _log("Passive 수신 중단: " + msg, LogLevel.Error); + _log($"Passive 수신 시작: {(mode == RemoteMode.Ssh ? "SSH" : "지정 포트(TLS)")} {port}", LogLevel.Success); + } + catch (Exception ex) + { + _key = ""; + var msg = ex is SocketException se && se.SocketErrorCode == SocketError.AddressAlreadyInUse + ? $"포트 {port}를 다른 프로그램이 사용 중입니다." + : ex.Message; + _log($"Passive 수신 시작 실패 (포트 {port}): {msg}", LogLevel.Error); + } + } + + private Task CheckSshAsync(string user, string password) + => Task.FromResult(user == RemoteServer.PassiveSshUser && !string.IsNullOrEmpty(password) && _byCode.ContainsKey(password.Trim())); + + private async Task OnConnectionAsync(JsonLineConnection conn, string peer, CancellationToken ct) + { + try + { + JsonObject first; + using (var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct)) + { + timeout.CancelAfter(TimeSpan.FromSeconds(10)); + first = await conn.ReadAsync(timeout.Token).ConfigureAwait(false); + } + if (first == null || Json.Str(first, "op") != "announce") + { + conn.Dispose(); + return; + } + var code = Json.Str(first, "code").Trim(); + var host = Json.Str(first, "hostname"); + if (!_byCode.TryGetValue(code, out var client)) + { + _log($"Passive 접속 거부: 등록되지 않은 접속 코드 ({host} {peer})", LogLevel.Warning); + conn.Dispose(); + return; + } + var addr = peer == "SSH" ? host : peer; + if (!client.Offer(conn, addr)) + { + // 이미 연결되어 있음 + conn.Dispose(); + } + } + catch + { + conn.Dispose(); + } + } + + public void Dispose() + { + _listener?.Dispose(); + _listener = null; + _key = ""; + } + } + /// 원격 서비스 로그 (LogTailView에서 로컬 파일과 같은 방식으로 사용) public sealed class RemoteLogSource : ILogSource { diff --git a/Core/Remote/RemoteCommon.cs b/Core/Remote/RemoteCommon.cs index 997ac22..3094922 100644 --- a/Core/Remote/RemoteCommon.cs +++ b/Core/Remote/RemoteCommon.cs @@ -72,7 +72,7 @@ namespace JJServiceManager.Remote /// public sealed class JsonLineConnection : IDisposable { - private const int MaxLineChars = 4 * 1024 * 1024; + private const int MaxLineChars = 16 * 1024 * 1024; private readonly Stream _stream; private readonly StreamReader _reader; @@ -89,32 +89,45 @@ namespace JJServiceManager.Remote public bool IsDisposed => Volatile.Read(ref _disposed) == 1; - /// 다음 메시지. 연결이 끊기면 null + private readonly char[] _buf = new char[64 * 1024]; + private int _bufPos, _bufLen; + private readonly StringBuilder _line = new StringBuilder(); + + /// 다음 메시지. 연결이 끊기면 null (한 번에 한 곳에서만 호출) public async Task ReadAsync(CancellationToken ct) { - var sb = new StringBuilder(); - var buf = new char[1]; + _line.Clear(); while (true) { - int n = await _reader.ReadAsync(buf.AsMemory(0, 1), ct).ConfigureAwait(false); - if (n == 0) return null; - char c = buf[0]; - if (c == '\n') + if (_bufPos >= _bufLen) { - var line = sb.ToString().Trim(); - sb.Clear(); - if (line.Length == 0) continue; - try - { - return JsonNode.Parse(line) as JsonObject ?? new JsonObject(); - } - catch (JsonException) - { - throw new IOException("잘못된 메시지를 받았습니다."); - } + _bufLen = await _reader.ReadAsync(_buf.AsMemory(), ct).ConfigureAwait(false); + _bufPos = 0; + if (_bufLen <= 0) return null; + } + + int idx = Array.IndexOf(_buf, '\n', _bufPos, _bufLen - _bufPos); + if (idx < 0) + { + _line.Append(_buf, _bufPos, _bufLen - _bufPos); + _bufPos = _bufLen; + if (_line.Length > MaxLineChars) throw new IOException("메시지가 너무 큽니다."); + continue; + } + + _line.Append(_buf, _bufPos, idx - _bufPos); + _bufPos = idx + 1; + var text = _line.ToString().Trim(); + _line.Clear(); + if (text.Length == 0) continue; + try + { + return JsonNode.Parse(text) as JsonObject ?? new JsonObject(); + } + catch (JsonException) + { + throw new IOException("잘못된 메시지를 받았습니다."); } - sb.Append(c); - if (sb.Length > MaxLineChars) throw new IOException("메시지가 너무 큽니다."); } } @@ -187,54 +200,28 @@ namespace JJServiceManager.Remote } /// - /// Windows 방화벽 규칙 (원격 접속 허용 포트) - /// 규칙 이름: "JJServiceManager Remote" (+ 테스트 인스턴스 꼬리표) + /// Windows 방화벽 규칙 + /// - "JJServiceManager Remote" : 대상 PC의 Active 수신 포트 + /// - "JJServiceManager Passive" : 관리 PC의 Passive 수신 포트 + /// (+ 테스트 인스턴스 꼬리표) /// public static class FirewallManager { - private static string RuleName => "JJServiceManager Remote" + AppInstance.Suffix; + public const string ActiveRule = "JJServiceManager Remote"; + public const string PassiveRule = "JJServiceManager Passive"; /// enabled면 해당 포트를 열고, 아니면 규칙을 지움 - public static (bool ok, string message) Apply(bool enabled, int port) + public static (bool ok, string message) Apply(string rule, bool enabled, int port) { + var name = rule + AppInstance.Suffix; // 기존 규칙은 지우고 다시 만듦 (포트가 바뀌었을 수 있음) - Run($"advfirewall firewall delete rule name=\"{RuleName}\""); - if (!enabled) return (true, "방화벽 규칙 삭제"); + ConsoleRunner.Run("netsh.exe", $"advfirewall firewall delete rule name=\"{name}\""); + if (!enabled) return (true, $"방화벽 규칙 삭제 ({name})"); - var r = Run($"advfirewall firewall add rule name=\"{RuleName}\" dir=in action=allow protocol=TCP localport={port} profile=any"); - return r.exit == 0 - ? (true, $"방화벽 TCP {port} 허용") - : (false, "방화벽 규칙 추가 실패: " + r.output); - } - - private static (int exit, string output) Run(string args) - { - try - { - var psi = new ProcessStartInfo("netsh.exe", args) - { - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - StandardOutputEncoding = OemEncoding(), - StandardErrorEncoding = OemEncoding() - }; - using var p = Process.Start(psi); - var o = p.StandardOutput.ReadToEnd() + p.StandardError.ReadToEnd(); - p.WaitForExit(15000); - return (p.ExitCode, o.Trim()); - } - catch (Exception ex) - { - return (-1, ex.Message); - } - } - - private static Encoding OemEncoding() - { - try { return Encoding.GetEncoding(System.Globalization.CultureInfo.CurrentCulture.TextInfo.OEMCodePage); } - catch { return Encoding.UTF8; } + var r = ConsoleRunner.Run("netsh.exe", $"advfirewall firewall add rule name=\"{name}\" dir=in action=allow protocol=TCP localport={port} profile=any"); + return r.exitCode == 0 + ? (true, $"방화벽 TCP {port} 허용 ({name})") + : (false, $"방화벽 규칙 추가 실패 ({name}): {r.output}"); } } diff --git a/Core/Remote/RemoteServer.cs b/Core/Remote/RemoteServer.cs index 376c6ed..22ef654 100644 --- a/Core/Remote/RemoteServer.cs +++ b/Core/Remote/RemoteServer.cs @@ -1,60 +1,74 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Linq; -using System.Net; -using System.Net.Security; -using System.Net.Sockets; -using System.Security.Claims; -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; -using Microsoft.DevTunnels.Ssh; -using Microsoft.DevTunnels.Ssh.Algorithms; -using Microsoft.DevTunnels.Ssh.Events; -using Microsoft.DevTunnels.Ssh.Messages; -using SshServerTcp = Microsoft.DevTunnels.Ssh.Tcp.SshServer; namespace JJServiceManager.Remote { + /// 원격관리(화면) 허용 설정 + public readonly record struct ScreenPolicy(bool AllowView, bool AllowControl, bool RequireApproval); + + /// 지금 이 PC 화면을 보고 있는 관리 PC + public readonly record struct ScreenViewer(int Id, string Who, bool Control); + /// /// 원격 접속 허용 (대상 역할). - /// 관리 PC의 접속을 받아, 이 PC에 "등록된 서비스"에 한해서만 - /// 목록 / 상태 / Start / Stop / 상세 / 로그 보기를 처리함. + /// - Active: 포트를 열고 관리 PC의 접속을 받음 + /// - Passive: 관리 PC로 접속해 감 (접속 코드) + /// 연결된 뒤에는 똑같이, 이 PC에 "등록된 서비스"에 한해서만 + /// 목록 / 상태 / Start / Stop / 상세 / 로그 보기를 처리하고, + /// 허용된 경우 화면 보기 / 키보드·마우스 제어를 처리함. /// public sealed class RemoteServer : IDisposable { - public const string Version = "1.1"; + public const string Version = "1.2"; public const string SshUser = "jjsm"; + public const string PassiveSshUser = "jjsm-passive"; private static readonly TimeSpan OpTimeout = TimeSpan.FromSeconds(60); private readonly Func> _services; private readonly Action _log; private readonly Action _changed; + private readonly Func _policy; + private readonly Func> _approve; private readonly object _sync = new object(); - private CancellationTokenSource _cts; - private TcpListener _listener; - private X509Certificate2 _cert; - private SshServerTcp _ssh; + private RemoteListener _listener; private string _hash = "", _salt = ""; private readonly Queue _failures = new Queue(); - public bool IsRunning { get; private set; } + // Passive + private CancellationTokenSource _passiveCts; + + // 화면 보기 중인 세션 + private readonly ConcurrentDictionary _screens = new ConcurrentDictionary(); + private int _nextScreenId; + + public bool IsRunning => _listener != null; public RemoteMode Mode { get; private set; } public int Port { get; private set; } + /// 화면을 보는 관리 PC 목록이 바뀜 (백그라운드 스레드) + public event Action> ScreenViewersChanged; + /// 등록된 서비스 목록 (스레드 안전한 복사본을 돌려줘야 함) /// 작업 로그 기록 /// 원격에서 Start/Stop 했을 때 (상태 갱신용) - public RemoteServer(Func> services, Action log, Action changed) + /// 원격관리 허용 설정 + /// 화면 연결 승인 요청 (누가, 제어 여부) → 허용 여부 + public RemoteServer(Func> services, Action log, Action changed, + Func policy, Func> approve) { _services = services; _log = log; _changed = changed; + _policy = policy; + _approve = approve; } /// 비밀번호만 바뀐 경우 (재시작 없이) @@ -67,162 +81,128 @@ namespace JJServiceManager.Remote } } + // ───────────────────────── Active: 접속 받기 ───────────────────────── + public void Start(RemoteMode mode, int port, string hash, string salt) { Stop(); UpdatePassword(hash, salt); Mode = mode; Port = port; - _cts = new CancellationTokenSource(); - try { - if (mode == RemoteMode.Port) StartTls(port, _cts.Token); - else StartSsh(port); - IsRunning = true; - _log($"원격 접속 허용 시작: {(mode == RemoteMode.Ssh ? "SSH" : "지정 포트(TLS)")} {port}", LogLevel.Success); + _listener = RemoteListener.Start(mode, port, CheckSshPasswordAsync, + (conn, peer, ct) => ServeAsync(conn, peer, requireHelloPassword: mode == RemoteMode.Port, ct)); + _listener.Failed += msg => + { + _log($"원격 접속 허용(SSH) 중단: {msg}", LogLevel.Error); + _listener?.Dispose(); + _listener = null; + }; + _log($"원격 접속 허용 시작 (Active 수신): {(mode == RemoteMode.Ssh ? "SSH" : "지정 포트(TLS)")} {port}", LogLevel.Success); } catch (Exception ex) { - IsRunning = false; - _log($"원격 접속 허용 시작 실패 (포트 {port}): {ex.Message}", LogLevel.Error); - Stop(); + _listener = null; + var msg = ex is System.Net.Sockets.SocketException se && se.SocketErrorCode == System.Net.Sockets.SocketError.AddressAlreadyInUse + ? $"포트 {port}를 다른 프로그램이 사용 중입니다." + : ex.Message; + _log($"원격 접속 허용 시작 실패 (포트 {port}): {msg}", LogLevel.Error); } } public void Stop() { - bool wasRunning = IsRunning; - IsRunning = false; - try { _cts?.Cancel(); } catch { } - try { _listener?.Stop(); } catch { } - try { _ssh?.Dispose(); } catch { } + if (_listener == null) return; + _listener.Dispose(); _listener = null; - _ssh = null; - _cts = null; - if (wasRunning) _log("원격 접속 허용 중지", LogLevel.Info); + _log("원격 접속 허용 (Active 수신) 중지", LogLevel.Info); } - public void Dispose() => Stop(); - - // ───────────────────────── 지정 포트 + TLS ───────────────────────── - - private void StartTls(int port, CancellationToken ct) + public void Dispose() { - _cert ??= CreateCertificate(); - _listener = new TcpListener(IPAddress.Any, port); - _listener.Start(); - var listener = _listener; - _ = Task.Run(async () => - { - while (!ct.IsCancellationRequested) - { - TcpClient tcp; - try { tcp = await listener.AcceptTcpClientAsync(ct).ConfigureAwait(false); } - catch { break; } - _ = Task.Run(() => HandleTcpAsync(tcp, ct)); - } - }); + Stop(); + StopPassive(); + StopAllScreens(); } - private async Task HandleTcpAsync(TcpClient tcp, CancellationToken ct) + // ───────────────────────── Passive: 관리 PC로 접속 ───────────────────────── + + /// Passive 접속 목록 적용 (빈 목록이면 중지) + public void StartPassive(IReadOnlyList managers) { - string peer = (tcp.Client.RemoteEndPoint as IPEndPoint)?.Address.ToString() ?? "?"; - try + StopPassive(); + if (managers == null || managers.Count == 0) return; + _passiveCts = new CancellationTokenSource(); + foreach (var m in managers) _ = Task.Run(() => PassiveLoopAsync(m.Clone(), _passiveCts.Token)); + } + + public void StopPassive() + { + try { _passiveCts?.Cancel(); } catch { } + _passiveCts = null; + } + + private async Task PassiveLoopAsync(PassiveManager m, CancellationToken ct) + { + string where = $"{m.Address}:{m.Port}"; + string lastProblem = null; + while (!ct.IsCancellationRequested) { - tcp.NoDelay = true; - var ssl = new SslStream(tcp.GetStream(), false); - using (var hs = CancellationTokenSource.CreateLinkedTokenSource(ct)) + var retry = TimeSpan.FromSeconds(5); + try { - hs.CancelAfter(TimeSpan.FromSeconds(15)); - await ssl.AuthenticateAsServerAsync(new SslServerAuthenticationOptions + JsonLineConnection conn; + using (var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct)) { - ServerCertificate = _cert, - ClientCertificateRequired = false - }, hs.Token).ConfigureAwait(false); - } - var conn = new JsonLineConnection(ssl, tcp); - await ServeAsync(conn, peer, requireHelloPassword: true, ct).ConfigureAwait(false); - } - catch - { - try { tcp.Dispose(); } catch { } - } - } - - /// TLS용 자체 서명 인증서 (실행할 때마다 새로 만듦, 지문 확인은 하지 않는 설정) - private static X509Certificate2 CreateCertificate() - { - using var rsa = RSA.Create(2048); - var req = new CertificateRequest("CN=JJServiceManager", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, false)); - req.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, false)); - using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(10)); - // Windows(SChannel)에서 개인 키를 쓰려면 PFX로 한 번 내보냈다가 다시 읽어야 함 - return new X509Certificate2(cert.Export(X509ContentType.Pfx), (string)null, X509KeyStorageFlags.Exportable); - } - - // ───────────────────────── 내장 SSH ───────────────────────── - - private void StartSsh(int port) - { - var hostKey = SshAlgorithms.PublicKey.ECDsaSha2Nistp384.GenerateKeyPair(); - var ssh = new SshServerTcp(SshSessionConfiguration.Default, new TraceSource("JJServiceManager.SshServer")); - ssh.Credentials = new SshServerCredentials(new[] { hostKey }); - - ssh.SessionAuthenticating += (s, e) => - { - if (e.AuthenticationType == SshAuthenticationType.ClientPassword) - e.AuthenticationTask = CheckSshPasswordAsync(e.Username, e.Password); - // 그 밖의 인증 방식(공개 키 등)은 허용하지 않음 - }; - - ssh.SessionOpened += (s, session) => - { - session.ChannelOpening += (s2, e) => - { - if (!e.IsRemoteRequest) return; - if (session.Principal == null) - { - e.FailureReason = SshChannelOpenFailureReason.AdministrativelyProhibited; - e.FailureDescription = "인증되지 않았습니다."; - return; + timeout.CancelAfter(TimeSpan.FromSeconds(10)); + conn = await RemoteDialer.DialAsync(m.Mode, m.Address, m.Port, PassiveSshUser, m.AccessCode, timeout.Token).ConfigureAwait(false); + await conn.SendAsync(new JsonObject + { + ["op"] = "announce", + ["code"] = m.AccessCode, + ["hostname"] = Environment.MachineName, + ["version"] = Version + }, timeout.Token).ConfigureAwait(false); } - var channel = e.Channel; - var token = _cts?.Token ?? CancellationToken.None; - _ = Task.Run(() => ServeAsync(new JsonLineConnection(new SshStream(channel)), "SSH", requireHelloPassword: false, token)); - }; - }; - - ssh.ExceptionRaised += (s, ex) => - { - if (ex is SocketException se && se.SocketErrorCode == SocketError.AddressAlreadyInUse) - _log($"원격 접속 허용 실패: 포트 {port}를 다른 프로그램이 사용 중입니다.", LogLevel.Error); - }; - - _ssh = ssh; - _ = ssh.AcceptSessionsAsync(port, IPAddress.Any).ContinueWith(t => - { - if (t.IsFaulted && IsRunning) - { - IsRunning = false; - _log($"원격 접속 허용(SSH) 중단: {t.Exception?.GetBaseException().Message}", LogLevel.Error); + _log($"Passive 연결됨: 관리 PC {where}", LogLevel.Success); + lastProblem = null; + await ServeAsync(conn, $"{m.Address}(Passive)", requireHelloPassword: true, ct).ConfigureAwait(false); + if (!ct.IsCancellationRequested) _log($"Passive 연결 끊김: 관리 PC {where}", LogLevel.Warning); + } + catch (Exception ex) when (!ct.IsCancellationRequested) + { + if (ex is RemoteAuthException) retry = TimeSpan.FromSeconds(30); + var msg = RemoteClient.Friendly(ex); + if (msg != lastProblem) + { + _log($"Passive 접속 실패 (관리 PC {where}): {msg}", LogLevel.Warning); + lastProblem = msg; + } + } + catch + { + break; } - }, TaskScheduler.Default); - } - private async Task CheckSshPasswordAsync(string user, string password) - { - if (IsThrottled()) return null; - if (CheckPassword(password)) return new ClaimsPrincipal(new ClaimsIdentity("password")); - RecordFailure(); - await Task.Delay(1000).ConfigureAwait(false); - _log("원격 접속 거부 (SSH): 비밀번호가 맞지 않습니다.", LogLevel.Warning); - return null; + try { await Task.Delay(retry, ct).ConfigureAwait(false); } + catch { break; } + } } // ───────────────────────── 인증 ───────────────────────── + private async Task CheckSshPasswordAsync(string user, string password) + { + if (user != SshUser) return false; + if (IsThrottled()) return false; + if (CheckPassword(password)) return true; + RecordFailure(); + await Task.Delay(1000).ConfigureAwait(false); + _log("원격 접속 거부 (SSH): 비밀번호가 맞지 않습니다.", LogLevel.Warning); + return false; + } + private bool CheckPassword(string password) { string h, s; @@ -253,6 +233,7 @@ namespace JJServiceManager.Remote string who = peer; var streams = new Dictionary(); int nextStream = 1; + ScreenSession screen = null; try { @@ -286,11 +267,14 @@ namespace JJServiceManager.Remote authed = true; } _log($"원격 접속: {who}", LogLevel.Notice); + var pol = _policy(); await Reply(conn, id, result: new JsonObject { ["hostname"] = Environment.MachineName, ["version"] = Version, - ["instance"] = AppInstance.Number + ["instance"] = AppInstance.Number, + ["allowView"] = pol.AllowView, + ["allowControl"] = pol.AllowView && pol.AllowControl }); continue; } @@ -375,6 +359,102 @@ namespace JJServiceManager.Remote break; } + // ───── 원격관리: 화면 ───── + + case "screenInfo": + { + var pol = _policy(); + 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 }); + await Reply(conn, id, result: new JsonObject + { + ["allowView"] = pol.AllowView, + ["allowControl"] = pol.AllowView && pol.AllowControl, + ["monitors"] = arr + }); + break; + } + + case "screenStart": + { + var whoNow = who; + int monitor = Json.Int(msg, "monitor", 1); + // 승인 창을 기다리는 동안 다른 요청은 계속 처리되도록 따로 실행 + _ = Task.Run(async () => + { + try + { + var pol = _policy(); + if (!pol.AllowView) throw new RemoteException("대상 PC에서 화면 보기를 허용하지 않았습니다."); + bool control = pol.AllowControl; + if (pol.RequireApproval) + { + _log($"[원격 화면] {whoNow} 연결 승인 요청", LogLevel.Notice); + bool ok = await _approve(whoNow, control).ConfigureAwait(false); + if (!ok) + { + _log($"[원격 화면] {whoNow} 연결 거부", LogLevel.Warning); + throw new RemoteException("대상 PC에서 연결을 거부했습니다."); + } + } + var old = Interlocked.Exchange(ref screen, null); + old?.Stop(); + var s = new ScreenSession(Interlocked.Increment(ref _nextScreenId), whoNow, control, monitor); + screen = s; + _screens[s.Id] = s; + _log($"[원격 화면] {whoNow} {(control ? "보기 + 제어" : "보기")} 시작", LogLevel.Notice); + NotifyViewers(); + await Reply(conn, id, result: new JsonObject { ["sid"] = s.Id, ["control"] = control }); + _ = Task.Run(() => ScreenLoopAsync(conn, s, ct)); + } + catch (RemoteException ex) { await Reply(conn, id, error: ex.Message); } + catch (Exception ex) { await Reply(conn, id, error: ex.Message); } + }); + break; + } + + case "screenMonitor": + { + var s = screen; + if (s != null) s.Monitor = Json.Int(msg, "monitor", 1); + await Reply(conn, id, result: new JsonObject()); + break; + } + + case "screenStop": + { + var s = Interlocked.Exchange(ref screen, null); + s?.Stop(); + await Reply(conn, id, result: new JsonObject()); + break; + } + + case "input": + { + // 응답 없이 바로 실행 (빠르게) + var s = screen; + if (s == null || s.Stopped || !s.Control) break; + var pol = _policy(); + if (!pol.AllowView || !pol.AllowControl) break; + try + { + if (Json.Str(msg, "kind") == "key") + { + InputInjector.Key(Json.Int(msg, "vk"), Json.Bool(msg, "down")); + } + else + { + double nx = 0, ny = 0; + try { nx = msg["x"]?.GetValue() ?? 0; ny = msg["y"]?.GetValue() ?? 0; } catch { } + InputInjector.Mouse(ScreenCapture.Area(s.Monitor), nx, ny, + Json.Str(msg, "action"), Json.Str(msg, "button"), Json.Int(msg, "delta")); + } + } + catch { } + break; + } + default: await Reply(conn, id, error: "알 수 없는 요청: " + op); break; @@ -392,11 +472,120 @@ namespace JJServiceManager.Remote foreach (var t in streams.Values) t.Dispose(); streams.Clear(); } + var s = Interlocked.Exchange(ref screen, null); + s?.Stop(); conn.Dispose(); if (authed && who != peer) _log($"원격 연결 종료: {who}", LogLevel.Info); } } + // ───────────────────────── 원격관리: 화면 보내기 ───────────────────────── + + private sealed class ScreenSession + { + private readonly CancellationTokenSource _cts = new CancellationTokenSource(); + public int Id { get; } + public string Who { get; } + public bool Control { get; } + public volatile int Monitor; + public bool Stopped => _cts.IsCancellationRequested; + public CancellationToken Token => _cts.Token; + + public ScreenSession(int id, string who, bool control, int monitor) + { + Id = id; + Who = who; + Control = control; + Monitor = monitor; + } + + public void Stop() + { + try { _cts.Cancel(); } catch { } + } + } + + private async Task ScreenLoopAsync(JsonLineConnection conn, ScreenSession s, CancellationToken ct) + { + const int FrameMs = 120; // 최대 약 8장/초 + ulong lastHash = 0; + var lastSent = DateTime.MinValue; + int lastMonitor = -1; + string endReason = "종료"; + try + { + while (!s.Stopped && !ct.IsCancellationRequested && !conn.IsDisposed) + { + var sw = Stopwatch.StartNew(); + var pol = _policy(); + if (!pol.AllowView) + { + endReason = "대상 PC에서 화면 보기 허용을 껐습니다."; + break; + } + + int monitor = s.Monitor; + var area = ScreenCapture.Area(monitor); + int maxWidth = monitor == 0 ? 2560 : 1920; + byte[] jpeg = null; + int w = 0, h = 0; + ulong hash = 0; + try { jpeg = ScreenCapture.CaptureJpeg(area, maxWidth, 60L, out w, out h, out hash); } + catch { /* 잠금 화면 등 캡처할 수 없는 순간 */ } + + bool changed = hash != lastHash || monitor != lastMonitor; + if (jpeg != null && (changed || DateTime.UtcNow - lastSent > TimeSpan.FromSeconds(2))) + { + await conn.SendAsync(new JsonObject + { + ["event"] = "frame", + ["sid"] = s.Id, + ["monitor"] = monitor, + ["w"] = w, + ["h"] = h, + ["data"] = Convert.ToBase64String(jpeg) + }, s.Token).ConfigureAwait(false); + lastHash = hash; + lastMonitor = monitor; + lastSent = DateTime.UtcNow; + } + + int wait = FrameMs - (int)sw.ElapsedMilliseconds; + if (wait > 0) await Task.Delay(wait, s.Token).ConfigureAwait(false); + } + } + catch + { + // 연결 끊김 또는 중지 + } + finally + { + s.Stop(); + _screens.TryRemove(s.Id, out _); + if (!conn.IsDisposed) + { + try { await conn.SendAsync(new JsonObject { ["event"] = "screenEnd", ["sid"] = s.Id, ["reason"] = endReason }).ConfigureAwait(false); } + catch { } + } + _log($"[원격 화면] {s.Who} 연결 끝", LogLevel.Info); + NotifyViewers(); + } + } + + /// 이 PC에서 [연결 끊기]를 눌렀을 때 모든 화면 연결 종료 + public void StopAllScreens() + { + foreach (var s in _screens.Values) s.Stop(); + } + + private void NotifyViewers() + { + var list = _screens.Values.Where(s => !s.Stopped).Select(s => new ScreenViewer(s.Id, s.Who, s.Control)).ToList(); + ScreenViewersChanged?.Invoke(list); + } + + // ───────────────────────── 서비스 ───────────────────────── + private ServiceEntry Registered(string serviceName) { var e = _services().FirstOrDefault(x => string.Equals(x.ServiceName, serviceName, StringComparison.OrdinalIgnoreCase)); @@ -419,7 +608,13 @@ namespace JJServiceManager.Remote ["logIsFolder"] = e.LogIsFolder }); } - return new JsonObject { ["services"] = arr }; + var pol = _policy(); + return new JsonObject + { + ["services"] = arr, + ["allowView"] = pol.AllowView, + ["allowControl"] = pol.AllowView && pol.AllowControl + }; } private JsonObject StartStop(bool start, string serviceName, string who) @@ -452,6 +647,8 @@ namespace JJServiceManager.Remote } } + // ───────────────────────── 보내기 ───────────────────────── + private static async Task SafeReply(JsonLineConnection conn, int id, Func work) { try diff --git a/Core/Remote/RemoteTransport.cs b/Core/Remote/RemoteTransport.cs new file mode 100644 index 0000000..c1861d8 --- /dev/null +++ b/Core/Remote/RemoteTransport.cs @@ -0,0 +1,229 @@ +using System; +using System.Diagnostics; +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.DevTunnels.Ssh; +using Microsoft.DevTunnels.Ssh.Algorithms; +using Microsoft.DevTunnels.Ssh.Events; +using Microsoft.DevTunnels.Ssh.Messages; +using SshClientTcp = Microsoft.DevTunnels.Ssh.Tcp.SshClient; +using SshServerTcp = Microsoft.DevTunnels.Ssh.Tcp.SshServer; + +namespace JJServiceManager.Remote +{ + /// + /// 접속을 받는 쪽 (지정 포트 TLS 또는 내장 SSH). + /// Active: 대상 PC가 사용 / Passive: 관리 PC가 사용 + /// + public sealed class RemoteListener : IDisposable + { + private static X509Certificate2 _cert; + private static readonly object CertLock = new object(); + + private readonly CancellationTokenSource _cts = new CancellationTokenSource(); + private TcpListener _tcp; + private SshServerTcp _ssh; + + public RemoteMode Mode { get; } + public int Port { get; } + + /// SSH가 비정상 종료되었을 때 + public event Action Failed; + + private RemoteListener(RemoteMode mode, int port) + { + Mode = mode; + Port = port; + } + + /// SSH 비밀번호 확인 (user, password) → 허용 여부 + /// 연결 하나가 생길 때 (연결, 상대 표시, 취소) + public static RemoteListener Start(RemoteMode mode, int port, + Func> sshAuth, + Func onConnection) + { + var l = new RemoteListener(mode, port); + if (mode == RemoteMode.Port) l.StartTls(onConnection); + else l.StartSsh(sshAuth, onConnection); + return l; + } + + private void StartTls(Func onConnection) + { + var cert = Certificate(); + _tcp = new TcpListener(IPAddress.Any, Port); + _tcp.Start(); + var listener = _tcp; + var ct = _cts.Token; + _ = Task.Run(async () => + { + while (!ct.IsCancellationRequested) + { + TcpClient tcp; + try { tcp = await listener.AcceptTcpClientAsync(ct).ConfigureAwait(false); } + catch { break; } + _ = Task.Run(async () => + { + string peer = (tcp.Client.RemoteEndPoint as IPEndPoint)?.Address.ToString() ?? "?"; + if (peer.StartsWith("::ffff:")) peer = peer.Substring(7); + try + { + tcp.NoDelay = true; + var ssl = new SslStream(tcp.GetStream(), false); + using (var hs = CancellationTokenSource.CreateLinkedTokenSource(ct)) + { + hs.CancelAfter(TimeSpan.FromSeconds(15)); + await ssl.AuthenticateAsServerAsync(new SslServerAuthenticationOptions + { + ServerCertificate = cert, + ClientCertificateRequired = false + }, hs.Token).ConfigureAwait(false); + } + await onConnection(new JsonLineConnection(ssl, tcp), peer, ct).ConfigureAwait(false); + } + catch + { + try { tcp.Dispose(); } catch { } + } + }); + } + }); + } + + private void StartSsh(Func> sshAuth, Func onConnection) + { + var hostKey = SshAlgorithms.PublicKey.ECDsaSha2Nistp384.GenerateKeyPair(); + var ssh = new SshServerTcp(SshSessionConfiguration.Default, new TraceSource("JJServiceManager.SshServer")); + ssh.Credentials = new SshServerCredentials(new[] { hostKey }); + var ct = _cts.Token; + + ssh.SessionAuthenticating += (s, e) => + { + if (e.AuthenticationType == SshAuthenticationType.ClientPassword) + { + var user = e.Username; + var pw = e.Password; + e.AuthenticationTask = Task.Run(async () => + await sshAuth(user, pw).ConfigureAwait(false) ? new ClaimsPrincipal(new ClaimsIdentity("password")) : null); + } + // 그 밖의 인증 방식(공개 키 등)은 허용하지 않음 + }; + + ssh.SessionOpened += (s, session) => + { + session.ChannelOpening += (s2, e) => + { + if (!e.IsRemoteRequest) return; + if (session.Principal == null) + { + e.FailureReason = SshChannelOpenFailureReason.AdministrativelyProhibited; + e.FailureDescription = "인증되지 않았습니다."; + return; + } + var channel = e.Channel; + _ = Task.Run(() => onConnection(new JsonLineConnection(new SshStream(channel)), "SSH", ct)); + }; + }; + + _ssh = ssh; + _ = ssh.AcceptSessionsAsync(Port, IPAddress.Any).ContinueWith(t => + { + if (t.IsFaulted && !ct.IsCancellationRequested) + { + var ex = t.Exception?.GetBaseException(); + var msg = ex is SocketException se && se.SocketErrorCode == SocketError.AddressAlreadyInUse + ? $"포트 {Port}를 다른 프로그램이 사용 중입니다." + : ex?.Message ?? "알 수 없는 오류"; + Failed?.Invoke(msg); + } + }, TaskScheduler.Default); + } + + public void Dispose() + { + try { _cts.Cancel(); } catch { } + try { _tcp?.Stop(); } catch { } + try { _ssh?.Dispose(); } catch { } + } + + /// TLS용 자체 서명 인증서 (실행할 때마다 새로 만듦, 지문 확인은 하지 않는 설정) + private static X509Certificate2 Certificate() + { + lock (CertLock) + { + if (_cert != null) return _cert; + using var rsa = RSA.Create(2048); + var req = new CertificateRequest("CN=JJServiceManager", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, false)); + req.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, false)); + using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(10)); + // Windows(SChannel)에서 개인 키를 쓰려면 PFX로 한 번 내보냈다가 다시 읽어야 함 + _cert = new X509Certificate2(cert.Export(X509ContentType.Pfx), (string)null, X509KeyStorageFlags.Exportable); + return _cert; + } + } + } + + /// 접속하는 쪽 (지정 포트 TLS 또는 내장 SSH) + public static class RemoteDialer + { + /// + /// 접속. SSH면 sshUser/sshPassword로 인증까지 함 (실패 시 RemoteAuthException). + /// TLS는 암호화만 하고 서버 지문은 확인하지 않음. + /// + public static async Task DialAsync(RemoteMode mode, string host, int port, + string sshUser, string sshPassword, CancellationToken ct) + { + host = (host ?? "").Trim(); + if (mode == RemoteMode.Port) + { + var tcp = new TcpClient { NoDelay = true }; + try + { + await tcp.ConnectAsync(host, port, ct).ConfigureAwait(false); + var ssl = new SslStream(tcp.GetStream(), false); + await ssl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions + { + TargetHost = "JJServiceManager", + RemoteCertificateValidationCallback = (a, b, c, d) => true + }, ct).ConfigureAwait(false); + return new JsonLineConnection(ssl, tcp); + } + catch + { + tcp.Dispose(); + throw; + } + } + + var client = new SshClientTcp(SshSessionConfiguration.Default, new TraceSource("JJServiceManager.SshClient")); + try + { + var session = await client.OpenSessionAsync(host, port, ct).ConfigureAwait(false); + // 서버 지문 확인 안 함 (설정: 확인 없이 암호화만) + session.Authenticating += (s, e) => e.AuthenticationTask = Task.FromResult(new ClaimsPrincipal()); + bool ok = await session.AuthenticateAsync(new SshClientCredentials(sshUser, sshPassword), ct).ConfigureAwait(false); + if (!ok) + { + session.Dispose(); + throw new RemoteAuthException(sshUser == RemoteServer.PassiveSshUser + ? "관리 PC가 접속 코드를 받아들이지 않았습니다." + : "접속 비밀번호가 맞지 않습니다."); + } + var channel = await session.OpenChannelAsync(ct).ConfigureAwait(false); + return new JsonLineConnection(new SshStream(channel), session, client); + } + catch + { + client.Dispose(); + throw; + } + } + } +} diff --git a/Core/Remote/ScreenCapture.cs b/Core/Remote/ScreenCapture.cs new file mode 100644 index 0000000..0474de2 --- /dev/null +++ b/Core/Remote/ScreenCapture.cs @@ -0,0 +1,282 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using Forms = System.Windows.Forms; + +namespace JJServiceManager.Remote +{ + /// 모니터 정보 (실제 픽셀 좌표) + public sealed class MonitorInfo + { + public int Index { get; set; } + public string Name { get; set; } = ""; + public int X { get; set; } + public int Y { get; set; } + public int Width { get; set; } + public int Height { get; set; } + public bool Primary { get; set; } + } + + /// 대상 PC: 화면 캡처 + public static class ScreenCapture + { + /// 모니터 목록 (1부터, 주 모니터 우선이 아니라 Windows 순서) + public static List Monitors() + { + var list = new List(); + int i = 1; + foreach (var s in Forms.Screen.AllScreens.OrderBy(s => s.Bounds.X).ThenBy(s => s.Bounds.Y)) + { + list.Add(new MonitorInfo + { + Index = i++, + Name = s.DeviceName, + X = s.Bounds.X, + Y = s.Bounds.Y, + Width = s.Bounds.Width, + Height = s.Bounds.Height, + Primary = s.Primary + }); + } + return list; + } + + /// monitor 0 = 전체 화면, 1~ = 해당 모니터 + public static Rectangle Area(int monitor) + { + if (monitor > 0) + { + var m = Monitors().FirstOrDefault(x => x.Index == monitor); + if (m != null) return new Rectangle(m.X, m.Y, m.Width, m.Height); + } + return Forms.SystemInformation.VirtualScreen; + } + + [StructLayout(LayoutKind.Sequential)] + private struct CURSORINFO + { + public int cbSize; + public int flags; + public IntPtr hCursor; + public POINT ptScreenPos; + } + + [StructLayout(LayoutKind.Sequential)] + private struct POINT { public int X; public int Y; } + + [StructLayout(LayoutKind.Sequential)] + private struct ICONINFO + { + public bool fIcon; + public int xHotspot; + public int yHotspot; + public IntPtr hbmMask; + public IntPtr hbmColor; + } + + [DllImport("user32.dll")] private static extern bool GetCursorInfo(ref CURSORINFO pci); + [DllImport("user32.dll")] private static extern bool GetIconInfo(IntPtr hIcon, out ICONINFO piconinfo); + [DllImport("user32.dll")] private static extern bool DrawIconEx(IntPtr hdc, int x, int y, IntPtr hIcon, int cx, int cy, int step, IntPtr brush, int flags); + [DllImport("gdi32.dll")] private static extern bool DeleteObject(IntPtr h); + + /// + /// 영역을 캡처해 JPEG로. maxWidth보다 넓으면 줄임. + /// hash: 변화 확인용 (같으면 보내지 않음) + /// + public static byte[] CaptureJpeg(Rectangle area, int maxWidth, long quality, out int width, out int height, out ulong hash) + { + using var bmp = new Bitmap(area.Width, area.Height, PixelFormat.Format32bppRgb); + using (var g = Graphics.FromImage(bmp)) + { + g.CopyFromScreen(area.X, area.Y, 0, 0, area.Size, CopyPixelOperation.SourceCopy); + DrawCursor(g, area); + } + + hash = Hash(bmp); + + Bitmap output = bmp; + Bitmap scaled = null; + if (area.Width > maxWidth) + { + double r = (double)maxWidth / area.Width; + int w = maxWidth, h = Math.Max(1, (int)(area.Height * r)); + scaled = new Bitmap(w, h, PixelFormat.Format24bppRgb); + using var g2 = Graphics.FromImage(scaled); + g2.InterpolationMode = InterpolationMode.HighQualityBilinear; + g2.DrawImage(bmp, 0, 0, w, h); + output = scaled; + } + + try + { + width = output.Width; + height = output.Height; + using var ms = new MemoryStream(); + var codec = ImageCodecInfo.GetImageEncoders().First(c => c.FormatID == ImageFormat.Jpeg.Guid); + using var ep = new EncoderParameters(1); + ep.Param[0] = new EncoderParameter(Encoder.Quality, quality); + output.Save(ms, codec, ep); + return ms.ToArray(); + } + finally + { + scaled?.Dispose(); + } + } + + private static void DrawCursor(Graphics g, Rectangle area) + { + try + { + var ci = new CURSORINFO { cbSize = Marshal.SizeOf() }; + if (!GetCursorInfo(ref ci) || ci.flags != 1 /* CURSOR_SHOWING */) return; + int hx = 0, hy = 0; + if (GetIconInfo(ci.hCursor, out var ii)) + { + hx = ii.xHotspot; + hy = ii.yHotspot; + if (ii.hbmMask != IntPtr.Zero) DeleteObject(ii.hbmMask); + if (ii.hbmColor != IntPtr.Zero) DeleteObject(ii.hbmColor); + } + int x = ci.ptScreenPos.X - area.X - hx; + int y = ci.ptScreenPos.Y - area.Y - hy; + if (x < -64 || y < -64 || x > area.Width || y > area.Height) return; + var hdc = g.GetHdc(); + try { DrawIconEx(hdc, x, y, ci.hCursor, 0, 0, 0, IntPtr.Zero, 3 /* DI_NORMAL */); } + finally { g.ReleaseHdc(hdc); } + } + catch { } + } + + /// 화면이 바뀌었는지 빠르게 확인하기 위한 값 (일부 픽셀만 사용) + private static unsafe ulong Hash(Bitmap bmp) + { + var data = bmp.LockBits(new Rectangle(0, 0, bmp.Width, bmp.Height), ImageLockMode.ReadOnly, PixelFormat.Format32bppRgb); + try + { + ulong h = 1469598103934665603UL; + byte* p = (byte*)data.Scan0; + for (int y = 0; y < data.Height; y += 4) + { + uint* row = (uint*)(p + (long)y * data.Stride); + for (int x = 0; x < data.Width; x += 4) + { + h ^= row[x]; + h *= 1099511628211UL; + } + } + return h; + } + finally + { + bmp.UnlockBits(data); + } + } + } + + /// 대상 PC: 원격에서 받은 마우스/키보드 입력을 실행 + public static class InputInjector + { + private const int INPUT_MOUSE = 0; + private const int INPUT_KEYBOARD = 1; + private const uint MOUSEEVENTF_MOVE = 0x0001, MOUSEEVENTF_LEFTDOWN = 0x0002, MOUSEEVENTF_LEFTUP = 0x0004, + MOUSEEVENTF_RIGHTDOWN = 0x0008, MOUSEEVENTF_RIGHTUP = 0x0010, MOUSEEVENTF_MIDDLEDOWN = 0x0020, MOUSEEVENTF_MIDDLEUP = 0x0040, + MOUSEEVENTF_WHEEL = 0x0800, MOUSEEVENTF_HWHEEL = 0x1000, MOUSEEVENTF_VIRTUALDESK = 0x4000, MOUSEEVENTF_ABSOLUTE = 0x8000; + private const uint KEYEVENTF_EXTENDEDKEY = 0x0001, KEYEVENTF_KEYUP = 0x0002; + + [StructLayout(LayoutKind.Sequential)] + private struct MOUSEINPUT { public int dx; public int dy; public int mouseData; public uint dwFlags; public uint time; public IntPtr dwExtraInfo; } + + [StructLayout(LayoutKind.Sequential)] + private struct KEYBDINPUT { public ushort wVk; public ushort wScan; public uint dwFlags; public uint time; public IntPtr dwExtraInfo; } + + [StructLayout(LayoutKind.Explicit)] + private struct InputUnion + { + [FieldOffset(0)] public MOUSEINPUT mi; + [FieldOffset(0)] public KEYBDINPUT ki; + } + + [StructLayout(LayoutKind.Sequential)] + private struct INPUT { public int type; public InputUnion U; } + + [DllImport("user32.dll", SetLastError = true)] + private static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize); + + [DllImport("user32.dll")] + private static extern uint MapVirtualKey(uint uCode, uint uMapType); + + /// + /// 마우스. nx, ny = 캡처 영역 안의 비율(0~1). + /// action: move / down / up / wheel / hwheel, button: left / right / middle + /// + public static void Mouse(Rectangle area, double nx, double ny, string action, string button, int delta) + { + var vs = Forms.SystemInformation.VirtualScreen; + nx = Math.Clamp(nx, 0, 1); + ny = Math.Clamp(ny, 0, 1); + double px = area.X + nx * (area.Width - 1); + double py = area.Y + ny * (area.Height - 1); + int ax = (int)Math.Round((px - vs.X) * 65535.0 / Math.Max(1, vs.Width - 1)); + int ay = (int)Math.Round((py - vs.Y) * 65535.0 / Math.Max(1, vs.Height - 1)); + + uint flags = MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK; + int data = 0; + switch (action) + { + case "down": + flags |= button switch { "right" => MOUSEEVENTF_RIGHTDOWN, "middle" => MOUSEEVENTF_MIDDLEDOWN, _ => MOUSEEVENTF_LEFTDOWN }; + break; + case "up": + flags |= button switch { "right" => MOUSEEVENTF_RIGHTUP, "middle" => MOUSEEVENTF_MIDDLEUP, _ => MOUSEEVENTF_LEFTUP }; + break; + case "wheel": + flags |= MOUSEEVENTF_WHEEL; + data = delta; + break; + case "hwheel": + flags |= MOUSEEVENTF_HWHEEL; + data = delta; + break; + } + + var input = new INPUT { type = INPUT_MOUSE }; + input.U.mi = new MOUSEINPUT { dx = ax, dy = ay, mouseData = data, dwFlags = flags }; + SendInput(1, new[] { input }, Marshal.SizeOf()); + } + + /// 키보드 (가상 키 코드) + public static void Key(int vk, bool down) + { + if (vk <= 0 || vk > 254) return; + uint flags = down ? 0 : KEYEVENTF_KEYUP; + if (IsExtended(vk)) flags |= KEYEVENTF_EXTENDEDKEY; + var input = new INPUT { type = INPUT_KEYBOARD }; + input.U.ki = new KEYBDINPUT + { + wVk = (ushort)vk, + wScan = (ushort)MapVirtualKey((uint)vk, 0), + dwFlags = flags + }; + SendInput(1, new[] { input }, Marshal.SizeOf()); + } + + private static bool IsExtended(int vk) => vk switch + { + 0x21 or 0x22 or 0x23 or 0x24 => true, // PgUp PgDn End Home + 0x25 or 0x26 or 0x27 or 0x28 => true, // 방향키 + 0x2D or 0x2E => true, // Insert Delete + 0x5B or 0x5C or 0x5D => true, // Win Apps + 0x6F => true, // Num / + 0x90 => true, // NumLock + 0xA3 or 0xA5 => true, // 오른쪽 Ctrl, Alt + 0x2C => true, // PrintScreen + _ => false + }; + } +} diff --git a/Core/SystemIntegration.cs b/Core/SystemIntegration.cs index b09d263..ffa6a99 100644 --- a/Core/SystemIntegration.cs +++ b/Core/SystemIntegration.cs @@ -95,36 +95,8 @@ $@" return (r.ExitCode == 0, r.Output); } - /// 명령 프롬프트 출력 인코딩 (한국어 Windows = CP949) - private static Encoding OemEncoding() - { - try { return Encoding.GetEncoding(System.Globalization.CultureInfo.CurrentCulture.TextInfo.OEMCodePage); } - catch { return Encoding.UTF8; } - } - private static (int ExitCode, string Output) RunSchtasks(string args) - { - try - { - var psi = new ProcessStartInfo("schtasks.exe", args) - { - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - StandardOutputEncoding = OemEncoding(), - StandardErrorEncoding = OemEncoding() - }; - using var p = Process.Start(psi); - var output = p.StandardOutput.ReadToEnd() + p.StandardError.ReadToEnd(); - p.WaitForExit(15000); - return (p.ExitCode, output.Trim()); - } - catch (Exception ex) - { - return (-1, ex.Message); - } - } + => ConsoleRunner.Run("schtasks.exe", args); } /// 전역 단축키 (어느 프로그램을 쓰고 있어도 동작) diff --git a/JJServiceManager.csproj b/JJServiceManager.csproj index 08176e3..93c1118 100644 --- a/JJServiceManager.csproj +++ b/JJServiceManager.csproj @@ -14,6 +14,7 @@ disable disable latest + true 1.1.0 JJServiceManagerEnt Windows 서비스 기동/종료 관리 diff --git a/Views/MainWindow.xaml b/Views/MainWindow.xaml index 9e27be9..d8c3f78 100644 --- a/Views/MainWindow.xaml +++ b/Views/MainWindow.xaml @@ -42,6 +42,9 @@ +