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 하나에 접속을 유지하며 서비스 목록을 주기적으로 받아옴. /// 끊기면 자동으로 다시 접속함. /// public sealed class RemoteClient : IDisposable { private static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(10); private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(2); private readonly RemoteTarget _target; private readonly CancellationTokenSource _cts = new CancellationTokenSource(); private readonly ConcurrentDictionary> _pending = new ConcurrentDictionary>(); private readonly ConcurrentDictionary _logs = new ConcurrentDictionary(); private JsonLineConnection _conn; private int _nextId; private sealed class LogSubscriber { public Action, LogLevel> OnLines; public Action OnFile; } public RemoteTarget Target => _target; 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(); /// 연결 상태가 바뀜 (백그라운드 스레드에서 발생) public event Action StateChanged; /// 서비스 목록을 새로 받음 (백그라운드 스레드에서 발생) public event Action ServicesChanged; /// 대상 PC의 hostname을 처음 알게 되었거나 바뀜 public event Action HostnameDiscovered; private readonly Func _password; public RemoteClient(RemoteTarget target, Func passwordProvider = null) { _target = target.Clone(); Hostname = target.Hostname ?? ""; var prot = _target.ProtectedPassword; _password = passwordProvider ?? (() => RemoteSecurity.Unprotect(prot)); } public void Start() => _ = Task.Run(RunAsync); public void Dispose() { try { _cts.Cancel(); } catch { } _conn?.Dispose(); } // ───────────────────────── 접속 유지 ───────────────────────── 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); _conn = conn; var host = Json.Str(hello, "hostname"); if (!string.IsNullOrEmpty(host) && host != Hostname) { Hostname = host; HostnameDiscovered?.Invoke(this, host); } 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); Services = ParseServices(res); ServicesChanged?.Invoke(this); await Task.WhenAny(reader, Task.Delay(PollInterval, ct)).ConfigureAwait(false); } if (reader.IsFaulted) throw reader.Exception.GetBaseException(); throw new RemoteException("연결이 끊겼습니다."); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } catch (RemoteAuthException ex) { SetState(RemoteState.AuthFailed, ex.Message); retry = TimeSpan.FromSeconds(30); } catch (Exception ex) { SetState(RemoteState.Disconnected, Friendly(ex)); } finally { var c = _conn; _conn = null; c?.Dispose(); FailPending("연결이 끊겼습니다."); _logs.Clear(); } try { await Task.Delay(retry, ct).ConfigureAwait(false); } catch { break; } } } private async Task ReadLoopAsync(JsonLineConnection conn, CancellationToken ct) { while (!ct.IsCancellationRequested) { var msg = await conn.ReadAsync(ct).ConfigureAwait(false); if (msg == null) return; if (msg.ContainsKey("event")) { var ev = Json.Str(msg, "event"); int sid = Json.Int(msg, "stream"); if (!_logs.TryGetValue(sid, out var sub)) continue; if (ev == "log") { var lines = (msg["lines"] as JsonArray)?.Select(n => n?.ToString() ?? "").ToList() ?? new List(); Enum.TryParse(Json.Str(msg, "level"), out var level); sub.OnLines?.Invoke(lines, level); } else if (ev == "logfile") { sub.OnFile?.Invoke(Json.Str(msg, "path")); } continue; } int id = Json.Int(msg, "id"); if (_pending.TryRemove(id, out var tcs)) tcs.TrySetResult(msg); } } private void SetState(RemoteState state, string message) { bool changed = State != state || StateMessage != message; State = state; StateMessage = message; if (changed) StateChanged?.Invoke(this); } private void FailPending(string message) { foreach (var kv in _pending.ToArray()) if (_pending.TryRemove(kv.Key, out var tcs)) tcs.TrySetException(new RemoteException(message)); } // ───────────────────────── 요청 ───────────────────────── /// 요청을 보내고 결과(result)를 받음. 실패하면 RemoteException public async Task RequestAsync(string op, JsonObject args, TimeSpan timeout) { var conn = _conn; if (conn == null || State != RemoteState.Connected && op != "list") throw new RemoteException("대상 PC에 연결되어 있지 않습니다."); int 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 conn.SendAsync(msg).ConfigureAwait(false); } catch (Exception ex) { _pending.TryRemove(id, out _); throw new RemoteException("보내기 실패: " + ex.Message); } var done = await Task.WhenAny(tcs.Task, Task.Delay(timeout)).ConfigureAwait(false); if (done != tcs.Task) { _pending.TryRemove(id, out _); throw new RemoteException("대상 PC의 응답이 없습니다."); } var reply = await tcs.Task.ConfigureAwait(false); if (!Json.Bool(reply, "ok")) throw new RemoteException(Json.Str(reply, "error"), Json.Bool(reply, "info")); return reply["result"] as JsonObject ?? new JsonObject(); } public Task StartServiceAsync(string service) => RequestAsync("start", new JsonObject { ["service"] = service }, TimeSpan.FromSeconds(90)); public Task StopServiceAsync(string service) => RequestAsync("stop", new JsonObject { ["service"] = service }, TimeSpan.FromSeconds(90)); public async Task> GetDetailsAsync(string service) { var r = await RequestAsync("details", new JsonObject { ["service"] = service }, TimeSpan.FromSeconds(20)).ConfigureAwait(false); return (r["lines"] as JsonArray)?.Select(n => n?.ToString() ?? "").ToList() ?? new List(); } /// 대상 PC의 서비스 로그를 실시간으로 받기 시작. 돌려받은 번호로 CloseLog public async Task OpenLogAsync(string service, string encoding, Action, LogLevel> onLines, Action onFile, bool newOnly = false) { var r = await RequestAsync("logOpen", new JsonObject { ["service"] = service, ["encoding"] = encoding ?? "auto", ["newOnly"] = newOnly }, TimeSpan.FromSeconds(20)).ConfigureAwait(false); int sid = Json.Int(r, "stream"); _logs[sid] = new LogSubscriber { OnLines = onLines, OnFile = onFile }; return sid; } public void CloseLog(int stream) { if (stream <= 0) return; _logs.TryRemove(stream, out _); if (State == RemoteState.Connected) _ = RequestAsync("logClose", new JsonObject { ["stream"] = stream }, TimeSpan.FromSeconds(10)).ContinueWith(t => { _ = t.Exception; }); } // ───────────────────────── 연결 ───────────────────────── /// 접속 + 인증 + 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("접속 비밀번호가 저장되어 있지 않습니다."); 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 { ["id"] = 0, ["op"] = "hello", ["client"] = Environment.MachineName, ["version"] = RemoteServer.Version }; if (sendPassword) hello["password"] = password; await conn.SendAsync(hello, tok).ConfigureAwait(false); var reply = await conn.ReadAsync(tok).ConfigureAwait(false); if (reply == null) throw new RemoteException("대상 PC가 연결을 끊었습니다."); if (!Json.Bool(reply, "ok")) { var err = Json.Str(reply, "error"); if (Json.Bool(reply, "authFailed")) throw new RemoteAuthException(err); throw new RemoteException(err); } return (conn, reply["result"] as JsonObject ?? new JsonObject()); } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { conn.Dispose(); throw new RemoteException("대상 PC의 응답이 없습니다. (방식(SSH/지정 포트)이 맞는지 확인)"); } catch { conn.Dispose(); throw; } } /// 설정 화면의 [연결 테스트]: 성공하면 hostname public static async Task TestAsync(RemoteTarget t, string password) { var (conn, hello) = await ConnectAsync(t, password, CancellationToken.None).ConfigureAwait(false); conn.Dispose(); return Json.Str(hello, "hostname"); } private static List ParseServices(JsonObject res) { var list = new List(); if (res?["services"] is not JsonArray arr) return list; foreach (var n in arr.OfType()) { int? st = null; try { if (n["status"] != null) st = n["status"].GetValue(); } catch { } list.Add(new RemoteServiceInfo { ServiceName = Json.Str(n, "name"), DisplayName = Json.Str(n, "display"), Status = st, LogPath = Json.Str(n, "logPath"), LogIsFolder = Json.Bool(n, "logIsFolder") }); } return list; } public static string Friendly(Exception ex) { ex = ex is AggregateException ae ? ae.GetBaseException() : ex; return ex switch { RemoteException re => re.Message, SocketException se when se.SocketErrorCode == SocketError.ConnectionRefused => "연결이 거부되었습니다. (대상 PC에서 원격 접속 허용이 꺼져 있거나 포트가 다름)", SocketException se when se.SocketErrorCode == SocketError.HostNotFound => "대상 PC를 찾을 수 없습니다. (IP 확인)", SocketException se when se.SocketErrorCode == SocketError.TimedOut || se.SocketErrorCode == SocketError.HostUnreachable || se.SocketErrorCode == SocketError.NetworkUnreachable => "대상 PC에 닿지 않습니다. (네트워크, 방화벽 확인)", System.Security.Authentication.AuthenticationException => "암호화 연결에 실패했습니다. (방식(SSH/지정 포트)이 맞는지 확인)", SshConnectionException => "SSH 연결에 실패했습니다. (방식(SSH/지정 포트)이 맞는지 확인)", System.IO.IOException io => "연결이 끊겼습니다. " + io.Message, _ => ex.Message }; } } /// 인증 실패 (비밀번호 틀림 등) public sealed class RemoteAuthException : Exception { public RemoteAuthException(string message) : base(message) { } } /// 원격 서비스 로그 (LogTailView에서 로컬 파일과 같은 방식으로 사용) public sealed class RemoteLogSource : ILogSource { private readonly RemoteClient _client; private readonly string _service; private readonly Func _encoding; private readonly Action, LogLevel> _output; private int _stream; private bool _disposed; private bool _wasConnected; public event Action FileChanged; public string CurrentFile { get; private set; } public RemoteLogSource(RemoteClient client, string service, Func encoding, Action, LogLevel> output) { _client = client; _service = service; _encoding = encoding; _output = output; } public void Start() { _client.StateChanged += OnState; _wasConnected = _client.State == RemoteState.Connected; if (_wasConnected) _ = OpenAsync(); else _output(new[] { "대상 PC에 연결되어 있지 않습니다. 연결되면 자동으로 표시합니다." }, LogLevel.Warning); } public void Reload() { var s = Interlocked.Exchange(ref _stream, 0); _client.CloseLog(s); _ = OpenAsync(); } private async Task OpenAsync(bool newOnly = false) { if (_disposed) return; try { int sid = await _client.OpenLogAsync(_service, _encoding(), (l, lv) => { if (!_disposed) _output(l, lv); }, f => { CurrentFile = f; FileChanged?.Invoke(f); }, newOnly).ConfigureAwait(false); if (_disposed) { _client.CloseLog(sid); return; } _stream = sid; } catch (Exception ex) { _output(new[] { "원격 로그를 열 수 없습니다: " + RemoteClient.Friendly(ex) }, LogLevel.Warning); } } private void OnState(RemoteClient c) { if (_disposed) return; bool now = c.State == RemoteState.Connected; if (now && !_wasConnected) { _output(new[] { "──────── 대상 PC에 다시 연결됨 (이후 새 로그만 표시) ────────" }, LogLevel.Notice); _ = OpenAsync(newOnly: true); } else if (!now && _wasConnected) { _stream = 0; _output(new[] { "──────── 대상 PC 연결 끊김: " + c.StateMessage + " ────────" }, LogLevel.Warning); } _wasConnected = now; } public void Dispose() { _disposed = true; _client.StateChanged -= OnState; var s = Interlocked.Exchange(ref _stream, 0); _client.CloseLog(s); } } }