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;
/// 중계 서버 권한 티켓으로 들어온 연결에서 실행한 작업 (티켓, 작업, 대상, 결과, 상세) → 서버 감사 로그
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 했을 때 (상태 갱신용)
/// 원격관리 허용 설정
/// 화면 연결 승인 요청 (누가, 제어 여부) → 허용 여부
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);
}
// ───────────────────────── 요청 처리 ─────────────────────────
/// 중계 통로로 들어온 연결 (권한 티켓 필수, 세션 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;
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})";
// 중계 서버 권한 티켓: 중계 통로면 필수, 직접 연결이면 있을 때만 확인
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())
{
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);
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(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;
}
if (!authed)
{
await Reply(conn, id, error: "먼저 인증해야 합니다.");
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":
await Reply(conn, id, result: new JsonObject());
break;
case "list":
{
var tk = ticket;
_ = Task.Run(() => SafeReply(conn, id, () => ListServices(tk)));
break;
}
case "start":
case "stop":
{
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, () =>
{
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(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 });
await Reply(conn, id, result: new JsonObject
{
["allowView"] = pol.AllowView,
["allowControl"] = pol.AllowView && pol.AllowControl,
["monitors"] = arr
});
break;
}
case "screenStart":
{
var whoNow = who;
var tk = ticket;
int monitor = Json.Int(msg, "monitor", 1);
// 승인 창을 기다리는 동안 다른 요청은 계속 처리되도록 따로 실행
_ = Task.Run(async () =>
{
try
{
var pol = Policy(tk);
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);
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) { Ticket = tk };
Audit(tk, "screenStart", "", "ok", control ? "보기 + 제어" : "보기");
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(ticket);
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 RelayTicket Ticket { get; init; }
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(s.Ticket);
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);
Audit(s.Ticket, "screenEnd", "", "ok", endReason);
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);
}
// ───────────────────────── 중계 서버 권한 ─────────────────────────
/// 이 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)
{
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(RelayTicket ticket = null)
{
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(ticket);
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 { }
}
}
}