JJServiceManagerEnt/Core/Remote/RemoteClient.cs

612 lines
27 KiB
C#

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Net.Sockets;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.DevTunnels.Ssh;
namespace JJServiceManager.Remote
{
public enum RemoteState { Connecting, Connected, Disconnected, AuthFailed }
/// <summary>
/// 관리 역할: 대상 PC 하나와 연결을 유지하며 서비스 목록을 주기적으로 받아옴.
/// - Active: 이쪽에서 대상 PC로 접속 (끊기면 자동 재접속)
/// - Passive: 대상 PC가 접속해 오기를 기다림 (PassiveHub가 연결을 넘겨줌)
/// 연결 하나로 목록 / Start·Stop / 로그 / 원격 화면을 모두 주고받음.
/// </summary>
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 Func<string> _password;
private readonly CancellationTokenSource _cts = new CancellationTokenSource();
private readonly ConcurrentDictionary<int, TaskCompletionSource<JsonObject>> _pending = new ConcurrentDictionary<int, TaskCompletionSource<JsonObject>>();
private readonly ConcurrentDictionary<int, LogSubscriber> _logs = new ConcurrentDictionary<int, LogSubscriber>();
private readonly object _incomingLock = new object();
private TaskCompletionSource<(JsonLineConnection conn, string peer)> _incoming;
private JsonLineConnection _conn;
private int _nextId;
private sealed class LogSubscriber
{
public Action<IList<string>, LogLevel> OnLines;
public Action<string> OnFile;
}
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<RemoteServiceInfo> Services { get; private set; } = new List<RemoteServiceInfo>();
/// <summary>대상 PC가 화면 보기 / 제어를 허용했는지 (목록 받을 때 갱신)</summary>
public bool AllowView { get; private set; }
public bool AllowControl { get; private set; }
/// <summary>연결 상태가 바뀜 (백그라운드 스레드에서 발생)</summary>
public event Action<RemoteClient> StateChanged;
/// <summary>서비스 목록을 새로 받음 (백그라운드 스레드에서 발생)</summary>
public event Action<RemoteClient> ServicesChanged;
/// <summary>대상 PC의 hostname을 처음 알게 되었거나 바뀜</summary>
public event Action<RemoteClient, string> HostnameDiscovered;
/// <summary>Passive: 대상 PC가 접속해 온 주소</summary>
public event Action<RemoteClient, string> PeerAddressDiscovered;
/// <summary>원격 화면 이벤트 (frame / screenEnd)</summary>
public event Action<JsonObject> ScreenEvent;
public RemoteClient(RemoteTarget target, Func<string> 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();
lock (_incomingLock) _incoming?.TrySetCanceled();
}
/// <summary>Passive: 대상 PC가 접속해 온 연결을 넘겨받음. 받아들였으면 true</summary>
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)
{
TimeSpan retry = TimeSpan.FromSeconds(5);
try
{
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");
if (!string.IsNullOrEmpty(host) && host != Hostname)
{
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(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);
}
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 = IsPassive ? TimeSpan.FromSeconds(1) : TimeSpan.FromSeconds(30);
}
catch (Exception ex)
{
SetState(RemoteState.Disconnected, Friendly(ex));
if (IsPassive) retry = TimeSpan.FromSeconds(1);
}
finally
{
var c = _conn;
_conn = null;
c?.Dispose();
FailPending("연결이 끊겼습니다.");
_logs.Clear();
ScreenEvent?.Invoke(new JsonObject { ["event"] = "screenEnd", ["sid"] = 0, ["reason"] = "대상 PC와 연결이 끊겼습니다." });
}
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");
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")
{
var lines = (msg["lines"] as JsonArray)?.Select(n => n?.ToString() ?? "").ToList() ?? new List<string>();
Enum.TryParse<LogLevel>(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));
}
// ───────────────────────── 요청 ─────────────────────────
/// <summary>요청을 보내고 결과(result)를 받음. 실패하면 RemoteException</summary>
public async Task<JsonObject> 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<JsonObject>(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();
}
/// <summary>응답을 기다리지 않고 보냄 (원격 화면 입력)</summary>
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<JsonObject> StartServiceAsync(string service)
=> RequestAsync("start", new JsonObject { ["service"] = service }, TimeSpan.FromSeconds(90));
public Task<JsonObject> StopServiceAsync(string service)
=> RequestAsync("stop", new JsonObject { ["service"] = service }, TimeSpan.FromSeconds(90));
public async Task<List<string>> 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<string>();
}
/// <summary>대상 PC의 서비스 로그를 실시간으로 받기 시작. 돌려받은 번호로 CloseLog</summary>
public async Task<int> OpenLogAsync(string service, string encoding, Action<IList<string>, LogLevel> onLines, Action<string> 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; });
}
// ───────────────────────── 연결 ─────────────────────────
/// <summary>Active: 접속 + 인증 + hello. 성공하면 연결과 hello 결과</summary>
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);
}
/// <summary>연결 위에서 hello (비밀번호 확인). 실패하면 연결을 닫고 예외</summary>
public static async Task<JsonObject> HelloAsync(JsonLineConnection conn, string password, bool sendPassword, CancellationToken ct)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(ConnectTimeout);
try
{
var hello = new JsonObject
{
["id"] = 0,
["op"] = "hello",
["client"] = Environment.MachineName,
["version"] = RemoteServer.Version
};
if (sendPassword) hello["password"] = password;
await conn.SendAsync(hello, timeout.Token).ConfigureAwait(false);
var reply = await conn.ReadAsync(timeout.Token).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 reply["result"] as JsonObject ?? new JsonObject();
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
conn.Dispose();
throw new RemoteException("대상 PC의 응답이 없습니다. (방식(SSH/지정 포트)이 맞는지 확인)");
}
catch
{
conn.Dispose();
throw;
}
}
/// <summary>설정 화면의 [연결 테스트]: 성공하면 hostname</summary>
public static async Task<string> 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<RemoteServiceInfo> ParseServices(JsonObject res)
{
var list = new List<RemoteServiceInfo>();
if (res?["services"] is not JsonArray arr) return list;
foreach (var n in arr.OfType<JsonObject>())
{
int? st = null;
try { if (n["status"] != null) st = n["status"].GetValue<int>(); } 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,
RemoteAuthException ra => ra.Message,
SocketException se when se.SocketErrorCode == SocketError.ConnectionRefused
=> "연결이 거부되었습니다. (상대 PC에서 기능이 꺼져 있거나 포트가 다름)",
SocketException se when se.SocketErrorCode == SocketError.HostNotFound
=> "상대 PC를 찾을 수 없습니다. (주소 확인)",
SocketException se when se.SocketErrorCode == SocketError.TimedOut || se.SocketErrorCode == SocketError.HostUnreachable || se.SocketErrorCode == SocketError.NetworkUnreachable
=> "상대 PC에 닿지 않습니다. (네트워크, 방화벽 확인)",
OperationCanceledException => "연결 시간이 초과되었습니다. (주소, 포트, 방화벽 확인)",
System.Security.Authentication.AuthenticationException
=> "암호화 연결에 실패했습니다. (방식(SSH/지정 포트)이 맞는지 확인)",
SshConnectionException
=> "SSH 연결에 실패했습니다. (방식(SSH/지정 포트)이 맞는지 확인)",
System.IO.IOException io => "연결이 끊겼습니다. " + io.Message,
_ => ex.Message
};
}
}
/// <summary>인증 실패 (비밀번호 틀림 등)</summary>
public sealed class RemoteAuthException : Exception
{
public RemoteAuthException(string message) : base(message) { }
}
/// <summary>
/// 관리 PC: Passive 대상 PC들의 접속을 받는 곳.
/// 접속해 온 연결의 첫 메시지(announce)의 접속 코드로 어느 대상인지 찾아 RemoteClient에 넘김.
/// </summary>
public sealed class PassiveHub : IDisposable
{
private readonly ConcurrentDictionary<string, RemoteClient> _byCode = new ConcurrentDictionary<string, RemoteClient>(StringComparer.OrdinalIgnoreCase);
private readonly Action<string, LogLevel> _log;
private RemoteListener _listener;
private string _key = "";
public PassiveHub(Action<string, LogLevel> 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();
/// <summary>수신 시작/중지 (방식·포트가 같으면 그대로)</summary>
public void Apply(bool enabled, RemoteMode mode, int port)
{
var key = enabled ? $"{mode}|{port}" : "off";
if (key == _key) return;
_key = key;
bool wasRunning = _listener != null;
_listener?.Dispose();
_listener = null;
if (!enabled)
{
if (wasRunning) _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<bool> 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 = "";
}
}
/// <summary>원격 서비스 로그 (LogTailView에서 로컬 파일과 같은 방식으로 사용)</summary>
public sealed class RemoteLogSource : ILogSource
{
private readonly RemoteClient _client;
private readonly string _service;
private readonly Func<string> _encoding;
private readonly Action<IList<string>, LogLevel> _output;
private int _stream;
private bool _disposed;
private bool _wasConnected;
public event Action<string> FileChanged;
public string CurrentFile { get; private set; }
public RemoteLogSource(RemoteClient client, string service, Func<string> encoding, Action<IList<string>, 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);
}
}
}