using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; 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); /// /// 원격 접속 허용 (대상 역할). /// - Active: 포트를 열고 관리 PC의 접속을 받음 /// - Passive: 관리 PC로 접속해 감 (접속 코드) /// 연결된 뒤에는 똑같이, 이 PC에 "등록된 서비스"에 한해서만 /// 목록 / 상태 / Start / Stop / 상세 / 로그 보기를 처리하고, /// 허용된 경우 화면 보기 / 키보드·마우스 제어를 처리함. /// public sealed class RemoteServer : IDisposable { 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 RemoteListener _listener; private string _hash = "", _salt = ""; private readonly Queue _failures = new Queue(); // 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, Func policy, Func> approve) { _services = services; _log = log; _changed = changed; _policy = policy; _approve = approve; } /// 비밀번호만 바뀐 경우 (재시작 없이) public void UpdatePassword(string hash, string salt) { lock (_sync) { _hash = hash ?? ""; _salt = salt ?? ""; } } // ───────────────────────── Active: 접속 받기 ───────────────────────── public void Start(RemoteMode mode, int port, string hash, string salt) { Stop(); UpdatePassword(hash, salt); Mode = mode; Port = port; try { _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) { _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() { if (_listener == null) return; _listener.Dispose(); _listener = null; _log("원격 접속 허용 (Active 수신) 중지", LogLevel.Info); } public void Dispose() { Stop(); StopPassive(); StopAllScreens(); } // ───────────────────────── Passive: 관리 PC로 접속 ───────────────────────── /// Passive 접속 목록 적용 (빈 목록이면 중지) public void StartPassive(IReadOnlyList managers) { 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) { var retry = TimeSpan.FromSeconds(5); try { JsonLineConnection conn; using (var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct)) { 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); } _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; } 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; lock (_sync) { h = _hash; s = _salt; } return RemoteSecurity.Verify(password, h, s); } /// 5분 동안 10번 넘게 틀리면 잠시 거부 private bool IsThrottled() { lock (_failures) { while (_failures.Count > 0 && DateTime.UtcNow - _failures.Peek() > TimeSpan.FromMinutes(5)) _failures.Dequeue(); return _failures.Count >= 10; } } private void RecordFailure() { lock (_failures) _failures.Enqueue(DateTime.UtcNow); } // ───────────────────────── 요청 처리 ───────────────────────── private async Task ServeAsync(JsonLineConnection conn, string peer, bool requireHelloPassword, CancellationToken ct) { bool authed = !requireHelloPassword; string who = peer; var streams = new Dictionary(); int nextStream = 1; ScreenSession screen = null; try { while (!ct.IsCancellationRequested) { var msg = await conn.ReadAsync(ct).ConfigureAwait(false); if (msg == null) break; var op = Json.Str(msg, "op"); int id = Json.Int(msg, "id"); if (op == "hello") { var client = Json.Str(msg, "client"); who = string.IsNullOrEmpty(client) ? peer : $"{client}({peer})"; if (!authed) { if (IsThrottled()) { await Reply(conn, id, error: "비밀번호를 여러 번 틀려 잠시 접속이 막혔습니다. 몇 분 뒤 다시 시도하세요.", extra: new JsonObject { ["authFailed"] = true }); break; } if (!CheckPassword(Json.Str(msg, "password"))) { RecordFailure(); await Task.Delay(1000, ct).ConfigureAwait(false); _log($"원격 접속 거부: {who} 비밀번호가 맞지 않습니다.", LogLevel.Warning); 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 { ["hostname"] = Environment.MachineName, ["version"] = Version, ["instance"] = AppInstance.Number, ["allowView"] = pol.AllowView, ["allowControl"] = pol.AllowView && pol.AllowControl }); continue; } if (!authed) { await Reply(conn, id, error: "먼저 인증해야 합니다."); break; } switch (op) { case "ping": await Reply(conn, id, result: new JsonObject()); break; case "list": _ = Task.Run(() => SafeReply(conn, id, () => ListServices())); break; case "start": case "stop": _ = Task.Run(() => SafeReply(conn, id, () => StartStop(op == "start", Json.Str(msg, "service"), who))); break; case "details": _ = Task.Run(() => SafeReply(conn, id, () => { var e = Registered(Json.Str(msg, "service")); var arr = new JsonArray(); foreach (var l in ServiceOps.GetDetails(e.ServiceName)) arr.Add(l); return new JsonObject { ["lines"] = arr }; })); break; case "logOpen": { try { var e = Registered(Json.Str(msg, "service")); if (string.IsNullOrWhiteSpace(e.LogPath)) throw new RemoteException("대상 PC에서 이 서비스의 로그 경로가 지정되지 않았습니다."); var enc = Json.Str(msg, "encoding"); var copy = new ServiceEntry { ServiceName = e.ServiceName, DisplayName = e.DisplayName, LogPath = e.LogPath, LogIsFolder = e.LogIsFolder, LogEncoding = string.IsNullOrEmpty(enc) ? e.LogEncoding : enc }; int sid = nextStream++; var tailer = new LogTailer(copy, (lines, level) => SendLog(conn, sid, lines, level)) { SkipInitial = Json.Bool(msg, "newOnly") }; tailer.FileChanged += f => Send(conn, new JsonObject { ["event"] = "logfile", ["stream"] = sid, ["path"] = f }); lock (streams) streams[sid] = tailer; await Reply(conn, id, result: new JsonObject { ["stream"] = sid, ["path"] = e.LogPath, ["isFolder"] = e.LogIsFolder }); tailer.Start(); } catch (Exception ex) { await Reply(conn, id, error: ex.Message); } break; } case "logClose": { int sid = Json.Int(msg, "stream"); lock (streams) { if (streams.Remove(sid, out var t)) t.Dispose(); } await Reply(conn, id, result: new JsonObject()); 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); EndScreen(old); 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); EndScreen(s); 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; } } } catch { // 연결 끊김 } finally { lock (streams) { foreach (var t in streams.Values) t.Dispose(); streams.Clear(); } var s = Interlocked.Exchange(ref screen, null); EndScreen(s); 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) }, ct).ConfigureAwait(false); // 보내는 도중 취소하면 TLS 스트림이 깨지므로 화면 중지 토큰은 쓰지 않음 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 { using var t = new CancellationTokenSource(TimeSpan.FromSeconds(5)); await conn.SendAsync(new JsonObject { ["event"] = "screenEnd", ["sid"] = s.Id, ["reason"] = endReason }, t.Token).ConfigureAwait(false); } catch { } } _log($"[원격 화면] {s.Who} 연결 끝", LogLevel.Info); NotifyViewers(); } } /// 화면 연결 하나 끝내기 — 표시 막대는 바로 갱신 private void EndScreen(ScreenSession s) { if (s == null) return; s.Stop(); if (_screens.TryRemove(s.Id, out _)) NotifyViewers(); } /// 이 PC에서 [연결 끊기]를 눌렀을 때 모든 화면 연결 종료 public void StopAllScreens() { foreach (var s in _screens.Values.ToList()) EndScreen(s); } 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)); if (e == null) throw new RemoteException("대상 PC에 등록되지 않은 서비스입니다: " + serviceName); return e; } private JsonObject ListServices() { var arr = new JsonArray(); foreach (var e in _services()) { var st = ServiceOps.GetStatus(e.ServiceName); arr.Add(new JsonObject { ["name"] = e.ServiceName, ["display"] = e.DisplayName, ["status"] = st.HasValue ? (int)st.Value : null, ["logPath"] = e.LogPath ?? "", ["logIsFolder"] = e.LogIsFolder }); } 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) { var e = Registered(serviceName); var name = string.IsNullOrEmpty(e.DisplayName) ? e.ServiceName : e.DisplayName; var what = start ? "시작" : "중지"; _log($"[원격 {who}] [{name}] {what} 요청", LogLevel.Notice); var sw = Stopwatch.StartNew(); try { if (start) ServiceOps.Start(e.ServiceName, OpTimeout); else ServiceOps.Stop(e.ServiceName, OpTimeout); _log($"[원격 {who}] [{name}] {what} 완료 ({sw.Elapsed.TotalSeconds:0.0}초)", LogLevel.Success); return new JsonObject { ["seconds"] = Math.Round(sw.Elapsed.TotalSeconds, 1) }; } catch (InfoException ex) { throw new RemoteException(ex.Message, isInfo: true); } catch (Exception ex) { var msg = ServiceOps.Describe(ex); _log($"[원격 {who}] [{name}] {what} 실패: {msg}", LogLevel.Error); throw new RemoteException(msg); } finally { _changed?.Invoke(); } } // ───────────────────────── 보내기 ───────────────────────── private static async Task SafeReply(JsonLineConnection conn, int id, Func work) { try { await Reply(conn, id, result: work()); } catch (RemoteException ex) { await Reply(conn, id, error: ex.Message, extra: ex.IsInfo ? new JsonObject { ["info"] = true } : null); } catch (Exception ex) { await Reply(conn, id, error: ex.Message); } } private static async Task Reply(JsonLineConnection conn, int id, JsonObject result = null, string error = null, JsonObject extra = null) { var msg = new JsonObject { ["id"] = id, ["ok"] = error == null }; if (error == null) msg["result"] = result ?? new JsonObject(); else msg["error"] = error; if (extra != null) foreach (var kv in extra.ToList()) { extra.Remove(kv.Key); msg[kv.Key] = kv.Value; } try { await conn.SendAsync(msg).ConfigureAwait(false); } catch { } } private static void SendLog(JsonLineConnection conn, int sid, IList lines, LogLevel level) { var arr = new JsonArray(); foreach (var l in lines) arr.Add(l); Send(conn, new JsonObject { ["event"] = "log", ["stream"] = sid, ["level"] = level.ToString(), ["lines"] = arr }); } /// 로그 이벤트는 순서가 중요하므로 보낼 때까지 기다림 (로그 타이머 스레드에서 호출) private static void Send(JsonLineConnection conn, JsonObject msg) { if (conn.IsDisposed) return; try { conn.SendAsync(msg).GetAwaiter().GetResult(); } catch { } } } }