487 lines
21 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|