JJServiceManagerEnt/Core/Remote/RemoteClient.cs

487 lines
21 KiB
C#

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 }
/// <summary>
/// 관리 역할: 대상 PC 하나에 접속을 유지하며 서비스 목록을 주기적으로 받아옴.
/// 끊기면 자동으로 다시 접속함.
/// </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 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 JsonLineConnection _conn;
private int _nextId;
private sealed class LogSubscriber
{
public Action<IList<string>, LogLevel> OnLines;
public Action<string> 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<RemoteServiceInfo> Services { get; private set; } = new List<RemoteServiceInfo>();
/// <summary>연결 상태가 바뀜 (백그라운드 스레드에서 발생)</summary>
public event Action<RemoteClient> StateChanged;
/// <summary>서비스 목록을 새로 받음 (백그라운드 스레드에서 발생)</summary>
public event Action<RemoteClient> ServicesChanged;
/// <summary>대상 PC의 hostname을 처음 알게 되었거나 바뀜</summary>
public event Action<RemoteClient, string> HostnameDiscovered;
private readonly Func<string> _password;
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();
}
// ───────────────────────── 접속 유지 ─────────────────────────
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<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();
}
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>접속 + 인증 + 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("접속 비밀번호가 저장되어 있지 않습니다.");
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;
}
}
/// <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,
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
};
}
}
/// <summary>인증 실패 (비밀번호 틀림 등)</summary>
public sealed class RemoteAuthException : Exception
{
public RemoteAuthException(string message) : base(message) { }
}
/// <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);
}
}
}