612 lines
27 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|